From 38c9058beb93cde213d0c7cd72b076b8df380d70 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 8 Mar 2026 03:42:06 +0100 Subject: [PATCH] Fix session stats, extraction UX, and queue UI issues --- src/main/app-controller.ts | 16 +- src/main/constants.ts | 3 + src/main/debrid.ts | 4 +- src/main/download-manager.ts | 138 ++++++++++++---- src/main/extractor.ts | 30 +++- src/main/main.ts | 2 + src/main/storage.ts | 12 ++ src/preload/preload.ts | 2 + src/renderer/App.tsx | 220 ++++++++++++++++++++++--- src/renderer/styles.css | 43 +++++ src/shared/ipc.ts | 2 + src/shared/preload-api.ts | 2 + src/shared/provider-daily-limits.ts | 52 ++++++ src/shared/types.ts | 6 +- tests/auto-rename.test.ts | 22 +++ tests/download-manager.test.ts | 242 +++++++++++++++++++++++++++- tests/extractor.test.ts | 12 ++ tests/storage.test.ts | 15 ++ 18 files changed, 759 insertions(+), 64 deletions(-) diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index b6d9d58..96266e8 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -179,14 +179,19 @@ export class AppController { return previousSettings; } - // Preserve the live totalDownloadedAllTime from the download manager + // Preserve the live all-time counters from the download manager const liveSettings = this.manager.getSettings(); nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0); + nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0); nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay; nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) }; + nextSettings.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) }; nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries( Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId)) ); + nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries( + Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId)) + ); this.settings = nextSettings; saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); @@ -379,6 +384,15 @@ export class AppController { return this.manager.getSessionStats(); } + public resetSessionStats(): void { + this.manager.resetSessionStats(); + } + + public resetDownloadStats(): void { + this.manager.resetDownloadStats(); + this.settings = this.manager.getSettings(); + } + public exportBackup(): Buffer { const settings = { ...this.settings }; const session = this.manager.getSession(); diff --git a/src/main/constants.ts b/src/main/constants.ts index af68f00..c3baeb2 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -102,6 +102,7 @@ export function defaultSettings(): AppSettings { hideExtractedItems: true, confirmDeleteSelection: true, totalDownloadedAllTime: 0, + totalCompletedFilesAllTime: 0, bandwidthSchedules: [], columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], extractCpuPriority: "high", @@ -110,8 +111,10 @@ export function defaultSettings(): AppSettings { hosterRouting: {}, providerDailyLimitBytes: {}, providerDailyUsageBytes: {}, + providerTotalUsageBytes: {}, debridLinkApiKeyDailyLimitBytes: {}, debridLinkApiKeyDailyUsageBytes: {}, + debridLinkApiKeyTotalUsageBytes: {}, providerDailyUsageDay: getProviderUsageDayKey(), scheduledStartEpochMs: 0 }; diff --git a/src/main/debrid.ts b/src/main/debrid.ts index a83376b..d082e04 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -76,8 +76,10 @@ function cloneSettings(settings: AppSettings): AppSettings { debridLinkDisabledKeyIds: [...(settings.debridLinkDisabledKeyIds || [])], providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) }, providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) }, + providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) }, debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }, - debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) } + debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }, + debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) } }; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 643e1de..bdbf57c 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -21,7 +21,14 @@ import { UiSnapshot } from "../shared/types"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { addDebridLinkApiKeyDailyUsageBytes, addProviderDailyUsageBytes, getProviderUsageDayKey, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; +import { + addDebridLinkApiKeyDailyUsageBytes, + addDebridLinkApiKeyTotalUsageBytes, + addProviderDailyUsageBytes, + addProviderTotalUsageBytes, + getProviderUsageDayKey, + isProviderDailyLimitReached +} from "../shared/provider-daily-limits"; import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS } from "./constants"; // Reference counter for NODE_TLS_REJECT_UNAUTHORIZED to avoid race conditions @@ -289,8 +296,10 @@ function cloneSettings(settings: AppSettings): AppSettings { bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })), providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) }, providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) }, + providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) }, debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }, - debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) } + debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }, + debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) } }; } @@ -573,7 +582,7 @@ const SCENE_SEASON_ONLY_RE = /(^|[._\-\s])s\d{1,2}(?=[._\-\s]|$)/i; const SCENE_SEASON_CAPTURE_RE = /(?:^|[._\-\s])s(\d{1,2})(?=[._\-\s]|$)/i; const SCENE_EPISODE_ONLY_RE = /(?:^|[._\-\s])e(?:p(?:isode)?)?\s*0*(\d{1,3})(?:[._\-\s]|$)/i; const SCENE_PART_TOKEN_RE = /(?:^|[._\-\s])(?:teil|part)\s*0*(\d{1,3})(?=[._\-\s]|$)/i; -const SCENE_COMPACT_EPISODE_CODE_RE = /(?:^|[._\-\s])(\d{3,4})(?=$|[._\-\s])/; +const SCENE_COMPACT_EPISODE_CODE_RE = /(?:^|[._\-\s])(\d{3,4})([a-z])?(?=$|[._\-\s])/i; const SCENE_RP_TOKEN_RE = /(?:^|[._\-\s])rp(?:[._\-\s]|$)/i; const SCENE_REPACK_TOKEN_RE = /(?:^|[._\-\s])repack(?:[._\-\s]|$)/i; const SCENE_QUALITY_TOKEN_RE = /([._\-\s])((?:4320|2160|1440|1080|720|576|540|480|360)p)(?=[._\-\s]|$)/i; @@ -705,6 +714,7 @@ function extractCompactEpisodeToken(fileName: string, seasonHint: number | null) } const code = match[1]; + const episodeSuffix = String(match[2] || "").toLowerCase(); if (code === "4320" || code === "2160" || code === "1440" || code === "1080" || code === "0720" || code === "720" || code === "0576" || code === "576" || code === "0540" || code === "540" || code === "0480" || code === "480" @@ -712,11 +722,18 @@ function extractCompactEpisodeToken(fileName: string, seasonHint: number | null) return null; } + const letterOffset = episodeSuffix + ? episodeSuffix.charCodeAt(0) - "a".charCodeAt(0) + : 0; const toToken = (season: number, episode: number): string | null => { - if (!Number.isFinite(season) || !Number.isFinite(episode) || season < 0 || season > 99 || episode <= 0 || episode > 999) { + const effectiveEpisode = episode + Math.max(0, letterOffset); + if (episodeSuffix && (letterOffset < 0 || letterOffset > 25)) { return null; } - return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; + if (!Number.isFinite(season) || !Number.isFinite(effectiveEpisode) || season < 0 || season > 99 || effectiveEpisode <= 0 || effectiveEpisode > 999) { + return null; + } + return `S${String(season).padStart(2, "0")}E${String(effectiveEpisode).padStart(2, "0")}`; }; if (seasonHint !== null && Number.isFinite(seasonHint) && seasonHint >= 0 && seasonHint <= 99) { @@ -1087,6 +1104,7 @@ export class DownloadManager extends EventEmitter { private speedBytesLastWindow = 0; private sessionDownloadedBytes = 0; + private sessionCompletedFiles = 0; private statsCache: DownloadStats | null = null; @@ -1254,6 +1272,7 @@ export class DownloadManager extends EventEmitter { public setSettings(next: AppSettings): void { const previous = this.settings; next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); + next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0); this.settings = next; this.ensureProviderDailyUsageFresh(nowMs()); this.debridService.setSettings(next); @@ -1397,17 +1416,11 @@ export class DownloadManager extends EventEmitter { this.resetSessionTotalsIfQueueEmpty(); - let totalFiles = 0; - for (const item of Object.values(this.session.items)) { - if (item.status === "completed") { - totalFiles += 1; - } - } - const stats = { totalDownloaded: this.sessionDownloadedBytes, totalDownloadedAllTime: this.settings.totalDownloadedAllTime, - totalFiles, + totalFilesSession: this.sessionCompletedFiles, + totalFilesAllTime: this.settings.totalCompletedFilesAllTime, totalPackages: this.session.packageOrder.length, sessionStartedAt: this.session.runStartedAt }; @@ -1416,6 +1429,11 @@ export class DownloadManager extends EventEmitter { return stats; } + private invalidateStatsCache(): void { + this.statsCache = null; + this.statsCacheAt = 0; + } + private resetSessionTotalsIfQueueEmpty(): void { if (this.itemCount > 0 || this.session.packageOrder.length > 0) { return; @@ -1423,18 +1441,35 @@ export class DownloadManager extends EventEmitter { if (Object.keys(this.session.items).length > 0 || Object.keys(this.session.packages).length > 0) { return; } + } + public resetSessionStats(): void { this.session.totalDownloadedBytes = 0; this.sessionDownloadedBytes = 0; - this.session.runStartedAt = 0; + this.sessionCompletedFiles = 0; + this.session.runStartedAt = this.session.running ? nowMs() : 0; + this.session.summaryText = ""; this.lastGlobalProgressBytes = 0; this.lastGlobalProgressAt = nowMs(); this.speedEvents = []; this.speedEventsHead = 0; this.speedBytesLastWindow = 0; this.speedBytesPerPackage.clear(); - this.statsCache = null; - this.statsCacheAt = 0; + this.summary = null; + this.invalidateStatsCache(); + this.persistSoon(); + this.emitState(true); + } + + public resetDownloadStats(): void { + this.settings.totalDownloadedAllTime = 0; + this.settings.totalCompletedFilesAllTime = 0; + this.settings.providerTotalUsageBytes = {}; + this.settings.debridLinkApiKeyTotalUsageBytes = {}; + this.lastSettingsPersistAt = nowMs(); + saveSettings(this.storagePaths, this.settings); + this.invalidateStatsCache(); + this.emitState(true); } public renamePackage(packageId: string, newName: string): void { @@ -3335,6 +3370,7 @@ export class DownloadManager extends EventEmitter { this.session.paused = false; this.session.runStartedAt = nowMs(); this.session.totalDownloadedBytes = 0; + this.sessionCompletedFiles = 0; this.session.summaryText = ""; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; @@ -3442,6 +3478,7 @@ export class DownloadManager extends EventEmitter { this.session.paused = false; this.session.runStartedAt = nowMs(); this.session.totalDownloadedBytes = 0; + this.sessionCompletedFiles = 0; this.session.summaryText = ""; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; @@ -3552,6 +3589,7 @@ export class DownloadManager extends EventEmitter { this.session.paused = false; this.session.runStartedAt = 0; this.session.totalDownloadedBytes = 0; + this.sessionCompletedFiles = 0; this.session.summaryText = ""; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; @@ -3583,6 +3621,7 @@ export class DownloadManager extends EventEmitter { // Only runStartedAt resets (for ETA/speed calculations relative to current run). this.session.runStartedAt = nowMs(); this.session.totalDownloadedBytes = 0; + this.sessionCompletedFiles = 0; this.session.summaryText = ""; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; @@ -4146,16 +4185,20 @@ export class DownloadManager extends EventEmitter { if (!this.runItemIds.has(itemId)) { return; } + const previous = this.runOutcomes.get(itemId); this.runOutcomes.set(itemId, status); + if (status === "completed" && previous !== "completed") { + this.sessionCompletedFiles += 1; + this.settings.totalCompletedFilesAllTime = Math.max(0, Number(this.settings.totalCompletedFilesAllTime || 0)) + 1; + this.invalidateStatsCache(); + } } private dropItemContribution(itemId: string): void { - const contributed = this.itemContributedBytes.get(itemId) || 0; - if (contributed > 0) { - this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - contributed); - this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes - contributed); - } this.itemContributedBytes.delete(itemId); + // Session totals are cumulative for the current app run and must not shrink + // just because an item/package is removed from the queue after completion. + this.invalidateStatsCache(); } private claimTargetPath(itemId: string, preferredPath: string, allowExistingFile = false): string { @@ -5180,12 +5223,16 @@ export class DownloadManager extends EventEmitter { } const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider; const nextUsage = addProviderDailyUsageBytes(this.settings, effectiveProvider, byteDelta); + const nextTotalUsage = addProviderTotalUsageBytes(this.settings, effectiveProvider, byteDelta); this.settings.providerDailyUsageDay = nextUsage.providerDailyUsageDay; this.settings.providerDailyUsageBytes = nextUsage.providerDailyUsageBytes; + this.settings.providerTotalUsageBytes = nextTotalUsage.providerTotalUsageBytes; if (effectiveProvider === "debridlink" && providerAccountId) { const nextKeyUsage = addDebridLinkApiKeyDailyUsageBytes(this.settings, providerAccountId, byteDelta); + const nextKeyTotalUsage = addDebridLinkApiKeyTotalUsageBytes(this.settings, providerAccountId, byteDelta); this.settings.providerDailyUsageDay = nextKeyUsage.providerDailyUsageDay; this.settings.debridLinkApiKeyDailyUsageBytes = nextKeyUsage.debridLinkApiKeyDailyUsageBytes; + this.settings.debridLinkApiKeyTotalUsageBytes = nextKeyTotalUsage.debridLinkApiKeyTotalUsageBytes; } } @@ -6438,7 +6485,9 @@ export class DownloadManager extends EventEmitter { item, active, refreshDelayMs, - `Direktlink erneuern, Retry ${active.genericErrorRetries}/${retryDisplayLimit}` + exhaustedReason.startsWith("range_ignored_on_resume:") + ? `Resume-Link erneuern, Retry ${active.genericErrorRetries}/${retryDisplayLimit}` + : `Direktlink erneuern, Retry ${active.genericErrorRetries}/${retryDisplayLimit}` ); item.lastError = exhaustedReason; this.persistSoon(); @@ -6838,20 +6887,26 @@ export class DownloadManager extends EventEmitter { const resumable = response.status === 206 || acceptRanges; active.resumable = resumable; - // CRITICAL: If we sent Range header but server responded 200 (not 206), - // it's sending the full file. We MUST write in truncate mode, not append. - const serverIgnoredRange = existingBytes > 0 && response.status === 200; - if (serverIgnoredRange) { - logger.warn(`Server ignorierte Range-Header (HTTP 200 statt 206), starte von vorne: ${item.fileName}`); - logAttemptEvent("WARN", "Server ignorierte Range-Header", { - attempt, - existingBytes - }); - } - const rawContentLength = Number(response.headers.get("content-length") || 0); const contentLength = Number.isFinite(rawContentLength) && rawContentLength > 0 ? rawContentLength : 0; const totalFromRange = parseContentRangeTotal(response.headers.get("content-range")); + const serverIgnoredRange = existingBytes > 0 && response.status === 200; + if (serverIgnoredRange) { + logger.warn(`Server ignorierte Range-Header (HTTP 200 statt 206), verwerfe Direktlink und behalte Teil-Datei: ${item.fileName}`); + logAttemptEvent("WARN", "Server ignorierte Range-Header beim Resume", { + attempt, + existingBytes, + contentLength, + directUrl + }); + try { + await response.body?.cancel(); + } catch { + // ignore + } + throw new Error(`range_ignored_on_resume:${existingBytes}/${contentLength || 0}`); + } + if (knownTotal && knownTotal > 0) { item.totalBytes = knownTotal; } else if (totalFromRange) { @@ -7436,6 +7491,9 @@ export class DownloadManager extends EventEmitter { error: lastError, targetPath: effectiveTargetPath }); + if (lastError.startsWith("range_ignored_on_resume:")) { + throw new Error(`direct_link_retry_exhausted:${lastError}`); + } if (attempt < maxAttempts) { item.retries += 1; item.fullStatus = `Downloadfehler, retry ${attempt}/${maxAttempts} (Direktlink)`; @@ -8250,12 +8308,15 @@ export class DownloadManager extends EventEmitter { ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` : ""; const archivePct = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0)))); + const isFinalizing = archivePct >= 99; let label: string; if (progress.passwordFound) { label = `Passwort gefunden · ${progress.archiveName}`; } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`; + } else if (isFinalizing) { + label = `Finalisieren${archiveLabel}${elapsed}`; } else { label = `Entpacken ${archivePct}%${archiveLabel}${elapsed}`; } @@ -8276,6 +8337,12 @@ export class DownloadManager extends EventEmitter { } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); pkg.postProcessLabel = `Passwort knacken: ${pwPct}%`; + } else if (Number(progress.archivePercent ?? 0) >= 99) { + const archive = progress.archiveName ? ` · ${progress.archiveName}` : ""; + const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 + ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` + : ""; + pkg.postProcessLabel = `Finalisieren (${currentDisplay}/${progress.total})${archive}${elapsed}`; } else { pkg.postProcessLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})`; } @@ -8698,12 +8765,15 @@ export class DownloadManager extends EventEmitter { ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` : ""; const archivePct = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0)))); + const isFinalizing = archivePct >= 99; let label: string; if (progress.passwordFound) { label = `Passwort gefunden · ${progress.archiveName}`; } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`; + } else if (isFinalizing) { + label = `Finalisieren${archiveTag}${elapsed}`; } else { label = `Entpacken ${archivePct}%${archiveTag}${elapsed}`; } @@ -8729,6 +8799,8 @@ export class DownloadManager extends EventEmitter { } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); overallLabel = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName || ""}`; + } else if (Number(progress.archivePercent ?? 0) >= 99) { + overallLabel = `Finalisieren (${currentDisplay}/${progress.total})${archive}${elapsed}`; } else { overallLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`; } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index b6ad9b9..de7ed03 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -1038,10 +1038,33 @@ export function resolveExtractorBackendMode( return "auto"; } +export function resolveExtractorBackendModeForArchive( + archivePath: string, + rawValue?: string | null, + isVitestEnv = Boolean(process.env.VITEST), + platform = process.platform +): ExtractBackendMode { + const requestedMode = resolveExtractorBackendMode(rawValue, isVitestEnv); + if (requestedMode !== "auto") { + return requestedMode; + } + // On Windows, multipart RAR extraction feels significantly snappier with the + // native CLI path than with the JVM backend, and we already harden that path + // with subst + flat-mode fallback. + if (String(platform || "").toLowerCase() === "win32" && isRarArchivePath(archivePath)) { + return "legacy"; + } + return requestedMode; +} + function extractorBackendMode(): ExtractBackendMode { return resolveExtractorBackendMode(process.env.RD_EXTRACT_BACKEND); } +function extractorBackendModeForArchive(archivePath: string): ExtractBackendMode { + return resolveExtractorBackendModeForArchive(archivePath, process.env.RD_EXTRACT_BACKEND); +} + function isJvmRuntimeMissingError(errorText: string): boolean { const text = String(errorText || "").toLowerCase(); return text.includes("could not find or load main class") @@ -1961,14 +1984,15 @@ async function runExternalExtract( onLog?: ExtractOptions["onLog"] ): Promise { const timeoutMs = await computeExtractTimeoutMs(archivePath); - const backendMode = extractorBackendMode(); + const configuredBackendMode = extractorBackendMode(); + const backendMode = extractorBackendModeForArchive(archivePath); const archiveName = path.basename(archivePath); const totalStartedAt = Date.now(); let jvmFailureReason = ""; let jvmCodecError = false; let fallbackFromJvm = false; - logger.info(`Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`); - onLog?.("INFO", `Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`); + logger.info(`Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, configuredMode=${configuredBackendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`); + onLog?.("INFO", `Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, configuredMode=${configuredBackendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`); await fs.promises.mkdir(targetDir, { recursive: true }); diff --git a/src/main/main.ts b/src/main/main.ts index 92efb13..41e081d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -464,6 +464,8 @@ function registerIpcHandlers(): void { return result.canceled ? [] : result.filePaths; }); ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats()); + ipcMain.handle(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats()); + ipcMain.handle(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats()); ipcMain.handle(IPC_CHANNELS.RESTART, () => { app.relaunch(); diff --git a/src/main/storage.ts b/src/main/storage.ts index 0779f15..bf86e24 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -298,6 +298,11 @@ export function normalizeSettings(settings: AppSettings): AppSettings { megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled, "sum" ); + const providerTotalUsageBytes = normalizeProviderByteMap( + settings.providerTotalUsageBytes, + megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled, + "sum" + ); const debridLinkApiKeyDailyLimitBytes = normalizeNamedByteMap( settings.debridLinkApiKeyDailyLimitBytes, debridLinkApiKeyIds @@ -306,6 +311,10 @@ export function normalizeSettings(settings: AppSettings): AppSettings { settings.debridLinkApiKeyDailyUsageBytes, debridLinkApiKeyIds ); + const debridLinkApiKeyTotalUsageBytes = normalizeNamedByteMap( + settings.debridLinkApiKeyTotalUsageBytes, + debridLinkApiKeyIds + ); const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds); const normalized: AppSettings = { token: asText(settings.token), @@ -374,6 +383,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems, confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime, + totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime, theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme, bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules), columnOrder: normalizeColumnOrder(settings.columnOrder), @@ -387,8 +397,10 @@ export function normalizeSettings(settings: AppSettings): AppSettings { "max" ), providerDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? providerDailyUsageBytes : {}, + providerTotalUsageBytes, debridLinkApiKeyDailyLimitBytes, debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {}, + debridLinkApiKeyTotalUsageBytes, providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) }; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index ccd0935..84b78f0 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -50,6 +50,8 @@ const api: ElectronApi = { pickFolder: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER), pickContainers: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS), getSessionStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS), + resetSessionStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS), + resetDownloadStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS), restart: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESTART), quit: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.QUIT), exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 81ca1d7..dcc6d28 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -19,11 +19,13 @@ import type { UpdateInstallProgress } from "../shared/types"; import { + getDebridLinkApiKeyTotalUsageBytes, getDebridLinkApiKeyDailyLimitBytes, getDebridLinkApiKeyDailyRemainingBytes, getDebridLinkApiKeyDailyUsageBytes, getProviderDailyLimitBytes, getProviderDailyRemainingBytes, + getProviderTotalUsageBytes, getProviderDailyUsageBytes, getProviderUsageDayKey } from "../shared/provider-daily-limits"; @@ -110,6 +112,7 @@ interface DebridLinkAccountKeyEntry { masked: string; disabled: boolean; dailyUsedBytes: number; + totalUsedBytes: number; dailyLimitBytes: number; dailyRemainingBytes: number | null; dailyLimitReached: boolean; @@ -127,6 +130,7 @@ interface ConfiguredAccountEntry { note: string; disabled: boolean; dailyUsedBytes: number; + totalUsedBytes: number; dailyLimitBytes: number; dailyRemainingBytes: number | null; dailyLimitReached: boolean; @@ -686,7 +690,8 @@ function validateAccountDialog(dialog: AccountDialogState): string | null { const emptyStats = (): DownloadStats => ({ totalDownloaded: 0, totalDownloadedAllTime: 0, - totalFiles: 0, + totalFilesSession: 0, + totalFilesAllTime: 0, totalPackages: 0, sessionStartedAt: 0 }); @@ -707,15 +712,17 @@ const emptySnapshot = (): UiSnapshot => ({ updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, theme: "dark", collapseNewPackages: true, autoSortPackagesByProgress: true, autoSkipExtracted: false, confirmDeleteSelection: true, accountListShowDetailedDebridLinkKeys: false, - bandwidthSchedules: [], totalDownloadedAllTime: 0, + bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], autoExtractWhenStopped: true, disabledProviders: [], hosterRouting: {}, providerDailyLimitBytes: {}, providerDailyUsageBytes: {}, + providerTotalUsageBytes: {}, debridLinkApiKeyDailyLimitBytes: {}, debridLinkApiKeyDailyUsageBytes: {}, + debridLinkApiKeyTotalUsageBytes: {}, providerDailyUsageDay: getProviderUsageDayKey(), scheduledStartEpochMs: 0 }, @@ -1163,11 +1170,11 @@ type PkgSortColumn = "name" | "size" | "hoster" | "progress"; const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"]; const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "added"]; const COLUMN_DEFS: Record = { - name: { label: "Name", width: "1fr", sortable: "name" }, + name: { label: "Name", width: "minmax(0, 0.92fr)", sortable: "name" }, size: { label: "Geladen / Größe", width: "160px", sortable: "size" }, progress: { label: "Fortschritt", width: "80px", sortable: "progress" }, hoster: { label: "Hoster", width: "110px", sortable: "hoster" }, - account: { label: "Service", width: "110px" }, + account: { label: "Service", width: "132px" }, prio: { label: "Priorität", width: "70px" }, status: { label: "Status", width: "160px" }, speed: { label: "Geschwindigkeit", width: "90px" }, @@ -1252,6 +1259,8 @@ export function App(): ReactElement { const toastTimerRef = useRef | null>(null); const onImportDlcRef = useRef<() => Promise>(() => Promise.resolve()); const [dragOver, setDragOver] = useState(false); + const [draggedProvider, setDraggedProvider] = useState(null); + const [providerDropTarget, setProviderDropTarget] = useState(null); const [editingPackageId, setEditingPackageId] = useState(null); const [editingName, setEditingName] = useState(""); const [collectorTabs, setCollectorTabs] = useState([ @@ -1946,6 +1955,43 @@ export function App(): ReactElement { })); }, []); + const onProviderDragStart = useCallback((event: DragEvent, provider: DebridProvider): void => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", provider); + setDraggedProvider(provider); + setProviderDropTarget(provider); + }, []); + + const onProviderDragOver = useCallback((event: DragEvent, provider: DebridProvider): void => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + if (providerDropTarget !== provider) { + setProviderDropTarget(provider); + } + }, [providerDropTarget]); + + const onProviderDrop = useCallback((event: DragEvent, provider: DebridProvider): void => { + event.preventDefault(); + if (!draggedProvider || draggedProvider === provider) { + return; + } + const currentOrder = [...activeProviderOrder]; + const fromIndex = currentOrder.indexOf(draggedProvider); + const toIndex = currentOrder.indexOf(provider); + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) { + return; + } + currentOrder.splice(fromIndex, 1); + currentOrder.splice(toIndex, 0, draggedProvider); + setProviderOrder(currentOrder); + setProviderDropTarget(provider); + }, [activeProviderOrder, draggedProvider, setProviderOrder]); + + const onProviderDragEnd = useCallback((): void => { + setDraggedProvider(null); + setProviderDropTarget(null); + }, []); + const normalizedSettingsDraft: AppSettings = useMemo(() => ({ ...settingsDraft, ...normalizeProviderSelectionForSettings(settingsDraft) @@ -1989,6 +2035,7 @@ export function App(): ReactElement { } const provider = getAccountServiceProvider(service); const dailyUsedBytes = getProviderDailyUsageBytes(snapshot.settings, provider); + const totalUsedBytes = getProviderTotalUsageBytes(snapshot.settings, provider); const dailyLimitBytes = getProviderDailyLimitBytes(settingsDraft, provider); const dailyRemainingBytes = getProviderDailyRemainingBytes({ providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes, @@ -2015,6 +2062,7 @@ export function App(): ReactElement { masked: key.masked, disabled: (settingsDraft.debridLinkDisabledKeyIds || []).includes(key.id), dailyUsedBytes: keyDailyUsedBytes, + totalUsedBytes: getDebridLinkApiKeyTotalUsageBytes(snapshot.settings, key.id), dailyLimitBytes: keyDailyLimitBytes, dailyRemainingBytes: keyDailyRemainingBytes, dailyLimitReached: keyDailyLimitBytes > 0 && keyDailyUsedBytes >= keyDailyLimitBytes @@ -2056,6 +2104,7 @@ export function App(): ReactElement { note, disabled: isDisabled, dailyUsedBytes, + totalUsedBytes, dailyLimitBytes, dailyRemainingBytes, dailyLimitReached, @@ -2238,7 +2287,9 @@ export function App(): ReactElement { totalDownloadedAllTime: Math.max(prev.totalDownloadedAllTime, result.totalDownloadedAllTime), providerDailyUsageDay: result.providerDailyUsageDay, providerDailyUsageBytes: { ...(result.providerDailyUsageBytes || {}) }, - debridLinkApiKeyDailyUsageBytes: { ...(result.debridLinkApiKeyDailyUsageBytes || {}) } + providerTotalUsageBytes: { ...(result.providerTotalUsageBytes || {}) }, + debridLinkApiKeyDailyUsageBytes: { ...(result.debridLinkApiKeyDailyUsageBytes || {}) }, + debridLinkApiKeyTotalUsageBytes: { ...(result.debridLinkApiKeyTotalUsageBytes || {}) } })); }; @@ -2973,7 +3024,94 @@ export function App(): ReactElement { }, [selectedIds, snapshot.session.packages, showToast]); const onPackageToggle = useCallback((packageId: string): void => { + let previousEnabled: boolean | null = null; + setSnapshot((prev) => { + const pkg = prev.session.packages[packageId]; + if (!pkg) { + return prev; + } + previousEnabled = pkg.enabled; + const nextEnabled = !pkg.enabled; + const nextItems = { ...prev.session.items }; + if (!nextEnabled) { + for (const itemId of pkg.itemIds) { + const item = nextItems[itemId]; + if (!item) { + continue; + } + if (item.status === "queued" || item.status === "reconnect_wait") { + nextItems[itemId] = { + ...item, + fullStatus: "Paket gestoppt", + updatedAt: Date.now() + }; + } + } + } else { + for (const itemId of pkg.itemIds) { + const item = nextItems[itemId]; + if (!item) { + continue; + } + if (item.status === "queued" && item.fullStatus === "Paket gestoppt") { + nextItems[itemId] = { + ...item, + fullStatus: "Wartet", + updatedAt: Date.now() + }; + } + } + } + const nextPkgStatus = !nextEnabled + ? (pkg.status === "downloading" || pkg.status === "extracting" ? "paused" : pkg.status) + : (pkg.status === "paused" ? "queued" : pkg.status); + const nextSnapshot: UiSnapshot = { + ...prev, + session: { + ...prev.session, + items: nextItems, + packages: { + ...prev.session.packages, + [packageId]: { + ...pkg, + enabled: nextEnabled, + status: nextPkgStatus, + updatedAt: Date.now() + } + }, + updatedAt: Date.now() + } + }; + latestStateRef.current = nextSnapshot; + return nextSnapshot; + }); void window.rd.togglePackage(packageId).catch((error) => { + if (previousEnabled !== null) { + setSnapshot((prev) => { + const pkg = prev.session.packages[packageId]; + if (!pkg) { + return prev; + } + const revertedSnapshot: UiSnapshot = { + ...prev, + session: { + ...prev.session, + packages: { + ...prev.session.packages, + [packageId]: { + ...pkg, + enabled: previousEnabled, + status: previousEnabled && pkg.status === "paused" ? "queued" : pkg.status, + updatedAt: Date.now() + } + }, + updatedAt: Date.now() + } + }; + latestStateRef.current = revertedSnapshot; + return revertedSnapshot; + }); + } showToast(`Paket-Umschalten fehlgeschlagen: ${String(error)}`, 2400); }); }, [showToast]); @@ -3913,6 +4051,7 @@ export function App(): ReactElement { pkg={pkg} items={itemsByPackage.get(pkg.id) ?? []} packageSpeed={packageSpeedMap.get(pkg.id) ?? 0} + stripeVariant={idx % 2 === 0 ? "a" : "b"} isFirst={idx === 0} isLast={idx === visiblePackages.length - 1} isEditing={editingPackageId === pkg.id} @@ -4064,6 +4203,22 @@ export function App(): ReactElement {

