Handle source links going offline during downloads

This commit is contained in:
Sucukdeluxe
2026-09-02 11:32:06 +02:00
parent cd0464d658
commit ad75469854
15 changed files with 573 additions and 84 deletions
+16 -4
View File
@@ -8,13 +8,13 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
## Zuletzt verifizierter Stand ## 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` - Lokaler Pfad: `C:\Users\Sascha\Desktop\Claude & ChatGPT Projekte\Multi-Debrid-Downloader`
- Arbeitsbranch: `release/v2.0.84` - Arbeitsbranch: `release/v2.0.84`
- Quellbasis: `release/v2.0.83` - Quellbasis: `release/v2.0.83`
- Release-Tag: `v2.0.84` - Release-Tag: `v2.0.84`
- Baseline-Commit: `f3f2f60ece9322bfaddeb8f47d07830bb8fe273a` - Baseline-Commit: `cd0464d658c94e56047028863bba2f88c312256a`
- Hotfix-Basis: `f3f2f60ece9322bfaddeb8f47d07830bb8fe273a` - Hotfix-Basis: `cd0464d658c94e56047028863bba2f88c312256a`
- Paketversion: `2.0.84` - Paketversion: `2.0.84`
- Letztes Release: `Multi-Debrid-Downloader v2.0.84`, veröffentlicht am 1. September 2026 auf GitHub und Forgejo - 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` - 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. - 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. - 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 ## Start-, Build- und Testbefehle
```powershell ```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. `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. - 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. - 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.
+4 -3
View File
@@ -102,9 +102,10 @@ export function defaultSettings(): AppSettings {
reconnectWaitSeconds: 45, reconnectWaitSeconds: 45,
completedCleanupPolicy: "never", completedCleanupPolicy: "never",
maxParallel: 4, maxParallel: 4,
maxParallelExtract: 2, maxParallelExtract: 2,
retryLimit: 0, retryLimit: 0,
speedLimitEnabled: false, offlineSkipScope: "archive" as const,
speedLimitEnabled: false,
speedLimitKbps: 0, speedLimitKbps: 0,
speedLimitMode: "global", speedLimitMode: "global",
proxyDownloadEnabled: false, proxyDownloadEnabled: false,
+270 -61
View File
@@ -116,7 +116,7 @@ type ActiveTask = {
itemId: string; itemId: string;
packageId: string; packageId: string;
abortController: AbortController; 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; resumable: boolean;
nonResumableCounted: boolean; nonResumableCounted: boolean;
freshRetryUsed?: boolean; freshRetryUsed?: boolean;
@@ -722,14 +722,18 @@ export function getAuthoritativeRealDebridTotal(
|| evaluateCandidate(contentLength, "content-length"); || evaluateCandidate(contentLength, "content-length");
} }
function isPermanentLinkError(errorText: string): boolean { function isPermanentLinkError(errorText: string): boolean {
const text = String(errorText || "").toLowerCase(); const text = String(errorText || "").toLowerCase();
return text.includes("permanent ungültig") return text.includes("source_link_offline:")
|| /file.?not.?found/.test(text) || text.includes("permanent ungültig")
|| /file.?unavailable/.test(text) || /(?:^|[^a-z0-9_])(?:file_unavailable|invalid_link|bad_link)(?:$|[^a-z0-9_])/.test(text)
|| /link.?is.?dead/.test(text) || /(?:^|[^a-z0-9_])file[ -]+unavailable(?:$|[^a-z0-9_])/.test(text)
|| text.includes("file has been removed") || /file.?not.?found/.test(text)
|| text.includes("file has been deleted") || /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 is no longer available")
|| text.includes("file was removed") || text.includes("file was removed")
|| text.includes("file was deleted"); || text.includes("file was deleted");
@@ -834,9 +838,12 @@ function isProviderBusyUnrestrictError(errorText: string): boolean {
|| text.includes("zu viele downloads"); || text.includes("zu viele downloads");
} }
function isHosterUnavailableError(errorText: string): boolean { function isHosterUnavailableError(errorText: string): boolean {
return String(errorText || "").toLowerCase().includes("hosternotavailable"); 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 { function isTemporaryUnrestrictError(errorText: string): boolean {
const text = String(errorText || "").toLowerCase(); const text = String(errorText || "").toLowerCase();
@@ -858,6 +865,20 @@ function isTemporaryUnrestrictError(errorText: string): boolean {
|| text.includes("worker error"); || 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 { 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(); const deepbridClassification = String(errorText || "").match(/deepbrid-anfrage fehlgeschlagen \((auth|rate_limit|temporary|link|malformed),/i)?.[1]?.toLowerCase();
if (deepbridClassification === "rate_limit") { if (deepbridClassification === "rate_limit") {
@@ -1635,15 +1656,15 @@ export function decideAutoRenameBaseName(
return { kind: "rename", baseName: targetBaseName, note }; return { kind: "rename", baseName: targetBaseName, note };
} }
const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*1\.rar$/; const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*1\.rar$/;
const ARCHIVE_RAR_RE = /^(.*)\.rar$/; const ARCHIVE_RAR_RE = /^(.*)\.rar$/;
const ARCHIVE_ZIP_SPLIT_RE = /^(.*)\.zip\.001$/; const ARCHIVE_ZIP_SPLIT_RE = /^(.*)\.zip\.001$/;
const ARCHIVE_7Z_SPLIT_RE = /^(.*)\.7z\.001$/; const ARCHIVE_7Z_SPLIT_RE = /^(.*)\.7z\.001$/;
const ARCHIVE_GENERIC_001_RE = /^(.*)\.001$/; const ARCHIVE_GENERIC_001_RE = /^(.*)\.001$/;
const ARCHIVE_KNOWN_001_RE = /\.(zip|7z)\.001$/; const ARCHIVE_KNOWN_001_RE = /\.(zip|7z)\.001$/;
const REGEX_ESCAPE_RE = /[.*+?^${}()|[\]\\]/g; 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 => const normalizeArchiveMatchName = (value: string): string =>
stripDuplicateSuffixBeforeExtension(path.basename(String(value || ""))); stripDuplicateSuffixBeforeExtension(path.basename(String(value || "")));
const entryLower = normalizeArchiveMatchName(archiveName).toLowerCase(); 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 { function stripDuplicateSuffixBeforeExtension(fileName: string): string {
return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, "");
@@ -2032,6 +2103,8 @@ export class DownloadManager extends EventEmitter {
private retryAfterByItem = new Map<string, number>(); private retryAfterByItem = new Map<string, number>();
private sourceAvailabilityRecheckAt = new Map<string, number>();
private packageDiskRetryAfterByPackage = new Map<string, number>(); private packageDiskRetryAfterByPackage = new Map<string, number>();
private diskWaitEvents: NonNullable<UiSnapshot["diskWaitEvents"]> = []; private diskWaitEvents: NonNullable<UiSnapshot["diskWaitEvents"]> = [];
@@ -3220,9 +3293,10 @@ export class DownloadManager extends EventEmitter {
if (!removedByPackageCleanup) { if (!removedByPackageCleanup) {
delete this.session.items[itemId]; delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1); this.itemCount = Math.max(0, this.itemCount - 1);
} }
this.retryAfterByItem.delete(itemId); this.retryAfterByItem.delete(itemId);
this.retryStateByItem.delete(itemId); this.sourceAvailabilityRecheckAt.delete(itemId);
this.retryStateByItem.delete(itemId);
this.dropItemContribution(itemId); this.dropItemContribution(itemId);
if (!hasActiveTask) { if (!hasActiveTask) {
this.releaseTargetPath(itemId); this.releaseTargetPath(itemId);
@@ -3394,8 +3468,9 @@ export class DownloadManager extends EventEmitter {
clearTimeout(this.successDigestTimer); clearTimeout(this.successDigestTimer);
this.successDigestTimer = null; this.successDigestTimer = null;
} }
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.providerStartReservations.clear(); this.sourceAvailabilityRecheckAt.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear(); this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear(); this.retryStateByItem.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
@@ -3634,8 +3709,9 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.delete(itemId); this.runItemIds.delete(itemId);
this.runOutcomes.delete(itemId); this.runOutcomes.delete(itemId);
this.retryAfterByItem.delete(itemId); this.retryAfterByItem.delete(itemId);
this.retryStateByItem.delete(itemId); this.sourceAvailabilityRecheckAt.delete(itemId);
this.retryStateByItem.delete(itemId);
} }
this.abortPackagePostProcessing(packageId, "skip"); this.abortPackagePostProcessing(packageId, "skip");
@@ -3881,6 +3957,9 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
if (item.status !== "queued") { if (item.status !== "queued") {
if (item.status === "failed" && item.onlineStatus === "offline") {
return;
}
item.onlineStatus = result.online ? "online" : "offline"; item.onlineStatus = result.online ? "online" : "offline";
item.updatedAt = nowMs(); item.updatedAt = nowMs();
return; 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<void> { private async checkDdownloadItems(itemIds: string[]): Promise<void> {
const itemsToCheck: Array<{ itemId: string; url: string }> = []; const itemsToCheck: Array<{ itemId: string; url: string }> = [];
for (const itemId of itemIds) { for (const itemId of itemIds) {
@@ -3948,6 +4137,9 @@ export class DownloadManager extends EventEmitter {
if (item.onlineStatus === "checking") item.onlineStatus = undefined; if (item.onlineStatus === "checking") item.onlineStatus = undefined;
return; return;
} }
if (item.status === "failed" && item.onlineStatus === "offline") {
return;
}
if (!result.online) { if (!result.online) {
item.onlineStatus = "offline"; item.onlineStatus = "offline";
item.updatedAt = nowMs(); item.updatedAt = nowMs();
@@ -4009,6 +4201,9 @@ export class DownloadManager extends EventEmitter {
if (item.onlineStatus === "checking") item.onlineStatus = undefined; if (item.onlineStatus === "checking") item.onlineStatus = undefined;
return; return;
} }
if (item.status === "failed" && item.onlineStatus === "offline") {
return;
}
if (!result.online) { if (!result.online) {
item.onlineStatus = "offline"; item.onlineStatus = "offline";
item.updatedAt = nowMs(); item.updatedAt = nowMs();
@@ -6059,8 +6254,9 @@ export class DownloadManager extends EventEmitter {
this.dropItemContribution(itemId); this.dropItemContribution(itemId);
this.runOutcomes.delete(itemId); this.runOutcomes.delete(itemId);
this.runItemIds.delete(itemId); this.runItemIds.delete(itemId);
this.retryAfterByItem.delete(itemId); this.retryAfterByItem.delete(itemId);
this.retryStateByItem.delete(itemId); this.sourceAvailabilityRecheckAt.delete(itemId);
this.retryStateByItem.delete(itemId);
item.status = "queued"; item.status = "queued";
item.downloadedBytes = 0; item.downloadedBytes = 0;
@@ -6142,8 +6338,9 @@ export class DownloadManager extends EventEmitter {
this.dropItemContribution(itemId); this.dropItemContribution(itemId);
this.runOutcomes.delete(itemId); this.runOutcomes.delete(itemId);
this.retryAfterByItem.delete(itemId); this.retryAfterByItem.delete(itemId);
this.retryStateByItem.delete(itemId); this.sourceAvailabilityRecheckAt.delete(itemId);
this.retryStateByItem.delete(itemId);
item.status = "queued"; item.status = "queued";
item.downloadedBytes = 0; item.downloadedBytes = 0;
@@ -6240,9 +6437,10 @@ export class DownloadManager extends EventEmitter {
item.status = "cancelled"; item.status = "cancelled";
item.fullStatus = "Übersprungen"; item.fullStatus = "Übersprungen";
item.speedBps = 0; item.speedBps = 0;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
this.retryAfterByItem.delete(itemId); this.retryAfterByItem.delete(itemId);
this.retryStateByItem.delete(itemId); this.sourceAvailabilityRecheckAt.delete(itemId);
this.retryStateByItem.delete(itemId);
this.releaseTargetPath(itemId); this.releaseTargetPath(itemId);
this.recordRunOutcome(itemId, "cancelled"); this.recordRunOutcome(itemId, "cancelled");
affectedPackageIds.add(item.packageId); affectedPackageIds.add(item.packageId);
@@ -8842,9 +9040,10 @@ export class DownloadManager extends EventEmitter {
} }
this.historyRecordedPackages.delete(packageId); this.historyRecordedPackages.delete(packageId);
this.abortPackagePostProcessing(packageId, "package_removed"); this.abortPackagePostProcessing(packageId, "package_removed");
for (const itemId of itemIds) { for (const itemId of itemIds) {
this.retryAfterByItem.delete(itemId); this.retryAfterByItem.delete(itemId);
this.retryStateByItem.delete(itemId); this.sourceAvailabilityRecheckAt.delete(itemId);
this.retryStateByItem.delete(itemId);
this.releaseTargetPath(itemId); this.releaseTargetPath(itemId);
this.dropItemContribution(itemId); this.dropItemContribution(itemId);
delete this.session.items[itemId]; delete this.session.items[itemId];
@@ -10145,12 +10344,16 @@ export class DownloadManager extends EventEmitter {
} }
} }
); );
} catch (unrestrictError) { } catch (unrestrictError) {
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
this.recordProviderFailure(cooldownProvider); this.recordProviderFailure(cooldownProvider);
throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`); 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)) { if (isProviderUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) {
this.recordProviderFailure(cooldownProvider); this.recordProviderFailure(cooldownProvider);
const backoffKind = classifyProviderUnrestrictBackoff(errText); const backoffKind = classifyProviderUnrestrictBackoff(errText);
@@ -10481,9 +10684,22 @@ export class DownloadManager extends EventEmitter {
genericErrorRetries: Number(active.genericErrorRetries || 0), genericErrorRetries: Number(active.genericErrorRetries || 0),
unrestrictRetries: Number(active.unrestrictRetries || 0) unrestrictRetries: Number(active.unrestrictRetries || 0)
}); });
} else if (reason === "reset") { } else if (reason === "reset") {
this.retryStateByItem.delete(item.id); this.retryStateByItem.delete(item.id);
} else if (reason === "package_toggle") { } 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", { this.logPackageForItem(item, "WARN", "Download wegen Paket-Toggle pausiert", {
reason reason
}); });
@@ -10693,20 +10909,13 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
if (isPermanentLinkError(errorText)) { if (isPermanentLinkError(errorText)) {
logger.error(`Link permanent ungültig: item=${item.fileName || item.id}, error=${errorText}, link=${item.url.slice(0, 80)}`); logger.error(`Link permanent ungültig: item=${item.fileName || item.id}, error=${errorText}, link=${item.url.slice(0, 80)}`);
item.status = "failed"; const confirmedBy = /source_link_offline:hoster:/i.test(errorText) ? "hoster" : "provider";
this.recordRunOutcome(item.id, "failed"); this.markItemOfflineAndSkipRelated(pkg, item, errorText, confirmedBy);
item.lastError = errorText; this.persistSoon();
item.fullStatus = `Link ungültig: ${errorText}`; this.emitState();
item.speedBps = 0; return;
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;
} }
const totalNonStallFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0); const totalNonStallFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
+3
View File
@@ -97,6 +97,9 @@ export function validateRendererSettingsUpdate(value: unknown, current: AppSetti
if (key === "themePreference" && entry !== "light" && entry !== "dark" && entry !== "system") { if (key === "themePreference" && entry !== "light" && entry !== "dark" && entry !== "system") {
invalid(); invalid();
} }
if (key === "offlineSkipScope" && entry !== "archive" && entry !== "package") {
invalid();
}
if (key === "dailyStartMinuteOfDay" && (!Number.isInteger(entry) || (entry as number) < 0 || (entry as number) > 1_439)) { if (key === "dailyStartMinuteOfDay" && (!Number.isInteger(entry) || (entry as number) < 0 || (entry as number) > 1_439)) {
invalid(); invalid();
} }
+1
View File
@@ -202,6 +202,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings
maxParallel: settings.maxParallel, maxParallel: settings.maxParallel,
maxParallelExtract: settings.maxParallelExtract, maxParallelExtract: settings.maxParallelExtract,
retryLimit: settings.retryLimit, retryLimit: settings.retryLimit,
offlineSkipScope: settings.offlineSkipScope,
speedLimitEnabled: settings.speedLimitEnabled, speedLimitEnabled: settings.speedLimitEnabled,
speedLimitKbps: settings.speedLimitKbps, speedLimitKbps: settings.speedLimitKbps,
speedLimitMode: settings.speedLimitMode, speedLimitMode: settings.speedLimitMode,
+4 -3
View File
@@ -608,9 +608,10 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
autoResumeOnStart: Boolean(settings.autoResumeOnStart), autoResumeOnStart: Boolean(settings.autoResumeOnStart),
autoReconnect: Boolean(settings.autoReconnect), autoReconnect: Boolean(settings.autoReconnect),
maxParallel: clampNumber(settings.maxParallel, defaults.maxParallel, 1, 50), maxParallel: clampNumber(settings.maxParallel, defaults.maxParallel, 1, 50),
maxParallelExtract: clampNumber(settings.maxParallelExtract, defaults.maxParallelExtract, 1, 8), maxParallelExtract: clampNumber(settings.maxParallelExtract, defaults.maxParallelExtract, 1, 8),
retryLimit: clampNumber(settings.retryLimit, defaults.retryLimit, 0, 99), retryLimit: clampNumber(settings.retryLimit, defaults.retryLimit, 0, 99),
reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600), offlineSkipScope: settings.offlineSkipScope === "package" ? "package" : "archive",
reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600),
completedCleanupPolicy: settings.completedCleanupPolicy, completedCleanupPolicy: settings.completedCleanupPolicy,
speedLimitEnabled: Boolean(settings.speedLimitEnabled), speedLimitEnabled: Boolean(settings.speedLimitEnabled),
speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000), speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000),
+1 -1
View File
@@ -952,7 +952,7 @@ const emptySnapshot = (): UiSnapshot => ({
cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", 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, proxyDownloadEnabled: false, proxyListPath: "", proxyApiProxyIndex: 1, proxyConnectionsPerDownload: 32,
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, 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, 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,
+3
View File
@@ -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"], ["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"], ["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"], ["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"], ["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"], ["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"], ["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"],
@@ -611,6 +611,17 @@ export function buildSettingsFormViewModel({
fields: [ fields: [
{ id: "maxParallel", kind: "number", label: "Max. gleichzeitige Downloads", value: String(settings.maxParallel), min: 1, max: 50 }, { 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: "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: "autoResumeOnStart", kind: "switch", label: "Beim Start automatisch fortsetzen", value: settings.autoResumeOnStart },
{ id: "clipboardWatch", kind: "switch", label: "Zwischenablage überwachen", value: settings.clipboardWatch } { id: "clipboardWatch", kind: "switch", label: "Zwischenablage überwachen", value: settings.clipboardWatch }
] ]
+7 -4
View File
@@ -13,7 +13,8 @@ export type DownloadStatus =
export type CleanupMode = "none" | "trash" | "delete"; export type CleanupMode = "none" | "trash" | "delete";
export type ConflictMode = "overwrite" | "skip" | "rename" | "ask"; export type ConflictMode = "overwrite" | "skip" | "rename" | "ask";
export type SpeedMode = "global" | "per_download"; 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 = export type DebridProvider =
| "realdebrid" | "realdebrid"
| "megadebrid" | "megadebrid"
@@ -205,9 +206,10 @@ export interface AppSettings extends DailyStartSettings, ProxyDownloadSettings {
reconnectWaitSeconds: number; reconnectWaitSeconds: number;
completedCleanupPolicy: FinishedCleanupPolicy; completedCleanupPolicy: FinishedCleanupPolicy;
maxParallel: number; maxParallel: number;
maxParallelExtract: number; maxParallelExtract: number;
retryLimit: number; retryLimit: number;
speedLimitEnabled: boolean; offlineSkipScope: OfflineSkipScope;
speedLimitEnabled: boolean;
speedLimitKbps: number; speedLimitKbps: number;
speedLimitMode: SpeedMode; speedLimitMode: SpeedMode;
updateRepo: string; updateRepo: string;
@@ -343,6 +345,7 @@ export interface RendererSettings extends DailyStartSettings, ProxyDownloadSetti
maxParallel: number; maxParallel: number;
maxParallelExtract: number; maxParallelExtract: number;
retryLimit: number; retryLimit: number;
offlineSkipScope: OfflineSkipScope;
speedLimitEnabled: boolean; speedLimitEnabled: boolean;
speedLimitKbps: number; speedLimitKbps: number;
speedLimitMode: SpeedMode; speedLimitMode: SpeedMode;
+211 -5
View File
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
import { EventEmitter, once } from "node:events"; import { EventEmitter, once } from "node:events";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
import { DiskReservationCoordinator } from "../src/main/disk-space"; import { DiskReservationCoordinator } from "../src/main/disk-space";
import { defaultSettings } from "../src/main/constants"; 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", () => { it("includes duplicate-suffixed archive copies in multipart matches", () => {
const items = [ const items = [
{ id: "dup-1", fileName: "show.s01e26.part1.rar" }, { id: "dup-1", fileName: "show.s01e26.part1.rar" },
@@ -1153,9 +1153,37 @@ describe("resolveArchiveItemsFromList", () => {
const resolved = resolveArchiveItemsFromList("show.s01e26.part1.rar", items); 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", () => { describe("download completion planning", () => {
it("does not allow early finish on provider metadata alone", () => { it("does not allow early finish on provider metadata alone", () => {
@@ -1328,6 +1356,184 @@ describe("download manager", () => {
expect(item.onlineStatus).toBe("online"); 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<string, number>();
const unrestrictLinks: string[] = [];
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("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(`<html><title>${fileName}</title><div>File size: <strong>16 B</strong></div></html>`, {
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<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === link) {
sourceChecks += 1;
return new Response("<html><title>missing.mkv</title><div>File size: <strong>16 B</strong></div></html>", {
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", () => { it("applies an imported settings snapshot without touching queued items or filesystem workflows", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-import-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-import-"));
tempDirs.push(root); tempDirs.push(root);
+8
View File
@@ -13,6 +13,14 @@ describe("renderer settings validation", () => {
.toEqual({ createWorkDirectoriesOnStartup: false }); .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", () => { 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 current = { ...defaultSettings(), theme: "dark" as const, themePreference: "system" as const };
const projected = createRendererSettings(current); const projected = createRendererSettings(current);
+23
View File
@@ -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", () => { it("projects proxy segmentation controls with safe defaults and connection totals", () => {
const form = buildSettingsFormViewModel({ const form = buildSettingsFormViewModel({
settings: { settings: {
+9 -2
View File
@@ -590,7 +590,8 @@ describe("settings storage", () => {
providerTertiary: "invalid-provider" as unknown as AppSettings["providerTertiary"], providerTertiary: "invalid-provider" as unknown as AppSettings["providerTertiary"],
cleanupMode: "broken" as unknown as AppSettings["cleanupMode"], cleanupMode: "broken" as unknown as AppSettings["cleanupMode"],
extractConflictMode: "broken" as unknown as AppSettings["extractConflictMode"], 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"], speedLimitMode: "broken" as unknown as AppSettings["speedLimitMode"],
maxParallel: 0, maxParallel: 0,
retryLimit: 999, retryLimit: 999,
@@ -611,7 +612,8 @@ describe("settings storage", () => {
expect(normalized.providerTertiary).toBe("none"); expect(normalized.providerTertiary).toBe("none");
expect(normalized.cleanupMode).toBe("none"); expect(normalized.cleanupMode).toBe("none");
expect(normalized.extractConflictMode).toBe("overwrite"); 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.speedLimitMode).toBe("global");
expect(normalized.maxParallel).toBe(1); expect(normalized.maxParallel).toBe(1);
expect(normalized.retryLimit).toBe(99); expect(normalized.retryLimit).toBe(99);
@@ -631,6 +633,11 @@ describe("settings storage", () => {
expect(defaultSettings().proxyConnectionsPerDownload).toBe(32); 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", () => { it("migrates the previous private update repository to the public release repository", () => {
for (const updateRepo of [ for (const updateRepo of [
"legacy-owner/real-debrid-downloader", "legacy-owner/real-debrid-downloader",
+2 -1
View File
@@ -114,7 +114,8 @@ function createSettings(): AppSettings {
completedCleanupPolicy: "never", completedCleanupPolicy: "never",
maxParallel: 4, maxParallel: 4,
maxParallelExtract: 2, maxParallelExtract: 2,
retryLimit: 3, retryLimit: 3,
offlineSkipScope: "archive",
speedLimitEnabled: false, speedLimitEnabled: false,
speedLimitKbps: 0, speedLimitKbps: 0,
speedLimitMode: "global", speedLimitMode: "global",