diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 279cbd7..d6552fb 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -49,10 +49,10 @@ function releaseTlsSkip(): void { delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; } } -import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; -import { planDownloadCompletion, validateDownloadedFileCompletion } from "./download-completion"; -import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys } from "./debrid"; -import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, type ExtractArchiveFailureInfo } from "./extractor"; +import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; +import { planDownloadCompletion, validateDownloadedFileCompletion } from "./download-completion"; +import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys } from "./debrid"; +import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, type ExtractArchiveFailureInfo } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { logger } from "./logger"; import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log"; @@ -433,15 +433,15 @@ function isHttp416Text(errorText: string): boolean { function shouldPreflightFinalizeItemFromDisk(item: DownloadItem): boolean { const text = `${item.fullStatus || ""} ${item.lastError || ""}`.toLowerCase(); - return text.includes("resume-link erneuern") - || text.includes("resume link erneuern") - || text.includes("direktlink erneuern") - || text.includes("direktlink erschöpft") - || text.includes("direct_link_retry_exhausted") - || text.includes("download_underflow") - || text.includes("resume_download_underflow") - || text.includes("range_ignored_on_resume") - || text.includes("server ignorierte range"); + return text.includes("resume-link erneuern") + || text.includes("resume link erneuern") + || text.includes("direktlink erneuern") + || text.includes("direktlink erschöpft") + || text.includes("direct_link_retry_exhausted") + || text.includes("download_underflow") + || text.includes("resume_download_underflow") + || text.includes("range_ignored_on_resume") + || text.includes("server ignorierte range"); } function isResumeHardResetReason(errorText: string): boolean { @@ -728,15 +728,15 @@ const SCENE_EPISODE_ONLY_RE = /(?:^|[._\-\s])e(?:p(?:isode)?)?\s*0*(\d{1,3})(?:[ const SCENE_PART_TOKEN_RE = /(?:^|[._\-\s])(?:teil|part)\s*0*(\d{1,3})(?=[._\-\s]|$)/i; const SCENE_COMPACT_EPISODE_CODE_RE = /(?:^|[._\-\s])(\d{3,4})([a-z])?(?=$|[._\-\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; -const SCENE_GROUP_SUFFIX_FALLBACK_RE = /-([A-Za-z0-9]{2,})$/; -const SCENE_FLEXIBLE_GROUP_SUFFIX_RE = /-([A-Za-z0-9]+(?:_[A-Za-z0-9]+)*)$/; -const SCENE_MIXED_GROUP_SUFFIX_RE = /-[^-]*[\/\\|\u2044\u2215][^-]*$/; -const SCENE_NON_GROUP_SUFFIXES = new Set([ - "x264", - "x265", - "h264", +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; +const SCENE_GROUP_SUFFIX_FALLBACK_RE = /-([A-Za-z0-9]{2,})$/; +const SCENE_FLEXIBLE_GROUP_SUFFIX_RE = /-([A-Za-z0-9]+(?:_[A-Za-z0-9]+)*)$/; +const SCENE_MIXED_GROUP_SUFFIX_RE = /-[^-]*[\/\\|\u2044\u2215][^-]*$/; +const SCENE_NON_GROUP_SUFFIXES = new Set([ + "x264", + "x265", + "h264", "h265", "avc", "hevc", @@ -747,92 +747,92 @@ const SCENE_NON_GROUP_SUFFIXES = new Set([ "bdrip", "hdtv", "dvdrip", - "remux" -]); - -function isValidSceneGroupSuffix(suffix: string): boolean { - const normalizedSuffix = String(suffix || "").trim(); - if (!normalizedSuffix) { - return false; - } - - const lower = normalizedSuffix.toLowerCase(); - if (SCENE_NON_GROUP_SUFFIXES.has(lower)) { - return false; - } - if (/^s\d{1,2}e\d{1,3}(?:e\d{1,3})?$/i.test(normalizedSuffix) || /^s\d{1,2}$/i.test(normalizedSuffix) || /^e\d{1,3}$/i.test(normalizedSuffix)) { - return false; - } - if (/^\d+p$/.test(lower) || /^\d+$/.test(lower)) { - return false; - } - if (/^\d/.test(normalizedSuffix)) { - return false; - } - if (/4s(?:f|j)/i.test(normalizedSuffix) && !/^(?:4sf|4sj)$/i.test(normalizedSuffix)) { - return false; - } - return /[a-z]/i.test(normalizedSuffix); -} - -function extractFlexibleSceneGroupSuffix(fileName: string): string | null { - const text = String(fileName || "").trim(); - if (!text) { - return null; - } - - const match = text.match(SCENE_FLEXIBLE_GROUP_SUFFIX_RE); - const suffix = String(match?.[1] || "").trim(); - if (!suffix || !/[a-z]/i.test(suffix)) { - return null; - } - - const suffixParts = suffix.split("_").filter(Boolean); - if (suffixParts.length === 0) { - return null; - } - if (!suffixParts.every((part) => isValidSceneGroupSuffix(part))) { - return null; - } - return suffix; -} - -function hasMixedSceneGroupSuffix(fileName: string): boolean { - const text = String(fileName || "").trim(); - if (!text) { - return false; - } - return SCENE_MIXED_GROUP_SUFFIX_RE.test(text); -} - -function applySourceSceneGroupSuffix(targetBaseName: string, sourceFileName: string): string { - const target = String(targetBaseName || "").trim(); - const suffix = extractFlexibleSceneGroupSuffix(sourceFileName); - if (!target || !suffix) { - return target; - } - - if (/-[^-]+$/.test(target)) { - return target.replace(/-[^-]+$/, `-${suffix}`); - } - return `${target}-${suffix}`; -} - -function hasSceneGroupSuffix(fileName: string): boolean { - const text = String(fileName || "").trim(); - if (!text) { - return false; - } - - if (SCENE_GROUP_SUFFIX_RE.test(text)) { - const directMatch = text.match(SCENE_GROUP_SUFFIX_FALLBACK_RE); - return isValidSceneGroupSuffix(String(directMatch?.[1] || "")); - } - - const fallbackMatch = text.match(SCENE_GROUP_SUFFIX_FALLBACK_RE); - const suffix = String(fallbackMatch?.[1] || "").trim(); - return isValidSceneGroupSuffix(suffix); -} + "remux" +]); + +function isValidSceneGroupSuffix(suffix: string): boolean { + const normalizedSuffix = String(suffix || "").trim(); + if (!normalizedSuffix) { + return false; + } + + const lower = normalizedSuffix.toLowerCase(); + if (SCENE_NON_GROUP_SUFFIXES.has(lower)) { + return false; + } + if (/^s\d{1,2}e\d{1,3}(?:e\d{1,3})?$/i.test(normalizedSuffix) || /^s\d{1,2}$/i.test(normalizedSuffix) || /^e\d{1,3}$/i.test(normalizedSuffix)) { + return false; + } + if (/^\d+p$/.test(lower) || /^\d+$/.test(lower)) { + return false; + } + if (/^\d/.test(normalizedSuffix)) { + return false; + } + if (/4s(?:f|j)/i.test(normalizedSuffix) && !/^(?:4sf|4sj)$/i.test(normalizedSuffix)) { + return false; + } + return /[a-z]/i.test(normalizedSuffix); +} + +function extractFlexibleSceneGroupSuffix(fileName: string): string | null { + const text = String(fileName || "").trim(); + if (!text) { + return null; + } + + const match = text.match(SCENE_FLEXIBLE_GROUP_SUFFIX_RE); + const suffix = String(match?.[1] || "").trim(); + if (!suffix || !/[a-z]/i.test(suffix)) { + return null; + } + + const suffixParts = suffix.split("_").filter(Boolean); + if (suffixParts.length === 0) { + return null; + } + if (!suffixParts.every((part) => isValidSceneGroupSuffix(part))) { + return null; + } + return suffix; +} + +function hasMixedSceneGroupSuffix(fileName: string): boolean { + const text = String(fileName || "").trim(); + if (!text) { + return false; + } + return SCENE_MIXED_GROUP_SUFFIX_RE.test(text); +} + +function applySourceSceneGroupSuffix(targetBaseName: string, sourceFileName: string): string { + const target = String(targetBaseName || "").trim(); + const suffix = extractFlexibleSceneGroupSuffix(sourceFileName); + if (!target || !suffix) { + return target; + } + + if (/-[^-]+$/.test(target)) { + return target.replace(/-[^-]+$/, `-${suffix}`); + } + return `${target}-${suffix}`; +} + +function hasSceneGroupSuffix(fileName: string): boolean { + const text = String(fileName || "").trim(); + if (!text) { + return false; + } + + if (SCENE_GROUP_SUFFIX_RE.test(text)) { + const directMatch = text.match(SCENE_GROUP_SUFFIX_FALLBACK_RE); + return isValidSceneGroupSuffix(String(directMatch?.[1] || "")); + } + + const fallbackMatch = text.match(SCENE_GROUP_SUFFIX_FALLBACK_RE); + const suffix = String(fallbackMatch?.[1] || "").trim(); + return isValidSceneGroupSuffix(suffix); +} export function extractEpisodeToken(fileName: string): string | null { const text = String(fileName || ""); @@ -1136,33 +1136,33 @@ export function buildAutoRenameBaseNameFromFoldersWithOptions( continue; } - if (resolvedEpisode - && forceEpisodeForSeasonFolder - && hasSceneGroupSuffix(target) - && !extractEpisodeToken(target) - && SCENE_SEASON_ONLY_RE.test(target)) { - target = applyEpisodeTokenToFolderName(target, resolvedEpisode.token); - } - - if (resolvedEpisode?.fromPart + if (resolvedEpisode + && forceEpisodeForSeasonFolder && hasSceneGroupSuffix(target) && !extractEpisodeToken(target) - && SCENE_SEASON_ONLY_RE.test(target)) { - target = applyEpisodeTokenToFolderName(target, resolvedEpisode.token); - } - - if (resolvedEpisode - && folderHasSeason - && !folderHasEpisode - && (hasMixedSceneGroupSuffix(folderName) || !hasSceneGroupSuffix(folderName))) { - target = applySourceSceneGroupSuffix(target, normalizedSourceFileName); - } - - if (globalRepackHint) { - target = ensureRepackToken(removeRpTokens(target)); - } - return sanitizeFilename(target); - } + && SCENE_SEASON_ONLY_RE.test(target)) { + target = applyEpisodeTokenToFolderName(target, resolvedEpisode.token); + } + + if (resolvedEpisode?.fromPart + && hasSceneGroupSuffix(target) + && !extractEpisodeToken(target) + && SCENE_SEASON_ONLY_RE.test(target)) { + target = applyEpisodeTokenToFolderName(target, resolvedEpisode.token); + } + + if (resolvedEpisode + && folderHasSeason + && !folderHasEpisode + && (hasMixedSceneGroupSuffix(folderName) || !hasSceneGroupSuffix(folderName))) { + target = applySourceSceneGroupSuffix(target, normalizedSourceFileName); + } + + if (globalRepackHint) { + target = ensureRepackToken(removeRpTokens(target)); + } + return sanitizeFilename(target); + } // Last-resort fallback: if no scene-group-suffix folder was found but a folder // has a season token and the source has an episode token, inject the episode anyway. @@ -1172,16 +1172,16 @@ export function buildAutoRenameBaseNameFromFoldersWithOptions( for (const folderName of ordered) { if (!SCENE_SEASON_ONLY_RE.test(folderName) || extractEpisodeToken(folderName)) { continue; - } - let target = applyEpisodeTokenToFolderName(folderName, resolvedEpisode.token); - if (hasMixedSceneGroupSuffix(folderName) || !hasSceneGroupSuffix(folderName)) { - target = applySourceSceneGroupSuffix(target, normalizedSourceFileName); - } - if (globalRepackHint) { - target = ensureRepackToken(removeRpTokens(target)); - } - return sanitizeFilename(target); - } + } + let target = applyEpisodeTokenToFolderName(folderName, resolvedEpisode.token); + if (hasMixedSceneGroupSuffix(folderName) || !hasSceneGroupSuffix(folderName)) { + target = applySourceSceneGroupSuffix(target, normalizedSourceFileName); + } + if (globalRepackHint) { + target = ensureRepackToken(removeRpTokens(target)); + } + return sanitizeFilename(target); + } } return null; @@ -1319,7 +1319,7 @@ function startupDuplicateStateRank(item: DownloadItem, diskExists: boolean): num return rank; } -export function extractArchiveNameFromExtractorLogMessage(message: string): string | null { +export function extractArchiveNameFromExtractorLogMessage(message: string): string | null { const text = String(message || "").trim(); if (!text) { return null; @@ -1335,37 +1335,37 @@ export function extractArchiveNameFromExtractorLogMessage(message: string): stri if (match?.[1]) { return match[1].trim(); } - } - return null; -} - -function summarizeExtractFailureReason(reason: string): string { - const text = compactErrorText(reason).replace(/^Error:\s*/i, "").trim(); - if (!text) { - return "Entpacken fehlgeschlagen"; - } - if (/checksum error|crc/i.test(text)) { - return "Checksum/CRC-Fehler im Archiv"; - } - if (/wrong password|falsches passwort|password/i.test(text) && /checksum error in the encrypted file/i.test(text)) { - return "Checksum- oder Passwortfehler im verschluesselten Archiv"; - } - if (/missing_file|next volume is required|cannot find volume|volume.*missing|part.*missing/i.test(text)) { - return "Teilarchiv fehlt oder ist nicht lesbar"; - } - if (/unexpected end of archive|no end header found|invalid or unsupported zip format|not a rar archive|ungueltig|unsupported_format/i.test(text)) { - return "Archiv unvollstaendig oder ungueltig"; - } - return text; -} - -function formatExtractFailureLabel(reason: string, archiveName = ""): string { - const summary = summarizeExtractFailureReason(reason); - const archive = String(archiveName || "").trim(); - return archive - ? `Entpack-Fehler [${archive}]: ${summary}` - : `Entpack-Fehler: ${summary}`; -} + } + return null; +} + +function summarizeExtractFailureReason(reason: string): string { + const text = compactErrorText(reason).replace(/^Error:\s*/i, "").trim(); + if (!text) { + return "Entpacken fehlgeschlagen"; + } + if (/checksum error|crc/i.test(text)) { + return "Checksum/CRC-Fehler im Archiv"; + } + if (/wrong password|falsches passwort|password/i.test(text) && /checksum error in the encrypted file/i.test(text)) { + return "Checksum- oder Passwortfehler im verschluesselten Archiv"; + } + if (/missing_file|next volume is required|cannot find volume|volume.*missing|part.*missing/i.test(text)) { + return "Teilarchiv fehlt oder ist nicht lesbar"; + } + if (/unexpected end of archive|no end header found|invalid or unsupported zip format|not a rar archive|ungueltig|unsupported_format/i.test(text)) { + return "Archiv unvollstaendig oder ungueltig"; + } + return text; +} + +function formatExtractFailureLabel(reason: string, archiveName = ""): string { + const summary = summarizeExtractFailureReason(reason); + const archive = String(archiveName || "").trim(); + return archive + ? `Entpack-Fehler [${archive}]: ${summary}` + : `Entpack-Fehler: ${summary}`; +} function retryDelayWithJitter(attempt: number, baseMs: number): number { const exponential = baseMs * Math.pow(1.5, Math.min(attempt - 1, 14)); @@ -3281,23 +3281,16 @@ export class DownloadManager extends EventEmitter { } let renamed = 0; - // Collect additional folder candidates from package metadata (outputDir, item filenames) + // Collect additional folder candidates from package metadata (outputDir only). + // Item filenames are intentionally excluded: they contain episode tokens from + // OTHER files in the package, which pollute resolveEpisodeTokenForAutoRename + // and cause all files to receive the same wrong episode number. const packageExtraCandidates: string[] = []; if (pkg) { const outputBase = path.basename(pkg.outputDir || ""); if (outputBase) { packageExtraCandidates.push(outputBase); } - for (const itemId of pkg.itemIds) { - const item = this.session.items[itemId]; - if (item?.fileName) { - const itemBase = path.basename(item.fileName, path.extname(item.fileName)); - const stripped = itemBase.replace(/\.part\d+$/i, "").replace(/\.vol\d+[+\d]*$/i, ""); - if (stripped) { - packageExtraCandidates.push(stripped); - } - } - } } for (const sourcePath of videoFiles) { @@ -3754,12 +3747,12 @@ export class DownloadManager extends EventEmitter { mkvFiles: mkvFiles.length }); - const reservedTargets = new Set(); - let moved = 0; - let skipped = 0; - let failed = 0; - let sourceArtifactsChanged = false; - let sourceCleanupRelevant = false; + const reservedTargets = new Set(); + let moved = 0; + let skipped = 0; + let failed = 0; + let sourceArtifactsChanged = false; + let sourceCleanupRelevant = false; for (const sourcePath of mkvFiles) { if (shouldAbort?.()) { @@ -3804,16 +3797,16 @@ export class DownloadManager extends EventEmitter { sourceSize }, resolved.item, resolved.matchedBy); // Remove the duplicate source file to avoid future re-processing - try { - await fs.promises.unlink(sourcePath); - sourceArtifactsChanged = true; - } catch { - /* ignore */ - } - sourceCleanupRelevant = true; - skipped += 1; - continue; - } + try { + await fs.promises.unlink(sourcePath); + sourceArtifactsChanged = true; + } catch { + /* ignore */ + } + sourceCleanupRelevant = true; + skipped += 1; + continue; + } } catch { // File doesn't exist in target yet — proceed normally } @@ -3825,13 +3818,13 @@ export class DownloadManager extends EventEmitter { } try { - await this.moveFileWithExdevFallback(sourcePath, targetPath); - moved += 1; - sourceArtifactsChanged = true; - sourceCleanupRelevant = true; - this.logPackageForPackage(pkg, "INFO", "MKV verschoben", { - sourcePath, - targetPath, + await this.moveFileWithExdevFallback(sourcePath, targetPath); + moved += 1; + sourceArtifactsChanged = true; + sourceCleanupRelevant = true; + this.logPackageForPackage(pkg, "INFO", "MKV verschoben", { + sourcePath, + targetPath, sourceSize }); const resolved = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), targetPath); @@ -3858,11 +3851,11 @@ export class DownloadManager extends EventEmitter { } } - if ((sourceArtifactsChanged || sourceCleanupRelevant) && await this.existsAsync(sourceDir)) { - const removedResidual = await this.cleanupNonMkvResidualFiles(sourceDir, targetDir); - if (removedResidual > 0) { - logger.info(`MKV-Sammelordner entfernte Restdateien: pkg=${pkg.name}, entfernt=${removedResidual}`); - } + if ((sourceArtifactsChanged || sourceCleanupRelevant) && await this.existsAsync(sourceDir)) { + const removedResidual = await this.cleanupNonMkvResidualFiles(sourceDir, targetDir); + if (removedResidual > 0) { + logger.info(`MKV-Sammelordner entfernte Restdateien: pkg=${pkg.name}, entfernt=${removedResidual}`); + } const removedDirs = await this.removeEmptyDirectoryTree(sourceDir); if (removedDirs > 0) { logger.info(`MKV-Sammelordner entfernte leere Ordner: pkg=${pkg.name}, entfernt=${removedDirs}`); @@ -5667,17 +5660,17 @@ export class DownloadManager extends EventEmitter { ): void { const affectedItemIds = new Set(); - for (const [archiveName, errorText] of failedArchiveErrors.entries()) { - const reason = compactErrorText(errorText || fallbackReason || "Entpacken fehlgeschlagen"); - for (const entry of resolveArchiveItems(archiveName)) { - if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) { - continue; - } - entry.fullStatus = formatExtractFailureLabel(reason, archiveName); - entry.updatedAt = appliedAt; - affectedItemIds.add(entry.id); - } - } + for (const [archiveName, errorText] of failedArchiveErrors.entries()) { + const reason = compactErrorText(errorText || fallbackReason || "Entpacken fehlgeschlagen"); + for (const entry of resolveArchiveItems(archiveName)) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) { + continue; + } + entry.fullStatus = formatExtractFailureLabel(reason, archiveName); + entry.updatedAt = appliedAt; + affectedItemIds.add(entry.id); + } + } let appliedSpecificFailure = affectedItemIds.size > 0; for (const entry of completedItems) { @@ -5688,13 +5681,13 @@ export class DownloadManager extends EventEmitter { continue; } - const currentStatus = String(entry.fullStatus || "").trim(); - if (currentStatus === "Entpacken - Error") { - entry.fullStatus = formatExtractFailureLabel(fallbackReason); - entry.updatedAt = appliedAt; - appliedSpecificFailure = true; - continue; - } + const currentStatus = String(entry.fullStatus || "").trim(); + if (currentStatus === "Entpacken - Error") { + entry.fullStatus = formatExtractFailureLabel(fallbackReason); + entry.updatedAt = appliedAt; + appliedSpecificFailure = true; + continue; + } if (isTransientExtractStatus(currentStatus)) { const previousStatus = String(previousStatuses.get(entry.id) || "").trim(); @@ -5709,13 +5702,13 @@ export class DownloadManager extends EventEmitter { return; } - for (const entry of completedItems) { - if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { - entry.fullStatus = formatExtractFailureLabel(fallbackReason); - entry.updatedAt = appliedAt; - } - } - } + for (const entry of completedItems) { + if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { + entry.fullStatus = formatExtractFailureLabel(fallbackReason); + entry.updatedAt = appliedAt; + } + } + } private async waitForCompletedArchiveFilesToSettle( pkg: PackageEntry, @@ -5923,22 +5916,22 @@ export class DownloadManager extends EventEmitter { // Only mark items with active extraction progress as "abgebrochen". // Items that were just pending ("Ausstehend", "Warten auf Parts") weren't // actively being extracted, so keep their label as-is. - if (ft !== "Entpacken - Ausstehend" && ft !== "Entpacken - Warten auf Parts") { - item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; - item.updatedAt = nowMs(); - } - } - } + if (ft !== "Entpacken - Ausstehend" && ft !== "Entpacken - Warten auf Parts") { + item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; + item.updatedAt = nowMs(); + } + } + } } } - private async acquirePostProcessSlot(packageId: string): Promise { - // Honor the user-facing "Parallele Entpackungen" setting for package-level - // post-processing so multiple episodes/packages can extract concurrently. - const maxConcurrent = Math.max(1, Math.min(8, this.settings.maxParallelExtract || 1)); - if (this.packagePostProcessActive < maxConcurrent) { - this.packagePostProcessActive += 1; - return; + private async acquirePostProcessSlot(packageId: string): Promise { + // Honor the user-facing "Parallele Entpackungen" setting for package-level + // post-processing so multiple episodes/packages can extract concurrently. + const maxConcurrent = Math.max(1, Math.min(8, this.settings.maxParallelExtract || 1)); + if (this.packagePostProcessActive < maxConcurrent) { + this.packagePostProcessActive += 1; + return; } await new Promise((resolve) => { this.packagePostProcessWaiters.push({ packageId, resolve }); @@ -7870,19 +7863,19 @@ export class DownloadManager extends EventEmitter { error: errorText, abortReason: reason || "none" }); - const directLinkRetryMatch = errorText.match(/^(?:Error:\s*)?direct_link_retry_exhausted:(.+)$/); - if (directLinkRetryMatch) { - const exhaustedReason = compactErrorText(directLinkRetryMatch[1] || errorText).replace(/^Error:\s*/i, ""); - if (item.provider === "debridlink") { - await sleep(450); - if (this.tryFinalizeItemFromDisk(pkg, item, "DebridLink-Settle-Recovery", exhaustedReason)) { - return; - } - } - if (isHttp416Text(exhaustedReason) && active.genericErrorRetries < maxHttp416Retries) { - this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath); - this.persistSoon(); - this.emitState(); + const directLinkRetryMatch = errorText.match(/^(?:Error:\s*)?direct_link_retry_exhausted:(.+)$/); + if (directLinkRetryMatch) { + const exhaustedReason = compactErrorText(directLinkRetryMatch[1] || errorText).replace(/^Error:\s*/i, ""); + if (item.provider === "debridlink") { + await sleep(450); + if (this.tryFinalizeItemFromDisk(pkg, item, "DebridLink-Settle-Recovery", exhaustedReason)) { + return; + } + } + if (isHttp416Text(exhaustedReason) && active.genericErrorRetries < maxHttp416Retries) { + this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath); + this.persistSoon(); + this.emitState(); return; } if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) { @@ -8248,22 +8241,22 @@ export class DownloadManager extends EventEmitter { if (response.status === 416 && existingBytes > 0) { await response.arrayBuffer().catch(() => undefined); const rangeTotal = parseContentRangeTotal(response.headers.get("content-range")); - const expectedTotal = rangeTotal && rangeTotal > 0 - ? rangeTotal - : (knownTotal && knownTotal > 0 ? knownTotal : null); - const closeEnoughToExpected = expectedTotal != null - && Math.abs(existingBytes - expectedTotal) <= ALLOCATION_UNIT_SIZE; - if (expectedTotal != null && closeEnoughToExpected) { - const finalizedTotal = Math.max(existingBytes, expectedTotal); - item.totalBytes = finalizedTotal; + const expectedTotal = rangeTotal && rangeTotal > 0 + ? rangeTotal + : (knownTotal && knownTotal > 0 ? knownTotal : null); + const closeEnoughToExpected = expectedTotal != null + && Math.abs(existingBytes - expectedTotal) <= ALLOCATION_UNIT_SIZE; + if (expectedTotal != null && closeEnoughToExpected) { + const finalizedTotal = Math.max(existingBytes, expectedTotal); + item.totalBytes = finalizedTotal; item.downloadedBytes = existingBytes; item.progressPercent = 100; item.speedBps = 0; item.updatedAt = nowMs(); logAttemptEvent("INFO", "HTTP 416 als vollständig behandelt", { existingBytes, - expectedTotal: finalizedTotal - }); + expectedTotal: finalizedTotal + }); return { resumable: true }; } // No total available but we have substantial data - assume file is complete @@ -8394,9 +8387,9 @@ export class DownloadManager extends EventEmitter { }); } - const correctedRealDebridTotal = getAuthoritativeRealDebridTotal( - item.provider, - knownTotal || 0, + const correctedRealDebridTotal = getAuthoritativeRealDebridTotal( + item.provider, + knownTotal || 0, existingBytes, response.status, contentLength, @@ -8426,19 +8419,19 @@ export class DownloadManager extends EventEmitter { item.totalBytes = totalFromRange; } else if (contentLength > 0) { // Only add existingBytes for 206 responses; for 200 the Content-Length is the full file - item.totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength; - } - const completionPlan = planDownloadCompletion({ - existingBytes, - responseStatus: response.status, - contentLength, - totalFromRange, - knownTotal, - correctedTotal: correctedRealDebridTotal?.totalBytes || null - }); - - const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w"; - logAttemptEvent("INFO", "HTTP-Antwort akzeptiert", { + item.totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength; + } + const completionPlan = planDownloadCompletion({ + existingBytes, + responseStatus: response.status, + contentLength, + totalFromRange, + knownTotal, + correctedTotal: correctedRealDebridTotal?.totalBytes || null + }); + + const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w"; + logAttemptEvent("INFO", "HTTP-Antwort akzeptiert", { attempt, status: response.status, acceptRanges, @@ -8791,14 +8784,14 @@ export class DownloadManager extends EventEmitter { // All expected bytes received — break immediately instead of waiting // for the server to close the connection. Some servers/CDNs delay // the FIN packet, which would trigger the stall timeout even though - // the file is already complete. This especially affects small - // multi-part archives (e.g. 15-20 × 101 MB) on fast connections. - // Use totalBytes (from unrestrict or Content-Length header) as - // primary check, fall back to raw contentLength for providers - // that don't report fileSize (e.g. Mega-Debrid Web). - if (completionPlan.canFinishEarly && completionPlan.expectedTotal && written >= completionPlan.expectedTotal) { - break; - } + // the file is already complete. This especially affects small + // multi-part archives (e.g. 15-20 × 101 MB) on fast connections. + // Use totalBytes (from unrestrict or Content-Length header) as + // primary check, fall back to raw contentLength for providers + // that don't report fileSize (e.g. Mega-Debrid Web). + if (completionPlan.canFinishEarly && completionPlan.expectedTotal && written >= completionPlan.expectedTotal) { + break; + } const throughputNow = nowMs(); if (lowThroughputTimeoutMs > 0 && throughputNow - throughputWindowStartedAt >= lowThroughputTimeoutMs) { @@ -8914,9 +8907,9 @@ export class DownloadManager extends EventEmitter { logger.warn(`Stream-Abschlussfehler unterdrückt: ${compactErrorText(streamCloseError)}`); } // Ensure stream is fully destroyed before potential retry opens new handle - if (!stream.destroyed) { - stream.destroy(); - } + if (!stream.destroyed) { + stream.destroy(); + } // fsync for pre-allocated files: force OS to flush all pending writes to // disk so extraction processes opening the file immediately after download // see the complete data (prevents "Checksum error" on Windows when file @@ -8933,27 +8926,27 @@ export class DownloadManager extends EventEmitter { } // If the body read succeeded but the final flush or stream close failed, // propagate the error so the download is retried instead of marked complete. - if (bodyError) { - throw bodyError; - } - } - - try { - const finalizedStat = await fs.promises.stat(effectiveTargetPath); - if (Number.isFinite(finalizedStat.size) && finalizedStat.size >= 0 && finalizedStat.size !== written) { - logAttemptEvent("WARN", "Dateigroesse nach Stream-Abschluss korrigiert", { - attempt, - previousWritten: written, - statSize: finalizedStat.size - }); - written = finalizedStat.size; - } - } catch { - // ignore stat race; validation below will handle empty/missing files - } - - // Detect tiny error-response files (e.g. hoster returning "Forbidden" with HTTP 200). - // No legitimate file-hoster download is < 512 bytes. + if (bodyError) { + throw bodyError; + } + } + + try { + const finalizedStat = await fs.promises.stat(effectiveTargetPath); + if (Number.isFinite(finalizedStat.size) && finalizedStat.size >= 0 && finalizedStat.size !== written) { + logAttemptEvent("WARN", "Dateigroesse nach Stream-Abschluss korrigiert", { + attempt, + previousWritten: written, + statSize: finalizedStat.size + }); + written = finalizedStat.size; + } + } catch { + // ignore stat race; validation below will handle empty/missing files + } + + // Detect tiny error-response files (e.g. hoster returning "Forbidden" with HTTP 200). + // No legitimate file-hoster download is < 512 bytes. if (written > 0 && written < 512) { let snippet = ""; try { @@ -8979,55 +8972,55 @@ export class DownloadManager extends EventEmitter { item.downloadedBytes = 0; item.progressPercent = 0; throw new Error(`Download zu klein (${written} B) – Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`); - } - } - - const completionValidation = validateDownloadedFileCompletion({ - actualBytes: written, - plan: completionPlan - }); - if (!completionValidation.ok) { - const shortfall = Math.max(0, completionValidation.totalBytes - written); - if (preAllocated) { - try { - await fs.promises.truncate(effectiveTargetPath, written); - } catch { /* best-effort */ } - } - logger.warn(`Download-Underflow: erwartet=${completionValidation.totalBytes}, erhalten=${written}, shortfall=${shortfall} fuer ${item.fileName}`); - item.downloadedBytes = written; - item.progressPercent = completionValidation.totalBytes > 0 - ? Math.max(0, Math.min(99, Math.floor((written / completionValidation.totalBytes) * 100))) - : 0; - item.speedBps = 0; - throw new Error(completionValidation.error || `download_underflow:${written}/${completionValidation.totalBytes}`); - } - - if (completionValidation.acceptedMetadataMismatch) { - logger.warn( - `Provider-Groesseninfo verworfen, HTTP-EOF als vollstaendig akzeptiert: ` + - `${item.fileName} erwartet=${completionPlan.expectedTotal}, erhalten=${written}` - ); - logAttemptEvent("WARN", "Provider-Groesseninfo weicht von finaler Dateigroesse ab", { - attempt, - expectedTotal: completionPlan.expectedTotal, - actualBytes: written, - source: completionPlan.source - }); - } - - // Truncate pre-allocated files to actual bytes written to prevent zero-padded tail + } + } + + const completionValidation = validateDownloadedFileCompletion({ + actualBytes: written, + plan: completionPlan + }); + if (!completionValidation.ok) { + const shortfall = Math.max(0, completionValidation.totalBytes - written); + if (preAllocated) { + try { + await fs.promises.truncate(effectiveTargetPath, written); + } catch { /* best-effort */ } + } + logger.warn(`Download-Underflow: erwartet=${completionValidation.totalBytes}, erhalten=${written}, shortfall=${shortfall} fuer ${item.fileName}`); + item.downloadedBytes = written; + item.progressPercent = completionValidation.totalBytes > 0 + ? Math.max(0, Math.min(99, Math.floor((written / completionValidation.totalBytes) * 100))) + : 0; + item.speedBps = 0; + throw new Error(completionValidation.error || `download_underflow:${written}/${completionValidation.totalBytes}`); + } + + if (completionValidation.acceptedMetadataMismatch) { + logger.warn( + `Provider-Groesseninfo verworfen, HTTP-EOF als vollstaendig akzeptiert: ` + + `${item.fileName} erwartet=${completionPlan.expectedTotal}, erhalten=${written}` + ); + logAttemptEvent("WARN", "Provider-Groesseninfo weicht von finaler Dateigroesse ab", { + attempt, + expectedTotal: completionPlan.expectedTotal, + actualBytes: written, + source: completionPlan.source + }); + } + + // Truncate pre-allocated files to actual bytes written to prevent zero-padded tail if (preAllocated && item.totalBytes && written < item.totalBytes) { try { await fs.promises.truncate(effectiveTargetPath, written); } catch { /* best-effort */ } logger.warn(`Pre-alloc underflow: erwartet=${item.totalBytes}, erhalten=${written} für ${item.fileName}`); - } - - item.downloadedBytes = written; - item.totalBytes = completionValidation.totalBytes > 0 ? completionValidation.totalBytes : item.totalBytes; - item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100; - item.speedBps = 0; - item.fullStatus = "Finalisierend..."; + } + + item.downloadedBytes = written; + item.totalBytes = completionValidation.totalBytes > 0 ? completionValidation.totalBytes : item.totalBytes; + item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100; + item.speedBps = 0; + item.fullStatus = "Finalisierend..."; item.updatedAt = nowMs(); this.emitState(); logAttemptEvent("INFO", "HTTP-Download-Versuch abgeschlossen", { @@ -10442,25 +10435,25 @@ export class DownloadManager extends EventEmitter { return; } - if (result.failed > 0) { - const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen"); - const failAt = nowMs(); - if (fullFailedArchiveErrors.size > 0) { - const archiveSummaries = [...fullFailedArchiveErrors.entries()] - .slice(0, 3) - .map(([archiveName, errorText]) => `${archiveName}: ${summarizeExtractFailureReason(errorText)}`) - .join(" | "); - logger.warn(`Post-Processing Entpacken Fehlerdetails: pkg=${pkg.name}, archives=${archiveSummaries}`); - this.logPackageForPackage(pkg, "WARN", "Post-Processing Entpacken Fehlerdetails", { - failedArchives: [...fullFailedArchiveErrors.keys()], - summary: archiveSummaries - }); - } - this.applyPackageExtractFailureStatuses( - completedItems, - resolveArchiveItems, - fullFailedArchiveErrors, - reason, + if (result.failed > 0) { + const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen"); + const failAt = nowMs(); + if (fullFailedArchiveErrors.size > 0) { + const archiveSummaries = [...fullFailedArchiveErrors.entries()] + .slice(0, 3) + .map(([archiveName, errorText]) => `${archiveName}: ${summarizeExtractFailureReason(errorText)}`) + .join(" | "); + logger.warn(`Post-Processing Entpacken Fehlerdetails: pkg=${pkg.name}, archives=${archiveSummaries}`); + this.logPackageForPackage(pkg, "WARN", "Post-Processing Entpacken Fehlerdetails", { + failedArchives: [...fullFailedArchiveErrors.keys()], + summary: archiveSummaries + }); + } + this.applyPackageExtractFailureStatuses( + completedItems, + resolveArchiveItems, + fullFailedArchiveErrors, + reason, preExtractStatuses, failAt ); @@ -10494,15 +10487,15 @@ export class DownloadManager extends EventEmitter { const isExtractAbort = reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout"); let timeoutHandled = false; if (isExtractAbort) { - if (timedOut) { - const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`; - logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`); - for (const entry of completedItems) { - if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { - entry.fullStatus = formatExtractFailureLabel(timeoutReason); - entry.updatedAt = nowMs(); - } - } + if (timedOut) { + const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`; + logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`); + for (const entry of completedItems) { + if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { + entry.fullStatus = formatExtractFailureLabel(timeoutReason); + entry.updatedAt = nowMs(); + } + } pkg.status = "failed"; pkg.updatedAt = nowMs(); timeoutHandled = true; @@ -10519,15 +10512,15 @@ export class DownloadManager extends EventEmitter { return; } } - if (!timeoutHandled) { - const reason = compactErrorText(error); - logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); - for (const entry of completedItems) { - if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { - entry.fullStatus = formatExtractFailureLabel(reason); - entry.updatedAt = nowMs(); - } - } + if (!timeoutHandled) { + const reason = compactErrorText(error); + logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); + for (const entry of completedItems) { + if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { + entry.fullStatus = formatExtractFailureLabel(reason); + entry.updatedAt = nowMs(); + } + } pkg.status = "failed"; } } finally { @@ -10626,6 +10619,7 @@ export class DownloadManager extends EventEmitter { removeLinks: false, removeSamples: false, passwordList: this.settings.archivePasswordList, + signal: deferredController.signal, packageId, onlyArchives: new Set(nestedCandidates.map((p) => process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p))), maxParallel: this.settings.maxParallelExtract || 2, @@ -10686,12 +10680,12 @@ export class DownloadManager extends EventEmitter { } // ── Link/Sample artifact removal ── - if (extractedCount > 0 || alreadyMarkedExtracted) { - throwIfAborted(); - if (this.settings.removeLinkFilesAfterExtract) { - const removedLinks = await removeDownloadLinkArtifacts(pkg.extractDir, { shouldAbort }); - if (removedLinks > 0) { - logger.info(`Deferred Link-Cleanup: pkg=${pkg.name}, entfernt=${removedLinks}`); + if (extractedCount > 0 || alreadyMarkedExtracted) { + throwIfAborted(); + if (this.settings.removeLinkFilesAfterExtract) { + const removedLinks = await removeDownloadLinkArtifacts(pkg.extractDir, { shouldAbort }); + if (removedLinks > 0) { + logger.info(`Deferred Link-Cleanup: pkg=${pkg.name}, entfernt=${removedLinks}`); } } if (this.settings.removeSamplesAfterExtract) {