Add start conflict prompts for existing extracted packages in v1.4.2
This commit is contained in:
parent
1c92591bf1
commit
01ed725136
@ -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",
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user