diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 56825c6..0b5da0f 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -1001,8 +1001,11 @@ export class AppController { return prepareCollectorContainers(filePaths, addedAt); } - public enrichCollectorPackages(request: CollectorEnrichmentRequest): Promise { - return enrichCollectorPackages(request, this.settings); + public enrichCollectorPackages( + request: CollectorEnrichmentRequest, + onProgress?: (result: CollectorInspectionResult) => void + ): Promise { + return enrichCollectorPackages(request, this.settings, {}, onProgress); } public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> { diff --git a/src/main/collector-inspection.ts b/src/main/collector-inspection.ts index 2823ff2..2d6bcbd 100644 --- a/src/main/collector-inspection.ts +++ b/src/main/collector-inspection.ts @@ -27,10 +27,42 @@ export interface CollectorInspectionDependencies { checkDdownload?: typeof checkDdownloadOnline; checkOneFichier?: (links: string[]) => Promise>; checkRapidgator?: typeof checkRapidgatorOnline; - resolveFilenames?: (links: string[]) => Promise>; + resolveFilenames?: (links: string[], onResolved?: (link: string, fileName: string) => void) => Promise>; importContainers?: typeof importDlcContainers; } +function collectorProgressResult(packages: CollectorPackage[], urls: ReadonlySet): CollectorInspectionResult { + const fragments = packages.flatMap((pkg) => { + const links = pkg.links.filter((link) => urls.has(link.url)).map((link) => ({ ...link })); + return links.length > 0 ? [{ ...pkg, links }] : []; + }); + return { packages: regroupEnrichedPackages(fragments), invalidCount: 0, duplicateCount: 0 }; +} + +function createCollectorProgressEmitter( + packages: CollectorPackage[], + onProgress?: (result: CollectorInspectionResult) => void +): { queue: (url: string) => void; flush: () => void } { + const pending = new Set(); + let timer: ReturnType | null = null; + const flush = (): void => { + if (timer) clearTimeout(timer); + timer = null; + if (!onProgress || pending.size === 0) return; + const urls = new Set(pending); + pending.clear(); + onProgress(collectorProgressResult(packages, urls)); + }; + return { + queue: (url) => { + if (!onProgress) return; + pending.add(url); + if (!timer) timer = setTimeout(flush, 100); + }, + flush + }; +} + interface PreparedSourceLink { url: string; fileName: string; @@ -177,7 +209,8 @@ function regroupEnrichedPackages(packages: CollectorPackage[]): CollectorPackage export async function enrichCollectorPackages( request: CollectorEnrichmentRequest, settings: AppSettings, - dependencies: CollectorInspectionDependencies = {} + dependencies: CollectorInspectionDependencies = {}, + onProgress?: (result: CollectorInspectionResult) => void ): Promise { const packages = request.packages.map((pkg) => ({ ...pkg, links: pkg.links.map((link) => ({ ...link })) })); const linksByUrl = new Map(packages.flatMap((pkg) => pkg.links).map((link) => [link.url, link])); @@ -195,9 +228,21 @@ export async function enrichCollectorPackages( const checkOneFichier = dependencies.checkOneFichier ?? checkOneFichierLinks; const checkRapidgator = dependencies.checkRapidgator ?? checkRapidgatorOnline; const checkDdownload = dependencies.checkDdownload ?? checkDdownloadOnline; - const resolveFilenames = dependencies.resolveFilenames ?? ((links) => new DebridService(settings).resolveFilenames(links)); + const resolveFilenames = dependencies.resolveFilenames ?? ((links, onResolved) => new DebridService(settings).resolveFilenames(links, onResolved)); + const progress = createCollectorProgressEmitter(packages, onProgress); const oneFichierPromise = oneFichierLinks.length > 0 - ? checkOneFichier(oneFichierLinks).catch(() => new Map()) + ? checkOneFichier(oneFichierLinks).then((results) => { + for (const [url, result] of results) { + const link = linksByUrl.get(url); + if (!link) continue; + link.availability = result.online ? "online" : "offline"; + link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline"; + if (result.fileName) link.fileName = sanitizeFilename(result.fileName); + if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; + progress.queue(url); + } + return results; + }).catch(() => new Map()) : Promise.resolve(new Map()); const rapidgatorPromise = runWithConcurrency(rapidgatorLinks, 8, async (url) => { const result = await checkRapidgator(url).catch(() => null); @@ -207,6 +252,7 @@ export async function enrichCollectorPackages( link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline"; if (result.fileName) link.fileName = sanitizeFilename(result.fileName); if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; + progress.queue(url); }); const ddownloadPromise = runWithConcurrency(ddownloadLinks, 4, async (url) => { const result = await checkDdownload(url).catch(() => null); @@ -216,9 +262,16 @@ export async function enrichCollectorPackages( link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline"; if (result.fileName) link.fileName = sanitizeFilename(result.fileName); if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; + progress.queue(url); }); const genericPromise = genericLinks.length > 0 - ? resolveFilenames(genericLinks).catch(() => new Map()) + ? resolveFilenames(genericLinks, (url, fileName) => { + const link = linksByUrl.get(url); + if (!link || !fileName) return; + link.fileName = sanitizeFilename(fileName); + link.status = "ready"; + progress.queue(url); + }).catch(() => new Map()) : Promise.resolve(new Map()); const [oneFichierResults, genericResults] = await Promise.all([ oneFichierPromise, @@ -226,20 +279,15 @@ export async function enrichCollectorPackages( rapidgatorPromise, ddownloadPromise ]).then(([oneFichier, generic]) => [oneFichier, generic] as const); - for (const [url, result] of oneFichierResults) { - const link = linksByUrl.get(url); - if (!link) continue; - link.availability = result.online ? "online" : "offline"; - link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline"; - if (result.fileName) link.fileName = sanitizeFilename(result.fileName); - if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; - } + void oneFichierResults; for (const [url, fileName] of genericResults) { const link = linksByUrl.get(url); if (!link || !fileName) continue; link.fileName = sanitizeFilename(fileName); link.status = "ready"; + progress.queue(url); } + progress.flush(); return { packages: regroupEnrichedPackages(packages), invalidCount: 0, duplicateCount: 0 }; } diff --git a/src/main/main.ts b/src/main/main.ts index 7a0a148..432a594 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -569,8 +569,13 @@ function registerIpcHandlers(): void { const request = validateCollectorContainerPreparationRequest({ filePaths, addedAt }); return controller.prepareCollectorContainers(request.filePaths, request.addedAt); }); - handleTrusted(IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, (_event: IpcMainInvokeEvent, value: unknown) => { - return controller.enrichCollectorPackages(validateCollectorEnrichmentRequest(value)); + handleTrusted(IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, (event: IpcMainInvokeEvent, value: unknown) => { + const request = validateCollectorEnrichmentRequest(value); + return controller.enrichCollectorPackages(request, (result) => { + if (!event.sender.isDestroyed()) { + event.sender.send(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, { requestId: request.requestId, result }); + } + }); }); handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 21fa0e5..b7c18b7 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -33,6 +33,7 @@ import { import type { RealDebridLoginRequest } from "../shared/preload-api"; import type { CollectorEnrichmentRequest, + CollectorEnrichmentProgress, CollectorInspectionResult, CollectorTextPreparationRequest } from "../shared/collector"; @@ -62,6 +63,11 @@ const api: ElectronApi = { ipcRenderer.invoke(IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, filePaths, addedAt), enrichCollectorPackages: (request: CollectorEnrichmentRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, request), + onCollectorEnrichmentProgress: (callback: (progress: CollectorEnrichmentProgress) => void): (() => void) => { + const listener = (_event: unknown, progress: CollectorEnrichmentProgress): void => callback(progress); + ipcRenderer.on(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener); + return () => ipcRenderer.removeListener(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener); + }, getPathForDroppedFile: (file: File): string => webUtils.getPathForFile(file), getStartConflicts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d3ab4ec..f013a76 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -61,7 +61,7 @@ import { Dialog } from "./ui/Dialog"; import { Icon } from "./ui/Icon"; import { Toast } from "./ui/Toast"; import { LinkAddressesDialog } from "./ui/LinkAddressesDialog"; -import { serializeCollectorPackages, type CollectorInspectionResult, type CollectorPackage } from "../shared/collector"; +import { serializeCollectorPackages, type CollectorEnrichmentProgress, type CollectorInspectionResult, type CollectorPackage } from "../shared/collector"; import { routeDroppedDlcFiles } from "./collector-drop"; import { beginCollectorEnrichment, filterCurrentCollectorEnrichment } from "./collector-enrichment"; import { @@ -77,6 +77,7 @@ import { CollectorInputDialog, MemoizedCollectorContent, CollectorSidebar, + CollectorSidebarStatus, CollectorToolbar, type CollectorViewActions } from "./views/collector/CollectorView"; @@ -1720,6 +1721,7 @@ export function App(): ReactElement { const [collectorInput, setCollectorInput] = useState(null); const collectorPackagesRef = useRef(collectorPackages); const collectorEnrichmentGenerationsRef = useRef(new Map()); + const collectorEnrichmentRequestsRef = useRef(new Map>()); const importCollectorTextRef = useRef<(rawText: string) => Promise>(() => Promise.resolve()); const activeTabRef = useRef(tab); const packageOrderRef = useRef([]); @@ -3608,8 +3610,10 @@ export function App(): ReactElement { return; } const generations = beginCollectorEnrichment(packages, collectorEnrichmentGenerationsRef.current); + const requestId = `collector-${Date.now().toString(36)}-${crypto.randomUUID()}`; + collectorEnrichmentRequestsRef.current.set(requestId, generations); setCollectorAnalyzingCount((current) => current + 1); - void window.rd.enrichCollectorPackages({ packages }).then((result) => { + void window.rd.enrichCollectorPackages({ requestId, packages }).then((result) => { mergeCollectorResult({ ...result, packages: filterCurrentCollectorEnrichment( @@ -3623,10 +3627,24 @@ export function App(): ReactElement { setCollectorError(`Metadatenprüfung fehlgeschlagen: ${String(error)}`); } }).finally(() => { + collectorEnrichmentRequestsRef.current.delete(requestId); setCollectorAnalyzingCount((current) => Math.max(0, current - 1)); }); }; + useEffect(() => window.rd.onCollectorEnrichmentProgress((progress: CollectorEnrichmentProgress) => { + const generations = collectorEnrichmentRequestsRef.current.get(progress.requestId); + if (!generations) return; + mergeCollectorResult({ + ...progress.result, + packages: filterCurrentCollectorEnrichment( + progress.result.packages, + generations, + collectorEnrichmentGenerationsRef.current + ) + }, true); + }), []); + const importCollectorText = async (rawText: string): Promise => { if (!rawText.trim()) { showToast("Keine Links eingegeben", 2200); @@ -6245,11 +6263,7 @@ export function App(): ReactElement { sidebarStatus={tab === "downloads" ? ( ) : tab === "collector" ? ( - <> - Pakete: {collectorPackages.length} - Links: {collectorViewModel.totalCount} - Ausgewählt: {collectorViewModel.selectedCount} - + ) : tab === "history" ? ( <> Einträge: {historyViewModel.totalCount} diff --git a/src/renderer/views/collector/CollectorView.tsx b/src/renderer/views/collector/CollectorView.tsx index 0939ccb..2082398 100644 --- a/src/renderer/views/collector/CollectorView.tsx +++ b/src/renderer/views/collector/CollectorView.tsx @@ -48,10 +48,9 @@ export interface CollectorInputDialogProps { } function packageStatus(row: CollectorWorkspacePackageRow): string { - const ready = row.allLinks.reduce((count, link) => count + (link.status === "ready" ? 1 : 0), 0); if (row.offlineCount === row.totalCount) return "Offline"; - if (ready === row.totalCount) return "Bereit"; - if (ready > 0 || row.onlineCount > 0) return `${ready}/${row.totalCount} geprüft`; + if (row.onlineCount === row.totalCount) return "Online"; + if (row.onlineCount > 0) return "Teilweise online"; return "Ungeprüft"; } @@ -73,10 +72,11 @@ function availabilityClass(row: CollectorWorkspacePackageRow): string { return "unknown"; } -function linkStatus(status: "ready" | "offline" | "unknown"): string { - if (status === "ready") return "Bereit"; - if (status === "offline") return "Offline"; - return "Ungeprüft"; +function availabilityTone(online: number, offline: number, total: number): "online" | "offline" | "partial" | "unknown" { + if (online === total) return "online"; + if (offline === total) return "offline"; + if (online > 0) return "partial"; + return "unknown"; } function linkAvailability(availability: "online" | "offline" | "unknown"): string { @@ -101,6 +101,7 @@ export function collectorFileInteractionAttributes( } interface CollectorViewportState { + scrollLeft: number; scrollTop: number; viewportHeight: number; } @@ -116,6 +117,7 @@ interface CollectorVirtualPackage { function useCollectorViewport(bodyRef: React.RefObject): CollectorViewportState { const [viewport, setViewport] = useState({ + scrollLeft: 0, scrollTop: 0, viewportHeight: DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT }); @@ -126,10 +128,11 @@ function useCollectorViewport(bodyRef: React.RefObject): Collect const measure = (): void => { frame = 0; const next = { + scrollLeft: body.scrollLeft, scrollTop: body.scrollTop, viewportHeight: body.clientHeight || DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT }; - setViewport((current) => current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight ? current : next); + setViewport((current) => current.scrollLeft === next.scrollLeft && current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight ? current : next); }; const schedule = (): void => { if (frame !== 0) return; @@ -147,6 +150,10 @@ function useCollectorViewport(bodyRef: React.RefObject): Collect return viewport; } +export function collectorHeaderScrollStyle(scrollLeft: number): CSSProperties { + return { transform: `translateX(${-Math.max(0, scrollLeft)}px)` }; +} + function collectorVirtualPackageStyle(top: number, height: number): CSSProperties { return { "--collector-virtual-package-top": `${top}px`, @@ -208,6 +215,19 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE ); } +export function CollectorSidebarStatus({ model }: { model: CollectorWorkspaceViewModel }): ReactElement { + return ( + <> + Pakete: {model.packageCount} + Links: {model.totalCount} + Ausgewählt: {model.selectedCount} + {model.analyzing ? ( + + ) : null} + + ); +} + export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement { return ( @@ -300,7 +320,7 @@ function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart, {row.hosters.map(formatHosterLabel).map((hoster) => )} - {packageStatus(row)} + {packageStatus(row)} {packageAvailability(row)} {formatDateTime(row.addedAt)} @@ -318,7 +338,7 @@ function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart, {link.fileName} {link.fileSizeBytes === null ? "Unbekannt" : humanSize(link.fileSizeBytes)} - {linkStatus(link.status)} + {linkAvailability(link.availability)} {linkAvailability(link.availability)} {formatDateTime(link.addedAt)} @@ -368,7 +388,7 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE model.animationsEnabled ); const effectivePinnedIds = mergeCollectorPinnedIds(resolveCollectorTransitionPins(transitionPinnedIds, freshPinnedIds), focusedPackageId); - const stateOffset = (model.analyzing ? 38 : 0) + (model.error ? 38 : 0); + const stateOffset = model.error ? 38 : 0; const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(virtualPackages, { scrollTop: Math.max(0, viewport.scrollTop - stateOffset), viewportHeight: viewport.viewportHeight, @@ -406,7 +426,7 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
-
+
Name Größe @@ -433,7 +453,6 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE }} ref={bodyRef} > - {model.analyzing ?
Analyse läuft im Hintergrund
: null} {model.error ?
{model.error}
: null} {model.empty ? ( span { animation: collector-analysis-pulse 1s ease-in-out infinite alternate; - background: var(--ui-primary); + background: var(--ui-success); border-radius: 50%; height: 7px; width: 7px; @@ -184,6 +184,12 @@ .collector-table-header { height: 41px; + overflow: hidden; +} + +.collector-table-header, +.collector-table-body { + scrollbar-gutter: stable; } .collector-table-header-row, @@ -211,6 +217,25 @@ padding: 0 12px; } +.collector-table-header-row > span:nth-child(2) { + padding-left: 48px; +} + +.collector-table-header-row > span:nth-child(n+3) { + display: flex; + justify-content: center; + text-align: center; +} + +.collector-size-cell, +.collector-hoster-cell, +.collector-status-cell, +.collector-availability-cell, +.collector-added-cell { + justify-content: center; + text-align: center; +} + .collector-table-body { overflow: auto; } @@ -326,7 +351,7 @@ .collector-name-cell.is-file { color: var(--ui-text); - padding-left: 36px; + padding-left: 32px; } .collector-collapse-button { @@ -404,6 +429,18 @@ color: var(--ui-text-muted); } +.collector-status-cell.is-online { + color: var(--ui-success); +} + +.collector-status-cell.is-offline { + color: var(--ui-danger); +} + +.collector-status-cell.is-partial { + color: var(--ui-warning-text); +} + .collector-availability-cell { font-weight: 600; white-space: nowrap; @@ -467,7 +504,7 @@ } @media (prefers-reduced-motion: reduce) { - .collector-background-state span { + .collector-sidebar-analysis > span { animation: none; } diff --git a/src/shared/collector.ts b/src/shared/collector.ts index 165358d..be90930 100644 --- a/src/shared/collector.ts +++ b/src/shared/collector.ts @@ -32,9 +32,15 @@ export interface CollectorContainerPreparationRequest { } export interface CollectorEnrichmentRequest { + requestId: string; packages: CollectorPackage[]; } +export interface CollectorEnrichmentProgress { + requestId: string; + result: CollectorInspectionResult; +} + export interface CollectorInspectionResult { packages: CollectorPackage[]; invalidCount: number; @@ -127,7 +133,10 @@ export function validateCollectorEnrichmentRequest(value: unknown): CollectorEnr throw new Error("Linksammler-Anreicherung ist ungültig"); } const raw = value as Record; - if (Object.keys(raw).some((key) => key !== "packages") + if (Object.keys(raw).some((key) => key !== "requestId" && key !== "packages") + || typeof raw.requestId !== "string" + || raw.requestId.length === 0 + || raw.requestId.length > 160 || !Array.isArray(raw.packages) || raw.packages.length === 0 || raw.packages.length > 2_000 @@ -135,7 +144,7 @@ export function validateCollectorEnrichmentRequest(value: unknown): CollectorEnr || raw.packages.reduce((sum, entry) => sum + (entry as CollectorPackage).links.length, 0) > 20_000) { throw new Error("Linksammler-Anreicherung ist ungültig"); } - return { packages: structuredClone(raw.packages) as CollectorPackage[] }; + return { requestId: raw.requestId, packages: structuredClone(raw.packages) as CollectorPackage[] }; } function collectorMarkerValue(value: string): string { diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 17fab39..11c16e0 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -17,6 +17,7 @@ export const IPC_CHANNELS = { PREPARE_COLLECTOR_TEXT: "collector:prepare-text", PREPARE_COLLECTOR_CONTAINERS: "collector:prepare-containers", ENRICH_COLLECTOR_PACKAGES: "collector:enrich-packages", + COLLECTOR_ENRICHMENT_PROGRESS: "collector:enrichment-progress", GET_START_CONFLICTS: "queue:get-start-conflicts", RESOLVE_START_CONFLICT: "queue:resolve-start-conflict", CLEAR_ALL: "queue:clear-all", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index c68e630..29dd8ce 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -36,6 +36,7 @@ import type { import { isRealDebridWebAccountId } from "./real-debrid-accounts"; import type { CollectorEnrichmentRequest, + CollectorEnrichmentProgress, CollectorInspectionResult, CollectorTextPreparationRequest } from "./collector"; @@ -87,6 +88,7 @@ export interface ElectronApi { prepareCollectorText: (request: CollectorTextPreparationRequest) => Promise; prepareCollectorContainers: (filePaths: string[], addedAt: number) => Promise; enrichCollectorPackages: (request: CollectorEnrichmentRequest) => Promise; + onCollectorEnrichmentProgress: (callback: (progress: CollectorEnrichmentProgress) => void) => () => void; getPathForDroppedFile: (file: File) => string; getStartConflicts: () => Promise; resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise; diff --git a/tests/account-preload.test.ts b/tests/account-preload.test.ts index 6d8a5b4..48e54d3 100644 --- a/tests/account-preload.test.ts +++ b/tests/account-preload.test.ts @@ -5,7 +5,9 @@ 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), - getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc") + getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc"), + on: vi.fn(), + removeListener: vi.fn() })); vi.mock("electron", () => ({ @@ -16,8 +18,8 @@ vi.mock("electron", () => ({ }, ipcRenderer: { invoke: electron.invoke, - on: vi.fn(), - removeListener: vi.fn(), + on: electron.on, + removeListener: electron.removeListener, send: vi.fn() }, webUtils: { getPathForFile: electron.getPathForFile } @@ -118,12 +120,12 @@ describe("account preload contract", () => { await electron.api?.prepareCollectorText(textRequest); await electron.api?.prepareCollectorContainers(["C:\\Imports\\sample.dlc"], 2345); - await electron.api?.enrichCollectorPackages({ packages }); + await electron.api?.enrichCollectorPackages({ requestId: "request-preload", packages }); expect(electron.invoke.mock.calls).toEqual([ [IPC_CHANNELS.PREPARE_COLLECTOR_TEXT, textRequest], [IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, ["C:\\Imports\\sample.dlc"], 2345], - [IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, { packages }] + [IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, { requestId: "request-preload", packages }] ]); }); @@ -134,4 +136,17 @@ describe("account preload contract", () => { expect(electron.getPathForFile).toHaveBeenCalledWith(file); expect(electron.invoke).not.toHaveBeenCalled(); }); + + it("subscribes and unsubscribes collector enrichment progress", () => { + const callback = vi.fn(); + const unsubscribe = electron.api?.onCollectorEnrichmentProgress(callback); + const listener = electron.on.mock.calls.find((call) => call[0] === IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS)?.[1] as ((event: unknown, value: unknown) => void) | undefined; + const progress = { requestId: "request-progress", result: { packages: [], invalidCount: 0, duplicateCount: 0 } }; + + listener?.({}, progress); + unsubscribe?.(); + + expect(callback).toHaveBeenCalledWith(progress); + expect(electron.removeListener).toHaveBeenCalledWith(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener); + }); }); diff --git a/tests/collector-inspection.test.ts b/tests/collector-inspection.test.ts index 758df7c..90a5f79 100644 --- a/tests/collector-inspection.test.ts +++ b/tests/collector-inspection.test.ts @@ -10,6 +10,7 @@ import { validateCollectorEnrichmentRequest, validateCollectorTextPreparationRequest } from "../src/shared/collector"; +import type { CollectorInspectionResult } from "../src/shared/collector"; describe("collector preparation", () => { it("returns a stable package skeleton without requesting metadata", () => { @@ -89,7 +90,7 @@ describe("collector enrichment", () => { const linkBefore = prepared.packages[0].links[0]; const result = await enrichCollectorPackages( - { packages: prepared.packages }, + { requestId: "request-one", packages: prepared.packages }, defaultSettings(), { checkOneFichier: async () => new Map([[linkBefore.url, { @@ -124,8 +125,8 @@ describe("collector enrichment", () => { return new Map(); }; - const firstRun = enrichCollectorPackages({ packages: first.packages }, defaultSettings(), { checkOneFichier }); - const secondRun = enrichCollectorPackages({ packages: second.packages }, defaultSettings(), { checkOneFichier }); + const firstRun = enrichCollectorPackages({ requestId: "request-first", packages: first.packages }, defaultSettings(), { checkOneFichier }); + const secondRun = enrichCollectorPackages({ requestId: "request-second", packages: second.packages }, defaultSettings(), { checkOneFichier }); await vi.waitFor(() => expect(started).toHaveLength(2)); resolvers.forEach((resolve) => resolve()); await Promise.all([firstRun, secondRun]); @@ -136,9 +137,54 @@ describe("collector enrichment", () => { ]); }); + it("reports finished RapidGator links before the complete enrichment resolves", async () => { + const prepared = prepareCollectorText({ + rawText: [ + "https://rapidgator.net/file/aaaaaaaa/one.bin.html", + "https://rapidgator.net/file/bbbbbbbb/two.bin.html" + ].join("\n"), + addedAt: 7_000 + }); + const resolvers = new Map void>(); + const progress: CollectorInspectionResult[] = []; + let completed = false; + const run = enrichCollectorPackages( + { requestId: "request-progress", packages: prepared.packages }, + defaultSettings(), + { + checkRapidgator: (url) => new Promise((resolve) => resolvers.set(url, resolve)) + }, + (result) => progress.push(result) + ).then((result) => { + completed = true; + return result; + }); + + await vi.waitFor(() => expect(resolvers.size).toBe(2)); + resolvers.get("https://rapidgator.net/file/aaaaaaaa/one.bin.html")?.({ + online: true, + fileName: "one.part01.rar", + fileSizeBytes: 100 + }); + await vi.waitFor(() => expect(progress.length).toBeGreaterThan(0)); + + expect(completed).toBe(false); + expect(progress.flatMap((entry) => entry.packages).flatMap((pkg) => pkg.links)).toEqual([ + expect.objectContaining({ fileName: "one.part01.rar", fileSizeBytes: 100, availability: "online" }) + ]); + + resolvers.get("https://rapidgator.net/file/bbbbbbbb/two.bin.html")?.({ + online: false, + fileName: "two.bin", + fileSizeBytes: 200 + }); + await run; + }); + it("rejects enrichment payloads that do not contain prepared absolute links", () => { - expect(() => validateCollectorEnrichmentRequest({ packages: [] })).toThrow(/ungültig/i); + expect(() => validateCollectorEnrichmentRequest({ requestId: "request-empty", packages: [] })).toThrow(/ungültig/i); expect(() => validateCollectorEnrichmentRequest({ + requestId: "request-invalid", packages: [{ id: "package", name: "Paket", diff --git a/tests/collector-view.test.tsx b/tests/collector-view.test.tsx index cf6e611..78ec4a6 100644 --- a/tests/collector-view.test.tsx +++ b/tests/collector-view.test.tsx @@ -16,9 +16,11 @@ import { CollectorInputDialog, MemoizedCollectorContent, CollectorSidebar, + CollectorSidebarStatus, CollectorToolbar, CollectorView, collectorFileInteractionAttributes, + collectorHeaderScrollStyle, collectorPackageIntrinsicBlockSize, toggleAllCollectorPackageIds, type CollectorViewActions @@ -391,6 +393,7 @@ describe("CollectorView", () => { expect(html).toContain("SBS14HD.part01.rar"); expect(html).toContain("SBS14HD.part02.rar"); expect(html).toContain("2/2 online"); + expect(html).toContain(">Online<"); expect(html).toContain("aria-label=\"SBS14HD einklappen\""); expect(html).not.toContain("URL oder Rohzeile"); expect(html).not.toContain(">Zeile<"); @@ -399,15 +402,50 @@ describe("CollectorView", () => { it("keeps rows and actions available during background analysis", () => { const model = buildCollectorWorkspaceViewModel(packages, "all", "", true, ["link-1"], [], "", true); const html = renderToStaticMarkup(); + const sidebarStatus = renderToStaticMarkup(); const toolbar = CollectorToolbar({ actions: createActions(), model }); - expect(html).toContain("Analyse läuft im Hintergrund"); + expect(html).not.toContain("Analyse läuft im Hintergrund"); + expect(sidebarStatus).toContain("Analyse läuft im Hintergrund"); + expect(sidebarStatus).toContain('role="status"'); expect(html).toContain("SBS14HD.part01.rar"); expect(findButton(toolbar, "Auswahl übergeben (1)").props.disabled).toBe(false); expect(findButton(toolbar, "Alle übergeben (4)").props.disabled).toBe(false); expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false); }); + it("derives status exclusively from availability instead of filename readiness", () => { + const unknownReady = [{ + ...packages[0], + links: [{ ...packages[0].links[0], availability: "unknown" as const, status: "ready" as const }] + }]; + const partial = [{ + ...packages[0], + links: [ + { ...packages[0].links[0], availability: "online" as const }, + { ...packages[0].links[1], availability: "unknown" as const } + ] + }]; + const offlineUnknown = [{ + ...packages[0], + links: [ + { ...packages[0].links[0], availability: "offline" as const }, + { ...packages[0].links[1], availability: "unknown" as const } + ] + }]; + + const unknownHtml = renderToStaticMarkup(); + const partialHtml = renderToStaticMarkup(); + const offlineUnknownHtml = renderToStaticMarkup(); + + expect(unknownHtml).not.toContain(">Bereit<"); + expect(unknownHtml).toContain(">Ungeprüft<"); + expect(partialHtml).toContain(">Teilweise online<"); + expect(partialHtml).toContain("1/2 online"); + expect(offlineUnknownHtml).not.toContain(">Teilweise online<"); + expect(offlineUnknownHtml).toContain(">Ungeprüft<"); + }); + it("renders known hosters as icons with their full name as tooltip", () => { const html = renderToStaticMarkup(); @@ -497,6 +535,12 @@ describe("CollectorView", () => { expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.collector-virtual-spacer\.is-motion-enabled \.collector-virtual-package\s*\{[^}]*transition-duration:\s*300ms, 300ms !important;/s); expect(css).toMatch(/\.collector-package-items-frame\.is-expanding\s*\{[^}]*animation:\s*collector-items-expand/s); expect(css).not.toMatch(/\.collector-package-items-frame\.is-animated\s*\{[^}]*animation:\s*collector-items-expand/s); + expect(css).toMatch(/\.collector-table-header-row > span:nth-child\(2\)\s*\{[^}]*padding-left:\s*48px;/s); + expect(css).toMatch(/\.collector-name-cell\.is-file\s*\{[^}]*padding-left:\s*32px;/s); + expect(css).toMatch(/\.collector-table-header,\s*\.collector-table-body\s*\{[^}]*scrollbar-gutter:\s*stable;/s); + expect(css).toMatch(/\.collector-size-cell,\s*\.collector-hoster-cell,\s*\.collector-status-cell,\s*\.collector-availability-cell,\s*\.collector-added-cell\s*\{[^}]*text-align:\s*center;/s); + expect(css).toMatch(/\.collector-table-header-row > span:nth-child\(n\+3\)\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s); + expect(collectorHeaderScrollStyle(37)).toEqual({ transform: "translateX(-37px)" }); }); it("uses one consistent gap across collector toolbar groups", () => { diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index c04e38a..ea1d7f3 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -62,6 +62,7 @@ export function createVisualElectronApi( prepareCollectorText: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }), prepareCollectorContainers: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }), enrichCollectorPackages: async (request) => ({ packages: clone(request.packages), invalidCount: 0, duplicateCount: 0 }), + onCollectorEnrichmentProgress: () => () => {}, getPathForDroppedFile: () => "", getStartConflicts: async () => [], resolveStartConflict: async (_packageId, policy) => ({