Add confirmed offline-package removal while preserving downloaded files

This commit is contained in:
Sucukdeluxe
2026-09-04 21:34:06 +02:00
parent d84673da1d
commit 8b061cdbc1
12 changed files with 134 additions and 3 deletions
+4
View File
@@ -4,6 +4,10 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
## [Unreleased] ## [Unreleased]
### Added
- Add “Remove packages with offline links …” to the download context menu. After confirmation, remove whole packages containing at least one offline link across the entire queue, regardless of filters or selection, while keeping downloaded files. Recheck the confirmed package IDs before removal.
### Fixed ### Fixed
- Disable CSS row transitions as well as JavaScript animations during column sorting, so packages immediately jump to their sorted positions. - Disable CSS row transitions as well as JavaScript animations during column sorting, so packages immediately jump to their sorted positions.
+7
View File
@@ -233,6 +233,13 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
## Start-, Build- und Testbefehle ## Start-, Build- und Testbefehle
### Unveröffentlichte Offline-Paketbereinigung vom 4. September 2026
- Das Rechtsklick-/Aktionsmenü der Downloadzeilen bietet unten „Pakete mit Offline-Links entfernen …“. Es erfasst die gesamte Queue unabhängig von Auswahl, Suche und Filtern, sobald mindestens ein Item ausdrücklich `onlineStatus: offline` besitzt. Teilverfügbarkeit genügt; ungeprüfte und nur fehlgeschlagene Links ohne Offline-Befund genügen nicht.
- Eine immer angezeigte Bestätigung nennt die sprachgerecht formatierte Paketanzahl und erklärt, dass vollständige Pakete einschließlich Online-Links entfernt, zugehörige aktive Downloads gestoppt und heruntergeladene Dateien behalten werden. Ohne Treffer ist die Aktion deaktiviert. Texte sind deutsch/englisch verfügbar.
- Der neue typisierte und validierte IPC-Aufruf `removeOfflinePackages` übergibt ausschließlich die zuvor bestätigten IDs. Der Main-Prozess prüft diese erneut: inzwischen wieder verfügbare Pakete bleiben stehen, inzwischen zusätzlich offline gewordene, aber nicht bestätigte Pakete werden nicht nachträglich aufgenommen. Der Paketabbruch erhält einen internen Dateierhaltungsmodus ohne Archiv-/Artefaktbereinigung; aktive Tasks verwenden den Stop-Abbruch, damit der bisherige Cancel-Handler keine Zieldatei löscht. Der bestehende normale Paketabbruch bleibt unverändert.
- Verifiziert: 177 fokussierte Tests einschließlich einer neuen Dateisystem-/Manager-Regression, zusätzlich 21 bestehende Abbruch-/Entfernungsfälle, TypeScript sowie Main- und Renderer-Build erfolgreich. Browserprüfung mit Testdaten bestätigt Menü auf einem anderen Paket, Bestätigungsanzahl, Abbrechen ohne Änderung, gezieltes Entfernen und deaktivierte Aktion ohne Treffer. Dev-App für den neuen Preload-/Main-Aufruf einmal regulär geschlossen und neu gestartet. Kein Release erstellt; nächster Schritt ist Saschas Bedienprüfung.
### Unveröffentlichter Sortierfix vom 4. September 2026 ### Unveröffentlichter Sortierfix vom 4. September 2026
- Der Sortierfix in `v2.0.87` unterdrückte nur die JavaScript-Animation. Die CSS-Transition der virtuellen Zeilen bewegte sie weiterhin 1,5 Sekunden lang. `VirtualizedDownloadsBody` verwendet jetzt denselben Sortierguard auch für `is-download-motion-disabled`, wodurch CSS und JavaScript beim Spaltensortieren gemeinsam ausgesetzt werden. - Der Sortierfix in `v2.0.87` unterdrückte nur die JavaScript-Animation. Die CSS-Transition der virtuellen Zeilen bewegte sie weiterhin 1,5 Sekunden lang. `VirtualizedDownloadsBody` verwendet jetzt denselben Sortierguard auch für `is-download-motion-disabled`, wodurch CSS und JavaScript beim Spaltensortieren gemeinsam ausgesetzt werden.
+6
View File
@@ -1338,6 +1338,12 @@ export class AppController {
this.manager.resetPackage(packageId); this.manager.resetPackage(packageId);
} }
public removeOfflinePackages(packageIds: string[]): number {
const removed = this.manager.removeOfflinePackages(packageIds);
this.audit("WARN", "Pakete mit Offline-Links aus der Queue entfernt", { removed });
return removed;
}
public cancelPackage(packageId: string): void { public cancelPackage(packageId: string): void {
this.audit("WARN", "Paket abgebrochen", { packageId }); this.audit("WARN", "Paket abgebrochen", { packageId });
this.manager.cancelPackage(packageId); this.manager.cancelPackage(packageId);
+12 -3
View File
@@ -1,4 +1,5 @@
import fs from "node:fs"; import fs from "node:fs";
import { getPackagesWithOfflineLinks } from "../shared/offline-packages";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
@@ -6384,7 +6385,13 @@ export class DownloadManager extends EventEmitter {
}); });
} }
public cancelPackage(packageId: string): void { public removeOfflinePackages(packageIds: string[]): number {
const candidates = getPackagesWithOfflineLinks(packageIds, this.session.packages, this.session.items);
for (const packageId of candidates) this.cancelPackage(packageId, true);
return candidates.length;
}
public cancelPackage(packageId: string, preserveFiles = false): void {
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
if (!pkg) { if (!pkg) {
return; return;
@@ -6408,8 +6415,8 @@ export class DownloadManager extends EventEmitter {
} }
const active = this.activeTasks.get(itemId); const active = this.activeTasks.get(itemId);
if (active) { if (active) {
active.abortReason = "cancel"; active.abortReason = preserveFiles ? "stop" : "cancel";
active.abortController.abort("cancel"); active.abortController.abort(active.abortReason);
} }
} }
@@ -6419,6 +6426,8 @@ export class DownloadManager extends EventEmitter {
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
if (preserveFiles) return;
this.cleanupQueue = this.cleanupQueue this.cleanupQueue = this.cleanupQueue
.then(async () => { .then(async () => {
const removed = await cleanupCancelledPackageArtifactsAsync(outputDir, { const removed = await cleanupCancelledPackageArtifactsAsync(outputDir, {
+3
View File
@@ -802,6 +802,9 @@ function registerIpcHandlers(): void {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
return controller.cancelPackage(packageId); return controller.cancelPackage(packageId);
}); });
handleTrusted(IPC_CHANNELS.REMOVE_OFFLINE_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => {
return controller.removeOfflinePackages(validateStringArray(packageIds, "packageIds"));
});
handleTrusted(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => { handleTrusted(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
validateString(newName, "newName"); validateString(newName, "newName");
+1
View File
@@ -83,6 +83,7 @@ const api: ElectronApi = {
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP), stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE), togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId), cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
removeOfflinePackages: (packageIds: string[]): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_OFFLINE_PACKAGES, packageIds),
renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName), renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName),
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds), reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId), removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
+32
View File
@@ -41,6 +41,7 @@ import {
} from "../shared/provider-daily-limits"; } from "../shared/provider-daily-limits";
import { preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order"; import { preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order";
import { createPackageOrderState } from "./package-order-state"; import { createPackageOrderState } from "./package-order-state";
import { getPackagesWithOfflineLinks } from "../shared/offline-packages";
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, resolveSelectAllSelectionScope, shouldClearDownloadSelection } from "./selection"; import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, resolveSelectAllSelectionScope, shouldClearDownloadSelection } from "./selection";
import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, formatAccountOperationError, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountToggleIntentEnabled, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui"; import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, formatAccountOperationError, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountToggleIntentEnabled, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui";
import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit"; import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
@@ -4850,6 +4851,31 @@ export function App(): ReactElement {
setSelectedIds(new Set()); setSelectedIds(new Set());
}, []); }, []);
const requestRemoveOfflinePackages = useCallback((): void => {
setContextMenu(null);
void performQuickAction(async () => {
const { session, settings } = snapshotRef.current;
const candidates = getPackagesWithOfflineLinks(session.packageOrder, session.packages, session.items);
if (candidates.length === 0) return;
const english = normalizeLanguage(settings.language) === "en";
const count = candidates.length.toLocaleString(english ? "en-US" : "de-DE");
const packageLabel = english ? (candidates.length === 1 ? "package" : "packages") : (candidates.length === 1 ? "Paket" : "Pakete");
const confirmed = await askConfirmPrompt({
title: english ? "Remove packages with offline links" : "Pakete mit Offline-Links entfernen",
message: english
? `Remove ${count} ${packageLabel} with at least one offline link from the entire download queue?\n\nEach package is removed in full, including its online links. This applies regardless of filters or selection. Active downloads in these packages will stop. Downloaded files are kept.`
: `${count} ${packageLabel} mit mindestens einem Offline-Link aus der gesamten Downloadliste entfernen?\n\nDie Pakete werden vollständig entfernt, einschließlich ihrer Online-Links. Das gilt unabhängig von Filtern und Auswahl. Laufende Downloads in diesen Paketen werden gestoppt. Bereits heruntergeladene Dateien bleiben erhalten.`,
confirmLabel: english ? "Remove packages" : "Pakete entfernen",
cancelLabel: english ? "Cancel" : "Abbrechen",
danger: true
});
if (!confirmed) return;
const removed = await window.rd.removeOfflinePackages(candidates);
const removedCount = removed.toLocaleString(english ? "en-US" : "de-DE");
showToast(english ? `${removedCount} ${removed === 1 ? "package" : "packages"} removed. Downloaded files were kept.` : `${removedCount} ${removed === 1 ? "Paket" : "Pakete"} entfernt. Heruntergeladene Dateien wurden behalten.`, 3200);
});
}, [askConfirmPrompt, performQuickAction, showToast]);
const requestDeleteSelection = useCallback((): void => { const requestDeleteSelection = useCallback((): void => {
if (selectedIds.size === 0) return; if (selectedIds.size === 0) return;
if (!settingsDraft.confirmDeleteSelection) { if (!settingsDraft.confirmDeleteSelection) {
@@ -7136,6 +7162,12 @@ export function App(): ReactElement {
else { executeDeleteSelection(ids); } else { executeDeleteSelection(ids); }
}}>{multi ? `Ausgewählte entfernen (${selectedPackageIds.length})` : "Paket entfernen"}</button> }}>{multi ? `Ausgewählte entfernen (${selectedPackageIds.length})` : "Paket entfernen"}</button>
)} )}
<div className="ctx-menu-sep" />
<button
className="ctx-menu-item ctx-danger"
disabled={actionBusy || getPackagesWithOfflineLinks(snapshot.session.packageOrder, snapshot.session.packages, snapshot.session.items).length === 0}
onClick={requestRemoveOfflinePackages}
>{normalizeLanguage(snapshot.settings.language) === "en" ? "Remove packages with offline links …" : "Pakete mit Offline-Links entfernen …"}</button>
</ContextMenu> </ContextMenu>
); );
})() : null} })() : null}
+1
View File
@@ -30,6 +30,7 @@ export const IPC_CHANNELS = {
STOP: "queue:stop", STOP: "queue:stop",
TOGGLE_PAUSE: "queue:toggle-pause", TOGGLE_PAUSE: "queue:toggle-pause",
CANCEL_PACKAGE: "queue:cancel-package", CANCEL_PACKAGE: "queue:cancel-package",
REMOVE_OFFLINE_PACKAGES: "queue:remove-offline-packages",
RENAME_PACKAGE: "queue:rename-package", RENAME_PACKAGE: "queue:rename-package",
REORDER_PACKAGES: "queue:reorder-packages", REORDER_PACKAGES: "queue:reorder-packages",
REMOVE_ITEM: "queue:remove-item", REMOVE_ITEM: "queue:remove-item",
+9
View File
@@ -0,0 +1,9 @@
import type { DownloadItem, PackageEntry } from "./types";
export function getPackagesWithOfflineLinks(
packageIds: readonly string[],
packages: Record<string, PackageEntry>,
items: Record<string, DownloadItem>
): string[] {
return [...new Set(packageIds)].filter((id) => packages[id]?.itemIds.some((itemId) => items[itemId]?.onlineStatus === "offline"));
}
+1
View File
@@ -103,6 +103,7 @@ export interface ElectronApi {
stop: () => Promise<void>; stop: () => Promise<void>;
togglePause: () => Promise<boolean>; togglePause: () => Promise<boolean>;
cancelPackage: (packageId: string) => Promise<void>; cancelPackage: (packageId: string) => Promise<void>;
removeOfflinePackages: (packageIds: string[]) => Promise<number>;
renamePackage: (packageId: string, newName: string) => Promise<void>; renamePackage: (packageId: string, newName: string) => Promise<void>;
reorderPackages: (packageIds: string[]) => Promise<void>; reorderPackages: (packageIds: string[]) => Promise<void>;
removeItem: (itemId: string) => Promise<void>; removeItem: (itemId: string) => Promise<void>;
+46
View File
@@ -0,0 +1,46 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { getPackagesWithOfflineLinks } from "../src/shared/offline-packages";
import type { SessionState } from "../src/shared/types";
describe("remove packages containing offline links", () => {
it("removes complete partial and offline packages, preserves files and rechecks the confirmed set", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-offline-removal-"));
const manager = new DownloadManager({ ...defaultSettings(), outputDir: root, autoExtract: false }, emptySession(), createStoragePaths(path.join(root, "state")));
try {
manager.addPackages(["partial", "offline", "online", "unknown", "recovered", "not-confirmed"].map((name) => ({
name, links: [`https://dummy/${name}/one.rar`, `https://dummy/${name}/two.rar`]
})));
const session = (manager as unknown as { session: SessionState }).session;
const packages = Object.fromEntries(Object.values(session.packages).map((entry) => [entry.name, entry]));
for (const name of ["partial", "offline", "recovered", "not-confirmed"]) session.items[packages[name].itemIds[0]].onlineStatus = "offline";
session.items[packages.partial.itemIds[1]].onlineStatus = "online";
session.items[packages.offline.itemIds[1]].onlineStatus = "offline";
for (const id of packages.online.itemIds) session.items[id].onlineStatus = "online";
const confirmed = getPackagesWithOfflineLinks(session.packageOrder, session.packages, session.items).filter((id) => id !== packages["not-confirmed"].id);
expect(confirmed).toHaveLength(3);
session.items[packages.recovered.itemIds[0]].onlineStatus = "online";
const preservedPaths = ["episode.rar", "episode.mkv", "episode.part"].map((name) => path.join(packages.partial.outputDir, name));
fs.mkdirSync(packages.partial.outputDir, { recursive: true });
for (const file of preservedPaths) fs.writeFileSync(file, "keep this content");
const active = { abortController: new AbortController(), abortReason: "none" };
(manager as any).activeTasks.set(packages.partial.itemIds[1], active);
expect(manager.removeOfflinePackages([...confirmed, confirmed[0], "missing"])).toBe(2);
await (manager as any).cleanupQueue;
expect(active.abortController.signal.aborted).toBe(true);
expect(active.abortReason).toBe("stop");
expect(Object.values(session.packages).map((entry) => entry.name)).toEqual(["online", "unknown", "recovered", "not-confirmed"]);
for (const id of [...packages.partial.itemIds, ...packages.offline.itemIds]) expect(session.items[id]).toBeUndefined();
for (const file of preservedPaths) expect(fs.readFileSync(file, "utf8")).toBe("keep this content");
expect(manager.removeOfflinePackages(confirmed)).toBe(0);
} finally {
manager.clearPersistTimer();
fs.rmSync(root, { recursive: true, force: true });
}
});
});
+12
View File
@@ -1,4 +1,5 @@
import type { ElectronApi } from "../../src/shared/preload-api"; import type { ElectronApi } from "../../src/shared/preload-api";
import { getPackagesWithOfflineLinks } from "../../src/shared/offline-packages";
import type { HistoryEntry, RendererSettings, RendererSettingsUpdate } from "../../src/shared/types"; import type { HistoryEntry, RendererSettings, RendererSettingsUpdate } from "../../src/shared/types";
import type { VisualFixture } from "./fixtures"; import type { VisualFixture } from "./fixtures";
@@ -110,6 +111,17 @@ export function createVisualElectronApi(
fixture.snapshot.session.paused = !fixture.snapshot.session.paused; fixture.snapshot.session.paused = !fixture.snapshot.session.paused;
return fixture.snapshot.session.paused; return fixture.snapshot.session.paused;
}, },
removeOfflinePackages: async (packageIds) => {
const { session } = fixture.snapshot;
const candidates = getPackagesWithOfflineLinks(packageIds, session.packages, session.items);
for (const id of candidates) {
for (const itemId of session.packages[id].itemIds) delete session.items[itemId];
delete session.packages[id];
}
session.packageOrder = session.packageOrder.filter((id) => !candidates.includes(id));
emitStateUpdate();
return candidates.length;
},
cancelPackage: async (packageId) => { cancelPackage: async (packageId) => {
const entry = fixture.snapshot.session.packages[packageId]; const entry = fixture.snapshot.session.packages[packageId];
if (entry) { if (entry) {