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:
+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);
|
||||
|
||||
Reference in New Issue
Block a user