Add start conflict prompts for existing extracted packages in v1.4.2

This commit is contained in:
Sucukdeluxe 2026-02-27 17:54:56 +01:00
parent 1c92591bf1
commit 01ed725136
11 changed files with 572 additions and 24 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.1", "version": "1.4.2",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -1,6 +1,16 @@
import path from "node:path"; import path from "node:path";
import { app } from "electron"; import { app } from "electron";
import { AddLinksPayload, AppSettings, ParsedPackageInput, UiSnapshot, UpdateCheckResult, UpdateInstallResult } from "../shared/types"; import {
AddLinksPayload,
AppSettings,
DuplicatePolicy,
ParsedPackageInput,
StartConflictEntry,
StartConflictResolutionResult,
UiSnapshot,
UpdateCheckResult,
UpdateInstallResult
} from "../shared/types";
import { importDlcContainers } from "./container"; import { importDlcContainers } from "./container";
import { APP_VERSION, defaultSettings } from "./constants"; import { APP_VERSION, defaultSettings } from "./constants";
import { DownloadManager } from "./download-manager"; import { DownloadManager } from "./download-manager";
@ -39,9 +49,12 @@ export class AppController {
if (this.settings.autoResumeOnStart) { if (this.settings.autoResumeOnStart) {
const snapshot = this.manager.getSnapshot(); const snapshot = this.manager.getSnapshot();
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait"); const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
if (hasPending && this.hasAnyProviderToken(this.settings)) { const hasConflicts = this.manager.getStartConflicts().length > 0;
if (hasPending && this.hasAnyProviderToken(this.settings) && !hasConflicts) {
this.manager.start(); this.manager.start();
logger.info("Auto-Resume beim Start aktiviert"); logger.info("Auto-Resume beim Start aktiviert");
} else if (hasPending && hasConflicts) {
logger.info("Auto-Resume übersprungen: Start-Konflikte erkannt");
} }
} }
} }
@ -113,6 +126,14 @@ export class AppController {
return result; return result;
} }
public getStartConflicts(): StartConflictEntry[] {
return this.manager.getStartConflicts();
}
public resolveStartConflict(packageId: string, policy: DuplicatePolicy): StartConflictResolutionResult {
return this.manager.resolveStartConflict(packageId, policy);
}
public clearAll(): void { public clearAll(): void {
this.manager.clearAll(); this.manager.clearAll();
} }

View File

@ -3,7 +3,20 @@ import path from "node:path";
import os from "node:os"; import os from "node:os";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { AppSettings, DownloadItem, DownloadStats, DownloadSummary, DownloadStatus, PackageEntry, ParsedPackageInput, SessionState, UiSnapshot } from "../shared/types"; import {
AppSettings,
DownloadItem,
DownloadStats,
DownloadSummary,
DownloadStatus,
DuplicatePolicy,
PackageEntry,
ParsedPackageInput,
SessionState,
StartConflictEntry,
StartConflictResolutionResult,
UiSnapshot
} from "../shared/types";
import { REQUEST_RETRIES } from "./constants"; import { REQUEST_RETRIES } from "./constants";
import { cleanupCancelledPackageArtifactsAsync } from "./cleanup"; import { cleanupCancelledPackageArtifactsAsync } from "./cleanup";
import { DebridService, MegaWebUnrestrictor } from "./debrid"; import { DebridService, MegaWebUnrestrictor } from "./debrid";
@ -514,6 +527,105 @@ export class DownloadManager extends EventEmitter {
return { addedPackages, addedLinks }; return { addedPackages, addedLinks };
} }
public getStartConflicts(): StartConflictEntry[] {
const conflicts: StartConflictEntry[] = [];
for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) {
continue;
}
const hasPendingItems = pkg.itemIds.some((itemId) => {
const item = this.session.items[itemId];
if (!item) {
return false;
}
return item.status === "queued" || item.status === "reconnect_wait";
});
if (!hasPendingItems) {
continue;
}
if (this.directoryHasAnyFiles(pkg.extractDir)) {
conflicts.push({
packageId: pkg.id,
packageName: pkg.name,
extractDir: pkg.extractDir
});
}
}
return conflicts;
}
public resolveStartConflict(packageId: string, policy: DuplicatePolicy): StartConflictResolutionResult {
const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled) {
return { skipped: false, overwritten: false };
}
if (policy === "skip") {
for (const itemId of pkg.itemIds) {
const active = this.activeTasks.get(itemId);
if (active) {
active.abortReason = "cancel";
active.abortController.abort("cancel");
}
this.releaseTargetPath(itemId);
delete this.session.items[itemId];
}
delete this.session.packages[packageId];
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
this.persistSoon();
this.emitState(true);
return { skipped: true, overwritten: false };
}
if (policy === "overwrite") {
try {
fs.rmSync(pkg.extractDir, { recursive: true, force: true });
} catch {
// ignore
}
try {
fs.rmSync(pkg.outputDir, { recursive: true, force: true });
} catch {
// ignore
}
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item) {
continue;
}
const active = this.activeTasks.get(itemId);
if (active) {
active.abortReason = "cancel";
active.abortController.abort("cancel");
}
this.releaseTargetPath(itemId);
item.status = "queued";
item.retries = 0;
item.speedBps = 0;
item.downloadedBytes = 0;
item.totalBytes = null;
item.progressPercent = 0;
item.resumable = true;
item.attempts = 0;
item.lastError = "";
item.fullStatus = "Wartet";
item.updatedAt = nowMs();
item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url)));
}
pkg.status = "queued";
pkg.updatedAt = nowMs();
this.persistSoon();
this.emitState(true);
return { skipped: false, overwritten: true };
}
return { skipped: false, overwritten: false };
}
private async resolveQueuedFilenames(unresolvedByLink: Map<string, string[]>): Promise<void> { private async resolveQueuedFilenames(unresolvedByLink: Map<string, string[]>): Promise<void> {
try { try {
let changed = false; let changed = false;

View File

@ -154,6 +154,9 @@ function registerIpcHandlers(): void {
}); });
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => controller.addLinks(payload)); ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => controller.addLinks(payload));
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => controller.addContainers(filePaths ?? [])); ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => controller.addContainers(filePaths ?? []));
ipcMain.handle(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts());
ipcMain.handle(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") =>
controller.resolveStartConflict(packageId, policy));
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll()); ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
ipcMain.handle(IPC_CHANNELS.START, () => controller.start()); ipcMain.handle(IPC_CHANNELS.START, () => controller.start());
ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop()); ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop());