Session-Übersicht

+
+ + +
Aktuelle Geschwindigkeit @@ -4078,8 +4233,12 @@ export function App(): ReactElement { {humanSize(snapshot.stats.totalDownloadedAllTime)}
- Fertige Dateien - {snapshot.stats.totalFiles} + Fertige Dateien (Gesamt) + {snapshot.stats.totalFilesAllTime} +
+
+ Fertige Dateien (Session) + {snapshot.stats.totalFilesSession}
Pakete @@ -4310,16 +4469,22 @@ export function App(): ReactElement {
{entry.debridLinkKeys.length > 0 ? ( - +
+ + Insgesamt: {humanSize(entry.totalUsedBytes)} +
) : ( -
- Heute: {humanSize(entry.dailyUsedBytes)} - {entry.dailyLimitBytes > 0 ? `Limit: ${humanSize(entry.dailyLimitBytes)}` : "Kein Tageslimit"} - {entry.dailyLimitBytes > 0 && ( - {entry.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(entry.dailyRemainingBytes || 0)}`} - )} +
+
+ Heute: {humanSize(entry.dailyUsedBytes)} + {entry.dailyLimitBytes > 0 ? `Limit: ${humanSize(entry.dailyLimitBytes)}` : "Kein Tageslimit"} + {entry.dailyLimitBytes > 0 && ( + {entry.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(entry.dailyRemainingBytes || 0)}`} + )} +
+ Insgesamt: {humanSize(entry.totalUsedBytes)}
)}
@@ -4380,7 +4545,15 @@ export function App(): ReactElement { {activeProviderOrder.length > 0 && (
{activeProviderOrder.map((provider, idx) => ( -
+
onProviderDragStart(event, provider)} + onDragOver={(event) => onProviderDragOver(event, provider)} + onDrop={(event) => onProviderDrop(event, provider)} + onDragEnd={onProviderDragEnd} + > {idx + 1}. {providerLabelWithMode(provider, settingsDraft)}
@@ -5109,7 +5282,7 @@ export function App(): ReactElement {
{hasPackages && !contextMenu.itemId && ( +
{(["high", "normal", "low"] as const).map((p) => { const label = p === "high" ? "Hoch" : p === "low" ? "Niedrig" : "Standard"; const pkgIds = selectedPackageIds; const allMatch = pkgIds.every((id) => (snapshot.session.packages[id]?.priority || "normal") === p); - return ; + return ; })}
@@ -5395,6 +5568,7 @@ interface PackageCardProps { pkg: PackageEntry; items: DownloadItem[]; packageSpeed: number; + stripeVariant: "a" | "b"; isFirst: boolean; isLast: boolean; isEditing: boolean; @@ -5422,7 +5596,7 @@ interface PackageCardProps { onDragEnd: () => void; } -const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement { +const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripeVariant, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement { const done = items.filter((item) => item.status === "completed").length; const failed = items.filter((item) => item.status === "failed").length; const cancelled = items.filter((item) => item.status === "cancelled").length; @@ -5460,7 +5634,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs return (
{ e.preventDefault(); e.stopPropagation(); onContextMenu(pkg.id, undefined, e.clientX, e.clientY); }} onClick={(e) => { if (e.ctrlKey || e.shiftKey) onSelect(pkg.id, e.ctrlKey, e.shiftKey); }} @@ -5527,7 +5701,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs {pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : ""} ); case "status": return ( - [{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` ? ${failed} Fehler` : ""}{cancelled > 0 ? ` ? ${cancelled} abgebr.` : ""}]{pkg.postProcessLabel ? ` - ${pkg.postProcessLabel}` : ""} + [{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` | ${failed} Fehler` : ""}{cancelled > 0 ? ` | ${cancelled} abgebr.` : ""}]{pkg.postProcessLabel ? ` - ${pkg.postProcessLabel}` : ""} ); case "speed": return ( {packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : ""} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 63a5b4e..9b251ba 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1336,6 +1336,15 @@ body, min-width: 0; } +.account-usage-stack { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 0; +} + .account-service-cell strong { font-size: 14px; } @@ -1378,6 +1387,12 @@ body, color: color-mix(in srgb, #f59e0b 78%, white 8%); } +.account-usage-total { + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} + .account-mode-pill, .account-status-pill { display: inline-flex; @@ -1703,6 +1718,19 @@ body, border-radius: 8px; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border); + cursor: grab; + transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease; +} + +.provider-order-row.dragging { + opacity: 0.6; + cursor: grabbing; +} + +.provider-order-row.drag-target { + border-color: color-mix(in srgb, var(--accent) 65%, var(--border)); + background: color-mix(in srgb, var(--accent) 10%, rgba(255, 255, 255, 0.04)); + transform: translateY(-1px); } .provider-order-num { @@ -2051,6 +2079,14 @@ body, border-bottom: 1px solid color-mix(in srgb, var(--border) 54%, transparent); } +.queue-package-card.pkg-stripe-a { + background: color-mix(in srgb, var(--surface) 18%, transparent); +} + +.queue-package-card.pkg-stripe-b { + background: color-mix(in srgb, var(--card) 24%, transparent); +} + .queue-package-card:hover { background: color-mix(in srgb, var(--accent) 3%, transparent); } @@ -2444,6 +2480,13 @@ td { grid-column: span 2; } +.stats-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 3ec16a4..6758a3c 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -30,6 +30,8 @@ export const IPC_CHANNELS = { CLIPBOARD_DETECTED: "clipboard:detected", TOGGLE_CLIPBOARD: "clipboard:toggle", GET_SESSION_STATS: "stats:get-session-stats", + RESET_SESSION_STATS: "stats:reset-session", + RESET_DOWNLOAD_STATS: "stats:reset-download", RESTART: "app:restart", QUIT: "app:quit", EXPORT_BACKUP: "app:export-backup", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 23fe3f2..274d909 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -45,6 +45,8 @@ export interface ElectronApi { pickFolder: () => Promise; pickContainers: () => Promise; getSessionStats: () => Promise; + resetSessionStats: () => Promise; + resetDownloadStats: () => Promise; restart: () => Promise; quit: () => Promise; exportBackup: () => Promise<{ saved: boolean }>; diff --git a/src/shared/provider-daily-limits.ts b/src/shared/provider-daily-limits.ts index 1be261b..dde1ff6 100644 --- a/src/shared/provider-daily-limits.ts +++ b/src/shared/provider-daily-limits.ts @@ -7,6 +7,10 @@ type ProviderDailySettings = Pick & Partial>; +type ProviderUsageSettings = + ProviderDailySettings + & Partial>; + function normalizePositiveBytes(value: unknown): number { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric <= 0) { @@ -59,6 +63,10 @@ export function isProviderDailyLimitReached( return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit; } +export function getProviderTotalUsageBytes(settings: ProviderUsageSettings, provider: DebridProvider): number { + return normalizePositiveBytes(settings.providerTotalUsageBytes?.[provider]); +} + export function resetProviderDailyUsage( settings: ProviderDailySettings, provider?: DebridProvider, @@ -110,6 +118,26 @@ export function addProviderDailyUsageBytes( }; } +export function addProviderTotalUsageBytes( + settings: ProviderUsageSettings, + provider: DebridProvider, + byteDelta: number +): Pick { + const increment = normalizePositiveBytes(byteDelta); + const currentUsageBytes = { ...(settings.providerTotalUsageBytes || {}) }; + if (increment <= 0) { + return { + providerTotalUsageBytes: currentUsageBytes + }; + } + + currentUsageBytes[provider] = normalizePositiveBytes(currentUsageBytes[provider]) + increment; + + return { + providerTotalUsageBytes: currentUsageBytes + }; +} + export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number { return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]); } @@ -146,6 +174,10 @@ export function isDebridLinkApiKeyDailyLimitReached( return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit; } +export function getDebridLinkApiKeyTotalUsageBytes(settings: ProviderUsageSettings, keyId: string): number { + return normalizePositiveBytes(settings.debridLinkApiKeyTotalUsageBytes?.[keyId]); +} + export function resetDebridLinkApiKeyDailyUsage( settings: ProviderDailySettings, keyId?: string, @@ -195,3 +227,23 @@ export function addDebridLinkApiKeyDailyUsageBytes( debridLinkApiKeyDailyUsageBytes: currentUsageBytes }; } + +export function addDebridLinkApiKeyTotalUsageBytes( + settings: ProviderUsageSettings, + keyId: string, + byteDelta: number +): Pick { + const increment = normalizePositiveBytes(byteDelta); + const currentUsageBytes = { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) }; + if (increment <= 0) { + return { + debridLinkApiKeyTotalUsageBytes: currentUsageBytes + }; + } + + currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment; + + return { + debridLinkApiKeyTotalUsageBytes: currentUsageBytes + }; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 4bd6def..8a6a3ac 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,7 +41,8 @@ export interface BandwidthScheduleEntry { export interface DownloadStats { totalDownloaded: number; totalDownloadedAllTime: number; - totalFiles: number; + totalFilesSession: number; + totalFilesAllTime: number; totalPackages: number; sessionStartedAt: number; } @@ -108,6 +109,7 @@ export interface AppSettings { hideExtractedItems: boolean; confirmDeleteSelection: boolean; totalDownloadedAllTime: number; + totalCompletedFilesAllTime: number; bandwidthSchedules: BandwidthScheduleEntry[]; columnOrder: string[]; extractCpuPriority: ExtractCpuPriority; @@ -116,8 +118,10 @@ export interface AppSettings { hosterRouting: Record; providerDailyLimitBytes: Partial>; providerDailyUsageBytes: Partial>; + providerTotalUsageBytes: Partial>; debridLinkApiKeyDailyLimitBytes: Record; debridLinkApiKeyDailyUsageBytes: Record; + debridLinkApiKeyTotalUsageBytes: Record; providerDailyUsageDay: string; scheduledStartEpochMs: number; } diff --git a/tests/auto-rename.test.ts b/tests/auto-rename.test.ts index c732c88..0005ea7 100644 --- a/tests/auto-rename.test.ts +++ b/tests/auto-rename.test.ts @@ -551,6 +551,28 @@ describe("buildAutoRenameBaseNameFromFolders", () => { expect(result).toBe("Lethal.Weapon.S02E11.German.DD51.Dubbed.DL.720p.AmazonHD.x264-TVS"); }); + it("maps compact code 319a to episode 19 in season 3 folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03.GERMAN.AC3.720p.HDTV.x264-hrs" + ], + "hrs-bpol.hdtv.7p-319a", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03E19.GERMAN.AC3.720p.HDTV.x264-hrs"); + }); + + it("maps compact code 319b to next episode in season 3 folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03.GERMAN.AC3.720p.HDTV.x264-hrs" + ], + "hrs-bpol.hdtv.7p-319b", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03E20.GERMAN.AC3.720p.HDTV.x264-hrs"); + }); + it("maps episode-only token e01 via season folder hint and keeps REPACK", () => { const result = buildAutoRenameBaseNameFromFoldersWithOptions( [ diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index d4e5930..d075924 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -324,6 +324,155 @@ describe("download manager", () => { } }); + it("preserves partial files and requests a fresh direct link when resume gets HTTP 200", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 21); + const pkgDir = path.join(root, "downloads", "resume-ignored"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "resume-ignored.mkv"); + const partialSize = 96 * 1024; + fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize)); + + let unrestrictCalls = 0; + let ignoredRangeCalls = 0; + let resumeCalls = 0; + const resumeStarts: number[] = []; + + const server = http.createServer((req, res) => { + const route = req.url || ""; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + + if (route === "/ignored-range") { + ignoredRangeCalls += 1; + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + return; + } + + if (route === "/resume-ok") { + resumeCalls += 1; + resumeStarts.push(start); + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + return; + } + + res.statusCode = 404; + res.end("not-found"); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const ignoredRangeUrl = `http://127.0.0.1:${address.port}/ignored-range`; + const resumeUrl = `http://127.0.0.1:${address.port}/resume-ok`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + download: unrestrictCalls === 1 ? ignoredRangeUrl : resumeUrl, + filename: "resume-ignored.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "resume-ignored-pkg"; + const itemId = "resume-ignored-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "resume-ignored", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "resume-ignored"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/resume-ignored", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: partialSize, + totalBytes: binary.length, + progressPercent: Math.floor((partialSize / binary.length) * 100), + fileName: "resume-ignored.mkv", + targetPath: existingTargetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 1, + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBe(binary.length); + expect(unrestrictCalls).toBeGreaterThanOrEqual(2); + expect(ignoredRangeCalls).toBeGreaterThanOrEqual(1); + expect(resumeCalls).toBeGreaterThanOrEqual(1); + expect(resumeStarts).toContain(partialSize); + expect(fs.statSync(existingTargetPath).size).toBe(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + it("assigns unique target paths for same filenames in parallel", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -3431,6 +3580,86 @@ describe("download manager", () => { expect(snapshot.session.runStartedAt).toBe(0); }); + it("keeps cumulative session totals when completed items are removed from the queue", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "pkg-complete-remove"; + const itemId = "item-complete-remove"; + const now = Date.now() - 1000; + const outputDir = path.join(root, "downloads", "pkg-complete-remove"); + const extractDir = path.join(root, "extract", "pkg-complete-remove"); + const targetPath = path.join(outputDir, "episode.mkv"); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "pkg-complete-remove", + outputDir, + extractDir, + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt: now, + updatedAt: now + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/item-complete-remove", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 3 * 1024, + totalBytes: 3 * 1024, + progressPercent: 100, + fileName: "episode.mkv", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (3 KB)", + createdAt: now, + updatedAt: now + }; + + 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")) + ); + + const internal = manager as unknown as { + session: { totalDownloadedBytes: number }; + sessionDownloadedBytes: number; + sessionCompletedFiles: number; + itemContributedBytes: Map; + removePackageFromSession: (packageId: string, itemIds: string[], reason?: "completed" | "deleted") => void; + }; + + internal.session.totalDownloadedBytes = 16 * 1024 * 1024 * 1024; + internal.sessionDownloadedBytes = 16 * 1024 * 1024 * 1024; + internal.sessionCompletedFiles = 1; + internal.itemContributedBytes.set(itemId, 3 * 1024 * 1024 * 1024); + + internal.removePackageFromSession(packageId, [itemId], "completed"); + + const snapshot = manager.getSnapshot(); + expect(snapshot.stats.totalPackages).toBe(0); + expect(snapshot.stats.totalDownloaded).toBe(16 * 1024 * 1024 * 1024); + expect(snapshot.stats.totalFilesSession).toBe(1); + expect(snapshot.session.totalDownloadedBytes).toBe(16 * 1024 * 1024 * 1024); + }); + it("does not start a run when queue is empty", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -5681,7 +5910,8 @@ describe("download manager", () => { megaPassword: "mega-pass", megaDebridApiEnabled: true, providerDailyUsageDay: getProviderUsageDayKey(), - providerDailyUsageBytes: { realdebrid: 512 } + providerDailyUsageBytes: { realdebrid: 512 }, + providerTotalUsageBytes: { realdebrid: 2048 } }, emptySession(), createStoragePaths(path.join(root, "state")) @@ -5697,6 +5927,9 @@ describe("download manager", () => { expect(internal.settings.providerDailyUsageBytes.realdebrid).toBe(512); expect(internal.settings.providerDailyUsageBytes["megadebrid-api"]).toBe(1024); expect((internal.settings.providerDailyUsageBytes as Record).megadebrid).toBeUndefined(); + expect(internal.settings.providerTotalUsageBytes.realdebrid).toBe(2048); + expect(internal.settings.providerTotalUsageBytes["megadebrid-api"]).toBe(1024); + expect((internal.settings.providerTotalUsageBytes as Record).megadebrid).toBeUndefined(); }); it("tracks daily usage on the actual Debrid-Link key without touching other keys", () => { @@ -5710,7 +5943,9 @@ describe("download manager", () => { debridLinkApiKeys: "dl-key-one\ndl-key-two", providerDailyUsageDay: getProviderUsageDayKey(), providerDailyUsageBytes: { debridlink: 256 }, - debridLinkApiKeyDailyUsageBytes: { [secondKey.id]: 512 } + providerTotalUsageBytes: { debridlink: 4096 }, + debridLinkApiKeyDailyUsageBytes: { [secondKey.id]: 512 }, + debridLinkApiKeyTotalUsageBytes: { [secondKey.id]: 2048 } }, emptySession(), createStoragePaths(path.join(root, "state")) @@ -5724,8 +5959,11 @@ describe("download manager", () => { internal.recordProviderDownloadedBytes("debridlink", 1024, firstKey.id); expect(internal.settings.providerDailyUsageBytes.debridlink).toBe(1280); + expect(internal.settings.providerTotalUsageBytes.debridlink).toBe(5120); expect(internal.settings.debridLinkApiKeyDailyUsageBytes[firstKey.id]).toBe(1024); expect(internal.settings.debridLinkApiKeyDailyUsageBytes[secondKey.id]).toBe(512); + expect(internal.settings.debridLinkApiKeyTotalUsageBytes[firstKey.id]).toBe(1024); + expect(internal.settings.debridLinkApiKeyTotalUsageBytes[secondKey.id]).toBe(2048); }); it("does not hang when rapid stop, disable provider, start", async () => { diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index d4863fe..e87915e 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -13,6 +13,7 @@ import { classifyExtractionError, findArchiveCandidates, orderExtractorCandidatesForArchive, + resolveExtractorBackendModeForArchive, resolveExtractorBackendMode, } from "../src/main/extractor"; @@ -1176,6 +1177,17 @@ describe("extractor", () => { expect(resolveExtractorBackendMode("jvm", false)).toBe("jvm"); expect(resolveExtractorBackendMode("auto", false)).toBe("auto"); }); + + it("prefers legacy for rar archives in auto mode on Windows", () => { + expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", undefined, false, "win32")).toBe("legacy"); + expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.r00", undefined, false, "win32")).toBe("legacy"); + }); + + it("keeps auto for non-rar archives and respects explicit overrides", () => { + expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.zip", undefined, false, "win32")).toBe("auto"); + expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "jvm", false, "win32")).toBe("jvm"); + expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "legacy", false, "win32")).toBe("legacy"); + }); }); describe("orderExtractorCandidatesForArchive", () => { diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 8e49f52..893a7a3 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -192,6 +192,10 @@ describe("settings storage", () => { realdebrid: 1024, megadebrid: 2048 } as AppSettings["providerDailyLimitBytes"], + providerTotalUsageBytes: { + realdebrid: 16384, + megadebrid: 32768 + } as AppSettings["providerTotalUsageBytes"], debridLinkApiKeyDailyLimitBytes: { [debridLinkKey.id]: 3072, stale: 1234 @@ -204,6 +208,10 @@ describe("settings storage", () => { debridLinkApiKeyDailyUsageBytes: { [debridLinkKey.id]: 8192, stale: 9999 + }, + debridLinkApiKeyTotalUsageBytes: { + [debridLinkKey.id]: 12288, + stale: 9999 } }); @@ -212,9 +220,16 @@ describe("settings storage", () => { expect(normalized.debridLinkApiKeyDailyLimitBytes).toEqual({ [debridLinkKey.id]: 3072 }); + expect(normalized.providerTotalUsageBytes).toEqual({ + realdebrid: 16384, + "megadebrid-api": 32768 + }); expect(normalized.providerDailyUsageDay).toBe(getProviderUsageDayKey()); expect(normalized.providerDailyUsageBytes).toEqual({}); expect(normalized.debridLinkApiKeyDailyUsageBytes).toEqual({}); + expect(normalized.debridLinkApiKeyTotalUsageBytes).toEqual({ + [debridLinkKey.id]: 12288 + }); }); it("normalizes archive password list line endings", () => {