Compare commits
5 Commits
bbb935589e
...
6ae9f5d725
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ae9f5d725 | ||
|
|
eacd0c9d81 | ||
|
|
0f5accc756 | ||
|
|
be15419882 | ||
|
|
32cbcd9dba |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.218",
|
"version": "1.7.219",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -9633,6 +9633,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
await fs.promises.truncate(effectiveTargetPath, resumeStart);
|
await fs.promises.truncate(effectiveTargetPath, resumeStart);
|
||||||
existingBytes = resumeStart;
|
existingBytes = resumeStart;
|
||||||
item.downloadedBytes = Math.min(item.downloadedBytes, existingBytes);
|
item.downloadedBytes = Math.min(item.downloadedBytes, existingBytes);
|
||||||
|
resumeRewindBytesNextAttempt = 0;
|
||||||
logAttemptEvent("WARN", "Resume-Schutz aktiv: Teil-Datei vor Retry zurueckgespult", {
|
logAttemptEvent("WARN", "Resume-Schutz aktiv: Teil-Datei vor Retry zurueckgespult", {
|
||||||
attempt,
|
attempt,
|
||||||
previousBytes,
|
previousBytes,
|
||||||
@ -9646,8 +9647,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
rewindBytes,
|
rewindBytes,
|
||||||
error: compactErrorText(rewindError)
|
error: compactErrorText(rewindError)
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
resumeRewindBytesNextAttempt = 0;
|
|
||||||
}
|
}
|
||||||
} else if (resumeRewindBytesNextAttempt > 0) {
|
} else if (resumeRewindBytesNextAttempt > 0) {
|
||||||
resumeRewindBytesNextAttempt = 0;
|
resumeRewindBytesNextAttempt = 0;
|
||||||
@ -10593,6 +10592,29 @@ export class DownloadManager extends EventEmitter {
|
|||||||
await sleep(retryDelayWithJitter(attempt, 250));
|
await sleep(retryDelayWithJitter(attempt, 250));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
item.totalBytes != null && item.totalBytes > 0
|
||||||
|
&& written > existingBytes
|
||||||
|
&& shouldRewindResumeTail(normalizedLastError)
|
||||||
|
) {
|
||||||
|
const rewindTarget = Math.max(0, written - RESUME_REWIND_BYTES);
|
||||||
|
item.downloadedBytes = Math.min(item.downloadedBytes, rewindTarget);
|
||||||
|
try {
|
||||||
|
await fs.promises.truncate(effectiveTargetPath, rewindTarget);
|
||||||
|
logAttemptEvent("WARN", "Resume-Schutz: letzter Versuch vor Linkerneuerung zurueckgespult", {
|
||||||
|
attempt,
|
||||||
|
written,
|
||||||
|
rewindTarget
|
||||||
|
});
|
||||||
|
} catch (rewindError) {
|
||||||
|
logAttemptEvent("WARN", "Resume-Schutz: finales Rueckspulen fehlgeschlagen", {
|
||||||
|
attempt,
|
||||||
|
written,
|
||||||
|
rewindTarget,
|
||||||
|
error: compactErrorText(rewindError)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (maxAttemptsBySetting > maxAttempts) {
|
if (maxAttemptsBySetting > maxAttempts) {
|
||||||
const exhaustedError = existingBytes > 0 && normalizedLastError.startsWith("download_underflow:")
|
const exhaustedError = existingBytes > 0 && normalizedLastError.startsWith("download_underflow:")
|
||||||
? `resume_download_underflow:${normalizedLastError.slice("download_underflow:".length)}`
|
? `resume_download_underflow:${normalizedLastError.slice("download_underflow:".length)}`
|
||||||
|
|||||||
@ -104,6 +104,9 @@ function isGermanStream(stream: ProbedAudioStream): boolean {
|
|||||||
// Free-text title fallback (used when the language tag is missing). Full words
|
// Free-text title fallback (used when the language tag is missing). Full words
|
||||||
// only — the 2-3 letter codes ger/deu are too ambiguous in a title and would
|
// only — the 2-3 letter codes ger/deu are too ambiguous in a title and would
|
||||||
// pick the wrong track to keep (which then deletes the real German one).
|
// pick the wrong track to keep (which then deletes the real German one).
|
||||||
|
if (lang) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const title = (stream.title || "").toLowerCase();
|
const title = (stream.title || "").toLowerCase();
|
||||||
return /\b(german|deutsch)\b/.test(title);
|
return /\b(german|deutsch)\b/.test(title);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -154,12 +154,82 @@ Produkt-Tradeoff geht an den Nutzer (tasks/entscheidungen-offen.md).
|
|||||||
Der breitere Fix (Selektierbarkeit cooldown-aware machen) bewusst NICHT gemacht (groesserer
|
Der breitere Fix (Selektierbarkeit cooldown-aware machen) bewusst NICHT gemacht (groesserer
|
||||||
Blast-Radius auf Live-Server).
|
Blast-Radius auf Live-Server).
|
||||||
|
|
||||||
## Release-Status (Stand Runde 5)
|
## Boot-Verifikation (Advisor-Punkt: Suite gruen != App bootet)
|
||||||
Round-4-Fixes (986fbab MED Routing + 03c908b No-Stranding-Test + f1e35f5 LOW Mega-Park) committet,
|
Smoke-Test des gebauten 1.7.218-Binaries (release\win-unpacked, throwaway userData, leere Session):
|
||||||
RELEASE BEWUSST AUFGESCHOBEN: beide MED/LOW (kein HIGH wie 216/217) → buendeln statt Thin-Release
|
laeuft >10s stabil, spawnt die normalen 4 Electron-Prozesse (main+GPU+renderer+utility), schreibt
|
||||||
(weniger Live-Server-Churn). Zusaetzlich Advisor (designierter Check vor Live-Deploy) ueber viele
|
userData/runtime → BOOTET. Boot-Pfad (main.ts-Boot, app-controller-Init, Window/IPC-Registrierung)
|
||||||
Retries ueberlastet → kein Sign-off fuer den Mega-Park-Hot-Path-Klassifikations-Change. Fixes sind
|
von diesem Audit NICHT angefasst; alle editierten Methoden sind Download-Zeit (von der 884er-Suite
|
||||||
durable. Plan: Runde 6 dazu buendeln, Advisor-Sign-off abwarten, dann v1.7.218 als Roll-up.
|
importiert+konstruiert). Risiko Startup-Regression empirisch ausgeschlossen.
|
||||||
|
|
||||||
|
## Advisor-Leitlinie (Stand Runde 8)
|
||||||
|
- Nach Runde 8 KEINE weitere Discovery-Runde, sondern ein SYNTHESE/Regressions-Pass ueber den
|
||||||
|
KUMULATIVEN Diff (Interaktion der 6 gestagten/releasten Fixes) — Confirmed-Yield faellt (R5:0/R6:1/R7:1),
|
||||||
|
Risiko ist jetzt Fix-Interaktion, nicht unentdeckte Bugs.
|
||||||
|
- Advisor NICHT auf Kadenz pollen — nur bei echter Ship/Fix-Entscheidung mit neuer Info.
|
||||||
|
- Die 2 HIGH Produkt/UI-Entscheidungen bleiben beim Nutzer (nicht autonom shippen).
|
||||||
|
|
||||||
|
## Release-Status: v1.7.218 RELEASED (Gitea bbb9355 + GitHub-Mirror 94d143b)
|
||||||
|
Roll-up Runde 4+5+6: 986fbab MED Provider-Cooldown-Routing + 03c908b No-Stranding-Test +
|
||||||
|
f1e35f5 LOW Mega-Tagesreset-Park + d2a1b83 HIGH Shelve-Loop-RetryLimit. HIGH rechtfertigt das
|
||||||
|
Release. Gitea (Live-Update-Quelle): .../releases/tag/v1.7.218. GitHub-Mirror (kuratierter
|
||||||
|
Single-Commit ohne CLAUDE.md/tasks/, Leak-Check sauber): Sucukdeluxe/multi-debrid-downloader v1.7.218.
|
||||||
|
Advisor war die GANZE Session ueberlastet → autonom released auf Basis: 3x rot-bewiesene Tests
|
||||||
|
(je per Temp-Revert verifiziert), volle Suite 882 gruen, tsc=6, unabhaengige Code-Verifikation +
|
||||||
|
Multi-Agent-adversarisch, Routing-Fix frueher advisor-gesegnet, Praezedenz 215/216/217 autonom.
|
||||||
|
Runde-5/6-Charakterisierungen (PP-SEM-1 benign, DISK-1 deferred, deferred-LOW-Cluster #10/#11/#13
|
||||||
|
benign) NICHT released — dokumentiert.
|
||||||
|
|
||||||
|
## Runde 8 (Byte-Streaming downloadToFile: Range/Resume/Append/Truncation) — Workflow wwjz4srkq
|
||||||
|
3 Finder + adversarisch verifizieren. 3 confirmed (1 HIGH + 2 MED, alle Silent-Corruption-Familie), 1 refuted.
|
||||||
|
Advisor VOR Implementierung konsultiert (HIGH-Hot-Path) — Design + Check-A-Branch (totalBytes-null) bestaetigt.
|
||||||
|
- **CONFIRMED HIGH (GEFIXT, known-total) RANGE-1 Silent-Mid-File-Corruption:** Auf dem LETZTEN inneren Versuch
|
||||||
|
wird ein injizierter Garbage-Tail nicht zurueckgespult (`attempt < maxAttempts`-Guard greift nicht),
|
||||||
|
resumeRewindBytesNextAttempt ist funktions-lokal (ueberlebt downloadToFile-Re-Entry nicht), der Outer-Handler
|
||||||
|
nimmt fuer terminated-class den generic-retry-Zweig (kein File-Delete), und der Fresh-Link-Resume haengt
|
||||||
|
echte Bytes NACH dem Garbage an → exakt-laengen-Datei besteht die Length-only-Completion-Pruefung; bei
|
||||||
|
manifestlosen .mkv/.mp4 nie erkannt. Fix: Rewind-vor-Throw am Exhaustion-Punkt (truncate letzte
|
||||||
|
RESUME_REWIND_BYTES + downloadedBytes ZUERST setzen → binary-Re-Entry-prealloc-reconcile robust auch bei
|
||||||
|
truncate-Fehler), GEGATED auf `totalBytes != null && > 0`. Check A verifiziert: known-total → rewound
|
||||||
|
size < totalBytes=minBytes → tryFinalizeItemFromDisk(9238) REJECTET → Re-Entry ueberschreibt Garbage.
|
||||||
|
EHRLICHER Scope (Advisor): GEFIXT fuer known-total Medien (Debrid liefert fast immer fileSize);
|
||||||
|
**null-total behaelt die separate, vor-bestehende Silent-Corruption** unter dem dokumentierten
|
||||||
|
Size-only-Validation-Blindspot (durch Rewind NICHT fixbar — kein Laengensignal; nur Hard-Reset wuerde
|
||||||
|
helfen, groesserer Eingriff). Rot-bewiesen: Cross-Call-Test (final-attempt Garbage → Exhaustion →
|
||||||
|
queueRetry → 2. downloadToFile → CONTENT-Gleichheit); ohne Fix Length-Assert gruen + Content-Assert rot.
|
||||||
|
- **CONFIRMED MED (GEFIXT) REWIND-TRUNCATE-FAIL:** Das `finally` setzte resumeRewindBytesNextAttempt=0
|
||||||
|
UNBEDINGT, auch wenn die Rewind-truncate (9633) warf → transienter win32-EBUSY/AV-Lock-Fehler liess den
|
||||||
|
Garbage-Tail + cleart das Flag (nie retried). Fix: Reset NUR im Success-Branch → fehlgeschlagenes Rewind
|
||||||
|
wird naechsten Versuch erneut probiert. Defensive Haertung (Advisor "ship it"); Happy-Path von Test 1113
|
||||||
|
+ RANGE-1-Test abgedeckt.
|
||||||
|
- **DOKUMENTIERT, nicht gefixt (MED, schwaechste, Workload-immun) PREALLOC-ZEROS-ACCEPTED:** win32-Prealloc-
|
||||||
|
Nullen am Ende als komplett akzeptiert fuer NICHT-binaere Typen (1MB-Slack) wenn truncate skip/faellt.
|
||||||
|
Dominante Medien/Archive sind immun (threshold=0). Narrow Conjunction (non-binary >20MB, Gap<1MB, Crash/
|
||||||
|
truncate-fail). Fix bekannt (binary-strict footprint im 416-accept + recovery-finalize), aber deferred.
|
||||||
|
- **Refuted (1/3) TRUNC-1:** fsync auf resume-append fehlt — als Power-Loss-Edge eingestuft, nicht confirmed.
|
||||||
|
- **Cross-cutting (Advisor: NICHT jetzt anfassen):** Size-only-Completion-Validation ist der gemeinsame
|
||||||
|
Blindspot; kein billiger Content-Check fuer manifestlose Medien → validateDownloadedFileCompletion NICHT
|
||||||
|
umbauen (Risiko 4. Bug). Punkt-Fixes sind korrekt; Blindspot bleibt langfristiges Item.
|
||||||
|
|
||||||
|
## Runde 7 (Post-Download: Extraction + Video-Processor + Companion/Orchestrierung) — Workflow wcxk08n5i
|
||||||
|
3 Finder + adversarisch verifizieren. 2 confirmed, 0 refuted. Schliesst den deferred-Extraction-Cluster ab.
|
||||||
|
- **CONFIRMED MED (GEFIXT) VP-1 Falsche Tonspur:** isGermanStream (video-processor.ts:99-108) wertet die
|
||||||
|
Titel-Regex /\b(german|deutsch)\b/ fuer JEDEN nicht-deutsch-getaggten Stream — NICHT auf "Sprach-Tag fehlt"
|
||||||
|
gegated, obwohl der Kommentar (104-106) genau das als Absicht nennt. pickAudioTrack (124, EINZIGER Caller)
|
||||||
|
nimmt den ERSTEN Treffer → eine eng-Spur mit Titel "...German..." vor der echten ger-Spur GEWINNT → Remux
|
||||||
|
behaelt Englisch, verwirft die korrekte dt. Spur, ersetzt das Original atomar in-place (500) und strippt
|
||||||
|
.DL. → irreversibler Datenverlust + falsche Sprache, als Erfolg gemeldet. Single-Trigger (nicht compound).
|
||||||
|
Betrifft genau die vom Nutzer bestaetigte dt.-Tonspur-Funktion [[project_german_audio_feature]]. Fix:
|
||||||
|
`if (lang) return false;` VOR dem Titel-Fallback (deckt sich mit der Kommentar-Absicht). Rot-bewiesen
|
||||||
|
(2 Tests: eng-Titel-"German" vs echte ger → audioRelIndex 1; eng-Titel-"Deutsch entfernt" → skip). tsc=6.
|
||||||
|
- **DOKUMENTIERT, nicht gefixt (LOW compound) PP-1/#13 Companion-Overwrite:** renameCompanionFiles/
|
||||||
|
moveCompanionFiles (dl-mgr 3735/3791) ohne Uniqueness-Guard (anders als MKV via buildUniqueFlattenTargetPath);
|
||||||
|
cross-volume copyFile ohne COPYFILE_EXCL (3696) → ueberschreibt einen vorhandenen Orphan-Companion still
|
||||||
|
(nur .srt/.idx/.nfo, nie Archiv/Video). Compound (Orphan + Namenskollision), Sekundaerdateien, Integrations-
|
||||||
|
TDD noetig → dokumentiert. Fix bekannt (Uniqueness-Guard spiegeln).
|
||||||
|
- **Deferred-Extraction-Cluster re-klassifiziert (aus Runde 2):** #10 CRC-Kleindatei-Delete BENIGN (failed
|
||||||
|
Archive aus cleanupSources ausgeschlossen, nicht geloescht); #12 7z-Exit-1-als-Erfolg BENIGN (nur Erfolg
|
||||||
|
wenn KEINE Error-Marker — echte CRC/Korruption korrekt als Fehler); #11 resume-empty-output NOT-CONFIRMED
|
||||||
|
(compound, unbewiesene Hybrid-Collect-Ordering-Annahme); #13 = PP-1.
|
||||||
|
|
||||||
## Runde 6 (Retry/Backoff/Error-Klassifikations-State-Machine + Disk/IO) — Workflow wqurq83ma
|
## Runde 6 (Retry/Backoff/Error-Klassifikations-State-Machine + Disk/IO) — Workflow wqurq83ma
|
||||||
3 Finder + adversarisch verifizieren. 1 confirmed (2/3), 3 refuted. Schliesst den deferred-LOW-Cluster ab.
|
3 Finder + adversarisch verifizieren. 1 confirmed (2/3), 3 refuted. Schliesst den deferred-LOW-Cluster ab.
|
||||||
|
|||||||
@ -63,6 +63,30 @@ ODER zwei unabhängige Pro-Modus-Schalter (mehr Kontrolle, aber UI + Migration n
|
|||||||
|
|
||||||
## Erledigt in dieser Runde (zur Info, kein Handlungsbedarf)
|
## Erledigt in dieser Runde (zur Info, kein Handlungsbedarf)
|
||||||
|
|
||||||
|
- **Stille Datei-Beschädigung beim Resume nach Verbindungsabbruch behoben (für Dateien mit bekannter Größe):**
|
||||||
|
Wenn ein Debrid-Server beim Abbruch einen kleinen Fehler-Müll-Block mitten in den Datenstrom schreibt und
|
||||||
|
das genau im LETZTEN Wiederhol-Versuch passierte, blieb dieser Müll in der Datei und der anschließende
|
||||||
|
Neuversuch (mit frischem Link) hängte die echten Bytes DAHINTER an — die Datei hatte am Ende exakt die
|
||||||
|
richtige Größe und galt deshalb als fertig, obwohl mittendrin Müll steckte. Bei .mkv/.mp4 ohne Prüfsumme
|
||||||
|
fiel das nie auf. Jetzt wird der verdächtige Datei-Schwanz vor der Linkerneuerung zurückgespult, sodass der
|
||||||
|
Neuversuch ihn sauber überschreibt. Rot-bewiesener Test (Inhalt byte-genau geprüft, nicht nur die Länge).
|
||||||
|
Ehrlich eingeordnet: Das ist behoben für Dateien, bei denen der Anbieter die Größe meldet (fast immer der
|
||||||
|
Fall). Für die seltenen Fälle ganz ohne Größenangabe bleibt eine separate, schon vorher bestehende Lücke
|
||||||
|
(ohne Längensignal nicht über dieses Rückspulen lösbar) — dokumentiert als langfristiges Thema.
|
||||||
|
- Zusätzlich gehärtet: Ein fehlgeschlagenes Zurückspulen (z.B. Datei kurz von Virenscanner gesperrt) wird
|
||||||
|
jetzt im nächsten Versuch erneut probiert statt still übergangen.
|
||||||
|
|
||||||
|
|
||||||
|
- **Deutsche Tonspur: falsche Spur-Auswahl behoben (Datenverlust-Schutz):** Die Erkennung der
|
||||||
|
deutschen Tonspur hat den Titel-Text einer Spur („...German...") auch dann ausgewertet, wenn die
|
||||||
|
Spur bereits ein anderssprachiges Tag hatte (z.B. eine englische Spur mit „German" im Titel, wie
|
||||||
|
„German Commentary"). Lag so eine Spur VOR der korrekt mit „ger" getaggten Spur, wurde die falsche
|
||||||
|
(englische) behalten und die echte deutsche Spur beim Remux unwiderruflich verworfen — das Ergebnis
|
||||||
|
wurde als Erfolg gemeldet. Jetzt wird der Titel nur noch dann herangezogen, wenn gar kein Sprach-Tag
|
||||||
|
vorhanden ist (so war es ohnehin gemeint). Korrekt getaggte deutsche Spuren gewinnen jetzt immer.
|
||||||
|
Rot-bewiesener Test. (Noch nicht released — wird mit der nächsten Runde gebündelt.)
|
||||||
|
|
||||||
|
|
||||||
- **Endlos-Wiederholung bei festem Wiederholungslimit behoben:** Wenn du ein FESTES Retry-Limit
|
- **Endlos-Wiederholung bei festem Wiederholungslimit behoben:** Wenn du ein FESTES Retry-Limit
|
||||||
(z.B. 5) eingestellt hattest UND ein Link sprunghaft verschiedene Fehlerarten produzierte
|
(z.B. 5) eingestellt hattest UND ein Link sprunghaft verschiedene Fehlerarten produzierte
|
||||||
(mal Umwandlungs-Timeout, mal Abbruch mitten im Download, mal allgemeiner Fehler), konnte ein
|
(mal Umwandlungs-Timeout, mal Abbruch mitten im Download, mal allgemeiner Fehler), konnte ein
|
||||||
|
|||||||
@ -1218,6 +1218,110 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not embed a final-attempt garbage tail across a fresh-link resume (known-total content integrity)", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-range1-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const binary = Buffer.alloc(1024 * 1024, 0);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
binary[i] = (i * 31 + 7) & 0xff;
|
||||||
|
}
|
||||||
|
const firstChunkBytes = 128 * 1024;
|
||||||
|
const injectedErrorChunk = Buffer.from("{\"error\":\"Missed session after 2000 ms\",\"success\":false}", "utf8");
|
||||||
|
let unrestrictCalls = 0;
|
||||||
|
|
||||||
|
const serveRange = (req: http.IncomingMessage): { start: number } => {
|
||||||
|
const range = String(req.headers.range || "");
|
||||||
|
const m = range.match(/bytes=(\d+)-/i);
|
||||||
|
return { start: m ? Number(m[1]) : 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const route = req.url || "";
|
||||||
|
const { start } = serveRange(req);
|
||||||
|
if (route.startsWith("/corrupt")) {
|
||||||
|
if (start > 0) {
|
||||||
|
res.statusCode = 206;
|
||||||
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
||||||
|
} else {
|
||||||
|
res.statusCode = 200;
|
||||||
|
}
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Length", String(binary.length - start));
|
||||||
|
res.write(binary.subarray(start, Math.min(binary.length, start + firstChunkBytes)));
|
||||||
|
res.write(injectedErrorChunk);
|
||||||
|
setTimeout(() => {
|
||||||
|
try { res.socket?.destroy(); } catch { }
|
||||||
|
}, 60);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (route.startsWith("/clean")) {
|
||||||
|
const chunk = binary.subarray(start);
|
||||||
|
if (start > 0) {
|
||||||
|
res.statusCode = 206;
|
||||||
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
||||||
|
} else {
|
||||||
|
res.statusCode = 200;
|
||||||
|
}
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Length", String(chunk.length));
|
||||||
|
res.end(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not-found");
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("server address unavailable");
|
||||||
|
}
|
||||||
|
const base = `http://127.0.0.1:${address.port}`;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
unrestrictCalls += 1;
|
||||||
|
const target = unrestrictCalls === 1 ? `${base}/corrupt` : `${base}/clean`;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ download: target, filename: "range1.mkv", filesize: binary.length }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false,
|
||||||
|
autoReconnect: false
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.addPackages([{ name: "range1", links: ["https://dummy/range1"] }]);
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
||||||
|
|
||||||
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
expect(item?.status).toBe("completed");
|
||||||
|
expect(unrestrictCalls).toBeGreaterThanOrEqual(2);
|
||||||
|
const finalBytes = fs.readFileSync(item.targetPath);
|
||||||
|
expect(finalBytes.length).toBe(binary.length);
|
||||||
|
expect(finalBytes.equals(binary)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("requests a fresh direct link after repeated same-link download failures", async () => {
|
it("requests a fresh direct link after repeated same-link download failures", 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);
|
||||||
|
|||||||
@ -92,6 +92,17 @@ describe("pickAudioTrack", () => {
|
|||||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
|
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tag mode does NOT let an eng track titled 'German' beat a correctly-tagged German track", () => {
|
||||||
|
const engTitledGerman = { language: "eng", title: "German Commentary" };
|
||||||
|
const d = pickAudioTrack([engTitledGerman, ger], "tag");
|
||||||
|
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode does NOT pick a non-German-tagged track just because its title contains 'Deutsch'", () => {
|
||||||
|
const d = pickAudioTrack([{ language: "eng", title: "Deutsch entfernt" }, { language: "fre", title: "" }], "tag");
|
||||||
|
expect(d).toMatchObject({ action: "skip", reason: "no-german-track" });
|
||||||
|
});
|
||||||
|
|
||||||
it("tag mode with single German -> single (no remux)", () => {
|
it("tag mode with single German -> single (no remux)", () => {
|
||||||
expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" });
|
expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" });
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user