Fix: Hybrid-Rename-Race — 1-2 Dateien pro Staffel blieben unbenannt

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 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe
2026-05-28 17:44:19 +02:00
co-authored by Claude Opus 4.7
parent e061997ed2
commit 18eada963f
2 changed files with 109 additions and 9 deletions
+71
View File
@@ -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);