Compare commits

..

7 Commits

Author SHA1 Message Date
Sucukdeluxe
77c937888a Release v1.7.190 2026-06-08 23:05:25 +02:00
Sucukdeluxe
fbbc960d9d docs(tasks): Bug-Audit Batch 2 abgeschlossen — 5 Fixes (L/M,H,J/Q,P,B/I) + verifizierte Nicht-Bugs (G,N,D/E,E,O,F) 2026-06-08 23:04:28 +02:00
Sucukdeluxe
dc05b51083 Fix: Settings-only-Backup-Import wischte Live-Queue + Zaehler (B/I)
importBackup wendete die Settings fuer beide Pfade ueber setSettings an, das bei
nicht-"never"-CleanupPolicy applyRetroactiveCleanupPolicy ausloest. Beim reinen
Settings-Restore purgte das die LIVE-Queue (fertige Items), obwohl der Vertrag
"running queue stays untouched" lautet (Dateien blieben auf Platte). Zudem rollte
der Import die laufenden Usage-/Status-Zaehler auf den (aelteren) Backup-Stand
zurueck (anders als updateSettings).

- setSettings bekommt optionales { suppressRetroactiveCleanup }; der Settings-only
  Import setzt es. Die importierte Policy gilt weiter fuer KUENFTIGE Completions
  ueber den normalen Vorwaertspfad (immediate/package_done) — nur der retroaktive
  Sweep wird hier unterdrueckt.
- overlayLiveUsageCounters aus updateSettings extrahiert und im Settings-only Import
  wiederverwendet (inkl. Key-Filter der Debrid-Link-Per-Key-Usage auf existierende
  Keys). Nicht ueber updateSettings geroutet (vermeidet dessen resetHistoryForRetention).
2026-06-08 22:51:16 +02:00
Sucukdeluxe
61a830475b Fix: verschachtelte Entpack-Fortschritte wurden bei jedem Lauf verworfen
Der Resume-Prune validiert Eintraege gegen die Top-Level-Archiv-Kandidaten auf
der Platte. Nested-Archiv-Schluessel (nested:<name>) haben dort kein Gegenstueck,
also wurden sie bei JEDEM extractPackageArchives-Aufruf geloescht — verschachtelte
Archive wurden beim Resume erneut entpackt. nested:-Schluessel werden im Prune
jetzt uebersprungen (sie werden mit dem Rest geleert, wenn das Paket fertig ist).
2026-06-08 22:47:34 +02:00
Sucukdeluxe
3c33b988c3 Fix: Post-Process-Identity-Guard (J) + Remux-Temp nie ins Library sammeln (Q)
J: runPackagePostProcessing loescht im finally die Map-Eintraege fuer das Paket.
Hatte ein Abort den Handle schon entfernt und ein neuer Lauf einen frischen
Task+Controller gesetzt, riss das spaete finall des alten Tasks diesen neuen
Eintrag mit raus -> nicht abbrechbarer Waisen-Task + doppeltes paralleles
Post-Processing. Jetzt nur loeschen wenn Map noch auf DIESEN Task/Controller zeigt.

Q: collectFilesByExtensions filtert jetzt ~rd-Praefix (unsere Remux-Temp/Orphan-
Sidecars) aus, damit eine bei einem Crash mitten im Remux liegengebliebene
Teil-Datei nie in die MKV-Library gesammelt wird.

(dropItemContribution: Kommentar ergaenzt, dass das Nicht-Abziehen der
Session-Totals Absicht ist — kumulative Session-Zaehler, per Test abgesichert.)
2026-06-08 22:46:14 +02:00
Sucukdeluxe
4432fa25e8 Fix: Logger-Flush konnte ungeschriebene Zeilen verlieren (Race mit 1MB-Cap)
flushAsync nahm eine Kopie der pending-Zeilen und entfernte sie nach dem await
per Index-Zaehlung (slice(snapshot.length)). Feuerte waehrend des awaits ein
write() den 1MB-Buffer-Cap, der vorne Zeilen wegshiftet, war die Zaehlung
desynchron und verwarf neu hinzugekommene, noch nicht geschriebene Zeilen.
Jetzt: pending-Zeilen per Move uebernehmen (Buffer auf [] zuruecksetzen) statt
kopieren; await-Zeit-writes laufen in einen frischen Buffer. Bei Schreibfehler
werden die Zeilen wieder vorn eingereiht und der Cap erneut angewandt.
2026-06-08 22:33:11 +02:00
Sucukdeluxe
272a41a4a7 Fix: zu weite Deutsch-Erkennung konnte falsche Tonspur behalten
- isGermanStream: Titel-Fallback nur noch ganze Woerter (german/deutsch); die
  2-3-Buchstaben-Codes ger/deu sind im freien Titel-Text mehrdeutig und konnten
  die falsche Spur als "deutsch" picken (und damit die echte deutsche loeschen).
  Der Sprach-Tag-Check (ger/deu/de) bleibt unveraendert.