View File

@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import { AddLinksPayload, AppSettings, UiSnapshot, UpdateCheckResult } from "../shared/types"; import { AddLinksPayload, AppSettings, DuplicatePolicy, StartConflictEntry, StartConflictResolutionResult, UiSnapshot, UpdateCheckResult } from "../shared/types";
import { IPC_CHANNELS } from "../shared/ipc"; import { IPC_CHANNELS } from "../shared/ipc";
import { ElectronApi } from "../shared/preload-api"; import { ElectronApi } from "../shared/preload-api";
@ -14,6 +14,9 @@ const api: ElectronApi = {
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload), ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths), ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths),
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy),
clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL), clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START), start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP), stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),

View File

@ -1,5 +1,18 @@
import { DragEvent, KeyboardEvent, ReactElement, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { DragEvent, KeyboardEvent, ReactElement, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import type { AppSettings, AppTheme, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStats, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types"; import type {
AppSettings,
AppTheme,
BandwidthScheduleEntry,
DebridFallbackProvider,
DebridProvider,
DownloadItem,
DownloadStats,
DuplicatePolicy,
PackageEntry,
StartConflictEntry,
UiSnapshot,
UpdateCheckResult
} from "../shared/types";
type Tab = "collector" | "downloads" | "settings"; type Tab = "collector" | "downloads" | "settings";
@ -9,6 +22,11 @@ interface CollectorTab {
text: string; text: string;
} }
interface StartConflictPromptState {
entry: StartConflictEntry;
applyToAll: boolean;
}
const emptyStats = (): DownloadStats => ({ const emptyStats = (): DownloadStats => ({
totalDownloaded: 0, totalDownloaded: 0,
totalFiles: 0, totalFiles: 0,
@ -86,6 +104,9 @@ export function App(): ReactElement {
const actionBusyRef = useRef(false); const actionBusyRef = useRef(false);
const dragOverRef = useRef(false); const dragOverRef = useRef(false);
const dragDepthRef = useRef(0); const dragDepthRef = useRef(0);
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
const startConflictGlobalPolicyRef = useRef<Extract<DuplicatePolicy, "skip" | "overwrite"> | null>(null);
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
@ -149,6 +170,11 @@ export function App(): ReactElement {
return () => { return () => {
if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); } if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
if (startConflictResolverRef.current) {
const resolver = startConflictResolverRef.current;
startConflictResolverRef.current = null;
resolver(null);
}
if (unsubscribe) { unsubscribe(); } if (unsubscribe) { unsubscribe(); }
if (unsubClipboard) { unsubClipboard(); } if (unsubClipboard) { unsubClipboard(); }
}; };
@ -289,9 +315,7 @@ export function App(): ReactElement {
const onSaveSettings = async (): Promise<void> => { const onSaveSettings = async (): Promise<void> => {
await performQuickAction(async () => { await performQuickAction(async () => {
const result = await window.rd.updateSettings(normalizedSettingsDraft); const result = await persistDraftSettings();
setSettingsDraft(result);
setSettingsDirty(false);
applyTheme(result.theme); applyTheme(result.theme);
showToast("Einstellungen gespeichert", 1800); showToast("Einstellungen gespeichert", 1800);
}, (error) => { }, (error) => {
@ -308,9 +332,81 @@ export function App(): ReactElement {
}); });
}; };
const persistDraftSettings = async (): Promise<AppSettings> => {
const result = await window.rd.updateSettings(normalizedSettingsDraft);
setSettingsDraft(result);
setSettingsDirty(false);
return result;
};
const closeStartConflictPrompt = (result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null): void => {
const resolver = startConflictResolverRef.current;
startConflictResolverRef.current = null;
setStartConflictPrompt(null);
if (resolver) {
resolver(result);
}
};
const askStartConflictDecision = (entry: StartConflictEntry): Promise<{ policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null> => {
return new Promise((resolve) => {
startConflictResolverRef.current = resolve;
setStartConflictPrompt({
entry,
applyToAll: false
});
});
};
const onStartDownloads = async (): Promise<void> => {
await performQuickAction(async () => {
if (configuredProviders.length === 0) {
setTab("settings");
showToast("Bitte zuerst mindestens einen Hoster-Account eintragen", 3000);
return;
}
await persistDraftSettings();
const conflicts = await window.rd.getStartConflicts();
let skipped = 0;
let overwritten = 0;
let rememberedPolicy = startConflictGlobalPolicyRef.current;
for (const conflict of conflicts) {
let decisionPolicy = rememberedPolicy;
if (!decisionPolicy) {
const decision = await askStartConflictDecision(conflict);
if (!decision) {
showToast("Start abgebrochen", 1800);
return;
}
decisionPolicy = decision.policy;
if (decision.applyToAll) {
startConflictGlobalPolicyRef.current = decision.policy;
rememberedPolicy = decision.policy;
}
}
const result = await window.rd.resolveStartConflict(conflict.packageId, decisionPolicy);
if (result.skipped) {
skipped += 1;
}
if (result.overwritten) {
overwritten += 1;
}
}
if (conflicts.length > 0) {
showToast(`Konflikte gelöst: ${overwritten} überschrieben, ${skipped} übersprungen`, 2800);
}
await window.rd.start();
});
};
const onAddLinks = async (): Promise<void> => { const onAddLinks = async (): Promise<void> => {
await performQuickAction(async () => { await performQuickAction(async () => {
await window.rd.updateSettings(normalizedSettingsDraft); await persistDraftSettings();
const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName }); const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName });
if (result.addedLinks > 0) { if (result.addedLinks > 0) {
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
@ -327,7 +423,7 @@ export function App(): ReactElement {
await performQuickAction(async () => { await performQuickAction(async () => {
const files = await window.rd.pickContainers(); const files = await window.rd.pickContainers();
if (files.length === 0) { return; } if (files.length === 0) { return; }
await window.rd.updateSettings(normalizedSettingsDraft); await persistDraftSettings();
const result = await window.rd.addContainers(files); const result = await window.rd.addContainers(files);
showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
}, (error) => { }, (error) => {
@ -345,7 +441,7 @@ export function App(): ReactElement {
const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || ""; const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
if (dlc.length > 0) { if (dlc.length > 0) {
await performQuickAction(async () => { await performQuickAction(async () => {
await window.rd.updateSettings(normalizedSettingsDraft); await persistDraftSettings();
const result = await window.rd.addContainers(dlc); const result = await window.rd.addContainers(dlc);
showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
}, (error) => { }, (error) => {
@ -580,17 +676,7 @@ export function App(): ReactElement {
<section className="control-strip"> <section className="control-strip">
<div className="buttons"> <div className="buttons">
<button className="btn accent" disabled={!snapshot.canStart || actionBusy} onClick={async () => { <button className="btn accent" disabled={!snapshot.canStart || actionBusy} onClick={() => { void onStartDownloads(); }}>Start</button>
await performQuickAction(async () => {
if (configuredProviders.length === 0) {
setTab("settings");
showToast("Bitte zuerst mindestens einen Hoster-Account eintragen", 3000);
return;
}
await window.rd.updateSettings(normalizedSettingsDraft);
await window.rd.start();
});
}}>Start</button>
<button className="btn" disabled={!snapshot.canPause || actionBusy} onClick={() => { void performQuickAction(() => window.rd.togglePause()); }}> <button className="btn" disabled={!snapshot.canPause || actionBusy} onClick={() => { void performQuickAction(() => window.rd.togglePause()); }}>
{snapshot.session.paused ? "Fortsetzen" : "Pause"} {snapshot.session.paused ? "Fortsetzen" : "Pause"}
</button> </button>
@ -894,6 +980,44 @@ export function App(): ReactElement {
)} )}
</main> </main>
{startConflictPrompt && (
<div className="modal-backdrop" onClick={() => closeStartConflictPrompt(null)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<h3>Paket bereits entpackt</h3>
<p>
<strong>{startConflictPrompt.entry.packageName}</strong> ist im Ziel bereits vorhanden.
</p>
<p className="modal-path" title={startConflictPrompt.entry.extractDir}>{startConflictPrompt.entry.extractDir}</p>
<label className="toggle-line">
<input
type="checkbox"
checked={startConflictPrompt.applyToAll}
onChange={(event) => {
const checked = event.target.checked;
setStartConflictPrompt((prev) => prev ? { ...prev, applyToAll: checked } : prev);
}}
/>
Für alle weiteren Pakete dieselbe Auswahl verwenden
</label>
<div className="modal-actions">
<button className="btn" onClick={() => closeStartConflictPrompt(null)}>Abbrechen</button>
<button
className="btn"
onClick={() => closeStartConflictPrompt({ policy: "skip", applyToAll: startConflictPrompt.applyToAll })}
>
Überspringen
</button>
<button
className="btn danger"
onClick={() => closeStartConflictPrompt({ policy: "overwrite", applyToAll: startConflictPrompt.applyToAll })}
>
Überschreiben
</button>
</div>
</div>
</div>
)}
{statusToast && <div className="toast">{statusToast}</div>} {statusToast && <div className="toast">{statusToast}</div>}
{dragOver && <div className="drop-overlay">Links oder .dlc Dateien hier ablegen</div>} {dragOver && <div className="drop-overlay">Links oder .dlc Dateien hier ablegen</div>}
</div> </div>

View File

@ -634,6 +634,48 @@ td {
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(4, 9, 18, 0.68);
display: grid;
place-items: center;
z-index: 20;
padding: 20px;
}
.modal-card {
width: min(560px, 100%);
border: 1px solid var(--border);
border-radius: 14px;
background: linear-gradient(180deg, color-mix(in srgb, var(--card) 98%, transparent), color-mix(in srgb, var(--surface) 98%, transparent));
box-shadow: 0 20px 38px rgba(0, 0, 0, 0.35);
padding: 16px;
display: grid;
gap: 10px;
}
.modal-card h3 {
margin: 0;
}
.modal-card p {
margin: 0;
color: var(--muted);
}
.modal-path {
font-size: 12px;
word-break: break-all;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.control-strip { .control-strip {
flex-direction: column; flex-direction: column;

View File

@ -7,6 +7,8 @@ export const IPC_CHANNELS = {
UPDATE_SETTINGS: "app:update-settings", UPDATE_SETTINGS: "app:update-settings",
ADD_LINKS: "queue:add-links", ADD_LINKS: "queue:add-links",
ADD_CONTAINERS: "queue:add-containers", ADD_CONTAINERS: "queue:add-containers",
GET_START_CONFLICTS: "queue:get-start-conflicts",
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
CLEAR_ALL: "queue:clear-all", CLEAR_ALL: "queue:clear-all",
START: "queue:start", START: "queue:start",
STOP: "queue:stop", STOP: "queue:stop",

View File

@ -1,4 +1,13 @@
import type { AddLinksPayload, AppSettings, UiSnapshot, UpdateCheckResult, UpdateInstallResult } from "./types"; import type {
AddLinksPayload,
AppSettings,
DuplicatePolicy,
StartConflictEntry,
StartConflictResolutionResult,
UiSnapshot,
UpdateCheckResult,
UpdateInstallResult
} from "./types";
export interface ElectronApi { export interface ElectronApi {
getSnapshot: () => Promise<UiSnapshot>; getSnapshot: () => Promise<UiSnapshot>;
@ -9,6 +18,8 @@ export interface ElectronApi {
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>; updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>; addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
getStartConflicts: () => Promise<StartConflictEntry[]>;
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
clearAll: () => Promise<void>; clearAll: () => Promise<void>;
start: () => Promise<void>; start: () => Promise<void>;
stop: () => Promise<void>; stop: () => Promise<void>;

View File

@ -157,12 +157,39 @@ export interface UiSnapshot {
export interface AddLinksPayload { export interface AddLinksPayload {
rawText: string; rawText: string;
packageName?: string; packageName?: string;
duplicatePolicy?: DuplicatePolicy;
} }
export interface AddContainerPayload { export interface AddContainerPayload {
filePaths: string[]; filePaths: string[];
} }
export type DuplicatePolicy = "keep" | "skip" | "overwrite";
export interface QueueAddResult {
addedPackages: number;
addedLinks: number;
skippedExistingPackages: string[];
overwrittenPackages: string[];
}
export interface ContainerConflictResult {
conflicts: string[];
packageCount: number;
linkCount: number;
}
export interface StartConflictEntry {
packageId: string;
packageName: string;
extractDir: string;
}
export interface StartConflictResolutionResult {
skipped: boolean;
overwritten: boolean;
}
export interface UpdateCheckResult { export interface UpdateCheckResult {
updateAvailable: boolean; updateAvailable: boolean;
currentVersion: string; currentVersion: string;

View File

@ -823,6 +823,209 @@ describe("download manager", () => {
expect(snapshot.canStart).toBe(true); expect(snapshot.canStart).toBe(true);
}); });
it("detects start conflicts when extract output already exists", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const packageId = "conflict-pkg";
const itemId = "conflict-item";
const now = Date.now() - 5000;
const outputDir = path.join(root, "downloads", "conflict");
const extractDir = path.join(root, "extract", "conflict");
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(extractDir, "existing.mkv"), "x", "utf8");
const session = emptySession();
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "conflict",
outputDir,
extractDir,
status: "queued",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt: now,
updatedAt: now
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/conflict",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "conflict.part01.rar",
targetPath: path.join(outputDir, "conflict.part01.rar"),
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: now,
updatedAt: now
};
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
},
session,
createStoragePaths(path.join(root, "state"))
);
const conflicts = manager.getStartConflicts();
expect(conflicts.length).toBe(1);
expect(conflicts[0]?.packageId).toBe(packageId);
});
it("resolves start conflict by skipping package", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const packageId = "skip-pkg";
const itemId = "skip-item";
const now = Date.now() - 5000;
const outputDir = path.join(root, "downloads", "skip");
const extractDir = path.join(root, "extract", "skip");
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(extractDir, "existing.mkv"), "x", "utf8");
const session = emptySession();
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "skip",
outputDir,
extractDir,
status: "queued",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt: now,
updatedAt: now
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/skip",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "skip.part01.rar",
targetPath: path.join(outputDir, "skip.part01.rar"),
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: now,
updatedAt: now
};
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
},
session,
createStoragePaths(path.join(root, "state"))
);
const result = manager.resolveStartConflict(packageId, "skip");
expect(result.skipped).toBe(true);
expect(manager.getSnapshot().session.packages[packageId]).toBeUndefined();
expect(manager.getSnapshot().session.items[itemId]).toBeUndefined();
});
it("resolves start conflict by overwriting and resetting queued package", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const packageId = "overwrite-pkg";
const itemId = "overwrite-item";
const now = Date.now() - 5000;
const outputDir = path.join(root, "downloads", "overwrite");
const extractDir = path.join(root, "extract", "overwrite");
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, "overwrite.part01.rar"), "part", "utf8");
fs.writeFileSync(path.join(extractDir, "existing.mkv"), "x", "utf8");
const session = emptySession();
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "overwrite",
outputDir,
extractDir,
status: "queued",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt: now,
updatedAt: now
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/overwrite",
provider: null,
status: "queued",
retries: 1,
speedBps: 0,
downloadedBytes: 42,
totalBytes: 100,
progressPercent: 42,
fileName: "overwrite.part01.rar",
targetPath: path.join(outputDir, "overwrite.part01.rar"),
resumable: true,
attempts: 3,
lastError: "x",
fullStatus: "Wartet",
createdAt: now,
updatedAt: now
};
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
},
session,
createStoragePaths(path.join(root, "state"))
);
const result = manager.resolveStartConflict(packageId, "overwrite");
expect(result.overwritten).toBe(true);
const snapshot = manager.getSnapshot();
const item = snapshot.session.items[itemId];
expect(item?.status).toBe("queued");
expect(item?.downloadedBytes).toBe(0);
expect(item?.progressPercent).toBe(0);
expect(item?.attempts).toBe(0);
expect(item?.lastError).toBe("");
expect(item?.fullStatus).toBe("Wartet");
expect(fs.existsSync(outputDir)).toBe(false);
expect(fs.existsSync(extractDir)).toBe(false);
});
it("requeues legacy 'Gestoppt' items on startup", () => { it("requeues legacy 'Gestoppt' items on startup", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);