diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 853a57c..c34ae74 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -3915,20 +3915,28 @@ export class DownloadManager extends EventEmitter { private async autoRenameExtractedVideoFiles( extractDir: string, pkg?: PackageEntry, - shouldAbort?: () => boolean + shouldAbort?: () => boolean, + treatFilesAsStable = false ): Promise { if (!pkg) { - return this.autoRenameExtractedVideoFilesImpl(extractDir, undefined, shouldAbort); + return this.autoRenameExtractedVideoFilesImpl(extractDir, undefined, shouldAbort, treatFilesAsStable); } return this.chainPackageFileOp(pkg.id, () => - this.autoRenameExtractedVideoFilesImpl(extractDir, pkg, shouldAbort) + this.autoRenameExtractedVideoFilesImpl(extractDir, pkg, shouldAbort, treatFilesAsStable) ); } private async autoRenameExtractedVideoFilesImpl( extractDir: string, pkg?: PackageEntry, - shouldAbort?: () => boolean + shouldAbort?: () => boolean, + // Im finalen Deferred-Pass ist die Extraktion abgeschlossen (awaited) — es gibt + // keinen concurrent Extractor-Write mehr. Der Frische-Gate (unten) ist dort ein + // False Positive: er wuerde eine eben extrahierte (noch "frische") Datei vom + // Rename ausschliessen, woraufhin der nachgelagerte Collect (deferFreshFiles=false) + // sie mit Original-Scene-Namen in die Library moved. treatFilesAsStable=true + // umgeht den Gate, sodass der Final-Pass garantiert ALLE Dateien umbenennt. + treatFilesAsStable = false ): Promise { if (!this.settings.autoRename4sf4sj) { return 0; @@ -4023,7 +4031,7 @@ export class DownloadManager extends EventEmitter { // Negative age = mtime in the future (clock skew, NTP correction, // VM resume after suspension). Treat as "definitely stable" so the // file doesn't get stuck waiting for the wall clock to catch up. - if (ageMs >= 0 && ageMs < FILE_STABILIZE_MIN_AGE_MS) { + if (!treatFilesAsStable && ageMs >= 0 && ageMs < FILE_STABILIZE_MIN_AGE_MS) { logger.info(`Auto-Rename: ${sourceName} uebersprungen — Datei noch frisch (${Math.floor(ageMs)}ms), wird beim naechsten Scan behandelt`); continue; } @@ -12114,7 +12122,12 @@ export class DownloadManager extends EventEmitter { extractDir: pkg.extractDir }); throwIfAborted(); - await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg, shouldAbort); + // treatFilesAsStable=true: Final-Pass — die Extraktion (inkl. Nested oben) ist + // abgeschlossen/awaited, es gibt keinen concurrent Extractor-Write mehr. Ohne + // diesen Gate-Bypass wuerde eine eben extrahierte, noch frische (< 2s) Datei vom + // Rename uebersprungen und vom nachfolgenden Collect (deferFreshFiles=false) mit + // Original-Scene-Namen in die Library gemoved (1-2 unbenannte Dateien pro Staffel). + await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg, shouldAbort, true); } // ── Archive cleanup (source archives in outputDir) ── diff --git a/tasks/todo.md b/tasks/todo.md index f1e63cc..c536117 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -68,3 +68,25 @@ App läuft headless auf Windows-Server → Nutzer sitzt nicht davor. Größte L - ⏭️ **M3** (`cancelPendingAsyncSaves` wartet nicht auf laufenden Save) — Report stuft selbst als reines I/O-Overlap ein; die Generation-Guard (storage.ts:1022) schützt die Datenintegrität bereits (stale Write wird verworfen). Kein Korrektheitsgewinn, daher kein Eingriff. **Verifikation:** 30 Test-Dateien, 621 Tests grün. Build sauber. Advisor-Review vor Implementierung (fing H2-Falle: Hybrid-Controller nicht in die Deferred-Map legen, sonst killt `runDeferredPostExtraction` sie selbst). + +--- + +## D. DEFERRED-PFAD RENAME-GAP (2026-05-28, Opus-Verifikation von 18eada9) + +**Kontext:** Eine abgestürzte Session (API 400 thinking-blocks) hinterließ ein uncommittetes Working-Tree, das **drei** releaste Commits revertierte (08372f9 Passwort + 18eada9 Hybrid-Rename + 98dc366 Support-Bundle, zurück auf v1.7.159). Kein dokumentierter Intent → als Crash-Debris bewertet, non-destruktiv **gestasht** (`git stash` — recoverable), HEAD/v1.7.162 wiederhergestellt. + +**Verifizierter Fund (Folge-Bug zu 18eada9):** +- 18eada9 schloss den "frische Datei landet unbenannt"-Bug nur für den **Hybrid-Pfad** (`deferFreshFiles=true` + Mehrfach-Pässe). +- Der **finale Deferred-Pass** (`runDeferredPostExtraction`) macht Rename (12125) → Collect (12156, `deferFreshFiles=false`). Ist eine Datei beim Deferred-Rename noch frisch (< `fileStabilizeMinAgeMs`, prod=2000ms) — v.a. eine eben per **Nested-Extraction** (12045, unmittelbar davor) geschriebene Datei — überspringt der Frische-Gate sie, und der Collect moved sie mit **Original-Scene-Namen** in die Library. `collectMkvFilesToLibrary` benennt selbst nicht um (Move-Body: `buildUniqueFlattenTargetPath`, nur Flatten). +- Pre-existierender Gap (Frische-Skip-Block älter als 18eada9); auch HEAD/v1.7.162 betroffen. + +**Gate (TDD, vor Fix):** neuer Regressionstest "deferred final pass renames fresh files before collecting them" → reproduzierte den Bug zuverlässig gegen HEAD (Datei landete unbenannt). + +**Fix (minimal, Root-Cause):** `treatFilesAsStable`-Param durch `autoRenameExtractedVideoFiles(Impl)`. Im Deferred-**Final**-Pass (kein concurrent Extractor-Write mehr, Extraktion awaited) wird der Frische-Gate umgangen → alle Dateien werden umbenannt, bevor der Collect sie sammelt. Hybrid-Pfad unangetastet (nutzt `...Impl` mit Default `false` → Frische-Skip bleibt aktiv, schützt weiter vor Rename mitten in concurrent Write). + +**Verifikation:** neuer Test grün, Hybrid-Test grün (kein Regress), **623 Tests grün** (31 Dateien), tsc unverändert (9 pre-existing). Advisor-Gate vor Fix (verlangte Repro-Test statt Timing-Argument). + +**Offen / bewusst nicht angefasst:** +- Gestashtes Crash-Debris (`stash@{0}`): enthält Revert von 08372f9/18eada9/98dc366 + log.old. Bei Bedarf inspizierbar/recoverbar; sonst irgendwann verwerfbar. +- 08372f9 (Passwort-Daemon-Reset) bewusst nicht neu aufgerollt (außerhalb dieses Goals, kein Hinweis auf Defekt). +- Untracked `*-postprocess/` + `fix-library-renames.mjs`: alte Experimente (Apr/Mai), unverändert gelassen. diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 1ea40a0..2d98bbd 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -9429,6 +9429,87 @@ describe("download manager", () => { void manager; }, 20000); + it("deferred final pass renames fresh files before collecting them (no scene names in library)", async () => { + // Folge-Fund zu 18eada9 (verifiziert via Advisor-Gate): 18eada9 schloss den + // "frische Datei landet unbenannt"-Bug nur fuer den HYBRID-Pfad (deferFreshFiles=true + // + Mehrfach-Pässe). Der finale Deferred-Pass (runDeferredPostExtraction) macht + // Rename (treatFilesAsStable? nein) -> Collect (deferFreshFiles=false). Ist eine + // Datei beim Deferred-Rename noch "frisch" (< fileStabilizeMinAgeMs) — z.B. eine + // gerade per Nested-Extraction (12045) geschriebene Datei — ueberspringt der + // Frische-Gate sie, und der Collect moved sie mit Original-Scene-Namen in die + // Library. Im Deferred-FINAL-Pass laeuft aber KEIN concurrent Extractor mehr + // (Extraktion abgeschlossen/awaited), der Frische-Gate ist dort ein False + // Positive. Fix: der Final-Pass-Rename behandelt alle Dateien als stabil + // (treatFilesAsStable=true) → benennt um, bevor der Collect sie sammelt. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + // Episoden-Ordner liefert den kanonischen Zielnamen (enthaelt SxxExx). + const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"); + fs.mkdirSync(epFolder, { recursive: true }); + + const sceneName = "awa-testshow02e05hd.mkv"; + const scenePath = path.join(epFolder, sceneName); + fs.writeFileSync(scenePath, Buffer.alloc(4096, 5)); // mtime = jetzt → "frisch" + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + // Produktion: fileStabilizeMinAgeMs=2000. Hier 30s, damit die gerade erstellte + // Datei garantiert als "frisch" gilt — wie eine eben extrahierte Datei, die der + // Deferred-Pass sofort danach verarbeitet. + (manager as any).fileStabilizeMinAgeMs = 30_000; + + const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; + const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`); + const sceneLibPath = path.join(mkvLibraryDir, sceneName); + + // Deferred-FINAL-Pass-Sequenz, exakt wie runDeferredPostExtraction: + // 1) Rename — treatFilesAsStable=true (Extraktion abgeschlossen, kein Frische-Skip) + // 2) Collect — deferFreshFiles=false + await (manager as any).autoRenameExtractedVideoFiles(extractDir, session.packages[packageId], undefined, true); + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); + + // Die Datei landet UMBENANNT in der Library — nicht mit dem Scene-Namen. + expect(fs.existsSync(renamedLibPath)).toBe(true); + expect(fs.existsSync(sceneLibPath)).toBe(false); + + void manager; + }, 20000); + it("moves direct MKV download from outputDir to library when no archive present (Mega-Debrid flow)", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);