diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index c78304d..77fe9a5 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -8,13 +8,13 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält ## Zuletzt verifizierter Stand -- Verifiziert am: 1. September 2026, Europe/Berlin +- Verifiziert am: 2. September 2026, Europe/Berlin - Lokaler Pfad: `C:\Users\Sascha\Desktop\Claude & ChatGPT Projekte\Multi-Debrid-Downloader` - Arbeitsbranch: `release/v2.0.84` - Quellbasis: `release/v2.0.83` - Release-Tag: `v2.0.84` -- Baseline-Commit: `f3f2f60ece9322bfaddeb8f47d07830bb8fe273a` -- Hotfix-Basis: `f3f2f60ece9322bfaddeb8f47d07830bb8fe273a` +- Baseline-Commit: `cd0464d658c94e56047028863bba2f88c312256a` +- Hotfix-Basis: `cd0464d658c94e56047028863bba2f88c312256a` - Paketversion: `2.0.84` - Letztes Release: `Multi-Debrid-Downloader v2.0.84`, veröffentlicht am 1. September 2026 auf GitHub und Forgejo - Runtime-Voraussetzung: Node.js `>=20`; lokal verifiziert mit Node.js `24.19.0` und npm `11.17.0` @@ -202,6 +202,14 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält - Faire Verteilung zwischen gleichzeitigen Downloads, Proxy-Bewertung, Cooldowns und adaptives 429/503-Backoff bleiben unverändert. - Download-, Entpack-, Passwort- und Nachbearbeitungslogik wurden nicht verändert. +## Unveröffentlichte Änderungen nach v2.0.84 + +- Permanente Providerfehler wie Real-Debrids `file_unavailable` markieren den ursprünglichen Quelllink nun unmittelbar als offline. Bei mehrdeutigen Unrestrict- oder Kontopoolfehlern wird ein zuvor als online erkannter Rapidgator-, DDownload- oder 1Fichier-Link einmal direkt beim Hoster nachgeprüft; nur ein bestätigtes Offline-Ergebnis beendet ihn dauerhaft. +- Unter „Allgemein“ bestimmt „Wenn ein Quelllink offline geht“, ob nur der betroffene Mehrteil-Archivsatz oder das gesamte MDD-Paket übersprungen wird. Standard ist der Archivsatz. Die Zuordnung funktioniert auch dann, wenn Part 2 oder ein späterer RAR-, ZIP-, 7z- oder generischer Split-Part ausfällt. +- Im Archivsatz-Modus laufen andere Folgen beziehungsweise unabhängige Dateien desselben Pakets weiter. Im Paketmodus werden alle noch offenen Dateien übersprungen. Bereits abgeschlossene Downloads bleiben in beiden Modi unverändert. +- Verspätete Verfügbarkeitsantworten können einen bereits endgültig als offline markierten Eintrag nicht wieder auf online setzen. Die neue Nachprüfung wird pro Item gedrosselt, sodass mehrdeutige Providerfehler den Hoster nicht in einer schnellen Endlosschleife abfragen. +- Entpack-, Passwort- und Nachbearbeitungslogik wurden nicht verändert; die vorhandene Dateinamen-Zuordnung für Mehrteilarchive wird ausschließlich für die Auswahl noch offener Download-Items wiederverwendet. + ## Start-, Build- und Testbefehle ```powershell @@ -231,7 +239,11 @@ npm exec -- tsc --noEmit `npm run release:win` baut Release-Artefakte und darf nur nach ausdrücklicher Release-Freigabe ausgeführt werden. -## Verifizierungen vom 1. September 2026 +## Verifizierungen bis 2. September 2026 + +- Unveröffentlichter Offline-Link-Fix: zwei vollständige Download-Manager-Läufe mit jeweils 274 von 274 erfolgreichen Tests. Die neuen Regressionen bestätigen den Wechsel eines zuvor online gemeldeten Rapidgator-Links auf offline, die direkte Behandlung von Real-Debrid-`file_unavailable`, den Schutz vor verspäteten Online-Antworten, die Gruppierung von `partN.rar`, `.rar`/`.rNN`, `.zip.NNN`, `.7z.NNN` und `.NNN` von jedem Split-Part aus sowie beide Überspring-Bereiche einschließlich Abbruch aktiver Geschwister und Erhalt fertiger Downloads. Nach der strikten Trennung von Download- und Entpacker-Gruppierung liefen die 9 betroffenen Regressionen und alle 84 Extractor-Tests erneut erfolgreich. Ein zusätzlicher vollständiger Lauf hatte ausschließlich im fachfremden Fake-Timer-Test für den globalen Speed-Limiter einen 10.000-Timer-Abbruch; dieser Test war in den beiden vorherigen Komplettläufen und direkt danach isoliert erfolgreich. +- Vollständiger Clientlauf ohne die lokal nicht ausführbaren Symlink-Fixtures: 143 Testdateien erfolgreich, 1 optionale JVM-Testdatei übersprungen; 2.718 Tests erfolgreich und 4 übersprungen. Alle 84 unveränderten Extractor-Tests sind grün. Die Public-Release-Metadatendatei lief separat mit 22 von 24 erfolgreichen Tests; die zwei übrigen Fälle endeten unverändert bereits beim Anlegen ihrer Windows-Symlinks mit `EPERM`. +- Nach dem Offline-Link-Fix erfolgreich: TypeScript, Main-Build, Renderer-Produktionsbuild, Node-Self-Check und alle 16 Backup-API-Tests. Die bekannte Vite-Warnung betrifft den rund 582 KiB großen Renderer-Chunk. - Kandidat für `v2.0.84`: 256 von 256 fokussierten Proxy-Segment-, Storage-, AppController-, Arbeitsordner- und Einstellungsansichtstests erfolgreich. Die Grenzwerttests bestätigen Minimum 2, Standard 32, Maximum 80 und die Begrenzung höherer Werte auf 80. - Vollständiger Clientlauf des `v2.0.84`-Kandidaten: 143 Testdateien erfolgreich, 1 optionale JVM-Testdatei übersprungen; 2.728 Tests erfolgreich und 4 übersprungen. Ausschließlich die zwei bekannten Windows-Symlink-Fixtures scheiterten mangels Berechtigung vor ihrer Produktassertion mit `EPERM`. Alle 265 Download-Manager- und 84 Extractor-Tests sind vollständig grün. diff --git a/src/main/constants.ts b/src/main/constants.ts index c3d8043..7ec0936 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -102,9 +102,10 @@ export function defaultSettings(): AppSettings { reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, - maxParallelExtract: 2, - retryLimit: 0, - speedLimitEnabled: false, + maxParallelExtract: 2, + retryLimit: 0, + offlineSkipScope: "archive" as const, + speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", proxyDownloadEnabled: false, diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 0769c24..ea03e85 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -116,7 +116,7 @@ type ActiveTask = { itemId: string; packageId: string; abortController: AbortController; - abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "reset" | "none"; + abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "reset" | "offline_skip" | "none"; resumable: boolean; nonResumableCounted: boolean; freshRetryUsed?: boolean; @@ -722,14 +722,18 @@ export function getAuthoritativeRealDebridTotal( || evaluateCandidate(contentLength, "content-length"); } -function isPermanentLinkError(errorText: string): boolean { - const text = String(errorText || "").toLowerCase(); - return text.includes("permanent ungültig") - || /file.?not.?found/.test(text) - || /file.?unavailable/.test(text) - || /link.?is.?dead/.test(text) - || text.includes("file has been removed") - || text.includes("file has been deleted") +function isPermanentLinkError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("source_link_offline:") + || text.includes("permanent ungültig") + || /(?:^|[^a-z0-9_])(?:file_unavailable|invalid_link|bad_link)(?:$|[^a-z0-9_])/.test(text) + || /(?:^|[^a-z0-9_])file[ -]+unavailable(?:$|[^a-z0-9_])/.test(text) + || /file.?not.?found/.test(text) + || /link.?is.?dead/.test(text) + || text.includes("datei nicht gefunden") + || text.includes("datei nicht mehr verfügbar") + || text.includes("file has been removed") + || text.includes("file has been deleted") || text.includes("file is no longer available") || text.includes("file was removed") || text.includes("file was deleted"); @@ -834,9 +838,12 @@ function isProviderBusyUnrestrictError(errorText: string): boolean { || text.includes("zu viele downloads"); } -function isHosterUnavailableError(errorText: string): boolean { - return String(errorText || "").toLowerCase().includes("hosternotavailable"); -} +function isHosterUnavailableError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("hosternotavailable") + || /hoster[ _-]?(?:temporarily[ _-]?)?unavailable/.test(text) + || /hoster[ _-]?(?:maintenance|not[ _-]?supported)/.test(text); +} function isTemporaryUnrestrictError(errorText: string): boolean { const text = String(errorText || "").toLowerCase(); @@ -858,6 +865,20 @@ function isTemporaryUnrestrictError(errorText: string): boolean { || text.includes("worker error"); } +function isRapidgatorSourceLink(link: string): boolean { + try { + const hostname = new URL(link).hostname.toLowerCase(); + return hostname === "rapidgator.net" + || hostname.endsWith(".rapidgator.net") + || hostname === "rg.to" + || hostname.endsWith(".rg.to") + || hostname === "rapidgator.asia" + || hostname.endsWith(".rapidgator.asia"); + } catch { + return false; + } +} + export function classifyProviderUnrestrictBackoff(errorText: string): "busy" | "temporary" | null { const deepbridClassification = String(errorText || "").match(/deepbrid-anfrage fehlgeschlagen \((auth|rate_limit|temporary|link|malformed),/i)?.[1]?.toLowerCase(); if (deepbridClassification === "rate_limit") { @@ -1635,15 +1656,15 @@ export function decideAutoRenameBaseName( return { kind: "rename", baseName: targetBaseName, note }; } -const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*1\.rar$/; -const ARCHIVE_RAR_RE = /^(.*)\.rar$/; -const ARCHIVE_ZIP_SPLIT_RE = /^(.*)\.zip\.001$/; -const ARCHIVE_7Z_SPLIT_RE = /^(.*)\.7z\.001$/; -const ARCHIVE_GENERIC_001_RE = /^(.*)\.001$/; -const ARCHIVE_KNOWN_001_RE = /\.(zip|7z)\.001$/; -const REGEX_ESCAPE_RE = /[.*+?^${}()|[\]\\]/g; +const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*1\.rar$/; +const ARCHIVE_RAR_RE = /^(.*)\.rar$/; +const ARCHIVE_ZIP_SPLIT_RE = /^(.*)\.zip\.001$/; +const ARCHIVE_7Z_SPLIT_RE = /^(.*)\.7z\.001$/; +const ARCHIVE_GENERIC_001_RE = /^(.*)\.001$/; +const ARCHIVE_KNOWN_001_RE = /\.(zip|7z)\.001$/; +const REGEX_ESCAPE_RE = /[.*+?^${}()|[\]\\]/g; -export function resolveArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] { +export function resolveArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] { const normalizeArchiveMatchName = (value: string): string => stripDuplicateSuffixBeforeExtension(path.basename(String(value || ""))); const entryLower = normalizeArchiveMatchName(archiveName).toLowerCase(); @@ -1716,8 +1737,58 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download } } - return []; -} + return []; +} + +export function resolveOfflineArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] { + const normalizeArchiveMatchName = (value: string): string => + stripDuplicateSuffixBeforeExtension(path.basename(String(value || ""))); + const entryLower = normalizeArchiveMatchName(archiveName).toLowerCase(); + const itemBaseName = (item: DownloadItem): string => + normalizeArchiveMatchName(item.targetPath || item.fileName || ""); + + let pattern: RegExp | null = null; + const multipartMatch = entryLower.match(/^(.*)\.part0*\d+\.rar$/); + if (multipartMatch) { + const prefix = multipartMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); + pattern = new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i"); + } + if (!pattern) { + const rarMatch = entryLower.match(/^(.*)\.r(?:ar|\d{2,3})$/); + if (rarMatch) { + const stem = rarMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); + pattern = new RegExp(`^${stem}\\.r(ar|\\d{2,3})$`, "i"); + } + } + if (!pattern) { + const zipSplitMatch = entryLower.match(/^(.*)\.zip\.\d+$/); + if (zipSplitMatch) { + const stem = zipSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); + pattern = new RegExp(`^${stem}\\.zip(\\.\\d+)?$`, "i"); + } + } + if (!pattern) { + const sevenSplitMatch = entryLower.match(/^(.*)\.7z\.\d+$/); + if (sevenSplitMatch) { + const stem = sevenSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); + pattern = new RegExp(`^${stem}\\.7z(\\.\\d+)?$`, "i"); + } + } + if (!pattern && /^(.*)\.\d{3}$/.test(entryLower) && !/\.(zip|7z)\.\d{3}$/.test(entryLower)) { + const genericSplitMatch = entryLower.match(/^(.*)\.\d{3}$/); + if (genericSplitMatch) { + const stem = genericSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); + pattern = new RegExp(`^${stem}\\.\\d{3}$`, "i"); + } + } + + if (pattern) { + const matched = items.filter((item) => pattern!.test(itemBaseName(item))); + if (matched.length > 0) return matched; + } + + return items.filter((item) => itemBaseName(item).toLowerCase() === entryLower); +} function stripDuplicateSuffixBeforeExtension(fileName: string): string { return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); @@ -2032,6 +2103,8 @@ export class DownloadManager extends EventEmitter { private retryAfterByItem = new Map(); + private sourceAvailabilityRecheckAt = new Map(); + private packageDiskRetryAfterByPackage = new Map(); private diskWaitEvents: NonNullable = []; @@ -3220,9 +3293,10 @@ export class DownloadManager extends EventEmitter { if (!removedByPackageCleanup) { delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); - } - this.retryAfterByItem.delete(itemId); - this.retryStateByItem.delete(itemId); + } + this.retryAfterByItem.delete(itemId); + this.sourceAvailabilityRecheckAt.delete(itemId); + this.retryStateByItem.delete(itemId); this.dropItemContribution(itemId); if (!hasActiveTask) { this.releaseTargetPath(itemId); @@ -3394,8 +3468,9 @@ export class DownloadManager extends EventEmitter { clearTimeout(this.successDigestTimer); this.successDigestTimer = null; } - this.retryAfterByItem.clear(); - this.providerStartReservations.clear(); + this.retryAfterByItem.clear(); + this.sourceAvailabilityRecheckAt.clear(); + this.providerStartReservations.clear(); this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); this.reservedTargetPaths.clear(); @@ -3634,8 +3709,9 @@ export class DownloadManager extends EventEmitter { this.runItemIds.delete(itemId); this.runOutcomes.delete(itemId); - this.retryAfterByItem.delete(itemId); - this.retryStateByItem.delete(itemId); + this.retryAfterByItem.delete(itemId); + this.sourceAvailabilityRecheckAt.delete(itemId); + this.retryStateByItem.delete(itemId); } this.abortPackagePostProcessing(packageId, "skip"); @@ -3881,6 +3957,9 @@ export class DownloadManager extends EventEmitter { return; } if (item.status !== "queued") { + if (item.status === "failed" && item.onlineStatus === "offline") { + return; + } item.onlineStatus = result.online ? "online" : "offline"; item.updatedAt = nowMs(); return; @@ -3910,6 +3989,116 @@ export class DownloadManager extends EventEmitter { } } + private async confirmSourceOfflineAfterFailure( + item: DownloadItem, + errorText: string, + signal: AbortSignal + ): Promise<"provider" | "hoster" | null> { + if (isPermanentLinkError(errorText)) { + return "provider"; + } + if (item.onlineStatus === "offline") { + return "hoster"; + } + if (item.status !== "validating" || item.onlineStatus === "checking") { + return null; + } + const supported = isRapidgatorSourceLink(item.url) || isDdownloadLink(item.url) || isOneFichierLink(item.url); + if (!supported) { + return null; + } + const checkedAt = nowMs(); + const previousCheckAt = this.sourceAvailabilityRecheckAt.get(item.id) || 0; + if (checkedAt - previousCheckAt < 30_000) { + return null; + } + this.sourceAvailabilityRecheckAt.set(item.id, checkedAt); + + try { + const checkSignal = AbortSignal.any([signal, AbortSignal.timeout(15_000)]); + if (isRapidgatorSourceLink(item.url)) { + const result = await checkRapidgatorOnline(item.url, checkSignal); + return result && !result.online ? "hoster" : null; + } + if (isDdownloadLink(item.url)) { + const result = await checkDdownloadOnline(item.url, checkSignal); + return result && !result.online ? "hoster" : null; + } + const results = await checkOneFichierLinks([item.url], checkSignal); + const result = results.get(item.url); + return result && !result.online ? "hoster" : null; + } catch (error) { + if (signal.aborted) { + throw error; + } + logger.warn(`Quelllink-Nachprüfung fehlgeschlagen: item=${item.fileName || item.id}, error=${compactErrorText(error)}`); + return null; + } + } + + private markItemOfflineAndSkipRelated( + pkg: PackageEntry, + item: DownloadItem, + errorText: string, + confirmedBy: "provider" | "hoster" + ): void { + const cleanError = errorText + .replace(/^Error:\s*/i, "") + .replace(/^source_link_offline:(?:provider|hoster):/i, "") + .trim(); + item.status = "failed"; + item.onlineStatus = "offline"; + item.lastError = cleanError || "Quelllink ist nicht mehr verfügbar"; + item.fullStatus = "Offline"; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.retryAfterByItem.delete(item.id); + this.retryStateByItem.delete(item.id); + this.sourceAvailabilityRecheckAt.delete(item.id); + this.recordRunOutcome(item.id, "failed"); + + const packageItems = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter(Boolean) as DownloadItem[]; + const scopeItems = this.settings.offlineSkipScope === "package" + ? packageItems + : resolveOfflineArchiveItemsFromList(item.fileName, packageItems); + const skippedItems: DownloadItem[] = []; + for (const related of scopeItems) { + if (related.id === item.id || isFinishedStatus(related.status)) { + continue; + } + const relatedActive = this.activeTasks.get(related.id); + if (relatedActive) { + relatedActive.abortReason = "offline_skip"; + relatedActive.abortController.abort("offline_skip"); + } + related.status = "cancelled"; + related.fullStatus = this.settings.offlineSkipScope === "package" + ? "Übersprungen (Paket enthält Offline-Link)" + : "Übersprungen (Archivteil offline)"; + related.lastError = `Übersprungen, weil ${item.fileName} offline ist`; + related.speedBps = 0; + related.updatedAt = nowMs(); + this.retryAfterByItem.delete(related.id); + this.retryStateByItem.delete(related.id); + this.sourceAvailabilityRecheckAt.delete(related.id); + if (!relatedActive) { + this.releaseTargetPath(related.id); + } + this.recordRunOutcome(related.id, "cancelled"); + skippedItems.push(related); + } + + this.logPackageForItem(item, "WARN", "Quelllink während des Downloads offline geworden", { + confirmedBy, + offlineSkipScope: this.settings.offlineSkipScope, + skippedItems: skippedItems.length, + skippedNames: skippedItems.map((entry) => entry.fileName).join(" | ") + }); + this.refreshPackageStatus(pkg); + } + private async checkDdownloadItems(itemIds: string[]): Promise { const itemsToCheck: Array<{ itemId: string; url: string }> = []; for (const itemId of itemIds) { @@ -3948,6 +4137,9 @@ export class DownloadManager extends EventEmitter { if (item.onlineStatus === "checking") item.onlineStatus = undefined; return; } + if (item.status === "failed" && item.onlineStatus === "offline") { + return; + } if (!result.online) { item.onlineStatus = "offline"; item.updatedAt = nowMs(); @@ -4009,6 +4201,9 @@ export class DownloadManager extends EventEmitter { if (item.onlineStatus === "checking") item.onlineStatus = undefined; return; } + if (item.status === "failed" && item.onlineStatus === "offline") { + return; + } if (!result.online) { item.onlineStatus = "offline"; item.updatedAt = nowMs(); @@ -6059,8 +6254,9 @@ export class DownloadManager extends EventEmitter { this.dropItemContribution(itemId); this.runOutcomes.delete(itemId); this.runItemIds.delete(itemId); - this.retryAfterByItem.delete(itemId); - this.retryStateByItem.delete(itemId); + this.retryAfterByItem.delete(itemId); + this.sourceAvailabilityRecheckAt.delete(itemId); + this.retryStateByItem.delete(itemId); item.status = "queued"; item.downloadedBytes = 0; @@ -6142,8 +6338,9 @@ export class DownloadManager extends EventEmitter { this.dropItemContribution(itemId); this.runOutcomes.delete(itemId); - this.retryAfterByItem.delete(itemId); - this.retryStateByItem.delete(itemId); + this.retryAfterByItem.delete(itemId); + this.sourceAvailabilityRecheckAt.delete(itemId); + this.retryStateByItem.delete(itemId); item.status = "queued"; item.downloadedBytes = 0; @@ -6240,9 +6437,10 @@ export class DownloadManager extends EventEmitter { item.status = "cancelled"; item.fullStatus = "Übersprungen"; item.speedBps = 0; - item.updatedAt = nowMs(); - this.retryAfterByItem.delete(itemId); - this.retryStateByItem.delete(itemId); + item.updatedAt = nowMs(); + this.retryAfterByItem.delete(itemId); + this.sourceAvailabilityRecheckAt.delete(itemId); + this.retryStateByItem.delete(itemId); this.releaseTargetPath(itemId); this.recordRunOutcome(itemId, "cancelled"); affectedPackageIds.add(item.packageId); @@ -8842,9 +9040,10 @@ export class DownloadManager extends EventEmitter { } this.historyRecordedPackages.delete(packageId); this.abortPackagePostProcessing(packageId, "package_removed"); - for (const itemId of itemIds) { - this.retryAfterByItem.delete(itemId); - this.retryStateByItem.delete(itemId); + for (const itemId of itemIds) { + this.retryAfterByItem.delete(itemId); + this.sourceAvailabilityRecheckAt.delete(itemId); + this.retryStateByItem.delete(itemId); this.releaseTargetPath(itemId); this.dropItemContribution(itemId); delete this.session.items[itemId]; @@ -10145,12 +10344,16 @@ export class DownloadManager extends EventEmitter { } } ); - } catch (unrestrictError) { - if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { - this.recordProviderFailure(cooldownProvider); + } catch (unrestrictError) { + if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { + this.recordProviderFailure(cooldownProvider); throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`); - } - const errText = compactErrorText(unrestrictError); + } + const errText = compactErrorText(unrestrictError); + const offlineConfirmation = await this.confirmSourceOfflineAfterFailure(item, errText, active.abortController.signal); + if (offlineConfirmation) { + throw new Error(`source_link_offline:${offlineConfirmation}:${errText}`); + } if (isProviderUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) { this.recordProviderFailure(cooldownProvider); const backoffKind = classifyProviderUnrestrictBackoff(errText); @@ -10481,9 +10684,22 @@ export class DownloadManager extends EventEmitter { genericErrorRetries: Number(active.genericErrorRetries || 0), unrestrictRetries: Number(active.unrestrictRetries || 0) }); - } else if (reason === "reset") { - this.retryStateByItem.delete(item.id); - } else if (reason === "package_toggle") { + } else if (reason === "reset") { + this.retryStateByItem.delete(item.id); + } else if (reason === "offline_skip") { + this.logPackageForItem(item, "WARN", "Download wegen Offline-Link im Zusammenhang übersprungen", { + reason + }); + item.status = "cancelled"; + item.speedBps = 0; + if (!/Übersprungen/.test(item.fullStatus || "")) { + item.fullStatus = "Übersprungen (Offline-Link im Zusammenhang)"; + } + item.updatedAt = nowMs(); + this.retryAfterByItem.delete(item.id); + this.retryStateByItem.delete(item.id); + this.recordRunOutcome(item.id, "cancelled"); + } else if (reason === "package_toggle") { this.logPackageForItem(item, "WARN", "Download wegen Paket-Toggle pausiert", { reason }); @@ -10693,20 +10909,13 @@ export class DownloadManager extends EventEmitter { return; } - if (isPermanentLinkError(errorText)) { - logger.error(`Link permanent ungültig: item=${item.fileName || item.id}, error=${errorText}, link=${item.url.slice(0, 80)}`); - item.status = "failed"; - this.recordRunOutcome(item.id, "failed"); - item.lastError = errorText; - item.fullStatus = `Link ungültig: ${errorText}`; - item.speedBps = 0; - item.updatedAt = nowMs(); - this.retryStateByItem.delete(item.id); - const failPkgDead = this.session.packages[item.packageId]; - if (failPkgDead) this.refreshPackageStatus(failPkgDead); - this.persistSoon(); - this.emitState(); - return; + if (isPermanentLinkError(errorText)) { + logger.error(`Link permanent ungültig: item=${item.fileName || item.id}, error=${errorText}, link=${item.url.slice(0, 80)}`); + const confirmedBy = /source_link_offline:hoster:/i.test(errorText) ? "hoster" : "provider"; + this.markItemOfflineAndSkipRelated(pkg, item, errorText, confirmedBy); + this.persistSoon(); + this.emitState(); + return; } const totalNonStallFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0); diff --git a/src/main/renderer-settings.ts b/src/main/renderer-settings.ts index 383209b..02bb87f 100644 --- a/src/main/renderer-settings.ts +++ b/src/main/renderer-settings.ts @@ -97,6 +97,9 @@ export function validateRendererSettingsUpdate(value: unknown, current: AppSetti if (key === "themePreference" && entry !== "light" && entry !== "dark" && entry !== "system") { invalid(); } + if (key === "offlineSkipScope" && entry !== "archive" && entry !== "package") { + invalid(); + } if (key === "dailyStartMinuteOfDay" && (!Number.isInteger(entry) || (entry as number) < 0 || (entry as number) > 1_439)) { invalid(); } diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 45b532b..96cff45 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -202,6 +202,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings maxParallel: settings.maxParallel, maxParallelExtract: settings.maxParallelExtract, retryLimit: settings.retryLimit, + offlineSkipScope: settings.offlineSkipScope, speedLimitEnabled: settings.speedLimitEnabled, speedLimitKbps: settings.speedLimitKbps, speedLimitMode: settings.speedLimitMode, diff --git a/src/main/storage.ts b/src/main/storage.ts index 21eabec..96433b4 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -608,9 +608,10 @@ export function normalizeSettings(settings: AppSettings): AppSettings { autoResumeOnStart: Boolean(settings.autoResumeOnStart), autoReconnect: Boolean(settings.autoReconnect), maxParallel: clampNumber(settings.maxParallel, defaults.maxParallel, 1, 50), - maxParallelExtract: clampNumber(settings.maxParallelExtract, defaults.maxParallelExtract, 1, 8), - retryLimit: clampNumber(settings.retryLimit, defaults.retryLimit, 0, 99), - reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600), + maxParallelExtract: clampNumber(settings.maxParallelExtract, defaults.maxParallelExtract, 1, 8), + retryLimit: clampNumber(settings.retryLimit, defaults.retryLimit, 0, 99), + offlineSkipScope: settings.offlineSkipScope === "package" ? "package" : "archive", + reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600), completedCleanupPolicy: settings.completedCleanupPolicy, speedLimitEnabled: Boolean(settings.speedLimitEnabled), speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6f96e4c..eb4cfd2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -952,7 +952,7 @@ const emptySnapshot = (): UiSnapshot => ({ cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", - maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", + maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, offlineSkipScope: "archive", speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", proxyDownloadEnabled: false, proxyListPath: "", proxyApiProxyIndex: 1, proxyConnectionsPerDownload: 32, updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, theme: "dark", themePreference: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 49e0f24..be1dfde 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -81,6 +81,9 @@ const pairs = [ ["Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.", "Session counters and results for the current queue are shown."], ["Sitzung zurücksetzen", "Reset session"], ["Gesamt zurücksetzen", "Reset total"], ["Fehler zurücksetzen", "Reset errors"], ["Bandbreitenverlauf", "Bandwidth history"], ["Bandbreitenverlauf der letzten 60 Sekunden", "Bandwidth history for the last 60 seconds"], ["Provider", "Provider"], ["Daten", "Data"], ["Ergebnisse", "Results"], ["Nie", "Never"], ["Sofort", "Immediately"], ["Beim App-Start", "On app startup"], ["Sobald Paket fertig ist", "When package completes"], ["Überschreiben", "Overwrite"], ["Überspringen", "Skip"], ["Nachfragen", "Ask"], + ["Wenn ein Quelllink offline geht", "When a source link goes offline"], ["Betroffenen Archivsatz überspringen", "Skip affected archive set"], ["Gesamtes Paket überspringen", "Skip entire package"], + ["Beim Archivsatz werden alle noch offenen Teile desselben Mehrteil-Archivs übersprungen. Bereits abgeschlossene Downloads bleiben erhalten.", "For an archive set, all remaining parts of the same multipart archive are skipped. Completed downloads are kept."], + ["Übersprungen (Paket enthält Offline-Link)", "Skipped (package contains offline link)"], ["Übersprungen (Archivteil offline)", "Skipped (archive part offline)"], ["Übersprungen (Offline-Link im Zusammenhang)", "Skipped (related source link offline)"], ["Abbrechen", "Cancel"], ["Speichern", "Save"], ["Schließen", "Close"], ["Löschen", "Delete"], ["Suchen", "Search"], ["Zurücksetzen", "Reset"], ["Nur fehlerhafte Dateien zurücksetzen", "Reset failed files only"], ["Gesamtes Paket zurücksetzen", "Reset entire package"], ["Ausgewählte Pakete vollständig zurücksetzen", "Reset selected packages completely"], ["Testen", "Test"], ["Öffnen", "Open"], ["Noch keine Downloads", "No downloads yet"], ["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."], ["Keine passenden Downloads", "No matching downloads"], ["Alle anzeigen", "Show all"], ["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Linksammler-Filter", "Link collector filters"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"], diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index d5c3c72..05f1814 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -611,6 +611,17 @@ export function buildSettingsFormViewModel({ fields: [ { id: "maxParallel", kind: "number", label: "Max. gleichzeitige Downloads", value: String(settings.maxParallel), min: 1, max: 50 }, { id: "retryLimit", kind: "number", label: "Automatische Wiederholungen", value: String(settings.retryLimit), min: 0, max: 99 }, + { + id: "offlineSkipScope", + kind: "select", + label: "Wenn ein Quelllink offline geht", + value: settings.offlineSkipScope, + options: [ + { value: "archive", label: "Betroffenen Archivsatz überspringen" }, + { value: "package", label: "Gesamtes Paket überspringen" } + ], + help: "Beim Archivsatz werden alle noch offenen Teile desselben Mehrteil-Archivs übersprungen. Bereits abgeschlossene Downloads bleiben erhalten." + }, { id: "autoResumeOnStart", kind: "switch", label: "Beim Start automatisch fortsetzen", value: settings.autoResumeOnStart }, { id: "clipboardWatch", kind: "switch", label: "Zwischenablage überwachen", value: settings.clipboardWatch } ] diff --git a/src/shared/types.ts b/src/shared/types.ts index 505920c..89f1328 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -13,7 +13,8 @@ export type DownloadStatus = export type CleanupMode = "none" | "trash" | "delete"; export type ConflictMode = "overwrite" | "skip" | "rename" | "ask"; export type SpeedMode = "global" | "per_download"; -export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done"; +export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done"; +export type OfflineSkipScope = "archive" | "package"; export type DebridProvider = | "realdebrid" | "megadebrid" @@ -205,9 +206,10 @@ export interface AppSettings extends DailyStartSettings, ProxyDownloadSettings { reconnectWaitSeconds: number; completedCleanupPolicy: FinishedCleanupPolicy; maxParallel: number; - maxParallelExtract: number; - retryLimit: number; - speedLimitEnabled: boolean; + maxParallelExtract: number; + retryLimit: number; + offlineSkipScope: OfflineSkipScope; + speedLimitEnabled: boolean; speedLimitKbps: number; speedLimitMode: SpeedMode; updateRepo: string; @@ -343,6 +345,7 @@ export interface RendererSettings extends DailyStartSettings, ProxyDownloadSetti maxParallel: number; maxParallelExtract: number; retryLimit: number; + offlineSkipScope: OfflineSkipScope; speedLimitEnabled: boolean; speedLimitKbps: number; speedLimitMode: SpeedMode; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index d596d64..5de22a0 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -6,7 +6,7 @@ import crypto from "node:crypto"; import { EventEmitter, once } from "node:events"; import AdmZip from "adm-zip"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager"; +import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveOfflineArchiveItemsFromList, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager"; import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion"; import { DiskReservationCoordinator } from "../src/main/disk-space"; import { defaultSettings } from "../src/main/constants"; @@ -1143,7 +1143,7 @@ describe("extractArchiveNameFromExtractorLogMessage", () => { }); }); -describe("resolveArchiveItemsFromList", () => { +describe("resolveArchiveItemsFromList", () => { it("includes duplicate-suffixed archive copies in multipart matches", () => { const items = [ { id: "dup-1", fileName: "show.s01e26.part1.rar" }, @@ -1153,9 +1153,37 @@ describe("resolveArchiveItemsFromList", () => { const resolved = resolveArchiveItemsFromList("show.s01e26.part1.rar", items); - expect(resolved.map((item) => item.id)).toEqual(["dup-1", "dup-2", "dup-3"]); - }); -}); + expect(resolved.map((item) => item.id)).toEqual(["dup-1", "dup-2", "dup-3"]); + }); +}); + +describe("resolveOfflineArchiveItemsFromList", () => { + it.each([ + "show.s01e26.part2.rar", + "legacy.s01e26.r01", + "split.s01e26.zip.002", + "seven.s01e26.7z.003", + "generic.s01e26.002" + ])("resolves every related archive part when the unavailable link is %s", (archiveName) => { + const stem = archiveName + .replace(/\.part\d+\.rar$/i, "") + .replace(/\.r\d+$/i, "") + .replace(/\.(zip|7z)\.\d+$/i, "") + .replace(/\.\d{3}$/i, ""); + const fileNames = archiveName.includes(".part") + ? [`${stem}.part1.rar`, `${stem}.part2.rar`, `${stem}.part3.rar`] + : archiveName.includes(".r01") + ? [`${stem}.rar`, `${stem}.r00`, `${stem}.r01`] + : archiveName.includes(".zip.") + ? [`${stem}.zip.001`, `${stem}.zip.002`, `${stem}.zip.003`] + : archiveName.includes(".7z.") + ? [`${stem}.7z.001`, `${stem}.7z.002`, `${stem}.7z.003`] + : [`${stem}.001`, `${stem}.002`, `${stem}.003`]; + const items = fileNames.map((fileName, index) => ({ id: String(index + 1), fileName })) as DownloadItem[]; + + expect(resolveOfflineArchiveItemsFromList(archiveName, items).map((item) => item.fileName)).toEqual(fileNames); + }); +}); describe("download completion planning", () => { it("does not allow early finish on provider metadata alone", () => { @@ -1328,6 +1356,184 @@ describe("download manager", () => { expect(item.onlineStatus).toBe("online"); }); + it("does not let a stale availability response overwrite a terminal offline result", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-stale-availability-")); + tempDirs.push(root); + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "availability", links: ["https://example.com/file.bin"] }]); + const internal = manager as any; + const item = Object.values(internal.session.items)[0] as DownloadItem; + item.status = "failed"; + item.fullStatus = "Offline"; + item.onlineStatus = "offline"; + + internal.applyRapidgatorCheckResult(item, { online: true, fileName: "file.bin", fileSizeBytes: 16 }); + + expect(item.status).toBe("failed"); + expect(item.onlineStatus).toBe("offline"); + expect(item.fullStatus).toBe("Offline"); + }); + + it("marks a previously online RapidGator link offline and skips its multipart archive after an ambiguous unrestrict failure", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-offline-recheck-")); + tempDirs.push(root); + const links = [ + "https://rapidgator.net/file/22222222222222222222222222222222/show.s01e02.part2.rar.html", + "https://rapidgator.net/file/11111111111111111111111111111111/show.s01e02.part1.rar.html", + "https://rapidgator.net/file/33333333333333333333333333333333/show.s01e02.part3.rar.html", + "https://rapidgator.net/file/44444444444444444444444444444444/show.s01e03.part1.rar.html" + ]; + const sourceChecks = new Map(); + const unrestrictLinks: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + const sourceLink = String((init?.body as URLSearchParams)?.get("link") || ""); + unrestrictLinks.push(sourceLink); + if (sourceLink === links[0]) { + return new Response(JSON.stringify({ error: "unknown_error", error_code: 99 }), { + status: 400, + headers: { "Content-Type": "application/json" } + }); + } + return new Response(JSON.stringify({ + download: `https://direct.example/${path.basename(new URL(sourceLink).pathname).replace(/\.html$/i, "")}`, + filename: path.basename(new URL(sourceLink).pathname).replace(/\.html$/i, ""), + filesize: 16 + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (links.includes(url)) { + const count = (sourceChecks.get(url) || 0) + 1; + sourceChecks.set(url, count); + if (url === links[0] && count > 1) { + return new Response("File not found", { status: 404 }); + } + const fileName = path.basename(new URL(url).pathname).replace(/\.html$/i, ""); + return new Response(`${fileName}
File size: 16 B
`, { + status: 200, + headers: { "Content-Type": "text/html" } + }); + } + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + + const manager = new DownloadManager({ + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + maxParallel: 1, + autoExtract: false, + enableIntegrityCheck: false, + offlineSkipScope: "archive" + }, emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "show-season", links }]); + await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.onlineStatus === "online"), 5_000); + + (manager as any).downloadToFile = async (active: { itemId: string }, _url: string, targetPath: string) => { + const item = (manager as any).session.items[active.itemId] as DownloadItem; + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, Buffer.alloc(16, 7)); + item.downloadedBytes = 16; + item.totalBytes = 16; + item.progressPercent = 100; + return { resumable: true }; + }; + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 10_000); + + const items = Object.values(manager.getSnapshot().session.items); + const byName = new Map(items.map((item) => [item.fileName, item])); + expect(byName.get("show.s01e02.part2.rar")).toMatchObject({ status: "failed", onlineStatus: "offline", fullStatus: "Offline" }); + expect(byName.get("show.s01e02.part1.rar")).toMatchObject({ status: "cancelled", fullStatus: "Übersprungen (Archivteil offline)" }); + expect(byName.get("show.s01e02.part3.rar")).toMatchObject({ status: "cancelled", fullStatus: "Übersprungen (Archivteil offline)" }); + expect(byName.get("show.s01e03.part1.rar")).toMatchObject({ status: "completed", onlineStatus: "online" }); + expect(sourceChecks.get(links[0])).toBe(2); + expect(unrestrictLinks).toEqual([links[0], links[3]]); + }); + + it("uses a permanent Real-Debrid file_unavailable response without a redundant hoster recheck", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-offline-provider-")); + tempDirs.push(root); + const link = "https://rapidgator.net/file/55555555555555555555555555555555/missing.mkv.html"; + let sourceChecks = 0; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === link) { + sourceChecks += 1; + return new Response("missing.mkv
File size: 16 B
", { + status: 200, + headers: { "Content-Type": "text/html" } + }); + } + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response(JSON.stringify({ error: "file_unavailable", error_code: 22 }), { + status: 400, + headers: { "Content-Type": "application/json" } + }); + } + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + + const manager = new DownloadManager({ + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "missing", links: [link] }]); + await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.onlineStatus === "online", 5_000); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 10_000); + + expect(Object.values(manager.getSnapshot().session.items)[0]).toMatchObject({ + status: "failed", + onlineStatus: "offline", + fullStatus: "Offline" + }); + expect(sourceChecks).toBe(1); + }); + + it("skips every unfinished item but keeps completed downloads when the offline scope is the whole package", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-offline-package-")); + tempDirs.push(root); + const manager = new DownloadManager({ + ...defaultSettings(), + offlineSkipScope: "package", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "package", links: [ + "https://example.test/offline.part2.rar", + "https://example.test/pending.part1.rar", + "https://example.test/completed.mkv" + ] }]); + const internal = manager as any; + const items = Object.values(internal.session.items) as DownloadItem[]; + items[0].status = "validating"; + items[0].onlineStatus = "online"; + items[1].status = "downloading"; + const relatedAbortController = new AbortController(); + const relatedActive = { abortReason: "none", abortController: relatedAbortController }; + internal.activeTasks.set(items[1].id, relatedActive); + items[2].status = "completed"; + items[2].downloadedBytes = 16; + items[2].totalBytes = 16; + items[2].progressPercent = 100; + + internal.markItemOfflineAndSkipRelated(internal.session.packages[items[0].packageId], items[0], "file_unavailable", "provider"); + + expect(items[0]).toMatchObject({ status: "failed", onlineStatus: "offline" }); + expect(items[1]).toMatchObject({ status: "cancelled", fullStatus: "Übersprungen (Paket enthält Offline-Link)" }); + expect(relatedActive.abortReason).toBe("offline_skip"); + expect(relatedAbortController.signal.aborted).toBe(true); + expect(items[2]).toMatchObject({ status: "completed", downloadedBytes: 16 }); + }); + it("applies an imported settings snapshot without touching queued items or filesystem workflows", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-import-")); tempDirs.push(root); diff --git a/tests/renderer-settings.test.ts b/tests/renderer-settings.test.ts index bb02608..86d9497 100644 --- a/tests/renderer-settings.test.ts +++ b/tests/renderer-settings.test.ts @@ -13,6 +13,14 @@ describe("renderer settings validation", () => { .toEqual({ createWorkDirectoriesOnStartup: false }); }); + it("projects and validates both offline skip scopes", () => { + const current = { ...defaultSettings(), offlineSkipScope: "archive" as const }; + + expect(createRendererSettings(current).offlineSkipScope).toBe("archive"); + expect(validateRendererSettingsUpdate({ offlineSkipScope: "package" }, current)).toEqual({ offlineSkipScope: "package" }); + expect(() => validateRendererSettingsUpdate({ offlineSkipScope: "item" }, current)).toThrow("Settings-Payload ist ungültig"); + }); + it("projects and validates the semantic theme preference independently from the resolved palette", () => { const current = { ...defaultSettings(), theme: "dark" as const, themePreference: "system" as const }; const projected = createRendererSettings(current); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 3d329d1..ad5c9b6 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -660,6 +660,29 @@ describe("settings views", () => { })); }); + it("offers archive-set and whole-package handling for links that go offline", () => { + const form = buildSettingsFormViewModel({ + settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" }, + section: "allgemein", + speedLimitInput: "0", + scheduleSpeedInputs: {} + }); + const field = form.groups.flatMap((group) => group.fields) + .find((entry) => entry.id === "offlineSkipScope"); + + expect(field).toEqual({ + id: "offlineSkipScope", + kind: "select", + label: "Wenn ein Quelllink offline geht", + value: "archive", + options: [ + { value: "archive", label: "Betroffenen Archivsatz überspringen" }, + { value: "package", label: "Gesamtes Paket überspringen" } + ], + help: "Beim Archivsatz werden alle noch offenen Teile desselben Mehrteil-Archivs übersprungen. Bereits abgeschlossene Downloads bleiben erhalten." + }); + }); + it("projects proxy segmentation controls with safe defaults and connection totals", () => { const form = buildSettingsFormViewModel({ settings: { diff --git a/tests/storage.test.ts b/tests/storage.test.ts index bd0088d..811396e 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -590,7 +590,8 @@ describe("settings storage", () => { providerTertiary: "invalid-provider" as unknown as AppSettings["providerTertiary"], cleanupMode: "broken" as unknown as AppSettings["cleanupMode"], extractConflictMode: "broken" as unknown as AppSettings["extractConflictMode"], - completedCleanupPolicy: "broken" as unknown as AppSettings["completedCleanupPolicy"], + completedCleanupPolicy: "broken" as unknown as AppSettings["completedCleanupPolicy"], + offlineSkipScope: "broken" as unknown as AppSettings["offlineSkipScope"], speedLimitMode: "broken" as unknown as AppSettings["speedLimitMode"], maxParallel: 0, retryLimit: 999, @@ -611,7 +612,8 @@ describe("settings storage", () => { expect(normalized.providerTertiary).toBe("none"); expect(normalized.cleanupMode).toBe("none"); expect(normalized.extractConflictMode).toBe("overwrite"); - expect(normalized.completedCleanupPolicy).toBe("never"); + expect(normalized.completedCleanupPolicy).toBe("never"); + expect(normalized.offlineSkipScope).toBe("archive"); expect(normalized.speedLimitMode).toBe("global"); expect(normalized.maxParallel).toBe(1); expect(normalized.retryLimit).toBe(99); @@ -631,6 +633,11 @@ describe("settings storage", () => { expect(defaultSettings().proxyConnectionsPerDownload).toBe(32); }); + it("uses archive-set skipping by default and preserves an explicit whole-package choice", () => { + expect(defaultSettings().offlineSkipScope).toBe("archive"); + expect(normalizeSettings({ ...defaultSettings(), offlineSkipScope: "package" }).offlineSkipScope).toBe("package"); + }); + it("migrates the previous private update repository to the public release repository", () => { for (const updateRepo of [ "legacy-owner/real-debrid-downloader", diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index d65a1a8..07f73fa 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -114,7 +114,8 @@ function createSettings(): AppSettings { completedCleanupPolicy: "never", maxParallel: 4, maxParallelExtract: 2, - retryLimit: 3, + retryLimit: 3, + offlineSkipScope: "archive", speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",