diff --git a/package.json b/package.json index 81a384d..8d2d6f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.1", + "version": "1.4.2", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index ef07281..72cf2cc 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -1,6 +1,16 @@ import path from "node:path"; 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 { APP_VERSION, defaultSettings } from "./constants"; import { DownloadManager } from "./download-manager"; @@ -39,9 +49,12 @@ export class AppController { if (this.settings.autoResumeOnStart) { const snapshot = this.manager.getSnapshot(); 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(); 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; } + public getStartConflicts(): StartConflictEntry[] { + return this.manager.getStartConflicts(); + } + + public resolveStartConflict(packageId: string, policy: DuplicatePolicy): StartConflictResolutionResult { + return this.manager.resolveStartConflict(packageId, policy); + } + public clearAll(): void { this.manager.clearAll(); } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index e578c25..3194d07 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -3,7 +3,20 @@ import path from "node:path"; import os from "node:os"; import { EventEmitter } from "node:events"; 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 { cleanupCancelledPackageArtifactsAsync } from "./cleanup"; import { DebridService, MegaWebUnrestrictor } from "./debrid"; @@ -514,6 +527,105 @@ export class DownloadManager extends EventEmitter { 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): Promise { try { let changed = false; diff --git a/src/main/main.ts b/src/main/main.ts index 402718c..0dfb86c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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_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.START, () => controller.start()); ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop()); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 018f767..13a114a 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -1,5 +1,5 @@ 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 { ElectronApi } from "../shared/preload-api"; @@ -14,6 +14,9 @@ const api: ElectronApi = { ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload), addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths), + getStartConflicts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), + resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise => + ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy), clearAll: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL), start: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.START), stop: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.STOP), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b58185a..9b4cd4a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,18 @@ 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"; @@ -9,6 +22,11 @@ interface CollectorTab { text: string; } +interface StartConflictPromptState { + entry: StartConflictEntry; + applyToAll: boolean; +} + const emptyStats = (): DownloadStats => ({ totalDownloaded: 0, totalFiles: 0, @@ -86,6 +104,9 @@ export function App(): ReactElement { const actionBusyRef = useRef(false); const dragOverRef = useRef(false); const dragDepthRef = useRef(0); + const [startConflictPrompt, setStartConflictPrompt] = useState(null); + const startConflictResolverRef = useRef<((result: { policy: Extract; applyToAll: boolean } | null) => void) | null>(null); + const startConflictGlobalPolicyRef = useRef | null>(null); const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; @@ -149,6 +170,11 @@ export function App(): ReactElement { return () => { if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } + if (startConflictResolverRef.current) { + const resolver = startConflictResolverRef.current; + startConflictResolverRef.current = null; + resolver(null); + } if (unsubscribe) { unsubscribe(); } if (unsubClipboard) { unsubClipboard(); } }; @@ -289,9 +315,7 @@ export function App(): ReactElement { const onSaveSettings = async (): Promise => { await performQuickAction(async () => { - const result = await window.rd.updateSettings(normalizedSettingsDraft); - setSettingsDraft(result); - setSettingsDirty(false); + const result = await persistDraftSettings(); applyTheme(result.theme); showToast("Einstellungen gespeichert", 1800); }, (error) => { @@ -308,9 +332,81 @@ export function App(): ReactElement { }); }; + const persistDraftSettings = async (): Promise => { + const result = await window.rd.updateSettings(normalizedSettingsDraft); + setSettingsDraft(result); + setSettingsDirty(false); + return result; + }; + + const closeStartConflictPrompt = (result: { policy: Extract; applyToAll: boolean } | null): void => { + const resolver = startConflictResolverRef.current; + startConflictResolverRef.current = null; + setStartConflictPrompt(null); + if (resolver) { + resolver(result); + } + }; + + const askStartConflictDecision = (entry: StartConflictEntry): Promise<{ policy: Extract; applyToAll: boolean } | null> => { + return new Promise((resolve) => { + startConflictResolverRef.current = resolve; + setStartConflictPrompt({ + entry, + applyToAll: false + }); + }); + }; + + const onStartDownloads = async (): Promise => { + 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 => { await performQuickAction(async () => { - await window.rd.updateSettings(normalizedSettingsDraft); + await persistDraftSettings(); const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName }); if (result.addedLinks > 0) { showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); @@ -327,7 +423,7 @@ export function App(): ReactElement { await performQuickAction(async () => { const files = await window.rd.pickContainers(); if (files.length === 0) { return; } - await window.rd.updateSettings(normalizedSettingsDraft); + await persistDraftSettings(); const result = await window.rd.addContainers(files); showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); }, (error) => { @@ -345,7 +441,7 @@ export function App(): ReactElement { const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || ""; if (dlc.length > 0) { await performQuickAction(async () => { - await window.rd.updateSettings(normalizedSettingsDraft); + await persistDraftSettings(); const result = await window.rd.addContainers(dlc); showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); }, (error) => { @@ -580,17 +676,7 @@ export function App(): ReactElement {
- + @@ -894,6 +980,44 @@ export function App(): ReactElement { )} + {startConflictPrompt && ( +
closeStartConflictPrompt(null)}> +
event.stopPropagation()}> +

Paket bereits entpackt

+

+ {startConflictPrompt.entry.packageName} ist im Ziel bereits vorhanden. +

+

{startConflictPrompt.entry.extractDir}

+ +
+ + + +
+
+
+ )} + {statusToast &&
{statusToast}
} {dragOver &&
Links oder .dlc Dateien hier ablegen
}
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index a896ae9..192cca2 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -634,6 +634,48 @@ td { 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) { .control-strip { flex-direction: column; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 9450cf3..4717593 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -7,6 +7,8 @@ export const IPC_CHANNELS = { UPDATE_SETTINGS: "app:update-settings", ADD_LINKS: "queue:add-links", ADD_CONTAINERS: "queue:add-containers", + GET_START_CONFLICTS: "queue:get-start-conflicts", + RESOLVE_START_CONFLICT: "queue:resolve-start-conflict", CLEAR_ALL: "queue:clear-all", START: "queue:start", STOP: "queue:stop", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 40c309a..7273f63 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -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 { getSnapshot: () => Promise; @@ -9,6 +18,8 @@ export interface ElectronApi { updateSettings: (settings: Partial) => Promise; addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>; addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; + getStartConflicts: () => Promise; + resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise; clearAll: () => Promise; start: () => Promise; stop: () => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index 0a3ce2c..08c8b06 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -157,12 +157,39 @@ export interface UiSnapshot { export interface AddLinksPayload { rawText: string; packageName?: string; + duplicatePolicy?: DuplicatePolicy; } export interface AddContainerPayload { 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 { updateAvailable: boolean; currentVersion: string; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 169780e..1de97b4 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -823,6 +823,209 @@ describe("download manager", () => { 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", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);