fix(downloads): stabilize queue and disk recovery

Keep package rows in their existing visible order across runtime state changes and remove obsolete automatic progress grouping.

Require an active usable provider before starting or resuming downloads, recover temporary disk-write blocks without consuming normal retries, and surface disk waits and extraction failures at package level.

Add focused regression coverage and prepare the v2.0.24 release metadata and changelog.
This commit is contained in:
Sucukdeluxe
2026-08-11 20:08:06 +02:00
parent a6e6d2d439
commit 2861fd95f7
14 changed files with 485 additions and 264 deletions
+22
View File
@@ -2,6 +2,28 @@
All notable changes to Multi-Debrid Downloader are documented in this file. All notable changes to Multi-Debrid Downloader are documented in this file.
## [2.0.24] - 2026-08-11
### Download queue
- Kept every package in its existing visible queue position when downloads start, finish, pause, retry, or change status.
- Removed status-driven package grouping and the obsolete automatic progress-sorting setting.
- Blocked start, package start, item start, and resume actions when no active usable download account is available.
- Kept the initial Start action disabled until the main process confirms that an eligible account is active.
### Disk and extraction recovery
- Added a dedicated disk-wait recovery path for full, quota-limited, temporarily busy, and stalled write targets.
- Retried disk-blocked downloads automatically after storage becomes writable without consuming the normal download retry budget.
- Prioritized `Waiting for disk` at package level when one or more files are blocked by storage.
- Reported completed downloads with extraction failures as failed packages instead of showing a misleading completed file count.
- Removed redundant provider names from start, download, data-wait, and disk-wait status labels while preserving diagnostic details.
### Reliability and testing
- Added regression coverage for unavailable and disabled accounts, resume protection, disk-write recovery, retry accounting, package-level disk waits, extraction failures, compact runtime statuses, and stable queue ordering.
- Verified the complete client suite, TypeScript compilation, production build, release metadata, archive contents, and application self-check.
## [2.0.23] - 2026-08-11 ## [2.0.23] - 2026-08-11
### Update experience ### Update experience
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.23", "version": "2.0.24",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.23", "version": "2.0.24",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "0.6.0", "adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.23", "version": "2.0.24",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
+1 -1
View File
@@ -108,7 +108,7 @@ export function defaultSettings(): AppSettings {
historyMaxEntries: 500, historyMaxEntries: 500,
historyMaxAgeDays: 0, historyMaxAgeDays: 0,
accountListShowDetailedDebridLinkKeys: false, accountListShowDetailedDebridLinkKeys: false,
autoSortPackagesByProgress: true, autoSortPackagesByProgress: false,
autoSkipExtracted: false, autoSkipExtracted: false,
hideExtractedItems: true, hideExtractedItems: true,
confirmDeleteSelection: true, confirmDeleteSelection: true,
+71 -16
View File
@@ -88,6 +88,18 @@ type ActiveTask = {
blockedOnDiskSince?: number; blockedOnDiskSince?: number;
}; };
const DOWNLOAD_ACCOUNT_PROVIDERS: readonly DebridProvider[] = [
"realdebrid",
"megadebrid-api",
"megadebrid-web",
"bestdebrid",
"alldebrid",
"ddownload",
"onefichier",
"debridlink",
"linksnappy"
];
type PackageItemDiskState = { type PackageItemDiskState = {
diskPath: string | null; diskPath: string | null;
exists: boolean; exists: boolean;
@@ -1700,6 +1712,19 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number {
return Math.floor(jitter); return Math.floor(jitter);
} }
export function getDiskWriteWaitReason(error: unknown): string | null {
const text = compactErrorText(error).replace(/^Error:\s*/i, "");
const marked = text.match(/^disk_write_wait:(.+)$/i);
if (marked?.[1]) {
return marked[1].trim();
}
if (/write_drain_timeout/i.test(text)) {
return "Festplatte reagiert nicht auf Schreibzugriffe";
}
const reason = classifyDiskError(error);
return reason && /\((?:ENOSPC|EDQUOT|EBUSY)\)/.test(reason) ? reason : null;
}
export async function runWithLimitedConcurrency<T>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> { export async function runWithLimitedConcurrency<T>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
const workerCount = Math.min(items.length, Math.max(1, Math.floor(concurrency))); const workerCount = Math.min(items.length, Math.max(1, Math.floor(concurrency)));
let nextIndex = 0; let nextIndex = 0;
@@ -2485,7 +2510,7 @@ export class DownloadManager extends EventEmitter {
stats: this.getStats(now), stats: this.getStats(now),
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
etaText: paused || !this.session.running ? "ETA: --" : `ETA: ${formatEta(eta)}`, etaText: paused || !this.session.running ? "ETA: --" : `ETA: ${formatEta(eta)}`,
canStart: !this.session.running, canStart: (!this.session.running || paused) && this.hasUsableDownloadAccount(),
canStop: this.session.running, canStop: this.session.running,
canPause: this.session.running, canPause: this.session.running,
clipboardActive: this.settings.clipboardWatch, clipboardActive: this.settings.clipboardWatch,
@@ -5522,6 +5547,7 @@ export class DownloadManager extends EventEmitter {
} }
public async startPackages(packageIds: string[]): Promise<void> { public async startPackages(packageIds: string[]): Promise<void> {
this.ensureUsableDownloadAccount();
const targetSet = new Set(packageIds); const targetSet = new Set(packageIds);
for (const pkgId of targetSet) { for (const pkgId of targetSet) {
@@ -5616,6 +5642,7 @@ export class DownloadManager extends EventEmitter {
} }
public async startItems(itemIds: string[]): Promise<void> { public async startItems(itemIds: string[]): Promise<void> {
this.ensureUsableDownloadAccount();
const targetSet = new Set(itemIds); const targetSet = new Set(itemIds);
const affectedPackageIds = new Set<string>(); const affectedPackageIds = new Set<string>();
@@ -5724,6 +5751,7 @@ export class DownloadManager extends EventEmitter {
if (this.session.running) { if (this.session.running) {
return; return;
} }
this.ensureUsableDownloadAccount();
this.schedulerGeneration += 1; this.schedulerGeneration += 1;
this.session.running = true; this.session.running = true;
@@ -6027,6 +6055,9 @@ export class DownloadManager extends EventEmitter {
return false; return false;
} }
const wasPaused = this.session.paused; const wasPaused = this.session.paused;
if (wasPaused) {
this.ensureUsableDownloadAccount();
}
this.session.paused = !this.session.paused; this.session.paused = !this.session.paused;
if (!wasPaused && this.session.paused) { if (!wasPaused && this.session.paused) {
@@ -8081,11 +8112,11 @@ export class DownloadManager extends EventEmitter {
return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim()); return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim());
} }
if (effectiveProvider === "megadebrid-api") { if (effectiveProvider === "megadebrid-api") {
const hasMegaCreds = getMegaDebridAccountsForMode(this.settings, "api").length > 0; const hasMegaCreds = getAvailableMegaDebridAccounts(this.settings, "api").length > 0;
return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" || this.settings.megaDebridApiEnabled)); return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" || this.settings.megaDebridApiEnabled));
} }
if (effectiveProvider === "megadebrid-web") { if (effectiveProvider === "megadebrid-web") {
const hasMegaCreds = getMegaDebridAccountsForMode(this.settings, "web").length > 0; const hasMegaCreds = getAvailableMegaDebridAccounts(this.settings, "web").length > 0;
return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" || this.settings.megaDebridWebEnabled)); return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" || this.settings.megaDebridWebEnabled));
} }
if (effectiveProvider === "bestdebrid") { if (effectiveProvider === "bestdebrid") {
@@ -8110,6 +8141,16 @@ export class DownloadManager extends EventEmitter {
return false; return false;
} }
private hasUsableDownloadAccount(): boolean {
return DOWNLOAD_ACCOUNT_PROVIDERS.some((provider) => this.isProviderConfigured(provider));
}
private ensureUsableDownloadAccount(): void {
if (!this.hasUsableDownloadAccount()) {
throw new Error("Kein aktiver Download-Account verfügbar");
}
}
private getProviderOrder(): DebridProvider[] { private getProviderOrder(): DebridProvider[] {
if (this.settings.providerOrder && this.settings.providerOrder.length > 0) { if (this.settings.providerOrder && this.settings.providerOrder.length > 0) {
return [...this.settings.providerOrder]; return [...this.settings.providerOrder];
@@ -9180,8 +9221,7 @@ export class DownloadManager extends EventEmitter {
item.totalBytes = mergeKnownTotalBytes(item.totalBytes, unrestricted.fileSize); item.totalBytes = mergeKnownTotalBytes(item.totalBytes, unrestricted.fileSize);
item.status = "downloading"; item.status = "downloading";
const pLabel = unrestricted.providerLabel; const pLabel = unrestricted.providerLabel;
const statusLabel = providerLabel(unrestricted.provider) || pLabel; item.fullStatus = "Starte...";
item.fullStatus = `Starte... (${statusLabel})`;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
this.emitState(); this.emitState();
logger.info(`Download Start: ${item.fileName} (${humanSize(unrestricted.fileSize || 0)}) via ${pLabel}, pkg=${pkg.name}`); logger.info(`Download Start: ${item.fileName} (${humanSize(unrestricted.fileSize || 0)}) via ${pLabel}, pkg=${pkg.name}`);
@@ -9211,7 +9251,7 @@ export class DownloadManager extends EventEmitter {
}); });
if (item.status !== "downloading") { if (item.status !== "downloading") {
item.status = "downloading"; item.status = "downloading";
item.fullStatus = `Download läuft (${statusLabel})`; item.fullStatus = "Download läuft";
item.updatedAt = nowMs(); item.updatedAt = nowMs();
this.emitState(); this.emitState();
} }
@@ -9514,6 +9554,14 @@ export class DownloadManager extends EventEmitter {
if (this.tryFinalizeItemFromDisk(pkg, item, "Error-Recovery", errorText)) { if (this.tryFinalizeItemFromDisk(pkg, item, "Error-Recovery", errorText)) {
return; return;
} }
const diskWaitReason = getDiskWriteWaitReason(error);
if (diskWaitReason) {
this.queueRetry(item, active, 2000, "Warte auf Festplatte");
item.lastError = diskWaitReason;
this.persistSoon();
this.emitState();
return;
}
this.logPackageForItem(item, "WARN", "Download-Fehlerpfad erreicht", { this.logPackageForItem(item, "WARN", "Download-Fehlerpfad erreicht", {
error: errorText, error: errorText,
abortReason: reason || "none" abortReason: reason || "none"
@@ -9888,9 +9936,8 @@ export class DownloadManager extends EventEmitter {
targetPath: string, targetPath: string,
knownTotal: number | null, knownTotal: number | null,
skipTlsVerify?: boolean, skipTlsVerify?: boolean,
pLabel?: string _pLabel?: string
): Promise<{ resumable: boolean }> { ): Promise<{ resumable: boolean }> {
const label = providerLabel(this.session.items[active.itemId]?.provider) || pLabel || "Debrid";
const item = this.session.items[active.itemId]; const item = this.session.items[active.itemId];
if (!item) { if (!item) {
throw new Error("Download-Item fehlt"); throw new Error("Download-Item fehlt");
@@ -10347,7 +10394,7 @@ export class DownloadManager extends EventEmitter {
if (diskBusyStatusVisible(nowTick) && nowTick - lastDiskBusyEmitAt >= 1200) { if (diskBusyStatusVisible(nowTick) && nowTick - lastDiskBusyEmitAt >= 1200) {
item.status = "downloading"; item.status = "downloading";
item.speedBps = 0; item.speedBps = 0;
item.fullStatus = `Warte auf Festplatte (${label})`; item.fullStatus = "Warte auf Festplatte";
item.updatedAt = nowTick; item.updatedAt = nowTick;
this.emitState(); this.emitState();
lastDiskBusyEmitAt = nowTick; lastDiskBusyEmitAt = nowTick;
@@ -10457,7 +10504,7 @@ export class DownloadManager extends EventEmitter {
if (diskBusyStatusVisible(nowTick) && nowTick - lastIdleEmitAt >= idlePulseMs) { if (diskBusyStatusVisible(nowTick) && nowTick - lastIdleEmitAt >= idlePulseMs) {
item.status = "downloading"; item.status = "downloading";
item.speedBps = 0; item.speedBps = 0;
item.fullStatus = `Warte auf Festplatte (${label})`; item.fullStatus = "Warte auf Festplatte";
item.updatedAt = nowTick; item.updatedAt = nowTick;
this.emitState(); this.emitState();
lastIdleEmitAt = nowTick; lastIdleEmitAt = nowTick;
@@ -10473,7 +10520,7 @@ export class DownloadManager extends EventEmitter {
} }
item.status = "downloading"; item.status = "downloading";
item.speedBps = 0; item.speedBps = 0;
item.fullStatus = `Warte auf Daten (${label})`; item.fullStatus = "Warte auf Daten";
if (nowTick - lastIdleEmitAt >= idlePulseMs) { if (nowTick - lastIdleEmitAt >= idlePulseMs) {
item.updatedAt = nowTick; item.updatedAt = nowTick;
this.emitState(); this.emitState();
@@ -10579,7 +10626,7 @@ export class DownloadManager extends EventEmitter {
if (nowTick - lastDiskBusyEmitAt >= 1200) { if (nowTick - lastDiskBusyEmitAt >= 1200) {
item.status = "downloading"; item.status = "downloading";
item.speedBps = 0; item.speedBps = 0;
item.fullStatus = `Warte auf Festplatte (${label})`; item.fullStatus = "Warte auf Festplatte";
item.updatedAt = nowTick; item.updatedAt = nowTick;
this.emitState(); this.emitState();
lastDiskBusyEmitAt = nowTick; lastDiskBusyEmitAt = nowTick;
@@ -10631,10 +10678,10 @@ export class DownloadManager extends EventEmitter {
const diskBusy = diskBusyStatusVisible(nowMs()); const diskBusy = diskBusyStatusVisible(nowMs());
if (diskBusy) { if (diskBusy) {
item.speedBps = 0; item.speedBps = 0;
item.fullStatus = `Warte auf Festplatte (${label})`; item.fullStatus = "Warte auf Festplatte";
} else { } else {
item.speedBps = Math.max(0, Math.floor(speed)); item.speedBps = Math.max(0, Math.floor(speed));
item.fullStatus = `Download läuft (${label})`; item.fullStatus = "Download läuft";
} }
const progressNow = nowMs(); const progressNow = nowMs();
const currentPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0; const currentPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0;
@@ -10857,6 +10904,10 @@ export class DownloadManager extends EventEmitter {
// instead of leaving only a generic write/stream error in the log. // instead of leaving only a generic write/stream error in the log.
logger.error(`Schreibfehler beim Download: ${diskCause} - ${item.fileName} (ziel=${effectiveTargetPath})`); logger.error(`Schreibfehler beim Download: ${diskCause} - ${item.fileName} (ziel=${effectiveTargetPath})`);
} }
const diskWaitReason = getDiskWriteWaitReason(error);
if (diskWaitReason) {
throw new Error(`disk_write_wait:${diskWaitReason}`);
}
if ( if (
normalizedLastError.startsWith("range_ignored_on_resume:") normalizedLastError.startsWith("range_ignored_on_resume:")
|| normalizedLastError.startsWith("range_mismatch_on_resume:") || normalizedLastError.startsWith("range_mismatch_on_resume:")
@@ -11111,6 +11162,7 @@ export class DownloadManager extends EventEmitter {
let success = 0; let success = 0;
let failed = 0; let failed = 0;
let cancelled = 0; let cancelled = 0;
let extractFailed = 0;
let total = 0; let total = 0;
for (const itemId of pkg.itemIds) { for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId]; const item = this.session.items[itemId];
@@ -11121,6 +11173,9 @@ export class DownloadManager extends EventEmitter {
const s = item.status; const s = item.status;
if (s === "completed") { if (s === "completed") {
success += 1; success += 1;
if (isExtractErrorLabel(item.fullStatus || "")) {
extractFailed += 1;
}
} else if (s === "failed") { } else if (s === "failed") {
failed += 1; failed += 1;
} else if (s === "cancelled") { } else if (s === "cancelled") {
@@ -11140,7 +11195,7 @@ export class DownloadManager extends EventEmitter {
} }
const prevStatus = pkg.status; const prevStatus = pkg.status;
if (failed > 0) { if (failed > 0 || extractFailed > 0) {
pkg.status = "failed"; pkg.status = "failed";
} else if (cancelled > 0) { } else if (cancelled > 0) {
pkg.status = success > 0 ? "completed" : "cancelled"; pkg.status = success > 0 ? "completed" : "cancelled";
@@ -11154,7 +11209,7 @@ export class DownloadManager extends EventEmitter {
// That includes mixed packages (success > 0): the dedup set prevents a // That includes mixed packages (success > 0): the dedup set prevents a
// double-fire if post-processing does run later. // double-fire if post-processing does run later.
if (pkg.status === "failed" && prevStatus !== "failed") { if (pkg.status === "failed" && prevStatus !== "failed") {
this.notifyPackageOutcome(pkg, "failed", `${failed} von ${total} Datei(en) fehlgeschlagen`); this.notifyPackageOutcome(pkg, "failed", `${failed + extractFailed} von ${total} Datei(en) fehlgeschlagen`);
if (success > 0) { if (success > 0) {
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
this.recordPackageHistory(pkg.id, pkg, items); this.recordPackageHistory(pkg.id, pkg, items);
+5 -16
View File
@@ -36,7 +36,7 @@ import {
getProviderDailyUsageBytes, getProviderDailyUsageBytes,
getProviderUsageDayKey getProviderUsageDayKey
} from "../shared/provider-daily-limits"; } from "../shared/provider-daily-limits";
import { sortPackageOrderByName, sortPackagesForDisplay } from "./package-order"; import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection"; import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui"; import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
import type { AccountModeFilter } from "./account-ui"; import type { AccountModeFilter } from "./account-ui";
@@ -1100,7 +1100,7 @@ const emptySnapshot = (): UiSnapshot => ({
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, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false,
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
accountListShowDetailedDebridLinkKeys: false, accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
@@ -1131,7 +1131,7 @@ const emptySnapshot = (): UiSnapshot => ({
paused: false, running: false, updatedAt: Date.now() paused: false, running: false, updatedAt: Date.now()
}, },
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --", summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
canStart: true, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {} canStart: false, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {}
}); });
const cleanupLabels: Record<string, string> = { const cleanupLabels: Record<string, string> = {
@@ -2340,20 +2340,9 @@ export function App(): ReactElement {
setSelectedIds((prev) => pruneSelection(prev, snapshot.session)); setSelectedIds((prev) => pruneSelection(prev, snapshot.session));
}, [snapshot.session.packages, snapshot.session.items]); }, [snapshot.session.packages, snapshot.session.items]);
const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1)
? snapshot.session.items
: null;
const visiblePackages = useMemo(() => { const visiblePackages = useMemo(() => {
if (!sortRelevantItems) { return preservePackageOrderForDisplay(packages);
return packages; }, [packages]);
}
return sortPackagesForDisplay(
packages,
sortRelevantItems,
true,
true
);
}, [packages, sortRelevantItems]);
const downloadsViewCore = useMemo(() => buildDownloadsViewModel({ const downloadsViewCore = useMemo(() => buildDownloadsViewModel({
packageOrder: visiblePackages.map((entry) => entry.id), packageOrder: visiblePackages.map((entry) => entry.id),
+1 -1
View File
@@ -13,7 +13,7 @@ const pairs = [
["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."], ["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."],
["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"], ["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"],
["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"], ["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"],
["Nach Fortschritt sortieren", "Sort by progress"], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"], ["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"], ["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"],
["Quelle und Zeitpunkt der Update-Prüfung.", "Update source and check timing."], ["Aktualisierung", "Update"], ["Beim Start nach Updates suchen", "Check for updates on startup"], ["Update-Quelle", "Update source"], ["Quelle und Zeitpunkt der Update-Prüfung.", "Update source and check timing."], ["Aktualisierung", "Update"], ["Beim Start nach Updates suchen", "Check for updates on startup"], ["Update-Quelle", "Update source"],
+3 -42
View File
@@ -1,13 +1,4 @@
import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types"; import type { PackageEntry } from "../shared/types";
const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["downloading", "validating", "integrity_check", "extracting"]);
function isPackageActive(pkg: PackageEntry, itemsById: Record<string, DownloadItem>): boolean {
return pkg.itemIds.some((id) => {
const item = itemsById[id];
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
});
}
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] { export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
const fromIndex = order.indexOf(draggedPackageId); const fromIndex = order.indexOf(draggedPackageId);
@@ -33,36 +24,6 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
return sorted; return sorted;
} }
export function sortPackagesForDisplay( export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
packages: PackageEntry[], return packages;
itemsById: Record<string, DownloadItem>,
running: boolean,
autoSortPackagesByProgress: boolean
): PackageEntry[] {
if (!running || !autoSortPackagesByProgress || packages.length <= 1) {
return packages;
}
const active = packages
.map((pkg, index) => ({ pkg, index }))
.filter(({ pkg }) => isPackageActive(pkg, itemsById))
.sort((left, right) => {
const leftStartedAt = left.pkg.downloadStartedAt || 0;
const rightStartedAt = right.pkg.downloadStartedAt || 0;
if (leftStartedAt > 0 && rightStartedAt > 0 && leftStartedAt !== rightStartedAt) {
return leftStartedAt - rightStartedAt;
}
if (leftStartedAt > 0 && rightStartedAt <= 0) return -1;
if (leftStartedAt <= 0 && rightStartedAt > 0) return 1;
return left.index - right.index;
})
.map(({ pkg }) => pkg);
const activeSet = new Set(active.map((pkg) => pkg.id));
const rest = packages.filter((pkg) => !activeSet.has(pkg.id));
if (active.length === 0) {
return packages;
}
return [...active, ...rest];
} }
@@ -134,6 +134,8 @@ function progress(value: number): number {
export function compactDownloadStatus(value: string): string { export function compactDownloadStatus(value: string): string {
const status = value.trim(); const status = value.trim();
const runtimeWait = status.match(/^(Starte\.\.\.|Starting\.\.\.|Warte auf Daten|Waiting for data|Warte auf Festplatte|Waiting for disk)(?:\s+\([^)]*\))?$/i);
if (runtimeWait) return runtimeWait[1];
if (/Link wird umgewandelt/i.test(status)) return "Umwandeln"; if (/Link wird umgewandelt/i.test(status)) return "Umwandeln";
if (/Download läuft\b/i.test(status)) return "Download läuft"; if (/Download läuft\b/i.test(status)) return "Download läuft";
if (/Download running\b/i.test(status)) return "Download running"; if (/Download running\b/i.test(status)) return "Download running";
@@ -448,12 +450,17 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
const postProcessLabel = entry.status === "extracting" && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel) const postProcessLabel = entry.status === "extracting" && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel)
? "Entpacken - Ausstehend" ? "Entpacken - Ausstehend"
: compactDownloadStatus(rawPostProcessLabel); : compactDownloadStatus(rawPostProcessLabel);
const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`; const extractFailure = row.allItems.find((item) => /^Entpack-Fehler\b/i.test(item.fullStatus || ""));
const waitsForDisk = row.allItems.some((item) => compactDownloadStatus(item.fullStatus || "") === "Warte auf Festplatte");
const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${extractFailure ? " · Entpack-Fehler" : ""}${audio ? ` · ${audio.text}` : ""}`;
const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating"); const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating");
const status = postProcessLabel && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting") const status = postProcessLabel && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting")
? postProcessLabel ? postProcessLabel
: downloading ? "Download läuft" : details; : extractFailure ? "Entpack-Fehler"
const title = audio?.tooltip ? `${details}\n${audio.tooltip}` : details; : waitsForDisk ? "Warte auf Festplatte"
: downloading ? "Download läuft" : details;
const statusDetails = extractFailure ? `${details}\n${extractFailure.fullStatus}` : details;
const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails;
return <DownloadStatusCell status={status} title={title} />; return <DownloadStatusCell status={status} title={title} />;
} }
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>; if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
@@ -118,7 +118,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1; const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
return ( return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar"> <div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button disabled={model.actionBusy || (!model.canStart && !model.paused)} onClick={actions.onStartDownloads} type="button">Start</button> <button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button> <button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button> <button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
{model.scheduleActive {model.scheduleActive
@@ -507,7 +507,6 @@ export function buildSettingsFormViewModel({
title: "Oberfläche und Bedienung", title: "Oberfläche und Bedienung",
fields: [ fields: [
{ id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages }, { id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages },
{ id: "autoSortPackagesByProgress", kind: "switch", label: "Nach Fortschritt sortieren", value: settings.autoSortPackagesByProgress },
{ id: "minimizeToTray", kind: "switch", label: "In den Infobereich minimieren", value: settings.minimizeToTray }, { id: "minimizeToTray", kind: "switch", label: "In den Infobereich minimieren", value: settings.minimizeToTray },
{ id: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection }, { id: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection },
{ id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads }, { id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads },
+242 -15
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, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager"; import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager";
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion"; import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
import { defaultSettings } from "../src/main/constants"; import { defaultSettings } from "../src/main/constants";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
@@ -42,6 +42,235 @@ describe("runWithLimitedConcurrency", () => {
}); });
}); });
describe("disk write recovery", () => {
it("classifies retryable disk write stalls without treating permission errors as temporary", () => {
expect(getDiskWriteWaitReason(Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" }))).toMatch(/Festplatte voll/);
expect(getDiskWriteWaitReason(new Error("write_drain_timeout"))).toMatch(/Festplatte/);
expect(getDiskWriteWaitReason(Object.assign(new Error("write EACCES"), { code: "EACCES" }))).toBeNull();
});
it("parks a disk-full download for automatic retry without consuming its retry budget", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-recovery-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "disk-recovery-package";
const itemId = "disk-recovery-item";
const outputDir = path.join(root, "downloads", "disk-recovery");
const extractDir = path.join(root, "extract", "disk-recovery");
const createdAt = Date.now();
session.running = true;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "disk-recovery",
outputDir,
extractDir,
status: "downloading",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://rapidgator.net/file/disk-recovery",
provider: "realdebrid",
status: "downloading",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: 1024,
progressPercent: 0,
fileName: "disk-recovery.bin",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Download läuft",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), autoExtract: false },
session,
createStoragePaths(path.join(root, "state"))
);
const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
(manager as any).activeTasks.set(itemId, active);
(manager as any).debridService.unrestrictLink = async () => ({
fileName: "disk-recovery.bin",
directUrl: "https://dummy/disk-recovery",
fileSize: 1024,
retriesUsed: 0,
provider: "realdebrid",
providerLabel: "Real-Debrid"
});
(manager as any).downloadToFile = async () => {
throw Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" });
};
const before = Date.now();
await (manager as any).processItem(active);
expect(session.items[itemId]).toEqual(expect.objectContaining({
status: "queued",
retries: 0,
speedBps: 0,
fullStatus: "Warte auf Festplatte"
}));
expect((manager as any).retryAfterByItem.get(itemId)).toBeGreaterThan(before);
expect(session.packages[packageId].status).toBe("queued");
});
it("marks a fully downloaded package as failed when post-processing failed", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-status-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "extract-status-package";
const itemId = "extract-status-item";
const createdAt = Date.now();
const outputDir = path.join(root, "downloads");
const targetPath = path.join(outputDir, "extract-status.part1.rar");
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(targetPath, Buffer.alloc(1024, 1));
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "extract-status",
outputDir,
extractDir: path.join(root, "extract"),
status: "completed",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://rapidgator.net/file/extract-status",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 1024,
totalBytes: 1024,
progressPercent: 100,
fileName: "extract-status.part1.rar",
targetPath,
resumable: true,
attempts: 1,
lastError: "Kein Speicherplatz",
fullStatus: "Entpack-Fehler [extract-status.part1.rar]: Kein Speicherplatz",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
(manager as any).refreshPackageStatus(session.packages[packageId]);
expect(session.packages[packageId].status).toBe("failed");
});
});
describe("download start account gate", () => {
it("disables every start path when no usable account is active", async () => {
const createManager = () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-start-account-gate-"));
tempDirs.push(root);
const manager = new DownloadManager(
{ ...defaultSettings(), outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "account-gate", links: ["https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] }]);
const snapshot = manager.getSnapshot();
const packageId = snapshot.session.packageOrder[0];
const itemId = snapshot.session.packages[packageId].itemIds[0];
return { manager, packageId, itemId };
};
const starts = [
({ manager }: ReturnType<typeof createManager>) => manager.start(),
({ manager, packageId }: ReturnType<typeof createManager>) => manager.startPackages([packageId]),
({ manager, itemId }: ReturnType<typeof createManager>) => manager.startItems([itemId])
];
for (const start of starts) {
const context = createManager();
expect(context.manager.getSnapshot().canStart).toBe(false);
try {
await expect(start(context)).rejects.toThrow("Kein aktiver Download-Account verfügbar");
expect(context.manager.getSnapshot().session.running).toBe(false);
} finally {
context.manager.stop();
}
}
});
it("treats disabled providers and disabled Mega-Debrid accounts as unavailable", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-account-gate-"));
tempDirs.push(root);
const megaLogin = "disabled@example.test";
const disabledMegaId = getMegaDebridAccountId(megaLogin);
const disabledProviderManager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", disabledProviders: ["realdebrid"] },
emptySession(),
createStoragePaths(path.join(root, "provider"))
);
const disabledMegaManager = new DownloadManager(
{
...defaultSettings(),
megaDebridApiCredentials: `${megaLogin}:secret`,
megaDebridApiEnabled: true,
megaDebridApiDisabledAccountIds: [disabledMegaId],
providerOrder: ["megadebrid-api"]
},
emptySession(),
createStoragePaths(path.join(root, "mega"))
);
expect(disabledProviderManager.getSnapshot().canStart).toBe(false);
expect(disabledMegaManager.getSnapshot().canStart).toBe(false);
});
it("allows start when an active account is available", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-active-account-gate-"));
tempDirs.push(root);
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token" },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
expect(manager.getSnapshot().canStart).toBe(true);
manager.setSettings({ ...defaultSettings(), outputDir: manager.getSettings().outputDir });
expect(manager.getSnapshot().canStart).toBe(false);
});
it("blocks resume when the last active account is unavailable", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-account-gate-"));
tempDirs.push(root);
const manager = new DownloadManager(
defaultSettings(),
emptySession(),
createStoragePaths(path.join(root, "state"))
);
const session = (manager as any).session;
session.running = true;
session.paused = true;
expect(manager.getSnapshot().canStart).toBe(false);
expect(() => manager.togglePause()).toThrow("Kein aktiver Download-Account verfügbar");
expect(manager.getSnapshot().session.paused).toBe(true);
});
});
describe("extractArchiveNameFromExtractorLogMessage", () => { describe("extractArchiveNameFromExtractorLogMessage", () => {
it("detects archive names from extractor log variants", () => { it("detects archive names from extractor log variants", () => {
expect(extractArchiveNameFromExtractorLogMessage("Extract-Backend Start: archive=scn-dhanbs7-S02E008.rar, mode=legacy")).toBe("scn-dhanbs7-S02E008.rar"); expect(extractArchiveNameFromExtractorLogMessage("Extract-Backend Start: archive=scn-dhanbs7-S02E008.rar, mode=legacy")).toBe("scn-dhanbs7-S02E008.rar");
@@ -2762,7 +2991,7 @@ describe("download manager", () => {
await manager.stop(); await manager.stop();
}); });
it("fails fast when Debrid-Link has no active api key left", async () => { it("blocks start when Debrid-Link has no active api key left", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two"); const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
@@ -2796,18 +3025,13 @@ describe("download manager", () => {
); );
manager.addPackages([{ name: "debridlink-no-key", links: ["https://rapidgator.net/file/no-active-key.part1.rar.html"] }]); manager.addPackages([{ name: "debridlink-no-key", links: ["https://rapidgator.net/file/no-active-key.part1.rar.html"] }]);
await manager.start(); expect(manager.getSnapshot().canStart).toBe(false);
await waitFor(() => { await expect(manager.start()).rejects.toThrow("Kein aktiver Download-Account verfügbar");
const item = Object.values(manager.getSnapshot().session.items)[0];
return Boolean(item && item.status === "failed");
}, 12000);
const item = Object.values(manager.getSnapshot().session.items)[0]; const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item?.status).toBe("failed"); expect(manager.getSnapshot().session.running).toBe(false);
expect(item?.fullStatus || "").toContain("Debrid-Link"); expect(item?.status).toBe("queued");
expect(item?.retries).toBe(0); expect(item?.retries).toBe(0);
await manager.stop();
}); });
it("recovers from repeated resume underflow by restarting from zero", async () => { it("recovers from repeated resume underflow by restarting from zero", async () => {
@@ -12582,7 +12806,7 @@ describe("download manager", () => {
expect(internal.settings.debridLinkApiKeyTotalUsageBytes[secondKey.id]).toBe(2048); expect(internal.settings.debridLinkApiKeyTotalUsageBytes[secondKey.id]).toBe(2048);
}); });
it("does not hang when rapid stop, disable provider, start", async () => { it("does not hang when rapid stop is followed by disabling the last provider", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
const binary = Buffer.alloc(256 * 1024, 7); const binary = Buffer.alloc(256 * 1024, 7);
@@ -12665,10 +12889,13 @@ describe("download manager", () => {
disabledProviders: ["realdebrid"] disabledProviders: ["realdebrid"]
}); });
const startPromise = manager.start();
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000)); const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000));
const result = await Promise.race([startPromise.then(() => "ok" as const), timeout]); const result = await Promise.race([
expect(result).toBe("ok"); manager.start().then(() => "started" as const, (error) => String(error)),
timeout
]);
expect(result).toContain("Kein aktiver Download-Account verfügbar");
expect(manager.getSnapshot().session.running).toBe(false);
} finally { } finally {
server.close(); server.close();
await once(server, "close"); await once(server, "close");
+61 -2
View File
@@ -636,7 +636,7 @@ describe("downloads view", () => {
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect"); expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
}); });
it("keeps start available for resume and pause independent from unrelated action busy state", () => { it("blocks resume without a usable account and keeps pause independent from unrelated action busy state", () => {
const pausedToolbar = DownloadsToolbar({ const pausedToolbar = DownloadsToolbar({
actions: createActions(), actions: createActions(),
model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true }) model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true })
@@ -646,7 +646,7 @@ describe("downloads view", () => {
model: withRuntime(createInput(), { paused: false, canPause: true, actionBusy: true }) model: withRuntime(createInput(), { paused: false, canPause: true, actionBusy: true })
}); });
expect(findButton(pausedToolbar, "Start").props.disabled).toBe(false); expect(findButton(pausedToolbar, "Start").props.disabled).toBe(true);
expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true); expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true);
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(false); expect(findButton(busyToolbar, "Pause").props.disabled).toBe(false);
}); });
@@ -1258,6 +1258,65 @@ describe("download table row contracts", () => {
expect(html).toContain('Mega-Debrid API: Kein Server verfügbar'); expect(html).toContain('Mega-Debrid API: Kein Server verfügbar');
}); });
it("removes redundant service suffixes from runtime statuses", () => {
expect(compactDownloadStatus("Starte... (Mega-Debrid Web)")).toBe("Starte...");
expect(compactDownloadStatus("Warte auf Daten (Mega-Debrid Web)")).toBe("Warte auf Daten");
expect(compactDownloadStatus("Warte auf Festplatte (Mega-Debrid Web)")).toBe("Warte auf Festplatte");
});
it("prioritizes disk waits and extraction errors in package status", () => {
const diskPackage = pkg("disk-package", "Disk package", ["disk-item", "active-item"]);
const diskHtml = renderToStaticMarkup(PackageCardContent({
actions: createActions(),
columnOrder: ["status"],
editing: false,
editingName: "",
gridTemplate: "220px",
packageSpeedBps: 1_000,
row: {
package: diskPackage,
items: [
item("disk-item", diskPackage.id, "downloading", { fullStatus: "Warte auf Festplatte (Mega-Debrid Web)" }),
item("active-item", diskPackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid Web)" })
],
allItems: [
item("disk-item", diskPackage.id, "downloading", { fullStatus: "Warte auf Festplatte (Mega-Debrid Web)" }),
item("active-item", diskPackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid Web)" })
],
collapsed: true
},
selectedIds: new Set<string>(),
selectedVersion: 0
}));
expect(diskHtml).toMatch(/>Warte auf Festplatte<\/span>/);
const errorPackage = { ...pkg("error-package", "Error package", ["error-a", "error-b"]), status: "completed" as const };
const errorHtml = renderToStaticMarkup(PackageCardContent({
actions: createActions(),
columnOrder: ["status"],
editing: false,
editingName: "",
gridTemplate: "220px",
packageSpeedBps: 0,
row: {
package: errorPackage,
items: [
item("error-a", errorPackage.id, "completed", { fullStatus: "Entpack-Fehler [release.part1.rar]: Kein Speicherplatz" }),
item("error-b", errorPackage.id, "completed", { fullStatus: "Entpackt - Done" })
],
allItems: [
item("error-a", errorPackage.id, "completed", { fullStatus: "Entpack-Fehler [release.part1.rar]: Kein Speicherplatz" }),
item("error-b", errorPackage.id, "completed", { fullStatus: "Entpackt - Done" })
],
collapsed: true
},
selectedIds: new Set<string>(),
selectedVersion: 0
}));
expect(errorHtml).toMatch(/>Entpack-Fehler<\/span>/);
expect(errorHtml).not.toMatch(/>2\/2<\/span>/);
});
it("shows only the operation in an actively downloading package status", () => { it("shows only the operation in an actively downloading package status", () => {
const activePackage = pkg("active-package", "Active package", ["active-item"]); const activePackage = pkg("active-package", "Active package", ["active-item"]);
const html = renderToStaticMarkup(PackageCardContent({ const html = renderToStaticMarkup(PackageCardContent({
+10 -108
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { DownloadItem, PackageEntry } from "../src/shared/types"; import type { PackageEntry } from "../src/shared/types";
import { sortPackagesForDisplay } from "../src/renderer/package-order"; import { preservePackageOrderForDisplay } from "../src/renderer/package-order";
function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry { function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry {
const now = Date.now(); const now = Date.now();
@@ -20,120 +20,22 @@ function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): Pa
}; };
} }
function createItem(id: string, packageId: string, status: DownloadItem["status"], downloadedBytes: number): DownloadItem { describe("preservePackageOrderForDisplay", () => {
const now = Date.now(); it("keeps the exact queue order", () => {
return {
id,
packageId,
url: `https://hoster.example/${id}`,
provider: null,
status,
retries: 0,
speedBps: 0,
downloadedBytes,
totalBytes: downloadedBytes,
progressPercent: downloadedBytes > 0 ? 50 : 0,
fileName: `${id}.bin`,
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "",
createdAt: now,
updatedAt: now
};
}
describe("sortPackagesForDisplay", () => {
it("floats active packages to the top, keeping queue order within each group", () => {
// pkg-a and pkg-b both have an active (downloading) item -> both float up in
// their original queue order; pkg-c (queued only) sinks below.
const packages = [ const packages = [
createPackage("pkg-a", ["a1", "a2"]), createPackage("pkg-a", ["a1", "a2"]),
createPackage("pkg-c", ["c1"]), createPackage("pkg-c", ["c1"]),
createPackage("pkg-b", ["b1", "b2"]) createPackage("pkg-b", ["b1", "b2"])
]; ];
const items: Record<string, DownloadItem> = { expect(preservePackageOrderForDisplay(packages).map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-c", "pkg-b"]);
a1: createItem("a1", "pkg-a", "downloading", 250),
a2: createItem("a2", "pkg-a", "completed", 500),
c1: createItem("c1", "pkg-c", "queued", 0),
b1: createItem("b1", "pkg-b", "downloading", 800),
b2: createItem("b2", "pkg-b", "completed", 900)
};
const sorted = sortPackagesForDisplay(packages, items, true, true);
// active group [pkg-a, pkg-b] in queue order, then rest [pkg-c]
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
}); });
it("does NOT reshuffle active packages when only their progress changes (anti-flicker)", () => { it("keeps the visible queue order stable across start and completion metadata", () => {
const packages = [ const packages = [
createPackage("pkg-a", ["a1"]), createPackage("pkg-first", ["first-item"], 100),
createPackage("pkg-b", ["b1"]) createPackage("pkg-second", ["second-item"], 200),
createPackage("pkg-third", ["third-item"], 300)
]; ];
// Both active. pkg-b initially has more bytes than pkg-a. expect(preservePackageOrderForDisplay(packages).map((pkg) => pkg.id)).toEqual(["pkg-first", "pkg-second", "pkg-third"]);
const before: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 100),
b1: createItem("b1", "pkg-b", "downloading", 900)
};
const orderBefore = sortPackagesForDisplay(packages, before, true, true).map((p) => p.id);
// A progress tick: pkg-a overtakes pkg-b in bytes. Order must NOT change —
// both are still active, so they keep queue order. (Old code swapped them.)
const after: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 5000),
b1: createItem("b1", "pkg-b", "downloading", 950)
};
const orderAfter = sortPackagesForDisplay(packages, after, true, true).map((p) => p.id);
expect(orderBefore).toEqual(["pkg-a", "pkg-b"]);
expect(orderAfter).toEqual(orderBefore);
});
it("keeps package order untouched when auto sort is disabled", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"]),
createPackage("pkg-c", ["c1"])
];
const items: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "queued", 0),
b1: createItem("b1", "pkg-b", "downloading", 500),
c1: createItem("c1", "pkg-c", "queued", 0)
};
const sorted = sortPackagesForDisplay(packages, items, true, false);
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
});
it("keeps every active package in activation order when a new package starts", () => {
const packages = [
createPackage("pkg-new", ["new-item"], 200),
createPackage("pkg-existing", ["existing-item"], 100)
];
const items: Record<string, DownloadItem> = {
"new-item": createItem("new-item", "pkg-new", "downloading", 100),
"existing-item": createItem("existing-item", "pkg-existing", "downloading", 200)
};
const sorted = sortPackagesForDisplay(packages, items, true, true);
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-existing", "pkg-new"]);
});
it("keeps queue order for active packages without a recorded start time", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"]),
createPackage("pkg-c", ["c1"])
];
const items: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "completed", 500),
b1: createItem("b1", "pkg-b", "downloading", 200),
c1: createItem("c1", "pkg-c", "downloading", 100)
};
expect(sortPackagesForDisplay(packages, items, true, true).map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-c", "pkg-a"]);
}); });
}); });