diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 58625f0..57f99fe 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -5,6 +5,7 @@ import { AppSettings, DuplicatePolicy, ParsedPackageInput, + SessionStats, StartConflictEntry, StartConflictResolutionResult, UiSnapshot, @@ -225,6 +226,10 @@ export class AppController { return this.manager.importQueue(json); } + public getSessionStats(): SessionStats { + return this.manager.getSessionStats(); + } + public shutdown(): void { abortActiveUpdateDownload(); this.manager.prepareForShutdown(); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 62a218a..87570f5 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -4737,4 +4737,79 @@ export class DownloadManager extends EventEmitter { this.persistNow(); this.emitState(); } + + public getSessionStats(): import("../shared/types").SessionStats { + const now = nowMs(); + this.pruneSpeedEvents(now); + + const bandwidthSamples: import("../shared/types").BandwidthSample[] = []; + for (let i = this.speedEventsHead; i < this.speedEvents.length; i += 1) { + const event = this.speedEvents[i]; + if (event) { + bandwidthSamples.push({ + timestamp: event.at, + speedBps: event.bytes * 3 + }); + } + } + + const paused = this.session.running && this.session.paused; + const currentSpeedBps = paused ? 0 : this.speedBytesLastWindow / 3; + + let totalBytes = 0; + let maxSpeed = 0; + for (let i = this.speedEventsHead; i < this.speedEvents.length; i += 1) { + const event = this.speedEvents[i]; + if (event) { + totalBytes += event.bytes; + const speed = event.bytes * 3; + if (speed > maxSpeed) { + maxSpeed = speed; + } + } + } + + const sessionDurationSeconds = this.session.runStartedAt > 0 + ? Math.max(0, Math.floor((now - this.session.runStartedAt) / 1000)) + : 0; + + const averageSpeedBps = sessionDurationSeconds > 0 + ? Math.floor(this.session.totalDownloadedBytes / sessionDurationSeconds) + : 0; + + let totalDownloads = 0; + let completedDownloads = 0; + let failedDownloads = 0; + let activeDownloads = 0; + let queuedDownloads = 0; + + for (const item of Object.values(this.session.items)) { + totalDownloads += 1; + if (item.status === "completed") { + completedDownloads += 1; + } else if (item.status === "failed") { + failedDownloads += 1; + } else if (item.status === "downloading" || item.status === "validating") { + activeDownloads += 1; + } else if (item.status === "queued" || item.status === "reconnect_wait") { + queuedDownloads += 1; + } + } + + return { + bandwidth: { + samples: bandwidthSamples.slice(-120), + currentSpeedBps: Math.floor(currentSpeedBps), + averageSpeedBps, + maxSpeedBps: Math.floor(maxSpeed), + totalBytesSession: this.session.totalDownloadedBytes, + sessionDurationSeconds + }, + totalDownloads, + completedDownloads, + failedDownloads, + activeDownloads, + queuedDownloads + }; + } } diff --git a/src/main/main.ts b/src/main/main.ts index d773a03..40f8c86 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -343,6 +343,7 @@ function registerIpcHandlers(): void { const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); return result.canceled ? [] : result.filePaths; }); + ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats()); controller.onState = (snapshot) => { if (!mainWindow || mainWindow.isDestroyed()) { diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 6991f3c..dd3a17f 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -3,6 +3,7 @@ import { AddLinksPayload, AppSettings, DuplicatePolicy, + SessionStats, StartConflictEntry, StartConflictResolutionResult, UiSnapshot, @@ -40,6 +41,7 @@ const api: ElectronApi = { toggleClipboard: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD), 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), onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => { const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot); ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 506f08f..bcfaf95 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -15,7 +15,7 @@ import type { UpdateInstallProgress } from "../shared/types"; -type Tab = "collector" | "downloads" | "settings"; +type Tab = "collector" | "downloads" | "statistics" | "settings"; interface CollectorTab { id: string; @@ -91,6 +91,186 @@ function humanSize(bytes: number): string { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } +interface BandwidthChartProps { + items: Record; + running: boolean; + paused: boolean; +} + +const BandwidthChart = memo(function BandwidthChart({ items, running, paused }: BandwidthChartProps): ReactElement { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const speedHistoryRef = useRef<{ time: number; speed: number }[]>([]); + const lastUpdateRef = useRef(0); + const [, forceUpdate] = useState(0); + const animationFrameRef = useRef(0); + + const drawChart = useCallback(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const rect = container.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + ctx.scale(dpr, dpr); + + const width = rect.width; + const height = rect.height; + const padding = { top: 20, right: 20, bottom: 30, left: 60 }; + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + ctx.clearRect(0, 0, width, height); + + const isDark = document.documentElement.getAttribute("data-theme") !== "light"; + const gridColor = isDark ? "rgba(35, 57, 84, 0.5)" : "rgba(199, 213, 234, 0.5)"; + const textColor = isDark ? "#90a4bf" : "#4e6482"; + const accentColor = isDark ? "#38bdf8" : "#1168d9"; + const fillColor = isDark ? "rgba(56, 189, 248, 0.15)" : "rgba(17, 104, 217, 0.15)"; + + ctx.strokeStyle = gridColor; + ctx.lineWidth = 1; + for (let i = 0; i <= 5; i += 1) { + const y = padding.top + (chartHeight / 5) * i; + ctx.beginPath(); + ctx.moveTo(padding.left, y); + ctx.lineTo(width - padding.right, y); + ctx.stroke(); + } + + const history = speedHistoryRef.current; + const now = Date.now(); + const maxTime = now; + const minTime = now - 60000; + + let maxSpeed = 0; + for (const point of history) { + if (point.speed > maxSpeed) maxSpeed = point.speed; + } + maxSpeed = Math.max(maxSpeed, 1024 * 1024); + const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed))); + + ctx.fillStyle = textColor; + ctx.font = "11px 'Manrope', sans-serif"; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + + for (let i = 0; i <= 5; i += 1) { + const y = padding.top + (chartHeight / 5) * i; + const speedVal = niceMax * (1 - i / 5); + ctx.fillText(formatSpeedMbps(speedVal), padding.left - 8, y); + } + + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText("0s", padding.left, height - padding.bottom + 8); + ctx.fillText("30s", padding.left + chartWidth / 2, height - padding.bottom + 8); + ctx.fillText("60s", width - padding.right, height - padding.bottom + 8); + + if (history.length < 2) { + ctx.fillStyle = textColor; + ctx.font = "13px 'Manrope', sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(running ? (paused ? "Pausiert" : "Sammle Daten...") : "Download starten fur Statistiken", width / 2, height / 2); + return; + } + + const points: { x: number; y: number }[] = []; + for (const point of history) { + const x = padding.left + ((point.time - minTime) / 60000) * chartWidth; + const y = padding.top + chartHeight - (point.speed / niceMax) * chartHeight; + points.push({ x, y }); + } + + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i += 1) { + ctx.lineTo(points[i].x, points[i].y); + } + ctx.lineTo(points[points.length - 1].x, padding.top + chartHeight); + ctx.lineTo(points[0].x, padding.top + chartHeight); + ctx.closePath(); + ctx.fillStyle = fillColor; + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i += 1) { + ctx.lineTo(points[i].x, points[i].y); + } + ctx.strokeStyle = accentColor; + ctx.lineWidth = 2; + ctx.stroke(); + + const lastPoint = points[points.length - 1]; + ctx.beginPath(); + ctx.arc(lastPoint.x, lastPoint.y, 4, 0, Math.PI * 2); + ctx.fillStyle = accentColor; + ctx.fill(); + }, [running, paused]); + + useEffect(() => { + const interval = setInterval(() => { + const now = Date.now(); + if (now - lastUpdateRef.current >= 250) { + forceUpdate((n) => n + 1); + } + }, 250); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + const now = Date.now(); + const totalSpeed = Object.values(items) + .filter((item) => item.status === "downloading") + .reduce((sum, item) => sum + (item.speedBps || 0), 0); + + const history = speedHistoryRef.current; + history.push({ time: now, speed: paused ? 0 : totalSpeed }); + + const cutoff = now - 60000; + while (history.length > 0 && history[0].time < cutoff) { + history.shift(); + } + + lastUpdateRef.current = now; + }, [items, paused]); + + useEffect(() => { + const handleResize = () => { + animationFrameRef.current = requestAnimationFrame(drawChart); + }; + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [drawChart]); + + useEffect(() => { + drawChart(); + }, [drawChart]); + + return ( +
+ +
+ ); +}); + let nextCollectorId = 1; function createScheduleId(): string { @@ -1249,6 +1429,7 @@ export function App(): ReactElement { @@ -1382,6 +1563,88 @@ export function App(): ReactElement { )} + {tab === "statistics" && ( +
+
+

Session-Ubersicht

+
+
+ Aktuelle Geschwindigkeit + {snapshot.speedText.replace("Geschwindigkeit: ", "")} +
+
+ Gesamt heruntergeladen + {humanSize(snapshot.stats.totalDownloaded)} +
+
+ Fertige Dateien + {snapshot.stats.totalFiles} +
+
+ Pakete + {snapshot.stats.totalPackages} +
+
+ Aktive Downloads + {Object.values(snapshot.session.items).filter((item) => item.status === "downloading").length} +
+
+ In Warteschlange + {Object.values(snapshot.session.items).filter((item) => item.status === "queued" || item.status === "reconnect_wait").length} +
+
+ Fehlerhaft + {Object.values(snapshot.session.items).filter((item) => item.status === "failed").length} +
+
+ {snapshot.etaText.split(": ")[0]} + {snapshot.etaText.split(": ")[1] || "--"} +
+
+
+ +
+

Bandbreitenverlauf

+ +
+ +
+

Provider-Statistik

+
+ {Object.entries( + Object.values(snapshot.session.items).reduce((acc, item) => { + const provider = item.provider || "unknown"; + if (!acc[provider]) { + acc[provider] = { total: 0, completed: 0, failed: 0, bytes: 0 }; + } + acc[provider].total += 1; + if (item.status === "completed") acc[provider].completed += 1; + if (item.status === "failed") acc[provider].failed += 1; + acc[provider].bytes += item.downloadedBytes; + return acc; + }, {} as Record) + ).map(([provider, stats]) => ( +
+ {provider === "unknown" ? "Unbekannt" : providerLabels[provider as DebridProvider] || provider} +
+
+
0 ? (stats.completed / stats.total) * 100 : 0}%` }} /> +
+
+ + {stats.completed}/{stats.total} fertig | {humanSize(stats.bytes)} + {stats.failed > 0 && | {stats.failed} Fehler} + +
+ ))} + {Object.keys(snapshot.session.items).length === 0 && ( +
Noch keine Downloads vorhanden.
+ )} +
+
+
+ )} + {tab === "settings" && (
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 284c0cc..9958992 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -626,6 +626,156 @@ td { grid-template-columns: 1.1fr 1fr; } +.statistics-view { + height: 100%; + overflow: auto; + display: grid; + grid-template-columns: 1fr 1.5fr; + grid-template-rows: auto 1fr; + gap: 10px; + min-height: 0; +} + +.stats-overview { + grid-column: span 2; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; + margin-top: 8px; +} + +.stat-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + background: var(--field); + border-radius: 10px; + border: 1px solid var(--border); +} + +.stat-label { + color: var(--muted); + font-size: 12px; +} + +.stat-value { + font-size: 18px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.stat-value.danger { + color: var(--danger); +} + +.stats-chart-card { + min-height: 280px; + display: flex; + flex-direction: column; +} + +.bandwidth-chart-container { + flex: 1; + min-height: 200px; + position: relative; + margin-top: 8px; + background: var(--field); + border-radius: 10px; + border: 1px solid var(--border); + overflow: hidden; +} + +.bandwidth-chart-container canvas { + display: block; + width: 100%; + height: 100%; +} + +.stats-provider-card { + min-height: 280px; + display: flex; + flex-direction: column; +} + +.provider-stats { + flex: 1; + overflow: auto; + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 8px; +} + +.provider-stat-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + background: var(--field); + border-radius: 10px; + border: 1px solid var(--border); +} + +.provider-name { + font-weight: 600; + font-size: 14px; +} + +.provider-bars { + display: flex; + flex-direction: column; + gap: 3px; +} + +.provider-bar { + height: 8px; + background: var(--progress-track); + border-radius: 999px; + overflow: hidden; +} + +.bar-fill { + height: 100%; + border-radius: 999px; + transition: width 0.3s ease; +} + +.bar-fill.completed { + background: linear-gradient(90deg, #3bc9ff, #22d3ee); +} + +.provider-detail { + color: var(--muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.empty-provider { + color: var(--muted); + text-align: center; + padding: 20px; + font-size: 13px; +} + +@media (max-width: 1100px) { + .statistics-view { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + } + + .stats-overview { + grid-column: span 1; + } + + .stats-grid { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + } +} + .schedule-row { display: grid; grid-template-columns: 56px auto 56px auto 92px auto auto auto; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 3f71776..f0608b9 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -25,5 +25,6 @@ export const IPC_CHANNELS = { PICK_CONTAINERS: "dialog:pick-containers", STATE_UPDATE: "state:update", CLIPBOARD_DETECTED: "clipboard:detected", - TOGGLE_CLIPBOARD: "clipboard:toggle" + TOGGLE_CLIPBOARD: "clipboard:toggle", + GET_SESSION_STATS: "stats:get-session-stats" } as const; diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 3ae4b87..e319396 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -2,6 +2,7 @@ import type { AddLinksPayload, AppSettings, DuplicatePolicy, + SessionStats, StartConflictEntry, StartConflictResolutionResult, UiSnapshot, @@ -35,6 +36,7 @@ export interface ElectronApi { toggleClipboard: () => Promise; pickFolder: () => Promise; pickContainers: () => Promise; + getSessionStats: () => Promise; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; onClipboardDetected: (callback: (links: string[]) => void) => () => void; onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; diff --git a/src/shared/types.ts b/src/shared/types.ts index 185aacf..6b3b303 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -226,3 +226,26 @@ export interface ParsedHashEntry { algorithm: "crc32" | "md5" | "sha1"; digest: string; } + +export interface BandwidthSample { + timestamp: number; + speedBps: number; +} + +export interface BandwidthStats { + samples: BandwidthSample[]; + currentSpeedBps: number; + averageSpeedBps: number; + maxSpeedBps: number; + totalBytesSession: number; + sessionDurationSeconds: number; +} + +export interface SessionStats { + bandwidth: BandwidthStats; + totalDownloads: number; + completedDownloads: number; + failedDownloads: number; + activeDownloads: number; + queuedDownloads: number; +} diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 9ee3db7..fd2ac87 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -954,6 +954,7 @@ describe("download manager", () => { token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), + retryLimit: 1, autoExtract: false }, emptySession(), @@ -1084,6 +1085,7 @@ describe("download manager", () => { token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), + retryLimit: 1, autoExtract: false }, session, @@ -1216,6 +1218,7 @@ describe("download manager", () => { token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), + retryLimit: 1, autoExtract: false }, session, @@ -1359,6 +1362,7 @@ describe("download manager", () => { token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), + retryLimit: 1, autoExtract: false }, session, @@ -1494,7 +1498,7 @@ describe("download manager", () => { server.close(); await once(server, "close"); } - }, 30000); + }, 70000); it("retries non-retriable HTTP statuses and eventually succeeds", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); @@ -4083,6 +4087,7 @@ describe("download manager", () => { const sourceFileName = `${nestedFolder}/tvr-gotham-s03e11-720p.mkv`; const zip = new AdmZip(); zip.addFile(sourceFileName, Buffer.from("video")); + zip.addFile(`${nestedFolder}/tvr-gotham-s03-720p.nfo`, Buffer.from("info")); zip.addFile(`${nestedFolder}/Thumbs.db`, Buffer.from("thumbs")); zip.addFile("desktop.ini", Buffer.from("system")); const archivePath = path.join(outputDir, "episode.zip"); diff --git a/tests/update.test.ts b/tests/update.test.ts index e8c4184..874190d 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -362,6 +362,73 @@ describe("update", () => { expect(requestedUrls.some((url) => url.includes("latest.yml"))).toBe(true); }); + it("rejects installer when latest.yml SHA512 digest does not match", async () => { + const executablePayload = fs.readFileSync(process.execPath); + const wrongDigestBase64 = Buffer.alloc(64, 0x13).toString("base64"); + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.endsWith("/releases/tags/v9.9.9")) { + return new Response(JSON.stringify({ + tag_name: "v9.9.9", + draft: false, + prerelease: false, + assets: [ + { + name: "Real-Debrid-Downloader Setup 9.9.9.exe", + browser_download_url: "https://example.invalid/setup-no-digest.exe" + }, + { + name: "latest.yml", + browser_download_url: "https://example.invalid/latest.yml" + } + ] + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + if (url.includes("latest.yml")) { + return new Response( + `version: 9.9.9\npath: Real-Debrid-Downloader Setup 9.9.9.exe\nsha512: ${wrongDigestBase64}\n`, + { + status: 200, + headers: { "Content-Type": "text/yaml" } + } + ); + } + + if (url.includes("setup-no-digest.exe")) { + return new Response(executablePayload, { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": String(executablePayload.length) + } + }); + } + + return new Response("missing", { status: 404 }); + }) as typeof fetch; + + const prechecked: UpdateCheckResult = { + updateAvailable: true, + currentVersion: APP_VERSION, + latestVersion: "9.9.9", + latestTag: "v9.9.9", + releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9", + setupAssetUrl: "https://example.invalid/setup-no-digest.exe", + setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe", + setupAssetDigest: "" + }; + + const result = await installLatestUpdate("owner/repo", prechecked); + expect(result.started).toBe(false); + expect(result.message).toMatch(/sha512|integrit|mismatch/i); + }); + it("emits install progress events while downloading and launching update", async () => { const executablePayload = fs.readFileSync(process.execPath); const digest = sha256Hex(executablePayload);