diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 5f3ed1e..69e5a89 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -2,7 +2,8 @@ import path from "node:path"; import os from "node:os"; import v8 from "node:v8"; import { randomUUID } from "node:crypto"; -import { app } from "electron"; +import { app } from "electron"; +import type { ExtractNowRequest } from "../shared/extract-now"; import { AddLinksPayload, AccountCheckScope, @@ -1038,10 +1039,10 @@ export class AppController { this.manager.retryExtraction(packageId); } - public extractNow(packageId: string): void { - this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId }); - this.manager.extractNow(packageId); - } + public extractNow(request: ExtractNowRequest): void { + this.audit("INFO", "Jetzt entpacken ausgelöst", { packageIds: request.packageIds, itemIds: request.itemIds }); + this.manager.extractNow(request); + } public resetPackage(packageId: string): void { this.audit("INFO", "Paket zurückgesetzt", { packageId }); diff --git a/src/main/clipboard-write.ts b/src/main/clipboard-write.ts new file mode 100644 index 0000000..213783e --- /dev/null +++ b/src/main/clipboard-write.ts @@ -0,0 +1,15 @@ +export const CLIPBOARD_WRITE_MAX_BYTES = 1024 * 1024; + +export function validateClipboardWriteText(value: unknown): string { + if (typeof value !== "string") { + throw new Error("text muss ein String sein"); + } + if (!value.trim()) { + throw new Error("text darf nicht leer sein"); + } + const bytes = Buffer.byteLength(value, "utf8"); + if (bytes > CLIPBOARD_WRITE_MAX_BYTES) { + throw new Error(`text ist zu groß (max ${CLIPBOARD_WRITE_MAX_BYTES} Bytes)`); + } + return value; +} diff --git a/src/main/debrid.ts b/src/main/debrid.ts index b561f85..456bbea 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1800,12 +1800,20 @@ async function runWithConcurrency(items: T[], concurrency: number, worker: (i } } -function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { +function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { if (!signal) { return AbortSignal.timeout(timeoutMs); } - return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]); -} + return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]); +} + +function isCallerAbortSignal(signal?: AbortSignal): boolean { + if (!signal?.aborted) { + return false; + } + const reason = signal.reason; + return !(reason && typeof reason === "object" && "name" in reason && reason.name === "TimeoutError"); +} async function readResponseTextLimited(response: Response, maxBytes: number, signal?: AbortSignal): Promise { const body = response.body; @@ -2506,18 +2514,21 @@ class MegaDebridClient { sourceAccountId: account.id, sourceAccountLabel: account.label }; - } catch (error) { - const elapsedMs = Date.now() - testStartedAt; - const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); - // Timeout/abort on THIS account (the shared unrestrict timeout fired). The - // account-wide cooldown exists ONLY to make the retry rotate to another - // account — so it is set only when another usable account actually exists. - // With no rotation target (single account / all others busy), cooling the - // sole account would freeze EVERY queued item while the account is healthy; - // a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so - // we park just this link (mega_debrid_slow_link) and leave the account free - // for other items. A quick user-cancel (below the min run) parks nothing. - if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { + } catch (error) { + const elapsedMs = Date.now() - testStartedAt; + const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); + if (isCallerAbortSignal(signal)) { + traceConversionPhase({ + phase: "mega-account", + provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", + account: rotationLabel, + workMs: elapsedMs, + outcome: "aborted", + detail: abortText + }); + throw error; + } + if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); const otherUsableAccounts = orderedEntries.reduce((count, candidate) => { if (candidate.account.id === account.id) { @@ -3258,10 +3269,13 @@ class DebridLinkClient { sourceAccountId: apiKey.id, sourceAccountLabel: apiKey.label }; - } catch (error) { - const failure = await this.classifyKeyFailure(error, apiKey, link, signal); - const elapsedMs = Date.now() - testStartedAt; - const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); + } catch (error) { + const elapsedMs = Date.now() - testStartedAt; + const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); + if (isCallerAbortSignal(signal)) { + throw error; + } + const failure = await this.classifyKeyFailure(error, apiKey, link, signal); if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); if (ranLongEnough) { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 1a3a784..8d21ebf 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -28,6 +28,7 @@ import { StartConflictResolutionResult, UiSnapshot, DebridAccountStatus } from "../shared/types"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import type { ExtractNowRequest } from "../shared/extract-now"; import { extractHosterFromUrl } from "../shared/hoster"; import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts"; @@ -41,8 +42,9 @@ import { addProviderTotalUsageBytes, addRealDebridAccountDailyUsageBytes, addRealDebridAccountTotalUsageBytes, - getProviderUsageDayKey, - isProviderDailyLimitReached + getProviderUsageDayKey, + isProviderDailyLimitReached, + isRealDebridAccountDailyLimitReached } from "../shared/provider-daily-limits"; import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS, DISK_BUSY_STATUS_THRESHOLD_MS } from "./constants"; import { parseCollectorInput } from "./link-parser"; @@ -1623,7 +1625,7 @@ export function decideAutoRenameBaseName( return { kind: "rename", baseName: targetBaseName, note }; } -const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*1\.rar$/; +const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*\d+\.rar$/; const ARCHIVE_RAR_RE = /^(.*)\.rar$/; const ARCHIVE_ZIP_SPLIT_RE = /^(.*)\.zip\.001$/; const ARCHIVE_7Z_SPLIT_RE = /^(.*)\.7z\.001$/; @@ -1637,22 +1639,15 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download const entryLower = normalizeArchiveMatchName(archiveName).toLowerCase(); const normalizedArchivePath = String(archivePath || "").trim(); - if (normalizedArchivePath) { - const archivePathKey = pathKey(path.join( - path.dirname(path.resolve(normalizedArchivePath)), - normalizeArchiveMatchName(normalizedArchivePath) - )); - const pathMatches = items.filter((item) => { + const candidateItems = normalizedArchivePath + ? items.filter((item) => { const targetPath = String(item.targetPath || "").trim(); if (!targetPath) { - return false; + return true; } - return pathKey(path.join(path.dirname(path.resolve(targetPath)), normalizeArchiveMatchName(targetPath))) === archivePathKey; - }); - if (pathMatches.length > 0) { - return pathMatches; - } - } + return pathKey(path.dirname(path.resolve(targetPath))) === pathKey(path.dirname(path.resolve(normalizedArchivePath))); + }) + : items; const itemBaseName = (item: DownloadItem): string => normalizeArchiveMatchName(item.targetPath || item.fileName || ""); @@ -1692,13 +1687,13 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download } } - if (pattern) { - const matched = items.filter((item) => pattern!.test(itemBaseName(item))); - if (matched.length > 0) return matched; - } - - const exactMatch = items.filter((item) => itemBaseName(item).toLowerCase() === entryLower); - if (exactMatch.length > 0) return exactMatch; + if (pattern) { + const matched = candidateItems.filter((item) => pattern!.test(itemBaseName(item))); + if (matched.length > 0) return matched; + } + + const exactMatch = candidateItems.filter((item) => itemBaseName(item).toLowerCase() === entryLower); + if (exactMatch.length > 0) return exactMatch; const archiveStem = entryLower .replace(/\.part\d+\.rar$/i, "") @@ -1708,22 +1703,63 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download .replace(/\.\d{3}$/i, "") .replace(/\.(zip|7z)$/i, ""); if (archiveStem.length > 3) { - const stemMatch = items.filter((item) => { - const name = itemBaseName(item).toLowerCase(); - return name.startsWith(archiveStem) && /\.(rar|r\d{2,3}|zip|7z|\d{3})$/i.test(name); - }); + const stemMatch = candidateItems.filter((item) => { + const name = itemBaseName(item).toLowerCase(); + return name.startsWith(archiveStem) && /\.(rar|r\d{2,3}|zip|7z|\d{3})$/i.test(name); + }); if (stemMatch.length > 0) return stemMatch; } - if (items.length === 1) { - const singleName = itemBaseName(items[0]).toLowerCase(); - if (/\.(rar|zip|7z|\d{3})$/i.test(singleName)) { - return items; - } - } + if (candidateItems.length === 1) { + const singleName = itemBaseName(candidateItems[0]).toLowerCase(); + if (/\.(rar|zip|7z|\d{3})$/i.test(singleName)) { + return candidateItems; + } + } - return []; -} + return []; +} + +export function resolveSelectedArchiveSetsFromCandidates( + candidatePaths: readonly string[], + items: DownloadItem[], + selectedItemIds: ReadonlySet +): { archivePaths: Set; itemIds: Set } { + const archivePaths = new Set(); + const itemIds = new Set(); + for (const candidatePath of candidatePaths) { + const archiveItems = resolveArchiveItemsFromList(path.basename(candidatePath), items, candidatePath); + if (!archiveItems.some((item) => selectedItemIds.has(item.id))) { + continue; + } + archivePaths.add(candidatePath); + for (const item of archiveItems) { + itemIds.add(item.id); + } + } + return { archivePaths, itemIds }; +} + +export function markPlannedHybridArchiveItemsPending( + items: DownloadItem[], + plannedItemIds: ReadonlySet, + updatedAt: number +): boolean { + let changed = false; + for (const item of items) { + if ( + !plannedItemIds.has(item.id) + || item.status !== "completed" + || item.fullStatus !== "Entpacken - Warten auf Parts" + ) { + continue; + } + item.fullStatus = "Entpacken - Ausstehend"; + item.updatedAt = updatedAt; + changed = true; + } + return changed; +} function stripDuplicateSuffixBeforeExtension(fileName: string): string { return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); @@ -1968,7 +2004,11 @@ export class DownloadManager extends EventEmitter { private hybridExtractedPaths = new Map>(); - private hybridFailedArchives = new Map>(); + private hybridFailedArchives = new Map>(); + + private manualExtractArchiveFilters = new Map>(); + + private manualExtractPackages = new Set(); private autoRecoveredForRedownload = new Set(); @@ -3022,6 +3062,8 @@ export class DownloadManager extends EventEmitter { this.packageHybridPostProcessTasks.delete(packageId); this.hybridExtractRequeue.delete(packageId); + this.manualExtractArchiveFilters.delete(packageId); + this.manualExtractPackages.delete(packageId); this.clearHybridArchiveState(packageId); return tasks; } @@ -3350,7 +3392,9 @@ export class DownloadManager extends EventEmitter { this.packageDeferredPostProcessTasks.clear(); this.packageHybridPostProcessControllers.clear(); this.packageHybridPostProcessTasks.clear(); - this.hybridExtractRequeue.clear(); + this.manualExtractArchiveFilters.clear(); + this.manualExtractPackages.clear(); + this.hybridExtractRequeue.clear(); this.hybridExtractedPaths.clear(); this.hybridFailedArchives.clear(); this.providerFailures.clear(); @@ -6479,10 +6523,9 @@ export class DownloadManager extends EventEmitter { item.lastError = ""; item.resumable = true; item.targetPath = ""; - item.provider = null; - item.fullStatus = "Wartet"; - item.onlineStatus = undefined; - item.updatedAt = nowMs(); + item.provider = null; + item.fullStatus = "Wartet"; + item.updatedAt = nowMs(); } const postProcessTasks = this.abortPackagePostProcessing(packageId, "reset"); @@ -6562,10 +6605,9 @@ export class DownloadManager extends EventEmitter { item.lastError = ""; item.resumable = true; item.targetPath = ""; - item.provider = null; - item.fullStatus = "Wartet"; - item.onlineStatus = undefined; - item.updatedAt = nowMs(); + item.provider = null; + item.fullStatus = "Wartet"; + item.updatedAt = nowMs(); if (this.session.running) { this.runItemIds.add(itemId); @@ -8222,7 +8264,7 @@ export class DownloadManager extends EventEmitter { const candidates = await findArchiveCandidates(pkg.outputDir); for (const candidate of candidates) { - const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), completedItems); + const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), completedItems, candidate); if (archiveItems.length === 0) { continue; } @@ -8266,7 +8308,7 @@ export class DownloadManager extends EventEmitter { private buildHybridArchiveRetryMarker(pkg: PackageEntry, items: DownloadItem[], archiveKey: string): string { const archiveName = path.basename(archiveKey); - const archiveItems = resolveArchiveItemsFromList(archiveName, items) + const archiveItems = resolveArchiveItemsFromList(archiveName, items, archiveKey) .slice() .sort((left, right) => { const leftName = (left.fileName || left.targetPath || left.id || "").toLowerCase(); @@ -8303,7 +8345,7 @@ export class DownloadManager extends EventEmitter { return 0; } - const archiveItems = resolveArchiveItemsFromList(failure.archiveName, items) + const archiveItems = resolveArchiveItemsFromList(failure.archiveName, items, failure.archivePath) .filter((item) => item.status === "completed"); if (archiveItems.length === 0) { logger.warn(`Auto-Recovery (${scope}): Keine completed Items für ${failure.archiveName} gefunden, überspringe`); @@ -8394,23 +8436,23 @@ export class DownloadManager extends EventEmitter { return changed; } - private applyPackageExtractFailureStatuses( - completedItems: DownloadItem[], - resolveArchiveItems: (archiveName: string) => DownloadItem[], - failedArchiveErrors: Map, + private applyPackageExtractFailureStatuses( + completedItems: DownloadItem[], + resolveArchiveItems: (archiveName: string, archivePath?: string) => DownloadItem[], + failedArchiveErrors: Map, fallbackReason: string, previousStatuses: Map, appliedAt = nowMs() ): void { const affectedItemIds = new Set(); - for (const [archiveName, errorText] of failedArchiveErrors.entries()) { - const reason = compactErrorText(errorText || fallbackReason || "Entpacken fehlgeschlagen"); - for (const entry of resolveArchiveItems(archiveName)) { + for (const failure of failedArchiveErrors.values()) { + const reason = compactErrorText(failure.errorText || fallbackReason || "Entpacken fehlgeschlagen"); + for (const entry of resolveArchiveItems(failure.archiveName, failure.archivePath)) { if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) { continue; } - entry.fullStatus = formatExtractFailureLabel(reason, archiveName); + entry.fullStatus = formatExtractFailureLabel(reason, failure.archiveName); entry.updatedAt = appliedAt; affectedItemIds.add(entry.id); } @@ -8888,9 +8930,11 @@ export class DownloadManager extends EventEmitter { // task/controller. After an abort deletes our handle a new run can install // a fresh task+controller for the same packageId; a blind delete here would // orphan that newer task (uncancellable) and allow a duplicate concurrent run. - if (this.packagePostProcessTasks.get(packageId) === handle.task) { - this.packagePostProcessTasks.delete(packageId); - } + if (this.packagePostProcessTasks.get(packageId) === handle.task) { + this.packagePostProcessTasks.delete(packageId); + this.manualExtractArchiveFilters.delete(packageId); + this.manualExtractPackages.delete(packageId); + } if (this.packagePostProcessAbortControllers.get(packageId) === abortController) { this.packagePostProcessAbortControllers.delete(packageId); } @@ -9133,23 +9177,35 @@ export class DownloadManager extends EventEmitter { }); this.beginPackageResultGeneration(packageId, false, true); this.reactivateStandalonePackageResult(packageId); + this.manualExtractPackages.add(packageId); this.persistSoon(); this.emitState(true); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`)); } - public extractNow(packageId: string): void { - const pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled) return; - if (this.packagePostProcessTasks.has(packageId)) return; + private armExtractNowPackage( + packageId: string, + selectedItemIds?: ReadonlySet, + archiveFilter?: ReadonlySet + ): boolean { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled) return false; + if (this.packagePostProcessTasks.has(packageId)) return false; this.clearHybridArchiveState(packageId); if (!pkg.enabled) { pkg.enabled = true; } const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; const completedItems = items.filter((item) => item.status === "completed"); - const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus)); - if (targetItems.length === 0) return; + const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id))); + if (targetItems.length === 0) { + this.manualExtractArchiveFilters.delete(packageId); + this.manualExtractPackages.delete(packageId); + return false; + } + if (archiveFilter) this.manualExtractArchiveFilters.set(packageId, new Set(archiveFilter)); + else this.manualExtractArchiveFilters.delete(packageId); + this.manualExtractPackages.add(packageId); pkg.status = "queued"; pkg.updatedAt = nowMs(); for (const item of targetItems) { @@ -9164,9 +9220,58 @@ export class DownloadManager extends EventEmitter { this.beginPackageResultGeneration(packageId, false, true); this.reactivateStandalonePackageResult(packageId); this.persistSoon(); - this.emitState(true); - void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); - } + this.emitState(true); + void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); + return true; + } + + private async extractNowItems(itemIds: readonly string[], excludedPackageIds: ReadonlySet): Promise { + const selectedByPackage = new Map>(); + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (!item || excludedPackageIds.has(item.packageId)) { + continue; + } + const selected = selectedByPackage.get(item.packageId) || new Set(); + selected.add(itemId); + selectedByPackage.set(item.packageId, selected); + } + for (const [packageId, selectedItemIds] of selectedByPackage) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || this.packagePostProcessTasks.has(packageId)) { + continue; + } + const completedItems = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter((item): item is DownloadItem => Boolean(item && item.status === "completed")); + const candidates = await findArchiveCandidates(pkg.outputDir); + const selection = resolveSelectedArchiveSetsFromCandidates(candidates, completedItems, selectedItemIds); + if (selection.archivePaths.size === 0 || selection.itemIds.size === 0) { + logger.warn(`Jetzt entpacken: Kein vollständiger Archivsatz für ${selectedItemIds.size} ausgewählte Datei(en) in pkg=${pkg.name}`); + continue; + } + this.armExtractNowPackage( + packageId, + selection.itemIds, + new Set([...selection.archivePaths].map((archivePath) => pathKey(archivePath))) + ); + } + } + + public extractNow(target: string | ExtractNowRequest): void { + if (typeof target === "string") { + this.armExtractNowPackage(target); + return; + } + const packageIds = [...new Set(target.packageIds)]; + const packageSet = new Set(packageIds); + for (const packageId of packageIds) { + this.armExtractNowPackage(packageId); + } + void this.extractNowItems(target.itemIds, packageSet).catch((error) => { + logger.warn(`Jetzt entpacken für Dateiauswahl fehlgeschlagen: ${compactErrorText(error)}`); + }); + } private notePackageDownloadStarted(pkg: PackageEntry, startedAt = nowMs()): void { if ((pkg.downloadStartedAt || 0) <= 0) { @@ -9447,7 +9552,7 @@ export class DownloadManager extends EventEmitter { if (effectiveProvider === "realdebrid") { const configuredAccounts = getRealDebridAccounts(this.settings); return configuredAccounts.length > 0 - ? getAvailableRealDebridAccounts(this.settings).length > 0 + ? configuredAccounts.some((account) => account.enabled && !isRealDebridAccountDailyLimitReached(this.settings, account.id)) : Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim()); } if (effectiveProvider === "megadebrid-api") { @@ -13353,7 +13458,7 @@ export class DownloadManager extends EventEmitter { continue; } - const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), packageItems); + const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), packageItems, candidate); if (archiveItems.length === 0) { continue; } @@ -13464,7 +13569,15 @@ export class DownloadManager extends EventEmitter { if (signal?.aborted) return 0; const findReadyStart = nowMs(); - const readyArchives = await this.findReadyArchiveSets(pkg); + const readyArchives = await this.findReadyArchiveSets(pkg); + const manualArchiveFilter = this.manualExtractArchiveFilters.get(packageId); + if (manualArchiveFilter) { + for (const archivePath of [...readyArchives]) { + if (!manualArchiveFilter.has(pathKey(archivePath))) { + readyArchives.delete(archivePath); + } + } + } const findReadyMs = nowMs() - findReadyStart; if (findReadyMs > 200) { logger.info(`findReadyArchiveSets dauerte ${(findReadyMs / 1000).toFixed(1)}s: pkg=${pkg.name}, found=${readyArchives.size}`); @@ -13489,7 +13602,7 @@ export class DownloadManager extends EventEmitter { continue; } - const archiveItems = resolveArchiveItemsFromList(path.basename(archiveKey), completedItems); + const archiveItems = resolveArchiveItemsFromList(path.basename(archiveKey), completedItems, archiveKey); const allItemsStillInError = archiveItems.length > 0 && archiveItems.every((item) => isExtractErrorLabel(item.fullStatus)); const retryMarker = this.buildHybridArchiveRetryMarker(pkg, items, archiveKey); if (!allItemsStillInError || previousFailure.marker !== retryMarker) { @@ -13514,69 +13627,30 @@ export class DownloadManager extends EventEmitter { this.emitState(); const hybridExtractStartMs = nowMs(); - const hybridFileNames = new Set(); - let dirFiles: string[] | undefined; - try { - dirFiles = (await fs.promises.readdir(pkg.outputDir, { withFileTypes: true })) - .filter((entry) => entry.isFile()) - .map((entry) => entry.name); - } catch { } - const archiveStems = new Set(); - for (const archiveKey of readyArchives) { - const parts = collectArchiveCleanupTargets(archiveKey, dirFiles); - for (const part of parts) { - const partName = path.basename(part).toLowerCase(); - hybridFileNames.add(partName); - const stem = partName - .replace(/\.part\d+\.rar$/i, "") - .replace(/\.(rar|r\d{2,3}|zip|z\d{2,3}|7z|tar|gz|bz2|xz|tgz|tbz2|txz|rev)$/i, "") - .replace(/\.(zip|7z)\.\d{3}$/i, "") - .replace(/\.\d{3}$/i, ""); - if (stem && stem !== partName) archiveStems.add(stem); - } - hybridFileNames.add(path.basename(archiveKey).toLowerCase()); - } - if (dirFiles && archiveStems.size > 0) { - for (const fileName of dirFiles) { - const lower = fileName.toLowerCase(); - if (!KNOWN_SMALL_FILE_RE.test(lower)) continue; - const companionStem = lower.replace(/\.[^.]+$/, ""); - if (archiveStems.has(companionStem)) { - hybridFileNames.add(lower); - } - } - } - const isHybridItem = (item: DownloadItem): boolean => { - if (item.targetPath && hybridFileNames.has(path.basename(item.targetPath).toLowerCase())) { - return true; - } - if (item.fileName && hybridFileNames.has(item.fileName.toLowerCase())) { - return true; - } - return false; - }; - const hybridItems = completedItems.filter(isHybridItem); + const plannedHybridItemIds = new Set(); + for (const archiveKey of readyArchives) { + for (const item of resolveArchiveItemsFromList(path.basename(archiveKey), completedItems, archiveKey)) { + plannedHybridItemIds.add(item.id); + } + const cleanupTargetKeys = new Set(collectArchiveCleanupTargets(archiveKey).map((target) => pathKey(target))); + for (const item of completedItems) { + if (item.targetPath && cleanupTargetKeys.has(pathKey(item.targetPath))) { + plannedHybridItemIds.add(item.id); + } + } + } + const hybridItems = completedItems.filter((item) => plannedHybridItemIds.has(item.id)); if (hybridItems.length > 0 && hybridItems.every((item) => isExtractedLabel(item.fullStatus))) { logger.info(`Hybrid-Extract: pkg=${pkg.name}, alle ${hybridItems.length} Items bereits entpackt, überspringe`); return 0; - } - - for (const archiveKey of [...readyArchives]) { - const archiveParts = collectArchiveCleanupTargets(archiveKey, dirFiles); - const archivePartNames = new Set(); - archivePartNames.add(path.basename(archiveKey).toLowerCase()); - for (const part of archiveParts) { - archivePartNames.add(path.basename(part).toLowerCase()); - } - const archiveItems = completedItems.filter((item) => { - const targetName = item.targetPath ? path.basename(item.targetPath).toLowerCase() : ""; - const fileName = (item.fileName || "").toLowerCase(); - return archivePartNames.has(targetName) || archivePartNames.has(fileName); - }); - if (archiveItems.length > 0 && archiveItems.every((item) => isExtractedLabel(item.fullStatus))) { - readyArchives.delete(archiveKey); - } + } + + for (const archiveKey of [...readyArchives]) { + const archiveItems = resolveArchiveItemsFromList(path.basename(archiveKey), completedItems, archiveKey); + if (archiveItems.length > 0 && archiveItems.every((item) => isExtractedLabel(item.fullStatus))) { + readyArchives.delete(archiveKey); + } } if (readyArchives.size === 0) { logger.info(`Hybrid-Extract: pkg=${pkg.name}, alle fertigen Archive bereits entpackt`); @@ -13586,11 +13660,9 @@ export class DownloadManager extends EventEmitter { const resolveArchiveItems = (archiveName: string, archivePath = ""): DownloadItem[] => resolveArchiveItemsFromList(archiveName, items, archivePath); - const readyArchiveKeyByName = new Map(); - const readyArchiveMarkers = new Map(); - for (const archiveKey of readyArchives) { - readyArchiveKeyByName.set(path.basename(archiveKey).toLowerCase(), archiveKey); - readyArchiveMarkers.set(archiveKey, this.buildHybridArchiveRetryMarker(pkg, items, archiveKey)); + const readyArchiveMarkers = new Map(); + for (const archiveKey of readyArchives) { + readyArchiveMarkers.set(archiveKey, this.buildHybridArchiveRetryMarker(pkg, items, archiveKey)); } const autoRecoveredArchives = new Set(); @@ -13601,8 +13673,7 @@ export class DownloadManager extends EventEmitter { let hybridLastEmitAt = 0; let hybridLastProgressCurrent: number | null = null; - const allDownloaded = completedItems.length >= items.length; - let labelsChanged = false; + let labelsChanged = false; for (const entry of completedItems) { if (isExtractedLabel(entry.fullStatus)) { continue; @@ -13610,9 +13681,7 @@ export class DownloadManager extends EventEmitter { if (isExtractErrorLabel(entry.fullStatus)) { continue; } - const belongsToReady = allDownloaded - || hybridFileNames.has((entry.fileName || "").toLowerCase()) - || (entry.targetPath && hybridFileNames.has(path.basename(entry.targetPath).toLowerCase())); + const belongsToReady = plannedHybridItemIds.has(entry.id); const targetLabel = belongsToReady ? "Entpacken - Ausstehend" : "Entpacken - Warten auf Parts"; if (entry.fullStatus !== targetLabel) { entry.fullStatus = targetLabel; @@ -13648,18 +13717,18 @@ export class DownloadManager extends EventEmitter { onLog: (level, message) => this.logExtractionForItems(pkg, items, "Hybrid-Extractor", level, message), onOutput: (event) => scope.add(event), onArchiveFailure: (failure) => { - failedArchiveCategories.set(String(failure.archiveName || "").toLowerCase(), failure.category); - const failedArchiveKey = readyArchiveKeyByName.get(String(failure.archiveName || "").toLowerCase()); - if (failedArchiveKey) { - failedArchiveErrors.set(failedArchiveKey, failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen"); - } - if (autoRecoveredArchives.has(failure.archiveName)) { - return; - } - const changed = this.autoRecoverArchiveCrcFailure(pkg, items, failure, "hybrid"); - if (changed > 0) { - autoRecoveredArchives.add(failure.archiveName); - } + const failedArchiveKey = pathKey(failure.archivePath); + failedArchiveCategories.set(failedArchiveKey, failure.category); + if (failedArchiveKey) { + failedArchiveErrors.set(failedArchiveKey, failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen"); + } + if (autoRecoveredArchives.has(failedArchiveKey)) { + return; + } + const changed = this.autoRecoverArchiveCrcFailure(pkg, items, failure, "hybrid"); + if (changed > 0) { + autoRecoveredArchives.add(failedArchiveKey); + } }, onProgress: (progress) => { if (progress.phase === "preparing") { @@ -13677,13 +13746,14 @@ export class DownloadManager extends EventEmitter { const currentCount = Math.max(0, Number(progress.current ?? 0)); const archiveFinished = progress.archiveDone === true || (hybridLastProgressCurrent !== null && currentCount > hybridLastProgressCurrent); - hybridLastProgressCurrent = currentCount; - - if (progress.archiveName) { - if (!hybridResolvedItems.has(progress.archiveName)) { + hybridLastProgressCurrent = currentCount; + + if (progress.archiveName) { + const progressKey = pathKey(progress.archivePath || progress.archiveName); + if (!hybridResolvedItems.has(progressKey)) { const resolved = resolveArchiveItems(progress.archiveName, progress.archivePath); - hybridResolvedItems.set(progress.archiveName, resolved); - hybridStartTimes.set(progress.archiveName, nowMs()); + hybridResolvedItems.set(progressKey, resolved); + hybridStartTimes.set(progressKey, nowMs()); if (resolved.length === 0) { logger.warn(`resolveArchiveItems (hybrid): KEINE Items gefunden für archiveName="${progress.archiveName}", items.length=${items.length}, itemNames=[${items.map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`); } else { @@ -13703,15 +13773,15 @@ export class DownloadManager extends EventEmitter { this.emitState(true); } } - const archItems = hybridResolvedItems.get(progress.archiveName) || []; + const archItems = hybridResolvedItems.get(progressKey) || []; if (archiveFinished) { const doneAt = nowMs(); - const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt; + const startedAt = hybridStartTimes.get(progressKey) || doneAt; const doneLabel = progress.archiveSuccess === false ? "Entpacken - Error" : formatExtractDone(doneAt - startedAt); - const archiveKey = readyArchiveKeyByName.get(progress.archiveName.toLowerCase()); + const archiveKey = readyArchives.has(progressKey) ? progressKey : undefined; if (archiveKey && progress.archiveSuccess !== false) { this.clearHybridArchiveState(packageId, archiveKey); } @@ -13719,15 +13789,15 @@ export class DownloadManager extends EventEmitter { pkg, progress, archItems, - failedArchiveCategories.get(progress.archiveName.toLowerCase()) || "" + failedArchiveCategories.get(progressKey) || "" ); for (const entry of archItems) { if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; entry.fullStatus = doneLabel; entry.updatedAt = doneAt; } - hybridResolvedItems.delete(progress.archiveName); - hybridStartTimes.delete(progress.archiveName); + hybridResolvedItems.delete(progressKey); + hybridStartTimes.delete(progressKey); const done = currentCount; if (done < progress.total) { pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Nächstes Archiv...`; @@ -13777,17 +13847,12 @@ export class DownloadManager extends EventEmitter { pkg.postProcessLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})`; } - const now = nowMs(); - if (now - hybridLastEmitAt >= EXTRACT_PROGRESS_EMIT_INTERVAL_MS) { - hybridLastEmitAt = now; - for (const entry of items) { - if (entry.status === "completed" && entry.fullStatus === "Entpacken - Warten auf Parts") { - entry.fullStatus = "Entpacken - Ausstehend"; - entry.updatedAt = now; - } - } - this.emitState(); - } + const now = nowMs(); + if (now - hybridLastEmitAt >= EXTRACT_PROGRESS_EMIT_INTERVAL_MS) { + hybridLastEmitAt = now; + markPlannedHybridArchiveItemsPending(items, plannedHybridItemIds, now); + this.emitState(); + } } })); @@ -14043,7 +14108,9 @@ export class DownloadManager extends EventEmitter { recoveryMs }); - const allDone = this.areAllPackageItemRefsFinished(pkg); + const allDone = this.areAllPackageItemRefsFinished(pkg); + const manualExtraction = this.manualExtractPackages.has(packageId); + const shouldExtract = this.settings.autoExtract || manualExtraction; if (!allDone && success + failed + cancelled >= items.length) { logger.warn( `Post-Processing wartet trotz gefiltert fertiger Items: ` + @@ -14052,7 +14119,7 @@ export class DownloadManager extends EventEmitter { ); } - if (!allDone && this.settings.hybridExtract && this.settings.autoExtract && failed === 0 && success > 0) { + if (!allDone && this.settings.hybridExtract && shouldExtract && failed === 0 && success > 0) { pkg.postProcessLabel = "Entpacken vorbereiten..."; this.emitState(); const hybridExtracted = await this.runHybridExtraction(packageId, pkg, items, signal); @@ -14091,7 +14158,7 @@ export class DownloadManager extends EventEmitter { const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus)); let extractedCount = 0; - if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) { + if (shouldExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) { pkg.postProcessLabel = "Entpacken vorbereiten..."; pkg.status = "extracting"; this.emitState(); @@ -14132,7 +14199,7 @@ export class DownloadManager extends EventEmitter { signal.addEventListener("abort", onParentAbort, { once: true }); } } - const extractDeadline = setTimeout(() => { + const extractDeadline = setTimeout(() => { if (signal?.aborted || extractAbortController.signal.aborted) { return; } @@ -14140,11 +14207,12 @@ export class DownloadManager extends EventEmitter { logger.error(`Post-Processing Extraction Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s: pkg=${pkg.name}`); if (!extractAbortController.signal.aborted) { extractAbortController.abort("extract_timeout"); - } - }, extractTimeoutMs); - try { + } + }, extractTimeoutMs); + let fullExtractionItems = completedItems; + try { const autoRecoveredArchives = new Set(); - const fullFailedArchiveErrors = new Map(); + const fullFailedArchiveErrors = new Map(); const fullFailedArchiveCategories = new Map(); const fullResolvedItems = new Map(); const fullStartTimes = new Map(); @@ -14160,14 +14228,25 @@ export class DownloadManager extends EventEmitter { throw new Error(String(extractAbortController.signal.reason || "aborted:extract")); } - const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems); + const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems); + const manualArchiveFilter = this.manualExtractArchiveFilters.get(packageId); + if (manualArchiveFilter) { + for (const archivePath of [...fullArchiveSet]) { + if (!manualArchiveFilter.has(pathKey(archivePath))) { + fullArchiveSet.delete(archivePath); + } + } + } const fullExtractItemIds = new Set(); for (const archivePath of fullArchiveSet) { - const archiveItems = resolveArchiveItems(path.basename(archivePath)); + const archiveItems = resolveArchiveItems(path.basename(archivePath), archivePath); for (const entry of archiveItems) { fullExtractItemIds.add(entry.id); } } + fullExtractionItems = manualArchiveFilter + ? completedItems.filter((entry) => fullExtractItemIds.has(entry.id)) + : completedItems; const pendingAt = nowMs(); for (const entry of completedItems) { if (!fullExtractItemIds.has(entry.id) || isExtractedLabel(entry.fullStatus)) { @@ -14197,21 +14276,26 @@ export class DownloadManager extends EventEmitter { onLog: (level, message) => this.logExtractionForItems(pkg, completedItems, "Extractor", level, message), onOutput: (event) => scope.add(event), onArchiveFailure: (failure) => { - fullFailedArchiveCategories.set(failure.archiveName.toLowerCase(), failure.category); - if (autoRecoveredArchives.has(failure.archiveName)) { - return; - } - const changed = this.autoRecoverArchiveCrcFailure(pkg, completedItems, failure, "full"); - if (changed > 0) { - autoRecoveredArchives.add(failure.archiveName); - fullFailedArchiveErrors.delete(failure.archiveName); - fullFailedArchiveCategories.delete(failure.archiveName.toLowerCase()); + const failureKey = pathKey(failure.archivePath); + fullFailedArchiveCategories.set(failureKey, failure.category); + if (autoRecoveredArchives.has(failureKey)) { return; - } - fullFailedArchiveErrors.set( - failure.archiveName, - failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen" - ); + } + const changed = this.autoRecoverArchiveCrcFailure(pkg, completedItems, failure, "full"); + if (changed > 0) { + autoRecoveredArchives.add(failureKey); + fullFailedArchiveErrors.delete(failureKey); + fullFailedArchiveCategories.delete(failureKey); + return; + } + fullFailedArchiveErrors.set( + failureKey, + { + archiveName: failure.archiveName, + archivePath: failure.archivePath, + errorText: failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen" + } + ); }, onProgress: (progress) => { if (progress.phase === "preparing") { @@ -14230,13 +14314,14 @@ export class DownloadManager extends EventEmitter { const currentCount = Math.max(0, Number(progress.current ?? 0)); const archiveFinished = progress.archiveDone === true || (fullLastProgressCurrent !== null && currentCount > fullLastProgressCurrent); - fullLastProgressCurrent = currentCount; - - if (progress.archiveName) { - if (!fullResolvedItems.has(progress.archiveName)) { + fullLastProgressCurrent = currentCount; + + if (progress.archiveName) { + const progressKey = pathKey(progress.archivePath || progress.archiveName); + if (!fullResolvedItems.has(progressKey)) { const resolved = resolveArchiveItems(progress.archiveName, progress.archivePath); - fullResolvedItems.set(progress.archiveName, resolved); - fullStartTimes.set(progress.archiveName, nowMs()); + fullResolvedItems.set(progressKey, resolved); + fullStartTimes.set(progressKey, nowMs()); if (resolved.length === 0) { logger.warn(`resolveArchiveItems (full): KEINE Items für archiveName="${progress.archiveName}", completedItems=${completedItems.length}, names=[${completedItems.map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`); } else { @@ -14251,11 +14336,11 @@ export class DownloadManager extends EventEmitter { emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true); } } - const archiveItems = fullResolvedItems.get(progress.archiveName) || []; + const archiveItems = fullResolvedItems.get(progressKey) || []; if (archiveFinished) { const doneAt = nowMs(); - const startedAt = fullStartTimes.get(progress.archiveName) || doneAt; + const startedAt = fullStartTimes.get(progressKey) || doneAt; const doneLabel = progress.archiveSuccess === false ? "Entpacken - Error" : formatExtractDone(doneAt - startedAt); @@ -14263,15 +14348,15 @@ export class DownloadManager extends EventEmitter { pkg, progress, archiveItems, - fullFailedArchiveCategories.get(progress.archiveName.toLowerCase()) || "" + fullFailedArchiveCategories.get(pathKey(progress.archivePath || progress.archiveName)) || "" ); for (const entry of archiveItems) { if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; entry.fullStatus = doneLabel; entry.updatedAt = doneAt; } - fullResolvedItems.delete(progress.archiveName); - fullStartTimes.delete(progress.archiveName); + fullResolvedItems.delete(progressKey); + fullStartTimes.delete(progressKey); const done = currentCount; if (done < progress.total) { emitExtractStatus(`Entpacken (${done}/${progress.total}) - Nächstes Archiv...`, true); @@ -14328,7 +14413,7 @@ export class DownloadManager extends EventEmitter { this.diskWaitEvents = [{ ...error.event, packageId }]; const retryAt = error.event.retryAt; this.packageDiskRetryAfterByPackage.set(packageId, retryAt); - for (const entry of completedItems) { + for (const entry of fullExtractionItems) { entry.fullStatus = "Warte auf Festplatte"; entry.lastError = "Zu wenig Speicherplatz"; entry.updatedAt = nowMs(); @@ -14363,18 +14448,18 @@ export class DownloadManager extends EventEmitter { const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen"); const failAt = nowMs(); if (fullFailedArchiveErrors.size > 0) { - const archiveSummaries = [...fullFailedArchiveErrors.entries()] - .slice(0, 3) - .map(([archiveName, errorText]) => `${archiveName}: ${summarizeExtractFailureReason(errorText)}`) + const archiveSummaries = [...fullFailedArchiveErrors.values()] + .slice(0, 3) + .map((failure) => `${failure.archiveName}: ${summarizeExtractFailureReason(failure.errorText)}`) .join(" | "); logger.warn(`Post-Processing Entpacken Fehlerdetails: pkg=${pkg.name}, archives=${archiveSummaries}`); this.logPackageForPackage(pkg, "WARN", "Post-Processing Entpacken Fehlerdetails", { - failedArchives: [...fullFailedArchiveErrors.keys()], + failedArchives: [...fullFailedArchiveErrors.values()].map((failure) => failure.archivePath), summary: archiveSummaries }); } this.applyPackageExtractFailureStatuses( - completedItems, + fullExtractionItems, resolveArchiveItems, fullFailedArchiveErrors, reason, @@ -14383,8 +14468,9 @@ export class DownloadManager extends EventEmitter { ); pkg.status = "failed"; } else { - const hasExtractedOutput = this.getPackageOutputScope(pkg).completeFiles() - .some((filePath) => isPathInsideDir(filePath, pkg.extractDir)); + const hasExtractedOutput = this.getPackageOutputScope(pkg).records() + .some((record) => isPathInsideDir(record.outputPath, pkg.extractDir) + && (!manualArchiveFilter || fullArchiveSet.has(pathKey(record.archivePath)))); const sourceExists = await this.existsAsync(pkg.outputDir); let finalStatusText = ""; @@ -14398,13 +14484,19 @@ export class DownloadManager extends EventEmitter { } const finalAt = nowMs(); - for (const entry of completedItems) { - if (!isExtractedLabel(entry.fullStatus)) { + for (const entry of fullExtractionItems) { + if (!isExtractedLabel(entry.fullStatus)) { entry.fullStatus = finalStatusText; entry.updatedAt = finalAt; } } - pkg.status = "completed"; + if (manualArchiveFilter) { + const hasRemainingExtractError = completedItems.some((entry) => isExtractErrorLabel(entry.fullStatus || "")); + const hasRemainingExtractWork = completedItems.some((entry) => !isExtractedLabel(entry.fullStatus || "") && /^Entpack/i.test(entry.fullStatus || "")); + pkg.status = hasRemainingExtractError ? "failed" : hasRemainingExtractWork ? "queued" : "completed"; + } else { + pkg.status = "completed"; + } } } catch (error) { const reasonRaw = String(error || ""); @@ -14414,8 +14506,8 @@ export class DownloadManager extends EventEmitter { if (timedOut) { const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`; logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`); - for (const entry of completedItems) { - if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { + for (const entry of fullExtractionItems) { + if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { entry.fullStatus = formatExtractFailureLabel(timeoutReason); entry.updatedAt = nowMs(); } @@ -14424,8 +14516,8 @@ export class DownloadManager extends EventEmitter { pkg.updatedAt = nowMs(); timeoutHandled = true; } else { - for (const entry of completedItems) { - if (/^Entpacken/i.test(entry.fullStatus || "") || /^Passwort/i.test(entry.fullStatus || "")) { + for (const entry of fullExtractionItems) { + if (/^Entpacken/i.test(entry.fullStatus || "") || /^Passwort/i.test(entry.fullStatus || "")) { entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; entry.updatedAt = nowMs(); } @@ -14439,8 +14531,8 @@ export class DownloadManager extends EventEmitter { if (!timeoutHandled) { const reason = compactErrorText(error); logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); - for (const entry of completedItems) { - if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { + for (const entry of fullExtractionItems) { + if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { entry.fullStatus = formatExtractFailureLabel(reason); entry.updatedAt = nowMs(); } @@ -14481,7 +14573,15 @@ export class DownloadManager extends EventEmitter { alreadyMarkedExtracted }); - void this.runDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount); + void this.runDeferredPostExtraction( + packageId, + pkg, + success, + failed, + alreadyMarkedExtracted, + extractedCount, + manualExtraction + ); } private runDeferredPostExtraction( @@ -14490,10 +14590,11 @@ export class DownloadManager extends EventEmitter { success: number, failed: number, alreadyMarkedExtracted: boolean, - extractedCount: number + extractedCount: number, + manualSelection = false ): Promise { this.trackPackagePostProcessResult(packageId); - const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount) + const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount, manualSelection) .finally(() => { const tasks = this.packageDeferredPostProcessTasks.get(packageId); tasks?.delete(task); @@ -14516,7 +14617,8 @@ export class DownloadManager extends EventEmitter { success: number, failed: number, alreadyMarkedExtracted: boolean, - extractedCount: number + extractedCount: number, + manualSelection: boolean ): Promise { const replacedController = this.packageDeferredPostProcessAbortControllers.get(packageId); if (replacedController && !replacedController.signal.aborted) { @@ -14537,7 +14639,7 @@ export class DownloadManager extends EventEmitter { try { throwIfAborted(); - if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.autoExtract) { + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && (this.settings.autoExtract || manualSelection)) { const nestedBlacklist = /\.(iso|img|bin|dmg|vhd|vhdx|vmdk|wim)$/i; const nestedCandidates = outputScope.archiveFiles() .filter((candidate) => isPathInsideDir(candidate, pkg.extractDir) && !nestedBlacklist.test(candidate)); @@ -14604,7 +14706,7 @@ export class DownloadManager extends EventEmitter { } } - if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none") { + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none" && !manualSelection) { pkg.postProcessLabel = "Aufräumen..."; this.emitState(); throwIfAborted(); @@ -14639,7 +14741,7 @@ export class DownloadManager extends EventEmitter { } } - if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0) { + if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && !manualSelection) { throwIfAborted(); await clearExtractResumeState(pkg.outputDir, packageId); await clearExtractResumeState(pkg.outputDir); diff --git a/src/main/extractor.ts b/src/main/extractor.ts index c0e90ee..49671bd 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -83,9 +83,10 @@ export interface ExtractProgressUpdate { archiveSuccess?: boolean; } -export interface ExtractArchiveFailureInfo { - archiveName: string; - errorText: string; +export interface ExtractArchiveFailureInfo { + archiveName: string; + archivePath: string; + errorText: string; category: ExtractErrorCategory; suggestRedownload: boolean; jvmFailureReason?: string; @@ -4193,9 +4194,10 @@ export async function extractPackageArchives(options: ExtractOptions): Promise([ "realdebrid", @@ -645,10 +646,9 @@ function registerIpcHandlers(): void { validateString(packageId, "packageId"); return controller.retryExtraction(packageId); }); - handleTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, packageId: string) => { - validateString(packageId, "packageId"); - return controller.extractNow(packageId); - }); + handleTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, request: unknown) => { + return controller.extractNow(normalizeExtractNowRequest(request)); + }); handleTrusted(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.resetPackage(packageId); @@ -713,16 +713,10 @@ function registerIpcHandlers(): void { updateClipboardWatcher(); return next; }); - handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, rawText: unknown) => { - const text = validateString(rawText, "text"); - const bytes = Buffer.byteLength(text, "utf8"); - if (!text.trim()) { - throw new Error("text darf nicht leer sein"); - } - if (bytes > CLIPBOARD_WRITE_MAX_BYTES) { - throw new Error(`text ist zu groß (max ${CLIPBOARD_WRITE_MAX_BYTES} Bytes)`); - } - try { + handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, rawText: unknown) => { + const text = validateClipboardWriteText(rawText); + const bytes = Buffer.byteLength(text, "utf8"); + try { clipboard.writeText(text); return true; } catch (error) { diff --git a/src/main/mega-web-fallback.ts b/src/main/mega-web-fallback.ts index 92f06de..956b2a1 100644 --- a/src/main/mega-web-fallback.ts +++ b/src/main/mega-web-fallback.ts @@ -177,52 +177,31 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise }); } -async function raceWithAbort(promise: Promise, signal?: AbortSignal, abortErrorFactory: () => Error = abortError): Promise { - if (!signal) { - return promise; - } - if (signal.aborted) { - throw abortErrorFactory(); - } - - return new Promise((resolve, reject) => { - let settled = false; - - const onAbort = (): void => { - if (settled) { - return; - } - settled = true; - signal.removeEventListener("abort", onAbort); - reject(abortErrorFactory()); - }; - - signal.addEventListener("abort", onAbort, { once: true }); - - promise.then((value) => { - if (settled) { - return; - } - settled = true; - signal.removeEventListener("abort", onAbort); - resolve(value); - }, (error) => { - if (settled) { - return; - } - settled = true; - signal.removeEventListener("abort", onAbort); - reject(error); - }); - }); -} +type MegaWebQueueTask = { + run: (canMutate: () => boolean) => Promise; + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; + signal?: AbortSignal; + queuedAt: number; + workStartedAt: number | null; + owner: symbol; + settled: boolean; + onAbort: () => void; +}; + +type MegaWebQueueState = { + active: MegaWebQueueTask | null; + pending: MegaWebQueueTask[]; +}; export class MegaWebFallback { // Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen // seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene // Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts // gleichzeitig umgewandelt werden statt global eine nach der anderen. - private queues = new Map>(); + private queues = new Map(); + + private activeQueueOwners = new Map(); private getCredentials: () => MegaCredentials; @@ -248,14 +227,16 @@ export class MegaWebFallback { } const key = creds.login.trim().toLowerCase(); const sessionGeneration = this.sessionGeneration; - return this.runExclusive(async () => { + return this.runExclusive(async (canMutate) => { throwIfAborted(overallSignal); - let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration); + let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration, canMutate); let generated = await this.generate(link, cookie, overallSignal); if (!generated) { - this.sessions.delete(key); - cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration); + if (canMutate()) { + this.sessions.delete(key); + } + cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration, canMutate); generated = await this.generate(link, cookie, overallSignal); if (!generated) { return null; @@ -275,14 +256,15 @@ export class MegaWebFallback { login: string, password: string, signal?: AbortSignal, - generation = this.sessionGeneration + generation = this.sessionGeneration, + canMutate: () => boolean = () => true ): Promise { const existing = this.sessions.get(key); if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) { return existing.cookie; } const cookie = await this.login(login, password, signal); - if (generation === this.sessionGeneration) { + if (generation === this.sessionGeneration && canMutate()) { this.sessions.set(key, { cookie, setAt: Date.now() }); } return cookie; @@ -293,36 +275,119 @@ export class MegaWebFallback { this.sessions.clear(); } - private async runExclusive(job: () => Promise, key: string, signal?: AbortSignal): Promise { - const queuedAt = Date.now(); - const QUEUE_WAIT_TIMEOUT_MS = 90000; - let workStarted = false; - const guardedJob = async (): Promise => { - throwIfAborted(signal); - const waited = Date.now() - queuedAt; - if (waited > QUEUE_WAIT_TIMEOUT_MS) { - traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` }); - throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); - } - workStarted = true; - const workStartedAt = Date.now(); - try { - const result = await job(); - traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "ok" }); - return result; - } catch (jobError) { - traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) }); - throw jobError; - } - }; - const prev = this.queues.get(key) ?? Promise.resolve(); - const run = prev.then(guardedJob, guardedJob); - this.queues.set(key, run.then(() => undefined, () => undefined)); - return raceWithAbort(run, signal, () => - workStarted - ? abortError() - : new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor((Date.now() - queuedAt) / 1000)}s Wartezeit, Account war belegt)`) - ); + private runExclusive(job: (canMutate: () => boolean) => Promise, key: string, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const state = this.queues.get(key) ?? { active: null, pending: [] }; + let task: MegaWebQueueTask; + task = { + run: job, + resolve: (value: unknown) => resolve(value as T), + reject, + signal, + queuedAt: Date.now(), + workStartedAt: null, + owner: Symbol(key), + settled: false, + onAbort: () => this.abortQueueTask(key, state, task) + }; + state.pending.push(task); + this.queues.set(key, state); + if (signal?.aborted) { + task.onAbort(); + return; + } + signal?.addEventListener("abort", task.onAbort, { once: true }); + this.startNextQueueTask(key, state); + }); + } + + private startNextQueueTask(key: string, state: MegaWebQueueState): void { + if (state.active) { + return; + } + const task = state.pending.shift(); + if (!task) { + if (this.queues.get(key) === state) { + this.queues.delete(key); + } + return; + } + if (task.settled) { + this.startNextQueueTask(key, state); + return; + } + const waited = Date.now() - task.queuedAt; + if (waited > 90000) { + traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` }); + this.settleQueueTask(task, undefined, new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`)); + this.startNextQueueTask(key, state); + return; + } + if (task.signal?.aborted) { + this.settleQueueTask(task, undefined, new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor(waited / 1000)}s Wartezeit, Account war belegt)`)); + this.startNextQueueTask(key, state); + return; + } + state.active = task; + task.workStartedAt = Date.now(); + this.activeQueueOwners.set(key, task.owner); + const canMutate = (): boolean => state.active === task && this.activeQueueOwners.get(key) === task.owner; + void task.run(canMutate).then((result) => { + traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - (task.workStartedAt ?? Date.now()), outcome: "ok" }); + this.settleQueueTask(task, result); + }, (error) => { + traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - (task.workStartedAt ?? Date.now()), outcome: "error", detail: compactErrorText(error).slice(0, 100) }); + this.settleQueueTask(task, undefined, error); + }).finally(() => { + if (state.active === task) { + state.active = null; + if (this.activeQueueOwners.get(key) === task.owner) { + this.activeQueueOwners.delete(key); + } + this.startNextQueueTask(key, state); + } + }); + } + + private abortQueueTask(key: string, state: MegaWebQueueState, task: MegaWebQueueTask): void { + if (task.settled) { + return; + } + const waited = Date.now() - task.queuedAt; + const wasActive = state.active === task; + if (!wasActive) { + const index = state.pending.indexOf(task); + if (index >= 0) { + state.pending.splice(index, 1); + } + } + this.settleQueueTask( + task, + undefined, + wasActive + ? abortError() + : new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor(waited / 1000)}s Wartezeit, Account war belegt)`) + ); + if (wasActive) { + state.active = null; + if (this.activeQueueOwners.get(key) === task.owner) { + this.activeQueueOwners.delete(key); + } + } + this.startNextQueueTask(key, state); + } + + private settleQueueTask(task: MegaWebQueueTask, value?: unknown, error?: unknown): void { + if (task.settled) { + return; + } + task.settled = true; + task.signal?.removeEventListener("abort", task.onAbort); + if (error !== undefined) { + task.reject(error); + } else { + task.resolve(value); + } } private async login(login: string, password: string, signal?: AbortSignal): Promise { @@ -460,9 +525,9 @@ export class MegaWebFallback { return null; } - public dispose(): void { - this.sessions.clear(); - } + public dispose(): void { + this.sessions.clear(); + } } export function compactMegaWebError(error: unknown): string { diff --git a/src/main/package-telemetry.ts b/src/main/package-telemetry.ts index 610cd05..0c26298 100644 --- a/src/main/package-telemetry.ts +++ b/src/main/package-telemetry.ts @@ -97,6 +97,7 @@ function getFailure( remuxOperations: readonly RemuxOperationMetric[], remuxFallbackFailures: number, archiveOperations: readonly ArchiveOperationMetric[], + extractionErrors: readonly string[], downloadErrors: readonly string[] ): { failurePhase: FailurePhase; errorCategory: string } { if (cleanupErrorCategory) { @@ -107,8 +108,8 @@ function getFailure( return { failurePhase: "remux", errorCategory: projectPackageFailureCategory("remux", failedRemux?.errorCategory) }; } const failedArchive = archiveOperations.find((operation) => operation.status === "failed"); - if (failedArchive) { - return { failurePhase: "extract", errorCategory: projectPackageFailureCategory("extract", failedArchive.errorCategory) }; + if (failedArchive || extractionErrors.length > 0) { + return { failurePhase: "extract", errorCategory: projectPackageFailureCategory("extract", failedArchive?.errorCategory || extractionErrors[0]) }; } const downloadError = downloadErrors.find(Boolean); if (downloadErrors.length > 0) { @@ -126,8 +127,13 @@ export function finalizePackageResult(telemetry: PackageTelemetry): PackageResul const cleanedCompletedDownloads = Math.max(0, Math.floor(finiteNonNegative(packageEntry.cleanedCompletedItemCount))); const completedDownloads = cleanedCompletedDownloads + telemetry.items.filter((item) => item.status === "completed").length; const failedDownloads = telemetry.items.filter((item) => item.status === "failed"); + const itemExtractionFailures = telemetry.items.filter((item) => item.status === "completed" && /^(?:Entpack-Fehler|Entpacken\s*-\s*(?:Fehler|Error))/i.test(item.fullStatus || "")); + const itemExtractionFailureCount = new Set(itemExtractionFailures.map((item) => { + const archiveName = String(item.fullStatus || "").match(/^Entpack-Fehler\s*\[([^\]]+)\]/i)?.[1]; + return archiveName?.toLocaleLowerCase("de-DE") || String(item.fullStatus || "").toLocaleLowerCase("de-DE") || item.id; + })).size; const cancelledDownloads = telemetry.items.filter((item) => item.status === "cancelled").length; - const failedArchives = archiveOperations.filter((operation) => operation.status === "failed").length; + const failedArchives = Math.max(archiveOperations.filter((operation) => operation.status === "failed").length, itemExtractionFailureCount); const cancelledArchives = archiveOperations.filter((operation) => operation.status === "cancelled").length; const failedRemuxOperations = remuxOperations.filter((operation) => operation.status === "failed").length; const cancelledRemuxOperations = remuxOperations.filter((operation) => operation.status === "cancelled").length; @@ -163,6 +169,7 @@ export function finalizePackageResult(telemetry: PackageTelemetry): PackageResul remuxOperations, audioStripFailures, archiveOperations, + itemExtractionFailures.map((item) => item.lastError || item.fullStatus), failedDownloads.map((item) => item.lastError || item.fullStatus) ); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 145c5d0..bff8097 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer, webUtils } from "electron"; +import type { ExtractNowRequest } from "../shared/extract-now"; import { AddLinksPayload, AccountCheckScope, @@ -118,7 +119,7 @@ const api: ElectronApi = { revealAccountSecret: (input: AccountSecretRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, input), getArchivePasswordList: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST), retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), - extractNow: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), + extractNow: (request: ExtractNowRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, request), resetPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), getHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void): (() => void) => { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 89f991b..5a30afc 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -63,6 +63,7 @@ import { BackupPassphraseDialog } from "./ui/BackupPassphraseDialog"; import { Dialog } from "./ui/Dialog"; import { Icon } from "./ui/Icon"; import { Toast } from "./ui/Toast"; +import { LinkAddressesDialog } from "./ui/LinkAddressesDialog"; import { buildCollectorTransferPackages, buildCollectorWorkspaceViewModel, @@ -106,6 +107,7 @@ import { import { buildDownloadsViewModel, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, getRemainingDownloadBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable"; import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog"; +import { buildExtractNowContextAction } from "./views/downloads/extract-action"; import { beginDownloadColumnDrag, clearDownloadColumnDrag, commitDownloadColumnDrag, createDownloadColumnOrderPersistence, DOWNLOAD_COLUMN_MOVE_DURATION_MS, updateDownloadColumnDrag, type DownloadColumnDragSession, type DownloadColumnOrderPersistence } from "./views/downloads/column-drag"; import { DownloadsContent, @@ -1377,7 +1379,7 @@ function sortPackageOrderBySize(order: string[], packages: Record, items: Record, descending: boolean): string[] { +function sortPackageOrderByHoster(order: string[], packages: Record, items: Record, descending: boolean): string[] { const sorted = [...order]; sorted.sort((a, b) => { const hosterA = [...new Set((packages[a]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url || "")).filter(Boolean))].join(",").toLowerCase(); @@ -1469,6 +1471,29 @@ function formatUpdateInstallProgress(progress: UpdateInstallProgress): string { return `Update-Fehler: ${progress.message}`; } +export function sortPackageOrderByService( + order: string[], + packages: Record, + items: Record, + descending: boolean, + visibleItemsByPackage: Record = {} +): string[] { + const sorted = [...order]; + const itemsFor = (packageId: string): readonly DownloadItem[] => visibleItemsByPackage[packageId] + ?? (packages[packageId]?.itemIds ?? []).map((id) => items[id]).filter((item): item is DownloadItem => Boolean(item)); + sorted.sort((a, b) => { + const serviceA = [...new Set(itemsFor(a).map((item) => { + return item?.providerLabel || (item?.provider ? providerLabels[item.provider] : ""); + }).filter(Boolean))].join(",").toLocaleLowerCase("de-DE"); + const serviceB = [...new Set(itemsFor(b).map((item) => { + return item?.providerLabel || (item?.provider ? providerLabels[item.provider] : ""); + }).filter(Boolean))].join(",").toLocaleLowerCase("de-DE"); + const cmp = serviceA.localeCompare(serviceB, "de"); + return descending ? -cmp : cmp; + }); + return sorted; +} + export function shouldApplyUpdateCheckResult( completedGeneration: number, currentGeneration: number @@ -4426,7 +4451,7 @@ export function App(): ReactElement { const onCopyOnlineBackupKey = async (): Promise => { if (!onlineBackupDialog?.key) return; try { - await navigator.clipboard.writeText(onlineBackupDialog.key); + if (!(await window.rd.writeClipboardText(onlineBackupDialog.key))) throw new Error("clipboard_write_rejected"); showToast("Online-Schlüssel kopiert", 2200); } catch { showToast("Schlüssel konnte nicht kopiert werden", 2600); @@ -4493,13 +4518,13 @@ export function App(): ReactElement { cancelLabel: "Schließen", details: details || undefined, detailsLabel: "Einträge anzeigen" - }); - if (copy && entries.length > 0) { - await navigator.clipboard.writeText(details); - showToast("Fehlerliste kopiert", 2600); + }); + if (copy && entries.length > 0) { + if (!(await window.rd.writeClipboardText(details))) throw new Error("clipboard_write_rejected"); + showToast("Fehlerliste kopiert", 2600); } - } catch (error) { - showToast(`Fehler-Ansicht fehlgeschlagen: ${String(error)}`, 3000); + } catch (error) { + showToast(String(error).includes("clipboard_write_rejected") ? "Kopieren fehlgeschlagen" : `Fehler-Ansicht fehlgeschlagen: ${String(error)}`, 3000); } }; @@ -4596,10 +4621,10 @@ export function App(): ReactElement { const onCopyRemoteDiagnosticsCode = async (): Promise => { if (!remoteDiag?.code) { return; - } - try { - await navigator.clipboard.writeText(remoteDiag.code); - showToast("Verbindungscode kopiert", 2200); + } + try { + if (!(await window.rd.writeClipboardText(remoteDiag.code))) throw new Error("clipboard_write_rejected"); + showToast("Verbindungscode kopiert", 2200); } catch { showToast("Kopieren fehlgeschlagen", 2200); } @@ -4720,6 +4745,14 @@ export function App(): ReactElement { ? sortPackageOrderBySize(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending) : column === "hoster" ? sortPackageOrderByHoster(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending) + : column === "service" + ? sortPackageOrderByService( + baseOrder, + snapshot.session.packages, + snapshot.session.items, + nextDescending, + Object.fromEntries(downloadsViewCore.packageRows.map((row) => [row.package.id, row.items])) + ) : sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending); pendingPackageOrderRef.current = [...sorted]; pendingPackageOrderAtRef.current = Date.now(); @@ -4732,7 +4765,7 @@ export function App(): ReactElement { setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: serverPackageOrderRef.current } })); showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); }); - }, [downloadsSortColumn, downloadsSortDescending, showToast, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]); + }, [downloadsSortColumn, downloadsSortDescending, downloadsViewCore.packageRows, showToast, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]); const clearDownloadQueue = useCallback((): void => { void performQuickAction(async () => { @@ -5374,7 +5407,7 @@ export function App(): ReactElement { }, onCopyIdentity: (label, value) => { void window.rd.writeClipboardText(value) - .then(() => showToast(`${label} kopiert`)) + .then((copied) => copied ? showToast(`${label} kopiert`) : showToast("Kopieren fehlgeschlagen")) .catch(() => showToast("Kopieren fehlgeschlagen")); }, onAdd: openCreateAccountDialog, @@ -6416,18 +6449,27 @@ export function App(): ReactElement { const startableStatuses = new Set(["queued", "cancelled", "reconnect_wait"]); const hasStartableItems = actionableSelectedIds.some((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); }); const hasItems = selectedItemIds.length > 0; + const extractAction = buildExtractNowContextAction({ + contextItemId: contextMenu.itemId, + selectedPackageIds, + selectedItemIds, + packages: snapshot.session.packages, + items: snapshot.session.items + }); return ( setContextMenu(null)} open ref={ctxMenuRef} x={contextMenu.x} y={contextMenu.y}> - {(hasPackages || hasStartableItems) && ( - )} - +
{hasPackages && !contextMenu.itemId && ( @@ -6497,16 +6539,13 @@ export function App(): ReactElement { setContextMenu(null); }}>Zurücksetzen{multi ? ` (${selectedItemIds.length})` : ""} )} - {hasPackages && !multi && (() => { - const pkg = snapshot.session.packages[contextMenu.packageId]; - const items = pkg?.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean) || []; - const someCompleted = items.some((item) => item && item.status === "completed" && !/^Entpackt\b/i.test(item.fullStatus || "")); - return (<> - {someCompleted && ( - - )} - ); - })()} + {extractAction && ( + + )} {hasPackages && !contextMenu.itemId && (<>
@@ -6687,8 +6726,8 @@ export function App(): ReactElement { type="button" title={`${key.masked}\nMaskierte Kennung kopieren`} onClick={() => { - void navigator.clipboard.writeText(key.masked) - .then(() => showToast("Maskierte Kennung kopiert", 1800)) + void window.rd.writeClipboardText(key.masked) + .then((copied) => copied ? showToast("Maskierte Kennung kopiert", 1800) : showToast("Kopieren fehlgeschlagen", 2200)) .catch(() => showToast("Kopieren fehlgeschlagen", 2200)); }} > @@ -6739,32 +6778,14 @@ export function App(): ReactElement { /> ) : null} {linkPopup ? ( - setLinkPopup(null)} open size="wide" title="Linkadressen anzeigen"> -

{linkPopup.title}

-
- {linkPopup.links.map((link, i) => ( -
- - -
- ))} -
-
- {linkPopup.isPackage && ( - - )} - {linkPopup.isPackage && ( - - )} - -
-
+ setLinkPopup(null)} + onToast={showToast} + title={linkPopup.title} + writeClipboardText={window.rd.writeClipboardText} + /> ) : null} )} diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 2e0a312..f21f8af 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -64,7 +64,7 @@ const pairs = [ ["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"], ["Sichtbar:", "Visible:"], ["pro Seite", "per page"], ["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"], ["Starttag", "Start day"], ["Ab heute", "Starting today"], ["Ab morgen", "Starting tomorrow"], ["Bitte eine gültige Startzeit auswählen.", "Select a valid start time."], - ["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], + ["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Warte auf Festplatte", "Waiting for disk"], ["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"], ["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"], ["Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.", "Session counters and results for the current queue are shown."], ["Sitzung zurücksetzen", "Reset session"], ["Gesamt zurücksetzen", "Reset total"], ["Fehler zurücksetzen", "Reset errors"], @@ -222,11 +222,41 @@ export function normalizeLanguage(value: unknown): AppLanguage { return value === "de" ? "de" : "en"; } +function translatePackageStatusParts(value: string, language: AppLanguage): string | null { + const parts = value.split(" · "); + if (parts.length < 2) return null; + const translated = parts.map((part): string | null => { + if (language === "en") { + const extractionError = part.match(/^(\d+) Entpackfehler$/); + if (extractionError) return `${extractionError[1]} extraction error${extractionError[1] === "1" ? "" : "s"}`; + const retry = part.match(/^(\d+) Wiederholung(?:en)?$/); + if (retry) return `${retry[1]} retr${retry[1] === "1" ? "y" : "ies"}`; + const error = part.match(/^(\d+) Fehler$/); + if (error) return `${error[1]} error${error[1] === "1" ? "" : "s"}`; + const cancelled = part.match(/^(\d+) abgebrochen$/); + if (cancelled) return `${cancelled[1]} cancelled`; + return null; + } + const extractionError = part.match(/^(\d+) extraction errors?$/); + if (extractionError) return `${extractionError[1]} Entpackfehler`; + const retry = part.match(/^(\d+) retr(?:y|ies)$/); + if (retry) return `${retry[1]} Wiederholung${retry[1] === "1" ? "" : "en"}`; + const error = part.match(/^(\d+) errors?$/); + if (error) return `${error[1]} Fehler`; + const cancelled = part.match(/^(\d+) cancelled$/); + if (cancelled) return `${cancelled[1]} abgebrochen`; + return null; + }); + return translated.every((part): part is string => part !== null) ? translated.join(" · ") : null; +} + function translateDynamic(value: string, language: AppLanguage): string { for (const [german, english] of prefixedPairs) { const source = language === "en" ? german : english; if (value.startsWith(source)) return `${language === "en" ? english : german}${value.slice(source.length)}`; } + const packageStatus = translatePackageStatusParts(value, language); + if (packageStatus) return packageStatus; if (language === "en") { const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/); if (update) return `${update[1]} is available. Installed version: ${update[2]}.`; @@ -274,6 +304,17 @@ function translateDynamic(value: string, language: AppLanguage): string { if (audio) return `Audio track: ${audio[1].replace(/ohne DE-Tag/g, "without DE tag").replace(/ffmpeg fehlt/g, "ffmpeg missing").replace(/(\d+) Fehler/g, "$1 errors")}`; const result = value.match(/^(\d+\/\d+) fertig(.*)$/); if (result) return `${result[1]} completed${result[2].replace(/(\d+) Fehler/g, "$1 errors").replace(/(\d+) abgebrochen/g, "$1 cancelled")}`; + const extractionErrorsAndRetries = value.match(/^(\d+) Entpackfehler · (\d+) Wiederholung(?:en)?$/); + if (extractionErrorsAndRetries) return `${extractionErrorsAndRetries[1]} extraction error${extractionErrorsAndRetries[1] === "1" ? "" : "s"} · ${extractionErrorsAndRetries[2]} retr${extractionErrorsAndRetries[2] === "1" ? "y" : "ies"}`; + const extractionErrors = value.match(/^(\d+) Entpackfehler$/); + if (extractionErrors) return `${extractionErrors[1]} extraction error${extractionErrors[1] === "1" ? "" : "s"}`; + const retries = value.match(/^(\d+) Wiederholung(?:en)?$/); + if (retries) return `${retries[1]} retr${retries[1] === "1" ? "y" : "ies"}`; + const downloadCompleteExtractionErrors = value.match(/^Download fertig · (\d+) Entpackfehler$/); + if (downloadCompleteExtractionErrors) return `Download complete · ${downloadCompleteExtractionErrors[1]} extraction error${downloadCompleteExtractionErrors[1] === "1" ? "" : "s"}`; + if (value === "Download fertig") return "Download complete"; + const extractSelection = value.match(/^Jetzt entpacken \((\d+)\)$/); + if (extractSelection) return `Extract now (${extractSelection[1]})`; const extracting = value.match(/^Entpacken (\d+%)$/); if (extracting) return `Extracting ${extracting[1]}`; const finalizing = value.match(/^Finalisieren - (\d+%)$/); @@ -447,6 +488,17 @@ function translateDynamic(value: string, language: AppLanguage): string { if (audio) return `Tonspur: ${audio[1].replace(/without DE tag/g, "ohne DE-Tag").replace(/ffmpeg missing/g, "ffmpeg fehlt").replace(/(\d+) errors/g, "$1 Fehler")}`; const result = value.match(/^(\d+\/\d+) completed(.*)$/); if (result) return `${result[1]} fertig${result[2].replace(/(\d+) errors/g, "$1 Fehler").replace(/(\d+) cancelled/g, "$1 abgebrochen")}`; + const extractionErrorsAndRetries = value.match(/^(\d+) extraction errors? · (\d+) retr(?:y|ies)$/); + if (extractionErrorsAndRetries) return `${extractionErrorsAndRetries[1]} Entpackfehler · ${extractionErrorsAndRetries[2]} Wiederholung${extractionErrorsAndRetries[2] === "1" ? "" : "en"}`; + const extractionErrors = value.match(/^(\d+) extraction errors?$/); + if (extractionErrors) return `${extractionErrors[1]} Entpackfehler`; + const retries = value.match(/^(\d+) retr(?:y|ies)$/); + if (retries) return `${retries[1]} Wiederholung${retries[1] === "1" ? "" : "en"}`; + const downloadCompleteExtractionErrors = value.match(/^Download complete · (\d+) extraction errors?$/); + if (downloadCompleteExtractionErrors) return `Download fertig · ${downloadCompleteExtractionErrors[1]} Entpackfehler`; + if (value === "Download complete") return "Download fertig"; + const extractSelection = value.match(/^Extract now \((\d+)\)$/); + if (extractSelection) return `Jetzt entpacken (${extractSelection[1]})`; const extracting = value.match(/^Extracting (\d+%)$/); if (extracting) return `Entpacken ${extracting[1]}`; const finalizing = value.match(/^Finalizing - (\d+%)$/); diff --git a/src/renderer/ui/LinkAddressesDialog.tsx b/src/renderer/ui/LinkAddressesDialog.tsx new file mode 100644 index 0000000..0d21bae --- /dev/null +++ b/src/renderer/ui/LinkAddressesDialog.tsx @@ -0,0 +1,77 @@ +import type { ReactElement } from "react"; +import { Dialog } from "./Dialog"; + +export interface LinkAddress { + name: string; + url: string; +} + +export interface LinkAddressesDialogProps { + title: string; + links: LinkAddress[]; + isPackage: boolean; + onClose: () => void; + writeClipboardText: (text: string) => Promise; + onToast: (message: string) => void; +} + +export function LinkAddressesDialog({ + title, + links, + isPackage, + onClose, + writeClipboardText, + onToast +}: LinkAddressesDialogProps): ReactElement { + const copy = async (text: string, successMessage: string): Promise => { + try { + const copied = await writeClipboardText(text); + onToast(copied === true ? successMessage : "Kopieren fehlgeschlagen"); + } catch { + onToast("Kopieren fehlgeschlagen"); + } + }; + + return ( + +

{title}

+
+ {links.map((link, index) => ( +
+ + +
+ ))} +
+
+ {isPackage ? ( + + ) : null} + {isPackage ? ( + + ) : null} + +
+
+ ); +} diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index eeb1c46..98760f3 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -13,13 +13,24 @@ import { providerLabels } from "../../download-format"; import type { DownloadPackageRow } from "./downloads-model"; +import { buildPackagePresentation } from "./package-presentation"; -export type DownloadSortColumn = "name" | "size" | "hoster" | "progress"; +export type DownloadSortColumn = "name" | "size" | "hoster" | "progress" | "service"; const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px"; const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px"; +const DOWNLOAD_COLUMN_DRAG_THRESHOLD_PX = 5; const PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR = "button, input, select, textarea, a, [contenteditable='true'], .downloads-copyable, .downloads-meter"; +interface DownloadColumnPointerGesture { + dragged: boolean; + pointerId: number; + sortColumn?: DownloadSortColumn; + startX: number; +} + +const downloadColumnPointerGestures = new WeakMap(); + type HosterLabel = ReturnType; function HosterLabelContent({ label }: { label: HosterLabel }): ReactElement { @@ -54,7 +65,7 @@ export const downloadColumnDefinitions: Record item.onlineStatus === "online").length; - const offline = items.filter((item) => item.onlineStatus === "offline").length; + const availability = items.map(effectiveItemOnlineStatus); + const online = availability.filter((status) => status === "online").length; + const offline = availability.filter((status) => status === "offline").length; if (total > 0 && online === total) return { online, total, state: "online" }; if (total > 0 && offline === total) return { online, total, state: "offline" }; if (total > 0 && online + offline === total) return { online, total, state: "partial" }; @@ -216,7 +233,7 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ? error && error !== displayStatus && !displayStatus.includes(error) ? `${displayStatus}${retrySuffix}\n${error}` : `${displayStatus}${retrySuffix}` : error; if (column === "name") { - return {item.fileName}; + return {item.fileName}; } if (column === "size") { const total = item.totalBytes || item.downloadedBytes || 0; @@ -241,7 +258,8 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): if (column === "status") return ; if (column === "speed") return {item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}; if (column === "availability") { - const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking"; + const effectiveStatus = effectiveItemOnlineStatus(item); + const state = effectiveStatus === "online" ? "online" : effectiveStatus === "offline" ? "offline" : "checking"; const text = state === "online" ? "Online" : state === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Prüfung" : "Ungeprüft"; return ; } @@ -318,37 +336,7 @@ export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps) export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual); export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } { - let done = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0)); - let failed = 0; - let cancelled = 0; - let extracted = Math.max(0, Number(row.package.cleanedExtractedItemCount || 0)); - let extracting = false; - let activeProgress = 0; - let extractingProgress = 0; - for (const item of row.allItems) { - if (item.status === "completed") done += 1; - else if (item.status === "failed") failed += 1; - else if (item.status === "cancelled") cancelled += 1; - const fullStatus = item.fullStatus || ""; - if (fullStatus.startsWith("Entpackt")) { - extracted += 1; - } else if (fullStatus.startsWith("Entpacken")) { - extracting = true; - const match = fullStatus.match(/^Entpacken\s+(\d+)%/); - if (match) extractingProgress += Number(match[1]) / 100; - } - if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) { - activeProgress += (item.progressPercent || 0) / 100; - } - } - const total = Math.max(1, Math.max(0, Number(row.package.cleanedCompletedItemCount || 0)) + row.allItems.length); - const allDownloaded = done + failed + cancelled >= total; - const allExtracted = extracted >= total; - const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0); - const downloadProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100))); - const extractionProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50)); - const value = Math.min(100, useExtractSplit ? downloadProgress + extractionProgress : downloadProgress); - return { done, failed, cancelled, total, value }; + return buildPackagePresentation(row).progress; } export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: number; total: number; value: number } { @@ -361,7 +349,8 @@ export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: n function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: number, editing: boolean, editingName: string, actions: DownloadsTableActions, finishRename: (value: string) => void): ReactElement | null { const entry = row.package; - const stats = getPackageProgress(row); + const presentation = buildPackagePresentation(row); + const stats = presentation.progress; if (column === "name") { return ( @@ -403,16 +392,14 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n const postProcessLabel = entry.status === "extracting" && compactPostProcessLabel === rawPostProcessLabel && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel) ? "Entpacken - Ausstehend" : compactPostProcessLabel; - const extractFailure = row.allItems.find((item) => /^Entpack-Fehler\b/i.test(item.fullStatus || "")); - const waitsForDisk = row.allItems.some((item) => compactDownloadStatus(item.fullStatus || "") === "Warte auf Festplatte"); - const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${extractFailure ? " · Entpack-Fehler" : ""}${audio ? ` · ${audio.text}` : ""}`; - const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating"); - const status = postProcessLabel && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting") + const details = `${presentation.details}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`; + const status = presentation.extractFailureCount === 0 + && presentation.retryCount === 0 + && postProcessLabel + && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting") ? postProcessLabel - : extractFailure ? "Entpack-Fehler" - : waitsForDisk ? "Warte auf Festplatte" - : downloading ? "Download läuft" : details; - const statusDetails = extractFailure ? `${details}\n${extractFailure.fullStatus}` : details; + : presentation.status; + const statusDetails = presentation.extractFailure ? `${details}\n${presentation.extractFailure.fullStatus}` : details; const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails; return ; } @@ -533,6 +520,11 @@ function moveColumnWithPointerActions(column: string, direction: -1 | 1, element actions.onColumnPointerUp(column, pointerEvent(clientX)); } +function isColumnSortPointerTarget(target: EventTarget | null): boolean { + const closest = (target as { closest?: (selector: string) => Element | null } | null)?.closest; + return typeof closest === "function" && closest.call(target, ".downloads-column-sort") !== null; +} + export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement { const allSelected = visibleIds.length > 0 && selectedCount === visibleIds.length; const mixedSelection = selectedCount > 0 && selectedCount < visibleIds.length; @@ -550,22 +542,41 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC data-download-column={column} key={column} onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }} - onPointerCancel={(event) => actions.onColumnPointerCancel(column, event)} + onPointerCancel={(event) => { + downloadColumnPointerGestures.delete(event.currentTarget); + actions.onColumnPointerCancel(column, event); + }} onPointerDown={(event) => { if (event.button !== 0 || !event.isPrimary) return; if (event.currentTarget.closest(".downloads-table")?.classList.contains("is-column-drag-settling")) return; + downloadColumnPointerGestures.set(event.currentTarget, { + dragged: false, + pointerId: event.pointerId, + sortColumn: definition.sortable && isColumnSortPointerTarget(event.target) ? definition.sortable : undefined, + startX: event.clientX + }); event.currentTarget.setPointerCapture(event.pointerId); actions.onColumnPointerDown(column, event); }} - onPointerMove={(event) => actions.onColumnPointerMove(column, event)} + onPointerMove={(event) => { + const gesture = downloadColumnPointerGestures.get(event.currentTarget); + if (gesture?.pointerId === event.pointerId && Math.abs(event.clientX - gesture.startX) >= DOWNLOAD_COLUMN_DRAG_THRESHOLD_PX) gesture.dragged = true; + actions.onColumnPointerMove(column, event); + }} onPointerUp={(event) => { + const gesture = downloadColumnPointerGestures.get(event.currentTarget); + if (gesture?.pointerId === event.pointerId) { + if (Math.abs(event.clientX - gesture.startX) >= DOWNLOAD_COLUMN_DRAG_THRESHOLD_PX) gesture.dragged = true; + downloadColumnPointerGestures.delete(event.currentTarget); + } if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); actions.onColumnPointerUp(column, event); + if (gesture?.pointerId === event.pointerId && gesture.sortColumn && !gesture.dragged) actions.onSortColumn(gesture.sortColumn); }} role="columnheader" > {definition.sortable - ? + ? : {definition.label}} event.stopPropagation()} role="group"> {index > 0 ? : null} diff --git a/src/renderer/views/downloads/extract-action.ts b/src/renderer/views/downloads/extract-action.ts new file mode 100644 index 0000000..45e3e24 --- /dev/null +++ b/src/renderer/views/downloads/extract-action.ts @@ -0,0 +1,46 @@ +import type { DownloadItem, PackageEntry } from "../../../shared/types"; +import type { ExtractNowRequest } from "../../../shared/extract-now"; + +export interface ExtractNowContextAction { + label: string; + request: ExtractNowRequest; + targetCount: number; +} + +export interface ExtractNowContextInput { + contextItemId?: string; + selectedPackageIds: readonly string[]; + selectedItemIds: readonly string[]; + packages: Record; + items: Record; +} + +function canExtractItem(item: DownloadItem | undefined): item is DownloadItem { + return Boolean(item && item.status === "completed" && !/^Entpackt\b/i.test(item.fullStatus || "")); +} + +export function buildExtractNowContextAction(input: ExtractNowContextInput): ExtractNowContextAction | null { + const packageIds = [...new Set(input.selectedPackageIds)].filter((packageId) => { + const entry = input.packages[packageId]; + return Boolean(entry && !entry.cancelled && entry.itemIds.some((itemId) => canExtractItem(input.items[itemId]))); + }); + const packageSet = new Set(packageIds); + const selectedItemIds = input.selectedItemIds.length > 0 + ? input.selectedItemIds + : input.contextItemId + ? [input.contextItemId] + : []; + const itemIds = [...new Set(selectedItemIds)].filter((itemId) => { + const item = input.items[itemId]; + return canExtractItem(item) && !packageSet.has(item.packageId); + }); + const targetCount = packageIds.length + itemIds.length; + if (targetCount === 0) { + return null; + } + return { + label: targetCount > 1 ? `Jetzt entpacken (${targetCount})` : "Jetzt entpacken", + request: { packageIds, itemIds }, + targetCount + }; +} diff --git a/src/renderer/views/downloads/package-presentation.ts b/src/renderer/views/downloads/package-presentation.ts new file mode 100644 index 0000000..4c6cf34 --- /dev/null +++ b/src/renderer/views/downloads/package-presentation.ts @@ -0,0 +1,140 @@ +import type { DownloadItem } from "../../../shared/types"; +import type { DownloadPackageRow } from "./downloads-model"; + +export interface PackageProgressPresentation { + done: number; + failed: number; + cancelled: number; + total: number; + value: number; +} + +export interface PackagePresentation { + progress: PackageProgressPresentation; + status: string; + details: string; + extractFailure?: DownloadItem; + extractFailureCount: number; + retryCount: number; + waitDiskCount: number; + extractingCount: number; +} + +function extractionPercent(fullStatus: string): number { + const match = fullStatus.match(/^Entpacken\s+(\d+)%/i); + return match ? Math.max(0, Math.min(100, Number(match[1]))) / 100 : 0; +} + +function isExtractFailure(fullStatus: string): boolean { + return /^(?:Entpack-Fehler|Entpacken\s*-\s*(?:Fehler|Error))/i.test(fullStatus); +} + +function isExtractionLifecycle(fullStatus: string): boolean { + return /^(?:Entpack|Passwort)/i.test(fullStatus); +} + +function isArchiveItem(item: DownloadItem): boolean { + return /\.(?:rar|r\d{2,3}|zip|7z|tar|gz|bz2|xz|tgz|tbz2|txz|\d{3})$/i.test(item.fileName || item.targetPath || ""); +} + +function isRetrying(item: DownloadItem): boolean { + return /(?:Link-Umwandlung erneut|Wiederholung|Retry|erneut)/i.test(item.fullStatus || "") + || (item.retries > 0 && (item.status === "queued" || item.status === "validating" || item.status === "reconnect_wait")); +} + +function downloadFraction(item: DownloadItem): number { + if (item.status === "completed") { + return 1; + } + if (item.totalBytes && item.totalBytes > 0) { + return Math.max(0, Math.min(1, item.downloadedBytes / item.totalBytes)); + } + return Math.max(0, Math.min(1, (item.progressPercent || 0) / 100)); +} + +export function buildPackagePresentation(row: DownloadPackageRow): PackagePresentation { + const cleanedCompleted = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0)); + const cleanedExtracted = Math.max(0, Number(row.package.cleanedExtractedItemCount || 0)); + let done = cleanedCompleted; + let failed = 0; + let cancelled = 0; + let downloadUnits = cleanedCompleted; + let extractionUnits = cleanedExtracted; + let extractionLifecycle = row.package.status === "extracting" + || /^(?:Entpack|Passwort)/i.test(row.package.postProcessLabel || "") + || (row.allItems.some(isArchiveItem) && !row.allItems.every((item) => /^Fertig\b/i.test(item.fullStatus || ""))); + let extracting = 0; + let retrying = 0; + let waitsForDisk = 0; + const extractFailures: DownloadItem[] = []; + + for (const item of row.allItems) { + if (item.status === "completed") done += 1; + else if (item.status === "failed") failed += 1; + else if (item.status === "cancelled") cancelled += 1; + downloadUnits += downloadFraction(item); + const fullStatus = item.fullStatus || ""; + if (/^Entpackt\b/i.test(fullStatus)) { + extractionUnits += 1; + extractionLifecycle = true; + } else { + const progress = extractionPercent(fullStatus); + if (progress > 0 || /^Entpacken\b/i.test(fullStatus)) { + extracting += 1; + extractionUnits += progress; + } + if (isExtractFailure(fullStatus)) { + extractFailures.push(item); + } + if (isExtractionLifecycle(fullStatus)) { + extractionLifecycle = true; + } + } + if (isRetrying(item)) retrying += 1; + if (/Warte auf Festplatte/i.test(fullStatus)) waitsForDisk += 1; + } + + const total = Math.max(1, cleanedCompleted + row.allItems.length); + const downloadValue = Math.floor(Math.min(1, downloadUnits / total) * (extractionLifecycle ? 90 : 100)); + const extractionValue = extractionLifecycle ? Math.floor(Math.min(1, extractionUnits / total) * 10) : 0; + const allExtracted = extractionLifecycle && extractionUnits >= total; + const value = allExtracted ? 100 : Math.min(extractionLifecycle ? 99 : 100, downloadValue + extractionValue); + + const parts: string[] = []; + if (extractFailures.length > 0) parts.push(`${extractFailures.length} Entpackfehler`); + if (retrying > 0) parts.push(`${retrying} Wiederholung${retrying === 1 ? "" : "en"}`); + if (failed > 0) parts.push(`${failed} Fehler`); + if (cancelled > 0) parts.push(`${cancelled} abgebrochen`); + const details = parts.length > 0 ? parts.join(" · ") : done >= total ? "Fertig" : `${done}/${total} fertig`; + const downloadsComplete = row.allItems.every((item) => downloadFraction(item) >= 1); + const packageExtractLabel = (row.package.postProcessLabel || "").trim(); + const downloading = row.package.status === "downloading" + || row.package.status === "validating" + || row.allItems.some((item) => item.status === "downloading" || item.status === "validating"); + + let status = allExtracted ? "Entpackt" : details; + if (extractFailures.length > 0 && retrying > 0) { + status = `${extractFailures.length} Entpackfehler · ${retrying} Wiederholung${retrying === 1 ? "" : "en"}`; + } else if (extractFailures.length > 0) { + status = downloadsComplete ? `Download fertig · ${extractFailures.length} Entpackfehler` : `${extractFailures.length} Entpackfehler`; + } else if (waitsForDisk > 0) { + status = "Warte auf Festplatte"; + } else if (extracting > 0 || row.package.status === "extracting") { + status = packageExtractLabel || "Entpacken"; + } else if (retrying > 0) { + status = `${retrying} Wiederholung${retrying === 1 ? "" : "en"}`; + } else if (downloading) { + status = "Download läuft"; + } + + return { + progress: { done, failed, cancelled, total, value }, + status, + details, + extractFailure: extractFailures[0], + extractFailureCount: extractFailures.length, + retryCount: retrying, + waitDiskCount: waitsForDisk, + extractingCount: extracting + }; +} diff --git a/src/shared/extract-now.ts b/src/shared/extract-now.ts new file mode 100644 index 0000000..416f6f9 --- /dev/null +++ b/src/shared/extract-now.ts @@ -0,0 +1,42 @@ +export interface ExtractNowRequest { + packageIds: string[]; + itemIds: string[]; +} + +const MAX_EXTRACT_NOW_TARGETS = 2000; +const MAX_EXTRACT_NOW_ID_LENGTH = 256; + +function normalizeIds(value: unknown, name: string): string[] { + if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.trim().length > 0)) { + throw new Error(`${name} muss ein Array nicht-leerer Strings sein`); + } + if (value.some((entry) => entry.trim().length > MAX_EXTRACT_NOW_ID_LENGTH)) { + throw new Error(`${name} enthält eine ID mit ungültiger Länge`); + } + return [...new Set(value.map((entry) => entry.trim()))]; +} + +export function normalizeExtractNowRequest(value: unknown): ExtractNowRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("extractNow muss ein Objekt sein"); + } + const record = value as Record; + const unknownKeys = Object.keys(record).filter((key) => key !== "packageIds" && key !== "itemIds"); + if (unknownKeys.length > 0) { + throw new Error(`extractNow enthält unbekannte Felder: ${unknownKeys.join(", ")}`); + } + const rawCount = (Array.isArray(record.packageIds) ? record.packageIds.length : 0) + + (Array.isArray(record.itemIds) ? record.itemIds.length : 0); + if (rawCount > MAX_EXTRACT_NOW_TARGETS) { + throw new Error(`extractNow unterstützt höchstens ${MAX_EXTRACT_NOW_TARGETS} Ziele`); + } + const packageIds = normalizeIds(record.packageIds, "packageIds"); + const itemIds = normalizeIds(record.itemIds, "itemIds"); + if (packageIds.length + itemIds.length === 0) { + throw new Error("extractNow benötigt mindestens ein Ziel"); + } + if (packageIds.length + itemIds.length > MAX_EXTRACT_NOW_TARGETS) { + throw new Error(`extractNow unterstützt höchstens ${MAX_EXTRACT_NOW_TARGETS} Ziele`); + } + return { packageIds, itemIds }; +} diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 250722b..58226ff 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -1,4 +1,4 @@ -import type { +import type { AddLinksPayload, AccountCheckScope, AccountCommandResult, @@ -33,6 +33,7 @@ import type { UpdateInstallProgress, UpdateInstallResult } from "./types"; +import type { ExtractNowRequest } from "./extract-now"; import { isRealDebridWebAccountId } from "./real-debrid-accounts"; import type { CollectorInspectionRequest, CollectorInspectionResult } from "./collector"; @@ -143,7 +144,7 @@ export interface ElectronApi { revealAccountSecret: (input: AccountSecretRequest) => Promise; getArchivePasswordList: () => Promise; retryExtraction: (packageId: string) => Promise; - extractNow: (packageId: string) => Promise; + extractNow: (request: ExtractNowRequest) => Promise; resetPackage: (packageId: string) => Promise; getHistory: () => Promise; onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void) => () => void; diff --git a/tests/app-shell.test.tsx b/tests/app-shell.test.tsx index 3121700..a2752aa 100644 --- a/tests/app-shell.test.tsx +++ b/tests/app-shell.test.tsx @@ -12,9 +12,10 @@ describe("desktop shell", () => { const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); expect(source).not.toMatch(/]*className="[^"]*link-popup-click/); - expect(source.match(/]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3); + expect(source.match(/]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(1); expect(source).not.toContain("navigator.clipboard.writeText(key.token)"); - expect(source).toContain("navigator.clipboard.writeText(key.masked)"); + expect(source).not.toContain("navigator.clipboard.writeText"); + expect(source).toContain("window.rd.writeClipboardText(key.masked)"); expect(source).toContain("Maskierte Kennung kopiert"); }); diff --git a/tests/clipboard-write.test.ts b/tests/clipboard-write.test.ts new file mode 100644 index 0000000..9915765 --- /dev/null +++ b/tests/clipboard-write.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { CLIPBOARD_WRITE_MAX_BYTES, validateClipboardWriteText } from "../src/main/clipboard-write"; + +describe("clipboard write validation", () => { + it("accepts complete large link packages up to one MiB", () => { + const text = "x".repeat(CLIPBOARD_WRITE_MAX_BYTES); + expect(validateClipboardWriteText(text)).toBe(text); + }); + + it("rejects empty, non-string and oversized payloads", () => { + expect(() => validateClipboardWriteText(" \n ")).toThrow(/leer/i); + expect(() => validateClipboardWriteText(4)).toThrow(/String/i); + expect(() => validateClipboardWriteText("x".repeat(CLIPBOARD_WRITE_MAX_BYTES + 1))).toThrow(/zu groß/i); + }); +}); diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 49a7264..0aceabc 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -656,8 +656,7 @@ describe("debrid service", () => { expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull(); }); - it("cools down a Debrid-Link key on an abort that ran long enough (retry rotates to the next key)", async () => { - process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; + it("does not cool down a Debrid-Link key when the caller aborts after more than eight seconds", async () => { const settings = { ...defaultSettings(), token: "", @@ -672,13 +671,16 @@ describe("debrid service", () => { providerSecondary: "none" as const, providerTertiary: "none" as const, autoProviderFallback: false - }; - const controller = new AbortController(); - globalThis.fetch = (async (input: RequestInfo | URL): Promise => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url.includes("/downloader/add")) { - controller.abort(); - throw new Error("aborted"); + }; + const controller = new AbortController(); + let now = 1_000_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/downloader/add")) { + now += 9_000; + controller.abort("stop"); + throw new Error("aborted"); } return new Response("not-found", { status: 404 }); }) as typeof fetch; @@ -689,9 +691,48 @@ describe("debrid service", () => { service.unrestrictLink("https://rapidgator.net/file/dl-long-abort", controller.signal) ).rejects.toThrow(); - const cooldown = getDebridLinkKeyCooldownStateForTests(keyId); - expect(cooldown?.remainingMs ?? 0).toBeGreaterThan(60_000); - }); + expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull(); + }); + + it("cools down a Debrid-Link key after an internal timeout", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "", + megaPassword: "", + megaCredentials: "", + debridLinkApiKeys: "dl-key-one", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + const callerController = new AbortController(); + const timeoutController = new AbortController(); + const signal = AbortSignal.any([callerController.signal, timeoutController.signal]); + let now = 1_000_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/downloader/add")) { + now += 9_000; + timeoutController.abort(new DOMException("The operation timed out", "TimeoutError")); + throw new Error("aborted"); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id; + const service = new DebridService(settings); + await expect( + service.unrestrictLink("https://rapidgator.net/file/dl-internal-timeout", signal) + ).rejects.toThrow(); + + expect(getDebridLinkKeyCooldownStateForTests(keyId)?.remainingMs ?? 0).toBeGreaterThan(60_000); + }); it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => { const settings = { @@ -2158,13 +2199,11 @@ describe("debrid service", () => { }; globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; - const controller = new AbortController(); - let calls = 0; - const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => { - calls += 1; - if (calls === 1) { - controller.abort("simulated-60s-timeout"); - return Promise.reject(new Error("aborted")); + let calls = 0; + const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => { + calls += 1; + if (calls <= REQUEST_RETRIES) { + return Promise.reject(new Error("aborted")); } return Promise.resolve({ fileName: "healthy.rar", @@ -2176,7 +2215,7 @@ describe("debrid service", () => { const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); - const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e); + const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html").then(() => null, (e: unknown) => e); expect(err).toBeTruthy(); expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i); @@ -2328,7 +2367,7 @@ describe("debrid service", () => { expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true); }, 20000); - it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => { + it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => { process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout const settings = { ...defaultSettings(), @@ -2345,13 +2384,17 @@ describe("debrid service", () => { providerTertiary: "none" as const, autoProviderFallback: false }; - globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; - - const loginsSeen: Array = []; - const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => { - loginsSeen.push(account?.login); - if (account?.login === "user1") { - throw new Error("aborted:debrid"); + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + + const callerController = new AbortController(); + const timeoutController = new AbortController(); + const signal = AbortSignal.any([callerController.signal, timeoutController.signal]); + const loginsSeen: Array = []; + const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => { + loginsSeen.push(account?.login); + if (account?.login === "user1") { + timeoutController.abort(new DOMException("The operation timed out", "TimeoutError")); + throw new Error("aborted:debrid"); } return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 }; }); @@ -2359,7 +2402,7 @@ describe("debrid service", () => { const user1Key = `${getMegaDebridAccountId("user1")}:web`; // Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down. - await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow(); + await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1", signal)).rejects.toThrow(); expect(loginsSeen).toContain("user1"); expect(loginsSeen).not.toContain("user2"); expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull(); @@ -2372,7 +2415,7 @@ describe("debrid service", () => { expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); }, 20000); - it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => { + it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => { process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown const settings = { ...defaultSettings(), @@ -2396,8 +2439,43 @@ describe("debrid service", () => { const user1Key = `${getMegaDebridAccountId("user1")}:web`; await expect(service.unrestrictLink("https://rapidgator.net/file/quick-cancel")).rejects.toThrow(); - expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull(); - }, 20000); + expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull(); + }, 20000); + + it("does not cool down a Mega-Web account when the caller aborts after more than eight seconds", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user1", + megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2", + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + const controller = new AbortController(); + let now = 1_000_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + const megaWeb = vi.fn(async () => { + now += 9_000; + controller.abort("stop"); + throw new Error("aborted:debrid"); + }); + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const user1Key = `${getMegaDebridAccountId("user1")}:web`; + + await expect( + service.unrestrictLink("https://rapidgator.net/file/long-caller-cancel", controller.signal) + ).rejects.toThrow(/aborted/i); + + expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull(); + }, 20000); it("respects provider selection and does not append hidden providers", async () => { const settings = { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 723a37e..ae598f2 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -997,7 +997,7 @@ describe("deterministic stop and restart lifecycle", () => { expect(internal.activeTasks.get(itemId)).toBe(newOwner); }); - it("emits an idle snapshot when the earliest provider cooldown expires", async () => { + it("keeps Start available while a configured account is temporarily cooling down", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-08-22T08:00:00.000Z")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-provider-cooldown-event-")); @@ -1025,15 +1025,14 @@ describe("deterministic stop and restart lifecycle", () => { const waiting = manager.getSnapshot(); expect(waiting).toMatchObject({ - canStart: false, + canStart: true, lifecycle: { - phase: "waiting_provider", + phase: "idle", retryAt: Date.parse("2026-08-22T08:00:01.000Z") } }); await vi.advanceTimersByTimeAsync(999); - expect(events.some((snapshot) => snapshot.canStart)).toBe(false); await vi.advanceTimersByTimeAsync(1); expect(events.at(-1)).toMatchObject({ canStart: true, @@ -2312,7 +2311,7 @@ describe("download manager", () => { expect((manager as any).shouldCollapseQuickPostProcessRequeue(packageId)).toBe(false); }); - it("extractNow only re-arms completed items that are not already extracted", () => { + it("extractNow only re-arms completed items that are not already extracted", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-now-")); tempDirs.push(root); @@ -2387,8 +2386,191 @@ describe("download manager", () => { expect((manager as any).session.items["extract-now-item-1"].fullStatus).toBe("Entpackt - Done (<1s)"); expect((manager as any).session.items["extract-now-item-2"].fullStatus).toBe("Entpackt - Done (1.2s)"); expect((manager as any).session.items["extract-now-item-3"].fullStatus).toBe("Entpacken - Ausstehend"); - expect((manager as any).session.packages[packageId].status).toBe("queued"); - }); + expect((manager as any).session.packages[packageId].status).toBe("queued"); + }); + + it("extractNow on one multipart child arms only its complete archive set", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-child-")); + tempDirs.push(root); + const session = emptySession(); + const packageId = "extract-child-pkg"; + const outputDir = path.join(root, "downloads", "Extract Child"); + const extractDir = path.join(root, "extract", "Extract Child"); + fs.mkdirSync(outputDir, { recursive: true }); + const createdAt = Date.now(); + const specs = [ + ["e01-1", "Episode.E01.part1.rar"], + ["e01-2", "Episode.E01.part2.rar"], + ["e02-1", "Episode.E02.part1.rar"], + ["e02-2", "Episode.E02.part2.rar"] + ] as const; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Extract Child", + outputDir, + extractDir, + status: "failed", + itemIds: specs.map(([id]) => id), + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + for (const [id, fileName] of specs) { + const targetPath = path.join(outputDir, fileName); + fs.writeFileSync(targetPath, Buffer.alloc(128, 3)); + session.items[id] = { + id, + packageId, + url: `https://example.invalid/${fileName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 128, + totalBytes: 128, + progressPercent: 100, + fileName, + targetPath, + resumable: true, + attempts: 1, + lastError: "Keine entpackten Dateien erkannt", + fullStatus: "Entpack-Fehler: Keine entpackten Dateien erkannt", + createdAt, + updatedAt: createdAt + }; + } + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: true, hybridExtract: true }, + session, + createStoragePaths(path.join(root, "state")) + ); + const postProcess = vi.fn(async () => {}); + (manager as any).runPackagePostProcessing = postProcess; + + manager.extractNow({ packageIds: [], itemIds: ["e01-2"] }); + await waitFor(() => postProcess.mock.calls.length === 1); + + expect((manager as any).session.items["e01-1"].fullStatus).toBe("Entpacken - Ausstehend"); + expect((manager as any).session.items["e01-2"].fullStatus).toBe("Entpacken - Ausstehend"); + expect((manager as any).session.items["e02-1"].fullStatus).toMatch(/^Entpack-Fehler/); + expect((manager as any).session.items["e02-2"].fullStatus).toMatch(/^Entpack-Fehler/); + const filter = (manager as any).manualExtractArchiveFilters.get(packageId) as Set; + expect([...filter].map((filePath) => path.basename(filePath).toLowerCase())).toEqual(["episode.e01.part1.rar"]); + }); + + it("extractNow item selection runs only the selected archive through real post-processing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-selected-real-")); + tempDirs.push(root); + const outputDir = path.join(root, "downloads", "Selected"); + const extractDir = path.join(root, "extract", "Selected"); + fs.mkdirSync(outputDir, { recursive: true }); + const firstArchive = path.join(outputDir, "Episode.E01.zip"); + const secondArchive = path.join(outputDir, "Episode.E02.zip"); + const firstZip = new AdmZip(); + firstZip.addFile("Episode.E01.mkv", Buffer.from("episode-one")); + firstZip.writeZip(firstArchive); + const secondZip = new AdmZip(); + secondZip.addFile("Episode.E02.mkv", Buffer.from("episode-two")); + secondZip.writeZip(secondArchive); + const createdAt = Date.now(); + const session = emptySession(); + const packageId = "selected-real-package"; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Selected", + outputDir, + extractDir, + status: "failed", + itemIds: ["selected-e01", "selected-e02"], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + for (const [id, archivePath] of [["selected-e01", firstArchive], ["selected-e02", secondArchive]] as const) { + const size = fs.statSync(archivePath).size; + session.items[id] = { + id, + packageId, + url: `https://example.invalid/${path.basename(archivePath)}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: size, + totalBytes: size, + progressPercent: 100, + fileName: path.basename(archivePath), + targetPath: archivePath, + resumable: true, + attempts: 1, + lastError: "Keine entpackten Dateien erkannt", + fullStatus: "Entpack-Fehler: Keine entpackten Dateien erkannt", + createdAt, + updatedAt: createdAt + }; + } + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir, + extractDir, + autoExtract: false, + hybridExtract: true, + cleanupMode: "none", + removeLinkFilesAfterExtract: false, + removeSamplesAfterExtract: false, + autoRename4sf4sj: false, + keepGermanAudioOnly: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + manager.extractNow({ packageIds: [], itemIds: ["selected-e01"] }); + await waitFor(() => fs.existsSync(path.join(extractDir, "Episode.E01.mkv")), 10_000); + await waitFor(() => !(manager as any).packagePostProcessTasks.has(packageId), 10_000); + await waitFor(() => !(manager as any).packageDeferredPostProcessTasks.has(packageId), 10_000); + + const snapshot = manager.getSnapshot().session; + expect(snapshot.items["selected-e01"].fullStatus).toMatch(/^Entpackt/); + expect(snapshot.items["selected-e02"].fullStatus).toMatch(/^Entpack-Fehler/); + expect(snapshot.packages[packageId].status).toBe("failed"); + expect(fs.existsSync(path.join(extractDir, "Episode.E02.mkv"))).toBe(false); + }, 15_000); + + it("assigns same-named archive failures only to the matching directory", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-scope-")); + tempDirs.push(root); + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + const firstPath = path.join(root, "Season 01", "release.part1.rar"); + const secondPath = path.join(root, "Season 02", "release.part1.rar"); + const items = [ + { id: "season-1", status: "completed", fullStatus: "Entpacken - Error", fileName: "release.part1.rar", targetPath: firstPath, downloadedBytes: 100 }, + { id: "season-2", status: "completed", fullStatus: "Entpack-Fehler: Previous", fileName: "release.part1.rar", targetPath: secondPath, downloadedBytes: 100 } + ] as unknown as DownloadItem[]; + const failures = new Map([[firstPath.toLowerCase(), { + archiveName: "release.part1.rar", + archivePath: firstPath, + errorText: "CRC failed" + }]]); + + (manager as any).applyPackageExtractFailureStatuses( + items, + (archiveName: string, archivePath: string) => resolveArchiveItemsFromList(archiveName, items, archivePath), + failures, + "Entpacken fehlgeschlagen", + new Map(items.map((item) => [item.id, item.fullStatus])), + Date.now() + ); + + expect(items[0].fullStatus).toMatch(/^Entpack-Fehler/); + expect(items[1].fullStatus).toBe("Entpack-Fehler: Previous"); + }); it("merges duplicate-suffixed completed startup items back into the canonical queued item", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-merge-")); @@ -6831,9 +7013,10 @@ describe("download manager", () => { const changed = (manager as any).autoRecoverArchiveCrcFailure( session.packages[packageId], itemIds.map((itemId) => session.items[itemId]!), - { - archiveName: "show.s01e01.part1.rar", - errorText: "Checksum error in the encrypted file", + { + archiveName: "show.s01e01.part1.rar", + archivePath: path.join(outputDir, "show.s01e01.part1.rar"), + errorText: "Checksum error in the encrypted file", category: "crc_error", suggestRedownload: true, jvmFailureReason: "Can not open the file as archive" @@ -6924,9 +7107,10 @@ describe("download manager", () => { const changed = (manager as any).autoRecoverArchiveCrcFailure( session.packages[packageId], itemIds.map((itemId) => session.items[itemId]!), - { - archiveName: "show.s01e01.part1.rar", - errorText: "Checksum error in the encrypted file", + { + archiveName: "show.s01e01.part1.rar", + archivePath: path.join(outputDir, "show.s01e01.part1.rar"), + errorText: "Checksum error in the encrypted file", category: "crc_error", suggestRedownload: true, jvmFailureReason: "Can not open the file as archive" @@ -7017,9 +7201,10 @@ describe("download manager", () => { const changed = (manager as any).autoRecoverArchiveCrcFailure( session.packages[packageId], itemIds.map((itemId) => session.items[itemId]!), - { - archiveName: "show.s01e01.part1.rar", - errorText: "Checksum error in the encrypted file", + { + archiveName: "show.s01e01.part1.rar", + archivePath: path.join(outputDir, "show.s01e01.part1.rar"), + errorText: "Checksum error in the encrypted file", category: "crc_error", suggestRedownload: true, jvmFailureReason: "Can not open the file as archive" @@ -7358,7 +7543,7 @@ describe("download manager", () => { } completedItems[0].fullStatus = "Entpacken - Error"; completedItems[1].fullStatus = "Entpacken - Error"; - const resolveArchiveItems = (archiveName: string) => { + const resolveArchiveItems = (archiveName: string, _archivePath?: string) => { const base = archiveName.replace(/\.part0*1\.rar$/i, ""); return completedItems.filter((item: any) => String(item.fileName || "").toLowerCase().startsWith(`${base}.part`)); }; @@ -7367,7 +7552,11 @@ describe("download manager", () => { {}, completedItems, resolveArchiveItems, - new Map([["show.s01e01.part1.rar", "Checksum error in the encrypted file"]]), + new Map([["show.s01e01.part1.rar", { + archiveName: "show.s01e01.part1.rar", + archivePath: path.resolve("show.s01e01.part1.rar"), + errorText: "Checksum error in the encrypted file" + }]]), "Checksum error in the encrypted file", previousStatuses, createdAt + 5_000 @@ -7415,8 +7604,12 @@ describe("download manager", () => { (DownloadManager.prototype as any).applyPackageExtractFailureStatuses.call( {}, completedItems, - (archiveName: string) => resolveArchiveItemsFromList(archiveName, completedItems), - new Map([["show.s01e01.part1.rar", "Checksum error in the encrypted file"]]), + (archiveName: string, archivePath: string) => resolveArchiveItemsFromList(archiveName, completedItems, archivePath), + new Map([["show.s01e01.part1.rar", { + archiveName: "show.s01e01.part1.rar", + archivePath: path.resolve("show.s01e01.part1.rar"), + errorText: "Checksum error in the encrypted file" + }]]), "Checksum error in the encrypted file", previousStatuses, createdAt + 5_000 @@ -8228,17 +8421,21 @@ describe("download manager", () => { autoExtract: false }, session, - createStoragePaths(path.join(root, "state")) - ); - - manager.clearAll(); - const snapshot = manager.getSnapshot(); + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).manualExtractArchiveFilters.set(packageId, new Set([targetPath])); + (manager as any).manualExtractPackages.add(packageId); + manager.clearAll(); + const snapshot = manager.getSnapshot(); expect(snapshot.stats.totalPackages).toBe(0); expect(snapshot.stats.totalFiles).toBe(0); expect(snapshot.stats.totalDownloaded).toBe(0); - expect(snapshot.session.totalDownloadedBytes).toBe(0); - expect(snapshot.session.runStartedAt).toBe(0); - }); + expect(snapshot.session.totalDownloadedBytes).toBe(0); + expect(snapshot.session.runStartedAt).toBe(0); + expect((manager as any).manualExtractArchiveFilters.size).toBe(0); + expect((manager as any).manualExtractPackages.size).toBe(0); + }); it("keeps cumulative session totals when completed items are removed from the queue", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); @@ -9237,7 +9434,7 @@ describe("download manager", () => { expect(snap.settings.providerDailyUsageBytes || {}).toEqual({}); }); - it("resets extraction state atomically for selected package items", () => { + it("resets extraction state without discarding definitive link availability", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const session = emptySession(); @@ -9293,7 +9490,9 @@ describe("download manager", () => { createStoragePaths(path.join(root, "state")) ); - manager.resetItems(itemIds); + (manager as any).manualExtractArchiveFilters.set(packageId, new Set(["stale-archive"])); + await manager.resetItems(itemIds); + expect((manager as any).manualExtractArchiveFilters.has(packageId)).toBe(false); const snapshot = manager.getSnapshot().session; expect(snapshot.packages[packageId]).toEqual(expect.objectContaining({ @@ -9309,9 +9508,15 @@ describe("download manager", () => { progressPercent: 0, lastError: "", fullStatus: "Wartet", - onlineStatus: undefined + onlineStatus: "online" })); } + + await manager.resetPackage(packageId); + const packageSnapshot = manager.getSnapshot().session; + for (const itemId of itemIds) { + expect(packageSnapshot.items[itemId].onlineStatus).toBe("online"); + } }); it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => { diff --git a/tests/download-sort.test.ts b/tests/download-sort.test.ts new file mode 100644 index 0000000..3fbf363 --- /dev/null +++ b/tests/download-sort.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { sortPackageOrderByService } from "../src/renderer/App"; + +describe("download package sorting", () => { + it("sorts the Service column by its visible provider labels", () => { + const packages = { + a: { id: "a", itemIds: ["item-a"] }, + b: { id: "b", itemIds: ["item-b"] } + } as any; + const items = { + "item-a": { provider: "realdebrid", providerLabel: "Real-Debrid" }, + "item-b": { provider: "debridlink", providerLabel: "Debrid-Link" } + } as any; + + expect(sortPackageOrderByService(["a", "b"], packages, items, false)).toEqual(["b", "a"]); + expect(sortPackageOrderByService(["a", "b"], packages, items, true)).toEqual(["a", "b"]); + }); + + it("uses filtered visible services instead of hidden package items", () => { + const packages = { + a: { id: "a", itemIds: ["a-hidden", "a-visible"] }, + b: { id: "b", itemIds: ["b-visible"] } + } as any; + const items = { + "a-visible": { provider: "debridlink", providerLabel: "ZZZ Visible" }, + "a-hidden": { provider: "realdebrid", providerLabel: "AAA Hidden" }, + "b-visible": { provider: "realdebrid", providerLabel: "Real-Debrid" } + } as any; + + expect(sortPackageOrderByService(["b", "a"], packages, items, false, { + a: [items["a-visible"]], + b: [items["b-visible"]] + })).toEqual(["b", "a"]); + }); +}); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index b7e73c2..e9212bc 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -777,6 +777,36 @@ function findButton(node: ReactNode, label: string): ReactElement { return findElement(node, (element) => element.type === "button" && element.props.children === label); } +function dispatchColumnSortPointerGesture(header: ReactElement, label: string, clientXs: readonly number[], deliverPointerClick: boolean): void { + const startX = clientXs[0]; + const endX = clientXs[clientXs.length - 1]; + if (startX === undefined || endX === undefined) throw new Error("Pointer gesture requires coordinates"); + const columnHeader = findElement(header, (element) => element.props["data-download-column"] === "name"); + const sortButton = findElement(columnHeader, (element) => element.type === "button" && String(element.props.children).startsWith(label)); + const capturedPointers = new Set(); + const currentTarget = { + closest: () => null, + hasPointerCapture: (pointerId: number) => capturedPointers.has(pointerId), + releasePointerCapture: (pointerId: number) => capturedPointers.delete(pointerId), + setPointerCapture: (pointerId: number) => capturedPointers.add(pointerId) + }; + const target = { closest: (selector: string) => selector === ".downloads-column-sort" ? {} : null }; + const event = (clientX: number) => ({ + button: 0, + clientX, + currentTarget, + isPrimary: true, + pointerId: 7, + preventDefault: () => {}, + target + }); + + columnHeader.props.onPointerDown(event(startX)); + clientXs.slice(1, -1).forEach((clientX) => columnHeader.props.onPointerMove(event(clientX))); + columnHeader.props.onPointerUp(event(endX)); + if (deliverPointerClick) sortButton.props.onClick({ detail: 1 }); +} + function withRuntime(input: DownloadsModelInput, overrides: Partial = {}): DownloadsViewModel { return { ...buildDownloadsViewModel(input), @@ -1523,6 +1553,9 @@ describe("download table row contracts", () => { expect(getAvailabilitySummary([ item("unknown-a", "package-a", "queued", { onlineStatus: undefined }) ])).toEqual({ online: 0, total: 1, state: "checking" }); + expect(getAvailabilitySummary([ + item("active-a", "package-a", "downloading", { onlineStatus: undefined }) + ])).toEqual({ online: 1, total: 1, state: "online" }); }); it("shows reset package availability as one compact unchecked label", () => { @@ -1549,6 +1582,20 @@ describe("download table row contracts", () => { expect(html).not.toContain(">online"); }); + it("shows an actively downloading item as online even without a stored availability result", () => { + const html = renderToStaticMarkup(ItemRowContent({ + actions: createActions(), + columnOrder: ["name", "availability"], + gridTemplate: "200px 150px", + item: item("active-availability", "package-a", "downloading", { onlineStatus: undefined }), + selected: false + })); + + expect(html).toContain(">Online"); + expect(html).not.toContain(">Ungeprüft"); + expect(html).toContain('class="downloads-link-state online"'); + }); + it("renders availability for package and file rows", () => { const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" }); const packageHtml = renderToStaticMarkup(PackageCardContent({ @@ -1613,7 +1660,7 @@ describe("download table row contracts", () => { selectedVersion: 0 })); - expect(html).toContain(">70%"); + expect(html).toContain(">94%"); }); it("never exposes archive filenames as the visible package status", () => { @@ -1779,7 +1826,7 @@ describe("download table row contracts", () => { expect(html).toMatch(/aria-sort="descending"[^>]*data-download-column="name"/); expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="size"/); - expect(html).not.toMatch(/aria-sort="[^"]+"[^>]*data-download-column="account"/); + expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="account"/); expect(moveLeft.props.type).toBe("button"); expect(calls).toEqual([ ["down", "size", 250], @@ -1788,6 +1835,81 @@ describe("download table row contracts", () => { ]); }); + it.each([ + { clientXs: [100, 100], deliverPointerClick: false }, + { clientXs: [100, 104, 104], deliverPointerClick: true } + ])("sorts exactly once when a captured pointer gesture stays below the drag threshold", ({ clientXs, deliverPointerClick }) => { + const sorted: string[] = []; + const header = DownloadsTableHeader({ + actions: createActions({ onSortColumn: (column) => sorted.push(column) }), + columnOrder: ["name", "size"], + gridTemplate: "200px 100px", + selectedCount: 0, + sortColumn: "name", + sortDirection: "asc", + visibleIds: [] + }); + + dispatchColumnSortPointerGesture(header, "Name", clientXs, deliverPointerClick); + + expect(sorted).toEqual(["name"]); + }); + + it("never sorts when a pointer gesture reaches the drag threshold", () => { + const sorted: string[] = []; + const header = DownloadsTableHeader({ + actions: createActions({ onSortColumn: (column) => sorted.push(column) }), + columnOrder: ["name", "size"], + gridTemplate: "200px 100px", + selectedCount: 0, + sortColumn: "name", + sortDirection: "asc", + visibleIds: [] + }); + + dispatchColumnSortPointerGesture(header, "Name", [100, 105, 101], true); + + expect(sorted).toEqual([]); + }); + + it("keeps sortable headers keyboard operable", () => { + const sorted: string[] = []; + const header = DownloadsTableHeader({ + actions: createActions({ onSortColumn: (column) => sorted.push(column) }), + columnOrder: ["name", "size"], + gridTemplate: "200px 100px", + selectedCount: 0, + sortColumn: "name", + sortDirection: "asc", + visibleIds: [] + }); + const sortButton = findElement(header, (element) => element.type === "button" && String(element.props.children).startsWith("Name")); + + sortButton.props.onClick({ detail: 0 }); + + expect(sorted).toEqual(["name"]); + }); + + it("exposes Service as a sortable column header", () => { + const sorted: string[] = []; + const header = DownloadsTableHeader({ + actions: createActions({ onSortColumn: (column) => sorted.push(column) }), + columnOrder: ["account"], + gridTemplate: "100px", + selectedCount: 0, + sortColumn: "service", + sortDirection: "desc", + visibleIds: [] + }); + const serviceHeader = findElement(header, (element) => element.props["data-download-column"] === "account"); + const sortButton = findElement(serviceHeader, (element) => element.type === "button"); + + sortButton.props.onClick({ detail: 0 }); + + expect(serviceHeader.props["aria-sort"]).toBe("descending"); + expect(sorted).toEqual(["service"]); + }); + it("opens the column menu without letting the same context event close it again", () => { const calls: Array<[string, number, number]> = []; const header = DownloadsTableHeader({ @@ -2088,7 +2210,7 @@ describe("download table row contracts", () => { selectedVersion: 0 })); - expect(html).toMatch(/title="0\/1 · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s); + expect(html).toMatch(/title="0\/1 fertig · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s); }); it("shows only a compact extraction error while retaining diagnostics in the tooltip", () => { @@ -2163,7 +2285,7 @@ describe("download table row contracts", () => { selectedIds: new Set(), selectedVersion: 0 })); - expect(errorHtml).toMatch(/>Entpack-Fehler<\/span>/); + expect(errorHtml).toMatch(/>Download fertig · 1 Entpackfehler<\/span>/); expect(errorHtml).not.toMatch(/>2\/2<\/span>/); }); @@ -2182,7 +2304,7 @@ describe("download table row contracts", () => { })); expect(html.match(/>Download läuft<\/span>/g)).toHaveLength(2); - expect(html).toContain('title="0/1"'); + expect(html).toContain('title="0/1 fertig"'); }); it("commits Enter and the resulting Blur rename sequence exactly once", () => { diff --git a/tests/extract-action.test.ts b/tests/extract-action.test.ts new file mode 100644 index 0000000..c76d53a --- /dev/null +++ b/tests/extract-action.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import type { DownloadItem, PackageEntry } from "../src/shared/types"; +import { buildExtractNowContextAction } from "../src/renderer/views/downloads/extract-action"; + +function item(id: string, packageId: string, status: DownloadItem["status"], fullStatus: string): DownloadItem { + return { + id, + packageId, + url: `https://example.invalid/${id}`, + provider: "realdebrid", + status, + retries: 0, + speedBps: 0, + downloadedBytes: status === "completed" ? 100 : 0, + totalBytes: 100, + progressPercent: status === "completed" ? 100 : 0, + fileName: `${id}.part1.rar`, + targetPath: `C:\\Downloads\\${id}.part1.rar`, + resumable: true, + attempts: 0, + lastError: "", + fullStatus, + createdAt: 1, + updatedAt: 1 + }; +} + +function pkg(id: string, itemIds: string[]): PackageEntry { + return { + id, + name: id, + outputDir: `C:\\Downloads\\${id}`, + extractDir: `C:\\Downloads\\_entpackt\\${id}`, + itemIds, + enabled: true, + cancelled: false, + status: "completed", + priority: "normal", + createdAt: 1, + updatedAt: 1 + }; +} + +describe("extract now context action", () => { + it("targets one completed child item so the manager can resolve its complete archive set", () => { + const items = { part2: item("part2", "pkg-1", "completed", "Fertig") }; + const action = buildExtractNowContextAction({ + contextItemId: "part2", + selectedPackageIds: [], + selectedItemIds: ["part2"], + packages: { "pkg-1": pkg("pkg-1", ["part2"]) }, + items + }); + + expect(action).toEqual({ + label: "Jetzt entpacken", + request: { packageIds: [], itemIds: ["part2"] }, + targetCount: 1 + }); + }); + + it("targets every selected package that has completed unextracted files", () => { + const items = { + a: item("a", "pkg-a", "completed", "Entpack-Fehler: Passwort"), + b: item("b", "pkg-b", "completed", "Entpacken - Ausstehend"), + c: item("c", "pkg-c", "queued", "Wartet") + }; + const action = buildExtractNowContextAction({ + selectedPackageIds: ["pkg-a", "pkg-b", "pkg-c"], + selectedItemIds: [], + packages: { + "pkg-a": pkg("pkg-a", ["a"]), + "pkg-b": pkg("pkg-b", ["b"]), + "pkg-c": pkg("pkg-c", ["c"]) + }, + items + }); + + expect(action).toEqual({ + label: "Jetzt entpacken (2)", + request: { packageIds: ["pkg-a", "pkg-b"], itemIds: [] }, + targetCount: 2 + }); + }); + + it("hides the action for extracted or incomplete selections", () => { + const items = { + extracted: item("extracted", "pkg-1", "completed", "Entpackt in 4s"), + queued: item("queued", "pkg-2", "queued", "Wartet") + }; + expect(buildExtractNowContextAction({ + selectedPackageIds: ["pkg-1", "pkg-2"], + selectedItemIds: [], + packages: { + "pkg-1": pkg("pkg-1", ["extracted"]), + "pkg-2": pkg("pkg-2", ["queued"]) + }, + items + })).toBeNull(); + }); +}); diff --git a/tests/extract-now-request.test.ts b/tests/extract-now-request.test.ts new file mode 100644 index 0000000..c83d3b6 --- /dev/null +++ b/tests/extract-now-request.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { normalizeExtractNowRequest } from "../src/shared/extract-now"; + +describe("extract now request", () => { + it("deduplicates package and item targets while preserving their order", () => { + expect(normalizeExtractNowRequest({ + packageIds: ["pkg-2", "pkg-1", "pkg-2"], + itemIds: ["item-2", "item-1", "item-2"] + })).toEqual({ + packageIds: ["pkg-2", "pkg-1"], + itemIds: ["item-2", "item-1"] + }); + }); + + it("rejects empty, malformed and oversized selections", () => { + expect(() => normalizeExtractNowRequest({ packageIds: [], itemIds: [] })).toThrow(/mindestens/i); + expect(() => normalizeExtractNowRequest({ packageIds: ["pkg"], itemIds: [4] })).toThrow(/itemIds/i); + expect(() => normalizeExtractNowRequest({ packageIds: Array.from({ length: 2001 }, (_, index) => `pkg-${index}`), itemIds: [] })).toThrow(/höchstens/i); + expect(() => normalizeExtractNowRequest({ packageIds: Array.from({ length: 2001 }, () => "pkg"), itemIds: [] })).toThrow(/höchstens/i); + expect(() => normalizeExtractNowRequest({ packageIds: ["p".repeat(257)], itemIds: [] })).toThrow(/Länge/i); + expect(() => normalizeExtractNowRequest({ packageIds: ["pkg"], itemIds: [], extra: true })).toThrow(/unbekannt/i); + expect(() => normalizeExtractNowRequest(null)).toThrow(/Objekt/i); + }); +}); diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index 55e8356..f9c9aab 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -171,6 +171,15 @@ describe("renderer localization", () => { ["Geplant: Heute 22:15", "Scheduled: Today 22:15"], ["Tonspur: 2 OK · 1 ohne DE-Tag · ffmpeg fehlt · 3 Fehler", "Audio track: 2 OK · 1 without DE tag · ffmpeg missing · 3 errors"], ["4/8 fertig · 2 Fehler", "4/8 completed · 2 errors"], + ["7 Entpackfehler · 1 Wiederholung", "7 extraction errors · 1 retry"], + ["Download fertig · 1 Entpackfehler", "Download complete · 1 extraction error"], + ["Jetzt entpacken (2)", "Extract now (2)"], + ["1 Entpackfehler", "1 extraction error"], + ["2 Wiederholungen", "2 retries"], + ["Download fertig", "Download complete"], + ["1 Fehler · 2 abgebrochen", "1 error · 2 cancelled"], + ["1 Entpackfehler · 2 Fehler", "1 extraction error · 2 errors"], + ["Warte auf Festplatte", "Waiting for disk"], ["Entpacken 52%", "Extracting 52%"], ["Fehlgeschlagen nach 3 Versuchen: HTTP 503 von https://host.test/a", "Failed after 3 attempts: HTTP 503 von https://host.test/a"], ["Update-Check fehlgeschlagen: ECONNRESET https://api.test/v1", "Update check failed: ECONNRESET https://api.test/v1"], diff --git a/tests/link-addresses-dialog.test.tsx b/tests/link-addresses-dialog.test.tsx new file mode 100644 index 0000000..b78a088 --- /dev/null +++ b/tests/link-addresses-dialog.test.tsx @@ -0,0 +1,119 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { LinkAddressesDialog, type LinkAddressesDialogProps } from "../src/renderer/ui/LinkAddressesDialog"; + +function findElements(node: ReactNode, predicate: (element: ReactElement>) => boolean): ReactElement>[] { + if (Array.isArray(node)) { + return node.flatMap((child) => findElements(child, predicate)); + } + if (!isValidElement>(node)) { + return []; + } + const matches = predicate(node) ? [node] : []; + return [...matches, ...findElements(node.props.children as ReactNode, predicate)]; +} + +function createDialog(overrides: Partial = {}): ReactElement { + return LinkAddressesDialog({ + title: "Testpaket", + links: [ + { name: "Erste Datei.mkv", url: "https://example.com/first" }, + { name: "Zweite Datei.mkv", url: "https://example.com/second" } + ], + isPackage: true, + onClose: vi.fn(), + writeClipboardText: vi.fn(async () => true), + onToast: vi.fn(), + ...overrides + }); +} + +function buttonByText(tree: ReactElement, label: string): ReactElement> { + const button = findElements(tree, (element) => element.type === "button" && element.props.children === label)[0]; + expect(button, `Button ${label} fehlt`).toBeDefined(); + return button; +} + +async function click(button: ReactElement>): Promise { + const onClick = button.props.onClick as (() => void | Promise) | undefined; + expect(onClick).toBeTypeOf("function"); + await onClick?.(); +} + +describe("LinkAddressesDialog", () => { + it("kopiert einzelne Namen und URLs ausschließlich über den sicheren Writer", async () => { + const writeClipboardText = vi.fn(async () => true); + const onToast = vi.fn(); + const tree = createDialog({ writeClipboardText, onToast }); + + const firstName = findElements(tree, (element) => element.type === "button" && element.props["aria-label"] === "Erste Datei.mkv kopieren")[0]; + const firstUrl = findElements(tree, (element) => element.type === "button" && element.props["aria-label"] === "Link kopieren")[0]; + await click(firstName); + await click(firstUrl); + + expect(writeClipboardText).toHaveBeenNthCalledWith(1, "Erste Datei.mkv"); + expect(writeClipboardText).toHaveBeenNthCalledWith(2, "https://example.com/first"); + expect(onToast).toHaveBeenNthCalledWith(1, "Name kopiert"); + expect(onToast).toHaveBeenNthCalledWith(2, "Link kopiert"); + }); + + it("meldet Erfolg nur bei true und behandelt false sowie Ablehnungen als Fehler", async () => { + const writeClipboardText = vi.fn() + .mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error("clipboard unavailable")); + const onToast = vi.fn(); + const tree = createDialog({ writeClipboardText, onToast }); + + await click(buttonByText(tree, "Alle Namen kopieren")); + await click(buttonByText(tree, "Alle Links kopieren")); + + expect(onToast).toHaveBeenNthCalledWith(1, "Kopieren fehlgeschlagen"); + expect(onToast).toHaveBeenNthCalledWith(2, "Kopieren fehlgeschlagen"); + expect(onToast).not.toHaveBeenCalledWith("Alle Namen kopiert"); + expect(onToast).not.toHaveBeenCalledWith("Alle Links kopiert"); + }); + + it("übergibt große Pakettexte ohne Kürzung oder Normalisierung", async () => { + const longName = `Groß-${"n".repeat(300_000)}`; + const longUrl = `https://example.com/${"u".repeat(300_000)}`; + const writeClipboardText = vi.fn(async () => true); + const onToast = vi.fn(); + const tree = createDialog({ + links: [ + { name: longName, url: longUrl }, + { name: " Zeilenende ", url: "https://example.com/trailing " } + ], + writeClipboardText, + onToast + }); + + await click(buttonByText(tree, "Alle Namen kopieren")); + await click(buttonByText(tree, "Alle Links kopieren")); + + expect(writeClipboardText).toHaveBeenNthCalledWith(1, `${longName}\n Zeilenende `); + expect(writeClipboardText).toHaveBeenNthCalledWith(2, `${longUrl}\nhttps://example.com/trailing `); + expect(onToast).toHaveBeenNthCalledWith(1, "Alle Namen kopiert"); + expect(onToast).toHaveBeenNthCalledWith(2, "Alle Links kopiert"); + }); + + it("behält Dialogdesign, Paketaktionen und Schließen-Verhalten bei", async () => { + const onClose = vi.fn(); + const packageTree = createDialog({ onClose }); + const singleTree = createDialog({ isPackage: false }); + const dialog = findElements(packageTree, (element) => typeof element.type === "function")[0]; + + expect(dialog.props.className).toBe("link-popup"); + expect(dialog.props.size).toBe("wide"); + expect(dialog.props.title).toBe("Linkadressen anzeigen"); + expect(findElements(packageTree, (element) => element.props.className === "link-popup-row")).toHaveLength(2); + expect(findElements(packageTree, (element) => element.props.className === "link-popup-name link-popup-click")).toHaveLength(2); + expect(findElements(packageTree, (element) => element.props.className === "link-popup-url link-popup-click")).toHaveLength(2); + expect(buttonByText(packageTree, "Alle Namen kopieren")).toBeDefined(); + expect(buttonByText(packageTree, "Alle Links kopieren")).toBeDefined(); + expect(findElements(singleTree, (element) => element.type === "button" && element.props.children === "Alle Namen kopieren")).toHaveLength(0); + expect(findElements(singleTree, (element) => element.type === "button" && element.props.children === "Alle Links kopieren")).toHaveLength(0); + + await click(buttonByText(packageTree, "Schließen")); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/mega-web-fallback.test.ts b/tests/mega-web-fallback.test.ts index 6ac568c..7b88a56 100644 --- a/tests/mega-web-fallback.test.ts +++ b/tests/mega-web-fallback.test.ts @@ -346,7 +346,7 @@ describe("mega-web-fallback", () => { expect(maxActiveLogins).toBe(2); }, 10000); - it("aborts pending Mega-Web polling when signal is cancelled", async () => { + it("aborts pending Mega-Web polling when signal is cancelled", async () => { globalThis.fetch = vi.fn((url: string | URL | Request, init?: RequestInit): Promise => { const urlStr = String(url); @@ -394,10 +394,60 @@ describe("mega-web-fallback", () => { await expect(fallback.unrestrict("https://mega.debrid/link2", controller.signal)).rejects.toThrow(/aborted/i); } finally { clearTimeout(timer); - } - }); - - it("klassifiziert einen Abbruch WAEHREND in der Queue als Queue-Timeout (nicht harter Abbruch), damit der belegte Account nicht bestraft wird", async () => { + } + }); + + it("starts an already queued account job after caller abort even when the old raw job ignores its signal", async () => { + let releaseFirstLogin: () => void = () => {}; + let markFirstLoginStarted: () => void = () => {}; + const firstLoginGate = new Promise((resolve) => { + releaseFirstLogin = resolve; + }); + const firstLoginStarted = new Promise((resolve) => { + markFirstLoginStarted = resolve; + }); + const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" })); + const internals = fallback as unknown as { + login: (login: string, password: string) => Promise; + generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string }>; + sessions: Map; + }; + let loginCount = 0; + vi.spyOn(internals, "login").mockImplementation(async () => { + loginCount += 1; + if (loginCount === 1) { + markFirstLoginStarted(); + await firstLoginGate; + return "stale-cookie"; + } + return "fresh-cookie"; + }); + vi.spyOn(internals, "generate").mockImplementation(async (link, cookie) => ({ + directUrl: `https://mega.direct/${cookie}/${link.endsWith("second") ? "second" : "first"}`, + fileName: "result.bin" + })); + const firstController = new AbortController(); + const first = fallback.unrestrict("https://mega.debrid/first", firstController.signal, { login: "same", password: "pw" }); + await firstLoginStarted; + const second = fallback.unrestrict("https://mega.debrid/second", undefined, { login: "same", password: "pw" }); + firstController.abort("stop"); + await expect(first).rejects.toThrow(/aborted/i); + + try { + const outcome = await Promise.race([ + second.then((result) => result?.directUrl || "missing"), + new Promise((resolve) => setTimeout(() => resolve("blocked"), 150)) + ]); + expect(outcome).toBe("https://mega.direct/fresh-cookie/second"); + } finally { + releaseFirstLogin(); + } + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(internals.sessions.get("same")?.cookie).toBe("fresh-cookie"); + }); + + it("klassifiziert einen Abbruch WAEHREND in der Queue als Queue-Timeout (nicht harter Abbruch), damit der belegte Account nicht bestraft wird", async () => { let releaseLogin: () => void = () => {}; const loginGate = new Promise((resolve) => { releaseLogin = resolve; }); globalThis.fetch = vi.fn(async (url: string | URL | Request) => { diff --git a/tests/package-presentation.test.ts b/tests/package-presentation.test.ts new file mode 100644 index 0000000..fd2a775 --- /dev/null +++ b/tests/package-presentation.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import type { DownloadItem, PackageEntry } from "../src/shared/types"; +import type { DownloadPackageRow } from "../src/renderer/views/downloads/downloads-model"; +import { buildPackagePresentation } from "../src/renderer/views/downloads/package-presentation"; + +function item(id: string, fullStatus: string, overrides: Partial = {}): DownloadItem { + return { + id, + packageId: "pkg", + url: `https://example.invalid/${id}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 100, + totalBytes: 100, + progressPercent: 100, + fileName: `${id}.rar`, + targetPath: `C:\\Downloads\\${id}.rar`, + resumable: true, + attempts: 0, + lastError: "", + fullStatus, + createdAt: 1, + updatedAt: 1, + ...overrides + }; +} + +function row(items: DownloadItem[], overrides: Partial = {}): DownloadPackageRow { + const entry = { + id: "pkg", + name: "Paket", + outputDir: "C:\\Downloads\\Paket", + extractDir: "C:\\Downloads\\_entpackt\\Paket", + itemIds: items.map((entry) => entry.id), + enabled: true, + cancelled: false, + status: "completed", + priority: "normal", + createdAt: 1, + updatedAt: 1, + ...overrides + } as PackageEntry; + return { package: entry, items, allItems: items, collapsed: true }; +} + +describe("download package presentation", () => { + it("reserves 90 percent for completed downloads and 10 percent for extraction", () => { + expect(buildPackagePresentation(row([ + item("a", "Entpack-Fehler: Passwort"), + item("b", "Entpack-Fehler: CRC") + ])).progress.value).toBe(90); + + expect(buildPackagePresentation(row([ + item("a", "Entpackt in 4s"), + item("b", "Entpack-Fehler: CRC") + ])).progress.value).toBe(95); + + expect(buildPackagePresentation(row([ + item("a", "Entpackt in 4s"), + item("b", "Entpackt in 5s") + ])).progress.value).toBe(100); + }); + + it("keeps ordinary completed downloads at 100 percent when no extraction phase exists", () => { + const presentation = buildPackagePresentation(row([ + item("a", "Fertig"), + item("b", "Fertig") + ])); + expect(presentation.progress.value).toBe(100); + expect(presentation.status).toBe("Fertig"); + }); + + it("does not move backwards when an archive download enters extraction", () => { + const active = item("archive", "Download läuft", { + status: "downloading", + downloadedBytes: 99, + progressPercent: 99 + }); + const before = buildPackagePresentation(row([active], { status: "downloading" })); + const after = buildPackagePresentation(row([{ ...active, status: "completed", downloadedBytes: 100, progressPercent: 100, fullStatus: "Entpacken - Ausstehend" }], { status: "extracting" })); + + expect(before.progress.value).toBe(89); + expect(after.progress.value).toBe(90); + }); + + it("summarizes mixed extraction errors and a live retry instead of showing a fraction", () => { + const items = [ + ...Array.from({ length: 7 }, (_, index) => item(`failed-${index}`, "Entpack-Fehler: Keine entpackten Dateien erkannt")), + item("retry", "Link-Umwandlung erneut, Versuch 6/...", { + status: "validating", + retries: 6, + downloadedBytes: 0, + progressPercent: 0 + }) + ]; + const presentation = buildPackagePresentation(row(items, { status: "queued" })); + + expect(presentation.status).toBe("7 Entpackfehler · 1 Wiederholung"); + expect(presentation.details).toContain("7 Entpackfehler"); + expect(presentation.details).toContain("1 Wiederholung"); + }); + + it("keeps a single normal active download compact", () => { + const presentation = buildPackagePresentation(row([ + item("active", "Download läuft", { + status: "downloading", + downloadedBytes: 50, + progressPercent: 50 + }) + ], { status: "downloading" })); + + expect(presentation.status).toBe("Download läuft"); + }); +}); diff --git a/tests/package-telemetry.test.ts b/tests/package-telemetry.test.ts index 9cbd4b0..0d25763 100644 --- a/tests/package-telemetry.test.ts +++ b/tests/package-telemetry.test.ts @@ -158,6 +158,24 @@ describe("package lifecycle telemetry", () => { })); }); + it("keeps a completed download with an extraction error out of successful package results", () => { + const item = { ...downloadItem("item-1"), fullStatus: "Entpack-Fehler: falsches Passwort", lastError: "falsches Passwort" }; + const result = finalizePackageResult(telemetry({ + package: packageEntry({ status: "failed", itemIds: [item.id] }), + items: [item], + archiveOperations: [] + })); + + expect(result).toEqual(expect.objectContaining({ + status: "failed", + successfulFiles: 0, + failedFiles: 1, + extractionFailures: 1, + failurePhase: "extract", + errorCategory: "Entpacken" + })); + }); + it("classifies a package with no successful files and a download failure as failed", () => { const item = downloadItem("item-1", "failed"); const result = finalizePackageResult(telemetry({ diff --git a/tests/resolve-archive-items.test.ts b/tests/resolve-archive-items.test.ts index 487f62c..6307e11 100644 --- a/tests/resolve-archive-items.test.ts +++ b/tests/resolve-archive-items.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { resolveArchiveItemsFromList } from "../src/main/download-manager"; +import { + markPlannedHybridArchiveItemsPending, + resolveArchiveItemsFromList, + resolveSelectedArchiveSetsFromCandidates, +} from "../src/main/download-manager"; type MinimalItem = { targetPath?: string; @@ -16,7 +20,7 @@ function makeItems(names: string[]): MinimalItem[] { })); } -describe("resolveArchiveItemsFromList", () => { +describe("resolveArchiveItemsFromList", () => { it("matches multipart .part1.rar archives", () => { const items = makeItems([ @@ -139,7 +143,7 @@ describe("resolveArchiveItemsFromList", () => { expect(result).toHaveLength(2); }); - it("does not cross-match different archive groups", () => { + it("does not cross-match different archive groups", () => { const items = makeItems([ "Episode.S01E01.part1.rar", "Episode.S01E01.part2.rar", @@ -152,6 +156,106 @@ describe("resolveArchiveItemsFromList", () => { const result2 = resolveArchiveItemsFromList("Episode.S01E02.part1.rar", items as any); expect(result2).toHaveLength(2); - expect(result2.every((i: any) => i.fileName.includes("S01E02"))).toBe(true); - }); -}); + expect(result2.every((i: any) => i.fileName.includes("S01E02"))).toBe(true); + }); + + it("resolves every multipart volume beside the selected non-first part without crossing directories", () => { + const items = [ + { + targetPath: "C:\\Downloads\\Package\\Disc A\\Movie.part1.rar", + fileName: "Movie.part1.rar", + id: "disc-a-part-1", + status: "completed", + }, + { + targetPath: "C:\\Downloads\\Package\\Disc A\\Movie.part2.rar", + fileName: "Movie.part2.rar", + id: "disc-a-part-2", + status: "completed", + }, + { + targetPath: "C:\\Downloads\\Package\\Disc B\\Movie.part1.rar", + fileName: "Movie.part1.rar", + id: "disc-b-part-1", + status: "completed", + }, + { + targetPath: "C:\\Downloads\\Package\\Disc B\\Movie.part2.rar", + fileName: "Movie.part2.rar", + id: "disc-b-part-2", + status: "completed", + }, + ]; + + const result = resolveArchiveItemsFromList( + "Movie.part2.rar", + items as any, + "C:\\Downloads\\Package\\Disc A\\Movie.part2.rar" + ); + + expect(result.map((item: any) => item.id)).toEqual([ + "disc-a-part-1", + "disc-a-part-2", + ]); + }); +}); + +describe("resolveSelectedArchiveSetsFromCandidates", () => { + it("maps a selected non-first part to its canonical archive and complete multipart set", () => { + const items = [ + { id: "e01-1", fileName: "Episode.E01.part1.rar", targetPath: "C:\\Downloads\\Episode.E01.part1.rar", status: "completed" }, + { id: "e01-2", fileName: "Episode.E01.part2.rar", targetPath: "C:\\Downloads\\Episode.E01.part2.rar", status: "completed" }, + { id: "e02-1", fileName: "Episode.E02.part1.rar", targetPath: "C:\\Downloads\\Episode.E02.part1.rar", status: "completed" }, + { id: "e02-2", fileName: "Episode.E02.part2.rar", targetPath: "C:\\Downloads\\Episode.E02.part2.rar", status: "completed" } + ]; + const selected = resolveSelectedArchiveSetsFromCandidates( + ["C:\\Downloads\\Episode.E01.part1.rar", "C:\\Downloads\\Episode.E02.part1.rar"], + items as any, + new Set(["e01-2"]) + ); + + expect([...selected.archivePaths]).toEqual(["C:\\Downloads\\Episode.E01.part1.rar"]); + expect([...selected.itemIds].sort()).toEqual(["e01-1", "e01-2"]); + }); +}); + +describe("markPlannedHybridArchiveItemsPending", () => { + it("keeps unplanned incomplete archive groups waiting", () => { + const items = [ + { + id: "planned-part-1", + status: "completed", + fullStatus: "Entpacken - Warten auf Parts", + updatedAt: 1, + }, + { + id: "foreign-part-1", + status: "completed", + fullStatus: "Entpacken - Warten auf Parts", + updatedAt: 2, + }, + ]; + + const changed = markPlannedHybridArchiveItemsPending( + items as any, + new Set(["planned-part-1"]), + 100 + ); + + expect(changed).toBe(true); + expect(items).toEqual([ + { + id: "planned-part-1", + status: "completed", + fullStatus: "Entpacken - Ausstehend", + updatedAt: 100, + }, + { + id: "foreign-part-1", + status: "completed", + fullStatus: "Entpacken - Warten auf Parts", + updatedAt: 2, + }, + ]); + }); +}); diff --git a/tests/visual/download-disclosure-transition.test.tsx b/tests/visual/download-disclosure-transition.test.tsx index bab12bc..72daee5 100644 --- a/tests/visual/download-disclosure-transition.test.tsx +++ b/tests/visual/download-disclosure-transition.test.tsx @@ -274,6 +274,35 @@ describe("download disclosure in the headless visual harness", () => { throw new Error(`Visual driver capture did not reach its ready state: ${name}`); } + it("sorts package rows through a real captured pointer click", async () => { + await loadDenseDownloads(1500); + if (!client) throw new Error("Chrome DevTools client is missing"); + const point = await client.evaluate<{ x: number; y: number }>(`(() => { + const button = [...document.querySelectorAll('.downloads-column-sort')].find((entry) => entry.textContent?.startsWith('Name')); + if (!(button instanceof HTMLElement)) throw new Error('Name sort button missing'); + const rect = button.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + })()`); + const readState = (): Promise<{ ariaSort: string | null; names: string[] }> => client!.evaluate(`(() => ({ + ariaSort: document.querySelector('[data-download-column="name"]')?.getAttribute('aria-sort') || null, + names: [...document.querySelectorAll('.downloads-package-row .downloads-name-cell strong')].map((entry) => entry.textContent || '') + }))()`); + + await client.send("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", clickCount: 1 }); + await client.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", clickCount: 1 }); + await delay(120); + const descending = await readState(); + await client.send("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", clickCount: 1 }); + await client.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", clickCount: 1 }); + await delay(120); + const ascending = await readState(); + + expect(descending.ariaSort).toBe("descending"); + expect(ascending.ariaSort).toBe("ascending"); + expect(descending.names.length).toBeGreaterThan(1); + expect(ascending.names).toEqual([...descending.names].reverse()); + }); + async function measureDisclosure(action: "einklappen" | "ausklappen"): Promise { if (!client) throw new Error("Chrome DevTools client is missing"); return client.evaluate(`(async () => { diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index a06f8db..a39f08e 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -359,10 +359,19 @@ export function createVisualElectronApi( entry.status = "extracting"; } }, - extractNow: async (packageId) => { - const entry = fixture.snapshot.session.packages[packageId]; - if (entry) { - entry.status = "extracting"; + extractNow: async (request) => { + for (const packageId of request.packageIds) { + const entry = fixture.snapshot.session.packages[packageId]; + if (entry) { + entry.status = "extracting"; + } + } + for (const itemId of request.itemIds) { + const item = fixture.snapshot.session.items[itemId]; + const entry = item ? fixture.snapshot.session.packages[item.packageId] : undefined; + if (entry) { + entry.status = "extracting"; + } } }, resetPackage: async (packageId) => {