Offer archive-set or whole-package offline cleanup
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -235,6 +235,10 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -9,6 +9,42 @@ import { getPackagesWithOfflineLinks } from "../src/shared/offline-packages";
|
||||
import type { SessionState } from "../src/shared/types";
|
||||
|
||||
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 () => {
|
||||
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")));
|
||||
@@ -30,7 +66,7 @@ describe("remove packages containing offline links", () => {
|
||||
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);
|
||||
expect(manager.removeOfflinePackages([...confirmed, confirmed[0], "missing"], "package")).toBe(2);
|
||||
await (manager as any).cleanupQueue;
|
||||
expect(active.abortController.signal.aborted).toBe(true);
|
||||
expect(active.abortReason).toBe("stop");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ElectronApi } from "../../src/shared/preload-api";
|
||||
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 { VisualFixture } from "./fixtures";
|
||||
|
||||
@@ -14,6 +15,21 @@ export function createVisualElectronApi(
|
||||
search = typeof window === "undefined" ? "" : window.location.search
|
||||
): ElectronApi {
|
||||
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");
|
||||
if (searchParams.get("animations") === "off") fixture.snapshot.settings.animatePackageDisclosure = false;
|
||||
let archivePasswordList = searchParams.get("archive-passwords") === "configured"
|
||||
@@ -111,14 +127,18 @@ export function createVisualElectronApi(
|
||||
fixture.snapshot.session.paused = !fixture.snapshot.session.paused;
|
||||
return fixture.snapshot.session.paused;
|
||||
},
|
||||
removeOfflinePackages: async (packageIds) => {
|
||||
removeOfflinePackages: async (packageIds, scope = "archive") => {
|
||||
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];
|
||||
const pkg = 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();
|
||||
return candidates.length;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user