From 18eada963f86d20e363df6074525bfbf32ba69ad Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Thu, 28 May 2026 17:44:19 +0200 Subject: [PATCH] =?UTF-8?q?Fix:=20Hybrid-Rename-Race=20=E2=80=94=201-2=20D?= =?UTF-8?q?ateien=20pro=20Staffel=20blieben=20unbenannt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Report (verifiziert via Support-Bundle): pl3x-24hours.s01e07, tmsf-burnnotice-s05e11-repack, -s05e15 landeten mit Original-Scene-Namen in der Library statt umbenannt. Andere Episoden derselben Pakete (formatidentisch) wurden korrekt umbenannt → kein Format-Problem, sondern Timing-Race. Root Cause (aus Log-Timeline): 1. autoRenameExtractedVideoFilesImpl erfasste `now` EINMAL am Scan-Start. Bei Hybrid-Extraktion werden weitere Dateien WÄHREND des Scans geschrieben → deren mtime > now → negatives ageMs → der "Clock-Skew = stabil"-Zweig wertete sie faelschlich als stabil → Rename mitten im Extractor-Write → EBUSY → 200ms- Retry deferred. 2. Der MKV-Collect hatte KEINEN Frische-Skip und moved die Datei im Retry-Fenster mit Original-Namen, bevor der Rename-Retry feuerte. 3. Rename + Collect liefen als zwei separate chainPackageFileOp-Ketten → ueberlappende Hybrid-Runden konnten einen Collect zwischen Rename und Collect einer anderen Runde einschieben. Fix (3 Teile, scoped auf extractDir des Pakets — kein Shared-Library-Scan, nicht das v1.7.107-Antipattern): 1. `now` wird PRO DATEI erfasst → frisch-geschriebene Dateien korrekt als "frisch" erkannt und deferred (statt EBUSY-Rename mitten im Write). 2. collectMkvFilesToLibrary bekommt deferFreshFiles-Param: im Hybrid-Pfad werden frische Dateien (juenger als fileStabilizeMinAgeMs) uebersprungen statt unbenannt gemoved. Der finale Deferred-Pass (deferFreshFiles=false) sammelt sie nach Stabilisierung ein (Safety-Net). 3. Hybrid-Pfad: Rename (Impl-Variante, kein Self-Chain) + Collect in EINER chainPackageFileOp-Kette → atomar, kein Interleaving ueberlappender Runden. Deferred-Pfad unangetastet (dort keine concurrent Extraktion). Regressionstest: frische Datei wird im Hybrid-Collect deferred, vom finalen Pass gesammelt. 622 Tests gruen, tsc-Fehlerzahl unveraendert (9 pre-existing). Co-Authored-By: Claude Opus 4.7 --- src/main/download-manager.ts | 47 +++++++++++++++++----- tests/download-manager.test.ts | 71 ++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 09c1b3f..853a57c 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -3995,7 +3995,6 @@ export class DownloadManager extends EventEmitter { // they've stabilized (hybrid-extract fires a new rename scan after every // archive completes, so nothing gets missed). const FILE_STABILIZE_MIN_AGE_MS = this.fileStabilizeMinAgeMs; - const now = Date.now(); for (const sourcePath of videoFiles) { if (shouldAbort?.()) { return renamed; @@ -4013,6 +4012,13 @@ export class DownloadManager extends EventEmitter { } catch { continue; } + // now PER FILE erfassen (nicht einmal am Scan-Start): bei Hybrid-Extraktion + // werden weitere Dateien WÄHREND dieses Scans geschrieben. Ein am Scan-Start + // erfasstes now waere fuer solche Dateien aelter als ihre mtime → negatives + // ageMs → der Clock-Skew-Zweig unten wuerde sie faelschlich als "stabil" + // werten und einen Rename mitten im Extractor-Write ausloesen (EBUSY → + // deferred → der Collect moved die Datei mit Original-Namen, statt umbenannt). + const now = Date.now(); const ageMs = now - sourceStat.mtimeMs; // Negative age = mtime in the future (clock skew, NTP correction, // VM resume after suspension). Treat as "definitely stable" so the @@ -4655,7 +4661,8 @@ export class DownloadManager extends EventEmitter { private async collectMkvFilesToLibrary( packageId: string, pkg: PackageEntry, - shouldAbort?: () => boolean + shouldAbort?: () => boolean, + deferFreshFiles = false ): Promise { if (!this.settings.collectMkvToLibrary) { return; @@ -4817,13 +4824,29 @@ export class DownloadManager extends EventEmitter { // Skip 0-byte files from failed/partial extractions let sourceSize = 0; + let sourceMtimeMs = 0; try { const stat = await fs.promises.stat(sourcePath); sourceSize = stat.size; + sourceMtimeMs = stat.mtimeMs; } catch { skipped += 1; continue; } + // Frische-Skip (nur Hybrid-Pfad: deferFreshFiles=true): eine gerade extrahierte + // Datei wird vom Auto-Rename absichtlich deferred (noch nicht stabil / EBUSY). + // Wuerde der Collect sie JETZT moven, landet sie mit Original-Namen in der + // Library statt umbenannt (genau der gemeldete "1-2 pro Staffel nicht + // umbenannt"-Bug). Wir defern sie ebenfalls → eine spaetere Hybrid-Runde oder + // der finale Deferred-Pass (deferFreshFiles=false) benennt sie um + sammelt sie. + if (deferFreshFiles && this.fileStabilizeMinAgeMs > 0) { + const ageMs = Date.now() - sourceMtimeMs; + if (ageMs >= 0 && ageMs < this.fileStabilizeMinAgeMs) { + logger.info(`MKV-Sammelordner: ${path.basename(sourcePath)} uebersprungen — Datei noch frisch (${Math.floor(ageMs)}ms), wird nach Stabilisierung gesammelt`); + skipped += 1; + continue; + } + } if (sourceSize === 0) { logger.warn(`MKV-Sammelordner: überspringe 0-Byte-Datei ${path.basename(sourcePath)}`); const resolved = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), targetDir); @@ -11361,15 +11384,21 @@ export class DownloadManager extends EventEmitter { hybridSet.add(hybridController); const hybridShouldAbort = (): boolean => hybridController.signal.aborted || this.session.packages[packageId] !== pkg; void (async () => { + // Atomare Kopplung von Rename + Collect in EINER chainPackageFileOp-Kette, + // damit zwischen ihnen keine andere (ueberlappende) Hybrid-Runde ihren + // Collect einschieben kann (das war der Rename-Race: ein Collect moved + // eine Datei bevor der zugehoerige Rename lief). Wichtig: die IMPL-Variante + // des Renames verwenden — die Public-Variante ruft selbst chainPackageFileOp + // auf, was hier zu verschachteltem Chaining (Deadlock) fuehren wuerde. + // deferFreshFiles=true: Dateien die der Rename als "noch frisch" auslaesst + // werden vom Collect ebenfalls deferred (statt mit Original-Namen gemoved). try { - await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg, hybridShouldAbort); + await this.chainPackageFileOp(pkg.id, async () => { + await this.autoRenameExtractedVideoFilesImpl(pkg.extractDir, pkg, hybridShouldAbort); + await this.collectMkvFilesToLibrary(packageId, pkg, hybridShouldAbort, true); + }); } catch (err) { - logger.warn(`Hybrid Auto-Rename Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`); - } - try { - await this.chainPackageFileOp(pkg.id, () => this.collectMkvFilesToLibrary(packageId, pkg, hybridShouldAbort)); - } catch (err) { - logger.warn(`Hybrid MKV-Collection Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`); + logger.warn(`Hybrid Post-Extract (Rename+Collect) Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`); } finally { const set = this.packageHybridPostProcessControllers.get(packageId); if (set) { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 0a2f236..1ea40a0 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -9358,6 +9358,77 @@ describe("download manager", () => { expect(fs.existsSync(originalExtractedPath)).toBe(false); }, 20000); + it("hybrid collect defers fresh files instead of moving them unrenamed; final pass collects them", async () => { + // Regression: User-Report — bei Hybrid-Extraktion blieben 1-2 Dateien pro + // Staffel unbenannt (mit Original-Scene-Namen in der Library). Ursache: eine + // frisch extrahierte Datei wird vom Auto-Rename absichtlich deferred (noch nicht + // stabil), aber der Collect moved sie vorher mit Original-Namen. Fix: der + // Hybrid-Collect (deferFreshFiles=true) ueberspringt frische Dateien; der finale + // Deferred-Pass (deferFreshFiles=false) sammelt sie nach Stabilisierung ein. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Fresh.Defer.Test.S01.German.720p.BluRay.x264-GRP"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(extractDir, { recursive: true }); + + const mkvName = "grp-freshshow.s01e07-720p.mkv"; + const mkvPath = path.join(extractDir, mkvName); + fs.writeFileSync(mkvPath, Buffer.alloc(4096, 7)); // 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: "downloading", + 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: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + // In Tests ist fileStabilizeMinAgeMs=0 (Frische-Erkennung aus) — fuer diesen + // Test aktivieren, damit die gerade erstellte Datei als "frisch" gilt. + (manager as any).fileStabilizeMinAgeMs = 30_000; + + const libPath = path.join(mkvLibraryDir, mkvName); + + // Hybrid-Collect (deferFreshFiles=true): frische Datei darf NICHT gemoved werden. + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, true); + expect(fs.existsSync(libPath)).toBe(false); + expect(fs.existsSync(mkvPath)).toBe(true); + + // Finaler Deferred-Pass (deferFreshFiles=false): sammelt die Datei trotzdem ein. + await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); + expect(fs.existsSync(libPath)).toBe(true); + expect(fs.existsSync(mkvPath)).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);