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:
@@ -2,6 +2,28 @@
|
||||
|
||||
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
|
||||
|
||||
### Update experience
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "0.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@@ -108,7 +108,7 @@ export function defaultSettings(): AppSettings {
|
||||
historyMaxEntries: 500,
|
||||
historyMaxAgeDays: 0,
|
||||
accountListShowDetailedDebridLinkKeys: false,
|
||||
autoSortPackagesByProgress: true,
|
||||
autoSortPackagesByProgress: false,
|
||||
autoSkipExtracted: false,
|
||||
hideExtractedItems: true,
|
||||
confirmDeleteSelection: true,
|
||||
|
||||
@@ -88,6 +88,18 @@ type ActiveTask = {
|
||||
blockedOnDiskSince?: number;
|
||||
};
|
||||
|
||||
const DOWNLOAD_ACCOUNT_PROVIDERS: readonly DebridProvider[] = [
|
||||
"realdebrid",
|
||||
"megadebrid-api",
|
||||
"megadebrid-web",
|
||||
"bestdebrid",
|
||||
"alldebrid",
|
||||
"ddownload",
|
||||
"onefichier",
|
||||
"debridlink",
|
||||
"linksnappy"
|
||||
];
|
||||
|
||||
type PackageItemDiskState = {
|
||||
diskPath: string | null;
|
||||
exists: boolean;
|
||||
@@ -1700,6 +1712,19 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
||||
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> {
|
||||
const workerCount = Math.min(items.length, Math.max(1, Math.floor(concurrency)));
|
||||
let nextIndex = 0;
|
||||
@@ -2485,7 +2510,7 @@ export class DownloadManager extends EventEmitter {
|
||||
stats: this.getStats(now),
|
||||
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
|
||||
etaText: paused || !this.session.running ? "ETA: --" : `ETA: ${formatEta(eta)}`,
|
||||
canStart: !this.session.running,
|
||||
canStart: (!this.session.running || paused) && this.hasUsableDownloadAccount(),
|
||||
canStop: this.session.running,
|
||||
canPause: this.session.running,
|
||||
clipboardActive: this.settings.clipboardWatch,
|
||||
@@ -5522,6 +5547,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
public async startPackages(packageIds: string[]): Promise<void> {
|
||||
this.ensureUsableDownloadAccount();
|
||||
const targetSet = new Set(packageIds);
|
||||
|
||||
for (const pkgId of targetSet) {
|
||||
@@ -5616,6 +5642,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
public async startItems(itemIds: string[]): Promise<void> {
|
||||
this.ensureUsableDownloadAccount();
|
||||
const targetSet = new Set(itemIds);
|
||||
|
||||
const affectedPackageIds = new Set<string>();
|
||||
@@ -5724,6 +5751,7 @@ export class DownloadManager extends EventEmitter {
|
||||
if (this.session.running) {
|
||||
return;
|
||||
}
|
||||
this.ensureUsableDownloadAccount();
|
||||
this.schedulerGeneration += 1;
|
||||
|
||||
this.session.running = true;
|
||||
@@ -6027,6 +6055,9 @@ export class DownloadManager extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
const wasPaused = this.session.paused;
|
||||
if (wasPaused) {
|
||||
this.ensureUsableDownloadAccount();
|
||||
}
|
||||
this.session.paused = !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());
|
||||
}
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
if (effectiveProvider === "bestdebrid") {
|
||||
@@ -8110,6 +8141,16 @@ export class DownloadManager extends EventEmitter {
|
||||
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[] {
|
||||
if (this.settings.providerOrder && this.settings.providerOrder.length > 0) {
|
||||
return [...this.settings.providerOrder];
|
||||
@@ -9180,8 +9221,7 @@ export class DownloadManager extends EventEmitter {
|
||||
item.totalBytes = mergeKnownTotalBytes(item.totalBytes, unrestricted.fileSize);
|
||||
item.status = "downloading";
|
||||
const pLabel = unrestricted.providerLabel;
|
||||
const statusLabel = providerLabel(unrestricted.provider) || pLabel;
|
||||
item.fullStatus = `Starte... (${statusLabel})`;
|
||||
item.fullStatus = "Starte...";
|
||||
item.updatedAt = nowMs();
|
||||
this.emitState();
|
||||
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") {
|
||||
item.status = "downloading";
|
||||
item.fullStatus = `Download läuft (${statusLabel})`;
|
||||
item.fullStatus = "Download läuft";
|
||||
item.updatedAt = nowMs();
|
||||
this.emitState();
|
||||
}
|
||||
@@ -9514,6 +9554,14 @@ export class DownloadManager extends EventEmitter {
|
||||
if (this.tryFinalizeItemFromDisk(pkg, item, "Error-Recovery", errorText)) {
|
||||
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", {
|
||||
error: errorText,
|
||||
abortReason: reason || "none"
|
||||
@@ -9888,9 +9936,8 @@ export class DownloadManager extends EventEmitter {
|
||||
targetPath: string,
|
||||
knownTotal: number | null,
|
||||
skipTlsVerify?: boolean,
|
||||
pLabel?: string
|
||||
_pLabel?: string
|
||||
): Promise<{ resumable: boolean }> {
|
||||
const label = providerLabel(this.session.items[active.itemId]?.provider) || pLabel || "Debrid";
|
||||
const item = this.session.items[active.itemId];
|
||||
if (!item) {
|
||||
throw new Error("Download-Item fehlt");
|
||||
@@ -10347,7 +10394,7 @@ export class DownloadManager extends EventEmitter {
|
||||
if (diskBusyStatusVisible(nowTick) && nowTick - lastDiskBusyEmitAt >= 1200) {
|
||||
item.status = "downloading";
|
||||
item.speedBps = 0;
|
||||
item.fullStatus = `Warte auf Festplatte (${label})`;
|
||||
item.fullStatus = "Warte auf Festplatte";
|
||||
item.updatedAt = nowTick;
|
||||
this.emitState();
|
||||
lastDiskBusyEmitAt = nowTick;
|
||||
@@ -10457,7 +10504,7 @@ export class DownloadManager extends EventEmitter {
|
||||
if (diskBusyStatusVisible(nowTick) && nowTick - lastIdleEmitAt >= idlePulseMs) {
|
||||
item.status = "downloading";
|
||||
item.speedBps = 0;
|
||||
item.fullStatus = `Warte auf Festplatte (${label})`;
|
||||
item.fullStatus = "Warte auf Festplatte";
|
||||
item.updatedAt = nowTick;
|
||||
this.emitState();
|
||||
lastIdleEmitAt = nowTick;
|
||||
@@ -10473,7 +10520,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
item.status = "downloading";
|
||||
item.speedBps = 0;
|
||||
item.fullStatus = `Warte auf Daten (${label})`;
|
||||
item.fullStatus = "Warte auf Daten";
|
||||
if (nowTick - lastIdleEmitAt >= idlePulseMs) {
|
||||
item.updatedAt = nowTick;
|
||||
this.emitState();
|
||||
@@ -10579,7 +10626,7 @@ export class DownloadManager extends EventEmitter {
|
||||
if (nowTick - lastDiskBusyEmitAt >= 1200) {
|
||||
item.status = "downloading";
|
||||
item.speedBps = 0;
|
||||
item.fullStatus = `Warte auf Festplatte (${label})`;
|
||||
item.fullStatus = "Warte auf Festplatte";
|
||||
item.updatedAt = nowTick;
|
||||
this.emitState();
|
||||
lastDiskBusyEmitAt = nowTick;
|
||||
@@ -10631,10 +10678,10 @@ export class DownloadManager extends EventEmitter {
|
||||
const diskBusy = diskBusyStatusVisible(nowMs());
|
||||
if (diskBusy) {
|
||||
item.speedBps = 0;
|
||||
item.fullStatus = `Warte auf Festplatte (${label})`;
|
||||
item.fullStatus = "Warte auf Festplatte";
|
||||
} else {
|
||||
item.speedBps = Math.max(0, Math.floor(speed));
|
||||
item.fullStatus = `Download läuft (${label})`;
|
||||
item.fullStatus = "Download läuft";
|
||||
}
|
||||
const progressNow = nowMs();
|
||||
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.
|
||||
logger.error(`Schreibfehler beim Download: ${diskCause} - ${item.fileName} (ziel=${effectiveTargetPath})`);
|
||||
}
|
||||
const diskWaitReason = getDiskWriteWaitReason(error);
|
||||
if (diskWaitReason) {
|
||||
throw new Error(`disk_write_wait:${diskWaitReason}`);
|
||||
}
|
||||
if (
|
||||
normalizedLastError.startsWith("range_ignored_on_resume:")
|
||||
|| normalizedLastError.startsWith("range_mismatch_on_resume:")
|
||||
@@ -11111,6 +11162,7 @@ export class DownloadManager extends EventEmitter {
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let cancelled = 0;
|
||||
let extractFailed = 0;
|
||||
let total = 0;
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
@@ -11121,6 +11173,9 @@ export class DownloadManager extends EventEmitter {
|
||||
const s = item.status;
|
||||
if (s === "completed") {
|
||||
success += 1;
|
||||
if (isExtractErrorLabel(item.fullStatus || "")) {
|
||||
extractFailed += 1;
|
||||
}
|
||||
} else if (s === "failed") {
|
||||
failed += 1;
|
||||
} else if (s === "cancelled") {
|
||||
@@ -11140,7 +11195,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
const prevStatus = pkg.status;
|
||||
if (failed > 0) {
|
||||
if (failed > 0 || extractFailed > 0) {
|
||||
pkg.status = "failed";
|
||||
} else if (cancelled > 0) {
|
||||
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
|
||||
// double-fire if post-processing does run later.
|
||||
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) {
|
||||
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
||||
this.recordPackageHistory(pkg.id, pkg, items);
|
||||
|
||||
+5
-16
@@ -36,7 +36,7 @@ import {
|
||||
getProviderDailyUsageBytes,
|
||||
getProviderUsageDayKey
|
||||
} from "../shared/provider-daily-limits";
|
||||
import { sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
||||
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
||||
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
|
||||
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
|
||||
import type { AccountModeFilter } from "./account-ui";
|
||||
@@ -1100,7 +1100,7 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
||||
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
||||
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,
|
||||
accountListShowDetailedDebridLinkKeys: false,
|
||||
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
||||
@@ -1131,7 +1131,7 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
paused: false, running: false, updatedAt: Date.now()
|
||||
},
|
||||
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> = {
|
||||
@@ -2340,20 +2340,9 @@ export function App(): ReactElement {
|
||||
setSelectedIds((prev) => pruneSelection(prev, snapshot.session));
|
||||
}, [snapshot.session.packages, snapshot.session.items]);
|
||||
|
||||
const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1)
|
||||
? snapshot.session.items
|
||||
: null;
|
||||
const visiblePackages = useMemo(() => {
|
||||
if (!sortRelevantItems) {
|
||||
return packages;
|
||||
}
|
||||
return sortPackagesForDisplay(
|
||||
packages,
|
||||
sortRelevantItems,
|
||||
true,
|
||||
true
|
||||
);
|
||||
}, [packages, sortRelevantItems]);
|
||||
return preservePackageOrderForDisplay(packages);
|
||||
}, [packages]);
|
||||
|
||||
const downloadsViewCore = useMemo(() => buildDownloadsViewModel({
|
||||
packageOrder: visiblePackages.map((entry) => entry.id),
|
||||
|
||||
@@ -13,7 +13,7 @@ const pairs = [
|
||||
["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"],
|
||||
["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)"],
|
||||
["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"],
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import type { DownloadItem, DownloadStatus, 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);
|
||||
});
|
||||
}
|
||||
import type { PackageEntry } from "../shared/types";
|
||||
|
||||
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
||||
const fromIndex = order.indexOf(draggedPackageId);
|
||||
@@ -33,36 +24,6 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function sortPackagesForDisplay(
|
||||
packages: PackageEntry[],
|
||||
itemsById: Record<string, DownloadItem>,
|
||||
running: boolean,
|
||||
autoSortPackagesByProgress: boolean
|
||||
): PackageEntry[] {
|
||||
if (!running || !autoSortPackagesByProgress || packages.length <= 1) {
|
||||
export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
|
||||
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 {
|
||||
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 (/Download läuft\b/i.test(status)) return "Download läuft";
|
||||
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)
|
||||
? "Entpacken - Ausstehend"
|
||||
: 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 status = postProcessLabel && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting")
|
||||
? postProcessLabel
|
||||
: extractFailure ? "Entpack-Fehler"
|
||||
: waitsForDisk ? "Warte auf Festplatte"
|
||||
: downloading ? "Download läuft" : details;
|
||||
const title = audio?.tooltip ? `${details}\n${audio.tooltip}` : details;
|
||||
const statusDetails = extractFailure ? `${details}\n${extractFailure.fullStatus}` : details;
|
||||
const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails;
|
||||
return <DownloadStatusCell status={status} title={title} />;
|
||||
}
|
||||
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;
|
||||
return (
|
||||
<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.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
|
||||
{model.scheduleActive
|
||||
|
||||
@@ -507,7 +507,6 @@ export function buildSettingsFormViewModel({
|
||||
title: "Oberfläche und Bedienung",
|
||||
fields: [
|
||||
{ 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: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection },
|
||||
{ id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads },
|
||||
|
||||
+242
-15
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, 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 { defaultSettings } from "../src/main/constants";
|
||||
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", () => {
|
||||
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");
|
||||
@@ -2762,7 +2991,7 @@ describe("download manager", () => {
|
||||
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-"));
|
||||
tempDirs.push(root);
|
||||
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"] }]);
|
||||
await manager.start();
|
||||
await waitFor(() => {
|
||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||
return Boolean(item && item.status === "failed");
|
||||
}, 12000);
|
||||
expect(manager.getSnapshot().canStart).toBe(false);
|
||||
await expect(manager.start()).rejects.toThrow("Kein aktiver Download-Account verfügbar");
|
||||
|
||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||
expect(item?.status).toBe("failed");
|
||||
expect(item?.fullStatus || "").toContain("Debrid-Link");
|
||||
expect(manager.getSnapshot().session.running).toBe(false);
|
||||
expect(item?.status).toBe("queued");
|
||||
expect(item?.retries).toBe(0);
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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-"));
|
||||
tempDirs.push(root);
|
||||
const binary = Buffer.alloc(256 * 1024, 7);
|
||||
@@ -12665,10 +12889,13 @@ describe("download manager", () => {
|
||||
disabledProviders: ["realdebrid"]
|
||||
});
|
||||
|
||||
const startPromise = manager.start();
|
||||
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000));
|
||||
const result = await Promise.race([startPromise.then(() => "ok" as const), timeout]);
|
||||
expect(result).toBe("ok");
|
||||
const result = await Promise.race([
|
||||
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 {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
|
||||
@@ -636,7 +636,7 @@ describe("downloads view", () => {
|
||||
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({
|
||||
actions: createActions(),
|
||||
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 })
|
||||
});
|
||||
|
||||
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(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');
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const activePackage = pkg("active-package", "Active package", ["active-item"]);
|
||||
const html = renderToStaticMarkup(PackageCardContent({
|
||||
|
||||
+10
-108
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DownloadItem, PackageEntry } from "../src/shared/types";
|
||||
import { sortPackagesForDisplay } from "../src/renderer/package-order";
|
||||
import type { PackageEntry } from "../src/shared/types";
|
||||
import { preservePackageOrderForDisplay } from "../src/renderer/package-order";
|
||||
|
||||
function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry {
|
||||
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 {
|
||||
const now = Date.now();
|
||||
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.
|
||||
describe("preservePackageOrderForDisplay", () => {
|
||||
it("keeps the exact queue order", () => {
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1", "a2"]),
|
||||
createPackage("pkg-c", ["c1"]),
|
||||
createPackage("pkg-b", ["b1", "b2"])
|
||||
];
|
||||
const items: Record<string, DownloadItem> = {
|
||||
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"]);
|
||||
expect(preservePackageOrderForDisplay(packages).map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-c", "pkg-b"]);
|
||||
});
|
||||
|
||||
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 = [
|
||||
createPackage("pkg-a", ["a1"]),
|
||||
createPackage("pkg-b", ["b1"])
|
||||
createPackage("pkg-first", ["first-item"], 100),
|
||||
createPackage("pkg-second", ["second-item"], 200),
|
||||
createPackage("pkg-third", ["third-item"], 300)
|
||||
];
|
||||
// Both active. pkg-b initially has more bytes than pkg-a.
|
||||
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"]);
|
||||
expect(preservePackageOrderForDisplay(packages).map((pkg) => pkg.id)).toEqual(["pkg-first", "pkg-second", "pkg-third"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user