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 = {}; for (const key of Object.keys(session.items)) { clonedItems[key] = { ...session.items[key] }; } const clonedPackages: Record = {}; 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(); 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 = Promise.resolve(); private packagePostProcessQueue: Promise = Promise.resolve(); private packagePostProcessTasks = new Map>(); private packagePostProcessAbortControllers = new Map(); private reservedTargetPaths = new Map(); private claimedTargetPathByItem = new Map(); private runItemIds = new Set(); private runPackageIds = new Set(); private runOutcomes = new Map(); private runCompletedPackages = new Set(); 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(); 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(); 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 { 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): Promise { 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(); 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(); 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>(); const dirFilesCache = new Map(); 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(); 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 { 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 { 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 { 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 = {}; 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 => 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> => { if (stallTimeoutMs <= 0) { return reader.read(); } return new Promise>((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((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(); 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 { 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 { 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(); } }