From 7dc12aca0ca206c95437e96ebb67f0b6a32ba203 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Tue, 3 Mar 2026 00:07:12 +0100 Subject: [PATCH] Fix disk-backpressure stalls and improve episode-token parsing --- src/main/download-manager.ts | 67 +++++++++++++++++++++++++++++----- tests/auto-rename.test.ts | 64 ++++++++++++++++++++++++++++++++ tests/download-manager.test.ts | 64 ++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 9 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 8490aff..5b02cda 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -38,6 +38,8 @@ type ActiveTask = { stallRetries?: number; genericErrorRetries?: number; unrestrictRetries?: number; + blockedOnDiskWrite?: boolean; + blockedOnDiskSince?: number; }; const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 10000; @@ -324,7 +326,7 @@ function toWindowsLongPathIfNeeded(filePath: string): string { const SCENE_RELEASE_FOLDER_RE = /-(?:4sf|4sj)$/i; const SCENE_GROUP_SUFFIX_RE = /-(?=[A-Za-z0-9]{2,}$)(?=[A-Za-z0-9]*[A-Z])[A-Za-z0-9]+$/; -const SCENE_EPISODE_RE = /(?:^|[._\-\s])s(\d{1,2})e(\d{1,3})(?:[._\-\s]|$)/i; +const SCENE_EPISODE_RE = /(?:^|[._\-\s])s(\d{1,2})e(\d{1,3})(?:e(\d{1,3}))?(?:[._\-\s]|$)/i; const SCENE_SEASON_ONLY_RE = /(^|[._\-\s])s\d{1,2}(?=[._\-\s]|$)/i; const SCENE_SEASON_CAPTURE_RE = /(?:^|[._\-\s])s(\d{1,2})(?=[._\-\s]|$)/i; const SCENE_EPISODE_ONLY_RE = /(?:^|[._\-\s])e(?:p(?:isode)?)?\s*0*(\d{1,3})(?:[._\-\s]|$)/i; @@ -395,7 +397,14 @@ export function extractEpisodeToken(fileName: string): string | null { return null; } - return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; + let token = `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; + if (match[3]) { + const episode2 = Number(match[3]); + if (Number.isFinite(episode2) && episode2 > 0) { + token += `E${String(episode2).padStart(2, "0")}`; + } + } + return token; } function extractSeasonToken(fileName: string): string | null { @@ -542,7 +551,7 @@ export function applyEpisodeTokenToFolderName(folderName: string, episodeToken: return episodeToken; } - const episodeRe = /(^|[._\-\s])s\d{1,2}e\d{1,3}(?=[._\-\s]|$)/i; + const episodeRe = /(^|[._\-\s])s\d{1,2}e\d{1,3}(?:e\d{1,3})?(?=[._\-\s]|$)/i; if (episodeRe.test(trimmed)) { return trimmed.replace(episodeRe, `$1${episodeToken}`); } @@ -3316,10 +3325,15 @@ export class DownloadManager extends EventEmitter { } let stalledCount = 0; + let diskBlockedCount = 0; for (const active of this.activeTasks.values()) { if (active.abortController.signal.aborted) { continue; } + if (active.blockedOnDiskWrite) { + diskBlockedCount += 1; + continue; + } const item = this.session.items[active.itemId]; if (item && (item.status === "downloading" || item.status === "validating")) { stalledCount += 1; @@ -3330,11 +3344,14 @@ export class DownloadManager extends EventEmitter { return; } - logger.warn(`Globaler Download-Stall erkannt (${Math.floor((now - this.lastGlobalProgressAt) / 1000)}s ohne Fortschritt), ${stalledCount} Task(s) neu starten`); + logger.warn(`Globaler Download-Stall erkannt (${Math.floor((now - this.lastGlobalProgressAt) / 1000)}s ohne Fortschritt), ${stalledCount} Task(s) neu starten, diskBlocked=${diskBlockedCount}`); for (const active of this.activeTasks.values()) { if (active.abortController.signal.aborted) { continue; } + if (active.blockedOnDiskWrite) { + continue; + } const item = this.session.items[active.itemId]; if (item && (item.status === "downloading" || item.status === "validating")) { active.abortReason = "stall"; @@ -3552,7 +3569,9 @@ export class DownloadManager extends EventEmitter { abortController: new AbortController(), abortReason: "none", resumable: true, - nonResumableCounted: false + nonResumableCounted: false, + blockedOnDiskWrite: false, + blockedOnDiskSince: 0 }; this.activeTasks.set(itemId, active); this.emitState(); @@ -4253,7 +4272,8 @@ export class DownloadManager extends EventEmitter { : 170; let lastUiEmitAt = 0; const stallTimeoutMs = getDownloadStallTimeoutMs(); - const drainTimeoutMs = Math.max(4000, Math.min(45000, stallTimeoutMs > 0 ? stallTimeoutMs : 15000)); + const drainTimeoutMs = Math.max(30000, Math.min(300000, stallTimeoutMs > 0 ? stallTimeoutMs * 12 : 120000)); + let lastDiskBusyEmitAt = 0; const waitDrain = (): Promise => new Promise((resolve, reject) => { if (active.abortController.signal.aborted) { @@ -4261,15 +4281,27 @@ export class DownloadManager extends EventEmitter { return; } + active.blockedOnDiskWrite = true; + active.blockedOnDiskSince = nowMs(); + if (item.status !== "paused" && !this.session.paused) { + const nowTick = nowMs(); + if (nowTick - lastDiskBusyEmitAt >= 1200) { + item.status = "downloading"; + item.speedBps = 0; + item.fullStatus = `Warte auf Festplatte (${providerLabel(item.provider)})`; + item.updatedAt = nowTick; + this.emitState(); + lastDiskBusyEmitAt = nowTick; + } + } + 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); + cleanup(); // Do NOT abort the controller here – drain timeout means disk is slow, // not network stall. Rejecting without abort lets the inner retry loop // handle it (resume download) instead of escalating to processItem's @@ -4282,6 +4314,8 @@ export class DownloadManager extends EventEmitter { clearTimeout(timeoutId); timeoutId = null; } + active.blockedOnDiskWrite = false; + active.blockedOnDiskSince = 0; stream.off("drain", onDrain); stream.off("error", onError); active.abortController.signal.removeEventListener("abort", onAbort); @@ -4336,6 +4370,21 @@ export class DownloadManager extends EventEmitter { return; } const nowTick = nowMs(); + if (active.blockedOnDiskWrite) { + if (item.status === "paused" || this.session.paused) { + return; + } + if (nowTick - lastIdleEmitAt >= idlePulseMs) { + item.status = "downloading"; + item.speedBps = 0; + item.fullStatus = `Warte auf Festplatte (${providerLabel(item.provider)})`; + item.updatedAt = nowTick; + this.emitState(); + lastIdleEmitAt = nowTick; + lastDiskBusyEmitAt = nowTick; + } + return; + } if (nowTick - lastDataAt < idlePulseMs) { return; } diff --git a/tests/auto-rename.test.ts b/tests/auto-rename.test.ts index 86d878d..c49f247 100644 --- a/tests/auto-rename.test.ts +++ b/tests/auto-rename.test.ts @@ -62,6 +62,22 @@ describe("extractEpisodeToken", () => { it("extracts from episode token at end of string", () => { expect(extractEpisodeToken("show.s02e03")).toBe("S02E03"); }); + + it("extracts double episode token s01e01e02", () => { + expect(extractEpisodeToken("tvr-mammon-s01e01e02-720p")).toBe("S01E01E02"); + }); + + it("extracts double episode with dot separators", () => { + expect(extractEpisodeToken("Show.S01E03E04.720p")).toBe("S01E03E04"); + }); + + it("extracts double episode at end of string", () => { + expect(extractEpisodeToken("show.s02e05e06")).toBe("S02E05E06"); + }); + + it("extracts double episode with single-digit numbers", () => { + expect(extractEpisodeToken("show-s1e1e2-720p")).toBe("S01E01E02"); + }); }); describe("applyEpisodeTokenToFolderName", () => { @@ -96,6 +112,21 @@ describe("applyEpisodeTokenToFolderName", () => { it("is case-insensitive for -4SF/-4SJ suffix", () => { expect(applyEpisodeTokenToFolderName("Show.720p-4SF", "S01E01")).toBe("Show.720p.S01E01-4SF"); }); + + it("applies double episode token to season-only folder", () => { + expect(applyEpisodeTokenToFolderName("Mammon.S01.German.1080P.Bluray.x264-SMAHD", "S01E01E02")) + .toBe("Mammon.S01E01E02.German.1080P.Bluray.x264-SMAHD"); + }); + + it("replaces existing double episode in folder with new token", () => { + expect(applyEpisodeTokenToFolderName("Show.S01E01E02.720p-4sf", "S01E03E04")) + .toBe("Show.S01E03E04.720p-4sf"); + }); + + it("replaces existing single episode in folder with double episode token", () => { + expect(applyEpisodeTokenToFolderName("Show.S01E01.720p-4sf", "S01E01E02")) + .toBe("Show.S01E01E02.720p-4sf"); + }); }); describe("sourceHasRpToken", () => { @@ -556,4 +587,37 @@ describe("buildAutoRenameBaseNameFromFolders", () => { ); expect(result).toBe("Cheat.der.Betrug.S01E01.GERMAN.720p.WEB.h264-tmsf"); }); + + it("renames double episode file into season folder (Mammon style)", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Mammon.S01.German.1080P.Bluray.x264-SMAHD" + ], + "tvr-mammon-s01e01e02-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Mammon.S01E01E02.German.1080P.Bluray.x264-SMAHD"); + }); + + it("renames second double episode file correctly", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Mammon.S01.German.1080P.Bluray.x264-SMAHD" + ], + "tvr-mammon-s01e03e04-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Mammon.S01E03E04.German.1080P.Bluray.x264-SMAHD"); + }); + + it("renames third double episode file correctly", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Mammon.S01.German.1080P.Bluray.x264-SMAHD" + ], + "tvr-mammon-s01e05e06-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Mammon.S01E05E06.German.1080P.Bluray.x264-SMAHD"); + }); }); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 1ff4013..cfb45f7 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -880,7 +880,14 @@ describe("download manager", () => { ); manager.addPackages([{ name: "drain-stall", links: ["https://dummy/drain-stall"] }]); + const queuedSnapshot = manager.getSnapshot(); + const packageId = queuedSnapshot.session.packageOrder[0] || ""; + const itemId = queuedSnapshot.session.packages[packageId]?.itemIds[0] || ""; manager.start(); + await waitFor(() => { + const status = manager.getSnapshot().session.items[itemId]?.fullStatus || ""; + return status.includes("Warte auf Festplatte"); + }, 12000); await waitFor(() => !manager.getSnapshot().session.running, 40000); const item = Object.values(manager.getSnapshot().session.items)[0]; @@ -4329,6 +4336,63 @@ describe("download manager", () => { expect(internal.speedBytesLastWindow).toBe(0); }); + it("does not trigger global stall abort while write-buffer is disk-blocked", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const previousGlobalWatchdog = process.env.RD_GLOBAL_STALL_TIMEOUT_MS; + process.env.RD_GLOBAL_STALL_TIMEOUT_MS = "2500"; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "disk-block-guard", links: ["https://dummy/disk-block-guard"] }]); + const snapshot = manager.getSnapshot(); + const packageId = snapshot.session.packageOrder[0] || ""; + const itemId = snapshot.session.packages[packageId]?.itemIds[0] || ""; + + const internal = manager as unknown as any; + internal.session.running = true; + internal.session.paused = false; + internal.session.reconnectUntil = 0; + internal.session.totalDownloadedBytes = 0; + internal.session.items[itemId].status = "downloading"; + internal.lastGlobalProgressBytes = 0; + internal.lastGlobalProgressAt = Date.now() - 10000; + + const abortController = new AbortController(); + internal.activeTasks.set(itemId, { + itemId, + packageId, + abortController, + abortReason: "none", + resumable: true, + nonResumableCounted: false, + blockedOnDiskWrite: true, + blockedOnDiskSince: Date.now() - 5000 + }); + + internal.runGlobalStallWatchdog(Date.now()); + + expect(abortController.signal.aborted).toBe(false); + expect(internal.lastGlobalProgressAt).toBeGreaterThan(Date.now() - 2000); + } finally { + if (previousGlobalWatchdog === undefined) { + delete process.env.RD_GLOBAL_STALL_TIMEOUT_MS; + } else { + process.env.RD_GLOBAL_STALL_TIMEOUT_MS = previousGlobalWatchdog; + } + } + }); + it("cleans run tracking when start conflict is skipped", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);