diff --git a/CHANGELOG.md b/CHANGELOG.md index 6558a63..bb2ce28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [2.0.33] - 2026-08-13 + +### Account and provider lifecycle + +- Separated Mega-Debrid API and Web account status, rotation position, sticky selection, cooldowns, and reloaded status records. +- Applied credential, access-mode, provider enable, routing, and account-pool changes to active link conversions without requiring an application restart. +- Tracked the provider currently handling each fallback attempt so live provider changes abort the exact in-flight conversion before stale credentials can start a download. +- Cleared stale provider cooldowns when accounts or providers are re-enabled and immediately retried affected queued work through the current routing rules. +- Made Real-Debrid, AllDebrid, and BestDebrid Web conversions abortable even when a remote login callback does not observe cancellation. +- Closed and discarded crashed Real-Debrid and AllDebrid login windows so a new login attempt starts with a clean browser session. + +### Download and recovery reliability + +- Bounded HTTP 416 clean-restart recovery across application restarts and preserved locked partial files without reporting false progress or looping indefinitely. +- Updated disk reservations from the authoritative HTTP content length before writing data when a provider did not report the final file size. +- Made post-header disk capacity checks abortable so Pause and Stop release the response and reservation queue even when a volume query stalls. +- Preserved completed package totals and history values when automatic cleanup removes finished files or packages. +- Kept failed and recovery-relevant queue entries represented in bounded support diagnostics even when large queues contain newer waiting items. + +### Interface and history consistency + +- Kept context menus inside the current window bounds while the application is resized. +- Shortened visible provider, archive extraction, and recovery errors while retaining their diagnostic details outside the compact status column. +- Canonicalized RapidGator and rg.to history entries as one hoster. +- Preserved mode-specific account status records across settings reloads. +- Prevented automatic history cleanup failures from blocking startup, retained the previous retention setting when a requested cleanup fails, and surfaced manual deletion failures accurately. + +### Support privacy + +- Removed configured account identifiers and credential-derived provider details from support summaries. +- Redacted package and file names discovered inside embedded package and item logs before writing the support archive. +- Redacted snapshot and runtime package and file names from current and rotated main downloader logs included in support archives. +- Prioritized active, failed, and recovery-pending diagnostics while keeping support bundle collection bounded and responsive. + ## [2.0.32] - 2026-08-13 ### Account controls diff --git a/package-lock.json b/package-lock.json index 0535e14..77c551a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "2.0.32", + "version": "2.0.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "2.0.32", + "version": "2.0.33", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 2f5c3e2..18937fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "2.0.32", + "version": "2.0.33", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/account-check.ts b/src/main/account-check.ts index f26dff0..4c4d2bc 100644 --- a/src/main/account-check.ts +++ b/src/main/account-check.ts @@ -1,5 +1,5 @@ -import type { AppSettings, DebridAccountStatus } from "../shared/types"; -import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts"; +import type { AppSettings, DebridAccountStatus } from "../shared/types"; +import { getMegaDebridAccountsForMode, getMegaDebridAccountStatusId, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts"; import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys"; import { logger } from "./logger"; import { compactErrorText } from "./utils"; @@ -48,8 +48,8 @@ export async function checkMegaDebridAccount( signal?: AbortSignal, now = Date.now() ): Promise { - const base: DebridAccountStatus = { - accountId: account.id, + const base: DebridAccountStatus = { + accountId: account.mode ? getMegaDebridAccountStatusId(account.id, account.mode) : account.id, provider: "megadebrid", label: account.label, maskedLogin: account.maskedLogin, @@ -163,7 +163,8 @@ export async function checkAllDebridAccounts( signal?: AbortSignal ): Promise { const now = Date.now(); - const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || ""); + const megaAccounts = (["api", "web"] as const) + .flatMap((mode) => getMegaDebridAccountsForMode(settings, mode)); const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); const taskFns: Array<() => Promise> = [ diff --git a/src/main/all-debrid-web.ts b/src/main/all-debrid-web.ts index 2e41bc6..390d18c 100644 --- a/src/main/all-debrid-web.ts +++ b/src/main/all-debrid-web.ts @@ -309,8 +309,17 @@ export class AllDebridWebFallback { providerHosts: ALLDEBRID_LOGIN_HOSTS, externalHosts: ALLDEBRID_LOGIN_HOSTS }); - window.setMenuBarVisibility(false); - window.on("closed", () => { + window.setMenuBarVisibility(false); + window.webContents.on("render-process-gone", () => { + if (this.loginWindow === window) { + this.loginWindow = null; + this.loginWindowPartition = ""; + } + if (!window.isDestroyed()) { + window.close(); + } + }); + window.on("closed", () => { if (this.loginWindow === window) { this.loginWindow = null; this.loginWindowPartition = ""; diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 858bfe6..eaa49a3 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -474,6 +474,16 @@ export class AppController { return previousSettings; } + const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; + const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries + || previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays; + if (retentionChanged && !resetHistoryForRetention(this.storagePaths, nextSettings.historyRetentionMode)) { + this.audit("ERROR", "Verlaufseinstellung nicht geändert", { + requestedMode: nextSettings.historyRetentionMode, + activeMode: previousSettings.historyRetentionMode + }); + return previousSettings; + } if (previousSettings.logStorageLocation !== nextSettings.logStorageLocation && !this.reconfigureLogStorage(nextSettings.logStorageLocation)) { nextSettings = normalizeSettings({ @@ -482,14 +492,9 @@ export class AppController { }); } this.overlayLiveUsageCounters(nextSettings); - const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; - const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries - || previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays; - this.settings = nextSettings; - if (retentionChanged) { - resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); - } else if (historyLimitsChanged && this.settings.historyRetentionMode !== "never") { - saveHistory(this.storagePaths, loadHistory(this.storagePaths), this.historyLimits()); + this.settings = nextSettings; + if (historyLimitsChanged && this.settings.historyRetentionMode !== "never") { + saveHistory(this.storagePaths, loadHistory(this.storagePaths), this.historyLimits()); } saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); @@ -1080,9 +1085,7 @@ public async checkDebridAccounts(): Promise { shutdownAccountRotationLog(); shutdownConversionLog(); shutdownAuditLog(); - if (this.settings.historyRetentionMode === "session") { - clearHistory(this.storagePaths); - } + resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode === "session" ? "session" : "permanent"); logger.info("App beendet"); } @@ -1150,10 +1153,18 @@ public async checkDebridAccounts(): Promise { return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()); } - public clearHistory(): void { - this.audit("WARN", "Verlauf geleert"); - clearHistory(this.storagePaths); - } + public clearHistory(): void { + try { + clearHistory(this.storagePaths); + this.audit("WARN", "Verlauf geleert"); + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code || "UNKNOWN") + : "UNKNOWN"; + this.audit("ERROR", "Verlauf konnte nicht geleert werden", { code }); + throw error; + } + } public setPackagePriority(packageId: string, priority: PackagePriority): void { this.audit("INFO", "Paket-Priorität geändert", { packageId, priority }); diff --git a/src/main/debrid.ts b/src/main/debrid.ts index a5f17ca..6834c33 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -311,8 +311,10 @@ function getMegaDebridAbortMinRunMs(): number { const megaDebridEmptyResponseStreaks = new Map(); export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10; -let megaDebridRotationCursor = 0; -let megaDebridStickyCount = 0; +const megaDebridRotationState: Record<"api" | "web", { cursor: number; stickyCount: number }> = { + api: { cursor: 0, stickyCount: 0 }, + web: { cursor: 0, stickyCount: 0 } +}; // Mega-Web cacht Sessions pro Account (~20 Min). Wuerde jede Link-Aufloesung den // Account wechseln (reines Round-Robin), zahlte JEDER Link einen kalten Login in // die serielle Single-Flight-Queue → minutenlanger Vorlauf. Stattdessen bleibt die @@ -368,13 +370,13 @@ export function clearMegaDebridAccountRuntimeStates(accountKeys: Iterable= MEGA_DEBRID_STICKY_LINKS) { - megaDebridRotationCursor = idx + 1; - megaDebridStickyCount = 0; - } else { - megaDebridRotationCursor = idx; - } + rotationState.stickyCount += 1; + if (rotationState.stickyCount >= MEGA_DEBRID_STICKY_LINKS) { + rotationState.cursor = idx + 1; + rotationState.stickyCount = 0; + } else { + rotationState.cursor = idx; + } logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`); logAccountRotation("INFO", providerName, rotationLabel, "OK", { elapsedMs, @@ -3977,7 +3980,13 @@ export class DebridService { return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`; } - public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings, preferredLeadProvider?: DebridProvider | null): Promise { + public async unrestrictLink( + link: string, + signal?: AbortSignal, + settingsSnapshot?: AppSettings, + preferredLeadProvider?: DebridProvider | null, + onProviderAttempt?: (provider: DebridProvider) => void + ): Promise { const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings); const routing = settings.hosterRouting || {}; @@ -3985,9 +3994,10 @@ export class DebridService { if (hosterKey && routing[hosterKey]) { const routedProvider = routing[hosterKey]; if (this.isProviderSelectableFor(settings, routedProvider)) { - logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`); - try { - const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal); + logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`); + try { + onProviderAttempt?.(routedProvider); + const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal); let fileName = result.fileName; if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { const fromPage = await resolveRapidgatorFilename(link, signal); @@ -4016,9 +4026,10 @@ export class DebridService { } } - if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) { - try { - const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal); + if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) { + try { + onProviderAttempt?.("onefichier"); + const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal); return { ...result, provider: "onefichier", @@ -4035,9 +4046,10 @@ export class DebridService { } } - if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) { - try { - const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal); + if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) { + try { + onProviderAttempt?.("ddownload"); + const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal); return { ...result, provider: "ddownload", @@ -4069,9 +4081,10 @@ export class DebridService { : primary; if (!selectedProvider) { throw new Error(this.formatProviderLimitMessage(settings, primary)); - } - try { - const result = await this.unrestrictViaProvider(settings, selectedProvider, link, signal); + } + try { + onProviderAttempt?.(selectedProvider); + const result = await this.unrestrictViaProvider(settings, selectedProvider, link, signal); let fileName = result.fileName; if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { const fromPage = await resolveRapidgatorFilename(link, signal); @@ -4110,10 +4123,11 @@ export class DebridService { continue; } - const providerStartedAt = Date.now(); - try { - logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`); - traceConversionPhase({ phase: "chain-try", provider }); + const providerStartedAt = Date.now(); + try { + logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`); + onProviderAttempt?.(provider); + traceConversionPhase({ phase: "chain-try", provider }); const result = await this.unrestrictViaProvider(settings, provider, link, signal); traceConversionPhase({ phase: "chain-ok", provider, workMs: Date.now() - providerStartedAt, outcome: "ok" }); let fileName = result.fileName; @@ -4195,8 +4209,8 @@ export class DebridService { private async unrestrictViaProvider(settings: AppSettings, provider: DebridProvider, link: string, signal?: AbortSignal): Promise { const effectiveProvider = resolveMegaDebridProvider(settings, provider); if (effectiveProvider === "realdebrid") { - if (this.shouldUseRealDebridWeb(settings) && this.options.realDebridWebUnrestrict) { - const result = await this.options.realDebridWebUnrestrict(link, signal); + if (this.shouldUseRealDebridWeb(settings) && this.options.realDebridWebUnrestrict) { + const result = await waitForPromiseWithSignal(this.options.realDebridWebUnrestrict(link, signal), signal); if (!result) { throw new Error("Real-Debrid-Web-Fallback nicht verfügbar"); } @@ -4214,8 +4228,8 @@ export class DebridService { return MegaDebridClient.unrestrictWithAccounts(settings, "web", false, link, this.options.megaWebUnrestrict, signal); } if (effectiveProvider === "alldebrid") { - if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) { - const result = await this.options.allDebridWebUnrestrict(link, signal); + if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) { + const result = await waitForPromiseWithSignal(this.options.allDebridWebUnrestrict(link, signal), signal); if (!result) { throw new Error("AllDebrid-Web-Fallback nicht verfügbar"); } @@ -4240,8 +4254,8 @@ export class DebridService { if (effectiveProvider === "linksnappy") { return this.getLinkSnappyClient(settings.linkSnappyLogin, settings.linkSnappyPassword).unrestrictLink(link, signal); } - if (this.shouldUseBestDebridWeb(settings) && this.options.bestDebridWebUnrestrict) { - const bdResult = await this.options.bestDebridWebUnrestrict(link, signal); + if (this.shouldUseBestDebridWeb(settings) && this.options.bestDebridWebUnrestrict) { + const bdResult = await waitForPromiseWithSignal(this.options.bestDebridWebUnrestrict(link, signal), signal); if (!bdResult) { throw new Error("BestDebrid-Web-Fallback nicht verfügbar"); } diff --git a/src/main/disk-space.ts b/src/main/disk-space.ts index cbf9e51..d90b9b2 100644 --- a/src/main/disk-space.ts +++ b/src/main/disk-space.ts @@ -16,6 +16,7 @@ export type DiskReservationRequest = { targetPath: string; requiredBytes: number | null; alreadyPresentBytes?: number; + signal?: AbortSignal; }; export type DiskWaitEvent = { @@ -46,7 +47,7 @@ type DiskReservationCoordinatorOptions = { statVolume?: (targetPath: string) => Promise; }; -type DiskReservationUpdate = Pick; +type DiskReservationUpdate = Pick; export type DiskReservationLease = { readonly volumeKey: string | null; @@ -85,6 +86,31 @@ async function defaultStatVolume(targetPath: string): Promise { } } +function waitForDiskOperation(operation: Promise, signal?: AbortSignal): Promise { + if (!signal) return operation; + if (signal.aborted) { + void operation.catch(() => {}); + return Promise.reject(new Error("aborted:disk-reservation")); + } + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener("abort", onAbort); + reject(new Error("aborted:disk-reservation")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + void operation.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + } + ); + }); +} + export class DiskReservationCoordinator { private readonly safetyBytes: number; private readonly retryDelayMs: number; @@ -105,7 +131,7 @@ export class DiskReservationCoordinator { return this.enqueue(async () => { const requiredBytes = calculateRemainingReservationBytes(request.requiredBytes, request.alreadyPresentBytes ?? 0); if (requiredBytes === null) return this.createLease(request.ownerId, request.targetPath, null, 0); - const volume = await this.statVolume(request.targetPath); + const volume = await waitForDiskOperation(this.statVolume(request.targetPath), request.signal); const reserved = this.reservedByVolume.get(volume.volumeKey) ?? 0; const availableBytes = Math.max(0, Math.floor(volume.freeBytes) - reserved - this.safetyBytes); if (requiredBytes > availableBytes) { @@ -149,7 +175,7 @@ export class DiskReservationCoordinator { if (nextBytes === null) return; const delta = nextBytes - lease.reservedBytes; if (delta > 0) { - const volume = await coordinator.statVolume(lease.targetPath); + const volume = await waitForDiskOperation(coordinator.statVolume(lease.targetPath), update.signal); const available = Math.max(0, Math.floor(volume.freeBytes) - (coordinator.reservedByVolume.get(volumeKey) ?? 0) - coordinator.safetyBytes); if (delta > available) throw new DiskCapacityError({ phase: "download", ownerId, volumeKey, requiredBytes: nextBytes, availableBytes: available, deficitBytes: delta - available, safetyBytes: coordinator.safetyBytes, retryAt: coordinator.now() + coordinator.retryDelayMs }); } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 49ba4ab..6602b8c 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -85,7 +85,8 @@ type ActiveTask = { resumeHardResetUsed?: boolean; stallRetries?: number; genericErrorRetries?: number; - unrestrictRetries?: number; + unrestrictRetries?: number; + validationProvider?: DebridProvider | null; blockedOnDiskWrite?: boolean; blockedOnDiskSince?: number; }; @@ -2328,6 +2329,13 @@ export class DownloadManager extends EventEmitter { public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void { const previous = this.settings; + const activeValidationProviders = new Map(); + for (const active of this.activeTasks.values()) { + const item = this.session.items[active.itemId]; + if (item?.status === "validating") { + activeValidationProviders.set(active.itemId, active.validationProvider || item.provider || this.getExpectedProviderForItem(item)); + } + } const previousMegaAccounts = (["api", "web"] as const) .flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => ({ mode, account }))); const previousMegaPoolEntries = new Map(previousMegaAccounts.map(({ mode, account }) => [`${account.id}:${mode}`, account.password])); @@ -2379,11 +2387,12 @@ export class DownloadManager extends EventEmitter { this.debridService.setSettings(next); this.allDebridHostInfoCache.clear(); - const prevOrder = JSON.stringify(previous.providerOrder ?? []); - const nextOrder = JSON.stringify(next.providerOrder ?? []); - const prevRouting = JSON.stringify(previous.hosterRouting ?? {}); - const nextRouting = JSON.stringify(next.hosterRouting ?? {}); - if (!opts?.settingsOnlyImport && (prevOrder !== nextOrder || prevRouting !== nextRouting)) { + const prevOrder = JSON.stringify(previous.providerOrder ?? []); + const nextOrder = JSON.stringify(next.providerOrder ?? []); + const prevRouting = JSON.stringify(previous.hosterRouting ?? {}); + const nextRouting = JSON.stringify(next.hosterRouting ?? {}); + const downloadRoutingChanged = prevOrder !== nextOrder || prevRouting !== nextRouting; + if (!opts?.settingsOnlyImport && downloadRoutingChanged) { const activeItemIds = new Set([...this.activeTasks.values()].map((t) => t.itemId)); for (const item of Object.values(this.session.items)) { if (!activeItemIds.has(item.id) && item.status !== "completed" && item.status !== "failed") { @@ -2402,24 +2411,56 @@ export class DownloadManager extends EventEmitter { logger.info(`Archiv-Passwortliste geaendert (${pwCount} Eintraege): Extractor-Caches zurueckgesetzt (learned=${reset.learnedCleared}, daemonRestart=${reset.daemonRestarted})`); } - const credChanges: Array<{ prev: string; next: string; providers: string[] }> = [ - { prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] }, - { prev: previous.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] }, - { prev: previous.bestToken || "", next: next.bestToken || "", providers: ["bestdebrid"] }, - { prev: previousDebridLinkPool, next: nextDebridLinkPool, providers: ["debridlink"] }, - { prev: previous.linkSnappyLogin + "|" + previous.linkSnappyPassword, next: next.linkSnappyLogin + "|" + next.linkSnappyPassword, providers: ["linksnappy"] }, - { prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] }, + const disabledProviderFingerprint = (settings: AppSettings, provider: DebridProvider): string => String((settings.disabledProviders || []).includes(provider)); + const credChanges: Array<{ prev: string; next: string; providers: DebridProvider[] }> = [ { - prev: `${previous.megaDebridApiCredentials}|${previous.megaDebridWebCredentials}|${previous.megaDebridApiEnabled}|${previous.megaDebridWebEnabled}`, - next: `${next.megaDebridApiCredentials}|${next.megaDebridWebCredentials}|${next.megaDebridApiEnabled}|${next.megaDebridWebEnabled}`, + prev: `${previous.token || ""}|${previous.realDebridUseWebLogin}|${disabledProviderFingerprint(previous, "realdebrid")}`, + next: `${next.token || ""}|${next.realDebridUseWebLogin}|${disabledProviderFingerprint(next, "realdebrid")}`, + providers: ["realdebrid"] + }, + { + prev: `${previous.allDebridToken || ""}|${previous.allDebridUseWebLogin}|${disabledProviderFingerprint(previous, "alldebrid")}`, + next: `${next.allDebridToken || ""}|${next.allDebridUseWebLogin}|${disabledProviderFingerprint(next, "alldebrid")}`, + providers: ["alldebrid"] + }, + { + prev: `${previous.bestToken || ""}|${previous.bestDebridUseWebLogin}|${disabledProviderFingerprint(previous, "bestdebrid")}`, + next: `${next.bestToken || ""}|${next.bestDebridUseWebLogin}|${disabledProviderFingerprint(next, "bestdebrid")}`, + providers: ["bestdebrid"] + }, + { + prev: `${previousDebridLinkPool}|${disabledProviderFingerprint(previous, "debridlink")}`, + next: `${nextDebridLinkPool}|${disabledProviderFingerprint(next, "debridlink")}`, + providers: ["debridlink"] + }, + { + prev: `${previous.linkSnappyLogin}|${previous.linkSnappyPassword}|${disabledProviderFingerprint(previous, "linksnappy")}`, + next: `${next.linkSnappyLogin}|${next.linkSnappyPassword}|${disabledProviderFingerprint(next, "linksnappy")}`, + providers: ["linksnappy"] + }, + { + prev: `${previous.ddownloadLogin}|${previous.ddownloadPassword}|${disabledProviderFingerprint(previous, "ddownload")}`, + next: `${next.ddownloadLogin}|${next.ddownloadPassword}|${disabledProviderFingerprint(next, "ddownload")}`, + providers: ["ddownload"] + }, + { + prev: `${previous.oneFichierApiKey}|${disabledProviderFingerprint(previous, "onefichier")}`, + next: `${next.oneFichierApiKey}|${disabledProviderFingerprint(next, "onefichier")}`, + providers: ["onefichier"] + }, + { + prev: `${previous.megaDebridApiCredentials}|${previous.megaDebridWebCredentials}|${previous.megaDebridApiEnabled}|${previous.megaDebridWebEnabled}|${disabledProviderFingerprint(previous, "megadebrid")}|${disabledProviderFingerprint(previous, "megadebrid-api")}|${disabledProviderFingerprint(previous, "megadebrid-web")}`, + next: `${next.megaDebridApiCredentials}|${next.megaDebridWebCredentials}|${next.megaDebridApiEnabled}|${next.megaDebridWebEnabled}|${disabledProviderFingerprint(next, "megadebrid")}|${disabledProviderFingerprint(next, "megadebrid-api")}|${disabledProviderFingerprint(next, "megadebrid-web")}`, providers: ["megadebrid", "megadebrid-api", "megadebrid-web"] } - ]; - let clearedProviderFailures = 0; - for (const change of credChanges) { - if (change.prev === change.next) continue; - for (const provider of change.providers) { - for (const key of [...this.providerFailures.keys()]) { + ]; + const changedProviders = new Set(); + let clearedProviderFailures = 0; + for (const change of credChanges) { + if (change.prev === change.next) continue; + for (const provider of change.providers) { + changedProviders.add(provider); + for (const key of [...this.providerFailures.keys()]) { if (key === provider || key.startsWith(`${provider}:`)) { this.providerFailures.delete(key); clearedProviderFailures += 1; @@ -2459,7 +2500,7 @@ export class DownloadManager extends EventEmitter { if (!item || item.status !== "validating") { continue; } - const provider = String(item.provider || this.getExpectedProviderForItem(item) || ""); + const provider = String(active.validationProvider || item.provider || this.getExpectedProviderForItem(item) || ""); if (provider !== "megadebrid" && provider !== "megadebrid-api" && provider !== "megadebrid-web") { continue; } @@ -2474,7 +2515,7 @@ export class DownloadManager extends EventEmitter { if (!item || item.status !== "validating") { continue; } - const provider = String(item.provider || this.getExpectedProviderForItem(item) || ""); + const provider = String(active.validationProvider || item.provider || this.getExpectedProviderForItem(item) || ""); if (provider !== "debridlink") { continue; } @@ -2483,6 +2524,21 @@ export class DownloadManager extends EventEmitter { } } + if (!opts?.settingsOnlyImport && (downloadRoutingChanged || changedProviders.size > 0)) { + for (const active of this.activeTasks.values()) { + const item = this.session.items[active.itemId]; + if (!item || item.status !== "validating" || active.abortController.signal.aborted) { + continue; + } + const provider = activeValidationProviders.get(active.itemId) || item.provider || null; + if (!downloadRoutingChanged && (!provider || !changedProviders.has(provider))) { + continue; + } + active.abortReason = "settings_refresh"; + active.abortController.abort("settings_refresh"); + } + } + if (!opts?.settingsOnlyImport && this.session.running) { if (!this.hasUsableDownloadAccount()) { if (!this.session.paused) { @@ -6573,14 +6629,18 @@ export class DownloadManager extends EventEmitter { if (pkg.itemIds.length === 0) { logger.info(`applyOnStartCleanupPolicy: entferne Paket ${pkg.name} (${completedItemIds.length} completed Items)`); this.removePackageFromSession(pkgId, completedItemIds); - } else { - if (completedItemIds.length > 0) { - logger.info(`applyOnStartCleanupPolicy: entferne ${completedItemIds.length} completed Items aus Paket ${pkg.name} (${pkg.itemIds.length} Items verbleiben)`); - } - for (const itemId of completedItemIds) { - delete this.session.items[itemId]; - this.itemCount = Math.max(0, this.itemCount - 1); - } + } else { + if (completedItemIds.length > 0) { + logger.info(`applyOnStartCleanupPolicy: entferne ${completedItemIds.length} completed Items aus Paket ${pkg.name} (${pkg.itemIds.length} Items verbleiben)`); + } + for (const itemId of completedItemIds) { + const item = this.session.items[itemId]; + if (item) { + this.captureCompletedItemCleanup(pkg, item); + } + delete this.session.items[itemId]; + this.itemCount = Math.max(0, this.itemCount - 1); + } } } logger.info(`applyOnStartCleanupPolicy: ${Object.keys(this.session.packages).length} Pakete, ${Object.keys(this.session.items).length} Items nach Bereinigung`); @@ -6610,13 +6670,17 @@ export class DownloadManager extends EventEmitter { this.retryStateByItem.delete(itemId); removed += 1; } - if (pkg.itemIds.length === 0) { - this.removePackageFromSession(pkgId, completedItemIds); - } else { - for (const itemId of completedItemIds) { - delete this.session.items[itemId]; - this.itemCount = Math.max(0, this.itemCount - 1); - } + if (pkg.itemIds.length === 0) { + this.removePackageFromSession(pkgId, completedItemIds); + } else { + for (const itemId of completedItemIds) { + const item = this.session.items[itemId]; + if (item) { + this.captureCompletedItemCleanup(pkg, item); + } + delete this.session.items[itemId]; + this.itemCount = Math.max(0, this.itemCount - 1); + } } } else if (policy === "package_done" || policy === "on_start") { const allCompleted = pkg.itemIds.every((id) => { @@ -9321,7 +9385,7 @@ export class DownloadManager extends EventEmitter { this.emitState(); return; } - delete item.http416FreshRestarts; + item.http416FreshRestarts = Math.max(freshRestarts, MAX_HTTP416_FRESH_RESTARTS); item.status = "failed"; this.recordRunOutcome(item.id, "failed"); item.lastError = errorText; @@ -9543,7 +9607,15 @@ export class DownloadManager extends EventEmitter { traceConversionNote("slots", this.describeSlotOccupancy()); traceConversionNote("retry", Number(active.unrestrictRetries || 0)); try { - return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider); + return await this.debridService.unrestrictLink( + item.url, + unrestrictedSignal, + undefined, + preferredLeadProvider, + (provider) => { + active.validationProvider = provider; + } + ); } catch (innerError) { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { traceConversionPhase({ @@ -9615,7 +9687,8 @@ export class DownloadManager extends EventEmitter { ownerId: item.id, targetPath: item.targetPath, requiredBytes: item.totalBytes, - alreadyPresentBytes: item.downloadedBytes + alreadyPresentBytes: item.downloadedBytes, + signal: active.abortController.signal }); this.diskLeasesByOwner.get(item.id)?.release(); this.diskLeasesByOwner.set(item.id, diskLease); @@ -10002,8 +10075,17 @@ export class DownloadManager extends EventEmitter { item.fullStatus = `Fehler: ${item.lastError}`; this.recordRunOutcome(item.id, "failed"); this.retryStateByItem.delete(item.id); - } else { + } else { const errorText = compactErrorText(error); + if (error instanceof DiskCapacityError) { + this.recordDiskWait(error.event, { itemId: item.id, packageId: pkg.id }); + this.releaseTargetPath(item.id); + this.queueRetry(item, active, Math.max(1000, error.event.retryAt - nowMs()), "Warte auf Festplatte"); + item.lastError = "Nicht genügend freier Speicherplatz"; + this.persistSoon(); + this.emitState(); + return; + } if (this.tryFinalizeItemFromDisk(pkg, item, "Error-Recovery", errorText)) { return; } @@ -10499,11 +10581,11 @@ export class DownloadManager extends EventEmitter { headers, signal: AbortSignal.any([active.abortController.signal, connectAbortController.signal]) }); - } catch (error) { - if (active.abortController.signal.aborted || String(error).includes("aborted:")) { - throw error; - } - lastError = compactErrorText(error); + } catch (error) { + if (active.abortController.signal.aborted || String(error).includes("aborted:")) { + throw error; + } + lastError = compactErrorText(error); logAttemptEvent("WARN", "HTTP-Verbindung fehlgeschlagen", { attempt, error: lastError @@ -10761,10 +10843,40 @@ export class DownloadManager extends EventEmitter { item.totalBytes = knownTotal; } else if (totalFromRange) { item.totalBytes = totalFromRange; - } else if (contentLength > 0) { - item.totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength; - } - const completionPlan = planDownloadCompletion({ + } else if (contentLength > 0) { + item.totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength; + } + if (item.totalBytes && item.totalBytes > 0) { + const existingLease = this.diskLeasesByOwner.get(item.id); + try { + if (existingLease?.volumeKey) { + await existingLease.update({ + requiredBytes: item.totalBytes, + alreadyPresentBytes: existingBytes, + signal: active.abortController.signal + }); + } else { + existingLease?.release(); + const updatedLease = await this.diskReservations.reserve({ + phase: "download", + ownerId: item.id, + targetPath: effectiveTargetPath, + requiredBytes: item.totalBytes, + alreadyPresentBytes: existingBytes, + signal: active.abortController.signal + }); + this.diskLeasesByOwner.set(item.id, updatedLease); + } + this.resolveDiskWait(item.id, "download"); + } catch (error) { + try { + await response.body?.cancel(); + } catch { + } + throw error; + } + } + const completionPlan = planDownloadCompletion({ existingBytes, responseStatus: response.status, contentLength, @@ -11407,14 +11519,17 @@ export class DownloadManager extends EventEmitter { targetPath: effectiveTargetPath }); return { resumable }; - } catch (error) { - if (preAllocated && item.totalBytes && written < item.totalBytes) { - try { await fs.promises.truncate(effectiveTargetPath, written); } catch { } - } - if (active.abortController.signal.aborted || String(error).includes("aborted:")) { - throw error; - } - lastError = compactErrorText(error); + } catch (error) { + if (preAllocated && item.totalBytes && written < item.totalBytes) { + try { await fs.promises.truncate(effectiveTargetPath, written); } catch { } + } + if (active.abortController.signal.aborted || String(error).includes("aborted:")) { + throw error; + } + if (error instanceof DiskCapacityError) { + throw error; + } + lastError = compactErrorText(error); const normalizedLastError = lastError.replace(/^Error:\s*/i, ""); const diskCause = classifyDiskError(error); logAttemptEvent("WARN", "HTTP-Download-Versuch fehlgeschlagen", { @@ -11537,11 +11652,18 @@ export class DownloadManager extends EventEmitter { continue; } - const is416Failure = item.status === "failed" && this.isHttp416Failure(item); - const hasZeroByteArchive = await this.hasZeroByteArchiveArtifact(item); - - if (item.status === "failed") { - if (!is416Failure && !hasZeroByteArchive && item.retries >= maxAutoRetryFailures) { + const is416Failure = item.status === "failed" && this.isHttp416Failure(item); + const hasZeroByteArchive = await this.hasZeroByteArchiveArtifact(item); + + if (item.status === "failed") { + if (is416Failure && Math.max(0, Number(item.http416FreshRestarts || 0)) >= MAX_HTTP416_FRESH_RESTARTS) { + logger.warn( + `Auto-Retry-Recovery (${trigger}) übersprungen: HTTP-416-Budget ausgeschöpft ` + + `für item=${item.fileName || item.id}, freshRestarts=${item.http416FreshRestarts}/${MAX_HTTP416_FRESH_RESTARTS}` + ); + continue; + } + if (!is416Failure && !hasZeroByteArchive && item.retries >= maxAutoRetryFailures) { continue; } this.queueItemForRetry(item, { @@ -13334,17 +13456,7 @@ export class DownloadManager extends EventEmitter { return; } } - pkg.cleanedCompletedItemCount = Math.max(0, Number(pkg.cleanedCompletedItemCount || 0)) + 1; - if (isExtractedLabel(item.fullStatus || "")) { - pkg.cleanedExtractedItemCount = Math.max(0, Number(pkg.cleanedExtractedItemCount || 0)) + 1; - } - pkg.cleanedDownloadedBytes = Math.max(0, Number(pkg.cleanedDownloadedBytes || 0)) + Math.max(0, item.downloadedBytes || 0); - pkg.cleanedTotalBytes = Math.max(0, Number(pkg.cleanedTotalBytes || 0)) + Math.max(0, item.totalBytes || item.downloadedBytes || 0); - pkg.cleanedUrls = [...new Set([...(pkg.cleanedUrls || []), item.url].filter(Boolean))]; - pkg.cleanedProviders = item.provider - ? [...new Set([...(pkg.cleanedProviders || []), item.provider])] - : [...(pkg.cleanedProviders || [])]; - pkg.updatedAt = nowMs(); + this.captureCompletedItemCleanup(pkg, item); pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); this.releaseTargetPath(itemId); this.dropItemContribution(itemId); @@ -13381,10 +13493,24 @@ export class DownloadManager extends EventEmitter { } this.removePackageFromSession(packageId, [...pkg.itemIds], "completed"); } - } - } - - private finishRun(): void { + } + } + + private captureCompletedItemCleanup(pkg: PackageEntry, item: DownloadItem): void { + pkg.cleanedCompletedItemCount = Math.max(0, Number(pkg.cleanedCompletedItemCount || 0)) + 1; + if (isExtractedLabel(item.fullStatus || "")) { + pkg.cleanedExtractedItemCount = Math.max(0, Number(pkg.cleanedExtractedItemCount || 0)) + 1; + } + pkg.cleanedDownloadedBytes = Math.max(0, Number(pkg.cleanedDownloadedBytes || 0)) + Math.max(0, item.downloadedBytes || 0); + pkg.cleanedTotalBytes = Math.max(0, Number(pkg.cleanedTotalBytes || 0)) + Math.max(0, item.totalBytes || item.downloadedBytes || 0); + pkg.cleanedUrls = [...new Set([...(pkg.cleanedUrls || []), item.url].filter(Boolean))]; + pkg.cleanedProviders = item.provider + ? [...new Set([...(pkg.cleanedProviders || []), item.provider])] + : [...(pkg.cleanedProviders || [])]; + pkg.updatedAt = nowMs(); + } + + private finishRun(): void { const runStartedAt = this.session.runStartedAt; this.session.running = false; this.session.paused = false; diff --git a/src/main/realdebrid-web.ts b/src/main/realdebrid-web.ts index 7732246..2c020bb 100644 --- a/src/main/realdebrid-web.ts +++ b/src/main/realdebrid-web.ts @@ -229,12 +229,21 @@ export class RealDebridWebFallback { const primeFromWindow = (): void => { void this.primeTokenFromWindow(window); }; - window.webContents.on("did-finish-load", primeFromWindow); - window.webContents.on("did-navigate", primeFromWindow); - window.webContents.on("did-navigate-in-page", primeFromWindow); - window.on("close", () => { - void this.primeTokenFromWindow(window); - }); + window.webContents.on("did-finish-load", primeFromWindow); + window.webContents.on("did-navigate", primeFromWindow); + window.webContents.on("did-navigate-in-page", primeFromWindow); + window.webContents.on("render-process-gone", () => { + if (this.loginWindow === window) { + this.loginWindow = null; + this.loginWindowPartition = ""; + } + if (!window.isDestroyed()) { + window.close(); + } + }); + window.on("close", () => { + void this.primeTokenFromWindow(window); + }); window.on("closed", () => { if (this.loginWindow === window) { this.loginWindow = null; diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 232d9ba..58c8efa 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -1,5 +1,5 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode } from "../shared/mega-debrid-accounts"; +import { getMegaDebridAccountsForMode, getMegaDebridAccountStatusId, getMegaDebridDisabledAccountIdsForMode } from "../shared/mega-debrid-accounts"; import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types"; import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus } from "./account-status-sanitizer"; @@ -78,7 +78,7 @@ export function createRendererAccounts(settings: AppSettings): RendererAccount[] dailyLimitBytes: settings.megaDebridAccountDailyLimitBytes[account.id] || 0, dailyUsageBytes: settings.megaDebridAccountDailyUsageBytes[account.id] || 0, totalUsageBytes: settings.megaDebridAccountTotalUsageBytes[account.id] || 0, - status: safeStatus(settings.debridAccountStatuses[account.id], redactions) + status: safeStatus(settings.debridAccountStatuses[getMegaDebridAccountStatusId(account.id, mode)], redactions) }); } } diff --git a/src/main/settings-live-overlay.ts b/src/main/settings-live-overlay.ts index 8658294..2be7042 100644 --- a/src/main/settings-live-overlay.ts +++ b/src/main/settings-live-overlay.ts @@ -1,11 +1,16 @@ import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; -import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools } from "../shared/mega-debrid-accounts"; +import { getMegaDebridAccountIds, getMegaDebridAccountStatusId, mergeMegaDebridCredentialPools } from "../shared/mega-debrid-accounts"; import type { AppSettings } from "../shared/types"; export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppSettings, liveTotalRuntimeMs: number): void { const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys)); const megaAccountIds = new Set(getMegaDebridAccountIds(mergeMegaDebridCredentialPools(target.megaDebridApiCredentials || "", target.megaDebridWebCredentials || "") || target.megaCredentials || "", target.megaPassword || "")); - const validAccountIds = new Set([...debridLinkKeyIds, ...megaAccountIds]); + const megaAccountStatusIds = [...megaAccountIds].flatMap((accountId) => [ + accountId, + getMegaDebridAccountStatusId(accountId, "api"), + getMegaDebridAccountStatusId(accountId, "web") + ]); + const validAccountIds = new Set([...debridLinkKeyIds, ...megaAccountStatusIds]); target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0); target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0); target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, liveTotalRuntimeMs); diff --git a/src/main/storage.ts b/src/main/storage.ts index ee0773b..fa892e9 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -3,7 +3,7 @@ import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; -import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; +import { getMegaDebridAccountIds, getMegaDebridAccountStatusId, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, SessionState } from "../shared/types"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { defaultSettings } from "./constants"; @@ -270,8 +270,15 @@ function normalizeDebridAccountStatuses( value: unknown, megaIds: string[], debridLinkIds: string[] -): Record { - const allowed = new Set([...megaIds, ...debridLinkIds]); +): Record { + const allowed = new Set([ + ...megaIds, + ...megaIds.flatMap((accountId) => [ + getMegaDebridAccountStatusId(accountId, "api"), + getMegaDebridAccountStatusId(accountId, "web") + ]), + ...debridLinkIds + ]); const result: Record = {}; if (value && typeof value === "object" && !Array.isArray(value)) { for (const [key, raw] of Object.entries(value as Record)) { @@ -1465,12 +1472,21 @@ export function addHistoryEntryForRetention(paths: StoragePaths, retentionMode: return addHistoryEntry(paths, entry, limits); } -export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): void { - if (retentionMode === "permanent") { - return; - } - clearHistory(paths); -} +export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): boolean { + if (retentionMode === "permanent") { + return true; + } + try { + clearHistory(paths); + return true; + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code || "UNKNOWN") + : "UNKNOWN"; + logger.warn(`Automatische Verlaufbereinigung fehlgeschlagen (${code})`); + return false; + } +} export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] { const existing = loadHistory(paths); @@ -1479,12 +1495,9 @@ export function removeHistoryEntry(paths: StoragePaths, entryId: string): Histor return updated; } -export function clearHistory(paths: StoragePaths): void { - ensureBaseDir(paths.baseDir); - if (fs.existsSync(paths.historyFile)) { - try { - fs.unlinkSync(paths.historyFile); - } catch { - } - } -} +export function clearHistory(paths: StoragePaths): void { + ensureBaseDir(paths.baseDir); + if (fs.existsSync(paths.historyFile)) { + fs.unlinkSync(paths.historyFile); + } +} diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 39d3688..26d022d 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -42,6 +42,7 @@ const MAX_PACKAGE_DTOS = 200; const MAX_ITEM_DTOS = 500; const MAX_HISTORY_FILE_BYTES = 1024 * 1024; const MAX_HISTORY_ENTRIES = 100; +const MAX_RUNTIME_PRIVATE_NAMES = 64; interface TextBudget { remainingBytes: number; @@ -100,6 +101,164 @@ function collectSensitiveValues(value: unknown, key = "", output = new Set, value: string): boolean { + const trimmed = value.trim(); + if (!trimmed || output.has(trimmed)) { + return true; + } + if (output.size >= MAX_RUNTIME_PRIVATE_NAMES) { + return false; + } + output.add(trimmed); + return true; +} + +function collectSnapshotPrivateNames( + packageEntries: readonly PackageEntry[], + itemEntries: readonly DownloadItem[] +): string[] { + const names = new Set(); + const addName = (name: string): boolean => { + const trimmed = String(name || "").trim(); + if (trimmed) { + names.add(trimmed); + } + return names.size <= MAX_RUNTIME_PRIVATE_NAMES; + }; + for (const entry of packageEntries) { + if (!addName(entry.name)) { + return [...names]; + } + } + for (const entry of itemEntries) { + if (!addName(entry.fileName)) { + return [...names]; + } + } + return [...names]; +} + +function collectRuntimeLogPrivateNames(value: string, output: Set): boolean { + const lines = value.split(/\r?\n/); + const itemFileNames = new Set(); + for (const line of lines) { + const footerIndex = line.lastIndexOf(" ==="); + const logKeyIndex = line.indexOf(" | logKey="); + if (footerIndex <= logKeyIndex) { + continue; + } + if (line.startsWith("=== Paket-Log Start:")) { + const marker = " | name="; + const markerIndex = line.indexOf(marker, logKeyIndex + 1); + if (markerIndex > logKeyIndex && !addRuntimePrivateName(output, line.slice(markerIndex + marker.length, footerIndex))) { + return false; + } + } else if (line.startsWith("=== Item-Log Start:")) { + const marker = " | fileName="; + const markerIndex = line.indexOf(marker, logKeyIndex + 1); + if (markerIndex > logKeyIndex) { + const fileName = line.slice(markerIndex + marker.length, footerIndex); + if (!addRuntimePrivateName(output, fileName) || !addRuntimePrivateName(itemFileNames, fileName)) { + return false; + } + } + } + } + for (const line of lines) { + const contextIndex = line.indexOf("Item-Kontext initialisiert"); + if (contextIndex < 0) { + continue; + } + const packageMarker = " | packageName="; + const packageStart = line.indexOf(packageMarker, contextIndex); + if (packageStart < 0) { + continue; + } + for (const fileName of itemFileNames) { + const fileMarker = ` | fileName=${fileName} | targetPath=`; + const fileStart = line.lastIndexOf(fileMarker); + if (fileStart > packageStart) { + if (!addRuntimePrivateName(output, line.slice(packageStart + packageMarker.length, fileStart))) { + return false; + } + break; + } + } + } + return true; +} + +function redactRuntimeMetadataLines(value: string): string { + return value.split(/\r?\n/).map((line) => { + if (line.startsWith("=== Paket-Log Start:")) { + const logKeyIndex = line.indexOf(" | logKey="); + const nameIndex = line.indexOf(" | name=", logKeyIndex + 1); + return logKeyIndex > 0 && nameIndex > logKeyIndex + ? `${line.slice(0, nameIndex)} | name= ===` + : "=== Laufzeitprotokoll-Kontext entfernt ==="; + } + if (line.startsWith("=== Item-Log Start:")) { + const logKeyIndex = line.indexOf(" | logKey="); + const nameIndex = line.indexOf(" | fileName=", logKeyIndex + 1); + return logKeyIndex > 0 && nameIndex > logKeyIndex + ? `${line.slice(0, nameIndex)} | fileName= ===` + : "=== Laufzeitprotokoll-Kontext entfernt ==="; + } + if (line.includes("Paket-Kontext initialisiert") || line.includes("Item-Kontext initialisiert")) { + return "Laufzeitprotokoll-Kontext entfernt"; + } + if (/\b(?:name|packageName|fileName)\s*=/.test(line)) { + return "Laufzeitprotokoll-Namensfeld entfernt"; + } + return line; + }).join("\n"); +} + +function redactMainDownloaderLogText(value: string): string { + return value.split(/\r?\n/).map((line) => { + const lifecycleMatch = /\b(Download (?:Start|fertig):)/.exec(line); + if (lifecycleMatch?.index !== undefined) { + return `${line.slice(0, lifecycleMatch.index)}${lifecycleMatch[1]} `; + } + if (/\b(?:pkg|item)\s*=/.test(line)) { + const prefix = /^(?:.*?\[(?:TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\]\s*)/i.exec(line)?.[0] || ""; + return `${prefix}Laufzeitprotokoll-Namensfeld entfernt`; + } + return line; + }).join("\n"); +} + +function redactRuntimeLogText( + value: string, + context: string, + sensitiveValues: ReadonlySet, + knownPrivateNames: readonly string[] +): string { + const privateNames = new Set(); + for (const privateName of knownPrivateNames) { + if (!addRuntimePrivateName(privateNames, privateName)) { + return "Laufzeitprotokoll-Kontext entfernt\n"; + } + } + if (!collectRuntimeLogPrivateNames(context, privateNames) || !collectRuntimeLogPrivateNames(value, privateNames)) { + return "Laufzeitprotokoll-Kontext entfernt\n"; + } + const fieldPattern = /\b(?:name|packageName|fileName)\s*=\s*(.*?)(?=\s+\|\s+|\s+===|\r?$)/gim; + for (const match of value.matchAll(fieldPattern)) { + if (!addRuntimePrivateName(privateNames, match[1] || "")) { + return "Laufzeitprotokoll-Kontext entfernt\n"; + } + } + let output = value; + for (const privateName of [...privateNames].sort((left, right) => right.length - left.length)) { + const escaped = escapeRegExp(privateName); + output = privateName.length >= 4 + ? output.replaceAll(privateName, "") + : output.replace(new RegExp(`(^|[^A-Za-z0-9])${escaped}(?=$|[^A-Za-z0-9])`, "g"), "$1"); + } + return redactSupportText(redactRuntimeMetadataLines(output), sensitiveValues); +} + function redactSupportText(value: string, sensitiveValues: ReadonlySet): string { const raw = String(value || "").replace(/\0/g, ""); const findMarker = (source: string, offset: number): string => { @@ -205,7 +364,12 @@ function getSourcePathKey(sourcePath: string): string { return process.platform === "win32" ? resolved.toLowerCase() : resolved; } -async function readTextTail(filePath: string, maxBytes: number): Promise { +interface TextTailResult { + text: string; + truncated: boolean; +} + +async function readTextHead(filePath: string, maxBytes: number): Promise { const stats = await fsp.stat(filePath); const bytesToRead = Math.min(stats.size, Math.max(0, maxBytes)); if (bytesToRead <= 0) { @@ -214,14 +378,44 @@ async function readTextTail(filePath: string, maxBytes: number): Promise const handle = await fsp.open(filePath, "r"); try { const buffer = Buffer.alloc(bytesToRead); - const { bytesRead } = await handle.read(buffer, 0, bytesToRead, Math.max(0, stats.size - bytesToRead)); - const text = buffer.subarray(0, bytesRead).toString("utf8"); - return stats.size > bytesRead ? `[gekürzt: letzte ${bytesRead} Bytes]\n${text}` : text; + const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0); + return buffer.subarray(0, bytesRead).toString("utf8"); } finally { await handle.close(); } } +async function readTextTail(filePath: string, maxBytes: number): Promise { + const stats = await fsp.stat(filePath); + const bytesToRead = Math.min(stats.size, Math.max(0, maxBytes)); + if (bytesToRead <= 0) { + return { text: "", truncated: false }; + } + const handle = await fsp.open(filePath, "r"); + try { + const buffer = Buffer.alloc(bytesToRead); + const { bytesRead } = await handle.read(buffer, 0, bytesToRead, Math.max(0, stats.size - bytesToRead)); + const truncated = stats.size > bytesRead; + let text = buffer.subarray(0, bytesRead).toString("utf8"); + if (truncated) { + const firstLineEnd = text.indexOf("\n"); + text = firstLineEnd >= 0 ? text.slice(firstLineEnd + 1) : ""; + text = `[gekürzt: letzte ${bytesRead} Bytes]\n${text}`; + } + return { text, truncated }; + } finally { + await handle.close(); + } +} + +function trimTextBufferToCompleteLines(buffer: Buffer, maxBytes: number): Buffer { + if (buffer.length <= maxBytes) { + return buffer; + } + const firstLineEnd = buffer.indexOf(0x0a, buffer.length - maxBytes); + return firstLineEnd >= 0 ? buffer.subarray(firstLineEnd + 1) : Buffer.alloc(0); +} + async function addTextFileIfExists( zip: AdmZip, sourcePath: string | null, @@ -231,7 +425,8 @@ async function addTextFileIfExists( budget: TextBudget, maxFileBytes: number, maxAgeMs?: number, - redactArchiveFileName = false + redactArchiveFileName = false, + knownPrivateNames: readonly string[] = [] ): Promise { if (!sourcePath || budget.remainingBytes <= 0) { return false; @@ -245,11 +440,21 @@ async function addTextFileIfExists( return false; } const allowedBytes = Math.min(maxFileBytes, budget.remainingBytes); - const text = redactSupportText(await readTextTail(sourcePath, allowedBytes), sensitiveValues); - let buffer = Buffer.from(text, "utf8"); - if (buffer.length > allowedBytes) { - buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8"); - } + const tail = await readTextTail(sourcePath, allowedBytes); + const normalizedZipPath = zipPath.replace(/\\/g, "/"); + const contextualRuntimeLog = /^(?:logs\/)?(?:package|item)-logs\//.test(normalizedZipPath); + const mainDownloaderLog = /^logs\/rd_downloader\.log(?:\.old)?$/.test(normalizedZipPath); + const runtimeLog = contextualRuntimeLog || mainDownloaderLog; + const context = contextualRuntimeLog && tail.truncated ? await readTextHead(sourcePath, MAX_TEXT_FILE_BYTES) : tail.text; + const text = runtimeLog + ? redactRuntimeLogText( + mainDownloaderLog ? redactMainDownloaderLogText(tail.text) : tail.text, + mainDownloaderLog ? redactMainDownloaderLogText(context) : context, + sensitiveValues, + knownPrivateNames + ) + : redactSupportText(tail.text, sensitiveValues); + const buffer = trimTextBufferToCompleteLines(Buffer.from(text, "utf8"), allowedBytes); await yieldToEventLoop(); zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues, redactArchiveFileName), buffer); includedSourcePaths.add(sourcePathKey); @@ -333,7 +538,8 @@ async function addRelevantLogFiles( maxFiles: number, includedSourcePaths: Set, sensitiveValues: ReadonlySet, - budget: TextBudget + budget: TextBudget, + resolvePrivateNames: (entry: T) => readonly string[] ): Promise { let added = 0; for (const entry of entries.slice(0, maxFiles)) { @@ -353,7 +559,8 @@ async function addRelevantLogFiles( budget, MAX_TEXT_FILE_BYTES, undefined, - true + true, + resolvePrivateNames(entry) )) { added += 1; } @@ -448,8 +655,16 @@ function createItemDto(entry: DownloadItem, fileName: string): Record(entries: T[], limit: number): T[] { - return entries.sort((a, b) => Number(isActiveStatus(b.status)) - Number(isActiveStatus(a.status)) || b.updatedAt - a.updatedAt).slice(0, limit); +function diagnosticPriority(entry: { status: unknown; resumeResetPending?: unknown; retries?: unknown; lastError?: unknown }): number { + const status = String(entry.status || ""); + if (["downloading", "converting", "reconnect_wait", "extracting", "finalizing"].includes(status)) return 3; + if (status === "failed" || entry.resumeResetPending === true || Number(entry.retries || 0) > 0 || String(entry.lastError || "").trim()) return 2; + if (isActiveStatus(status)) return 1; + return 0; +} + +function selectRelevantEntries(entries: T[], limit: number): T[] { + return entries.sort((a, b) => diagnosticPriority(b) - diagnosticPriority(a) || b.updatedAt - a.updatedAt).slice(0, limit); } function createSessionDto(session: SessionState): Record { @@ -849,6 +1064,7 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri const snapshot = manager.getSnapshot(); const packageEntries = Object.values(snapshot.session.packages); const itemEntries = Object.values(snapshot.session.items); + const snapshotPrivateNames = collectSnapshotPrivateNames(packageEntries, itemEntries); const selectedPackageEntries = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS); const selectedItemEntries = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS); const selectedPackages = selectedPackageEntries @@ -925,16 +1141,7 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri textBudget, MAX_RUNTIME_FILE_BYTES ); - const addCurrentLog = (sourcePath: string | null, zipPath: string): Promise => addTextFileIfExists( - zip, - sourcePath, - zipPath, - includedSourcePaths, - sensitiveValues, - textBudget, - MAX_TEXT_FILE_BYTES - ); - const addRotatedLog = (sourcePath: string | null, zipPath: string): Promise => addTextFileIfExists( + const addCurrentLog = (sourcePath: string | null, zipPath: string, privateNames: readonly string[] = []): Promise => addTextFileIfExists( zip, sourcePath, zipPath, @@ -942,7 +1149,21 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri sensitiveValues, textBudget, MAX_TEXT_FILE_BYTES, - SUPPORT_BUNDLE_LOG_WINDOW_MS + undefined, + false, + privateNames + ); + const addRotatedLog = (sourcePath: string | null, zipPath: string, privateNames: readonly string[] = []): Promise => addTextFileIfExists( + zip, + sourcePath, + zipPath, + includedSourcePaths, + sensitiveValues, + textBudget, + MAX_TEXT_FILE_BYTES, + SUPPORT_BUNDLE_LOG_WINDOW_MS, + false, + privateNames ); await addRuntimeFile(path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`); @@ -964,7 +1185,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri MAX_PACKAGE_LOG_FILES, includedSourcePaths, sensitiveValues, - textBudget + textBudget, + (entry) => [entry.name] ); const relevantItemLogCount = await addRelevantLogFiles( zip, @@ -974,7 +1196,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri MAX_ITEM_LOG_FILES, includedSourcePaths, sensitiveValues, - textBudget + textBudget, + (entry) => [entry.fileName, snapshot.session.packages[entry.packageId]?.name || ""] ); const mainLogPath = getLogFilePath(); @@ -983,8 +1206,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri const traceLogPath = getTraceLogPath(); const accountRotationLogPath = getAccountRotationLogPath(); const conversionLogPath = getConversionLogPath(); - await addCurrentLog(mainLogPath, "logs/rd_downloader.log"); - await addRotatedLog(`${mainLogPath}.old`, "logs/rd_downloader.log.old"); + await addCurrentLog(mainLogPath, "logs/rd_downloader.log", snapshotPrivateNames); + await addRotatedLog(`${mainLogPath}.old`, "logs/rd_downloader.log.old", snapshotPrivateNames); await addCurrentLog(auditLogPath, "logs/audit.log"); await addRotatedLog(auditLogPath ? `${auditLogPath}.old` : null, "logs/audit.log.old"); await addCurrentLog(renameLogPath, "logs/rename.log"); diff --git a/src/main/support-data.ts b/src/main/support-data.ts index 007421f..f133fec 100644 --- a/src/main/support-data.ts +++ b/src/main/support-data.ts @@ -2,9 +2,13 @@ import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { isNotifyUrlValid } from "./notify"; import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types"; -function hasText(value: unknown): boolean { - return String(value || "").trim().length > 0; -} +function hasText(value: unknown): boolean { + return String(value || "").trim().length > 0; +} + +function sumUsage(values: Record | undefined): number { + return Object.values(values || {}).reduce((sum, value) => sum + Math.max(0, Number(value) || 0), 0); +} export function buildAccountSummary(settings: AppSettings): Record { const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys); @@ -112,8 +116,8 @@ export function buildRedactedSettingsPayload(settings: AppSettings): Record>, + accountId: string | null, + kind: AccountKind +): DebridAccountStatus | undefined { + if (!accountId) { + return undefined; + } + const mode = kind === "megadebrid-api" + ? "api" + : kind === "megadebrid-web" + ? "web" + : null; + return mode + ? statuses[getMegaDebridAccountStatusId(accountId, mode)] ?? statuses[accountId] + : statuses[accountId]; +} + export interface ResetUiActionGate { busy: boolean; } @@ -2513,7 +2533,9 @@ export function App(): ReactElement { ), [configuredAccountServices]); const accountEditOption = accountEditDialog ? findAccountOption(accountEditDialog.target.kind) : null; const accountEditRow = accountEditDialog ? accountRows.find((row) => row.rowKey === accountEditDialog.target.rowKey) ?? null : null; - const accountEditStatus = accountEditRow?.accountId ? snapshot.settings.debridAccountStatuses?.[accountEditRow.accountId] ?? null : null; + const accountEditStatus = accountEditRow + ? resolveAccountStatus(snapshot.settings.debridAccountStatuses, accountEditRow.accountId, accountEditRow.entry.kind) ?? null + : null; const accountEditQuickAction = accountEditOption ? getAccountQuickActionMeta(accountEditOption.kind) : null; const accountDialogOption = accountDialog?.kind ? findAccountOption(accountDialog.kind) : null; const accountDialogSelectableOptions = useMemo(() => { @@ -3041,7 +3063,10 @@ export function App(): ReactElement { const removeAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); void (async () => { - const username = resolveAccountUsername(row.username, row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId]?.email : undefined); + const username = resolveAccountUsername( + row.username, + resolveAccountStatus(snapshot.settings.debridAccountStatuses, row.accountId, row.entry.kind)?.email + ); const confirmed = await askConfirmPrompt({ title: `${row.hosterLabel} entfernen`, message: `Soll ${row.hosterLabel}${username !== "—" ? ` (${username})` : ""} wirklich entfernt werden?`, @@ -4987,7 +5012,7 @@ export function App(): ReactElement { ? accountRowBindings.get(accountContextMenu.rowId) ?? null : null; const accountSources = useMemo(() => accountRows.map((row) => { - const checkedStatus = row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId] : undefined; + const checkedStatus = resolveAccountStatus(snapshot.settings.debridAccountStatuses, row.accountId, row.entry.kind); const state: AccountRowSource["status"]["state"] = row.disabled ? "disabled" : !checkedStatus diff --git a/src/renderer/ui/ContextMenu.tsx b/src/renderer/ui/ContextMenu.tsx index 66f65d2..e0fb169 100644 --- a/src/renderer/ui/ContextMenu.tsx +++ b/src/renderer/ui/ContextMenu.tsx @@ -35,7 +35,7 @@ export type ContextMenuKeyboardAction = export type ContextMenuSubmenuKeyboardAction = "open" | "close"; -export function clampContextMenuPosition( +export function clampContextMenuPosition( x: number, y: number, width: number, @@ -46,8 +46,30 @@ export function clampContextMenuPosition( return { x: Math.max(0, Math.min(x, Math.max(0, viewportWidth - width))), y: Math.max(0, Math.min(y, Math.max(0, viewportHeight - height))) - }; -} + }; +} + +export function observeContextMenuPosition( + menu: Pick, + anchor: () => { x: number; y: number }, + onPosition: (position: { x: number; y: number }) => void +): () => void { + const reposition = (): void => { + const rect = menu.getBoundingClientRect(); + const point = anchor(); + onPosition(clampContextMenuPosition( + point.x, + point.y, + rect.width, + rect.height, + window.innerWidth, + window.innerHeight + )); + }; + reposition(); + window.addEventListener("resize", reposition); + return () => window.removeEventListener("resize", reposition); +} export function getContextSubmenuPosition( trigger: { left: number; right: number; top: number }, @@ -223,13 +245,14 @@ export const ContextMenu = forwardRef(function if (!previousFocusRef.current) { previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; } - const rect = menuRef.current.getBoundingClientRect(); - const next = clampContextMenuPosition(x, y, rect.width, rect.height, window.innerWidth, window.innerHeight); - setPosition((current) => current.x === next.x && current.y === next.y && current.sourceX === x && current.sourceY === y && current.ready - ? current - : { ...next, sourceX: x, sourceY: y, ready: true }); - getTopLevelMenuItems(menuRef.current)[0]?.focus(); - }, [open, x, y]); + const stopObserving = observeContextMenuPosition(menuRef.current, () => ({ x, y }), (next) => { + setPosition((current) => current.x === next.x && current.y === next.y && current.sourceX === x && current.sourceY === y && current.ready + ? current + : { ...next, sourceX: x, sourceY: y, ready: true }); + }); + getTopLevelMenuItems(menuRef.current)[0]?.focus(); + return stopObserving; + }, [open, x, y]); useEffect(() => { if (!open) { diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index 8c6853d..cc983bd 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -147,6 +147,8 @@ export function compactDownloadStatus(value: string): string { } if (/^Entpack-Fehler\b/i.test(status)) return "Entpack-Fehler"; if (/^Extraction error\b/i.test(status)) return "Extraction error"; + if (/^Entpacken\s*-\s*(?:Error|Fehler)\b/i.test(status)) return "Entpack-Fehler"; + if (/^Extracting\s*-\s*(?:Error|Fehler)\b/i.test(status)) return "Extraction error"; const extractionPending = status.match(/^(Entpacken|Extracting)\s*-\s*(Ausstehend|Pending|Warten auf Parts|Waiting for parts)/i); if (extractionPending) return `${extractionPending[1]} - ${extractionPending[2]}`; const extracting = status.match(/Entpacken\s+(\d+)%/i); @@ -168,6 +170,8 @@ export function compactDownloadStatus(value: string): string { if (percentage) return `${finalizing[1]} - ${progress(Number(percentage[1]))}%`; return finalizing[1]; } + if (/^Fehler(?:\s*:|$)/i.test(status)) return "Fehler"; + if (/^Error(?:\s*:|$)/i.test(status)) return "Error"; return status; } diff --git a/src/renderer/views/history/history-model.ts b/src/renderer/views/history/history-model.ts index 817497f..231d74a 100644 --- a/src/renderer/views/history/history-model.ts +++ b/src/renderer/views/history/history-model.ts @@ -1,4 +1,5 @@ import type { DebridProvider, HistoryEntry } from "../../../shared/types"; +import { normalizeHosterHostname } from "../../../shared/hoster"; export type HistoryFilter = "all" | "today" | "week" | "older" | "completed" | "deleted" | "failed"; export type HistoryViewStatus = HistoryEntry["status"] | "failed"; @@ -168,11 +169,12 @@ export function deriveHistoryHoster(urls: string[] | undefined): string { continue; } const hostname = url.hostname.toLocaleLowerCase("de-DE"); - if (!hostname || seen.has(hostname)) { + const hoster = normalizeHosterHostname(hostname) === "rapidgator" ? "rapidgator.net" : hostname; + if (!hostname || seen.has(hoster)) { continue; } - seen.add(hostname); - hostnames.push(hostname); + seen.add(hoster); + hostnames.push(hoster); } catch { continue; } diff --git a/src/shared/mega-debrid-accounts.ts b/src/shared/mega-debrid-accounts.ts index f295f49..962e45c 100644 --- a/src/shared/mega-debrid-accounts.ts +++ b/src/shared/mega-debrid-accounts.ts @@ -1,10 +1,11 @@ export interface MegaDebridAccountEntry { - id: string; - login: string; - password: string; - index: number; - label: string; - maskedLogin: string; + id: string; + login: string; + password: string; + index: number; + label: string; + maskedLogin: string; + mode?: MegaDebridAccountMode; } export type MegaDebridAccountMode = "api" | "web"; @@ -35,9 +36,13 @@ function fnv1a64(text: string): string { return hash.toString(36); } -export function getMegaDebridAccountId(login: string): string { - return `mda_${fnv1a64(login.trim().toLowerCase())}`; -} +export function getMegaDebridAccountId(login: string): string { + return `mda_${fnv1a64(login.trim().toLowerCase())}`; +} + +export function getMegaDebridAccountStatusId(accountId: string, mode: MegaDebridAccountMode): string { + return `${accountId}:${mode}`; +} export function maskMegaDebridLogin(login: string): string { const trimmed = login.trim(); @@ -124,7 +129,8 @@ export function getMegaDebridCredentialsForMode(settings: MegaDebridModeSettings } export function getMegaDebridAccountsForMode(settings: MegaDebridModeSettings, mode: MegaDebridAccountMode): MegaDebridAccountEntry[] { - return parseMegaDebridAccounts(getMegaDebridCredentialsForMode(settings, mode), settings.megaPassword || ""); + return parseMegaDebridAccounts(getMegaDebridCredentialsForMode(settings, mode), settings.megaPassword || "") + .map((account) => ({ ...account, mode })); } export function getMegaDebridDisabledAccountIdsForMode(settings: MegaDebridModeSettings, mode: MegaDebridAccountMode): string[] { diff --git a/tests/account-check.test.ts b/tests/account-check.test.ts index 441a51c..7004aaf 100644 --- a/tests/account-check.test.ts +++ b/tests/account-check.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check"; -import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check"; +import { getMegaDebridAccountId, type MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts"; import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys"; import type { AppSettings } from "../src/shared/types"; @@ -117,7 +117,7 @@ describe("checkAllDebridAccounts", () => { expect(result).toEqual([]); }); - it("checks every configured mega account + debrid-link key", async () => { + it("checks every configured mega account + debrid-link key", async () => { const futureSec = Math.floor(Date.now() / 1000) + 1000; vi.stubGlobal("fetch", vi.fn(async (url: string) => { if (String(url).includes("mega-debrid")) { @@ -136,10 +136,41 @@ describe("checkAllDebridAccounts", () => { expect(result).toHaveLength(5); expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2); expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3); - expect(result.every((r) => r.valid)).toBe(true); - }); - - it("caps concurrency (never more than 4 in flight) and preserves result order", async () => { + expect(result.every((r) => r.valid)).toBe(true); + }); + + it("keeps API and Web status identities separate when both modes use the same login", async () => { + const futureSec = Math.floor(Date.now() / 1000) + 1000; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + const password = url.searchParams.get("password"); + if (password === "api-pass") { + return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "api-token", vip_end: String(futureSec) }) }; + } + return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "error", response_text: "web credentials rejected" }) }; + }) as unknown as typeof fetch); + + const settings = { + megaCredentials: "shared@example.test:api-pass", + megaPassword: "", + megaDebridApiCredentials: "shared@example.test:api-pass", + megaDebridWebCredentials: "shared@example.test:web-pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: true, + megaDebridPreferApi: true, + debridLinkApiKeys: "" + } as unknown as AppSettings; + + const result = await checkAllDebridAccounts(settings); + const baseId = getMegaDebridAccountId("shared@example.test"); + + expect(result).toHaveLength(2); + expect(result.map((status) => status.accountId)).toEqual([`${baseId}:api`, `${baseId}:web`]); + expect(result[0]).toMatchObject({ valid: true, isPremium: true }); + expect(result[1]).toMatchObject({ valid: false, isPremium: false }); + }); + + it("caps concurrency (never more than 4 in flight) and preserves result order", async () => { let inFlight = 0; let maxInFlight = 0; vi.stubGlobal("fetch", vi.fn(async () => { diff --git a/tests/account-ui.test.ts b/tests/account-ui.test.ts index 3adfee0..15aea8a 100644 --- a/tests/account-ui.test.ts +++ b/tests/account-ui.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { resolveAccountStatus } from "../src/renderer/App"; import { buildConfiguredProviderOrder, getAccountDialogSelectableOptions, @@ -8,6 +9,7 @@ import { resolveAccountUsername, resolveVisibleAccountKind } from "../src/renderer/account-ui"; +import type { DebridAccountStatus } from "../src/shared/types"; describe("account mode filter", () => { it("shows only API options for the API filter", () => { @@ -69,3 +71,36 @@ describe("account usernames", () => { expect(resolveAccountUsername("", undefined)).toBe("—"); }); }); + +describe("account row statuses", () => { + it("shows the status matching each Mega-Debrid mode", () => { + const apiStatus: DebridAccountStatus = { + accountId: "mda_shared:api", + provider: "megadebrid", + label: "Mega-Debrid API", + maskedLogin: "sh***ed", + valid: false, + isPremium: false, + premiumUntilMs: null, + message: "API ungültig", + checkedAt: 10 + }; + const webStatus: DebridAccountStatus = { + accountId: "mda_shared:web", + provider: "megadebrid", + label: "Mega-Debrid Web", + maskedLogin: "sh***ed", + valid: true, + isPremium: true, + premiumUntilMs: 2_000, + message: "Web gültig", + checkedAt: 20 + }; + const statuses = { + "mda_shared:api": apiStatus, + "mda_shared:web": webStatus + }; + expect(resolveAccountStatus(statuses, "mda_shared", "megadebrid-api")?.message).toBe("API ungültig"); + expect(resolveAccountStatus(statuses, "mda_shared", "megadebrid-web")?.message).toBe("Web gültig"); + }); +}); diff --git a/tests/alldebrid-web.test.ts b/tests/alldebrid-web.test.ts index c743e1a..01b2038 100644 --- a/tests/alldebrid-web.test.ts +++ b/tests/alldebrid-web.test.ts @@ -5,6 +5,7 @@ const { mockSession, mockFetch, mockBrowserWindowCtor, + mockBrowserWindow, mockLoadURL, mockShow, mockFocus, @@ -63,6 +64,7 @@ const { }, mockFetch: fetch, mockBrowserWindowCtor: BrowserWindowCtor, + mockBrowserWindow: browserWindow, mockLoadURL: loadURL, mockShow: show, mockFocus: focus, @@ -117,6 +119,21 @@ describe("alldebrid-web", () => { expect(mockFocus).toHaveBeenCalled(); }); + it("replaces a login window after its renderer process crashes", async () => { + const fallback = new AllDebridWebFallback(() => true); + + await fallback.openLoginWindow(); + const crashHandler = mockBrowserWindow.webContents.on.mock.calls.find(([event]) => event === "render-process-gone")?.[1]; + + expect(crashHandler).toBeTypeOf("function"); + crashHandler?.(); + await fallback.openLoginWindow(); + + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(2); + expect(mockLoadURL).toHaveBeenCalledTimes(2); + }); + it("uses an existing AllDebrid Web session to unrestrict without opening a login window", async () => { mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ link: "https://alldebrid.direct/session-file.bin", diff --git a/tests/app-controller-history.test.ts b/tests/app-controller-history.test.ts new file mode 100644 index 0000000..d5fbc18 --- /dev/null +++ b/tests/app-controller-history.test.ts @@ -0,0 +1,118 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { AppController } from "../src/main/app-controller"; +import { createStoragePaths, loadHistory, loadSettings, saveHistory, saveSettings } from "../src/main/storage"; + +const electronState = vi.hoisted(() => ({ userDataDir: "" })); + +vi.mock("electron", () => ({ + app: { + getPath: () => electronState.userDataDir + }, + BrowserWindow: class {}, + clipboard: {}, + dialog: {}, + ipcMain: { handle: vi.fn(), on: vi.fn() }, + Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() }, + safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() }, + shell: {}, + Tray: class {} +})); + +const tempDirs: string[] = []; + +function createHistoryEntry(outputDir: string) { + return { + id: "history-locked", + name: "locked", + totalBytes: 1, + downloadedBytes: 1, + fileCount: 1, + provider: "realdebrid" as const, + completedAt: 100, + durationSeconds: 1, + status: "completed" as const, + outputDir, + urls: [] + }; +} + +function failHistoryDeletion(historyFile: string) { + const originalUnlink = fs.unlinkSync; + return vi.spyOn(fs, "unlinkSync").mockImplementation((target) => { + if (target === historyFile) { + const error = new Error("EPERM: history file is locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalUnlink(target); + }); +} + +beforeEach(() => { + electronState.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-history-")); + tempDirs.push(electronState.userDataDir); +}); + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("AppController history retention", () => { + it("remains startable when session-history cleanup returns EPERM", () => { + const paths = createStoragePaths(path.join(electronState.userDataDir, "runtime")); + saveSettings(paths, { ...defaultSettings(), historyRetentionMode: "session" }); + saveHistory(paths, [createHistoryEntry(path.join(electronState.userDataDir, "out"))]); + const unlinkSpy = failHistoryDeletion(paths.historyFile); + + let controller!: AppController; + expect(() => { + controller = new AppController(); + }).not.toThrow(); + expect(loadHistory(paths)).toHaveLength(1); + + unlinkSpy.mockRestore(); + controller.shutdown(); + }); + + it("rolls back a retention update when history deletion fails", () => { + const paths = createStoragePaths(path.join(electronState.userDataDir, "runtime")); + saveSettings(paths, { ...defaultSettings(), historyRetentionMode: "permanent" }); + saveHistory(paths, [createHistoryEntry(path.join(electronState.userDataDir, "out"))]); + const controller = new AppController(); + const unlinkSpy = failHistoryDeletion(paths.historyFile); + + const result = controller.updateSettings({ historyRetentionMode: "session" }); + + expect(result.historyRetentionMode).toBe("permanent"); + expect(controller.getSettings().historyRetentionMode).toBe("permanent"); + expect(loadSettings(paths).historyRetentionMode).toBe("permanent"); + expect(loadHistory(paths)).toHaveLength(1); + + unlinkSpy.mockRestore(); + controller.shutdown(); + }); + + it("keeps manual history deletion failures visible without auditing false success", () => { + const paths = createStoragePaths(path.join(electronState.userDataDir, "runtime")); + saveSettings(paths, { ...defaultSettings(), historyRetentionMode: "permanent" }); + saveHistory(paths, [createHistoryEntry(path.join(electronState.userDataDir, "out"))]); + const controller = new AppController(); + const audit = vi.fn(); + (controller as unknown as { audit: typeof audit }).audit = audit; + const unlinkSpy = failHistoryDeletion(paths.historyFile); + + expect(() => controller.clearHistory()).toThrow(/EPERM/); + expect(loadHistory(paths)).toHaveLength(1); + expect(audit.mock.calls.map((call) => call[1])).toEqual(["Verlauf konnte nicht geleert werden"]); + + unlinkSpy.mockRestore(); + controller.shutdown(); + }); +}); diff --git a/tests/context-menu.test.tsx b/tests/context-menu.test.tsx index 1d925cb..f45f6a1 100644 --- a/tests/context-menu.test.tsx +++ b/tests/context-menu.test.tsx @@ -1,16 +1,21 @@ import { renderToStaticMarkup } from "react-dom/server"; import { readFileSync } from "node:fs"; -import { describe, expect, it, vi } from "vitest"; -import { - clampContextMenuPosition, - ContextMenu, - getContextMenuKeyboardAction, - getContextMenuSubmenuKeyboardAction, - getContextSubmenuPosition +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + clampContextMenuPosition, + ContextMenu, + getContextMenuKeyboardAction, + getContextMenuSubmenuKeyboardAction, + getContextSubmenuPosition, + observeContextMenuPosition } from "../src/renderer/ui/ContextMenu"; const contextMenuSource = readFileSync(new URL("../src/renderer/ui/ContextMenu.tsx", import.meta.url), "utf8"); const stylesSource = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8"); + +afterEach(() => { + vi.unstubAllGlobals(); +}); describe("ContextMenu", () => { it("renders menu semantics and marks buttons as menu items", () => { @@ -40,7 +45,7 @@ describe("ContextMenu", () => { error.mockRestore(); }); - it("clamps every edge to the visible viewport", () => { + it("clamps every edge to the visible viewport", () => { expect(clampContextMenuPosition(790, 590, 220, 180, 800, 600)).toEqual({ x: 580, y: 420 }); expect(clampContextMenuPosition(-12, -8, 220, 180, 800, 600)).toEqual({ x: 0, y: 0 }); expect(clampContextMenuPosition(40, 60, 220, 180, 800, 600)).toEqual({ x: 40, y: 60 }); @@ -104,6 +109,29 @@ describe("ContextMenu", () => { )).toEqual({ x: 590, y: 450 }); }); + it("repositions an open menu immediately when the viewport is resized", () => { + const viewport = Object.assign(new EventTarget(), { innerWidth: 800, innerHeight: 600 }); + vi.stubGlobal("window", viewport); + const menu = { + getBoundingClientRect: () => ({ width: 200, height: 150 }) + } as Pick; + const onPosition = vi.fn(); + const stop = observeContextMenuPosition(menu, () => ({ x: 700, y: 550 }), onPosition); + + expect(onPosition).toHaveBeenLastCalledWith({ x: 600, y: 450 }); + + viewport.innerWidth = 500; + viewport.innerHeight = 350; + viewport.dispatchEvent(new Event("resize")); + + expect(onPosition).toHaveBeenLastCalledWith({ x: 300, y: 200 }); + expect(onPosition).toHaveBeenCalledTimes(2); + + stop(); + viewport.dispatchEvent(new Event("resize")); + expect(onPosition).toHaveBeenCalledTimes(2); + }); + it("keeps submenus hidden until their viewport-safe position is ready", () => { expect(contextMenuSource).toContain('position.ready && position.sourceX === x && position.sourceY === y ? "is-positioned" : ""'); expect(contextMenuSource).toContain('parts.items.classList.add("is-positioned")'); diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index f4175b9..aaadead 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -33,7 +33,7 @@ describe("leadProviderChainWith", () => { }); describe("debrid service", () => { - it("falls back to Mega web when Real-Debrid fails", async () => { + it("falls back to Mega web when Real-Debrid fails", async () => { const settings = { ...defaultSettings(), token: "rd-token", @@ -1294,7 +1294,7 @@ describe("debrid service", () => { await expect(service.unrestrictLink("https://rapidgator.net/file/missing-alldebrid-web")).rejects.toThrow(/nicht konfiguriert/i); }); - it("uses Real-Debrid web path when enabled", async () => { + it("uses Real-Debrid web path when enabled", async () => { const settings = { ...defaultSettings(), token: "rd-token", @@ -1321,10 +1321,49 @@ describe("debrid service", () => { expect(result.directUrl).toContain("real-debrid.com/d/"); expect(result.fileSize).toBe(5678); expect(realDebridWeb).toHaveBeenCalledTimes(1); - expect(fetchSpy).toHaveBeenCalledTimes(0); - }); - - it("treats Real-Debrid web mode as not configured when callback is unavailable and no token", async () => { + expect(fetchSpy).toHaveBeenCalledTimes(0); + }); + + it.each([ + ["Real-Debrid", "realdebrid", "realDebridWebUnrestrict", { token: "rd-token", realDebridUseWebLogin: true }], + ["AllDebrid", "alldebrid", "allDebridWebUnrestrict", { allDebridToken: "ad-token", allDebridUseWebLogin: true }], + ["BestDebrid", "bestdebrid", "bestDebridWebUnrestrict", { bestToken: "best-token", bestDebridUseWebLogin: true }] + ] as const)("aborts a hanging %s Web provider callback even when it ignores the signal", async (_label, providerName, callbackName, settingsPatch) => { + let markStarted: () => void = () => {}; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const providerCallback = vi.fn(() => { + markStarted(); + return new Promise(() => {}); + }); + const settings = { + ...defaultSettings(), + ...settingsPatch, + providerOrder: [] as const, + providerPrimary: providerName, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + const service = new DebridService(settings, { [callbackName]: providerCallback }); + const controller = new AbortController(); + const outcome = service.unrestrictLink("https://rapidgator.net/file/hanging-web-provider", controller.signal).then( + () => "fulfilled", + (error: unknown) => String(error) + ); + + await started; + controller.abort("pause"); + const result = await Promise.race([ + outcome, + new Promise((resolve) => setTimeout(() => resolve("timeout"), 100)) + ]); + + expect(result).toMatch(/aborted/i); + }); + + it("treats Real-Debrid web mode as not configured when callback is unavailable and no token", async () => { const settings = { ...defaultSettings(), token: "", @@ -1496,6 +1535,47 @@ describe("debrid service", () => { expect(megaWeb).toHaveBeenCalledTimes(1); }); + it("reports each provider as soon as its conversion attempt starts", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + providerOrder: ["realdebrid", "megadebrid"] as const, + autoProviderFallback: true + }; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response(JSON.stringify({ error: "traffic_limit" }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + const service = new DebridService(settings, { + megaWebUnrestrict: vi.fn(async () => ({ + fileName: "file.bin", + directUrl: "https://mega-web.example/file.bin", + fileSize: null, + retriesUsed: 0 + })) + }); + const attempts: string[] = []; + + await service.unrestrictLink( + "https://rapidgator.net/file/provider-attempts.rar.html", + undefined, + undefined, + undefined, + (provider) => attempts.push(provider) + ); + + expect(attempts).toEqual(["realdebrid", "megadebrid"]); + }); + it("uses Mega web fallback when API fails", async () => { const settings = { ...defaultSettings(), @@ -2225,7 +2305,7 @@ describe("debrid service", () => { expect(usedIds).toEqual(new Array(5).fill(getMegaDebridAccountId("user1"))); }, 30000); - it("wechselt erst nach einem Schwung Links auf den naechsten Account", async () => { + it("wechselt erst nach einem Schwung Links auf den naechsten Account", async () => { const settings = { ...defaultSettings(), token: "", bestToken: "", allDebridToken: "", @@ -2249,10 +2329,119 @@ describe("debrid service", () => { } expect(usedIds.slice(0, MEGA_DEBRID_STICKY_LINKS)).toEqual(new Array(MEGA_DEBRID_STICKY_LINKS).fill(getMegaDebridAccountId("user1"))); - expect(usedIds[MEGA_DEBRID_STICKY_LINKS]).toBe(getMegaDebridAccountId("user2")); - }, 30000); - - it("ueberspringt einen gesperrten Account und bleibt dann klebrig beim naechsten", async () => { + expect(usedIds[MEGA_DEBRID_STICKY_LINKS]).toBe(getMegaDebridAccountId("user2")); + }, 30000); + + it("keeps the Mega-Debrid Web cursor independent from API rotation", async () => { + const apiSettings = { + ...defaultSettings(), + token: "", bestToken: "", allDebridToken: "", + megaCredentials: "cursor-api-1:pass1\ncursor-api-2:pass2", + megaDebridApiCredentials: "cursor-api-1:pass1\ncursor-api-2:pass2", + megaDebridWebCredentials: "", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + providerOrder: [] as const, providerPrimary: "megadebrid-api" as const, + providerSecondary: "none" as const, providerTertiary: "none" as const, + autoProviderFallback: false + }; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = String(input); + if (url.includes("action=connectUser")) { + return new Response(JSON.stringify({ response_code: "ok", token: "cursor-api-token" }), { status: 200 }); + } + if (url.includes("action=getLink")) { + return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/cursor.rar", filename: "cursor.rar" }), { status: 200 }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const apiService = new DebridService(apiSettings); + for (let index = 0; index < MEGA_DEBRID_STICKY_LINKS; index += 1) { + await apiService.unrestrictLink(`https://rapidgator.net/file/api-cursor-${index}`); + } + + const webLogins: string[] = []; + const webSettings = { + ...defaultSettings(), + token: "", bestToken: "", allDebridToken: "", + megaCredentials: "cursor-web-1:pass1\ncursor-web-2:pass2", + megaDebridApiCredentials: "", + megaDebridWebCredentials: "cursor-web-1:pass1\ncursor-web-2:pass2", + megaDebridApiEnabled: false, + megaDebridWebEnabled: true, + providerOrder: [] as const, providerPrimary: "megadebrid-web" as const, + providerSecondary: "none" as const, providerTertiary: "none" as const, + autoProviderFallback: false + }; + const webService = new DebridService(webSettings, { + megaWebUnrestrict: async (_link, _signal, account) => { + webLogins.push(account?.login || ""); + return { fileName: "web-cursor.rar", directUrl: "https://mega-web.example/web-cursor.rar", fileSize: null, retriesUsed: 0 }; + } + }); + + await webService.unrestrictLink("https://rapidgator.net/file/web-cursor"); + + expect(webLogins).toEqual(["cursor-web-1"]); + }, 30000); + + it("keeps the Mega-Debrid Web sticky counter independent from API successes", async () => { + const apiSettings = { + ...defaultSettings(), + token: "", bestToken: "", allDebridToken: "", + megaCredentials: "sticky-api-1:pass1\nsticky-api-2:pass2", + megaDebridApiCredentials: "sticky-api-1:pass1\nsticky-api-2:pass2", + megaDebridWebCredentials: "", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + providerOrder: [] as const, providerPrimary: "megadebrid-api" as const, + providerSecondary: "none" as const, providerTertiary: "none" as const, + autoProviderFallback: false + }; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = String(input); + if (url.includes("action=connectUser")) { + return new Response(JSON.stringify({ response_code: "ok", token: "sticky-api-token" }), { status: 200 }); + } + if (url.includes("action=getLink")) { + return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/sticky.rar", filename: "sticky.rar" }), { status: 200 }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const apiService = new DebridService(apiSettings); + for (let index = 0; index < MEGA_DEBRID_STICKY_LINKS - 1; index += 1) { + await apiService.unrestrictLink(`https://rapidgator.net/file/api-sticky-${index}`); + } + + const webLogins: string[] = []; + const webSettings = { + ...defaultSettings(), + token: "", bestToken: "", allDebridToken: "", + megaCredentials: "sticky-web-1:pass1\nsticky-web-2:pass2", + megaDebridApiCredentials: "", + megaDebridWebCredentials: "sticky-web-1:pass1\nsticky-web-2:pass2", + megaDebridApiEnabled: false, + megaDebridWebEnabled: true, + providerOrder: [] as const, providerPrimary: "megadebrid-web" as const, + providerSecondary: "none" as const, providerTertiary: "none" as const, + autoProviderFallback: false + }; + const webService = new DebridService(webSettings, { + megaWebUnrestrict: async (_link, _signal, account) => { + webLogins.push(account?.login || ""); + return { fileName: "web-sticky.rar", directUrl: "https://mega-web.example/web-sticky.rar", fileSize: null, retriesUsed: 0 }; + } + }); + + await webService.unrestrictLink("https://rapidgator.net/file/web-sticky-1"); + await webService.unrestrictLink("https://rapidgator.net/file/web-sticky-2"); + + expect(webLogins).toEqual(["sticky-web-1", "sticky-web-1"]); + }, 30000); + + it("ueberspringt einen gesperrten Account und bleibt dann klebrig beim naechsten", async () => { const settings = { ...defaultSettings(), token: "", bestToken: "", allDebridToken: "", diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 072586e..3b65d6a 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -188,12 +188,12 @@ describe("disk write recovery", () => { (manager as any).debridService.unrestrictLink = async () => ({ fileName: "reserve-download.bin", directUrl: "https://dummy/reserve-download", - fileSize: 1_024, + fileSize: null, retriesUsed: 0, provider: "realdebrid", providerLabel: "Real-Debrid" }); - globalThis.fetch = vi.fn(async () => new Response(Buffer.alloc(16, 1), { + globalThis.fetch = vi.fn(async () => new Response(Buffer.alloc(1_024, 1), { status: 200, headers: { "content-length": "1024", @@ -232,6 +232,107 @@ describe("disk write recovery", () => { })); }); + it("releases an active download when Stop aborts a blocked post-header disk reservation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-reserve-abort-")); + tempDirs.push(root); + const session = emptySession(); + const packageId = "reserve-abort-package"; + const itemId = "reserve-abort-item"; + const outputDir = path.join(root, "downloads", "reserve-abort"); + const createdAt = Date.now(); + session.running = true; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "reserve-abort", + outputDir, + extractDir: path.join(root, "extract", "reserve-abort"), + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://rapidgator.net/file/reserve-abort", + provider: "realdebrid", + status: "downloading", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "reserve-abort.bin", + targetPath: "", + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Download läuft", + createdAt, + updatedAt: createdAt + }; + let reservationStarted = false; + let statCalls = 0; + const manager = new DownloadManager( + { ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), autoExtract: false }, + session, + createStoragePaths(path.join(root, "state")) + ); + (manager as any).diskReservations = new DiskReservationCoordinator({ + safetyBytes: 0, + statVolume: async (targetPath) => { + statCalls += 1; + reservationStarted = true; + if (statCalls === 1) { + return await new Promise(() => undefined); + } + return { path: targetPath, volumeKey: "follow-up-volume", freeBytes: 4_096, totalBytes: 8_192 }; + } + }); + (manager as any).debridService.unrestrictLink = async () => ({ + fileName: "reserve-abort.bin", + directUrl: "https://dummy/reserve-abort", + fileSize: null, + retriesUsed: 0, + provider: "realdebrid", + providerLabel: "Real-Debrid" + }); + const response = new Response(Buffer.alloc(1_024, 3), { + status: 200, + headers: { "content-length": "1024", "accept-ranges": "bytes" } + }); + const cancelSpy = vi.spyOn(response.body!, "cancel"); + globalThis.fetch = vi.fn(async () => response) as typeof fetch; + const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 }; + (manager as any).activeTasks.set(itemId, active); + + const processing = (manager as any).processItem(active) as Promise; + await waitFor(() => reservationStarted, 2_000); + active.abortReason = "stop"; + active.abortController.abort("stop"); + + await expect(Promise.race([ + processing.then(() => "released"), + new Promise((resolve) => setTimeout(() => resolve("blocked"), 500)) + ])).resolves.toBe("released"); + expect(cancelSpy).toHaveBeenCalled(); + const followUpLease = await Promise.race([ + (manager as any).diskReservations.reserve({ + phase: "download", + ownerId: "follow-up", + targetPath: path.join(root, "follow-up.bin"), + requiredBytes: 512, + alreadyPresentBytes: 0 + }), + new Promise((resolve) => setTimeout(() => resolve(null), 500)) + ]); + expect(followUpLease).not.toBeNull(); + followUpLease?.release(); + }); + it("keeps disk-wait downloads out of the scheduler until their capacity retry is due", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disk-reserve-resume-")); tempDirs.push(root); @@ -990,6 +1091,113 @@ describe("download manager", () => { expect(failures.has("realdebrid")).toBe(true); }); + it("aborts active Real-Debrid validation when the provider is disabled live", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-real-live-disable-")); + tempDirs.push(root); + const settings = { + ...defaultSettings(), + token: "rd-token", + allDebridToken: "ad-token", + providerOrder: ["realdebrid", "alldebrid"] as const + }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "live-disable", links: ["https://rapidgator.net/file/live-disable"] }]); + const session = (manager as any).session; + const item = Object.values(session.items)[0] as any; + item.provider = null; + item.status = "validating"; + session.running = true; + const active = { + itemId: item.id, + packageId: item.packageId, + abortController: new AbortController(), + abortReason: "none", + resumable: true, + nonResumableCounted: false + }; + (manager as any).activeTasks.set(item.id, active); + const failures = (manager as any).providerFailures as Map; + failures.set("realdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 }); + failures.set("alldebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 }); + + manager.setSettings({ + ...settings, + disabledProviders: ["realdebrid"] + }); + + expect(active.abortController.signal.aborted).toBe(true); + expect(active.abortReason).toBe("settings_refresh"); + expect(failures.has("realdebrid:rapidgator.net")).toBe(false); + expect(failures.has("alldebrid:rapidgator.net")).toBe(true); + }); + + it("aborts the active fallback provider when its settings change live", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-fallback-live-disable-")); + tempDirs.push(root); + const settings = { + ...defaultSettings(), + token: "rd-token", + allDebridToken: "ad-token", + providerOrder: ["realdebrid", "alldebrid"] as const, + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state"))); + let fallbackStarted = false; + let providerSignal: AbortSignal | undefined; + (manager as any).debridService.unrestrictLink = async ( + _link: string, + signal?: AbortSignal, + _settingsSnapshot?: AppSettings, + _preferredLeadProvider?: DebridProvider | null, + onProviderAttempt?: (provider: DebridProvider) => void + ) => { + providerSignal = signal; + onProviderAttempt?.("alldebrid"); + fallbackStarted = true; + return await new Promise((_resolve, reject) => { + const onAbort = (): void => reject(new Error("aborted:settings-refresh")); + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + }); + }; + manager.addPackages([{ name: "fallback-live-disable", links: ["https://rapidgator.net/file/fallback-live-disable"] }]); + + await manager.start(); + await waitFor(() => fallbackStarted, 5_000); + manager.setSettings({ ...settings, disabledProviders: ["alldebrid"] }); + + expect(providerSignal?.aborted).toBe(true); + manager.stop(); + }); + + it("clears a provider cooldown when the provider is re-enabled live", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-real-live-reenable-")); + tempDirs.push(root); + const settings: AppSettings = { + ...defaultSettings(), + token: "rd-token", + disabledProviders: ["realdebrid"] + }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state"))); + const failures = (manager as any).providerFailures as Map; + failures.set("realdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 }); + failures.set("realdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 }); + + manager.setSettings({ + ...settings, + disabledProviders: [] + }); + + expect(failures.has("realdebrid")).toBe(false); + expect(failures.has("realdebrid:rapidgator.net")).toBe(false); + }); + it("invalidates only the Mega-Debrid Web session when Web credentials change", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-web-session-refresh-")); tempDirs.push(root); @@ -3840,7 +4048,7 @@ describe("download manager", () => { expect(item?.status).toBe("failed"); expect(downloadCalls).toBeGreaterThan(4); expect(downloadCalls).toBeLessThan(30); - expect(item.http416FreshRestarts).toBeUndefined(); + expect(item.http416FreshRestarts).toBe(2); } finally { if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; } } @@ -3907,7 +4115,7 @@ describe("download manager", () => { await (manager as any).escalateHttp416OrFail(item, active, "", "HTTP 416"); expect(item.status).toBe("failed"); - expect(item.http416FreshRestarts).toBeUndefined(); + expect(item.http416FreshRestarts).toBe(2); }); it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => { @@ -6304,7 +6512,7 @@ describe("download manager", () => { expect(snapshot.canStart).toBe(true); }); - it("requeues failed HTTP 416 items automatically on startup", async () => { + it("does not requeue an exhausted HTTP 416 item after restart", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -6366,19 +6574,19 @@ describe("download manager", () => { await manager.waitForStartupRecovery(); - const snapshot = manager.getSnapshot(); - const item = snapshot.session.items[itemId]; - expect(item?.status).toBe("queued"); - expect(item?.attempts).toBe(0); - expect(item?.downloadedBytes).toBe(0); - expect(item?.progressPercent).toBe(0); - expect(item?.fullStatus).toContain("Auto-Retry"); + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[itemId]; + expect(item?.status).toBe("failed"); + expect(item?.attempts).toBe(3); + expect(item?.downloadedBytes).toBe(12 * 1024); + expect(item?.progressPercent).toBe(100); + expect(item?.fullStatus).toContain("Fehler"); expect(item?.http416FreshRestarts).toBe(2); - expect(snapshot.session.packages[packageId]?.status).toBe("queued"); - expect(fs.existsSync(targetPath)).toBe(false); - }); + expect(snapshot.session.packages[packageId]?.status).toBe("failed"); + expect(fs.existsSync(targetPath)).toBe(true); + }); - it("keeps a locked HTTP 416 partial intact and persists a pending clean reset", async () => { + it("keeps an exhausted locked HTTP 416 partial intact without requeueing it after restart", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -6450,17 +6658,18 @@ describe("download manager", () => { createStoragePaths(path.join(root, "state")) ); - await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "queued", 2000); + await manager.waitForStartupRecovery(); const item = manager.getSnapshot().session.items[itemId]; expect(item).toMatchObject({ - status: "queued", + status: "failed", downloadedBytes: partialBytes, totalBytes: partialBytes * 2, progressPercent: 50, - resumeResetPending: true, - fullStatus: "Warte auf Teildatei-Freigabe" + http416FreshRestarts: 2, + fullStatus: "Fehler: Error: HTTP 416" }); + expect(item?.resumeResetPending).toBeUndefined(); expect(fs.existsSync(targetPath)).toBe(true); expect(fs.statSync(targetPath).size).toBe(partialBytes); } finally { @@ -10267,6 +10476,85 @@ describe("download manager", () => { expect(packageEntry.cleanedTotalBytes).toBe(1_000); }); + it.each([ + ["on_start", "on_start"], + ["retroactive immediate", "never"] + ] as const)("preserves completed package progress during %s cleanup", (_name, initialPolicy) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const session = emptySession(); + const packageId = `cleanup-${initialPolicy}-package`; + const completedItemId = `cleanup-${initialPolicy}-completed`; + const queuedItemId = `cleanup-${initialPolicy}-queued`; + const createdAt = Date.now(); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: `cleanup-${initialPolicy}`, + outputDir: path.join(root, "downloads", `cleanup-${initialPolicy}`), + extractDir: path.join(root, "extract", `cleanup-${initialPolicy}`), + status: "queued", + itemIds: [completedItemId, queuedItemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[completedItemId] = { + id: completedItemId, + packageId, + url: "https://dummy/completed", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 1_000, + totalBytes: 1_000, + progressPercent: 100, + fileName: "completed.rar", + targetPath: path.join(root, "downloads", `cleanup-${initialPolicy}`, "completed.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt - Fertig", + createdAt, + updatedAt: createdAt + }; + session.items[queuedItemId] = { + ...session.items[completedItemId], + id: queuedItemId, + url: "https://dummy/queued", + status: "queued", + downloadedBytes: 0, + totalBytes: 2_000, + progressPercent: 0, + fileName: "queued.rar", + targetPath: path.join(root, "downloads", `cleanup-${initialPolicy}`, "queued.rar"), + fullStatus: "Wartet" + }; + const settings = { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + completedCleanupPolicy: initialPolicy + }; + const manager = new DownloadManager(settings, session, createStoragePaths(path.join(root, "state"))); + + if (initialPolicy === "never") { + manager.setSettings({ ...settings, completedCleanupPolicy: "immediate" }); + } + + const packageEntry = manager.getSnapshot().session.packages[packageId]; + expect(packageEntry.itemIds).toEqual([queuedItemId]); + expect(packageEntry.cleanedCompletedItemCount).toBe(1); + expect(packageEntry.cleanedExtractedItemCount).toBe(1); + expect(packageEntry.cleanedDownloadedBytes).toBe(1_000); + expect(packageEntry.cleanedTotalBytes).toBe(1_000); + expect(packageEntry.cleanedUrls).toEqual(["https://dummy/completed"]); + expect(packageEntry.cleanedProviders).toEqual(["realdebrid"]); + }); + it("includes immediately cleaned items in the final package history entry", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 4c921ab..48bc364 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -1412,6 +1412,13 @@ describe("download table row contracts", () => { expect(compactDownloadStatus("Warte auf Festplatte (Mega-Debrid Web)")).toBe("Warte auf Festplatte"); }); + it("keeps provider and hybrid extraction diagnostics out of the visible status", () => { + expect(compactDownloadStatus("Fehler: Mega-Debrid API: Kein Server verfügbar")).toBe("Fehler"); + expect(compactDownloadStatus("Error: Mega-Debrid Web: No server available")).toBe("Error"); + expect(compactDownloadStatus("Entpacken - Error")).toBe("Entpack-Fehler"); + expect(compactDownloadStatus("Extracting - Error")).toBe("Extraction error"); + }); + it("prioritizes disk waits and extraction errors in package status", () => { const diskPackage = pkg("disk-package", "Disk package", ["disk-item", "active-item"]); const diskHtml = renderToStaticMarkup(PackageCardContent({ diff --git a/tests/history-view.test.tsx b/tests/history-view.test.tsx index c653b3e..f7b333a 100644 --- a/tests/history-view.test.tsx +++ b/tests/history-view.test.tsx @@ -159,19 +159,25 @@ describe("history model", () => { expect(filterHistoryRows(searchable, "all", "d", now).map((row) => row.id)).toEqual(["new", "old"]); }); - it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => { - expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rapidgator.net/b", "https://ddownload.com/c", "not a url"])).toBe("rapidgator.net, ddownload.com"); - expect(deriveHistoryHoster([])).toBe("—"); + it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => { + expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rapidgator.net/b", "https://ddownload.com/c", "not a url"])).toBe("rapidgator.net, ddownload.com"); + expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rg.to/b", "https://cdn.rg.to/c"])).toBe("rapidgator.net"); + expect(deriveHistoryHoster([])).toBe("—"); expect(deriveHistoryHoster(undefined)).toBe("—"); expect(deriveHistoryStartAt(entry({ id: "start", name: "Start", completedAt: 20_000, durationSeconds: 3 }))).toBe(17_000); expect(deriveHistoryStartAt(entry({ id: "clamped", name: "Clamp", completedAt: 2_000, durationSeconds: 3 }))).toBe(0); const row = filterHistoryRows([entry({ id: "provider", name: "Provider", provider: "realdebrid", urls: [] })], "all", "", now)[0]; expect(row.hoster).toBe("—"); - expect(row.providerLabel).toBe("Real-Debrid"); - }); - - it("prunes removed ids and preserves the original set instance when every id survives", () => { + expect(row.providerLabel).toBe("Real-Debrid"); + }); + + it("keeps unrelated hostnames distinct", () => { + expect(deriveHistoryHoster(["https://files.example.com/a", "https://cdn.example.net/b"])).toBe("files.example.com, cdn.example.net"); + expect(deriveHistoryHoster(["https://foo.co.uk/a", "https://bar.co.uk/b"])).toBe("foo.co.uk, bar.co.uk"); + }); + + it("prunes removed ids and preserves the original set instance when every id survives", () => { const stable = new Set(["today", "week"]); expect(pruneHistoryIds(stable, ["today", "week", "older"])).toBe(stable); diff --git a/tests/realdebrid-web.test.ts b/tests/realdebrid-web.test.ts index 46d2b46..450be4e 100644 --- a/tests/realdebrid-web.test.ts +++ b/tests/realdebrid-web.test.ts @@ -11,6 +11,7 @@ const { mockLoadURL, mockShow, mockFocus, + mockClose, mockSetWindowOpenHandler, mockSetPermissionRequestHandler } = vi.hoisted(() => { @@ -73,6 +74,7 @@ const { mockLoadURL: loadURL, mockShow: show, mockFocus: focus, + mockClose: browserWindow.close, mockSetWindowOpenHandler: setWindowOpenHandler, mockSetPermissionRequestHandler: setPermissionRequestHandler }; @@ -120,7 +122,7 @@ describe("realdebrid-web", () => { .toBe("ghi789"); }); - it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => { + it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => { const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ download: "https://cdn.real-debrid.example/file.bin", filename: "file.bin", @@ -160,6 +162,21 @@ describe("realdebrid-web", () => { expect(mockSessionFetch).not.toHaveBeenCalled(); expect(apiFetch).toHaveBeenCalledTimes(1); expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link"); - expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled(); - }); -}); + expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled(); + }); + + it("replaces a login window after its renderer process crashes", async () => { + const fallback = new RealDebridWebFallback(() => true); + + await fallback.openLoginWindow(); + const crashHandler = mockBrowserWindow.webContents.on.mock.calls.find(([event]) => event === "render-process-gone")?.[1]; + + expect(crashHandler).toBeTypeOf("function"); + crashHandler?.(); + await fallback.openLoginWindow(); + + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(2); + expect(mockLoadURL).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/renderer-state.test.ts b/tests/renderer-state.test.ts index 91061f7..eb03461 100644 --- a/tests/renderer-state.test.ts +++ b/tests/renderer-state.test.ts @@ -128,4 +128,54 @@ describe("renderer state serialization", () => { expect(serialized).not.toContain(secret); } }); + + it("assigns mode-specific statuses to API and Web rows with the same Mega-Debrid login", () => { + const login = "shared-status@example.test"; + const baseId = getMegaDebridAccountId(login); + const apiStatus = { + accountId: `${baseId}:api`, + provider: "megadebrid" as const, + label: "Account 1", + maskedLogin: "sh***st", + valid: true, + isPremium: true, + premiumUntilMs: 2_000, + message: "API valid", + checkedAt: 1 + }; + const webStatus = { + accountId: `${baseId}:web`, + provider: "megadebrid" as const, + label: "Account 1", + maskedLogin: "sh***st", + valid: false, + isPremium: false, + premiumUntilMs: null, + message: "Web rejected", + checkedAt: 1 + }; + const state = createRendererState({ + ...defaultSettings(), + megaCredentials: `${login}:api-pass`, + megaDebridApiCredentials: `${login}:api-pass`, + megaDebridWebCredentials: `${login}:web-pass`, + megaDebridApiEnabled: true, + megaDebridWebEnabled: true, + debridAccountStatuses: { + [apiStatus.accountId]: apiStatus, + [webStatus.accountId]: webStatus + } + }); + + expect(state.accounts.find((account) => account.kind === "megadebrid-api")?.status).toMatchObject({ + accountId: `${baseId}:api`, + valid: true, + message: "API valid" + }); + expect(state.accounts.find((account) => account.kind === "megadebrid-web")?.status).toMatchObject({ + accountId: `${baseId}:web`, + valid: false, + message: "Web rejected" + }); + }); }); diff --git a/tests/settings-live-overlay.test.ts b/tests/settings-live-overlay.test.ts index 9143f48..31bf39e 100644 --- a/tests/settings-live-overlay.test.ts +++ b/tests/settings-live-overlay.test.ts @@ -8,11 +8,15 @@ describe("live settings overlay", () => { it("keeps current Mega-Debrid counters and drops data for identities no longer configured", () => { const keepMegaId = getMegaDebridAccountId("keep@example.com"); const removedMegaId = getMegaDebridAccountId("removed@example.com"); + const keepMegaApiStatusId = `${keepMegaId}:api`; + const keepMegaWebStatusId = `${keepMegaId}:web`; + const removedMegaApiStatusId = `${removedMegaId}:api`; const keepKeyId = getDebridLinkApiKeyId("keep-key"); const removedKeyId = getDebridLinkApiKeyId("removed-key"); const target = { ...defaultSettings(), - megaCredentials: "keep@example.com:pass", + megaDebridApiCredentials: "keep@example.com:api-pass", + megaDebridWebCredentials: "keep@example.com:web-pass", megaLogin: "keep@example.com", megaPassword: "pass", debridLinkApiKeys: "keep-key", @@ -29,7 +33,10 @@ describe("live settings overlay", () => { debridLinkApiKeyTotalUsageBytes: { [keepKeyId]: 5_000, [removedKeyId]: 6_000 }, debridAccountStatuses: { [keepMegaId]: { accountId: keepMegaId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "ke***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }, + [keepMegaApiStatusId]: { accountId: keepMegaApiStatusId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "ke***om", valid: false, isPremium: false, premiumUntilMs: null, message: "API ungültig", checkedAt: 2 }, + [keepMegaWebStatusId]: { accountId: keepMegaWebStatusId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "ke***om", valid: true, isPremium: true, premiumUntilMs: null, message: "Web gültig", checkedAt: 3 }, [removedMegaId]: { accountId: removedMegaId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "re***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }, + [removedMegaApiStatusId]: { accountId: removedMegaApiStatusId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "re***om", valid: true, isPremium: true, premiumUntilMs: null, message: "API gültig", checkedAt: 2 }, [keepKeyId]: { accountId: keepKeyId, provider: "debridlink" as const, label: "Key 1", maskedLogin: "kee***key", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 }, [removedKeyId]: { accountId: removedKeyId, provider: "debridlink" as const, label: "Key 2", maskedLogin: "rem***key", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 } } @@ -41,7 +48,14 @@ describe("live settings overlay", () => { expect(target.megaDebridAccountTotalUsageBytes).toEqual({ [keepMegaId]: 3_000 }); expect(target.debridLinkApiKeyDailyUsageBytes).toEqual({ [keepKeyId]: 500 }); expect(target.debridLinkApiKeyTotalUsageBytes).toEqual({ [keepKeyId]: 5_000 }); - expect(Object.keys(target.debridAccountStatuses).sort()).toEqual([keepKeyId, keepMegaId].sort()); + expect(Object.keys(target.debridAccountStatuses).sort()).toEqual([ + keepKeyId, + keepMegaId, + keepMegaApiStatusId, + keepMegaWebStatusId + ].sort()); + expect(target.debridAccountStatuses[keepMegaApiStatusId]?.message).toBe("API ungültig"); + expect(target.debridAccountStatuses[keepMegaWebStatusId]?.message).toBe("Web gültig"); expect(target.totalRuntimeAllTimeMs).toBe(9_000); }); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 476133c..a70eed3 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1,14 +1,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; -import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import { getMegaDebridAccountId, getMegaDebridAccountStatusId } from "../src/shared/mega-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { AppSettings } from "../src/shared/types"; import { defaultSettings } from "../src/main/constants"; import { configureCredentialProtector } from "../src/main/credential-protection"; -import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage"; +import { addHistoryEntryForRetention, clearHistory, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage"; const tempDirs: string[] = []; type SettingsSaveMode = "sync" | "async"; @@ -29,8 +29,9 @@ beforeEach(() => { }); }); -afterEach(() => { - for (const dir of tempDirs.splice(0)) { +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); @@ -204,6 +205,49 @@ describe("settings storage", () => { expect(loaded.allDebridToken).toBe("all-token"); }); + it("preserves mode-specific Mega-Debrid account statuses across save and load", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const accountId = getMegaDebridAccountId("shared-login"); + const apiStatusId = getMegaDebridAccountStatusId(accountId, "api"); + const webStatusId = getMegaDebridAccountStatusId(accountId, "web"); + const settings = { + ...defaultSettings(), + rememberToken: true, + megaDebridApiCredentials: "shared-login:api-password", + megaDebridWebCredentials: "shared-login:web-password", + debridAccountStatuses: { + [apiStatusId]: { + accountId: apiStatusId, + provider: "megadebrid" as const, + label: "API account", + maskedLogin: "sh*******in", + valid: false, + isPremium: false, + premiumUntilMs: null, + message: "API login failed", + checkedAt: 100 + }, + [webStatusId]: { + accountId: webStatusId, + provider: "megadebrid" as const, + label: "Web account", + maskedLogin: "sh*******in", + valid: true, + isPremium: true, + premiumUntilMs: 200, + message: "Web login succeeded", + checkedAt: 101 + } + } + }; + + saveSettings(paths, settings); + + expect(loadSettings(paths).debridAccountStatuses).toEqual(settings.debridAccountStatuses); + }); + it.each(["sync", "async"] as const)("preserves the previous recoverable settings state during a %s save", async (mode) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir); @@ -702,7 +746,7 @@ describe("settings storage", () => { expect(loadHistoryForRetention(paths, "never")).toEqual([]); }); - it("clears persisted history for session retention mode", () => { + it("clears persisted history for session retention mode", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir); const paths = createStoragePaths(dir); @@ -723,8 +767,39 @@ describe("settings storage", () => { resetHistoryForRetention(paths, "session"); - expect(loadHistory(paths)).toEqual([]); - }); + expect(loadHistory(paths)).toEqual([]); + }); + + it("propagates a history deletion failure instead of reporting success", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + saveHistory(paths, [{ + id: "hist-locked", + name: "locked", + totalBytes: 1, + downloadedBytes: 1, + fileCount: 1, + provider: "realdebrid", + completedAt: Date.now(), + durationSeconds: 1, + status: "completed", + outputDir: path.join(dir, "out"), + urls: [] + }]); + const originalUnlink = fs.unlinkSync; + vi.spyOn(fs, "unlinkSync").mockImplementation((target) => { + if (target === paths.historyFile) { + const error = new Error("EPERM: history file is locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalUnlink(target); + }); + + expect(() => clearHistory(paths)).toThrow(/EPERM/); + expect(loadHistory(paths)).toHaveLength(1); + }); it("caps persisted history to the configured maxEntries", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); diff --git a/tests/support-bundle.test.ts b/tests/support-bundle.test.ts index da6c948..6980d09 100644 --- a/tests/support-bundle.test.ts +++ b/tests/support-bundle.test.ts @@ -14,7 +14,7 @@ import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/ma import { initAccountRotationLog, logAccountRotation, shutdownAccountRotationLog } from "../src/main/account-rotation-log"; import { configureLogger, flushLoggerSync, logger } from "../src/main/logger"; import { ensurePackageLog, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log"; -import { ensureItemLog, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log"; +import { ensureItemLog, flushItemLogs, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log"; import { initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log"; import { primeDebridLinkRuntimeCooldownForTests, @@ -602,6 +602,289 @@ describe("buildSupportBundle (async, non-blocking)", () => { expect(itemEntry?.getData().toString("utf8") || "").toContain("item-buffer-marker"); }); + it("redacts snapshot and runtime package and file names from the tailed main log", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-main-log-names-")); + tempDirs.push(root); + const packageName = "MAINPKG-BEGIN === MAINPKG-MIDDLE | fileName=MAINPKG-END"; + const fileName = "MAINFILE-BEGIN | packageName=MAINFILE-MIDDLE === MAINFILE-END.rar"; + const runtimeOnlyName = "RUNTIME-BEGIN | decoy=RUNTIME-MIDDLE === RUNTIME-END"; + flushLoggerSync(); + configureLogger(root); + const mainLogPath = path.join(root, "rd_downloader.log"); + const completeTail = [ + "", + "2026-08-13 12:00:00.000 [INFO] main-diagnostic-marker status=downloading", + `2026-08-13 12:00:00.000 [INFO] runtime-name-marker packageName=${runtimeOnlyName} | status=queued`, + `2026-08-13 12:00:00.000 [INFO] main-package-marker ${packageName}`, + `2026-08-13 12:00:00.000 [INFO] main-file-marker ${fileName}`, + "" + ].join("\n"); + const partialOffset = 8; + const fillerLength = 128 * 1024 - Buffer.byteLength(packageName.slice(partialOffset) + completeTail, "utf8"); + fs.appendFileSync(mainLogPath, `${packageName}${completeTail}${"z".repeat(fillerLength)}`, "utf8"); + const manager = { + getSnapshot: () => ({ + stats: {}, + session: { + version: 1, + packages: { + "package-main-log": { + id: "package-main-log", + name: packageName, + outputDir: path.join(root, "output", packageName), + extractDir: path.join(root, "extract", packageName), + status: "downloading", + itemIds: ["item-main-log"], + cancelled: false, + enabled: true, + createdAt: 1, + updatedAt: 2 + } + }, + items: { + "item-main-log": { + id: "item-main-log", + packageId: "package-main-log", + url: "https://example.test/main-log", + provider: "realdebrid", + status: "downloading", + retries: 0, + speedBps: 1, + downloadedBytes: 1, + totalBytes: 2, + progressPercent: 50, + fileName, + targetPath: path.join(root, "output", packageName, fileName), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Download läuft", + createdAt: 1, + updatedAt: 2 + } + }, + packageOrder: ["package-main-log"], + running: true, + paused: false, + updatedAt: 2 + }, + speedText: "1 B/s", + etaText: "1s", + canStart: false, + canStop: true, + canPause: true + }), + getPackageLogPath: () => null, + getItemLogPath: () => null + } as unknown as DownloadManager; + + const buffer = await buildSupportBundle(manager, root, { + hostDiagnosticsMode: "none", + debugSetupMode: "deferred" + }); + const mainLog = new AdmZip(buffer).getEntry("logs/rd_downloader.log")?.getData().toString("utf8") || ""; + + for (const fragment of [ + "MAINPKG-BEGIN", + "MAINPKG-MIDDLE", + "MAINPKG-END", + "MAINFILE-BEGIN", + "MAINFILE-MIDDLE", + "MAINFILE-END", + "RUNTIME-BEGIN", + "RUNTIME-MIDDLE", + "RUNTIME-END" + ]) { + expect(mainLog).not.toContain(fragment); + } + expect(mainLog).toContain("main-diagnostic-marker"); + expect(mainLog).toContain("main-package-marker"); + expect(mainLog).toContain("main-file-marker"); + }); + + it("redacts completed download names after the package was removed from the session", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-removed-main-log-names-")); + tempDirs.push(root); + flushLoggerSync(); + configureLogger(root); + const fileName = "REMOVED-PRIVATE-FILE.part01.rar"; + const packageName = "REMOVED-PRIVATE-PACKAGE"; + fs.appendFileSync( + path.join(root, "rd_downloader.log"), + `2026-08-13 12:00:00.000 [INFO] Download fertig: ${fileName} (1.00 GB), pkg=${packageName}\n`, + "utf8" + ); + + const buffer = await buildSupportBundle(fakeManager(), root, { + hostDiagnosticsMode: "none", + debugSetupMode: "deferred" + }); + const mainLog = new AdmZip(buffer).getEntry("logs/rd_downloader.log")?.getData().toString("utf8") || ""; + + expect(mainLog).toContain("Download fertig:"); + expect(mainLog).not.toContain(fileName); + expect(mainLog).not.toContain(packageName); + }); + + it("redacts runtime package and file names from included package and item logs", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-runtime-names-")); + tempDirs.push(root); + const packageId = "package-runtime-private"; + const itemId = "item-runtime-private"; + const privatePackageName = "Family.Vacation.Private.Release"; + const privateFileName = "Family.Vacation.Private.Release.part01.rar"; + initPackageLogs(root); + initItemLogs(root); + ensurePackageLog({ + packageId, + name: privatePackageName, + outputDir: path.join(root, "output", privatePackageName), + extractDir: path.join(root, "extract", privatePackageName) + }); + ensureItemLog({ + itemId, + packageId, + packageName: privatePackageName, + fileName: privateFileName, + targetPath: path.join(root, "output", privatePackageName, privateFileName) + }); + logPackageEvent(packageId, "INFO", `package-transfer-active ${privatePackageName}`, { status: "downloading" }); + logItemEvent(itemId, "INFO", `item-transfer-active ${privateFileName}`, { status: "downloading" }); + + const manager = { + getSnapshot: () => ({ + stats: {}, + session: { + version: 1, + packages: { + [packageId]: { + id: packageId, + name: privatePackageName, + outputDir: path.join(root, "output", privatePackageName), + extractDir: path.join(root, "extract", privatePackageName), + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt: 1, + updatedAt: 2 + } + }, + items: { + [itemId]: { + id: itemId, + packageId, + url: "https://rapidgator.net/file/runtime-private", + provider: "megadebrid-web", + status: "downloading", + retries: 0, + speedBps: 1024, + downloadedBytes: 512, + totalBytes: 1024, + progressPercent: 50, + fileName: privateFileName, + targetPath: path.join(root, "output", privatePackageName, privateFileName), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Download läuft", + createdAt: 1, + updatedAt: 2 + } + }, + packageOrder: [packageId], + running: true, + paused: false, + updatedAt: 2 + }, + speedText: "1 KB/s", + etaText: "1s", + canStart: false, + canStop: true, + canPause: true + }), + getPackageLogPath: () => null, + getItemLogPath: () => null + } as unknown as DownloadManager; + + const buffer = await buildSupportBundle(manager, root, { + hostDiagnosticsMode: "none", + debugSetupMode: "deferred" + }); + const zip = new AdmZip(buffer); + const packageLog = zip.getEntries() + .find((entry) => entry.entryName.startsWith("logs/package-logs/")) + ?.getData().toString("utf8") || ""; + const itemLog = zip.getEntries() + .find((entry) => entry.entryName.startsWith("logs/item-logs/")) + ?.getData().toString("utf8") || ""; + const overview = [ + zip.getEntry("overview/packages.json")?.getData().toString("utf8") || "", + zip.getEntry("overview/items.json")?.getData().toString("utf8") || "" + ].join("\n"); + + expect(`${packageLog}\n${itemLog}`).not.toContain(privatePackageName); + expect(`${packageLog}\n${itemLog}`).not.toContain(privateFileName); + expect(packageLog).toContain(packageId); + expect(itemLog).toContain(itemId); + expect(packageLog).toContain("package-transfer-active"); + expect(itemLog).toContain("item-transfer-active"); + expect(`${packageLog}\n${itemLog}`).toContain("status=downloading"); + expect(overview).toContain('"name": "package-001.release"'); + expect(overview).toContain('"fileName": "item-001.rar"'); + }); + + it("removes delimiter-injected package and file names from complete and tailed runtime logs", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-runtime-name-boundaries-")); + tempDirs.push(root); + const packageName = "PRIVATEPKG-BEGIN === PRIVATEPKG-MIDDLE | decoy=PRIVATEPKG-END"; + const fileName = "PRIVATEFILE-BEGIN | decoyField=PRIVATEFILE-MIDDLE | packageName=PRIVATEFILE-END.rar"; + initPackageLogs(root); + initItemLogs(root); + const packageLogPath = ensurePackageLog({ + packageId: "package-runtime-boundaries", + name: packageName, + outputDir: path.join(root, "output", packageName), + extractDir: path.join(root, "extract", packageName) + }); + const itemLogPath = ensureItemLog({ + itemId: "item-runtime-boundaries", + packageId: "package-runtime-boundaries", + packageName, + fileName, + targetPath: path.join(root, "output", packageName, fileName) + }); + expect(packageLogPath).not.toBeNull(); + expect(itemLogPath).not.toBeNull(); + fs.appendFileSync(packageLogPath!, `2026-08-13 12:00:00.000 [INFO] package-diagnostic-marker ${packageName} | status=downloading\n`, "utf8"); + const partialOffset = 12; + const completeTail = `\n2026-08-13 12:00:00.000 [INFO] item-diagnostic-marker ${fileName} | status=downloading\n`; + const fillerLength = 128 * 1024 - Buffer.byteLength(fileName.slice(partialOffset) + completeTail, "utf8"); + fs.appendFileSync(itemLogPath!, `${"p".repeat(1024)}${fileName}${completeTail}${"z".repeat(fillerLength)}`, "utf8"); + + const buffer = await buildSupportBundle(fakeManager(), root, { + hostDiagnosticsMode: "none", + debugSetupMode: "deferred" + }); + const runtimeLogText = new AdmZip(buffer).getEntries() + .filter((entry) => /logs\/(?:package|item)-logs\//.test(entry.entryName)) + .map((entry) => entry.getData().toString("utf8")) + .join("\n"); + + for (const fragment of [ + "PRIVATEPKG-BEGIN", + "PRIVATEPKG-MIDDLE", + "PRIVATEPKG-END", + "PRIVATEFILE-BEGIN", + "PRIVATEFILE-MIDDLE", + "PRIVATEFILE-END" + ]) { + expect(runtimeLogText).not.toContain(fragment); + } + expect(runtimeLogText).toContain("package-diagnostic-marker"); + expect(runtimeLogText).toContain("item-diagnostic-marker"); + }); + it("bounds recent item logs to the newest diagnostic files", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); tempDirs.push(root); @@ -738,9 +1021,14 @@ describe("buildSupportBundle (async, non-blocking)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-")); tempDirs.push(root); const escapedSecret = "prefix\"suffix\\trail\tend"; + const privatePackageName = "Private Default Package Name"; + const firstKeyId = getDebridLinkApiKeyId("abc123456789xyz"); fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({ megaDebridWebCredentials: `primary-user:primary-password-secret\nsecondary-user:secondary-password-secret\nZ9:Q7!\nAlice:${escapedSecret}`, debridLinkApiKeys: "abc123456789xyz,def987654321uvw", + debridLinkApiKeyDailyUsageBytes: { [firstKeyId]: 1234 }, + debridLinkApiKeyTotalUsageBytes: { [firstKeyId]: 5678 }, + packageName: privatePackageName, megaDebridWebEnabled: true }), "utf8"); initAccountRotationLog(root); @@ -823,7 +1111,9 @@ describe("buildSupportBundle (async, non-blocking)", () => { "file-private.bin", "manifest-bearer-secret", "manifest-query-secret", - "manifest-fragment" + "manifest-fragment", + privatePackageName, + firstKeyId ]; for (const secret of forbidden) { @@ -970,6 +1260,92 @@ describe("buildSupportBundle (async, non-blocking)", () => { expect(timerGaps.length).toBeGreaterThan(2); expect(Math.max(...timerGaps)).toBeLessThan(100); }, 15_000); + + it("retains failed recovery items when the pending queue exceeds the DTO cap", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-recovery-priority-")); + tempDirs.push(root); + const recoveryId = "failed-recovery-item"; + initItemLogs(root); + ensureItemLog({ itemId: recoveryId, packageId: "package-recovery", packageName: "Recovery", fileName: "recovery.rar", targetPath: "C:\\Downloads\\recovery.rar" }); + logItemEvent(recoveryId, "ERROR", "failed-recovery-marker"); + flushItemLogs(); + const queuedItems = Object.fromEntries(Array.from({ length: 501 }, (_, index) => { + const id = `queued-${index}`; + return [id, { + id, + packageId: "package-queued", + url: `https://rapidgator.net/file/${index}`, + provider: "megadebrid-web", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: 100, + progressPercent: 0, + fileName: `${id}.rar`, + targetPath: `C:\\Downloads\\${id}.rar`, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt: index + 1, + updatedAt: index + 1 + }]; + })); + const items = { + ...queuedItems, + [recoveryId]: { + id: recoveryId, + packageId: "package-recovery", + url: "https://rapidgator.net/file/recovery", + provider: "megadebrid-web", + status: "failed", + retries: 8, + speedBps: 0, + downloadedBytes: 512, + totalBytes: 1024, + progressPercent: 50, + fileName: "recovery.rar", + targetPath: "C:\\Downloads\\recovery.rar", + resumable: true, + attempts: 9, + lastError: "Resume recovery exhausted", + fullStatus: "Fehler", + resumeResetPending: true, + createdAt: 1, + updatedAt: 1 + } + }; + const manager = { + getSnapshot: () => ({ + stats: {}, + session: { version: 1, packages: {}, items, packageOrder: [], running: true, paused: false, updatedAt: 1000 }, + speedText: "", + etaText: "", + canStart: false, + canStop: true, + canPause: true + }), + getPackageLogPath: () => null, + getItemLogPath: () => null + } as unknown as DownloadManager; + + const buffer = await buildSupportBundle(manager, root, { + hostDiagnosticsMode: "none", + debugSetupMode: "deferred" + }); + const zip = new AdmZip(buffer); + const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as { + items?: Array<{ id?: string }>; + }; + const logText = zip.getEntries() + .filter((entry) => entry.entryName.startsWith("logs/item-logs/")) + .map((entry) => entry.getData().toString("utf8")) + .join("\n"); + + expect(itemOverview.items?.some((entry) => entry.id === recoveryId)).toBe(true); + expect(logText).toContain("failed-recovery-marker"); + }); }); describe("support bundle export runner", () => {