Add confirmed offline-package removal while preserving downloaded files
This commit is contained in:
@@ -4,6 +4,10 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
- Disable CSS row transitions as well as JavaScript animations during column sorting, so packages immediately jump to their sorted positions.
|
||||
|
||||
@@ -233,6 +233,13 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
||||
|
||||
## 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
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -1338,6 +1338,12 @@ export class AppController {
|
||||
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 {
|
||||
this.audit("WARN", "Paket abgebrochen", { packageId });
|
||||
this.manager.cancelPackage(packageId);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import { getPackagesWithOfflineLinks } from "../shared/offline-packages";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
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];
|
||||
if (!pkg) {
|
||||
return;
|
||||
@@ -6408,8 +6415,8 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
const active = this.activeTasks.get(itemId);
|
||||
if (active) {
|
||||
active.abortReason = "cancel";
|
||||
active.abortController.abort("cancel");
|
||||
active.abortReason = preserveFiles ? "stop" : "cancel";
|
||||
active.abortController.abort(active.abortReason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6419,6 +6426,8 @@ export class DownloadManager extends EventEmitter {
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
|
||||
if (preserveFiles) return;
|
||||
|
||||
this.cleanupQueue = this.cleanupQueue
|
||||
.then(async () => {
|
||||
const removed = await cleanupCancelledPackageArtifactsAsync(outputDir, {
|
||||
|
||||
@@ -802,6 +802,9 @@ function registerIpcHandlers(): void {
|
||||
validateString(packageId, "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) => {
|
||||
validateString(packageId, "packageId");
|
||||
validateString(newName, "newName");
|
||||
|
||||
@@ -83,6 +83,7 @@ const api: ElectronApi = {
|
||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
|
||||
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
|
||||
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),
|
||||
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
|
||||
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "../shared/provider-daily-limits";
|
||||
import { preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order";
|
||||
import { createPackageOrderState } from "./package-order-state";
|
||||
import { getPackagesWithOfflineLinks } from "../shared/offline-packages";
|
||||
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 { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
|
||||
@@ -4850,6 +4851,31 @@ export function App(): ReactElement {
|
||||
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 => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (!settingsDraft.confirmDeleteSelection) {
|
||||
@@ -7136,6 +7162,12 @@ export function App(): ReactElement {
|
||||
else { executeDeleteSelection(ids); }
|
||||
}}>{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>
|
||||
);
|
||||
})() : null}
|
||||
|
||||
@@ -30,6 +30,7 @@ export const IPC_CHANNELS = {
|
||||
STOP: "queue:stop",
|
||||
TOGGLE_PAUSE: "queue:toggle-pause",
|
||||
CANCEL_PACKAGE: "queue:cancel-package",
|
||||
REMOVE_OFFLINE_PACKAGES: "queue:remove-offline-packages",
|
||||
RENAME_PACKAGE: "queue:rename-package",
|
||||
REORDER_PACKAGES: "queue:reorder-packages",
|
||||
REMOVE_ITEM: "queue:remove-item",
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -103,6 +103,7 @@ export interface ElectronApi {
|
||||
stop: () => Promise<void>;
|
||||
togglePause: () => Promise<boolean>;
|
||||
cancelPackage: (packageId: string) => Promise<void>;
|
||||
removeOfflinePackages: (packageIds: string[]) => Promise<number>;
|
||||
renamePackage: (packageId: string, newName: string) => Promise<void>;
|
||||
reorderPackages: (packageIds: string[]) => Promise<void>;
|
||||
removeItem: (itemId: string) => Promise<void>;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
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 { VisualFixture } from "./fixtures";
|
||||
|
||||
@@ -110,6 +111,17 @@ export function createVisualElectronApi(
|
||||
fixture.snapshot.session.paused = !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) => {
|
||||
const entry = fixture.snapshot.session.packages[packageId];
|
||||
if (entry) {
|
||||
|
||||
Reference in New Issue
Block a user