Fix provider order/routing respecting on restart, schedule timer on manual start

- Clear item.provider on stop/restart so provider order/routing changes
  are respected on next download attempt
- Reset item.provider for all non-completed items when providerOrder or
  hosterRouting changes in settings
- Cancel scheduled-start timer when queue is started manually
- Track disk-fallback hybrid-extract archives per session to prevent
  infinite post-processing loop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-06 23:52:09 +01:00
parent 9dd5d4eef8
commit 9d374b97cf
2 changed files with 63 additions and 1 deletions

View File

@ -991,6 +991,10 @@ export class DownloadManager extends EventEmitter {
private hybridExtractRequeue = new Set<string>();
// Tracks archive paths already attempted per package in the current post-processing session.
// Prevents infinite re-extraction of disk-fallback archives that have no session items.
private hybridExtractedPaths = new Map<string, Set<string>>();
private reservedTargetPaths = new Map<string, string>();
private claimedTargetPathByItem = new Map<string, string>();
@ -1062,10 +1066,31 @@ export class DownloadManager extends EventEmitter {
}
public setSettings(next: AppSettings): void {
const previous = this.settings;
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
this.settings = next;
this.debridService.setSettings(next);
this.allDebridHostInfoCache.clear();
// When the provider order or hoster routing changes, clear the cached provider on
// all non-active, non-terminal items so the new settings are respected on the next
// download attempt.
const prevOrder = JSON.stringify(previous.providerOrder ?? []);
const nextOrder = JSON.stringify(next.providerOrder ?? []);
const prevRouting = JSON.stringify(previous.hosterRouting ?? {});
const nextRouting = JSON.stringify(next.hosterRouting ?? {});
if (prevOrder !== nextOrder || prevRouting !== nextRouting) {
const activeItemIds = new Set([...this.activeTasks.values()].map((t) => t.itemId));
for (const item of Object.values(this.session.items)) {
// Clear for all non-active items except truly finished ones (completed/failed).
// "cancelled" items with fullStatus="Gestoppt" are stopped downloads that will
// restart on the next start() call, so they must also get their provider cleared.
if (!activeItemIds.has(item.id) && item.status !== "completed" && item.status !== "failed") {
item.provider = null;
}
}
}
this.resolveExistingQueuedOpaqueFilenames();
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
if (next.completedCleanupPolicy !== "never") {
@ -1428,6 +1453,7 @@ export class DownloadManager extends EventEmitter {
this.packagePostProcessTasks.clear();
this.packagePostProcessAbortControllers.clear();
this.hybridExtractRequeue.clear();
this.hybridExtractedPaths.clear();
this.providerFailures.clear();
this.packagePostProcessQueue = Promise.resolve();
this.packagePostProcessActive = 0;
@ -1618,6 +1644,7 @@ export class DownloadManager extends EventEmitter {
this.packagePostProcessAbortControllers.delete(packageId);
this.packagePostProcessTasks.delete(packageId);
this.hybridExtractRequeue.delete(packageId);
this.hybridExtractedPaths.delete(packageId);
this.runPackageIds.delete(packageId);
this.runCompletedPackages.delete(packageId);
@ -2772,6 +2799,7 @@ export class DownloadManager extends EventEmitter {
this.packagePostProcessAbortControllers.delete(packageId);
this.packagePostProcessTasks.delete(packageId);
this.hybridExtractRequeue.delete(packageId);
this.hybridExtractedPaths.delete(packageId);
this.runCompletedPackages.delete(packageId);
// 3. Clean up extraction progress manifest (.rd_extract_progress.json)
@ -2861,6 +2889,7 @@ export class DownloadManager extends EventEmitter {
this.packagePostProcessAbortControllers.delete(pkgId);
this.packagePostProcessTasks.delete(pkgId);
this.hybridExtractRequeue.delete(pkgId);
this.hybridExtractedPaths.delete(pkgId);
this.runCompletedPackages.delete(pkgId);
this.historyRecordedPackages.delete(pkgId);
@ -3502,6 +3531,7 @@ export class DownloadManager extends EventEmitter {
item.fullStatus = "Wartet";
item.lastError = "";
item.speedBps = 0;
item.provider = null; // Re-evaluate provider order on restart
item.updatedAt = nowMs();
continue;
}
@ -3985,6 +4015,9 @@ export class DownloadManager extends EventEmitter {
return existing;
}
// Fresh session: reset the set of already-tried archives so new downloads can be retried.
this.hybridExtractedPaths.delete(packageId);
const abortController = new AbortController();
this.packagePostProcessAbortControllers.set(packageId, abortController);
@ -4305,6 +4338,7 @@ export class DownloadManager extends EventEmitter {
// causing "Start Selected" to continue with ALL packages after cleanup.
this.runCompletedPackages.delete(packageId);
this.hybridExtractRequeue.delete(packageId);
this.hybridExtractedPaths.delete(packageId);
this.resetSessionTotalsIfQueueEmpty();
}
@ -6757,6 +6791,18 @@ export class DownloadManager extends EventEmitter {
if (findReadyMs > 200) {
logger.info(`findReadyArchiveSets dauerte ${(findReadyMs / 1000).toFixed(1)}s: pkg=${pkg.name}, found=${readyArchives.size}`);
}
// Skip archives already attempted in this post-processing session to prevent
// infinite re-extraction of disk-fallback archives with no session items.
const alreadyTried = this.hybridExtractedPaths.get(packageId);
if (alreadyTried) {
for (const key of [...readyArchives]) {
if (alreadyTried.has(key)) {
readyArchives.delete(key);
}
}
}
if (readyArchives.size === 0) {
logger.info(`Hybrid-Extract: pkg=${pkg.name}, keine fertigen Archive-Sets`);
return 0;
@ -6995,6 +7041,14 @@ export class DownloadManager extends EventEmitter {
});
logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`);
// Mark all attempted archives as tried so they are not retried in subsequent
// requeue rounds of the same post-processing session (prevents infinite loop
// when disk-fallback archives have no corresponding session items).
{
let tried = this.hybridExtractedPaths.get(packageId);
if (!tried) { tried = new Set(); this.hybridExtractedPaths.set(packageId, tried); }
for (const key of readyArchives) { tried.add(key); }
}
if (result.extracted > 0) {
// Fire-and-forget: rename then collect MKVs in background so the
// slot is not blocked and the next archive set can start immediately.

View File

@ -315,7 +315,15 @@ function registerIpcHandlers(): void {
return controller.resolveStartConflict(packageId, policy);
});
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
ipcMain.handle(IPC_CHANNELS.START, () => controller.start());
ipcMain.handle(IPC_CHANNELS.START, () => {
// Cancel any pending scheduled start when the user starts manually
if (scheduledStartTimer !== null) {
clearTimeout(scheduledStartTimer);
scheduledStartTimer = null;
controller.updateSettings({ scheduledStartEpochMs: 0 });
}
return controller.start();
});
ipcMain.handle(IPC_CHANNELS.START_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => {
validateStringArray(packageIds ?? [], "packageIds");
return controller.startPackages(packageIds ?? []);