- looksLikeGermanRelease: 'dubbed' entfernt — ein nacktes "Dubbed" kann ein
  italienischer/franzoesischer Dub sein und darf den German-first-Fallback nicht
  ausloesen. Explizite german/deutsch-Tokens reichen.
- 2 Negativtests (3-Letter-Titel-Code, nicht-deutscher Dub).
2026-06-08 22:31:34 +02:00
8 changed files with 153 additions and 63 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.189", "version": "1.7.190",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -303,6 +303,27 @@ export class AppController {
return next; return next;
} }
// Carry the live, runtime-maintained usage/status counters onto a settings
// object about to be applied, so they are never rolled back to a stale snapshot.
// All-time totals take the max; daily/total usage and account statuses are taken
// live; per-key Debrid-Link usage is filtered to keys that still exist.
private overlayLiveUsageCounters(target: AppSettings): void {
const liveSettings = this.manager.getSettings();
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
target.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
target.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
target.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
target.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
);
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
);
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
}
public updateSettings(partial: Partial<AppSettings>): AppSettings { public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial); const sanitizedPatch = sanitizeSettingsPatch(partial);
const previousSettings = this.settings; const previousSettings = this.settings;
@ -315,20 +336,7 @@ export class AppController {
return previousSettings; return previousSettings;
} }
const liveSettings = this.manager.getSettings(); this.overlayLiveUsageCounters(nextSettings);
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
nextSettings.totalRuntimeAllTimeMs = Math.max(nextSettings.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
nextSettings.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
);
nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
);
nextSettings.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
this.settings = nextSettings; this.settings = nextSettings;
if (retentionChanged) { if (retentionChanged) {
@ -697,14 +705,18 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
} }
} }
const restoredSettings = normalizeSettings(importedSettings); const restoredSettings = normalizeSettings(importedSettings);
this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
// Settings-only backup: settings are already applied live (same path as the // Settings-only backup: keep the running queue AND the live counters untouched.
// normal updateSettings flow). Do NOT stop the manager, wipe the session, // Overlay the live usage/status counters so they don't roll back to the backup's
// block persistence or relaunch — the running queue stays untouched. // (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the
// backup's cleanup policy can't purge the live completed queue here (BUG B) — the
// policy still governs FUTURE completions through the normal path. Do NOT stop the
// manager, wipe the session, block persistence or relaunch.
if (!hasSession) { if (!hasSession) {
this.overlayLiveUsageCounters(restoredSettings);
this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
this.audit("INFO", "Backup importiert (nur Einstellungen)", { this.audit("INFO", "Backup importiert (nur Einstellungen)", {
accountSummary: buildAccountSummary(this.settings) accountSummary: buildAccountSummary(this.settings)
}); });
@ -715,6 +727,10 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
}; };
} }
this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
this.manager.stop(); this.manager.stop();
this.manager.abortAllPostProcessing(); this.manager.abortAllPostProcessing();
this.manager.clearPersistTimer(); this.manager.clearPersistTimer();

View File

