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
parent e061997ed2
commit 18eada963f
2 changed files with 109 additions and 9 deletions

View File

@ -3995,7 +3995,6 @@ export class DownloadManager extends EventEmitter {
// they've stabilized (hybrid-extract fires a new rename scan after every // they've stabilized (hybrid-extract fires a new rename scan after every
// archive completes, so nothing gets missed). // archive completes, so nothing gets missed).
const FILE_STABILIZE_MIN_AGE_MS = this.fileStabilizeMinAgeMs; const FILE_STABILIZE_MIN_AGE_MS = this.fileStabilizeMinAgeMs;
const now = Date.now();
for (const sourcePath of videoFiles) { for (const sourcePath of videoFiles) {
if (shouldAbort?.()) { if (shouldAbort?.()) {
return renamed; return renamed;
@ -4013,6 +4012,13 @@ export class DownloadManager extends EventEmitter {
} catch { } catch {
continue; 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; const ageMs = now - sourceStat.mtimeMs;
// Negative age = mtime in the future (clock skew, NTP correction, // Negative age = mtime in the future (clock skew, NTP correction,
// VM resume after suspension). Treat as "definitely stable" so the // VM resume after suspension). Treat as "definitely stable" so the
@ -4655,7 +4661,8 @@ export class DownloadManager extends EventEmitter {
private async collectMkvFilesToLibrary( private async collectMkvFilesToLibrary(
packageId: string, packageId: string,
pkg: PackageEntry, pkg: PackageEntry,
shouldAbort?: () => boolean shouldAbort?: () => boolean,
deferFreshFiles = false
): Promise<void> { ): Promise<void> {
if (!this.settings.collectMkvToLibrary) { if (!this.settings.collectMkvToLibrary) {
return; return;
@ -4817,13 +4824,29 @@ export class DownloadManager extends EventEmitter {
// Skip 0-byte files from failed/partial extractions // Skip 0-byte files from failed/partial extractions
let sourceSize = 0; let sourceSize = 0;
let sourceMtimeMs = 0;
try { try {
const stat = await fs.promises.stat(sourcePath); const stat = await fs.promises.stat(sourcePath);
sourceSize = stat.size; sourceSize = stat.size;
sourceMtimeMs = stat.mtimeMs;
} catch { } catch {
skipped += 1; skipped += 1;
continue; 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) { if (sourceSize === 0) {
logger.warn(`MKV-Sammelordner: überspringe 0-Byte-Datei ${path.basename(sourcePath)}`); logger.warn(`MKV-Sammelordner: überspringe 0-Byte-Datei ${path.basename(sourcePath)}`);
const resolved = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), targetDir); const resolved = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), targetDir);
@ -11361,15 +11384,21 @@ export class DownloadManager extends EventEmitter {
hybridSet.add(hybridController); hybridSet.add(hybridController);
const hybridShouldAbort = (): boolean => hybridController.signal.aborted || this.session.packages[packageId] !== pkg; const hybridShouldAbort = (): boolean => hybridController.signal.aborted || this.session.packages[packageId] !== pkg;
void (async () => { 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 { 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) { } catch (err) {
logger.warn(`Hybrid Auto-Rename Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`); logger.warn(`Hybrid Post-Extract (Rename+Collect) 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)}`);
} finally { } finally {
const set = this.packageHybridPostProcessControllers.get(packageId); const set = this.packageHybridPostProcessControllers.get(packageId);
if (set) { if (set) {

View File

@ -9358,6 +9358,77 @@ describe("download manager", () => {
expect(fs.existsSync(originalExtractedPath)).toBe(false); expect(fs.existsSync(originalExtractedPath)).toBe(false);
}, 20000); }, 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 () => { 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);