diff --git a/src/main/collector-inspection.ts b/src/main/collector-inspection.ts index 1c64290..50283b1 100644 --- a/src/main/collector-inspection.ts +++ b/src/main/collector-inspection.ts @@ -14,6 +14,7 @@ interface CollectorInspectionDependencies { checkRapidgator?: typeof checkRapidgatorOnline; resolveFilenames?: (links: string[]) => Promise>; createId?: (prefix: "package" | "link") => string; + inspectionTimeoutMs?: number; } interface SourceLink { @@ -64,6 +65,17 @@ async function runWithConcurrency(items: T[], limit: number, worker: (item: T await Promise.all(runners); } +async function waitForCollectorMetadata(tasks: Promise[], timeoutMs: number): Promise { + const all = Promise.all(tasks).then(() => undefined); + let timer: ReturnType | null = null; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(1, timeoutMs)); + timer.unref?.(); + }); + await Promise.race([all, timeout]); + if (timer) clearTimeout(timer); +} + function countInputLines(rawText: string): { invalidCount: number; duplicateCount: number } { const seen = new Set(); let invalidCount = 0; @@ -172,7 +184,11 @@ export async function inspectCollectorPackages( const checkRapidgator = dependencies.checkRapidgator ?? checkRapidgatorOnline; const resolveFilenames = dependencies.resolveFilenames ?? ((urls) => new DebridService(settings).resolveFilenames(urls)); - const oneFichierPromise = checkOneFichier(oneFichierLinks).catch(() => new Map()); + let oneFichierResults = new Map(); + let genericResults = new Map(); + const oneFichierPromise = checkOneFichier(oneFichierLinks) + .then((results) => { oneFichierResults = results; }) + .catch(() => undefined); const rapidgatorPromise = runWithConcurrency(rapidgatorLinks, 8, async (url) => { const result = await checkRapidgator(url).catch(() => null); const link = linksByUrl.get(url); @@ -192,10 +208,13 @@ export async function inspectCollectorPackages( if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; }); const genericPromise = genericLinks.length > 0 - ? resolveFilenames(genericLinks).catch(() => new Map()) - : Promise.resolve(new Map()); + ? resolveFilenames(genericLinks).then((results) => { genericResults = results; }).catch(() => undefined) + : Promise.resolve(); - const [oneFichierResults, genericResults] = await Promise.all([oneFichierPromise, genericPromise, rapidgatorPromise, ddownloadPromise]).then(([one, generic]) => [one, generic] as const); + await waitForCollectorMetadata( + [oneFichierPromise, genericPromise, rapidgatorPromise, ddownloadPromise], + dependencies.inspectionTimeoutMs ?? 30_000 + ); for (const [url, result] of oneFichierResults) { const link = linksByUrl.get(url); if (!link) continue; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 66bae99..145c5d0 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; import { AddLinksPayload, AccountCheckScope, @@ -56,7 +56,8 @@ const api: ElectronApi = { ipcRenderer.invoke(IPC_CHANNELS.INSPECT_COLLECTOR_TEXT, request), inspectCollectorContainers: (filePaths: string[], addedAt: number): Promise => ipcRenderer.invoke(IPC_CHANNELS.INSPECT_COLLECTOR_CONTAINERS, filePaths, addedAt), - getStartConflicts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), + getPathForDroppedFile: (file: File): string => webUtils.getPathForFile(file), + 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), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 969f412..89f991b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -49,6 +49,7 @@ import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons"; import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state"; import { createUiLocalizer, normalizeLanguage } from "./i18n"; +import { importDroppedDlcFiles } from "./collector-drop"; import { runLocalBackupExport, runLocalBackupImport, type BackupPassphraseMode } from "./backup-flow"; import type { DownloadSpeedHistoryState } from "./download-speed-state"; import { extractHoster, formatDateTime, formatSpeedMbps, humanSize, providerLabels } from "./download-format"; @@ -3649,11 +3650,20 @@ export function App(): ReactElement { const hasUri = event.dataTransfer.types.includes("text/uri-list"); if (!hasFiles && !hasUri) { return; } const files = Array.from(event.dataTransfer.files ?? []) as File[]; - const dlc = files.filter((f) => f.name.toLowerCase().endsWith(".dlc")).map((f) => (f as unknown as { path?: string }).path).filter((v): v is string => !!v); + const dlcFiles = files.filter((file) => file.name.toLowerCase().endsWith(".dlc")); const importFiles = files.filter((f) => /\.(json|txt)$/i.test(f.name)); const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || ""; - if (dlc.length > 0) { - await enqueueCollectorInspection(() => window.rd.inspectCollectorContainers(dlc, Date.now())); + if (dlcFiles.length > 0) { + await performQuickAction(async () => { + const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); + const result = await importDroppedDlcFiles(dlcFiles, window.rd.getPathForDroppedFile, window.rd.addContainers); + if (!result || result.addedLinks === 0) throw new Error("Keine gültigen Links in der DLC-Datei gefunden"); + setTab("downloads"); + showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); + if (snapshotRef.current.settings.collapseNewPackages) await collapseNewPackages(existingIds); + }, (error) => { + showToast(`DLC-Import fehlgeschlagen: ${String(error)}`, 3000); + }); } else if (importFiles.length > 0) { const { queueJson, linkText } = await readCollectorImportFiles(importFiles); await importQueueJsonTexts(queueJson); diff --git a/src/renderer/collector-drop.ts b/src/renderer/collector-drop.ts new file mode 100644 index 0000000..3f70fd2 --- /dev/null +++ b/src/renderer/collector-drop.ts @@ -0,0 +1,24 @@ +export function resolveDroppedDlcPaths( + files: ReadonlyArray, + getPathForFile: (file: File) => string +): string[] { + const paths: string[] = []; + for (const file of files) { + if (!file.name.toLowerCase().endsWith(".dlc")) continue; + try { + const filePath = String(getPathForFile(file) || "").trim(); + if (filePath) paths.push(filePath); + } catch { + } + } + return paths; +} + +export async function importDroppedDlcFiles( + files: ReadonlyArray, + getPathForFile: (file: File) => string, + addContainers: (filePaths: string[]) => Promise +): Promise { + const filePaths = resolveDroppedDlcPaths(files, getPathForFile); + return filePaths.length > 0 ? addContainers(filePaths) : null; +} diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index ffca3e0..250722b 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -82,7 +82,8 @@ export interface ElectronApi { addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; inspectCollectorText: (request: CollectorInspectionRequest) => Promise; inspectCollectorContainers: (filePaths: string[], addedAt: number) => Promise; - getStartConflicts: () => Promise; + getPathForDroppedFile: (file: File) => string; + getStartConflicts: () => Promise; resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise; clearAll: () => Promise; start: () => Promise; diff --git a/tests/account-preload.test.ts b/tests/account-preload.test.ts index de41ca9..d6e6eef 100644 --- a/tests/account-preload.test.ts +++ b/tests/account-preload.test.ts @@ -4,7 +4,8 @@ import type { ElectronApi } from "../src/shared/preload-api"; const electron = vi.hoisted(() => ({ api: undefined as ElectronApi | undefined, - invoke: vi.fn<(...args: unknown[]) => Promise>(async () => undefined) + invoke: vi.fn<(...args: unknown[]) => Promise>(async () => undefined), + getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc") })); vi.mock("electron", () => ({ @@ -18,7 +19,8 @@ vi.mock("electron", () => ({ on: vi.fn(), removeListener: vi.fn(), send: vi.fn() - } + }, + webUtils: { getPathForFile: electron.getPathForFile } })); describe("account preload contract", () => { @@ -122,4 +124,12 @@ describe("account preload contract", () => { [IPC_CHANNELS.INSPECT_COLLECTOR_CONTAINERS, ["C:\\Imports\\sample.dlc"], 5678] ]); }); + + it("resolves dropped files through Electron webUtils without IPC", () => { + const file = { name: "dropped.dlc" } as File; + + expect(electron.api?.getPathForDroppedFile(file)).toBe("C:\\Imports\\dropped.dlc"); + expect(electron.getPathForFile).toHaveBeenCalledWith(file); + expect(electron.invoke).not.toHaveBeenCalled(); + }); }); diff --git a/tests/collector-drop.test.ts b/tests/collector-drop.test.ts new file mode 100644 index 0000000..ec2f90b --- /dev/null +++ b/tests/collector-drop.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import { importDroppedDlcFiles, resolveDroppedDlcPaths } from "../src/renderer/collector-drop"; + +describe("collector DLC drop", () => { + it("resolves DLC paths through the preload bridge instead of File.path", () => { + const first = { name: "first.dlc" } as File; + const ignored = { name: "notes.txt" } as File; + const second = { name: "SECOND.DLC" } as File; + const getPath = vi.fn((file: File) => file === first ? "C:\\Drops\\first.dlc" : "C:\\Drops\\second.dlc"); + + expect(resolveDroppedDlcPaths([first, ignored, second], getPath)).toEqual([ + "C:\\Drops\\first.dlc", + "C:\\Drops\\second.dlc" + ]); + expect(getPath.mock.calls.map(([file]) => file)).toEqual([first, second]); + }); + + it("ignores files whose native path cannot be resolved", () => { + const file = { name: "broken.dlc" } as File; + + expect(resolveDroppedDlcPaths([file], () => "")).toEqual([]); + expect(resolveDroppedDlcPaths([file], () => { throw new Error("unavailable"); })).toEqual([]); + }); + + it("imports dropped DLC files directly into Downloads without collector analysis", async () => { + const file = { name: "package.dlc" } as File; + const addContainers = vi.fn(async () => ({ addedPackages: 2, addedLinks: 16 })); + + await expect(importDroppedDlcFiles([file], () => "C:\\Drops\\package.dlc", addContainers)).resolves.toEqual({ + addedPackages: 2, + addedLinks: 16 + }); + expect(addContainers).toHaveBeenCalledWith(["C:\\Drops\\package.dlc"]); + }); +}); diff --git a/tests/collector-inspection.test.ts b/tests/collector-inspection.test.ts index bda717f..8c3b8dd 100644 --- a/tests/collector-inspection.test.ts +++ b/tests/collector-inspection.test.ts @@ -100,6 +100,21 @@ describe("collector inspection", () => { })); }); + it("returns visible unknown links when metadata analysis reaches its deadline", async () => { + const link = "https://1fichier.com/?slow123"; + const result = await inspectCollectorText({ rawText: link, addedAt: 3200 }, defaultSettings(), { + checkOneFichier: async () => new Promise(() => {}), + inspectionTimeoutMs: 10 + }); + + expect(result.packages).toHaveLength(1); + expect(result.packages[0].links).toEqual([expect.objectContaining({ + url: link, + availability: "unknown", + status: "unknown" + })]); + }); + it("resolves DDownload metadata before grouping without using a debrid account", async () => { const link = "https://ddownload.com/ntwscdw62gyb"; let genericResolverCalls = 0; diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index 922aeb8..a06f8db 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -61,6 +61,7 @@ export function createVisualElectronApi( addContainers: async () => ({ addedPackages: 0, addedLinks: 0 }), inspectCollectorText: async () => clone(fixture.collector), inspectCollectorContainers: async () => clone(fixture.collector), + getPathForDroppedFile: () => "", getStartConflicts: async () => [], resolveStartConflict: async (_packageId, policy) => ({ skipped: policy === "skip",