Auto-Rename Race-Fix: parallele Scans pro Package serialisieren

Im Production-Log:
  21:30:33.957Z Auto-Rename Scan gestartet | videoFiles=25
  21:30:33.992Z Auto-Rename Scan gestartet | videoFiles=25  (35ms spaeter, gleiches pkg!)
  21:30:33.994Z Auto-Rename durchgefuehrt | E24.B          (Scan 1)
  21:30:34.009Z Auto-Rename uebersprungen: Ziel existiert  (Scan 2 sieht renamed file)
  21:30:34.029Z Auto-Rename durchgefuehrt | E24.A          (Scan 1)
  21:30:34.056Z Auto-Rename fehlgeschlagen | ENOENT        (Scan 2 versucht renamed file)

Ursache: hybrid-extract feuert nach JEDEM erfolgreichen Archive einen
fire-and-forget autoRename (Z.10915), und der deferred Post-Process-Pfad
ruft am Ende nochmal autoRename auf (Z.11630). Bei einem Multi-Archive-
Package (25 Episoden) ueberlappen sich 2+ Scans auf demselben Fileset.

Ergebnis: "Ziel existiert"-Warnungen + ENOENT-Fehler beim Rename.
Manchmal blieben einzelne Files unbenannt durchrutschen (Scan 2 sieht
File X, will renamen, aber Scan 1 hat es schon weg-renamed).

Fix: pro Package via Promise-Chaining serialisieren. Neue Map
autoRenameInFlight haelt das laufende Scan-Promise pro packageId. Der
neue Wrapper kettet jeden weiteren Aufruf an das vorherige Promise an
— so laeuft maximal ein Scan zur Zeit pro Package, der naechste startet
erst wenn der vorherige fertig ist (und sieht damit den korrekten
Disk-State).

Test: zwei parallele autoRenameExtractedVideoFiles-Aufrufe fuer dasselbe
Package mit 3 obfuskierten Files. Beide resolven sauber, Summe der
Renames == 3, alle 3 Folders enthalten am Ende den korrekten Folder-
Namen statt Hoster-Obfuskation. 582/582 Tests gruen.
This commit is contained in:
Sucukdeluxe 2026-04-22 01:38:26 +02:00
parent 7ab508617a
commit c417ebb57f
2 changed files with 122 additions and 0 deletions

View File

@ -1685,6 +1685,18 @@ export class DownloadManager extends EventEmitter {
private itemContributedBytes = new Map<string, number>();
/** Per-package serialization for autoRenameExtractedVideoFiles. The hybrid-
* extract path fires a fire-and-forget rename after every successful
* archive iteration, and the deferred post-process path runs another
* rename when post-processing finishes. For a multi-archive package
* (e.g. 25 episodes) these can overlap, with two concurrent scans seeing
* the same files, racing to rename, and producing "Ziel existiert" /
* ENOENT noise plus occasionally missed renames. We chain subsequent
* invocations onto the running promise so the second scan re-scans
* AFTER the first finishes picking up any newly-arrived files while
* guaranteeing no two scans operate on the same fileset simultaneously. */
private autoRenameInFlight = new Map<string, Promise<number>>();
private runItemIds = new Set<string>();
private runPackageIds = new Set<string>();
@ -3661,6 +3673,36 @@ export class DownloadManager extends EventEmitter {
extractDir: string,
pkg?: PackageEntry,
shouldAbort?: () => boolean
): Promise<number> {
// Serialize per-package: chain onto any in-flight scan for the same
// package so two scans never read the same fileset in parallel. Without
// this, hybrid-extract's per-archive trigger + the deferred post-process
// trigger frequently overlap and cause "Ziel existiert" / ENOENT
// log noise (and occasionally a missed rename when the second scan's
// chosen target file disappears between scan and rename).
if (!pkg) {
return this.autoRenameExtractedVideoFilesImpl(extractDir, undefined, shouldAbort);
}
const previous = this.autoRenameInFlight.get(pkg.id);
const next = (previous ?? Promise.resolve(0)).catch(() => 0).then(() =>
this.autoRenameExtractedVideoFilesImpl(extractDir, pkg, shouldAbort)
);
this.autoRenameInFlight.set(pkg.id, next);
try {
return await next;
} finally {
// Only clear the slot if no newer chained call took our place. This
// keeps the chain intact when several callers queue up at once.
if (this.autoRenameInFlight.get(pkg.id) === next) {
this.autoRenameInFlight.delete(pkg.id);
}
}
}
private async autoRenameExtractedVideoFilesImpl(
extractDir: string,
pkg?: PackageEntry,
shouldAbort?: () => boolean
): Promise<number> {
if (!this.settings.autoRename4sf4sj) {
return 0;

View File

@ -10375,4 +10375,84 @@ describe("download manager", () => {
const pkgLogFiles = fs.readdirSync(packageLogsDir).filter((f) => f.startsWith("package_") && f.endsWith(".txt"));
expect(pkgLogFiles.length).toBe(60);
});
it("serializes parallel auto-rename invocations for the same package (no Ziel existiert / ENOENT race)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-race-"));
tempDirs.push(root);
const stateDir = path.join(root, "state");
fs.mkdirSync(stateDir, { recursive: true });
initPackageLogs(stateDir);
initItemLogs(stateDir);
initRenameLog(stateDir);
// Build extract tree with 3 episode-folders, each containing 1 obfuscated MKV
// mirroring the scene release pattern from the production log.
const extractDir = path.join(root, "extracted");
const episodes = [
{ folder: "Test.Show.S02E01.Pilot.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e01hd.mkv" },
{ folder: "Test.Show.S02E02.Second.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e02hd.mkv" },
{ folder: "Test.Show.S02E03.Third.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e03hd.mkv" }
];
for (const ep of episodes) {
const dir = path.join(extractDir, ep.folder);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, ep.file), Buffer.alloc(1024, 0));
}
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir,
autoExtract: false,
autoReconnect: false,
autoRename4sf4sj: true
},
emptySession(),
createStoragePaths(stateDir)
);
const pkg: any = {
id: "race-pkg-1",
name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake",
outputDir: path.join(root, "downloads", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"),
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 0,
updatedAt: 0,
downloadStartedAt: 0,
downloadCompletedAt: 0
};
// Fire two scans simultaneously for the SAME package — without
// serialization, both would race on the same fileset.
const [n1, n2] = await Promise.all([
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg),
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg)
]);
// First scan should rename all 3 files. Second scan, having waited for
// the first via the in-flight promise, should find them already
// renamed (== 0 fresh renames). What matters is that BOTH calls
// resolved cleanly (no thrown ENOENT) and the disk state is correct.
expect(typeof n1).toBe("number");
expect(typeof n2).toBe("number");
expect(n1 + n2).toBe(3);
// All three episodes should now have the folder-derived name (the
// obfuscated source name was overridden via the v1.7.148 logic AND
// the rename actually succeeded for ALL of them, not just some).
for (const ep of episodes) {
const dir = path.join(extractDir, ep.folder);
const files = fs.readdirSync(dir);
const renamedFile = `${ep.folder}.mkv`;
expect(files).toContain(renamedFile);
expect(files).not.toContain(ep.file);
}
});
});