Compare commits

...

2 Commits

Author SHA1 Message Date
Sucukdeluxe
5b6b1763d5 Release v1.4.52 with bandwidth statistics
Some checks are pending
Build and Release / build (push) Waiting to run
2026-03-01 15:32:02 +01:00
Sucukdeluxe
f850555e4f Add bandwidth statistics tab with live chart
- Add new Statistics tab between Downloads and Settings
- Implement real-time bandwidth chart using Canvas (60s history)
- Add session overview with 8 stats cards (speed, downloaded, files, packages, etc.)
- Add provider statistics with progress bars
- Add getSessionStats IPC endpoint
- Support dark/light theme in chart rendering
2026-03-01 15:27:50 +01:00
12 changed files with 598 additions and 4 deletions

View File

@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "1.4.51",
"version": "1.4.52",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",

View File

@ -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();

View File

@ -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
};
}
}

View File

@ -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()) {

View File

@ -3,6 +3,7 @@ import {
AddLinksPayload,
AppSettings,
DuplicatePolicy,
SessionStats,
StartConflictEntry,
StartConflictResolutionResult,
UiSnapshot,
@ -40,6 +41,7 @@ const api: ElectronApi = {
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
getSessionStats: (): Promise<SessionStats> => 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);

View File

@ -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<string, DownloadItem>;
running: boolean;
paused: boolean;
}
const BandwidthChart = memo(function BandwidthChart({ items, running, paused }: BandwidthChartProps): ReactElement {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const speedHistoryRef = useRef<{ time: number; speed: number }[]>([]);
const lastUpdateRef = useRef<number>(0);
const [, forceUpdate] = useState(0);
const animationFrameRef = useRef<number>(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 (
<div ref={containerRef} className="bandwidth-chart-container">
<canvas ref={canvasRef} />
</div>
);
});
let nextCollectorId = 1;
function createScheduleId(): string {
@ -1249,6 +1429,7 @@ export function App(): ReactElement {
<nav className="tabs">
<button className={tab === "collector" ? "tab active" : "tab"} onClick={() => setTab("collector")}>Linksammler</button>
<button className={tab === "downloads" ? "tab active" : "tab"} onClick={() => setTab("downloads")}>Downloads</button>
<button className={tab === "statistics" ? "tab active" : "tab"} onClick={() => setTab("statistics")}>Statistiken</button>
<button className={tab === "settings" ? "tab active" : "tab"} onClick={() => setTab("settings")}>Einstellungen</button>
</nav>
@ -1382,6 +1563,88 @@ export function App(): ReactElement {
</section>
)}
{tab === "statistics" && (
<section className="statistics-view">
<article className="card stats-overview">
<h3>Session-Ubersicht</h3>
<div className="stats-grid">
<div className="stat-item">
<span className="stat-label">Aktuelle Geschwindigkeit</span>
<span className="stat-value">{snapshot.speedText.replace("Geschwindigkeit: ", "")}</span>
</div>
<div className="stat-item">
<span className="stat-label">Gesamt heruntergeladen</span>
<span className="stat-value">{humanSize(snapshot.stats.totalDownloaded)}</span>
</div>
<div className="stat-item">
<span className="stat-label">Fertige Dateien</span>
<span className="stat-value">{snapshot.stats.totalFiles}</span>
</div>
<div className="stat-item">
<span className="stat-label">Pakete</span>
<span className="stat-value">{snapshot.stats.totalPackages}</span>
</div>
<div className="stat-item">
<span className="stat-label">Aktive Downloads</span>
<span className="stat-value">{Object.values(snapshot.session.items).filter((item) => item.status === "downloading").length}</span>
</div>
<div className="stat-item">
<span className="stat-label">In Warteschlange</span>
<span className="stat-value">{Object.values(snapshot.session.items).filter((item) => item.status === "queued" || item.status === "reconnect_wait").length}</span>
</div>
<div className="stat-item">
<span className="stat-label">Fehlerhaft</span>
<span className="stat-value danger">{Object.values(snapshot.session.items).filter((item) => item.status === "failed").length}</span>
</div>
<div className="stat-item">
<span className="stat-label">{snapshot.etaText.split(": ")[0]}</span>
<span className="stat-value">{snapshot.etaText.split(": ")[1] || "--"}</span>
</div>
</div>
</article>
<article className="card stats-chart-card">
<h3>Bandbreitenverlauf</h3>
<BandwidthChart items={snapshot.session.items} running={snapshot.session.running} paused={snapshot.session.paused} />
</article>
<article className="card stats-provider-card">
<h3>Provider-Statistik</h3>
<div className="provider-stats">
{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<string, { total: number; completed: number; failed: number; bytes: number }>)
).map(([provider, stats]) => (
<div key={provider} className="provider-stat-item">
<span className="provider-name">{provider === "unknown" ? "Unbekannt" : providerLabels[provider as DebridProvider] || provider}</span>
<div className="provider-bars">
<div className="provider-bar">
<div className="bar-fill completed" style={{ width: `${stats.total > 0 ? (stats.completed / stats.total) * 100 : 0}%` }} />
</div>
</div>
<span className="provider-detail">
{stats.completed}/{stats.total} fertig | {humanSize(stats.bytes)}
{stats.failed > 0 && <span className="danger"> | {stats.failed} Fehler</span>}
</span>
</div>
))}
{Object.keys(snapshot.session.items).length === 0 && (
<div className="empty-provider">Noch keine Downloads vorhanden.</div>
)}
</div>
</article>
</section>
)}
{tab === "settings" && (
<section className="settings-shell">
<article className="card settings-toolbar">

View File

@ -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;

View File

@ -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;

View File

@ -2,6 +2,7 @@ import type {
AddLinksPayload,
AppSettings,
DuplicatePolicy,
SessionStats,
StartConflictEntry,
StartConflictResolutionResult,
UiSnapshot,
@ -35,6 +36,7 @@ export interface ElectronApi {
toggleClipboard: () => Promise<boolean>;
pickFolder: () => Promise<string | null>;
pickContainers: () => Promise<string[]>;
getSessionStats: () => Promise<SessionStats>;
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;

View File

@ -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;
}

View File

@ -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");

View File

@ -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<Response> => {
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);