Offer archive-set or whole-package offline cleanup
This commit is contained in:
@@ -4,6 +4,7 @@ import v8 from "node:v8";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { app } from "electron";
|
||||
import {
|
||||
OfflineSkipScope,
|
||||
AddLinksPayload,
|
||||
AccountCheckScope,
|
||||
AllDebridHostInfo,
|
||||
@@ -1338,9 +1339,9 @@ 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 });
|
||||
public removeOfflinePackages(packageIds: string[], scope: OfflineSkipScope = "archive"): number {
|
||||
const removed = this.manager.removeOfflinePackages(packageIds, scope);
|
||||
this.audit("WARN", "Offline-Bereinigung der Queue", { affectedPackages: removed, scope });
|
||||
return removed;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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 path from "node:path";
|
||||
import os from "node:os";
|
||||
@@ -1755,55 +1758,6 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download
|
||||
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 {
|
||||
return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, "");
|
||||
@@ -3289,7 +3243,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
public removeItem(itemId: string): void {
|
||||
public removeItem(itemId: string, preserveFiles = false): void {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) {
|
||||
return;
|
||||
@@ -3301,8 +3255,8 @@ export class DownloadManager extends EventEmitter {
|
||||
const active = this.activeTasks.get(itemId);
|
||||
const hasActiveTask = Boolean(active);
|
||||
if (active) {
|
||||
active.abortReason = "cancel";
|
||||
active.abortController.abort("cancel");
|
||||
active.abortReason = preserveFiles ? "stop" : "cancel";
|
||||
active.abortController.abort(active.abortReason);
|
||||
}
|
||||
const pkg = this.session.packages[item.packageId];
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -802,8 +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.REMOVE_OFFLINE_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[], scope: unknown = "archive") => {
|
||||
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) => {
|
||||
validateString(packageId, "packageId");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron";
|
||||
import {
|
||||
OfflineSkipScope,
|
||||
AddLinksPayload,
|
||||
AccountCheckScope,
|
||||
AccountCommandResult,
|
||||
@@ -83,7 +84,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),
|
||||
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),
|
||||
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
|
||||
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
|
||||
|
||||
+21
-5
@@ -42,6 +42,8 @@ import {
|
||||
import { preservePackageOrderForDisplay, sortPackageOrderByAvailability, sortPackageOrderByName } from "./package-order";
|
||||
import { createPackageOrderState } from "./package-order-state";
|
||||
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 { 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";
|
||||
@@ -288,6 +290,7 @@ interface StartConflictPromptState {
|
||||
}
|
||||
|
||||
interface ConfirmPromptState {
|
||||
offlineScope?: OfflineSkipScope;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
@@ -1918,6 +1921,7 @@ export function App(): ReactElement {
|
||||
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | 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 offlineRemovalScopeRef = useRef<OfflineSkipScope>("archive");
|
||||
const [backupPassphraseMode, setBackupPassphraseMode] = useState<BackupPassphraseMode | null>(null);
|
||||
const [onlineBackupDialog, setOnlineBackupDialog] = useState<OnlineBackupDialogState | null>(null);
|
||||
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null);
|
||||
@@ -4860,19 +4864,21 @@ export function App(): ReactElement {
|
||||
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");
|
||||
offlineRemovalScopeRef.current = "archive";
|
||||
const confirmed = await askConfirmPrompt({
|
||||
offlineScope: "archive",
|
||||
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",
|
||||
? `${count} ${packageLabel} with offline links in the entire queue. This applies regardless of filters or selection.`
|
||||
: `${count} ${packageLabel} mit Offline-Links in der gesamten Queue. Das gilt unabhängig von Filtern und Auswahl.`,
|
||||
confirmLabel: english ? "Remove" : "Entfernen",
|
||||
cancelLabel: english ? "Cancel" : "Abbrechen",
|
||||
danger: true
|
||||
});
|
||||
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");
|
||||
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]);
|
||||
|
||||
@@ -6758,6 +6764,16 @@ export function App(): ReactElement {
|
||||
confirm={confirmPrompt ? (
|
||||
<Dialog actions={null} danger={confirmPrompt.danger} onClose={() => closeConfirmPrompt(false)} open title={confirmPrompt.title}>
|
||||
<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 && (
|
||||
<details className="modal-details">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
UpdateInstallResult
|
||||
} from "./types";
|
||||
import { isRealDebridWebAccountId } from "./real-debrid-accounts";
|
||||
import type { OfflineSkipScope } from "./types";
|
||||
import type {
|
||||
CollectorEnrichmentRequest,
|
||||
CollectorEnrichmentProgress,
|
||||
@@ -103,7 +104,7 @@ export interface ElectronApi {
|
||||
stop: () => Promise<void>;
|
||||
togglePause: () => Promise<boolean>;
|
||||
cancelPackage: (packageId: string) => Promise<void>;
|
||||
removeOfflinePackages: (packageIds: string[]) => Promise<number>;
|
||||
removeOfflinePackages: (packageIds: string[], scope?: OfflineSkipScope) => Promise<number>;
|
||||
renamePackage: (packageId: string, newName: string) => Promise<void>;
|
||||
reorderPackages: (packageIds: string[]) => Promise<void>;
|
||||
removeItem: (itemId: string) => Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user