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:
@@ -108,7 +108,7 @@ export function defaultSettings(): AppSettings {
|
||||
historyMaxEntries: 500,
|
||||
historyMaxAgeDays: 0,
|
||||
accountListShowDetailedDebridLinkKeys: false,
|
||||
autoSortPackagesByProgress: true,
|
||||
autoSortPackagesByProgress: false,
|
||||
autoSkipExtracted: false,
|
||||
hideExtractedItems: true,
|
||||
confirmDeleteSelection: true,
|
||||
|
||||
+113
-58
@@ -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<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,
|
||||
@@ -5521,8 +5546,9 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
public async startPackages(packageIds: string[]): Promise<void> {
|
||||
const targetSet = new Set(packageIds);
|
||||
public async startPackages(packageIds: string[]): Promise<void> {
|
||||
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<void> {
|
||||
const targetSet = new Set(itemIds);
|
||||
public async startItems(itemIds: string[]): Promise<void> {
|
||||
this.ensureUsableDownloadAccount();
|
||||
const targetSet = new Set(itemIds);
|
||||
|
||||
const affectedPackageIds = new Set<string>();
|
||||
for (const itemId of targetSet) {
|
||||
@@ -5720,11 +5747,12 @@ export class DownloadManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
public async start(options?: { excludePackageIds?: ReadonlySet<string> }): Promise<void> {
|
||||
if (this.session.running) {
|
||||
return;
|
||||
}
|
||||
this.schedulerGeneration += 1;
|
||||
public async start(options?: { excludePackageIds?: ReadonlySet<string> }): Promise<void> {
|
||||
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);
|
||||
|
||||
+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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 <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 },
|
||||
|
||||
Reference in New Issue
Block a user