Offer archive-set or whole-package offline cleanup

This commit is contained in:
Sucukdeluxe
2026-09-04 21:42:55 +02:00
parent 8b061cdbc1
commit 2a721dc955
12 changed files with 207 additions and 72 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
### Added ### 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. - Add “Remove packages with offline links …” to the download context menu. The confirmation defaults to removing only affected archive sets, with an option to remove entire parent packages. One offline part removes all parts of that archive while other episodes remain in archive-set mode. Both modes cover the entire queue regardless of filters or selection, keep downloaded files, and recheck the confirmed package IDs before removal.
### Fixed ### Fixed
+4
View File
@@ -235,6 +235,10 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
### Unveröffentlichte Offline-Paketbereinigung vom 4. September 2026 ### Unveröffentlichte Offline-Paketbereinigung vom 4. September 2026
- Erweiterung nach Saschas Rückmeldung: Die Bestätigung enthält jetzt zwei Radiooptionen. Standard bei jedem Öffnen ist „Nur betroffene Archivsätze“; alternativ „Ganze Pakete“. Im Archivsatz-Modus entfernt ein Offline-Part alle zusammengehörigen Parts derselben Folge, einschließlich bereits abgeschlossener Queue-Einträge; andere Folgen bleiben im Oberpaket. Leere Oberpakete verschwinden automatisch. Dateien bleiben in beiden Modi erhalten. Der Scope wird separat vom automatischen Offline-Überspringen übergeben und verändert dessen Einstellung nicht.
- Die vorhandene Offline-Archivzuordnung wurde in `src/shared/offline-archive-items.ts` gemeinsam nutzbar gemacht; der bisherige Main-Export bleibt kompatibel. Die Zuordnung unterstützt `partN.rar`, `.rar`/`.rNN`, `.zip.NNN`, `.7z.NNN` und generische `.NNN`-Teile. Einzeldateien ohne passende Geschwister werden einzeln entfernt. Main und visuelle Test-API verwenden denselben Matcher.
- Erweiterung verifiziert: 175 fokussierte Tests und 30 bestehende Offline-/Archivzuordnungs-/Abbruch-/Entfernungsfälle erfolgreich, TypeScript sowie Main-/Renderer-Build erfolgreich. Fünf neue Manager-/Dateisystemfälle bestätigen die Archivformate, den Erhalt anderer Folgen und unveränderte Dateien. Browserprüfung unter `?motion=on&offline-cleanup=multipart` bestätigt vorausgewählten Archivsatz-Modus, Folge 3 entfernt/Folge 4 erhalten und alternativ vollständige Paketentfernung. Dialoglayout im Dark-Theme geprüft. Dev-App für den aktualisierten Main-/Preload-Pfad regulär neu gestartet; weiterhin unveröffentlicht.
- 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. - 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. - 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. - 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.
+4 -3
View File
@@ -4,6 +4,7 @@ import v8 from "node:v8";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { app } from "electron"; import { app } from "electron";
import { import {
OfflineSkipScope,
AddLinksPayload, AddLinksPayload,
AccountCheckScope, AccountCheckScope,
AllDebridHostInfo, AllDebridHostInfo,
@@ -1338,9 +1339,9 @@ export class AppController {
this.manager.resetPackage(packageId); this.manager.resetPackage(packageId);
} }
public removeOfflinePackages(packageIds: string[]): number { public removeOfflinePackages(packageIds: string[], scope: OfflineSkipScope = "archive"): number {
const removed = this.manager.removeOfflinePackages(packageIds); const removed = this.manager.removeOfflinePackages(packageIds, scope);
this.audit("WARN", "Pakete mit Offline-Links aus der Queue entfernt", { removed }); this.audit("WARN", "Offline-Bereinigung der Queue", { affectedPackages: removed, scope });
return removed; return removed;
} }
+26 -54
View File
@@ -1,4 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import type { OfflineSkipScope } from "../shared/types";
import { resolveOfflineArchiveItemsFromList } from "../shared/offline-archive-items";
export { resolveOfflineArchiveItemsFromList } from "../shared/offline-archive-items";
import { getPackagesWithOfflineLinks } from "../shared/offline-packages"; 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";
@@ -1755,55 +1758,6 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download
return []; return [];
} }
export function resolveOfflineArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] {
const normalizeArchiveMatchName = (value: string): string =>
stripDuplicateSuffixBeforeExtension(path.basename(String(value || "")));
const entryLower = normalizeArchiveMatchName(archiveName).toLowerCase();
const itemBaseName = (item: DownloadItem): string =>
normalizeArchiveMatchName(item.targetPath || item.fileName || "");
let pattern: RegExp | null = null;
const multipartMatch = entryLower.match(/^(.*)\.part0*\d+\.rar$/);
if (multipartMatch) {
const prefix = multipartMatch[1].replace(REGEX_ESCAPE_RE, "\\$&");
pattern = new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i");
}
if (!pattern) {
const rarMatch = entryLower.match(/^(.*)\.r(?:ar|\d{2,3})$/);
if (rarMatch) {
const stem = rarMatch[1].replace(REGEX_ESCAPE_RE, "\\$&");
pattern = new RegExp(`^${stem}\\.r(ar|\\d{2,3})$`, "i");
}
}
if (!pattern) {
const zipSplitMatch = entryLower.match(/^(.*)\.zip\.\d+$/);
if (zipSplitMatch) {
const stem = zipSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&");
pattern = new RegExp(`^${stem}\\.zip(\\.\\d+)?$`, "i");
}
}
if (!pattern) {
const sevenSplitMatch = entryLower.match(/^(.*)\.7z\.\d+$/);
if (sevenSplitMatch) {
const stem = sevenSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&");
pattern = new RegExp(`^${stem}\\.7z(\\.\\d+)?$`, "i");
}
}
if (!pattern && /^(.*)\.\d{3}$/.test(entryLower) && !/\.(zip|7z)\.\d{3}$/.test(entryLower)) {
const genericSplitMatch = entryLower.match(/^(.*)\.\d{3}$/);
if (genericSplitMatch) {
const stem = genericSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&");
pattern = new RegExp(`^${stem}\\.\\d{3}$`, "i");
}
}
if (pattern) {
const matched = items.filter((item) => pattern!.test(itemBaseName(item)));
if (matched.length > 0) return matched;
}
return items.filter((item) => itemBaseName(item).toLowerCase() === entryLower);
}
function stripDuplicateSuffixBeforeExtension(fileName: string): string { function stripDuplicateSuffixBeforeExtension(fileName: string): string {
return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, "");
@@ -3289,7 +3243,7 @@ export class DownloadManager extends EventEmitter {
this.emitState(true); this.emitState(true);
} }
public removeItem(itemId: string): void { public removeItem(itemId: string, preserveFiles = false): void {
const item = this.session.items[itemId]; const item = this.session.items[itemId];
if (!item) { if (!item) {
return; return;
@@ -3301,8 +3255,8 @@ export class DownloadManager extends EventEmitter {
const active = this.activeTasks.get(itemId); const active = this.activeTasks.get(itemId);
const hasActiveTask = Boolean(active); const hasActiveTask = Boolean(active);
if (active) { if (active) {
active.abortReason = "cancel"; active.abortReason = preserveFiles ? "stop" : "cancel";
active.abortController.abort("cancel"); active.abortController.abort(active.abortReason);
} }
const pkg = this.session.packages[item.packageId]; const pkg = this.session.packages[item.packageId];
let removedByPackageCleanup = false; let removedByPackageCleanup = false;
@@ -6385,9 +6339,27 @@ export class DownloadManager extends EventEmitter {
}); });
} }
public removeOfflinePackages(packageIds: string[]): number { public removeOfflinePackages(packageIds: string[], scope: OfflineSkipScope = "archive"): number {
const candidates = getPackagesWithOfflineLinks(packageIds, this.session.packages, this.session.items); const candidates = getPackagesWithOfflineLinks(packageIds, this.session.packages, this.session.items);
for (const packageId of candidates) this.cancelPackage(packageId, true); for (const packageId of candidates) {
if (scope === "package") {
this.cancelPackage(packageId, true);
continue;
}
const items = this.session.packages[packageId].itemIds.map((id) => this.session.items[id]).filter(Boolean);
const itemIds = new Set<string>();
for (const item of items) {
if (item.onlineStatus !== "offline") continue;
itemIds.add(item.id);
for (const related of resolveOfflineArchiveItemsFromList(item.fileName, items)) itemIds.add(related.id);
}
if (itemIds.size === items.length) {
this.cancelPackage(packageId, true);
} else {
this.abortPackagePostProcessing(packageId, "offline_cleanup");
for (const itemId of itemIds) this.removeItem(itemId, true);
}
}
return candidates.length; return candidates.length;
} }
+3 -2
View File
@@ -802,8 +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[]) => { handleTrusted(IPC_CHANNELS.REMOVE_OFFLINE_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[], scope: unknown = "archive") => {
return controller.removeOfflinePackages(validateStringArray(packageIds, "packageIds")); if (scope !== "archive" && scope !== "package") throw new Error("Ungültiger Bereich für Offline-Bereinigung");
return controller.removeOfflinePackages(validateStringArray(packageIds, "packageIds"), scope);
}); });
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");
+2 -1
View File
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"; import { contextBridge, ipcRenderer, webUtils } from "electron";
import { import {
OfflineSkipScope,
AddLinksPayload, AddLinksPayload,
AccountCheckScope, AccountCheckScope,
AccountCommandResult, AccountCommandResult,
@@ -83,7 +84,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), removeOfflinePackages: (packageIds: string[], scope: OfflineSkipScope = "archive"): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_OFFLINE_PACKAGES, packageIds, scope),
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),
+21 -5
View File
@@ -42,6 +42,8 @@ import {
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 { getPackagesWithOfflineLinks } from "../shared/offline-packages";
import type { OfflineSkipScope } from "../shared/types";
import { OfflineRemovalScopeChoice } from "./views/downloads/OfflineRemovalScopeChoice";
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";
@@ -288,6 +290,7 @@ interface StartConflictPromptState {
} }
interface ConfirmPromptState { interface ConfirmPromptState {
offlineScope?: OfflineSkipScope;
title: string; title: string;
message: string; message: string;
confirmLabel: string; confirmLabel: string;
@@ -1918,6 +1921,7 @@ export function App(): ReactElement {
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null); const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null); const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null); const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
const offlineRemovalScopeRef = useRef<OfflineSkipScope>("archive");
const [backupPassphraseMode, setBackupPassphraseMode] = useState<BackupPassphraseMode | null>(null); const [backupPassphraseMode, setBackupPassphraseMode] = useState<BackupPassphraseMode | null>(null);
const [onlineBackupDialog, setOnlineBackupDialog] = useState<OnlineBackupDialogState | null>(null); const [onlineBackupDialog, setOnlineBackupDialog] = useState<OnlineBackupDialogState | null>(null);
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null); const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null);
@@ -4860,19 +4864,21 @@ export function App(): ReactElement {
const english = normalizeLanguage(settings.language) === "en"; const english = normalizeLanguage(settings.language) === "en";
const count = candidates.length.toLocaleString(english ? "en-US" : "de-DE"); const count = candidates.length.toLocaleString(english ? "en-US" : "de-DE");
const packageLabel = english ? (candidates.length === 1 ? "package" : "packages") : (candidates.length === 1 ? "Paket" : "Pakete"); const packageLabel = english ? (candidates.length === 1 ? "package" : "packages") : (candidates.length === 1 ? "Paket" : "Pakete");
offlineRemovalScopeRef.current = "archive";
const confirmed = await askConfirmPrompt({ const confirmed = await askConfirmPrompt({
offlineScope: "archive",
title: english ? "Remove packages with offline links" : "Pakete mit Offline-Links entfernen", title: english ? "Remove packages with offline links" : "Pakete mit Offline-Links entfernen",
message: english 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} with offline links in the entire queue. This applies regardless of filters or selection.`
: `${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.`, : `${count} ${packageLabel} mit Offline-Links in der gesamten Queue. Das gilt unabhängig von Filtern und Auswahl.`,
confirmLabel: english ? "Remove packages" : "Pakete entfernen", confirmLabel: english ? "Remove" : "Entfernen",
cancelLabel: english ? "Cancel" : "Abbrechen", cancelLabel: english ? "Cancel" : "Abbrechen",
danger: true danger: true
}); });
if (!confirmed) return; if (!confirmed) return;
const removed = await window.rd.removeOfflinePackages(candidates); const removed = await window.rd.removeOfflinePackages(candidates, offlineRemovalScopeRef.current);
const removedCount = removed.toLocaleString(english ? "en-US" : "de-DE"); 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); showToast(english ? `Cleanup completed in ${removedCount} ${removed === 1 ? "package" : "packages"}. Downloaded files were kept.` : `Bereinigung in ${removedCount} ${removed === 1 ? "Paket" : "Paketen"} abgeschlossen. Heruntergeladene Dateien wurden behalten.`, 3200);
}); });
}, [askConfirmPrompt, performQuickAction, showToast]); }, [askConfirmPrompt, performQuickAction, showToast]);
@@ -6758,6 +6764,16 @@ export function App(): ReactElement {
confirm={confirmPrompt ? ( confirm={confirmPrompt ? (
<Dialog actions={null} danger={confirmPrompt.danger} onClose={() => closeConfirmPrompt(false)} open title={confirmPrompt.title}> <Dialog actions={null} danger={confirmPrompt.danger} onClose={() => closeConfirmPrompt(false)} open title={confirmPrompt.title}>
<p style={{ whiteSpace: "pre-line" }}>{confirmPrompt.message}</p> <p style={{ whiteSpace: "pre-line" }}>{confirmPrompt.message}</p>
{confirmPrompt.offlineScope && (
<OfflineRemovalScopeChoice
scope={confirmPrompt.offlineScope}
english={normalizeLanguage(settingsDraft.language) === "en"}
onChange={(scope) => {
offlineRemovalScopeRef.current = scope;
setConfirmPrompt((current) => current ? { ...current, offlineScope: scope } : current);
}}
/>
)}
{confirmPrompt.details && ( {confirmPrompt.details && (
<details className="modal-details"> <details className="modal-details">
<summary>{confirmPrompt.detailsLabel || "Details anzeigen"}</summary> <summary>{confirmPrompt.detailsLabel || "Details anzeigen"}</summary>
@@ -0,0 +1,26 @@
import type { OfflineSkipScope } from "../../../shared/types";
export function OfflineRemovalScopeChoice({ scope, english, onChange }: {
scope: OfflineSkipScope;
english: boolean;
onChange: (scope: OfflineSkipScope) => void;
}) {
return (
<fieldset style={{ border: 0, padding: 0, margin: "16px 0", display: "grid", gap: 14 }}>
<legend style={{ marginBottom: 12 }}>{english ? "What should be removed?" : "Was soll entfernt werden?"}</legend>
<label style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
<input type="radio" name="offline-removal-scope" value="archive" checked={scope === "archive"} onChange={() => onChange("archive")} />
<span><strong>{english ? "Only affected archive sets" : "Nur betroffene Archivsätze"}</strong><br />
{english ? "Remove all parts of the affected episode or archive. Other episodes in the package remain." : "Alle Parts der betroffenen Folge oder des Archivs entfernen. Andere Folgen im Paket bleiben erhalten."}
</span>
</label>
<label style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
<input type="radio" name="offline-removal-scope" value="package" checked={scope === "package"} onChange={() => onChange("package")} />
<span><strong>{english ? "Entire packages" : "Ganze Pakete"}</strong><br />
{english ? "Remove the entire parent package as soon as one link is offline." : "Das gesamte übergeordnete Paket entfernen, sobald ein Link offline ist."}
</span>
</label>
<p style={{ margin: 0 }}>{english ? "Active downloads being removed will stop. Downloaded files are kept in both cases." : "Die zu entfernenden laufenden Downloads werden gestoppt. Heruntergeladene Dateien bleiben in beiden Fällen erhalten."}</p>
</fieldset>
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { DownloadItem } from "./types";
const REGEX_ESCAPE_RE = /[.*+?^$(){}|[\]\\]/g;
function stripDuplicateSuffixBeforeExtension(fileName: string): string {
return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, "");
}
export function resolveOfflineArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] {
const normalizeArchiveMatchName = (value: string): string =>
stripDuplicateSuffixBeforeExtension((String(value || "").replace(/\\/g, "/").split("/").pop() || ""));
const entryLower = normalizeArchiveMatchName(archiveName).toLowerCase();
const itemBaseName = (item: DownloadItem): string =>
normalizeArchiveMatchName(item.targetPath || item.fileName || "");
let pattern: RegExp | null = null;
const multipartMatch = entryLower.match(/^(.*)\.part0*\d+\.rar$/);
if (multipartMatch) {
const prefix = multipartMatch[1].replace(REGEX_ESCAPE_RE, "\\$&");
pattern = new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i");
}
if (!pattern) {
const rarMatch = entryLower.match(/^(.*)\.r(?:ar|\d{2,3})$/);
if (rarMatch) {
const stem = rarMatch[1].replace(REGEX_ESCAPE_RE, "\\$&");
pattern = new RegExp(`^${stem}\\.r(ar|\\d{2,3})$`, "i");
}
}
if (!pattern) {
const zipSplitMatch = entryLower.match(/^(.*)\.zip\.\d+$/);
if (zipSplitMatch) {
const stem = zipSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&");
pattern = new RegExp(`^${stem}\\.zip(\\.\\d+)?$`, "i");
}
}
if (!pattern) {
const sevenSplitMatch = entryLower.match(/^(.*)\.7z\.\d+$/);
if (sevenSplitMatch) {
const stem = sevenSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&");
pattern = new RegExp(`^${stem}\\.7z(\\.\\d+)?$`, "i");
}
}
if (!pattern && /^(.*)\.\d{3}$/.test(entryLower) && !/\.(zip|7z)\.\d{3}$/.test(entryLower)) {
const genericSplitMatch = entryLower.match(/^(.*)\.\d{3}$/);
if (genericSplitMatch) {
const stem = genericSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&");
pattern = new RegExp(`^${stem}\\.\\d{3}$`, "i");
}
}
if (pattern) {
const matched = items.filter((item) => pattern!.test(itemBaseName(item)));
if (matched.length > 0) return matched;
}
return items.filter((item) => itemBaseName(item).toLowerCase() === entryLower);
}
+2 -1
View File
@@ -34,6 +34,7 @@ import type {
UpdateInstallResult UpdateInstallResult
} from "./types"; } from "./types";
import { isRealDebridWebAccountId } from "./real-debrid-accounts"; import { isRealDebridWebAccountId } from "./real-debrid-accounts";
import type { OfflineSkipScope } from "./types";
import type { import type {
CollectorEnrichmentRequest, CollectorEnrichmentRequest,
CollectorEnrichmentProgress, CollectorEnrichmentProgress,
@@ -103,7 +104,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>; removeOfflinePackages: (packageIds: string[], scope?: OfflineSkipScope) => 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>;
+37 -1
View File
@@ -9,6 +9,42 @@ import { getPackagesWithOfflineLinks } from "../src/shared/offline-packages";
import type { SessionState } from "../src/shared/types"; import type { SessionState } from "../src/shared/types";
describe("remove packages containing offline links", () => { describe("remove packages containing offline links", () => {
it.each([
["episode03.part1.rar", "episode03.part2.rar"],
["episode03.rar", "episode03.r00"],
["episode03.zip.001", "episode03.zip.002"],
["episode03.7z.001", "episode03.7z.002"],
["episode03.001", "episode03.002"]
])("removes the archive set containing offline %s and keeps other episodes", async (first, second) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-offline-archive-"));
const manager = new DownloadManager({ ...defaultSettings(), outputDir: root, autoExtract: false }, emptySession(), createStoragePaths(path.join(root, "state")));
try {
const names = [first, second, "episode04.part1.rar", "episode04.part2.rar", "notes.txt"];
manager.addPackages([{ name: "Season", links: names.map((name) => `https://dummy/${name}`), fileNames: names }]);
const session = (manager as unknown as { session: SessionState }).session;
const pkg = session.packages[session.packageOrder[0]];
const ids = [...pkg.itemIds];
fs.mkdirSync(pkg.outputDir, { recursive: true });
for (const [index, id] of ids.entries()) {
session.items[id].fileName = names[index];
session.items[id].targetPath = path.join(pkg.outputDir, names[index]);
session.items[id].onlineStatus = index === 1 ? "offline" : "online";
fs.writeFileSync(session.items[id].targetPath, names[index]);
}
session.items[ids[0]].status = "completed";
expect(manager.removeOfflinePackages([pkg.id])).toBe(1);
await (manager as any).cleanupQueue;
expect(pkg.itemIds).toEqual(ids.slice(2));
expect(session.items[ids[0]]).toBeUndefined();
expect(session.items[ids[1]]).toBeUndefined();
expect(session.packageOrder).toEqual([pkg.id]);
for (const name of names) expect(fs.readFileSync(path.join(pkg.outputDir, name), "utf8")).toBe(name);
} finally {
manager.clearPersistTimer();
fs.rmSync(root, { recursive: true, force: true });
}
});
it("removes complete partial and offline packages, preserves files and rechecks the confirmed set", async () => { 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 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"))); const manager = new DownloadManager({ ...defaultSettings(), outputDir: root, autoExtract: false }, emptySession(), createStoragePaths(path.join(root, "state")));
@@ -30,7 +66,7 @@ describe("remove packages containing offline links", () => {
for (const file of preservedPaths) fs.writeFileSync(file, "keep this content"); for (const file of preservedPaths) fs.writeFileSync(file, "keep this content");
const active = { abortController: new AbortController(), abortReason: "none" }; const active = { abortController: new AbortController(), abortReason: "none" };
(manager as any).activeTasks.set(packages.partial.itemIds[1], active); (manager as any).activeTasks.set(packages.partial.itemIds[1], active);
expect(manager.removeOfflinePackages([...confirmed, confirmed[0], "missing"])).toBe(2); expect(manager.removeOfflinePackages([...confirmed, confirmed[0], "missing"], "package")).toBe(2);
await (manager as any).cleanupQueue; await (manager as any).cleanupQueue;
expect(active.abortController.signal.aborted).toBe(true); expect(active.abortController.signal.aborted).toBe(true);
expect(active.abortReason).toBe("stop"); expect(active.abortReason).toBe("stop");
+24 -4
View File
@@ -1,5 +1,6 @@
import type { ElectronApi } from "../../src/shared/preload-api"; import type { ElectronApi } from "../../src/shared/preload-api";
import { getPackagesWithOfflineLinks } from "../../src/shared/offline-packages"; import { getPackagesWithOfflineLinks } from "../../src/shared/offline-packages";
import { resolveOfflineArchiveItemsFromList } from "../../src/shared/offline-archive-items";
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";
@@ -14,6 +15,21 @@ export function createVisualElectronApi(
search = typeof window === "undefined" ? "" : window.location.search search = typeof window === "undefined" ? "" : window.location.search
): ElectronApi { ): ElectronApi {
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
if (searchParams.get("offline-cleanup") === "multipart") {
const { session } = fixture.snapshot;
const pkg = session.packages[session.packageOrder[0]];
const originals = pkg.itemIds.map((id) => session.items[id]);
originals.forEach((item, index) => {
item.fileName = `episode03.part${index + 1}.rar`;
item.targetPath = "";
item.onlineStatus = index === 1 ? "offline" : "online";
});
for (let part = 1; part <= 2; part++) {
const id = `${pkg.id}-episode04-${part}`;
session.items[id] = { ...originals[0], id, fileName: `episode04.part${part}.rar`, targetPath: "", onlineStatus: "online" };
pkg.itemIds.push(id);
}
}
const historyState = searchParams.get("history-state"); const historyState = searchParams.get("history-state");
if (searchParams.get("animations") === "off") fixture.snapshot.settings.animatePackageDisclosure = false; if (searchParams.get("animations") === "off") fixture.snapshot.settings.animatePackageDisclosure = false;
let archivePasswordList = searchParams.get("archive-passwords") === "configured" let archivePasswordList = searchParams.get("archive-passwords") === "configured"
@@ -111,14 +127,18 @@ 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) => { removeOfflinePackages: async (packageIds, scope = "archive") => {
const { session } = fixture.snapshot; const { session } = fixture.snapshot;
const candidates = getPackagesWithOfflineLinks(packageIds, session.packages, session.items); const candidates = getPackagesWithOfflineLinks(packageIds, session.packages, session.items);
for (const id of candidates) { for (const id of candidates) {
for (const itemId of session.packages[id].itemIds) delete session.items[itemId]; const pkg = session.packages[id];
delete session.packages[id]; const items = pkg.itemIds.map((itemId) => session.items[itemId]).filter(Boolean);
const targets = scope === "package" ? items : items.filter((item) => item.onlineStatus === "offline").flatMap((item) => [item, ...resolveOfflineArchiveItemsFromList(item.fileName, items)]);
for (const item of targets) delete session.items[item.id];
pkg.itemIds = pkg.itemIds.filter((itemId) => session.items[itemId]);
if (pkg.itemIds.length === 0) delete session.packages[id];
} }
session.packageOrder = session.packageOrder.filter((id) => !candidates.includes(id)); session.packageOrder = session.packageOrder.filter((id) => session.packages[id]);
emitStateUpdate(); emitStateUpdate();
return candidates.length; return candidates.length;
}, },