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
+4 -3
View File
@@ -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,
+270 -61
View File
@@ -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<string, number>();
private sourceAvailabilityRecheckAt = new Map<string, number>();
private packageDiskRetryAfterByPackage = new Map<string, number>();
private diskWaitEvents: NonNullable<UiSnapshot["diskWaitEvents"]> = [];
@@ -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<void> {
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);
+3
View File
@@ -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();
}
+1
View File
@@ -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,
+4 -3
View File
@@ -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),
+1 -1
View File
@@ -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,
+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"],
["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"],
@@ -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 }
]
+7 -4
View File
@@ -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;