@ -2081,7 +2081,7 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
} }
public setSettings(next: AppSettings): void { public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean }): void {
const previous = this.settings; const previous = this.settings;
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0); next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
@ -2145,7 +2145,7 @@ export class DownloadManager extends EventEmitter {
this.resolveExistingQueuedOpaqueFilenames(); this.resolveExistingQueuedOpaqueFilenames();
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`)); void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
if (next.completedCleanupPolicy !== "never") { if (!opts?.suppressRetroactiveCleanup && next.completedCleanupPolicy !== "never") {
this.applyRetroactiveCleanupPolicy(); this.applyRetroactiveCleanupPolicy();
} }
this.emitState(); this.emitState();
@ -3546,6 +3546,11 @@ export class DownloadManager extends EventEmitter {
if (!entry.isFile()) { if (!entry.isFile()) {
continue; continue;
} }
// Never collect our own remux temp/orphan sidecars (~rd<token>.<ext>): a
// partial file left by a crash mid-remux must not be swept into the library.
if (entry.name.startsWith("~rd")) {
continue;
}
const extension = path.extname(entry.name).toLowerCase(); const extension = path.extname(entry.name).toLowerCase();
if (!normalizedExtensions.has(extension)) { if (!normalizedExtensions.has(extension)) {
continue; continue;
@ -6107,6 +6112,11 @@ export class DownloadManager extends EventEmitter {
} }
private dropItemContribution(itemId: string): void { private dropItemContribution(itemId: string): void {
// NOTE: deliberately does NOT subtract from session.totalDownloadedBytes /
// sessionDownloadedBytes. Those are cumulative-session counters and must stay
// put when a completed item is removed from the queue (see the test "keeps
// cumulative session totals when completed items are removed from the queue").
// The retry path subtracts on its own because those bytes get re-downloaded.
this.itemContributedBytes.delete(itemId); this.itemContributedBytes.delete(itemId);
this.invalidateStatsCache(); this.invalidateStatsCache();
} }
@ -7151,6 +7161,9 @@ export class DownloadManager extends EventEmitter {
const abortController = new AbortController(); const abortController = new AbortController();
this.packagePostProcessAbortControllers.set(packageId, abortController); this.packagePostProcessAbortControllers.set(packageId, abortController);
// Holder so the task's own finally can identity-check itself (the task Promise
// cannot reference its own const inside its initializer). Assigned right after.
const handle: { task?: Promise<void> } = {};
const task = (async () => { const task = (async () => {
const slotWaitStart = nowMs(); const slotWaitStart = nowMs();
await this.acquirePostProcessSlot(packageId); await this.acquirePostProcessSlot(packageId);
@ -7197,8 +7210,16 @@ export class DownloadManager extends EventEmitter {
} while (this.hybridExtractRequeue.has(packageId)); } while (this.hybridExtractRequeue.has(packageId));
} finally { } finally {
this.releasePostProcessSlot(); this.releasePostProcessSlot();
this.packagePostProcessTasks.delete(packageId); // Identity guard: only clear the map entries if they still point to THIS
this.packagePostProcessAbortControllers.delete(packageId); // task/controller. After an abort deletes our handle a new run can install
// a fresh task+controller for the same packageId; a blind delete here would
// orphan that newer task (uncancellable) and allow a duplicate concurrent run.
if (this.packagePostProcessTasks.get(packageId) === handle.task) {
this.packagePostProcessTasks.delete(packageId);
}
if (this.packagePostProcessAbortControllers.get(packageId) === abortController) {
this.packagePostProcessAbortControllers.delete(packageId);
}
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
if (this.hybridExtractRequeue.delete(packageId)) { if (this.hybridExtractRequeue.delete(packageId)) {
@ -7209,6 +7230,7 @@ export class DownloadManager extends EventEmitter {
} }
})(); })();
handle.task = task;
this.packagePostProcessTasks.set(packageId, task); this.packagePostProcessTasks.set(packageId, task);
return task; return task;
} }

View File

@ -2883,6 +2883,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
const resumeCompletedAtStart = resumeCompleted.size; const resumeCompletedAtStart = resumeCompleted.size;
const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath)))); const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath))));
for (const archiveName of Array.from(resumeCompleted.values())) { for (const archiveName of Array.from(resumeCompleted.values())) {
// Nested-archive progress (keyed "nested:<name>") has no top-level candidate on
// disk to validate against, so it must NOT be pruned here — otherwise every
// extractPackageArchives call wiped it and nested archives were re-extracted on
// resume. It is cleared together with the rest once the package fully completes.
if (archiveName.startsWith("nested:")) {
continue;
}
if (!allCandidateNames.has(archiveName)) { if (!allCandidateNames.has(archiveName)) {
resumeCompleted.delete(archiveName); resumeCompleted.delete(archiveName);
} }

View File

@ -183,7 +183,14 @@ async function flushAsync(): Promise<void> {
} }
flushInFlight = true; flushInFlight = true;
const linesSnapshot = pendingLines.slice(); // Move (not copy) the pending lines out and take ownership. A concurrent write()
// during the await below pushes new lines AND can trim the 1MB cap from the FRONT
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
// here means await-time writes queue independently and nothing desyncs.
const linesSnapshot = pendingLines;
pendingLines = [];
pendingChars = 0;
const chunk = linesSnapshot.join(""); const chunk = linesSnapshot.join("");
try { try {
@ -200,9 +207,19 @@ async function flushAsync(): Promise<void> {
} else if (!primary.ok) { } else if (!primary.ok) {
writeStderr(`LOGGER write failed: ${primary.errorText}\n`); writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
} }
if (wroteAny) { if (!wroteAny) {
pendingLines = pendingLines.slice(linesSnapshot.length); // Write failed: requeue the unwritten lines AHEAD of anything that arrived
pendingChars = Math.max(0, pendingChars - chunk.length); // during the await (preserve order), then re-apply the buffer cap so a
// persistent write failure cannot grow the buffer without bound.
pendingLines = linesSnapshot.concat(pendingLines);
pendingChars += chunk.length;
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
const removed = pendingLines.shift();
if (!removed) {
break;
}
pendingChars = Math.max(0, pendingChars - removed.length);
}
} }
} finally { } finally {
flushInFlight = false; flushInFlight = false;

View File

@ -89,10 +89,11 @@ export function isRemuxableVideoFile(fileName: string): boolean {
// True when the release name explicitly marks it as a German release. Used in // True when the release name explicitly marks it as a German release. Used in
// tag mode to fall back to the first audio track (German-first scene convention) // tag mode to fall back to the first audio track (German-first scene convention)
// when the audio language tags are wrong (a German dub mislabeled "eng"), instead // when the audio language tags are wrong (a German dub mislabeled "eng"), instead
// of skipping. Deliberately requires an explicit german/deutsch/dubbed token — // of skipping. Deliberately requires an explicit german/deutsch token — the
// the ".DL." marker alone (present on every processed file) is not enough. // ".DL." marker alone (present on every processed file) is not enough, and a bare
// "dubbed" can mean an Italian/French dub, so it must NOT flag a German release.
export function looksLikeGermanRelease(fileName: string): boolean { export function looksLikeGermanRelease(fileName: string): boolean {
return /(^|[._\s-])(german|deutsch|dubbed)([._\s-]|$)/i.test(fileName); return /(^|[._\s-])(german|deutsch)([._\s-]|$)/i.test(fileName);
} }
function isGermanStream(stream: ProbedAudioStream): boolean { function isGermanStream(stream: ProbedAudioStream): boolean {
@ -100,8 +101,11 @@ function isGermanStream(stream: ProbedAudioStream): boolean {
if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) { if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) {
return true; return true;
} }
// 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
// pick the wrong track to keep (which then deletes the real German one).
const title = (stream.title || "").toLowerCase(); const title = (stream.title || "").toLowerCase();
return /\b(german|deutsch|ger|deu)\b/.test(title); return /\b(german|deutsch)\b/.test(title);
} }
// Decide which audio track to keep. Safety invariant: only ever choose to remux // Decide which audio track to keep. Safety invariant: only ever choose to remux

View File

@ -24,36 +24,49 @@ schlechtestes Risiko/Nutzen, kann für diesen User gar nicht feuern).
Advisor bestätigt Ansatz besser als bak-dance (kein Missing-File-Window). 3 neue Tests Advisor bestätigt Ansatz besser als bak-dance (kein Missing-File-Window). 3 neue Tests
(Recovery + Retry-Pfad), 41 video-processor-Tests grün, tsc=6 (Baseline). Commit 189af22. (Recovery + Retry-Pfad), 41 video-processor-Tests grün, tsc=6 (Baseline). Commit 189af22.
### Release 2 — Medium/Low (v1.7.190), ein Commit pro Fix ### Release 2 — v1.7.190 (GEFIXT + verifiziert, ein Commit pro Fix)
- [ ] **B/I** `app-controller.ts` importBackup settings-only: setSettings → applyRetroactive - [x] **L+M** video-processor.ts zu weite Deutsch-Erkennung. isGermanStream Titel-Fallback nur
CleanupPolicy purged die LIVE-Queue (Vertragsbruch "running queue stays untouched"; Dateien ganze Wörter (ger/deu raus → konnten falsche Spur picken + echte dt. löschen); looksLikeGerman
bleiben aber auf Platte). **Fix (Advisor):** (b) retroaktiven Sweep NUR für diesen Import Release 'dubbed' raus (ital./franz. Dub triggerte German-first). 2 Negativtests. Commit 272a41a.
unterdrücken (importierte Policy gilt weiter für künftige Completions über normalen Pfad) — - [x] **H** logger.ts flushAsync slice-snapshot korrumpiert bei 1MB-Cap-Trim während await →
NICHT über updateSettings routen (zweite Landmine resetHistoryForRetention). **I:** die 5 ungeschriebene Zeilen verloren. Move-snapshot (Buffer auf [] übernehmen) + Requeue bei
Live-Usage/Status-Felder overlayen wie updateSettings 322-331 INKL. Key-Filterung der Schreibfehler. Commit 4432fa2.
debridLinkApiKey*UsageBytes auf keyIds in restored debridLinkApiKeys (3 All-Time-Totals deckt - [x] **J+Q** download-manager. J: runPackagePostProcessing finally löschte Map-Eintrag ohne
setSettings-Math.max schon ab). Vorher 1 grep: forward-Anwendungsstelle der Policy bestätigen. Identity-Guard → Abort+Neustart-Race riss neuen Task raus (Waise + Doppel-Lauf); jetzt nur
- [ ] **C** ~~fixe Temp-Name-Kollision~~ → bereits in A subsumiert (unique Name). löschen wenn Map noch auf DIESEN Task/Controller zeigt (handle-Objekt wegen TS2454). Q:
- [ ] **D/E** debrid.ts Rotation: abort-Klassifizierung über `signal.reason` (TimeoutError vs collectFilesByExtensions filtert `~rd`-Temp-Präfix (crash-verwaiste Teil-Remuxe nie ins
cancel) statt Text/elapsedMs; API-Pfad 'cancel' umgeht. **VORHER empirisch bestätigen:** Library). Commit 3c33b98.
`AbortSignal.any([ac.signal, AbortSignal.timeout(x)]).reason?.name==='TimeoutError'` in DIESEM - [x] **P** extractor.ts nested-Resume-Keys (`nested:<name>`) bei jedem extractPackageArchives
Electron-Build; konservativen Fallback behalten, alte Guard nicht blind löschen. gepurged → verschachtelte Archive beim Resume neu entpackt; `startsWith("nested:")` im Prune
- [ ] **F** Mega-Web empty-streak Concurrency (streak permanent-park unreachable-to-clear vorher übersprungen. Commit 61a8304.
re-verifizieren bevor Maschinerie). - [x] **B/I** app-controller.ts importBackup settings-only purgte LIVE-Queue (Dateien blieben auf
- [ ] **G** download-manager `dropItemContribution` subtrahiert Session-Totals nicht. Platte) + rollte Usage-Zähler zurück. Fix: setSettings({suppressRetroactiveCleanup}) +
- [ ] **H** logger.ts `flushAsync` snapshot-by-slice korrumpiert bei 1MB-Cap-Trim während await overlayLiveUsageCounters (extrahiert+wiederverwendet, inkl. Key-Filter). Commit dc05b51.
→ move-snapshot (`linesSnapshot = pendingLines; pendingLines = []`).
- [ ] **I** → mit B zusammen (app-controller live-usage-Counter). ### Verifiziert KEINE Bugs / bewusst NICHT angefasst (Advisor-Disziplin: erst belegen, dann ändern)
- [ ] **J** download-manager `abortPackagePostProcessing` löscht Task-Handle ohne Identity-Guard. - **G** dropItemContribution "subtrahiert Session-Totals nicht" → **KEIN Bug**: Test "keeps
- [ ] **L** `isGermanStream` Title-Regex False-Positive. cumulative session totals when completed items are removed" kodifiziert die Absicht (Session-
- [ ] **M** `looksLikeGermanRelease` 'dubbed' zu breit. Zähler kumulativ, divergieren bewusst von der Item-Map; Retry-Pfad zieht ab, weil neu geladen
- [ ] **N** `stripDualLangFromFileName` Kollision. wird). Fix-Versuch ließ den Test failen → revertiert, Klarstellungs-Kommentar gesetzt.
- [ ] **O** classifyAccountFailure abort-Branch jetzt tot (nach v1.7.187-Fix). - **N** stripDualLangFromFileName "Kollision" → **bereits geguarded**: existsAsync-Skip verhindert
- [ ] **P** extractor.ts nested-Resume-Keys (`nested:<name>`) bei jedem extractPackageArchives Überschreiben; Remux machte Inhalt eh deutsch-only; collect strippt `.DL.` downstream. Residual
gepurged (prune-Whitelist nur top-level) → `startsWith("nested:")` in prune skippen. = generischer Rename-TOCTOU (in JEDEM Rename-Pfad), kein spezifischer Bug hier.
- [ ] **Q** (NEU, aus A-Review) `collectFilesByExtensions` filtert `~rd`-Temp-Präfix NICHT → - **D/E** abort-Klassifizierung über signal.reason statt Text → **deferred (Robustheit, kein
crash-verwaiste Teil-Remuxe könnten in Library gesammelt werden. Vorbestehend (alter fixer Live-Bug auf User-Pfad)**. BELEGT: mega-web-fallback normalisiert JEDEN Abort (Timeout UND
`~rdtmp` wurde überschrieben, neuer unique akkumuliert) → `~`-Präfix in collect skippen. Cancel) zu `new Error("aborted:mega-web")` → aktueller Guard `/aborted/i && !/timeout/i` FEUERT
→ v1.7.187-Cooldown LÄUFT auf dem Web-Pfad (User-Pfad). Einzige Imperfektion: Cancel >8s wird
fälschlich gecooled (minor). Empirisch bestätigt: `AbortSignal.any([ac,timeout]).reason?.name===
'TimeoutError'` (timeout) vs string/AbortError (cancel) — falls je gebaut: signal.aborted-gaten,
reason.name nutzen, Text-Fallback behalten, reason-Test. Hoch-Risiko (kritischer Unrestrict-Pfad
JEDES Downloads) → nicht für Robustheit anfassen. API-Pfad-Abort-Text nicht erschöpfend geprüft.
- **E** "API 'cancel'-Pfad umgeht" → **nicht real**: kein `'cancel'`-throw im Code gefunden.
- **O** classifyAccountFailure abort-Branch tot → **stehen lassen**: tot NUR wegen aktueller
Text-Interception; ein signal.aborted-gated D/E würde ihn wiederbeleben. Kein Kosmetik-Churn.
- **F** Mega-Web empty-streak Concurrency → **N-shaped, deferred**: Streak wird bei Erfolg (1956)
+ Nicht-Limit-Fehler (2005) gecleart; "bis Neustart gesperrt" ist bewusste Tageslimit-Logik,
Restart-cleared; Mega-Web single-flight → Concurrency greift nicht. Keine fühlbare Schädigung
konstruierbar → keine Park-State-Maschinerie.
- **C** → in A subsumiert (unique Temp-Name). **K** übersprungen (auto-rename-Reorder, Risiko≫Nutzen).
--- ---

View File

@ -85,6 +85,13 @@ describe("pickAudioTrack", () => {
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1 }); expect(d).toMatchObject({ action: "remux", audioRelIndex: 1 });
}); });
it("tag mode does NOT treat an ambiguous 3-letter title code as German (no false-positive pick)", () => {
// Two untagged tracks whose titles are only "Ger"/"Deu" must not be mistaken
// for a German track; with no real German signal this falls back to first.
const d = pickAudioTrack([{ language: "", title: "Ger" }, { language: "", title: "Deu" }], "tag");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
});
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" });
}); });
@ -125,6 +132,10 @@ describe("looksLikeGermanRelease", () => {
expect(looksLikeGermanRelease("Show.S01E01.DL.720p.x264.mkv")).toBe(false); expect(looksLikeGermanRelease("Show.S01E01.DL.720p.x264.mkv")).toBe(false);
expect(looksLikeGermanRelease("Show.S01E01.MULTi.1080p.mkv")).toBe(false); expect(looksLikeGermanRelease("Show.S01E01.MULTi.1080p.mkv")).toBe(false);
}); });
it("does not flag a non-German dub as a German release (bare 'Dubbed' is ambiguous)", () => {
expect(looksLikeGermanRelease("Movie.2020.ITALIAN.Dubbed.DL.1080p.mkv")).toBe(false);
expect(looksLikeGermanRelease("Movie.2020.FRENCH.DUBBED.DL.720p.mkv")).toBe(false);
});
}); });
describe("parseFfprobeAudioStreams", () => { describe("parseFfprobeAudioStreams", () => {