Compare commits
4 Commits
30dbbbae9e
...
748c07a531
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
748c07a531 | ||
|
|
d923d6dabb | ||
|
|
5495f5f24f | ||
|
|
35622445da |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.162",
|
"version": "1.7.163",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -3915,20 +3915,28 @@ export class DownloadManager extends EventEmitter {
|
|||||||
private async autoRenameExtractedVideoFiles(
|
private async autoRenameExtractedVideoFiles(
|
||||||
extractDir: string,
|
extractDir: string,
|
||||||
pkg?: PackageEntry,
|
pkg?: PackageEntry,
|
||||||
shouldAbort?: () => boolean
|
shouldAbort?: () => boolean,
|
||||||
|
treatFilesAsStable = false
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
if (!pkg) {
|
if (!pkg) {
|
||||||
return this.autoRenameExtractedVideoFilesImpl(extractDir, undefined, shouldAbort);
|
return this.autoRenameExtractedVideoFilesImpl(extractDir, undefined, shouldAbort, treatFilesAsStable);
|
||||||
}
|
}
|
||||||
return this.chainPackageFileOp(pkg.id, () =>
|
return this.chainPackageFileOp(pkg.id, () =>
|
||||||
this.autoRenameExtractedVideoFilesImpl(extractDir, pkg, shouldAbort)
|
this.autoRenameExtractedVideoFilesImpl(extractDir, pkg, shouldAbort, treatFilesAsStable)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async autoRenameExtractedVideoFilesImpl(
|
private async autoRenameExtractedVideoFilesImpl(
|
||||||
extractDir: string,
|
extractDir: string,
|
||||||
pkg?: PackageEntry,
|
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<number> {
|
): Promise<number> {
|
||||||
if (!this.settings.autoRename4sf4sj) {
|
if (!this.settings.autoRename4sf4sj) {
|
||||||
return 0;
|
return 0;
|
||||||
@ -4023,7 +4031,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// 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
|
||||||
// file doesn't get stuck waiting for the wall clock to catch up.
|
// 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`);
|
logger.info(`Auto-Rename: ${sourceName} uebersprungen — Datei noch frisch (${Math.floor(ageMs)}ms), wird beim naechsten Scan behandelt`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -12114,7 +12122,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
extractDir: pkg.extractDir
|
extractDir: pkg.extractDir
|
||||||
});
|
});
|
||||||
throwIfAborted();
|
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) ──
|
// ── Archive cleanup (source archives in outputDir) ──
|
||||||
|
|||||||
31
tasks/lessons.md
Normal file
31
tasks/lessons.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Lessons
|
||||||
|
|
||||||
|
## 2026-05-28 — Analyse-Befund gegen beobachtete Realität gaten (Advisor-Korrektur)
|
||||||
|
|
||||||
|
**Muster:** Meine Analyse sagte einen *häufigen* Bug voraus (jede letzte Datei im
|
||||||
|
Standard-Modus + jede Nested-Datei landet unbenannt), während der User nur "1-2 pro
|
||||||
|
Staffel" meldete. Ich habe die Diskrepanz bemerkt ("zu schwer um unbemerkt zu bleiben")
|
||||||
|
und sie mit weiterem Timing-Argument wegrationalisiert.
|
||||||
|
|
||||||
|
**Regel:** Wenn die eigene Analyse etwas vorhersagt, das der beobachteten Realität
|
||||||
|
widerspricht, NICHT die bequeme Lesart wählen — **mit einem Reproduktions-Test gaten**,
|
||||||
|
bevor man fixt. Failing Test gegen den Ist-Stand zuerst (TDD/systematic-debugging Phase 4):
|
||||||
|
- reproduziert → Bug bestätigt, mit Sicherheit fixen.
|
||||||
|
- reproduziert nicht → Analyse hat eine Mitigation übersehen, kein Fix für Nicht-Bug.
|
||||||
|
|
||||||
|
## 2026-05-28 — Crash-Debris im Working Tree: stashen, nicht verwerfen
|
||||||
|
|
||||||
|
**Muster:** Eine abgestürzte Session (API 400) hinterließ ein uncommittetes Working Tree,
|
||||||
|
das drei releaste Commits revertierte. Verlockung: `git checkout`/discard, um clean HEAD
|
||||||
|
zu bekommen.
|
||||||
|
|
||||||
|
**Regel:** Fremde/unverstandene uncommittete Änderungen **`git stash`** (non-destruktiv,
|
||||||
|
recoverable), nie blind verwerfen. Gibt clean HEAD, nichts geht verloren, kein Stall auf
|
||||||
|
User-Rückfrage. Danach dem User sagen WAS gestasht wurde und WARUM.
|
||||||
|
|
||||||
|
## Wiring-Lock vs. Mechanism-Test
|
||||||
|
|
||||||
|
Ein Test, der eine Hilfsfunktion mit dem richtigen Flag direkt aufruft, beweist nur, dass
|
||||||
|
das Flag funktioniert — NICHT, dass der Produktionspfad das Flag setzt. Für echte
|
||||||
|
Absicherung einen End-to-End-Test durch den realen Einstiegspunkt fahren und per
|
||||||
|
Negativ-Gate (Flag temporär entfernen → Test muss fallen) verifizieren.
|
||||||
@ -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.
|
- ⏭️ **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).
|
**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.
|
||||||
|
|||||||
@ -9429,6 +9429,153 @@ describe("download manager", () => {
|
|||||||
void manager;
|
void manager;
|
||||||
}, 20000);
|
}, 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("deferred post-extraction wiring renames fresh files end-to-end (treatFilesAsStable reaches the rename)", async () => {
|
||||||
|
// Wiring-Lock zum vorherigen Test: stellt sicher, dass runDeferredPostExtraction
|
||||||
|
// den Rename TATSAECHLICH mit treatFilesAsStable=true aufruft. Wuerde jemand das
|
||||||
|
// `true` an der Call-Site (autoRenameExtractedVideoFiles(..., true)) entfernen,
|
||||||
|
// faellt dieser Test (frische Datei landet wieder unbenannt) — der reine
|
||||||
|
// Mechanism-Test wuerde das NICHT bemerken (er ruft den Rename selbst mit true).
|
||||||
|
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);
|
||||||
|
const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake");
|
||||||
|
fs.mkdirSync(epFolder, { recursive: true });
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const sceneName = "awa-testshow02e05hd.mkv";
|
||||||
|
fs.writeFileSync(path.join(epFolder, sceneName), 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"))
|
||||||
|
);
|
||||||
|
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
||||||
|
|
||||||
|
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
||||||
|
// Treibt den ECHTEN Produktionspfad: runDeferredPostExtraction → Rename
|
||||||
|
// (Call-Site mit treatFilesAsStable=true) → Collect (deferFreshFiles=false).
|
||||||
|
// success=1 (Collect-Gate), alreadyMarkedExtracted=true (Rename-Gate), failed=0.
|
||||||
|
await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1);
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(mkvLibraryDir, sceneName))).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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user