3145 lines
102 KiB
TypeScript
3145 lines
102 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { EventEmitter } from "node:events";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import {
|
|
AppSettings,
|
|
DownloadItem,
|
|
DownloadStats,
|
|
DownloadSummary,
|
|
DownloadStatus,
|
|
DuplicatePolicy,
|
|
PackageEntry,
|
|
ParsedPackageInput,
|
|
SessionState,
|
|
StartConflictEntry,
|
|
StartConflictResolutionResult,
|
|
UiSnapshot
|
|
} from "../shared/types";
|
|
import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS } from "./constants";
|
|
import { cleanupCancelledPackageArtifactsAsync } from "./cleanup";
|
|
import { DebridService, MegaWebUnrestrictor } from "./debrid";
|
|
import { collectArchiveCleanupTargets, extractPackageArchives } from "./extractor";
|
|
import { validateFileAgainstManifest } from "./integrity";
|
|
import { logger } from "./logger";
|
|
import { StoragePaths, saveSession, saveSessionAsync } from "./storage";
|
|
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils";
|
|
|
|
type ActiveTask = {
|
|
itemId: string;
|
|
packageId: string;
|
|
abortController: AbortController;
|
|
abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "none";
|
|
resumable: boolean;
|
|
speedEvents: Array<{ at: number; bytes: number }>;
|
|
nonResumableCounted: boolean;
|
|
};
|
|
|
|
const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 30000;
|
|
|
|
const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000;
|
|
|
|
const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 90000;
|
|
|
|
function getDownloadStallTimeoutMs(): number {
|
|
const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN);
|
|
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) {
|
|
return Math.floor(fromEnv);
|
|
}
|
|
return DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS;
|
|
}
|
|
|
|
function getDownloadConnectTimeoutMs(): number {
|
|
const fromEnv = Number(process.env.RD_CONNECT_TIMEOUT_MS ?? NaN);
|
|
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 180000) {
|
|
return Math.floor(fromEnv);
|
|
}
|
|
return DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS;
|
|
}
|
|
|
|
function getGlobalStallWatchdogTimeoutMs(): number {
|
|
const fromEnv = Number(process.env.RD_GLOBAL_STALL_TIMEOUT_MS ?? NaN);
|
|
if (Number.isFinite(fromEnv)) {
|
|
if (fromEnv <= 0) {
|
|
return 0;
|
|
}
|
|
if (fromEnv >= 2000 && fromEnv <= 600000) {
|
|
return Math.floor(fromEnv);
|
|
}
|
|
}
|
|
return DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS;
|
|
}
|
|
|
|
type DownloadManagerOptions = {
|
|
megaWebUnrestrict?: MegaWebUnrestrictor;
|
|
};
|
|
|
|
function cloneSession(session: SessionState): SessionState {
|
|
const clonedItems: Record<string, DownloadItem> = {};
|
|
for (const key of Object.keys(session.items)) {
|
|
clonedItems[key] = { ...session.items[key] };
|
|
}
|
|
const clonedPackages: Record<string, PackageEntry> = {};
|
|
for (const key of Object.keys(session.packages)) {
|
|
const pkg = session.packages[key];
|
|
clonedPackages[key] = { ...pkg, itemIds: [...pkg.itemIds] };
|
|
}
|
|
return {
|
|
...session,
|
|
packageOrder: [...session.packageOrder],
|
|
packages: clonedPackages,
|
|
items: clonedItems
|
|
};
|
|
}
|
|
|
|
function parseContentRangeTotal(contentRange: string | null): number | null {
|
|
if (!contentRange) {
|
|
return null;
|
|
}
|
|
const match = contentRange.match(/\/(\d+)$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const value = Number(match[1]);
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function parseContentDispositionFilename(contentDisposition: string | null): string {
|
|
if (!contentDisposition) {
|
|
return "";
|
|
}
|
|
|
|
const encodedMatch = contentDisposition.match(/filename\*\s*=\s*([^;]+)/i);
|
|
if (encodedMatch?.[1]) {
|
|
let value = encodedMatch[1].trim();
|
|
value = value.replace(/^UTF-8''/i, "");
|
|
value = value.replace(/^['"]+|['"]+$/g, "");
|
|
try {
|
|
const decoded = decodeURIComponent(value).trim();
|
|
if (decoded) {
|
|
return decoded;
|
|
}
|
|
} catch {
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
|
|
const plainMatch = contentDisposition.match(/filename\s*=\s*([^;]+)/i);
|
|
if (!plainMatch?.[1]) {
|
|
return "";
|
|
}
|
|
return plainMatch[1].trim().replace(/^['"]+|['"]+$/g, "");
|
|
}
|
|
|
|
function canRetryStatus(status: number): boolean {
|
|
return status === 429 || status >= 500;
|
|
}
|
|
|
|
function isArchiveLikePath(filePath: string): boolean {
|
|
const lower = path.basename(filePath).toLowerCase();
|
|
return /\.(?:part\d+\.rar|rar|r\d{2}|zip|z\d{2}|7z|7z\.\d{3})$/i.test(lower);
|
|
}
|
|
|
|
function isFetchFailure(errorText: string): boolean {
|
|
const text = String(errorText || "").toLowerCase();
|
|
return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error");
|
|
}
|
|
|
|
function isUnrestrictFailure(errorText: string): boolean {
|
|
const text = String(errorText || "").toLowerCase();
|
|
return text.includes("unrestrict") || text.includes("mega-web") || text.includes("mega-debrid")
|
|
|| text.includes("bestdebrid") || text.includes("alldebrid") || text.includes("kein debrid")
|
|
|| text.includes("session") || text.includes("login");
|
|
}
|
|
|
|
function isFinishedStatus(status: DownloadStatus): boolean {
|
|
return status === "completed" || status === "failed" || status === "cancelled";
|
|
}
|
|
|
|
function isExtractedLabel(statusText: string): boolean {
|
|
return /^entpackt\b/i.test(String(statusText || "").trim());
|
|
}
|
|
|
|
function providerLabel(provider: DownloadItem["provider"]): string {
|
|
if (provider === "realdebrid") {
|
|
return "Real-Debrid";
|
|
}
|
|
if (provider === "megadebrid") {
|
|
return "Mega-Debrid";
|
|
}
|
|
if (provider === "bestdebrid") {
|
|
return "BestDebrid";
|
|
}
|
|
if (provider === "alldebrid") {
|
|
return "AllDebrid";
|
|
}
|
|
return "Debrid";
|
|
}
|
|
|
|
function pathKey(filePath: string): string {
|
|
const resolved = path.resolve(filePath);
|
|
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
}
|
|
|
|
function isPathInsideDir(filePath: string, dirPath: string): boolean {
|
|
const file = pathKey(filePath);
|
|
const dir = pathKey(dirPath);
|
|
if (file === dir) {
|
|
return true;
|
|
}
|
|
const withSep = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`;
|
|
return file.startsWith(withSep);
|
|
}
|
|
|
|
const SCENE_RELEASE_FOLDER_RE = /-(?:4sf|4sj)$/i;
|
|
const SCENE_EPISODE_RE = /(?:^|[._\-\s])s(\d{1,2})e(\d{1,3})(?:[._\-\s]|$)/i;
|
|
const SCENE_SEASON_ONLY_RE = /(^|[._\-\s])s\d{1,2}(?=[._\-\s]|$)/i;
|
|
const SCENE_RP_TOKEN_RE = /(?:^|[._\-\s])rp(?:[._\-\s]|$)/i;
|
|
const SCENE_REPACK_TOKEN_RE = /(?:^|[._\-\s])repack(?:[._\-\s]|$)/i;
|
|
const SCENE_QUALITY_TOKEN_RE = /([._\-\s])((?:4320|2160|1440|1080|720|576|540|480|360)p)(?=[._\-\s]|$)/i;
|
|
|
|
function extractEpisodeToken(fileName: string): string | null {
|
|
const match = String(fileName || "").match(SCENE_EPISODE_RE);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const season = Number(match[1]);
|
|
const episode = Number(match[2]);
|
|
if (!Number.isFinite(season) || !Number.isFinite(episode) || season < 0 || episode < 0) {
|
|
return null;
|
|
}
|
|
|
|
return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`;
|
|
}
|
|
|
|
function applyEpisodeTokenToFolderName(folderName: string, episodeToken: string): string {
|
|
const trimmed = String(folderName || "").trim();
|
|
if (!trimmed) {
|
|
return episodeToken;
|
|
}
|
|
|
|
const withEpisode = trimmed.replace(
|
|
/(^|[._\-\s])s\d{1,2}e\d{1,3}(?=[._\-\s]|$)/i,
|
|
`$1${episodeToken}`
|
|
);
|
|
if (withEpisode !== trimmed) {
|
|
return withEpisode;
|
|
}
|
|
|
|
const withSeason = trimmed.replace(SCENE_SEASON_ONLY_RE, `$1${episodeToken}`);
|
|
if (withSeason !== trimmed) {
|
|
return withSeason;
|
|
}
|
|
|
|
const withSuffixInsert = trimmed.replace(/-(4sf|4sj)$/i, `.${episodeToken}-$1`);
|
|
if (withSuffixInsert !== trimmed) {
|
|
return withSuffixInsert;
|
|
}
|
|
|
|
return `${trimmed}.${episodeToken}`;
|
|
}
|
|
|
|
function sourceHasRpToken(fileName: string): boolean {
|
|
return SCENE_RP_TOKEN_RE.test(String(fileName || ""));
|
|
}
|
|
|
|
function ensureRepackToken(baseName: string): string {
|
|
if (SCENE_REPACK_TOKEN_RE.test(baseName)) {
|
|
return baseName;
|
|
}
|
|
|
|
const withQualityToken = baseName.replace(SCENE_QUALITY_TOKEN_RE, ".REPACK.$2");
|
|
if (withQualityToken !== baseName) {
|
|
return withQualityToken;
|
|
}
|
|
|
|
const withSuffixToken = baseName.replace(/-(4sf|4sj)$/i, ".REPACK-$1");
|
|
if (withSuffixToken !== baseName) {
|
|
return withSuffixToken;
|
|
}
|
|
|
|
return `${baseName}.REPACK`;
|
|
}
|
|
|
|
function buildAutoRenameBaseName(folderName: string, sourceFileName: string): string | null {
|
|
if (!SCENE_RELEASE_FOLDER_RE.test(folderName)) {
|
|
return null;
|
|
}
|
|
|
|
const episodeToken = extractEpisodeToken(sourceFileName);
|
|
if (!episodeToken) {
|
|
return null;
|
|
}
|
|
|
|
let next = applyEpisodeTokenToFolderName(folderName, episodeToken);
|
|
if (sourceHasRpToken(sourceFileName)) {
|
|
next = ensureRepackToken(next);
|
|
}
|
|
|
|
return sanitizeFilename(next);
|
|
}
|
|
|
|
export class DownloadManager extends EventEmitter {
|
|
private settings: AppSettings;
|
|
|
|
private session: SessionState;
|
|
|
|
private storagePaths: StoragePaths;
|
|
|
|
private debridService: DebridService;
|
|
|
|
private activeTasks = new Map<string, ActiveTask>();
|
|
|
|
private scheduleRunning = false;
|
|
|
|
private persistTimer: NodeJS.Timeout | null = null;
|
|
|
|
private speedEvents: Array<{ at: number; bytes: number }> = [];
|
|
|
|
private summary: DownloadSummary | null = null;
|
|
|
|
private nonResumableActive = 0;
|
|
|
|
private stateEmitTimer: NodeJS.Timeout | null = null;
|
|
|
|
private speedBytesLastWindow = 0;
|
|
|
|
private statsCache: DownloadStats | null = null;
|
|
|
|
private statsCacheAt = 0;
|
|
|
|
private lastPersistAt = 0;
|
|
|
|
private cleanupQueue: Promise<void> = Promise.resolve();
|
|
|
|
private packagePostProcessQueue: Promise<void> = Promise.resolve();
|
|
|
|
private packagePostProcessTasks = new Map<string, Promise<void>>();
|
|
|
|
private packagePostProcessAbortControllers = new Map<string, AbortController>();
|
|
|
|
private reservedTargetPaths = new Map<string, string>();
|
|
|
|
private claimedTargetPathByItem = new Map<string, string>();
|
|
|
|
private runItemIds = new Set<string>();
|
|
|
|
private runPackageIds = new Set<string>();
|
|
|
|
private runOutcomes = new Map<string, "completed" | "failed" | "cancelled">();
|
|
|
|
private runCompletedPackages = new Set<string>();
|
|
|
|
private lastSchedulerHeartbeatAt = 0;
|
|
|
|
private lastReconnectMarkAt = 0;
|
|
|
|
private lastGlobalProgressBytes = 0;
|
|
|
|
private lastGlobalProgressAt = 0;
|
|
|
|
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) {
|
|
super();
|
|
this.settings = settings;
|
|
this.session = cloneSession(session);
|
|
this.storagePaths = storagePaths;
|
|
this.debridService = new DebridService(settings, { megaWebUnrestrict: options.megaWebUnrestrict });
|
|
this.applyOnStartCleanupPolicy();
|
|
this.normalizeSessionStatuses();
|
|
this.recoverRetryableItems("startup");
|
|
this.recoverPostProcessingOnStartup();
|
|
this.resolveExistingQueuedOpaqueFilenames();
|
|
this.cleanupExistingExtractedArchives();
|
|
}
|
|
|
|
public setSettings(next: AppSettings): void {
|
|
this.settings = next;
|
|
this.debridService.setSettings(next);
|
|
this.resolveExistingQueuedOpaqueFilenames();
|
|
this.cleanupExistingExtractedArchives();
|
|
this.emitState();
|
|
}
|
|
|
|
public getSettings(): AppSettings {
|
|
return this.settings;
|
|
}
|
|
|
|
public getSession(): SessionState {
|
|
return cloneSession(this.session);
|
|
}
|
|
|
|
public getSummary(): DownloadSummary | null {
|
|
return this.summary;
|
|
}
|
|
|
|
public getSnapshot(): UiSnapshot {
|
|
const now = nowMs();
|
|
this.pruneSpeedEvents(now);
|
|
const paused = this.session.running && this.session.paused;
|
|
const speedBps = paused ? 0 : this.speedBytesLastWindow / 3;
|
|
|
|
let totalItems = 0;
|
|
let doneItems = 0;
|
|
if (this.session.running && this.runItemIds.size > 0) {
|
|
totalItems = this.runItemIds.size;
|
|
for (const itemId of this.runItemIds) {
|
|
if (this.runOutcomes.has(itemId)) {
|
|
doneItems += 1;
|
|
continue;
|
|
}
|
|
const item = this.session.items[itemId];
|
|
if (item && isFinishedStatus(item.status)) {
|
|
doneItems += 1;
|
|
}
|
|
}
|
|
} else {
|
|
const sessionItems = Object.values(this.session.items);
|
|
totalItems = sessionItems.length;
|
|
for (const item of sessionItems) {
|
|
if (isFinishedStatus(item.status)) {
|
|
doneItems += 1;
|
|
}
|
|
}
|
|
}
|
|
const elapsed = this.session.runStartedAt > 0 ? (now - this.session.runStartedAt) / 1000 : 0;
|
|
const rate = doneItems > 0 && elapsed > 0 ? doneItems / elapsed : 0;
|
|
const remaining = totalItems - doneItems;
|
|
const eta = remaining > 0 && rate > 0 ? remaining / rate : -1;
|
|
|
|
const reconnectMs = Math.max(0, this.session.reconnectUntil - now);
|
|
|
|
return {
|
|
settings: this.settings,
|
|
session: cloneSession(this.session),
|
|
summary: this.summary,
|
|
stats: this.getStats(now),
|
|
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
|
|
etaText: paused ? "ETA: --" : `ETA: ${formatEta(eta)}`,
|
|
canStart: !this.session.running,
|
|
canStop: this.session.running,
|
|
canPause: this.session.running,
|
|
clipboardActive: this.settings.clipboardWatch,
|
|
reconnectSeconds: Math.ceil(reconnectMs / 1000)
|
|
};
|
|
}
|
|
|
|
public getStats(now = nowMs()): DownloadStats {
|
|
const itemCount = Object.keys(this.session.items).length;
|
|
if (this.statsCache && this.session.running && itemCount >= 500 && now - this.statsCacheAt < 1500) {
|
|
return this.statsCache;
|
|
}
|
|
|
|
let totalDownloaded = 0;
|
|
let totalFiles = 0;
|
|
for (const item of Object.values(this.session.items)) {
|
|
if (item.status === "completed") {
|
|
totalDownloaded += item.downloadedBytes;
|
|
totalFiles += 1;
|
|
}
|
|
}
|
|
|
|
if (this.session.running) {
|
|
let visibleRunBytes = 0;
|
|
for (const itemId of this.runItemIds) {
|
|
const item = this.session.items[itemId];
|
|
if (item) {
|
|
visibleRunBytes += item.downloadedBytes;
|
|
}
|
|
}
|
|
totalDownloaded += Math.max(0, this.session.totalDownloadedBytes - visibleRunBytes);
|
|
} else {
|
|
totalDownloaded = Math.max(totalDownloaded, this.session.totalDownloadedBytes);
|
|
}
|
|
|
|
const stats = {
|
|
totalDownloaded,
|
|
totalFiles,
|
|
totalPackages: Object.keys(this.session.packages).length,
|
|
sessionStartedAt: this.session.runStartedAt
|
|
};
|
|
this.statsCache = stats;
|
|
this.statsCacheAt = now;
|
|
return stats;
|
|
}
|
|
|
|
public renamePackage(packageId: string, newName: string): void {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg) {
|
|
return;
|
|
}
|
|
pkg.name = sanitizeFilename(newName) || pkg.name;
|
|
pkg.updatedAt = nowMs();
|
|
this.persistSoon();
|
|
this.emitState(true);
|
|
}
|
|
|
|
public reorderPackages(packageIds: string[]): void {
|
|
const valid = packageIds.filter((id) => this.session.packages[id]);
|
|
const remaining = this.session.packageOrder.filter((id) => !valid.includes(id));
|
|
this.session.packageOrder = [...valid, ...remaining];
|
|
this.persistSoon();
|
|
this.emitState(true);
|
|
}
|
|
|
|
public removeItem(itemId: string): void {
|
|
const item = this.session.items[itemId];
|
|
if (!item) {
|
|
return;
|
|
}
|
|
this.recordRunOutcome(itemId, "cancelled");
|
|
const active = this.activeTasks.get(itemId);
|
|
if (active) {
|
|
active.abortReason = "cancel";
|
|
active.abortController.abort("cancel");
|
|
}
|
|
const pkg = this.session.packages[item.packageId];
|
|
if (pkg) {
|
|
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
|
if (pkg.itemIds.length === 0) {
|
|
this.removePackageFromSession(item.packageId, []);
|
|
} else {
|
|
pkg.updatedAt = nowMs();
|
|
}
|
|
}
|
|
delete this.session.items[itemId];
|
|
this.releaseTargetPath(itemId);
|
|
this.persistSoon();
|
|
this.emitState(true);
|
|
}
|
|
|
|
public togglePackage(packageId: string): void {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg) {
|
|
return;
|
|
}
|
|
|
|
const nextEnabled = !pkg.enabled;
|
|
pkg.enabled = nextEnabled;
|
|
|
|
if (!nextEnabled) {
|
|
if (pkg.status === "downloading") {
|
|
pkg.status = "paused";
|
|
}
|
|
for (const itemId of pkg.itemIds) {
|
|
const item = this.session.items[itemId];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (this.session.running && !isFinishedStatus(item.status) && !this.runOutcomes.has(itemId)) {
|
|
this.runItemIds.delete(itemId);
|
|
}
|
|
const active = this.activeTasks.get(itemId);
|
|
if (active) {
|
|
active.abortReason = "package_toggle";
|
|
active.abortController.abort("package_toggle");
|
|
continue;
|
|
}
|
|
if (item.status === "queued" || item.status === "reconnect_wait") {
|
|
item.status = "queued";
|
|
item.speedBps = 0;
|
|
item.fullStatus = "Paket gestoppt";
|
|
item.updatedAt = nowMs();
|
|
}
|
|
}
|
|
this.runPackageIds.delete(packageId);
|
|
this.runCompletedPackages.delete(packageId);
|
|
} else {
|
|
if (pkg.status === "paused") {
|
|
pkg.status = "queued";
|
|
}
|
|
for (const itemId of pkg.itemIds) {
|
|
const item = this.session.items[itemId];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (item.status === "queued" && item.fullStatus === "Paket gestoppt") {
|
|
item.fullStatus = "Wartet";
|
|
item.updatedAt = nowMs();
|
|
}
|
|
}
|
|
if (this.session.running) {
|
|
void this.ensureScheduler();
|
|
}
|
|
}
|
|
|
|
pkg.updatedAt = nowMs();
|
|
this.persistSoon();
|
|
this.emitState(true);
|
|
}
|
|
|
|
public exportQueue(): string {
|
|
const exportData = {
|
|
version: 1,
|
|
packages: this.session.packageOrder.map((id) => {
|
|
const pkg = this.session.packages[id];
|
|
if (!pkg) {
|
|
return null;
|
|
}
|
|
return {
|
|
name: pkg.name,
|
|
links: pkg.itemIds
|
|
.map((itemId) => this.session.items[itemId]?.url)
|
|
.filter(Boolean)
|
|
};
|
|
}).filter(Boolean)
|
|
};
|
|
return JSON.stringify(exportData, null, 2);
|
|
}
|
|
|
|
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
|
|
const data = JSON.parse(json) as { packages?: Array<{ name: string; links: string[] }> };
|
|
if (!Array.isArray(data.packages)) {
|
|
return { addedPackages: 0, addedLinks: 0 };
|
|
}
|
|
const inputs: ParsedPackageInput[] = data.packages
|
|
.filter((pkg) => pkg.name && Array.isArray(pkg.links) && pkg.links.length > 0)
|
|
.map((pkg) => ({ name: pkg.name, links: pkg.links }));
|
|
return this.addPackages(inputs);
|
|
}
|
|
|
|
public clearAll(): void {
|
|
this.stop();
|
|
this.abortPostProcessing("clear_all");
|
|
if (this.stateEmitTimer) {
|
|
clearTimeout(this.stateEmitTimer);
|
|
this.stateEmitTimer = null;
|
|
}
|
|
this.session.packageOrder = [];
|
|
this.session.packages = {};
|
|
this.session.items = {};
|
|
this.session.summaryText = "";
|
|
this.runItemIds.clear();
|
|
this.runPackageIds.clear();
|
|
this.runOutcomes.clear();
|
|
this.runCompletedPackages.clear();
|
|
this.reservedTargetPaths.clear();
|
|
this.claimedTargetPathByItem.clear();
|
|
this.packagePostProcessTasks.clear();
|
|
this.packagePostProcessAbortControllers.clear();
|
|
this.packagePostProcessQueue = Promise.resolve();
|
|
this.summary = null;
|
|
this.persistNow();
|
|
this.emitState(true);
|
|
}
|
|
|
|
public addPackages(packages: ParsedPackageInput[]): { addedPackages: number; addedLinks: number } {
|
|
let addedPackages = 0;
|
|
let addedLinks = 0;
|
|
const unresolvedByLink = new Map<string, string[]>();
|
|
for (const pkg of packages) {
|
|
const links = pkg.links.filter((link) => !!link.trim());
|
|
if (links.length === 0) {
|
|
continue;
|
|
}
|
|
const packageId = uuidv4();
|
|
const outputDir = ensureDirPath(this.settings.outputDir, pkg.name);
|
|
const extractBase = this.settings.extractDir || path.join(this.settings.outputDir, "_entpackt");
|
|
const extractDir = this.settings.createExtractSubfolder ? ensureDirPath(extractBase, pkg.name) : extractBase;
|
|
const packageEntry: PackageEntry = {
|
|
id: packageId,
|
|
name: sanitizeFilename(pkg.name),
|
|
outputDir,
|
|
extractDir,
|
|
status: "queued",
|
|
itemIds: [],
|
|
cancelled: false,
|
|
enabled: true,
|
|
createdAt: nowMs(),
|
|
updatedAt: nowMs()
|
|
};
|
|
|
|
for (const link of links) {
|
|
const itemId = uuidv4();
|
|
const fileName = filenameFromUrl(link);
|
|
const item: DownloadItem = {
|
|
id: itemId,
|
|
packageId,
|
|
url: link,
|
|
provider: null,
|
|
status: "queued",
|
|
retries: 0,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName,
|
|
targetPath: path.join(outputDir, fileName),
|
|
resumable: true,
|
|
attempts: 0,
|
|
lastError: "",
|
|
fullStatus: "Wartet",
|
|
createdAt: nowMs(),
|
|
updatedAt: nowMs()
|
|
};
|
|
packageEntry.itemIds.push(itemId);
|
|
this.session.items[itemId] = item;
|
|
if (this.session.running) {
|
|
this.runItemIds.add(itemId);
|
|
this.runPackageIds.add(packageId);
|
|
}
|
|
if (looksLikeOpaqueFilename(fileName)) {
|
|
const existing = unresolvedByLink.get(link) ?? [];
|
|
existing.push(itemId);
|
|
unresolvedByLink.set(link, existing);
|
|
}
|
|
addedLinks += 1;
|
|
}
|
|
|
|
this.session.packages[packageId] = packageEntry;
|
|
this.session.packageOrder.push(packageId);
|
|
addedPackages += 1;
|
|
}
|
|
|
|
this.persistSoon();
|
|
this.emitState();
|
|
if (unresolvedByLink.size > 0) {
|
|
void this.resolveQueuedFilenames(unresolvedByLink);
|
|
}
|
|
return { addedPackages, addedLinks };
|
|
}
|
|
|
|
public getStartConflicts(): StartConflictEntry[] {
|
|
const hasFilesByExtractDir = new Map<string, boolean>();
|
|
const conflicts: StartConflictEntry[] = [];
|
|
for (const packageId of this.session.packageOrder) {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
|
continue;
|
|
}
|
|
|
|
const hasPendingItems = pkg.itemIds.some((itemId) => {
|
|
const item = this.session.items[itemId];
|
|
if (!item) {
|
|
return false;
|
|
}
|
|
return item.status === "queued" || item.status === "reconnect_wait";
|
|
});
|
|
if (!hasPendingItems) {
|
|
continue;
|
|
}
|
|
|
|
if (!this.isPackageSpecificExtractDir(pkg)) {
|
|
continue;
|
|
}
|
|
|
|
const extractDirKey = pathKey(pkg.extractDir);
|
|
const hasExtractedFiles = hasFilesByExtractDir.has(extractDirKey)
|
|
? Boolean(hasFilesByExtractDir.get(extractDirKey))
|
|
: this.directoryHasAnyFiles(pkg.extractDir);
|
|
if (!hasFilesByExtractDir.has(extractDirKey)) {
|
|
hasFilesByExtractDir.set(extractDirKey, hasExtractedFiles);
|
|
}
|
|
|
|
if (hasExtractedFiles) {
|
|
conflicts.push({
|
|
packageId: pkg.id,
|
|
packageName: pkg.name,
|
|
extractDir: pkg.extractDir
|
|
});
|
|
}
|
|
}
|
|
return conflicts;
|
|
}
|
|
|
|
public async resolveStartConflict(packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg || pkg.cancelled) {
|
|
return { skipped: false, overwritten: false };
|
|
}
|
|
|
|
if (policy === "skip") {
|
|
for (const itemId of pkg.itemIds) {
|
|
const active = this.activeTasks.get(itemId);
|
|
if (active) {
|
|
active.abortReason = "cancel";
|
|
active.abortController.abort("cancel");
|
|
}
|
|
this.releaseTargetPath(itemId);
|
|
delete this.session.items[itemId];
|
|
}
|
|
delete this.session.packages[packageId];
|
|
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
|
|
this.persistSoon();
|
|
this.emitState(true);
|
|
return { skipped: true, overwritten: false };
|
|
}
|
|
|
|
if (policy === "overwrite") {
|
|
const canDeleteExtractDir = this.isPackageSpecificExtractDir(pkg) && !this.isExtractDirSharedWithOtherPackages(pkg.id, pkg.extractDir);
|
|
if (canDeleteExtractDir) {
|
|
try {
|
|
await fs.promises.rm(pkg.extractDir, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
try {
|
|
await fs.promises.rm(pkg.outputDir, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
for (const itemId of pkg.itemIds) {
|
|
const item = this.session.items[itemId];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
const active = this.activeTasks.get(itemId);
|
|
if (active) {
|
|
active.abortReason = "cancel";
|
|
active.abortController.abort("cancel");
|
|
}
|
|
this.releaseTargetPath(itemId);
|
|
item.status = "queued";
|
|
item.retries = 0;
|
|
item.speedBps = 0;
|
|
item.downloadedBytes = 0;
|
|
item.totalBytes = null;
|
|
item.progressPercent = 0;
|
|
item.resumable = true;
|
|
item.attempts = 0;
|
|
item.lastError = "";
|
|
item.fullStatus = "Wartet";
|
|
item.updatedAt = nowMs();
|
|
item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url)));
|
|
}
|
|
pkg.status = "queued";
|
|
pkg.updatedAt = nowMs();
|
|
this.persistSoon();
|
|
this.emitState(true);
|
|
return { skipped: false, overwritten: true };
|
|
}
|
|
|
|
return { skipped: false, overwritten: false };
|
|
}
|
|
|
|
private isPackageSpecificExtractDir(pkg: PackageEntry): boolean {
|
|
const expectedName = sanitizeFilename(pkg.name).toLowerCase();
|
|
if (!expectedName) {
|
|
return false;
|
|
}
|
|
return path.basename(pkg.extractDir).toLowerCase() === expectedName;
|
|
}
|
|
|
|
private isExtractDirSharedWithOtherPackages(packageId: string, extractDir: string): boolean {
|
|
const key = pathKey(extractDir);
|
|
for (const otherId of this.session.packageOrder) {
|
|
if (otherId === packageId) {
|
|
continue;
|
|
}
|
|
const other = this.session.packages[otherId];
|
|
if (!other || other.cancelled) {
|
|
continue;
|
|
}
|
|
if (pathKey(other.extractDir) === key) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private async resolveQueuedFilenames(unresolvedByLink: Map<string, string[]>): Promise<void> {
|
|
try {
|
|
let changed = false;
|
|
const applyResolvedName = (link: string, fileName: string): void => {
|
|
const itemIds = unresolvedByLink.get(link);
|
|
if (!itemIds || itemIds.length === 0) {
|
|
return;
|
|
}
|
|
if (!fileName || fileName.toLowerCase() === "download.bin") {
|
|
return;
|
|
}
|
|
const normalized = sanitizeFilename(fileName);
|
|
if (!normalized || normalized.toLowerCase() === "download.bin") {
|
|
return;
|
|
}
|
|
|
|
let changedForLink = false;
|
|
for (const itemId of itemIds) {
|
|
const item = this.session.items[itemId];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (!looksLikeOpaqueFilename(item.fileName)) {
|
|
continue;
|
|
}
|
|
if (item.status !== "queued" && item.status !== "reconnect_wait") {
|
|
continue;
|
|
}
|
|
item.fileName = normalized;
|
|
item.targetPath = path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, normalized);
|
|
item.updatedAt = nowMs();
|
|
changed = true;
|
|
changedForLink = true;
|
|
}
|
|
|
|
if (changedForLink) {
|
|
this.persistSoon();
|
|
this.emitState();
|
|
}
|
|
};
|
|
|
|
await this.debridService.resolveFilenames(Array.from(unresolvedByLink.keys()), applyResolvedName);
|
|
|
|
if (changed) {
|
|
this.persistSoon();
|
|
this.emitState();
|
|
}
|
|
} catch (error) {
|
|
logger.warn(`Dateinamen-Resolve fehlgeschlagen: ${compactErrorText(error)}`);
|
|
}
|
|
}
|
|
|
|
private resolveExistingQueuedOpaqueFilenames(): void {
|
|
const unresolvedByLink = new Map<string, string[]>();
|
|
for (const item of Object.values(this.session.items)) {
|
|
if (!looksLikeOpaqueFilename(item.fileName)) {
|
|
continue;
|
|
}
|
|
if (item.status !== "queued" && item.status !== "reconnect_wait") {
|
|
continue;
|
|
}
|
|
const pkg = this.session.packages[item.packageId];
|
|
if (!pkg || pkg.cancelled) {
|
|
continue;
|
|
}
|
|
const existing = unresolvedByLink.get(item.url) ?? [];
|
|
existing.push(item.id);
|
|
unresolvedByLink.set(item.url, existing);
|
|
}
|
|
|
|
if (unresolvedByLink.size > 0) {
|
|
void this.resolveQueuedFilenames(unresolvedByLink);
|
|
}
|
|
}
|
|
|
|
private cleanupExistingExtractedArchives(): void {
|
|
if (this.settings.cleanupMode === "none") {
|
|
return;
|
|
}
|
|
|
|
const extractDirUsage = new Map<string, number>();
|
|
for (const packageId of this.session.packageOrder) {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg || pkg.cancelled || !pkg.extractDir) {
|
|
continue;
|
|
}
|
|
const key = pathKey(pkg.extractDir);
|
|
extractDirUsage.set(key, (extractDirUsage.get(key) || 0) + 1);
|
|
}
|
|
|
|
const cleanupTargetsByPackage = new Map<string, Set<string>>();
|
|
const dirFilesCache = new Map<string, string[]>();
|
|
for (const packageId of this.session.packageOrder) {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg || pkg.cancelled || pkg.status !== "completed") {
|
|
continue;
|
|
}
|
|
if (this.packagePostProcessTasks.has(packageId)) {
|
|
continue;
|
|
}
|
|
|
|
const items = pkg.itemIds
|
|
.map((itemId) => this.session.items[itemId])
|
|
.filter(Boolean) as DownloadItem[];
|
|
if (items.length === 0 || !items.every((item) => item.status === "completed")) {
|
|
continue;
|
|
}
|
|
|
|
const hasExtractMarker = items.some((item) => isExtractedLabel(item.fullStatus));
|
|
const extractDirIsUnique = (extractDirUsage.get(pathKey(pkg.extractDir)) || 0) === 1;
|
|
const hasExtractedOutput = extractDirIsUnique && this.directoryHasAnyFiles(pkg.extractDir);
|
|
if (!hasExtractMarker && !hasExtractedOutput) {
|
|
continue;
|
|
}
|
|
|
|
const packageTargets = cleanupTargetsByPackage.get(packageId) ?? new Set<string>();
|
|
for (const item of items) {
|
|
const rawTargetPath = String(item.targetPath || "").trim();
|
|
const fallbackTargetPath = item.fileName ? path.join(pkg.outputDir, sanitizeFilename(item.fileName)) : "";
|
|
const targetPath = rawTargetPath || fallbackTargetPath;
|
|
if (!targetPath || !isArchiveLikePath(targetPath)) {
|
|
continue;
|
|
}
|
|
const dir = path.dirname(targetPath);
|
|
let filesInDir = dirFilesCache.get(dir);
|
|
if (!filesInDir) {
|
|
try {
|
|
filesInDir = fs.readdirSync(dir, { withFileTypes: true })
|
|
.filter((entry) => entry.isFile())
|
|
.map((entry) => entry.name);
|
|
} catch {
|
|
filesInDir = [];
|
|
}
|
|
dirFilesCache.set(dir, filesInDir);
|
|
}
|
|
|
|
for (const cleanupTarget of collectArchiveCleanupTargets(targetPath, filesInDir)) {
|
|
packageTargets.add(cleanupTarget);
|
|
}
|
|
}
|
|
if (packageTargets.size > 0) {
|
|
cleanupTargetsByPackage.set(packageId, packageTargets);
|
|
}
|
|
}
|
|
|
|
if (cleanupTargetsByPackage.size === 0) {
|
|
return;
|
|
}
|
|
|
|
this.cleanupQueue = this.cleanupQueue
|
|
.then(async () => {
|
|
for (const [packageId, targets] of cleanupTargetsByPackage.entries()) {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg) {
|
|
continue;
|
|
}
|
|
|
|
logger.info(`Nachträgliches Cleanup geprüft: pkg=${pkg.name}, targets=${targets.size}, marker=${pkg.itemIds.some((id) => isExtractedLabel(this.session.items[id]?.fullStatus || ""))}`);
|
|
|
|
let removed = 0;
|
|
for (const targetPath of targets) {
|
|
if (!fs.existsSync(targetPath)) {
|
|
continue;
|
|
}
|
|
try {
|
|
await fs.promises.rm(targetPath, { force: true });
|
|
removed += 1;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (removed > 0) {
|
|
logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: ${removed} Datei(en) gelöscht`);
|
|
if (!this.directoryHasAnyFiles(pkg.outputDir)) {
|
|
const removedDirs = this.removeEmptyDirectoryTree(pkg.outputDir);
|
|
if (removedDirs > 0) {
|
|
logger.info(`Nachträgliches Cleanup entfernte leere Download-Ordner für ${pkg.name}: ${removedDirs}`);
|
|
}
|
|
}
|
|
} else {
|
|
logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: keine Dateien entfernt`);
|
|
}
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
logger.warn(`Nachträgliches Archive-Cleanup fehlgeschlagen: ${compactErrorText(error)}`);
|
|
});
|
|
}
|
|
|
|
private directoryHasAnyFiles(rootDir: string): boolean {
|
|
if (!rootDir || !fs.existsSync(rootDir)) {
|
|
return false;
|
|
}
|
|
const deadline = nowMs() + 55;
|
|
let inspectedDirs = 0;
|
|
const stack = [rootDir];
|
|
while (stack.length > 0) {
|
|
inspectedDirs += 1;
|
|
if (inspectedDirs > 6000 || nowMs() > deadline) {
|
|
return true;
|
|
}
|
|
const current = stack.pop() as string;
|
|
let entries: fs.Dirent[] = [];
|
|
try {
|
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (entry.isFile()) {
|
|
return true;
|
|
}
|
|
if (entry.isDirectory()) {
|
|
stack.push(path.join(current, entry.name));
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private removeEmptyDirectoryTree(rootDir: string): number {
|
|
if (!rootDir || !fs.existsSync(rootDir)) {
|
|
return 0;
|
|
}
|
|
|
|
const dirs = [rootDir];
|
|
const stack = [rootDir];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop() as string;
|
|
let entries: fs.Dirent[] = [];
|
|
try {
|
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
const full = path.join(current, entry.name);
|
|
dirs.push(full);
|
|
stack.push(full);
|
|
}
|
|
}
|
|
}
|
|
|
|
dirs.sort((a, b) => b.length - a.length);
|
|
let removed = 0;
|
|
for (const dirPath of dirs) {
|
|
try {
|
|
const entries = fs.readdirSync(dirPath);
|
|
if (entries.length === 0) {
|
|
fs.rmdirSync(dirPath);
|
|
removed += 1;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
private collectVideoFiles(rootDir: string): string[] {
|
|
if (!rootDir || !fs.existsSync(rootDir)) {
|
|
return [];
|
|
}
|
|
|
|
const files: string[] = [];
|
|
const stack = [rootDir];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop() as string;
|
|
let entries: fs.Dirent[] = [];
|
|
try {
|
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(current, entry.name);
|
|
if (entry.isDirectory()) {
|
|
stack.push(fullPath);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
const extension = path.extname(entry.name).toLowerCase();
|
|
if (!SAMPLE_VIDEO_EXTENSIONS.has(extension)) {
|
|
continue;
|
|
}
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
private autoRenameExtractedVideoFiles(extractDir: string): number {
|
|
if (!this.settings.autoRename4sf4sj) {
|
|
return 0;
|
|
}
|
|
|
|
const videoFiles = this.collectVideoFiles(extractDir);
|
|
let renamed = 0;
|
|
|
|
for (const sourcePath of videoFiles) {
|
|
const sourceName = path.basename(sourcePath);
|
|
const sourceExt = path.extname(sourceName);
|
|
const sourceBaseName = path.basename(sourceName, sourceExt);
|
|
const folderName = path.basename(path.dirname(sourcePath));
|
|
const targetBaseName = buildAutoRenameBaseName(folderName, sourceBaseName);
|
|
if (!targetBaseName) {
|
|
continue;
|
|
}
|
|
|
|
const targetPath = path.join(path.dirname(sourcePath), `${targetBaseName}${sourceExt}`);
|
|
if (pathKey(targetPath) === pathKey(sourcePath)) {
|
|
continue;
|
|
}
|
|
if (fs.existsSync(targetPath)) {
|
|
logger.warn(`Auto-Rename übersprungen (Ziel existiert): ${targetPath}`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
fs.renameSync(sourcePath, targetPath);
|
|
renamed += 1;
|
|
} catch (error) {
|
|
logger.warn(`Auto-Rename fehlgeschlagen (${sourceName}): ${compactErrorText(error)}`);
|
|
}
|
|
}
|
|
|
|
if (renamed > 0) {
|
|
logger.info(`Auto-Rename (4SF/4SJ): ${renamed} Datei(en) umbenannt`);
|
|
}
|
|
return renamed;
|
|
}
|
|
|
|
public cancelPackage(packageId: string): void {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg) {
|
|
return;
|
|
}
|
|
const packageName = pkg.name;
|
|
const outputDir = pkg.outputDir;
|
|
const itemIds = [...pkg.itemIds];
|
|
|
|
for (const itemId of itemIds) {
|
|
const item = this.session.items[itemId];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
this.recordRunOutcome(itemId, "cancelled");
|
|
const active = this.activeTasks.get(itemId);
|
|
if (active) {
|
|
active.abortReason = "cancel";
|
|
active.abortController.abort("cancel");
|
|
}
|
|
}
|
|
|
|
this.removePackageFromSession(packageId, itemIds);
|
|
this.persistSoon();
|
|
this.emitState(true);
|
|
|
|
this.cleanupQueue = this.cleanupQueue
|
|
.then(async () => {
|
|
const removed = await cleanupCancelledPackageArtifactsAsync(outputDir);
|
|
logger.info(`Paket ${packageName} abgebrochen, ${removed} Artefakte gelöscht`);
|
|
})
|
|
.catch((error) => {
|
|
logger.warn(`Cleanup für Paket ${packageName} fehlgeschlagen: ${compactErrorText(error)}`);
|
|
});
|
|
}
|
|
|
|
public start(): void {
|
|
if (this.session.running) {
|
|
return;
|
|
}
|
|
|
|
const recoveredItems = this.recoverRetryableItems("start");
|
|
if (recoveredItems > 0) {
|
|
this.persistSoon();
|
|
this.emitState(true);
|
|
}
|
|
|
|
this.triggerPendingExtractions();
|
|
|
|
const runItems = Object.values(this.session.items)
|
|
.filter((item) => {
|
|
if (item.status !== "queued" && item.status !== "reconnect_wait") {
|
|
return false;
|
|
}
|
|
const pkg = this.session.packages[item.packageId];
|
|
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
|
|
});
|
|
if (runItems.length === 0) {
|
|
this.runItemIds.clear();
|
|
this.runPackageIds.clear();
|
|
this.runOutcomes.clear();
|
|
this.runCompletedPackages.clear();
|
|
this.reservedTargetPaths.clear();
|
|
this.claimedTargetPathByItem.clear();
|
|
this.session.running = false;
|
|
this.session.paused = false;
|
|
this.session.runStartedAt = 0;
|
|
this.session.totalDownloadedBytes = 0;
|
|
this.session.summaryText = "";
|
|
this.session.reconnectUntil = 0;
|
|
this.session.reconnectReason = "";
|
|
this.speedEvents = [];
|
|
this.speedBytesLastWindow = 0;
|
|
this.lastGlobalProgressBytes = 0;
|
|
this.lastGlobalProgressAt = nowMs();
|
|
this.summary = null;
|
|
this.persistSoon();
|
|
this.emitState(true);
|
|
return;
|
|
}
|
|
this.runItemIds = new Set(runItems.map((item) => item.id));
|
|
this.runPackageIds = new Set(runItems.map((item) => item.packageId));
|
|
this.runOutcomes.clear();
|
|
this.runCompletedPackages.clear();
|
|
|
|
this.session.running = true;
|
|
this.session.paused = false;
|
|
this.session.runStartedAt = nowMs();
|
|
this.session.totalDownloadedBytes = 0;
|
|
this.session.summaryText = "";
|
|
this.session.reconnectUntil = 0;
|
|
this.session.reconnectReason = "";
|
|
this.lastReconnectMarkAt = 0;
|
|
this.speedEvents = [];
|
|
this.speedBytesLastWindow = 0;
|
|
this.lastGlobalProgressBytes = 0;
|
|
this.lastGlobalProgressAt = nowMs();
|
|
this.summary = null;
|
|
this.persistSoon();
|
|
this.emitState(true);
|
|
this.ensureScheduler();
|
|
}
|
|
|
|
public stop(): void {
|
|
this.session.running = false;
|
|
this.session.paused = false;
|
|
this.session.reconnectUntil = 0;
|
|
this.session.reconnectReason = "";
|
|
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
|
this.lastGlobalProgressAt = nowMs();
|
|
this.abortPostProcessing("stop");
|
|
for (const active of this.activeTasks.values()) {
|
|
active.abortReason = "stop";
|
|
active.abortController.abort("stop");
|
|
}
|
|
this.persistSoon();
|
|
this.emitState(true);
|
|
}
|
|
|
|
public prepareForShutdown(): void {
|
|
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
|
|
this.session.running = false;
|
|
this.session.paused = false;
|
|
this.session.reconnectUntil = 0;
|
|
this.session.reconnectReason = "";
|
|
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
|
this.lastGlobalProgressAt = nowMs();
|
|
this.abortPostProcessing("shutdown");
|
|
|
|
let requeuedItems = 0;
|
|
for (const active of this.activeTasks.values()) {
|
|
const item = this.session.items[active.itemId];
|
|
if (item && !isFinishedStatus(item.status)) {
|
|
item.status = "queued";
|
|
item.speedBps = 0;
|
|
const pkg = this.session.packages[item.packageId];
|
|
item.fullStatus = pkg && !pkg.enabled ? "Paket gestoppt" : "Wartet";
|
|
item.updatedAt = nowMs();
|
|
requeuedItems += 1;
|
|
}
|
|
active.abortReason = "shutdown";
|
|
active.abortController.abort("shutdown");
|
|
}
|
|
|
|
for (const pkg of Object.values(this.session.packages)) {
|
|
if (pkg.status === "downloading"
|
|
|| pkg.status === "validating"
|
|
|| pkg.status === "extracting"
|
|
|| pkg.status === "integrity_check"
|
|
|| pkg.status === "paused"
|
|
|| pkg.status === "reconnect_wait") {
|
|
pkg.status = pkg.enabled ? "queued" : "paused";
|
|
pkg.updatedAt = nowMs();
|
|
}
|
|
}
|
|
|
|
for (const item of Object.values(this.session.items)) {
|
|
if (item.status === "completed" && /^Entpacken/i.test(item.fullStatus || "")) {
|
|
item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
|
item.updatedAt = nowMs();
|
|
const pkg = this.session.packages[item.packageId];
|
|
if (pkg) {
|
|
pkg.status = pkg.enabled ? "queued" : "paused";
|
|
pkg.updatedAt = nowMs();
|
|
}
|
|
}
|
|
}
|
|
|
|
this.speedEvents = [];
|
|
this.speedBytesLastWindow = 0;
|
|
this.runItemIds.clear();
|
|
this.runPackageIds.clear();
|
|
this.runOutcomes.clear();
|
|
this.runCompletedPackages.clear();
|
|
this.session.summaryText = "";
|
|
this.persistNow();
|
|
this.emitState(true);
|
|
logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`);
|
|
}
|
|
|
|
public togglePause(): boolean {
|
|
if (!this.session.running) {
|
|
return false;
|
|
}
|
|
this.session.paused = !this.session.paused;
|
|
this.persistSoon();
|
|
this.emitState(true);
|
|
return this.session.paused;
|
|
}
|
|
|
|
private normalizeSessionStatuses(): void {
|
|
this.session.running = false;
|
|
this.session.paused = false;
|
|
this.session.reconnectUntil = 0;
|
|
this.session.reconnectReason = "";
|
|
|
|
for (const item of Object.values(this.session.items)) {
|
|
if (item.provider !== "realdebrid" && item.provider !== "megadebrid" && item.provider !== "bestdebrid" && item.provider !== "alldebrid") {
|
|
item.provider = null;
|
|
}
|
|
if (item.status === "cancelled" && item.fullStatus === "Gestoppt") {
|
|
item.status = "queued";
|
|
item.fullStatus = "Wartet";
|
|
item.lastError = "";
|
|
item.speedBps = 0;
|
|
continue;
|
|
}
|
|
if (item.status === "downloading"
|
|
|| item.status === "validating"
|
|
|| item.status === "extracting"
|
|
|| item.status === "integrity_check"
|
|
|| item.status === "paused"
|
|
|| item.status === "reconnect_wait") {
|
|
item.status = "queued";
|
|
item.speedBps = 0;
|
|
}
|
|
}
|
|
for (const pkg of Object.values(this.session.packages)) {
|
|
if (pkg.enabled === undefined) {
|
|
pkg.enabled = true;
|
|
}
|
|
if (pkg.status === "downloading"
|
|
|| pkg.status === "validating"
|
|
|| pkg.status === "extracting"
|
|
|| pkg.status === "integrity_check"
|
|
|| pkg.status === "paused"
|
|
|| pkg.status === "reconnect_wait") {
|
|
pkg.status = "queued";
|
|
}
|
|
|
|
const items = pkg.itemIds
|
|
.map((itemId) => this.session.items[itemId])
|
|
.filter(Boolean) as DownloadItem[];
|
|
if (items.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const hasPending = items.some((item) => (
|
|
item.status === "queued"
|
|
|| item.status === "reconnect_wait"
|
|
|| item.status === "validating"
|
|
|| item.status === "downloading"
|
|
|| item.status === "paused"
|
|
|| item.status === "extracting"
|
|
|| item.status === "integrity_check"
|
|
));
|
|
if (hasPending) {
|
|
pkg.status = pkg.enabled ? "queued" : "paused";
|
|
continue;
|
|
}
|
|
|
|
const success = items.filter((item) => item.status === "completed").length;
|
|
const failed = items.filter((item) => item.status === "failed").length;
|
|
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
|
|
|
if (failed > 0) {
|
|
pkg.status = "failed";
|
|
} else if (cancelled > 0 && success === 0) {
|
|
pkg.status = "cancelled";
|
|
} else if (success > 0) {
|
|
pkg.status = "completed";
|
|
}
|
|
}
|
|
this.persistSoon();
|
|
}
|
|
|
|
private applyOnStartCleanupPolicy(): void {
|
|
if (this.settings.completedCleanupPolicy !== "on_start") {
|
|
return;
|
|
}
|
|
for (const pkgId of [...this.session.packageOrder]) {
|
|
const pkg = this.session.packages[pkgId];
|
|
if (!pkg) {
|
|
continue;
|
|
}
|
|
pkg.itemIds = pkg.itemIds.filter((itemId) => {
|
|
const item = this.session.items[itemId];
|
|
if (!item) {
|
|
return false;
|
|
}
|
|
if (item.status === "completed") {
|
|
delete this.session.items[itemId];
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
if (pkg.itemIds.length === 0) {
|
|
delete this.session.packages[pkgId];
|
|
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== pkgId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private persistSoon(): void {
|
|
if (this.persistTimer) {
|
|
return;
|
|
}
|
|
|
|
const itemCount = Object.keys(this.session.items).length;
|
|
const minGapMs = this.session.running
|
|
? itemCount >= 1500
|
|
? 3000
|
|
: itemCount >= 700
|
|
? 2200
|
|
: itemCount >= 250
|
|
? 1500
|
|
: 700
|
|
: 300;
|
|
const sinceLastPersist = nowMs() - this.lastPersistAt;
|
|
const delay = Math.max(120, minGapMs - sinceLastPersist);
|
|
|
|
this.persistTimer = setTimeout(() => {
|
|
this.persistTimer = null;
|
|
this.persistNow();
|
|
}, delay);
|
|
}
|
|
|
|
private persistNow(): void {
|
|
this.lastPersistAt = nowMs();
|
|
if (this.session.running) {
|
|
void saveSessionAsync(this.storagePaths, this.session);
|
|
} else {
|
|
saveSession(this.storagePaths, this.session);
|
|
}
|
|
}
|
|
|
|
private emitState(force = false): void {
|
|
if (force) {
|
|
if (this.stateEmitTimer) {
|
|
clearTimeout(this.stateEmitTimer);
|
|
this.stateEmitTimer = null;
|
|
}
|
|
this.emit("state", this.getSnapshot());
|
|
return;
|
|
}
|
|
if (this.stateEmitTimer) {
|
|
return;
|
|
}
|
|
const itemCount = Object.keys(this.session.items).length;
|
|
const emitDelay = this.session.running
|
|
? itemCount >= 1500
|
|
? 1200
|
|
: itemCount >= 700
|
|
? 900
|
|
: itemCount >= 250
|
|
? 560
|
|
: 320
|
|
: 260;
|
|
this.stateEmitTimer = setTimeout(() => {
|
|
this.stateEmitTimer = null;
|
|
this.emit("state", this.getSnapshot());
|
|
}, emitDelay);
|
|
}
|
|
|
|
private speedEventsHead = 0;
|
|
|
|
private pruneSpeedEvents(now: number): void {
|
|
const cutoff = now - 3000;
|
|
while (this.speedEventsHead < this.speedEvents.length && this.speedEvents[this.speedEventsHead].at < cutoff) {
|
|
this.speedBytesLastWindow = Math.max(0, this.speedBytesLastWindow - this.speedEvents[this.speedEventsHead].bytes);
|
|
this.speedEventsHead += 1;
|
|
}
|
|
if (this.speedEventsHead > 50) {
|
|
this.speedEvents = this.speedEvents.slice(this.speedEventsHead);
|
|
this.speedEventsHead = 0;
|
|
}
|
|
}
|
|
|
|
private lastSpeedPruneAt = 0;
|
|
|
|
private recordSpeed(bytes: number): void {
|
|
const now = nowMs();
|
|
const bucket = now - (now % 120);
|
|
const last = this.speedEvents[this.speedEvents.length - 1];
|
|
if (last && last.at === bucket) {
|
|
last.bytes += bytes;
|
|
} else {
|
|
this.speedEvents.push({ at: bucket, bytes });
|
|
}
|
|
this.speedBytesLastWindow += bytes;
|
|
if (now - this.lastSpeedPruneAt >= 1500) {
|
|
this.pruneSpeedEvents(now);
|
|
this.lastSpeedPruneAt = now;
|
|
}
|
|
}
|
|
|
|
private recordRunOutcome(itemId: string, status: "completed" | "failed" | "cancelled"): void {
|
|
if (!this.runItemIds.has(itemId)) {
|
|
return;
|
|
}
|
|
this.runOutcomes.set(itemId, status);
|
|
}
|
|
|
|
private claimTargetPath(itemId: string, preferredPath: string, allowExistingFile = false): string {
|
|
const existingClaim = this.claimedTargetPathByItem.get(itemId);
|
|
if (existingClaim) {
|
|
const owner = this.reservedTargetPaths.get(pathKey(existingClaim));
|
|
if (owner === itemId) {
|
|
return existingClaim;
|
|
}
|
|
this.claimedTargetPathByItem.delete(itemId);
|
|
}
|
|
|
|
const parsed = path.parse(preferredPath);
|
|
const maxIndex = 10000;
|
|
for (let index = 0; index <= maxIndex; index += 1) {
|
|
const candidate = index === 0
|
|
? preferredPath
|
|
: path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`);
|
|
const key = pathKey(candidate);
|
|
const owner = this.reservedTargetPaths.get(key);
|
|
const existsOnDisk = fs.existsSync(candidate);
|
|
const allowExistingCandidate = allowExistingFile && index === 0;
|
|
if ((!owner || owner === itemId) && (owner === itemId || !existsOnDisk || allowExistingCandidate)) {
|
|
this.reservedTargetPaths.set(key, itemId);
|
|
this.claimedTargetPathByItem.set(itemId, candidate);
|
|
return candidate;
|
|
}
|
|
}
|
|
logger.error(`claimTargetPath: Limit erreicht für ${preferredPath}`);
|
|
this.reservedTargetPaths.set(pathKey(preferredPath), itemId);
|
|
this.claimedTargetPathByItem.set(itemId, preferredPath);
|
|
return preferredPath;
|
|
}
|
|
|
|
private releaseTargetPath(itemId: string): void {
|
|
const claimedPath = this.claimedTargetPathByItem.get(itemId);
|
|
if (!claimedPath) {
|
|
return;
|
|
}
|
|
const key = pathKey(claimedPath);
|
|
const owner = this.reservedTargetPaths.get(key);
|
|
if (owner === itemId) {
|
|
this.reservedTargetPaths.delete(key);
|
|
}
|
|
this.claimedTargetPathByItem.delete(itemId);
|
|
}
|
|
|
|
private abortPostProcessing(reason: string): void {
|
|
for (const [packageId, controller] of this.packagePostProcessAbortControllers.entries()) {
|
|
if (!controller.signal.aborted) {
|
|
controller.abort(reason);
|
|
}
|
|
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg) {
|
|
continue;
|
|
}
|
|
|
|
if (pkg.status === "extracting" || pkg.status === "integrity_check") {
|
|
pkg.status = pkg.enabled ? "queued" : "paused";
|
|
pkg.updatedAt = nowMs();
|
|
}
|
|
|
|
for (const itemId of pkg.itemIds) {
|
|
const item = this.session.items[itemId];
|
|
if (!item || item.status !== "completed") {
|
|
continue;
|
|
}
|
|
if (/^Entpacken/i.test(item.fullStatus || "")) {
|
|
item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
|
item.updatedAt = nowMs();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private runPackagePostProcessing(packageId: string): Promise<void> {
|
|
const existing = this.packagePostProcessTasks.get(packageId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const abortController = new AbortController();
|
|
this.packagePostProcessAbortControllers.set(packageId, abortController);
|
|
|
|
const task = this.packagePostProcessQueue
|
|
.catch(() => undefined)
|
|
.then(async () => {
|
|
await this.handlePackagePostProcessing(packageId, abortController.signal);
|
|
})
|
|
.catch((error) => {
|
|
logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`);
|
|
})
|
|
.finally(() => {
|
|
this.packagePostProcessTasks.delete(packageId);
|
|
this.packagePostProcessAbortControllers.delete(packageId);
|
|
this.persistSoon();
|
|
this.emitState();
|
|
});
|
|
|
|
this.packagePostProcessTasks.set(packageId, task);
|
|
this.packagePostProcessQueue = task;
|
|
return task;
|
|
}
|
|
|
|
private recoverPostProcessingOnStartup(): void {
|
|
const packageIds = [...this.session.packageOrder];
|
|
if (packageIds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
let changed = false;
|
|
for (const packageId of packageIds) {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg || pkg.cancelled) {
|
|
continue;
|
|
}
|
|
|
|
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
|
if (items.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const success = items.filter((item) => item.status === "completed").length;
|
|
const failed = items.filter((item) => item.status === "failed").length;
|
|
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
|
if (success + failed + cancelled < items.length) {
|
|
continue;
|
|
}
|
|
|
|
if (this.settings.autoExtract && failed === 0 && success > 0) {
|
|
const needsPostProcess = pkg.status !== "completed"
|
|
|| items.some((item) => item.status === "completed" && !isExtractedLabel(item.fullStatus));
|
|
if (needsPostProcess) {
|
|
pkg.status = "queued";
|
|
pkg.updatedAt = nowMs();
|
|
for (const item of items) {
|
|
if (item.status === "completed" && !isExtractedLabel(item.fullStatus)) {
|
|
item.fullStatus = "Entpacken ausstehend";
|
|
item.updatedAt = nowMs();
|
|
}
|
|
}
|
|
changed = true;
|
|
void this.runPackagePostProcessing(packageId);
|
|
} else if (pkg.status !== "completed") {
|
|
pkg.status = "completed";
|
|
pkg.updatedAt = nowMs();
|
|
changed = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const targetStatus = failed > 0 ? "failed" : cancelled > 0 && success === 0 ? "cancelled" : "completed";
|
|
if (pkg.status !== targetStatus) {
|
|
pkg.status = targetStatus;
|
|
pkg.updatedAt = nowMs();
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
this.persistSoon();
|
|
this.emitState();
|
|
}
|
|
}
|
|
|
|
private triggerPendingExtractions(): void {
|
|
if (!this.settings.autoExtract) {
|
|
return;
|
|
}
|
|
for (const packageId of this.session.packageOrder) {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
|
continue;
|
|
}
|
|
if (this.packagePostProcessTasks.has(packageId)) {
|
|
continue;
|
|
}
|
|
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
|
if (items.length === 0) {
|
|
continue;
|
|
}
|
|
const success = items.filter((item) => item.status === "completed").length;
|
|
const failed = items.filter((item) => item.status === "failed").length;
|
|
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
|
if (success + failed + cancelled < items.length || failed > 0 || success === 0) {
|
|
continue;
|
|
}
|
|
const needsExtraction = items.some((item) =>
|
|
item.status === "completed" && !isExtractedLabel(item.fullStatus)
|
|
);
|
|
if (!needsExtraction) {
|
|
continue;
|
|
}
|
|
pkg.status = "queued";
|
|
pkg.updatedAt = nowMs();
|
|
for (const item of items) {
|
|
if (item.status === "completed" && !isExtractedLabel(item.fullStatus)) {
|
|
item.fullStatus = "Entpacken ausstehend";
|
|
item.updatedAt = nowMs();
|
|
}
|
|
}
|
|
logger.info(`Entpacken via Start ausgelöst: pkg=${pkg.name}`);
|
|
void this.runPackagePostProcessing(packageId);
|
|
}
|
|
}
|
|
|
|
private removePackageFromSession(packageId: string, itemIds: string[]): void {
|
|
for (const itemId of itemIds) {
|
|
delete this.session.items[itemId];
|
|
}
|
|
delete this.session.packages[packageId];
|
|
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
|
|
this.runPackageIds.delete(packageId);
|
|
this.runCompletedPackages.delete(packageId);
|
|
}
|
|
|
|
private async ensureScheduler(): Promise<void> {
|
|
if (this.scheduleRunning) {
|
|
return;
|
|
}
|
|
this.scheduleRunning = true;
|
|
logger.info("Scheduler gestartet");
|
|
try {
|
|
while (this.session.running) {
|
|
const now = nowMs();
|
|
if (now - this.lastSchedulerHeartbeatAt >= 60000) {
|
|
this.lastSchedulerHeartbeatAt = now;
|
|
logger.info(`Scheduler Heartbeat: active=${this.activeTasks.size}, queued=${this.countQueuedItems()}, reconnect=${this.reconnectActive()}, paused=${this.session.paused}, postProcess=${this.packagePostProcessTasks.size}`);
|
|
}
|
|
|
|
if (this.session.paused) {
|
|
await sleep(120);
|
|
continue;
|
|
}
|
|
|
|
if (this.reconnectActive() && (this.nonResumableActive > 0 || this.activeTasks.size === 0)) {
|
|
const markNow = nowMs();
|
|
if (markNow - this.lastReconnectMarkAt >= 900) {
|
|
this.lastReconnectMarkAt = markNow;
|
|
const changed = this.markQueuedAsReconnectWait();
|
|
if (!changed) {
|
|
this.emitState();
|
|
}
|
|
}
|
|
await sleep(220);
|
|
continue;
|
|
}
|
|
|
|
while (this.activeTasks.size < Math.max(1, this.settings.maxParallel)) {
|
|
const next = this.findNextQueuedItem();
|
|
if (!next) {
|
|
break;
|
|
}
|
|
this.startItem(next.packageId, next.itemId);
|
|
}
|
|
|
|
this.runGlobalStallWatchdog(now);
|
|
|
|
if (this.activeTasks.size === 0 && !this.hasQueuedItems() && this.packagePostProcessTasks.size === 0) {
|
|
this.finishRun();
|
|
break;
|
|
}
|
|
|
|
const maxParallel = Math.max(1, this.settings.maxParallel);
|
|
const schedulerSleepMs = this.activeTasks.size >= maxParallel ? 170 : 120;
|
|
await sleep(schedulerSleepMs);
|
|
}
|
|
} finally {
|
|
this.scheduleRunning = false;
|
|
logger.info("Scheduler beendet");
|
|
}
|
|
}
|
|
|
|
private reconnectActive(): boolean {
|
|
return this.session.reconnectUntil > nowMs();
|
|
}
|
|
|
|
private runGlobalStallWatchdog(now: number): void {
|
|
const timeoutMs = getGlobalStallWatchdogTimeoutMs();
|
|
if (timeoutMs <= 0) {
|
|
return;
|
|
}
|
|
|
|
if (!this.session.running || this.session.paused || this.reconnectActive()) {
|
|
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
|
this.lastGlobalProgressAt = now;
|
|
return;
|
|
}
|
|
|
|
if (this.session.totalDownloadedBytes !== this.lastGlobalProgressBytes) {
|
|
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
|
this.lastGlobalProgressAt = now;
|
|
return;
|
|
}
|
|
|
|
if (now - this.lastGlobalProgressAt < timeoutMs) {
|
|
return;
|
|
}
|
|
|
|
const stalled = Array.from(this.activeTasks.values()).filter((active) => {
|
|
if (active.abortController.signal.aborted) {
|
|
return false;
|
|
}
|
|
const item = this.session.items[active.itemId];
|
|
return Boolean(item && item.status === "downloading");
|
|
});
|
|
if (stalled.length === 0) {
|
|
this.lastGlobalProgressAt = now;
|
|
return;
|
|
}
|
|
|
|
logger.warn(`Globaler Download-Stall erkannt (${Math.floor((now - this.lastGlobalProgressAt) / 1000)}s ohne Fortschritt), ${stalled.length} Task(s) neu starten`);
|
|
for (const active of stalled) {
|
|
if (active.abortController.signal.aborted) {
|
|
continue;
|
|
}
|
|
active.abortReason = "stall";
|
|
active.abortController.abort("stall");
|
|
}
|
|
this.lastGlobalProgressAt = now;
|
|
}
|
|
|
|
private requestReconnect(reason: string): void {
|
|
if (!this.settings.autoReconnect) {
|
|
return;
|
|
}
|
|
|
|
const until = nowMs() + this.settings.reconnectWaitSeconds * 1000;
|
|
this.session.reconnectUntil = Math.max(this.session.reconnectUntil, until);
|
|
this.session.reconnectReason = reason;
|
|
this.lastReconnectMarkAt = 0;
|
|
|
|
for (const active of this.activeTasks.values()) {
|
|
if (active.resumable) {
|
|
active.abortReason = "reconnect";
|
|
active.abortController.abort("reconnect");
|
|
}
|
|
}
|
|
|
|
logger.warn(`Reconnect angefordert: ${reason}`);
|
|
this.emitState();
|
|
}
|
|
|
|
private markQueuedAsReconnectWait(): boolean {
|
|
let changed = false;
|
|
for (const item of Object.values(this.session.items)) {
|
|
const pkg = this.session.packages[item.packageId];
|
|
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
|
continue;
|
|
}
|
|
if (item.status === "queued") {
|
|
item.status = "reconnect_wait";
|
|
item.fullStatus = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`;
|
|
item.updatedAt = nowMs();
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
this.emitState();
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
private findNextQueuedItem(): { packageId: string; itemId: string } | null {
|
|
for (const packageId of this.session.packageOrder) {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
|
continue;
|
|
}
|
|
for (const itemId of pkg.itemIds) {
|
|
const item = this.session.items[itemId];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (item.status === "queued" || item.status === "reconnect_wait") {
|
|
return { packageId, itemId };
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private hasQueuedItems(): boolean {
|
|
for (const packageId of this.session.packageOrder) {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
|
continue;
|
|
}
|
|
for (const itemId of pkg.itemIds) {
|
|
const item = this.session.items[itemId];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (item.status === "queued" || item.status === "reconnect_wait") {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private countQueuedItems(): number {
|
|
let count = 0;
|
|
for (const packageId of this.session.packageOrder) {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
|
continue;
|
|
}
|
|
for (const itemId of pkg.itemIds) {
|
|
const item = this.session.items[itemId];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (item.status === "queued" || item.status === "reconnect_wait") {
|
|
count += 1;
|
|
}
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private startItem(packageId: string, itemId: string): void {
|
|
const item = this.session.items[itemId];
|
|
const pkg = this.session.packages[packageId];
|
|
if (!item || !pkg || pkg.cancelled || !pkg.enabled) {
|
|
return;
|
|
}
|
|
if (this.activeTasks.has(itemId)) {
|
|
return;
|
|
}
|
|
|
|
item.status = "validating";
|
|
item.fullStatus = "Link wird umgewandelt";
|
|
item.updatedAt = nowMs();
|
|
pkg.status = "downloading";
|
|
pkg.updatedAt = nowMs();
|
|
|
|
const active: ActiveTask = {
|
|
itemId,
|
|
packageId,
|
|
abortController: new AbortController(),
|
|
abortReason: "none",
|
|
resumable: true,
|
|
speedEvents: [],
|
|
nonResumableCounted: false
|
|
};
|
|
this.activeTasks.set(itemId, active);
|
|
this.emitState();
|
|
|
|
void this.processItem(active).finally(() => {
|
|
this.releaseTargetPath(item.id);
|
|
if (active.nonResumableCounted) {
|
|
this.nonResumableActive = Math.max(0, this.nonResumableActive - 1);
|
|
}
|
|
this.activeTasks.delete(itemId);
|
|
this.persistSoon();
|
|
this.emitState();
|
|
});
|
|
}
|
|
|
|
private async processItem(active: ActiveTask): Promise<void> {
|
|
const item = this.session.items[active.itemId];
|
|
const pkg = this.session.packages[active.packageId];
|
|
if (!item || !pkg) {
|
|
return;
|
|
}
|
|
|
|
let freshRetryUsed = false;
|
|
let stallRetries = 0;
|
|
let genericErrorRetries = 0;
|
|
let unrestrictRetries = 0;
|
|
const maxGenericErrorRetries = Math.max(2, REQUEST_RETRIES);
|
|
const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES);
|
|
while (true) {
|
|
try {
|
|
const unrestricted = await this.debridService.unrestrictLink(item.url);
|
|
item.provider = unrestricted.provider;
|
|
item.retries += unrestricted.retriesUsed;
|
|
item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url));
|
|
try {
|
|
fs.mkdirSync(pkg.outputDir, { recursive: true });
|
|
} catch (mkdirError) {
|
|
throw new Error(`Zielordner kann nicht erstellt werden: ${compactErrorText(mkdirError)}`);
|
|
}
|
|
const existingTargetPath = String(item.targetPath || "").trim();
|
|
const canReuseExistingTarget = existingTargetPath
|
|
&& isPathInsideDir(existingTargetPath, pkg.outputDir)
|
|
&& (item.downloadedBytes > 0 || fs.existsSync(existingTargetPath));
|
|
const preferredTargetPath = canReuseExistingTarget
|
|
? existingTargetPath
|
|
: path.join(pkg.outputDir, item.fileName);
|
|
item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget));
|
|
item.totalBytes = unrestricted.fileSize;
|
|
item.status = "downloading";
|
|
item.fullStatus = `Download läuft (${unrestricted.providerLabel})`;
|
|
item.updatedAt = nowMs();
|
|
this.emitState();
|
|
|
|
const maxAttempts = REQUEST_RETRIES;
|
|
let done = false;
|
|
while (!done && item.attempts < maxAttempts) {
|
|
item.attempts += 1;
|
|
const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes);
|
|
active.resumable = result.resumable;
|
|
if (!active.resumable && !active.nonResumableCounted) {
|
|
active.nonResumableCounted = true;
|
|
this.nonResumableActive += 1;
|
|
}
|
|
|
|
if (this.settings.enableIntegrityCheck) {
|
|
item.status = "integrity_check";
|
|
item.fullStatus = "CRC-Check läuft";
|
|
item.updatedAt = nowMs();
|
|
this.emitState();
|
|
|
|
const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir);
|
|
if (!validation.ok) {
|
|
item.lastError = validation.message;
|
|
item.fullStatus = `${validation.message}, Neuversuch`;
|
|
try {
|
|
fs.rmSync(item.targetPath, { force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
if (item.attempts < maxAttempts) {
|
|
item.status = "queued";
|
|
item.progressPercent = 0;
|
|
item.downloadedBytes = 0;
|
|
item.totalBytes = unrestricted.fileSize;
|
|
this.emitState();
|
|
await sleep(300);
|
|
continue;
|
|
}
|
|
throw new Error(`Integritätsprüfung fehlgeschlagen (${validation.message})`);
|
|
}
|
|
}
|
|
|
|
const finalTargetPath = String(item.targetPath || "").trim();
|
|
const fileSizeOnDisk = finalTargetPath && fs.existsSync(finalTargetPath)
|
|
? fs.statSync(finalTargetPath).size
|
|
: item.downloadedBytes;
|
|
const expectsNonEmptyFile = (item.totalBytes || 0) > 0 || isArchiveLikePath(finalTargetPath || item.fileName);
|
|
if (expectsNonEmptyFile && fileSizeOnDisk <= 0) {
|
|
try {
|
|
fs.rmSync(finalTargetPath, { force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
this.releaseTargetPath(item.id);
|
|
item.downloadedBytes = 0;
|
|
item.progressPercent = 0;
|
|
item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null;
|
|
item.speedBps = 0;
|
|
item.updatedAt = nowMs();
|
|
throw new Error("Leere Datei erkannt (0 B)");
|
|
}
|
|
|
|
done = true;
|
|
}
|
|
item.status = "completed";
|
|
item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`;
|
|
item.progressPercent = 100;
|
|
item.speedBps = 0;
|
|
item.updatedAt = nowMs();
|
|
pkg.updatedAt = nowMs();
|
|
this.recordRunOutcome(item.id, "completed");
|
|
|
|
void this.runPackagePostProcessing(pkg.id).finally(() => {
|
|
this.applyCompletedCleanupPolicy(pkg.id, item.id);
|
|
this.persistSoon();
|
|
this.emitState();
|
|
});
|
|
this.persistSoon();
|
|
this.emitState();
|
|
return;
|
|
} catch (error) {
|
|
const reason = active.abortReason;
|
|
if (reason === "cancel") {
|
|
item.status = "cancelled";
|
|
item.fullStatus = "Entfernt";
|
|
this.recordRunOutcome(item.id, "cancelled");
|
|
try {
|
|
fs.rmSync(item.targetPath, { force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
} else if (reason === "stop") {
|
|
item.status = "cancelled";
|
|
item.fullStatus = "Gestoppt";
|
|
this.recordRunOutcome(item.id, "cancelled");
|
|
try {
|
|
fs.rmSync(item.targetPath, { force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
} else if (reason === "shutdown") {
|
|
item.status = "queued";
|
|
item.speedBps = 0;
|
|
const activePkg = this.session.packages[item.packageId];
|
|
item.fullStatus = activePkg && !activePkg.enabled ? "Paket gestoppt" : "Wartet";
|
|
} else if (reason === "reconnect") {
|
|
item.status = "queued";
|
|
item.fullStatus = "Wartet auf Reconnect";
|
|
} else if (reason === "package_toggle") {
|
|
item.status = "queued";
|
|
item.speedBps = 0;
|
|
item.fullStatus = "Paket gestoppt";
|
|
} else if (reason === "stall") {
|
|
stallRetries += 1;
|
|
if (stallRetries <= 2) {
|
|
item.retries += 1;
|
|
item.status = "queued";
|
|
item.speedBps = 0;
|
|
item.fullStatus = `Keine Daten empfangen, Retry ${stallRetries}/2`;
|
|
item.lastError = "";
|
|
item.attempts = 0;
|
|
item.updatedAt = nowMs();
|
|
active.abortController = new AbortController();
|
|
active.abortReason = "none";
|
|
this.persistSoon();
|
|
this.emitState();
|
|
await sleep(350 * stallRetries);
|
|
continue;
|
|
}
|
|
item.status = "failed";
|
|
item.lastError = "Download hing wiederholt";
|
|
item.fullStatus = `Fehler: ${item.lastError}`;
|
|
this.recordRunOutcome(item.id, "failed");
|
|
} else {
|
|
const errorText = compactErrorText(error);
|
|
const shouldFreshRetry = !freshRetryUsed && isFetchFailure(errorText);
|
|
const isHttp416 = /(^|\D)416(\D|$)/.test(errorText);
|
|
if (isHttp416) {
|
|
try {
|
|
fs.rmSync(item.targetPath, { force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
this.releaseTargetPath(item.id);
|
|
item.downloadedBytes = 0;
|
|
item.totalBytes = null;
|
|
item.progressPercent = 0;
|
|
}
|
|
if (shouldFreshRetry) {
|
|
freshRetryUsed = true;
|
|
item.retries += 1;
|
|
try {
|
|
fs.rmSync(item.targetPath, { force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
this.releaseTargetPath(item.id);
|
|
item.status = "queued";
|
|
item.fullStatus = "Netzwerkfehler erkannt, frischer Retry";
|
|
item.lastError = "";
|
|
item.attempts = 0;
|
|
item.downloadedBytes = 0;
|
|
item.totalBytes = null;
|
|
item.progressPercent = 0;
|
|
item.speedBps = 0;
|
|
item.updatedAt = nowMs();
|
|
this.persistSoon();
|
|
this.emitState();
|
|
await sleep(450);
|
|
continue;
|
|
}
|
|
|
|
if (isUnrestrictFailure(errorText) && unrestrictRetries < maxUnrestrictRetries) {
|
|
unrestrictRetries += 1;
|
|
item.retries += 1;
|
|
item.status = "queued";
|
|
item.fullStatus = `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`;
|
|
item.lastError = errorText;
|
|
item.attempts = 0;
|
|
item.speedBps = 0;
|
|
item.updatedAt = nowMs();
|
|
active.abortController = new AbortController();
|
|
active.abortReason = "none";
|
|
this.persistSoon();
|
|
this.emitState();
|
|
await sleep(Math.min(8000, 2000 * unrestrictRetries));
|
|
continue;
|
|
}
|
|
|
|
if (genericErrorRetries < maxGenericErrorRetries) {
|
|
genericErrorRetries += 1;
|
|
item.retries += 1;
|
|
item.status = "queued";
|
|
item.fullStatus = `Fehler erkannt, Auto-Retry ${genericErrorRetries}/${maxGenericErrorRetries}`;
|
|
item.lastError = errorText;
|
|
item.attempts = 0;
|
|
item.speedBps = 0;
|
|
item.updatedAt = nowMs();
|
|
active.abortController = new AbortController();
|
|
active.abortReason = "none";
|
|
this.persistSoon();
|
|
this.emitState();
|
|
await sleep(Math.min(1200, 300 * genericErrorRetries));
|
|
continue;
|
|
}
|
|
|
|
item.status = "failed";
|
|
this.recordRunOutcome(item.id, "failed");
|
|
item.lastError = errorText;
|
|
item.fullStatus = `Fehler: ${item.lastError}`;
|
|
}
|
|
item.speedBps = 0;
|
|
item.updatedAt = nowMs();
|
|
this.persistSoon();
|
|
this.emitState();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async downloadToFile(
|
|
active: ActiveTask,
|
|
directUrl: string,
|
|
targetPath: string,
|
|
knownTotal: number | null
|
|
): Promise<{ resumable: boolean }> {
|
|
const item = this.session.items[active.itemId];
|
|
if (!item) {
|
|
throw new Error("Download-Item fehlt");
|
|
}
|
|
|
|
let lastError = "";
|
|
let effectiveTargetPath = targetPath;
|
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
|
let existingBytes = 0;
|
|
try {
|
|
const stat = await fs.promises.stat(effectiveTargetPath);
|
|
existingBytes = stat.size;
|
|
} catch {
|
|
// file does not exist
|
|
}
|
|
const headers: Record<string, string> = {};
|
|
if (existingBytes > 0) {
|
|
headers.Range = `bytes=${existingBytes}-`;
|
|
}
|
|
|
|
while (this.reconnectActive()) {
|
|
if (active.abortController.signal.aborted) {
|
|
throw new Error(`aborted:${active.abortReason}`);
|
|
}
|
|
await sleep(250);
|
|
}
|
|
|
|
let response: Response;
|
|
const connectTimeoutMs = getDownloadConnectTimeoutMs();
|
|
let connectTimer: NodeJS.Timeout | null = null;
|
|
try {
|
|
if (connectTimeoutMs > 0) {
|
|
connectTimer = setTimeout(() => {
|
|
if (active.abortController.signal.aborted) {
|
|
return;
|
|
}
|
|
active.abortReason = "stall";
|
|
active.abortController.abort("stall");
|
|
}, connectTimeoutMs);
|
|
}
|
|
response = await fetch(directUrl, {
|
|
method: "GET",
|
|
headers,
|
|
signal: active.abortController.signal
|
|
});
|
|
} catch (error) {
|
|
if (active.abortController.signal.aborted || String(error).includes("aborted:")) {
|
|
throw error;
|
|
}
|
|
lastError = compactErrorText(error);
|
|
if (attempt < REQUEST_RETRIES) {
|
|
item.retries += 1;
|
|
item.fullStatus = `Verbindungsfehler, retry ${attempt + 1}/${REQUEST_RETRIES}`;
|
|
this.emitState();
|
|
await sleep(300 * attempt);
|
|
continue;
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (connectTimer) {
|
|
clearTimeout(connectTimer);
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 416 && existingBytes > 0) {
|
|
const rangeTotal = parseContentRangeTotal(response.headers.get("content-range"));
|
|
const expectedTotal = knownTotal && knownTotal > 0 ? knownTotal : rangeTotal;
|
|
if (expectedTotal && existingBytes === expectedTotal) {
|
|
item.totalBytes = expectedTotal;
|
|
item.downloadedBytes = existingBytes;
|
|
item.progressPercent = 100;
|
|
item.speedBps = 0;
|
|
item.updatedAt = nowMs();
|
|
return { resumable: true };
|
|
}
|
|
|
|
try {
|
|
fs.rmSync(effectiveTargetPath, { force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
item.downloadedBytes = 0;
|
|
item.totalBytes = knownTotal && knownTotal > 0 ? knownTotal : null;
|
|
item.progressPercent = 0;
|
|
item.speedBps = 0;
|
|
item.fullStatus = `Range-Konflikt (HTTP 416), starte neu ${Math.min(REQUEST_RETRIES, attempt + 1)}/${REQUEST_RETRIES}`;
|
|
item.updatedAt = nowMs();
|
|
this.emitState();
|
|
if (attempt < REQUEST_RETRIES) {
|
|
item.retries += 1;
|
|
await sleep(280 * attempt);
|
|
continue;
|
|
}
|
|
}
|
|
const text = await response.text();
|
|
lastError = `HTTP ${response.status}`;
|
|
const responseText = compactErrorText(text || "");
|
|
if (responseText && responseText !== "Unbekannter Fehler" && !/(^|\b)http\s*\d{3}\b/i.test(responseText)) {
|
|
lastError = `HTTP ${response.status}: ${responseText}`;
|
|
}
|
|
if (this.settings.autoReconnect && [429, 503].includes(response.status)) {
|
|
this.requestReconnect(`HTTP ${response.status}`);
|
|
}
|
|
if (attempt < REQUEST_RETRIES) {
|
|
item.retries += 1;
|
|
item.fullStatus = `Serverfehler ${response.status}, retry ${attempt + 1}/${REQUEST_RETRIES}`;
|
|
this.emitState();
|
|
await sleep(350 * attempt);
|
|
continue;
|
|
}
|
|
throw new Error(lastError);
|
|
}
|
|
|
|
const acceptRanges = (response.headers.get("accept-ranges") || "").toLowerCase().includes("bytes");
|
|
try {
|
|
if (existingBytes === 0) {
|
|
const rawHeaderName = parseContentDispositionFilename(response.headers.get("content-disposition")).trim();
|
|
const fromHeader = rawHeaderName ? sanitizeFilename(rawHeaderName) : "";
|
|
if (fromHeader && !looksLikeOpaqueFilename(fromHeader) && fromHeader !== item.fileName) {
|
|
const pkg = this.session.packages[item.packageId];
|
|
if (pkg) {
|
|
this.releaseTargetPath(item.id);
|
|
effectiveTargetPath = this.claimTargetPath(item.id, path.join(pkg.outputDir, fromHeader));
|
|
item.fileName = fromHeader;
|
|
item.targetPath = effectiveTargetPath;
|
|
item.updatedAt = nowMs();
|
|
this.emitState();
|
|
}
|
|
}
|
|
}
|
|
|
|
const resumable = response.status === 206 || acceptRanges;
|
|
active.resumable = resumable;
|
|
|
|
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
const totalFromRange = parseContentRangeTotal(response.headers.get("content-range"));
|
|
if (knownTotal && knownTotal > 0) {
|
|
item.totalBytes = knownTotal;
|
|
} else if (totalFromRange) {
|
|
item.totalBytes = totalFromRange;
|
|
} else if (contentLength > 0) {
|
|
item.totalBytes = existingBytes + contentLength;
|
|
}
|
|
|
|
const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w";
|
|
if (writeMode === "w" && existingBytes > 0) {
|
|
fs.rmSync(effectiveTargetPath, { force: true });
|
|
}
|
|
|
|
const stream = fs.createWriteStream(effectiveTargetPath, { flags: writeMode });
|
|
let written = writeMode === "a" ? existingBytes : 0;
|
|
let windowBytes = 0;
|
|
let windowStarted = nowMs();
|
|
const itemCount = Object.keys(this.session.items).length;
|
|
const uiUpdateIntervalMs = itemCount >= 1500
|
|
? 650
|
|
: itemCount >= 700
|
|
? 420
|
|
: itemCount >= 250
|
|
? 280
|
|
: 170;
|
|
let lastUiEmitAt = 0;
|
|
let lastProgressPercent = item.progressPercent;
|
|
const stallTimeoutMs = getDownloadStallTimeoutMs();
|
|
const drainTimeoutMs = Math.max(4000, Math.min(45000, stallTimeoutMs > 0 ? stallTimeoutMs : 15000));
|
|
|
|
const waitDrain = (): Promise<void> => new Promise((resolve, reject) => {
|
|
if (active.abortController.signal.aborted) {
|
|
reject(new Error(`aborted:${active.abortReason}`));
|
|
return;
|
|
}
|
|
|
|
let settled = false;
|
|
let timeoutId: NodeJS.Timeout | null = setTimeout(() => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
stream.off("drain", onDrain);
|
|
stream.off("error", onError);
|
|
active.abortController.signal.removeEventListener("abort", onAbort);
|
|
if (!active.abortController.signal.aborted) {
|
|
active.abortReason = "stall";
|
|
active.abortController.abort("stall");
|
|
}
|
|
reject(new Error("write_drain_timeout"));
|
|
}, drainTimeoutMs);
|
|
|
|
const cleanup = (): void => {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = null;
|
|
}
|
|
stream.off("drain", onDrain);
|
|
stream.off("error", onError);
|
|
active.abortController.signal.removeEventListener("abort", onAbort);
|
|
};
|
|
|
|
const onDrain = (): void => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
const onError = (streamError: Error): void => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
cleanup();
|
|
reject(streamError);
|
|
};
|
|
const onAbort = (): void => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
cleanup();
|
|
reject(new Error(`aborted:${active.abortReason}`));
|
|
};
|
|
|
|
stream.once("drain", onDrain);
|
|
stream.once("error", onError);
|
|
active.abortController.signal.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
|
|
try {
|
|
const body = response.body;
|
|
if (!body) {
|
|
throw new Error("Leerer Response-Body");
|
|
}
|
|
const reader = body.getReader();
|
|
let lastDataAt = nowMs();
|
|
let lastIdleEmitAt = 0;
|
|
const idlePulseMs = Math.max(1500, Math.min(3500, Math.floor(stallTimeoutMs / 4) || 2000));
|
|
const idleTimer = setInterval(() => {
|
|
if (active.abortController.signal.aborted) {
|
|
return;
|
|
}
|
|
const nowTick = nowMs();
|
|
if (nowTick - lastDataAt < idlePulseMs) {
|
|
return;
|
|
}
|
|
if (item.status === "paused") {
|
|
return;
|
|
}
|
|
item.status = "downloading";
|
|
item.speedBps = 0;
|
|
item.fullStatus = `Warte auf Daten (${providerLabel(item.provider)})`;
|
|
if (nowTick - lastIdleEmitAt >= idlePulseMs) {
|
|
item.updatedAt = nowTick;
|
|
this.emitState();
|
|
lastIdleEmitAt = nowTick;
|
|
}
|
|
}, idlePulseMs);
|
|
const readWithTimeout = async (): Promise<ReadableStreamReadResult<Uint8Array>> => {
|
|
if (stallTimeoutMs <= 0) {
|
|
return reader.read();
|
|
}
|
|
return new Promise<ReadableStreamReadResult<Uint8Array>>((resolve, reject) => {
|
|
let settled = false;
|
|
const timer = setTimeout(() => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
active.abortReason = "stall";
|
|
active.abortController.abort("stall");
|
|
reject(new Error("stall_timeout"));
|
|
}, stallTimeoutMs);
|
|
|
|
reader.read().then((result) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve(result);
|
|
}).catch((error) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
});
|
|
};
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await readWithTimeout();
|
|
if (done) {
|
|
break;
|
|
}
|
|
const chunk = value;
|
|
lastDataAt = nowMs();
|
|
if (active.abortController.signal.aborted) {
|
|
throw new Error(`aborted:${active.abortReason}`);
|
|
}
|
|
while (this.session.paused && this.session.running && !active.abortController.signal.aborted) {
|
|
item.status = "paused";
|
|
item.fullStatus = "Pausiert";
|
|
this.emitState();
|
|
await sleep(120);
|
|
}
|
|
if (active.abortController.signal.aborted) {
|
|
throw new Error(`aborted:${active.abortReason}`);
|
|
}
|
|
if (this.reconnectActive() && active.resumable) {
|
|
active.abortReason = "reconnect";
|
|
active.abortController.abort("reconnect");
|
|
throw new Error("aborted:reconnect");
|
|
}
|
|
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
await this.applySpeedLimit(buffer.length, windowBytes, windowStarted);
|
|
if (active.abortController.signal.aborted) {
|
|
throw new Error(`aborted:${active.abortReason}`);
|
|
}
|
|
if (!stream.write(buffer)) {
|
|
await waitDrain();
|
|
}
|
|
written += buffer.length;
|
|
windowBytes += buffer.length;
|
|
this.session.totalDownloadedBytes += buffer.length;
|
|
this.recordSpeed(buffer.length);
|
|
|
|
const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.1);
|
|
const speed = windowBytes / elapsed;
|
|
if (elapsed >= 1.2) {
|
|
windowStarted = nowMs();
|
|
windowBytes = 0;
|
|
}
|
|
|
|
item.status = "downloading";
|
|
item.speedBps = Math.max(0, Math.floor(speed));
|
|
item.downloadedBytes = written;
|
|
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0;
|
|
item.fullStatus = `Download läuft (${providerLabel(item.provider)})`;
|
|
const nowTick = nowMs();
|
|
const progressChanged = item.progressPercent !== lastProgressPercent;
|
|
if (progressChanged || nowTick - lastUiEmitAt >= uiUpdateIntervalMs) {
|
|
item.updatedAt = nowTick;
|
|
this.emitState();
|
|
lastUiEmitAt = nowTick;
|
|
lastProgressPercent = item.progressPercent;
|
|
}
|
|
}
|
|
} finally {
|
|
clearInterval(idleTimer);
|
|
}
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => {
|
|
if (stream.closed || stream.destroyed) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const onDone = (): void => {
|
|
stream.off("error", onError);
|
|
stream.off("finish", onDone);
|
|
stream.off("close", onDone);
|
|
resolve();
|
|
};
|
|
const onError = (streamError: Error): void => {
|
|
stream.off("finish", onDone);
|
|
stream.off("close", onDone);
|
|
reject(streamError);
|
|
};
|
|
stream.once("finish", onDone);
|
|
stream.once("close", onDone);
|
|
stream.once("error", onError);
|
|
stream.end();
|
|
});
|
|
}
|
|
|
|
item.downloadedBytes = written;
|
|
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100;
|
|
item.speedBps = 0;
|
|
item.updatedAt = nowMs();
|
|
return { resumable };
|
|
} catch (error) {
|
|
if (active.abortController.signal.aborted || String(error).includes("aborted:")) {
|
|
throw error;
|
|
}
|
|
lastError = compactErrorText(error);
|
|
if (attempt < REQUEST_RETRIES) {
|
|
item.retries += 1;
|
|
item.fullStatus = `Downloadfehler, retry ${attempt + 1}/${REQUEST_RETRIES}`;
|
|
this.emitState();
|
|
await sleep(350 * attempt);
|
|
continue;
|
|
}
|
|
throw new Error(lastError || "Download fehlgeschlagen");
|
|
}
|
|
}
|
|
|
|
throw new Error(lastError || "Download fehlgeschlagen");
|
|
}
|
|
|
|
private recoverRetryableItems(trigger: "startup" | "start"): number {
|
|
let recovered = 0;
|
|
const touchedPackages = new Set<string>();
|
|
|
|
for (const packageId of this.session.packageOrder) {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg || pkg.cancelled) {
|
|
continue;
|
|
}
|
|
|
|
for (const itemId of pkg.itemIds) {
|
|
const item = this.session.items[itemId];
|
|
if (!item || item.status === "cancelled") {
|
|
continue;
|
|
}
|
|
|
|
const is416Failure = this.isHttp416Failure(item);
|
|
const hasZeroByteArchive = this.hasZeroByteArchiveArtifact(item);
|
|
|
|
if (item.status === "failed") {
|
|
this.queueItemForRetry(item, {
|
|
hardReset: is416Failure || hasZeroByteArchive,
|
|
reason: is416Failure
|
|
? "Wartet (Auto-Retry: HTTP 416)"
|
|
: hasZeroByteArchive
|
|
? "Wartet (Auto-Retry: 0B-Datei)"
|
|
: "Wartet (Auto-Retry)"
|
|
});
|
|
recovered += 1;
|
|
touchedPackages.add(pkg.id);
|
|
continue;
|
|
}
|
|
|
|
if (item.status === "completed" && hasZeroByteArchive) {
|
|
this.queueItemForRetry(item, {
|
|
hardReset: true,
|
|
reason: "Wartet (Auto-Retry: 0B-Datei)"
|
|
});
|
|
recovered += 1;
|
|
touchedPackages.add(pkg.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (recovered > 0) {
|
|
for (const packageId of touchedPackages) {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg) {
|
|
continue;
|
|
}
|
|
this.refreshPackageStatus(pkg);
|
|
}
|
|
logger.warn(`Auto-Retry-Recovery (${trigger}): ${recovered} Item(s) wieder in Queue gesetzt`);
|
|
}
|
|
|
|
return recovered;
|
|
}
|
|
|
|
private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void {
|
|
const targetPath = String(item.targetPath || "").trim();
|
|
if (options.hardReset && targetPath) {
|
|
try {
|
|
fs.rmSync(targetPath, { force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
this.releaseTargetPath(item.id);
|
|
item.downloadedBytes = 0;
|
|
item.totalBytes = null;
|
|
item.progressPercent = 0;
|
|
}
|
|
|
|
item.status = "queued";
|
|
item.speedBps = 0;
|
|
item.attempts = 0;
|
|
item.lastError = "";
|
|
item.resumable = true;
|
|
item.fullStatus = options.reason;
|
|
item.updatedAt = nowMs();
|
|
}
|
|
|
|
private isHttp416Failure(item: DownloadItem): boolean {
|
|
const text = `${item.lastError} ${item.fullStatus}`;
|
|
return /(^|\D)416(\D|$)/.test(text);
|
|
}
|
|
|
|
private hasZeroByteArchiveArtifact(item: DownloadItem): boolean {
|
|
const targetPath = String(item.targetPath || "").trim();
|
|
const archiveCandidate = isArchiveLikePath(targetPath || item.fileName);
|
|
if (!archiveCandidate) {
|
|
return false;
|
|
}
|
|
|
|
if (targetPath && fs.existsSync(targetPath)) {
|
|
try {
|
|
return fs.statSync(targetPath).size <= 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (item.downloadedBytes <= 0 && item.progressPercent >= 100) {
|
|
return true;
|
|
}
|
|
|
|
return /\b0\s*B\b/i.test(item.fullStatus || "");
|
|
}
|
|
|
|
private refreshPackageStatus(pkg: PackageEntry): void {
|
|
let pending = 0;
|
|
let success = 0;
|
|
let failed = 0;
|
|
let cancelled = 0;
|
|
let total = 0;
|
|
for (const itemId of pkg.itemIds) {
|
|
const item = this.session.items[itemId];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
total += 1;
|
|
const s = item.status;
|
|
if (s === "completed") {
|
|
success += 1;
|
|
} else if (s === "failed") {
|
|
failed += 1;
|
|
} else if (s === "cancelled") {
|
|
cancelled += 1;
|
|
} else {
|
|
pending += 1;
|
|
}
|
|
}
|
|
if (total === 0) {
|
|
return;
|
|
}
|
|
|
|
if (pending > 0) {
|
|
pkg.status = pkg.enabled ? "queued" : "paused";
|
|
pkg.updatedAt = nowMs();
|
|
return;
|
|
}
|
|
|
|
if (failed > 0) {
|
|
pkg.status = "failed";
|
|
} else if (cancelled > 0 && success === 0) {
|
|
pkg.status = "cancelled";
|
|
} else if (success > 0) {
|
|
pkg.status = "completed";
|
|
}
|
|
pkg.updatedAt = nowMs();
|
|
}
|
|
|
|
private cachedSpeedLimitKbps = 0;
|
|
|
|
private cachedSpeedLimitAt = 0;
|
|
|
|
private getEffectiveSpeedLimitKbps(): number {
|
|
const now = nowMs();
|
|
if (now - this.cachedSpeedLimitAt < 2000) {
|
|
return this.cachedSpeedLimitKbps;
|
|
}
|
|
this.cachedSpeedLimitAt = now;
|
|
const schedules = this.settings.bandwidthSchedules;
|
|
if (schedules.length > 0) {
|
|
const hour = new Date().getHours();
|
|
for (const entry of schedules) {
|
|
if (!entry.enabled) {
|
|
continue;
|
|
}
|
|
const wraps = entry.startHour > entry.endHour;
|
|
const inRange = wraps
|
|
? hour >= entry.startHour || hour < entry.endHour
|
|
: hour >= entry.startHour && hour < entry.endHour;
|
|
if (inRange) {
|
|
this.cachedSpeedLimitKbps = entry.speedLimitKbps;
|
|
return this.cachedSpeedLimitKbps;
|
|
}
|
|
}
|
|
}
|
|
if (this.settings.speedLimitEnabled && this.settings.speedLimitKbps > 0) {
|
|
this.cachedSpeedLimitKbps = this.settings.speedLimitKbps;
|
|
return this.cachedSpeedLimitKbps;
|
|
}
|
|
this.cachedSpeedLimitKbps = 0;
|
|
return 0;
|
|
}
|
|
|
|
private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise<void> {
|
|
const limitKbps = this.getEffectiveSpeedLimitKbps();
|
|
if (limitKbps <= 0) {
|
|
return;
|
|
}
|
|
const bytesPerSecond = limitKbps * 1024;
|
|
const now = nowMs();
|
|
const elapsed = Math.max((now - localWindowStarted) / 1000, 0.1);
|
|
if (this.settings.speedLimitMode === "per_download") {
|
|
const projected = localWindowBytes + chunkBytes;
|
|
const allowed = bytesPerSecond * elapsed;
|
|
if (projected > allowed) {
|
|
const sleepMs = Math.ceil(((projected - allowed) / bytesPerSecond) * 1000);
|
|
if (sleepMs > 0) {
|
|
await sleep(Math.min(300, sleepMs));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.pruneSpeedEvents(now);
|
|
const globalBytes = this.speedBytesLastWindow + chunkBytes;
|
|
const globalAllowed = bytesPerSecond * 3;
|
|
if (globalBytes > globalAllowed) {
|
|
await sleep(Math.min(250, Math.ceil(((globalBytes - globalAllowed) / bytesPerSecond) * 1000)));
|
|
}
|
|
}
|
|
|
|
private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise<void> {
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg || pkg.cancelled) {
|
|
return;
|
|
}
|
|
if (signal?.aborted) {
|
|
return;
|
|
}
|
|
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
|
const success = items.filter((item) => item.status === "completed").length;
|
|
const failed = items.filter((item) => item.status === "failed").length;
|
|
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
|
logger.info(`Post-Processing Start: pkg=${pkg.name}, success=${success}, failed=${failed}, cancelled=${cancelled}, autoExtract=${this.settings.autoExtract}`);
|
|
|
|
if (success + failed + cancelled < items.length) {
|
|
pkg.status = "downloading";
|
|
logger.info(`Post-Processing verschoben: pkg=${pkg.name}, noch offene items`);
|
|
return;
|
|
}
|
|
|
|
const completedItems = items.filter((item) => item.status === "completed");
|
|
const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus));
|
|
|
|
if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) {
|
|
pkg.status = "extracting";
|
|
this.emitState();
|
|
|
|
const updateExtractingStatus = (text: string): void => {
|
|
const updatedAt = nowMs();
|
|
for (const entry of completedItems) {
|
|
if (entry.fullStatus === text) {
|
|
continue;
|
|
}
|
|
entry.fullStatus = text;
|
|
entry.updatedAt = updatedAt;
|
|
}
|
|
};
|
|
|
|
updateExtractingStatus("Entpacken 0%");
|
|
this.emitState();
|
|
|
|
const extractTimeoutMs = 4 * 60 * 60 * 1000;
|
|
const extractDeadline = setTimeout(() => {
|
|
logger.error(`Post-Processing Extraction Timeout nach 4h: pkg=${pkg.name}`);
|
|
}, extractTimeoutMs);
|
|
try {
|
|
const result = await extractPackageArchives({
|
|
packageDir: pkg.outputDir,
|
|
targetDir: pkg.extractDir,
|
|
cleanupMode: this.settings.cleanupMode,
|
|
conflictMode: this.settings.extractConflictMode,
|
|
removeLinks: this.settings.removeLinkFilesAfterExtract,
|
|
removeSamples: this.settings.removeSamplesAfterExtract,
|
|
passwordList: this.settings.archivePasswordList,
|
|
signal,
|
|
onProgress: (progress) => {
|
|
const label = progress.phase === "done"
|
|
? "Entpacken 100%"
|
|
: (() => {
|
|
const archive = progress.archiveName ? ` · ${progress.archiveName}` : "";
|
|
const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000
|
|
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
|
|
: "";
|
|
const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
|
|
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive));
|
|
return `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`;
|
|
})();
|
|
updateExtractingStatus(label);
|
|
this.emitState();
|
|
}
|
|
});
|
|
clearTimeout(extractDeadline);
|
|
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
|
|
if (result.failed > 0) {
|
|
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
|
|
for (const entry of completedItems) {
|
|
entry.fullStatus = `Entpack-Fehler: ${reason}`;
|
|
entry.updatedAt = nowMs();
|
|
}
|
|
pkg.status = "failed";
|
|
} else {
|
|
const hasExtractedOutput = this.directoryHasAnyFiles(pkg.extractDir);
|
|
if (result.extracted > 0 || hasExtractedOutput) {
|
|
this.autoRenameExtractedVideoFiles(pkg.extractDir);
|
|
}
|
|
const sourceExists = fs.existsSync(pkg.outputDir);
|
|
let finalStatusText = "";
|
|
|
|
if (result.extracted > 0 || hasExtractedOutput) {
|
|
finalStatusText = "Entpackt";
|
|
} else if (!sourceExists) {
|
|
finalStatusText = "Entpackt (Quelle fehlt)";
|
|
logger.warn(`Post-Processing ohne Quellordner: pkg=${pkg.name}, outputDir fehlt`);
|
|
} else {
|
|
finalStatusText = "Entpackt (keine Archive)";
|
|
}
|
|
|
|
for (const entry of completedItems) {
|
|
entry.fullStatus = finalStatusText;
|
|
entry.updatedAt = nowMs();
|
|
}
|
|
pkg.status = "completed";
|
|
}
|
|
} catch (error) {
|
|
clearTimeout(extractDeadline);
|
|
const reasonRaw = String(error || "");
|
|
if (reasonRaw.includes("aborted:extract")) {
|
|
for (const entry of completedItems) {
|
|
if (/^Entpacken/i.test(entry.fullStatus || "")) {
|
|
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
|
}
|
|
entry.updatedAt = nowMs();
|
|
}
|
|
pkg.status = pkg.enabled ? "queued" : "paused";
|
|
pkg.updatedAt = nowMs();
|
|
logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`);
|
|
return;
|
|
}
|
|
const reason = compactErrorText(error);
|
|
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
|
|
for (const entry of completedItems) {
|
|
entry.fullStatus = `Entpack-Fehler: ${reason}`;
|
|
entry.updatedAt = nowMs();
|
|
}
|
|
pkg.status = "failed";
|
|
}
|
|
} else if (failed > 0) {
|
|
pkg.status = "failed";
|
|
} else if (cancelled > 0 && success === 0) {
|
|
pkg.status = "cancelled";
|
|
} else {
|
|
pkg.status = "completed";
|
|
}
|
|
if (this.runPackageIds.has(packageId)) {
|
|
if (pkg.status === "completed") {
|
|
this.runCompletedPackages.add(packageId);
|
|
} else {
|
|
this.runCompletedPackages.delete(packageId);
|
|
}
|
|
}
|
|
pkg.updatedAt = nowMs();
|
|
logger.info(`Post-Processing Ende: pkg=${pkg.name}, status=${pkg.status}`);
|
|
|
|
this.applyPackageDoneCleanup(packageId);
|
|
}
|
|
|
|
private applyPackageDoneCleanup(packageId: string): void {
|
|
if (this.settings.completedCleanupPolicy !== "package_done") {
|
|
return;
|
|
}
|
|
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg || pkg.status !== "completed") {
|
|
return;
|
|
}
|
|
|
|
const allCompleted = pkg.itemIds.every((itemId) => {
|
|
const item = this.session.items[itemId];
|
|
return !item || item.status === "completed";
|
|
});
|
|
if (!allCompleted) {
|
|
return;
|
|
}
|
|
|
|
this.removePackageFromSession(packageId, [...pkg.itemIds]);
|
|
}
|
|
|
|
private applyCompletedCleanupPolicy(packageId: string, itemId: string): void {
|
|
const policy = this.settings.completedCleanupPolicy;
|
|
if (policy === "never" || policy === "on_start") {
|
|
return;
|
|
}
|
|
|
|
const pkg = this.session.packages[packageId];
|
|
if (!pkg) {
|
|
return;
|
|
}
|
|
|
|
if (policy === "immediate") {
|
|
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
|
delete this.session.items[itemId];
|
|
if (pkg.itemIds.length === 0) {
|
|
this.removePackageFromSession(packageId, []);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (policy === "package_done") {
|
|
const hasOpen = pkg.itemIds.some((id) => {
|
|
const item = this.session.items[id];
|
|
return item != null && item.status !== "completed";
|
|
});
|
|
if (!hasOpen) {
|
|
this.removePackageFromSession(packageId, [...pkg.itemIds]);
|
|
}
|
|
}
|
|
}
|
|
|
|
private finishRun(): void {
|
|
this.session.running = false;
|
|
this.session.paused = false;
|
|
const total = this.runItemIds.size;
|
|
const outcomes = Array.from(this.runOutcomes.values());
|
|
const success = outcomes.filter((status) => status === "completed").length;
|
|
const failed = outcomes.filter((status) => status === "failed").length;
|
|
const cancelled = outcomes.filter((status) => status === "cancelled").length;
|
|
const extracted = this.runCompletedPackages.size;
|
|
const duration = this.session.runStartedAt > 0 ? Math.max(1, Math.floor((nowMs() - this.session.runStartedAt) / 1000)) : 1;
|
|
const avgSpeed = Math.floor(this.session.totalDownloadedBytes / duration);
|
|
this.summary = {
|
|
total,
|
|
success,
|
|
failed,
|
|
cancelled,
|
|
extracted,
|
|
durationSeconds: duration,
|
|
averageSpeedBps: avgSpeed
|
|
};
|
|
this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`;
|
|
this.runItemIds.clear();
|
|
this.runPackageIds.clear();
|
|
this.runOutcomes.clear();
|
|
this.runCompletedPackages.clear();
|
|
this.reservedTargetPaths.clear();
|
|
this.claimedTargetPathByItem.clear();
|
|
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
|
this.lastGlobalProgressAt = nowMs();
|
|
this.persistNow();
|
|
this.emitState();
|
|
}
|
|
}
|