diff --git a/CHANGELOG.md b/CHANGELOG.md index 4baab27..4205148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index a1f37dc..28b52cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index bddb8b3..759ed98 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/constants.ts b/src/main/constants.ts index ff491c0..bfa7b97 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -108,7 +108,7 @@ export function defaultSettings(): AppSettings { historyMaxEntries: 500, historyMaxAgeDays: 0, accountListShowDetailedDebridLinkKeys: false, - autoSortPackagesByProgress: true, + autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 5df4ff5..28d9fd5 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -72,7 +72,7 @@ import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettings import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils"; import { mergeKnownTotalBytes } from "./download-size"; -type ActiveTask = { +type ActiveTask = { itemId: string; packageId: string; abortController: AbortController; @@ -86,7 +86,19 @@ type ActiveTask = { unrestrictRetries?: number; blockedOnDiskWrite?: boolean; blockedOnDiskSince?: number; -}; +}; + +const DOWNLOAD_ACCOUNT_PROVIDERS: readonly DebridProvider[] = [ + "realdebrid", + "megadebrid-api", + "megadebrid-web", + "bestdebrid", + "alldebrid", + "ddownload", + "onefichier", + "debridlink", + "linksnappy" +]; type PackageItemDiskState = { diskPath: string | null; @@ -798,7 +810,7 @@ function formatExtractDone(elapsedMs: number): string { : `Entpackt - Done (${Math.round(secs)}s)`; } -function providerLabel(provider: DownloadItem["provider"]): string { +function providerLabel(provider: DownloadItem["provider"]): string { if (provider === "realdebrid") { return "Real-Debrid"; } @@ -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(items: readonly T[], concurrency: number, worker: (item: T) => Promise): Promise { 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, @@ -5521,8 +5546,9 @@ export class DownloadManager extends EventEmitter { this.emitState(); } - public async startPackages(packageIds: string[]): Promise { - const targetSet = new Set(packageIds); + public async startPackages(packageIds: string[]): Promise { + this.ensureUsableDownloadAccount(); + const targetSet = new Set(packageIds); for (const pkgId of targetSet) { const pkg = this.session.packages[pkgId]; @@ -5615,8 +5641,9 @@ export class DownloadManager extends EventEmitter { }); } - public async startItems(itemIds: string[]): Promise { - const targetSet = new Set(itemIds); + public async startItems(itemIds: string[]): Promise { + this.ensureUsableDownloadAccount(); + const targetSet = new Set(itemIds); const affectedPackageIds = new Set(); for (const itemId of targetSet) { @@ -5720,11 +5747,12 @@ export class DownloadManager extends EventEmitter { }); } - public async start(options?: { excludePackageIds?: ReadonlySet }): Promise { - if (this.session.running) { - return; - } - this.schedulerGeneration += 1; + public async start(options?: { excludePackageIds?: ReadonlySet }): Promise { + if (this.session.running) { + return; + } + this.ensureUsableDownloadAccount(); + this.schedulerGeneration += 1; this.session.running = true; @@ -6022,12 +6050,15 @@ export class DownloadManager extends EventEmitter { logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`); } - public togglePause(): boolean { - if (!this.session.running) { - return false; - } - const wasPaused = this.session.paused; - this.session.paused = !this.session.paused; + public togglePause(): boolean { + if (!this.session.running) { + return false; + } + const wasPaused = this.session.paused; + if (wasPaused) { + this.ensureUsableDownloadAccount(); + } + this.session.paused = !this.session.paused; if (!wasPaused && this.session.paused) { this.speedEvents = []; @@ -8068,7 +8099,7 @@ export class DownloadManager extends EventEmitter { } } - private isProviderConfigured(provider: DebridProvider): boolean { + private isProviderConfigured(provider: DebridProvider): boolean { this.ensureProviderDailyUsageFresh(nowMs()); const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider; if ((this.settings.disabledProviders || []).includes(provider) || (this.settings.disabledProviders || []).includes(effectiveProvider)) { @@ -8081,12 +8112,12 @@ 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; - return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" || this.settings.megaDebridWebEnabled)); + const hasMegaCreds = getAvailableMegaDebridAccounts(this.settings, "web").length > 0; + return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" || this.settings.megaDebridWebEnabled)); } if (effectiveProvider === "bestdebrid") { return Boolean(this.settings.bestDebridUseWebLogin || this.settings.bestToken.trim()); @@ -8107,8 +8138,18 @@ export class DownloadManager extends EventEmitter { if (provider === "linksnappy") { return Boolean(this.settings.linkSnappyLogin.trim() && this.settings.linkSnappyPassword.trim()); } - 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[] { if (this.settings.providerOrder && this.settings.providerOrder.length > 0) { @@ -9178,10 +9219,9 @@ export class DownloadManager extends EventEmitter { : path.join(pkg.outputDir, item.fileName); item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget)); 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.status = "downloading"; + const pLabel = unrestricted.providerLabel; + item.fullStatus = "Starte..."; item.updatedAt = nowMs(); this.emitState(); logger.info(`Download Start: ${item.fileName} (${humanSize(unrestricted.fileSize || 0)}) via ${pLabel}, pkg=${pkg.name}`); @@ -9209,9 +9249,9 @@ export class DownloadManager extends EventEmitter { existingBytes: item.downloadedBytes, totalBytes: item.totalBytes }); - if (item.status !== "downloading") { - item.status = "downloading"; - item.fullStatus = `Download läuft (${statusLabel})`; + if (item.status !== "downloading") { + item.status = "downloading"; + item.fullStatus = "Download läuft"; item.updatedAt = nowMs(); this.emitState(); } @@ -9510,10 +9550,18 @@ export class DownloadManager extends EventEmitter { this.recordRunOutcome(item.id, "failed"); this.retryStateByItem.delete(item.id); } else { - const errorText = compactErrorText(error); - if (this.tryFinalizeItemFromDisk(pkg, item, "Error-Recovery", errorText)) { - return; - } + const errorText = compactErrorText(error); + 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" @@ -9886,12 +9934,11 @@ export class DownloadManager extends EventEmitter { active: ActiveTask, directUrl: string, targetPath: string, - knownTotal: number | null, - skipTlsVerify?: boolean, - pLabel?: string - ): Promise<{ resumable: boolean }> { - const label = providerLabel(this.session.items[active.itemId]?.provider) || pLabel || "Debrid"; - const item = this.session.items[active.itemId]; + knownTotal: number | null, + skipTlsVerify?: boolean, + _pLabel?: string + ): Promise<{ resumable: boolean }> { + 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; @@ -10852,11 +10899,15 @@ export class DownloadManager extends EventEmitter { targetPath: effectiveTargetPath, ...(diskCause ? { diskCause } : {}) }); - if (diskCause) { + if (diskCause) { // Surface the concrete OS cause (disk full, permission, ...) prominently // 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 ( normalizedLastError.startsWith("range_ignored_on_resume:") || normalizedLastError.startsWith("range_mismatch_on_resume:") @@ -11106,11 +11157,12 @@ export class DownloadManager extends EventEmitter { }); } - private refreshPackageStatus(pkg: PackageEntry): void { + private refreshPackageStatus(pkg: PackageEntry): void { let pending = 0; let success = 0; let failed = 0; - let cancelled = 0; + let cancelled = 0; + let extractFailed = 0; let total = 0; for (const itemId of pkg.itemIds) { const item = this.session.items[itemId]; @@ -11119,8 +11171,11 @@ export class DownloadManager extends EventEmitter { } total += 1; const s = item.status; - if (s === "completed") { - success += 1; + if (s === "completed") { + success += 1; + if (isExtractErrorLabel(item.fullStatus || "")) { + extractFailed += 1; + } } else if (s === "failed") { failed += 1; } else if (s === "cancelled") { @@ -11140,8 +11195,8 @@ export class DownloadManager extends EventEmitter { } const prevStatus = pkg.status; - if (failed > 0) { - pkg.status = "failed"; + if (failed > 0 || extractFailed > 0) { + pkg.status = "failed"; } else if (cancelled > 0) { pkg.status = success > 0 ? "completed" : "cancelled"; } else if (success > 0) { @@ -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); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0b5d002..568b322 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 = { @@ -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), diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 6c4859e..d130f2e 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -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"], diff --git a/src/renderer/package-order.ts b/src/renderer/package-order.ts index 1b563e3..bb3eb42 100644 --- a/src/renderer/package-order.ts +++ b/src/renderer/package-order.ts @@ -1,13 +1,4 @@ -import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types"; - -const ACTIVE_PACKAGE_STATUSES = new Set(["downloading", "validating", "integrity_check", "extracting"]); - -function isPackageActive(pkg: PackageEntry, itemsById: Record): 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, - 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]; -} +export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] { + return packages; +} diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index 210f3d1..0e55247 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -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 - : downloading ? "Download läuft" : details; - const title = audio?.tooltip ? `${details}\n${audio.tooltip}` : details; + : extractFailure ? "Entpack-Fehler" + : 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 ; } if (column === "speed") return {packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}; diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index ea10a45..6d4b153 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -118,7 +118,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1; return (
- + {model.scheduleActive diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index 5f028ad..1d099e8 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -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 }, diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 94c6b38..d3d7e45 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -6,7 +6,7 @@ import crypto from "node:crypto"; import { EventEmitter, once } from "node:events"; import AdmZip from "adm-zip"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, 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"; @@ -41,6 +41,235 @@ describe("runWithLimitedConcurrency", () => { expect(completed.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]); }); }); + +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) => manager.start(), + ({ manager, packageId }: ReturnType) => manager.startPackages([packageId]), + ({ manager, itemId }: ReturnType) => 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", () => { @@ -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"); @@ -2793,21 +3022,16 @@ describe("download manager", () => { settings, emptySession(), createStoragePaths(path.join(root, "state")) - ); - - 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); - - const item = Object.values(manager.getSnapshot().session.items)[0]; - expect(item?.status).toBe("failed"); - expect(item?.fullStatus || "").toContain("Debrid-Link"); - expect(item?.retries).toBe(0); - - await manager.stop(); + ); + + manager.addPackages([{ name: "debridlink-no-key", links: ["https://rapidgator.net/file/no-active-key.part1.rar.html"] }]); + 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(manager.getSnapshot().session.running).toBe(false); + expect(item?.status).toBe("queued"); + expect(item?.retries).toBe(0); }); 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 timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000)); + 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"); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index bf7fcc4..586ab08 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -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(), + 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(), + 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({ diff --git a/tests/package-order.test.ts b/tests/package-order.test.ts index 703f452..08e7e0b 100644 --- a/tests/package-order.test.ts +++ b/tests/package-order.test.ts @@ -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. - const packages = [ - createPackage("pkg-a", ["a1", "a2"]), - createPackage("pkg-c", ["c1"]), - createPackage("pkg-b", ["b1", "b2"]) - ]; - const items: Record = { - 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)", () => { - const packages = [ - createPackage("pkg-a", ["a1"]), - createPackage("pkg-b", ["b1"]) - ]; - // Both active. pkg-b initially has more bytes than pkg-a. - const before: Record = { - 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 = { - 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 = { - 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"]); +describe("preservePackageOrderForDisplay", () => { + it("keeps the exact queue order", () => { + const packages = [ + createPackage("pkg-a", ["a1", "a2"]), + createPackage("pkg-c", ["c1"]), + createPackage("pkg-b", ["b1", "b2"]) + ]; + expect(preservePackageOrderForDisplay(packages).map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-c", "pkg-b"]); }); - it("keeps every active package in activation order when a new package starts", () => { + it("keeps the visible queue order stable across start and completion metadata", () => { const packages = [ - createPackage("pkg-new", ["new-item"], 200), - createPackage("pkg-existing", ["existing-item"], 100) + createPackage("pkg-first", ["first-item"], 100), + createPackage("pkg-second", ["second-item"], 200), + createPackage("pkg-third", ["third-item"], 300) ]; - const items: Record = { - "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 = { - 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"]); }); });