feat: preserve speed history and improve account setup

This commit is contained in:
Sucukdeluxe
2026-08-09 14:59:52 +02:00
parent 403051e3cf
commit 29bdf589eb
13 changed files with 279 additions and 132 deletions
+8
View File
@@ -8,6 +8,14 @@ Desktop downloader for Windows with package-based queue management, multi-provid
![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6)
![License](https://img.shields.io/badge/license-MIT-green)
## Changelog
### 2.0.6
- Keeps the download speed history alive when switching between tabs.
- Adds API and Web-Login filters to the account picker.
- Uses clearer Web-Login account labels and validates supported credentials before saving.
## Why this tool?
- JDownloader-style workflow with packages, progress, extraction, history, and clean post-processing.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "real-debrid-downloader",
"version": "2.0.5",
"version": "2.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "real-debrid-downloader",
"version": "2.0.5",
"version": "2.0.6",
"license": "MIT",
"dependencies": {
"adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "2.0.5",
"version": "2.0.6",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
+10 -8
View File
@@ -546,14 +546,16 @@ export class AppController {
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
}
public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
const statuses = await checkAllDebridAccounts(this.settings);
this.manager.applyDebridAccountStatuses(statuses);
this.audit("INFO", "Debrid-Accounts geprueft", {
total: statuses.length,
valid: statuses.filter((s) => s.valid).length,
premium: statuses.filter((s) => s.isPremium).length
});
public async checkDebridAccounts(settingsOverride?: AppSettings): Promise<DebridAccountStatus[]> {
const statuses = await checkAllDebridAccounts(settingsOverride ? normalizeSettings(settingsOverride) : this.settings);
if (!settingsOverride) {
this.manager.applyDebridAccountStatuses(statuses);
this.audit("INFO", "Debrid-Accounts geprueft", {
total: statuses.length,
valid: statuses.filter((s) => s.valid).length,
premium: statuses.filter((s) => s.isPremium).length
});
}
return statuses;
}
+2 -2
View File
@@ -747,8 +747,8 @@ function registerIpcHandlers(): void {
return controller.getDebridLinkHostLimits();
});
ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => {
return controller.checkDebridAccounts();
ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async (_event, settings?: AppSettings) => {
return controller.checkDebridAccounts(settings);
});
ipcMain.handle(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, async (_event, login: string, password: string) => {
+1 -1
View File
@@ -87,7 +87,7 @@ const api: ElectronApi = {
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
checkDebridAccounts: (): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS),
checkDebridAccounts: (settings?: AppSettings): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, settings),
checkMegaDebridAccount: (login: string, password: string): Promise<DebridAccountStatus | null> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password),
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
+76 -44
View File
@@ -38,6 +38,10 @@ import {
} from "../shared/provider-daily-limits";
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
import { pruneSelection } from "./selection";
import { matchesAccountModeFilter } from "./account-ui";
import type { AccountModeFilter } from "./account-ui";
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
import type { DownloadSpeedHistoryState } from "./download-speed-state";
type Tab = "collector" | "downloads" | "history" | "statistics" | "settings";
type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates";
@@ -245,8 +249,8 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
kind: "realdebrid-web",
service: "realdebrid",
serviceLabel: "Real-Debrid",
title: "Real-Debrid Web",
modeLabel: "Web",
title: "Real-Debrid Web-Login",
modeLabel: "Web-Login",
pickerDescription: "Login über Browserfenster statt Token."
},
{
@@ -262,8 +266,8 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
kind: "megadebrid-web",
service: "megadebrid-web",
serviceLabel: "Mega-Debrid",
title: "Mega-Debrid Web",
modeLabel: "Web",
title: "Mega-Debrid Web-Login",
modeLabel: "Web-Login",
pickerDescription: "Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account.",
needsToken: true
},
@@ -280,8 +284,8 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
kind: "bestdebrid-web",
service: "bestdebrid",
serviceLabel: "BestDebrid",
title: "BestDebrid Web",
modeLabel: "Web",
title: "BestDebrid Web-Login",
modeLabel: "Web-Login",
pickerDescription: "Cookie-Import aus dem Browser statt API-Token."
},
{
@@ -297,8 +301,8 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
kind: "alldebrid-web",
service: "alldebrid",
serviceLabel: "AllDebrid",
title: "AllDebrid Web",
modeLabel: "Web",
title: "AllDebrid Web-Login",
modeLabel: "Web-Login",
pickerDescription: "Login über Browserfenster für reCAPTCHA.",
},
{
@@ -332,8 +336,8 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
kind: "linksnappy-login",
service: "linksnappy",
serviceLabel: "LinkSnappy",
title: "LinkSnappy Web",
modeLabel: "Web",
title: "LinkSnappy Web-Login",
modeLabel: "Web-Login",
pickerDescription: "Login für linksnappy.com mit Benutzername und Passwort.",
needsCredentials: true
}
@@ -803,6 +807,9 @@ function validateAccountDialog(dialog: AccountDialogState): string | null {
}
}
if (dialog.kind === "debridlink-api") {
if (parseDebridLinkApiKeys(dialog.token).length === 0) {
return `${option.title}: Bitte mindestens einen gültigen API-Key eintragen.`;
}
for (const key of parseDebridLinkApiKeys(dialog.token)) {
const raw = dialog.keyDailyLimitGbById?.[key.id] || "";
if (!raw.trim()) {
@@ -1486,14 +1493,12 @@ interface DownloadSpeedSparklineProps {
items: Record<string, DownloadItem>;
running: boolean;
paused: boolean;
speedStateRef: React.MutableRefObject<DownloadSpeedHistoryState>;
hidden?: boolean;
}
const SPARKLINE_MAX_SAMPLES = 160;
const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, running, paused }: DownloadSpeedSparklineProps): ReactElement {
const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, running, paused, speedStateRef, hidden = false }: DownloadSpeedSparklineProps): ReactElement {
const canvasRef = useRef<HTMLCanvasElement>(null);
const histRef = useRef<number[]>([]);
const displayRef = useRef<number>(0);
const itemsRef = useRef(items);
const activeRef = useRef(running && !paused);
const [liveSpeed, setLiveSpeed] = useState(0);
@@ -1515,7 +1520,7 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, run
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const hist = histRef.current;
const hist = speedStateRef.current.history;
if (hist.length < 2) return;
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
@@ -1528,8 +1533,8 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, run
const pad = 2;
const h = cssH - pad * 2;
const step = cssW / (SPARKLINE_MAX_SAMPLES - 1);
const startIdx = SPARKLINE_MAX_SAMPLES - hist.length;
const step = cssW / (DOWNLOAD_SPEED_MAX_SAMPLES - 1);
const startIdx = DOWNLOAD_SPEED_MAX_SAMPLES - hist.length;
const px = (i: number): number => (startIdx + i) * step;
const py = (v: number): number => pad + h - (v / maxV) * h;
@@ -1558,14 +1563,7 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, run
if (it.status === "downloading") target += it.speedBps || 0;
}
}
const cur = displayRef.current;
const alpha = target > cur ? 0.45 : 0.12;
let next = cur + (target - cur) * alpha;
if (next < 1) next = 0;
displayRef.current = next;
const hist = histRef.current;
hist.push(next);
if (hist.length > SPARKLINE_MAX_SAMPLES) hist.splice(0, hist.length - SPARKLINE_MAX_SAMPLES);
speedStateRef.current = updateDownloadSpeedHistory(speedStateRef.current, target);
setLiveSpeed(target);
draw();
};
@@ -1575,7 +1573,7 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, run
}, []);
return (
<div className="speed-sparkline" title="Aktuelle Download-Geschwindigkeit (geglättet)">
<div className={`speed-sparkline${hidden ? " speed-sparkline-hidden" : ""}`} aria-hidden={hidden} title="Aktuelle Download-Geschwindigkeit (geglättet)">
<canvas ref={canvasRef} className="speed-sparkline-canvas" />
<span className="speed-sparkline-value">{liveSpeed > 0 ? formatSpeedMbps(liveSpeed) : "0 B/s"}</span>
</div>
@@ -1786,6 +1784,7 @@ export function App(): ReactElement {
const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null);
const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null);
const [accountDialogSearch, setAccountDialogSearch] = useState("");
const [accountDialogModeFilter, setAccountDialogModeFilter] = useState<AccountModeFilter>("all");
const [keyStatsPopup, setKeyStatsPopup] = useState<string | null>(null);
const [debridLinkHostLimits, setDebridLinkHostLimits] = useState<Record<string, DebridLinkHostLimitInfo>>({});
const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false);
@@ -2769,6 +2768,9 @@ export function App(): ReactElement {
const accountDialogSearchQuery = accountDialogSearch.trim().toLowerCase();
const filteredAccountDialogOptions = useMemo(() => (
accountDialogSelectableOptions.filter((option) => {
if (!matchesAccountModeFilter(option, accountDialogModeFilter)) {
return false;
}
if (!accountDialogSearchQuery) {
return true;
}
@@ -2781,7 +2783,7 @@ export function App(): ReactElement {
].join(" ").toLowerCase();
return haystack.includes(accountDialogSearchQuery);
})
), [accountDialogSearchQuery, accountDialogSelectableOptions]);
), [accountDialogModeFilter, accountDialogSearchQuery, accountDialogSelectableOptions]);
const accountTableStyle = useMemo(() => ({
"--account-col-service": `${accountColumnWidths.service}px`,
"--account-col-mode": `${accountColumnWidths.mode}px`,
@@ -3002,11 +3004,13 @@ export function App(): ReactElement {
const openCreateAccountDialog = (): void => {
setAccountDialogSearch("");
setAccountDialogModeFilter("all");
setAccountDialog(createAccountDialogState("create", null, settingsDraft));
};
const openEditAccountDialog = (kind: AccountKind): void => {
setAccountDialogSearch("");
setAccountDialogModeFilter("all");
setAccountDialog(createAccountDialogState("edit", kind, settingsDraft));
};
@@ -3036,6 +3040,7 @@ export function App(): ReactElement {
const closeAccountDialog = useCallback((): void => {
setAccountDialog(null);
setAccountDialogSearch("");
setAccountDialogModeFilter("all");
}, []);
const onSaveAccountDialog = async (quickAction?: AccountQuickAction): Promise<void> => {
@@ -3051,6 +3056,20 @@ export function App(): ReactElement {
const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null;
await performQuickAction(async () => {
const nextDraft = applyAccountDialogToSettings(settingsDraft, dialogSnapshot);
const requiresCredentialCheck = selectedOption?.kind === "megadebrid-api"
|| selectedOption?.kind === "megadebrid-web"
|| selectedOption?.kind === "debridlink-api";
if (requiresCredentialCheck) {
const statuses = await window.rd.checkDebridAccounts(nextDraft);
const invalidStatuses = statuses.filter((status) => !status.valid);
if (invalidStatuses.length > 0) {
const details = invalidStatuses
.map((status) => status.message || "Zugangsdaten ungültig")
.join(" | ");
showToast(`Prüfung fehlgeschlagen: ${details}`, 4200);
return;
}
}
await persistSpecificSettings(nextDraft);
closeAccountDialog();
if (quickAction) {
@@ -3917,6 +3936,7 @@ export function App(): ReactElement {
}, []);
const speedHistoryRef = useRef<{ time: number; speed: number }[]>([]);
const speedSparklineStateRef = useRef<DownloadSpeedHistoryState>({ history: [], display: 0 });
const dragSelectRef = useRef(false);
const dragAnchorRef = useRef<string | null>(null);
const dragDidMoveRef = useRef(false);
@@ -4999,13 +5019,13 @@ export function App(): ReactElement {
<button className={tab === "history" ? "tab active" : "tab"} onClick={() => setTab("history")}>Verlauf</button>
<button className={tab === "statistics" ? "tab active" : "tab"} onClick={() => setTab("statistics")}>Statistiken</button>
<div className="tab-actions">
{tab === "downloads" && (
<DownloadSpeedSparkline
items={snapshot.session.items}
running={snapshot.session.running}
paused={snapshot.session.paused}
/>
)}
<DownloadSpeedSparkline
items={snapshot.session.items}
running={snapshot.session.running}
paused={snapshot.session.paused}
speedStateRef={speedSparklineStateRef}
hidden={tab !== "downloads"}
/>
{tab === "downloads" && (
<input
className="search-input tab-search"
@@ -5549,7 +5569,7 @@ export function App(): ReactElement {
<button className="btn" disabled={actionBusy || accountCheckBusy} onClick={() => { void checkAllAccounts(); }} title="Prüft Login-Gültigkeit und Premium-Restlaufzeit aller Mega-Debrid-/Debrid-Link-Accounts">
{accountCheckBusy ? "Prüfe Accounts…" : "Alle prüfen"}
</button>
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
<button className="btn account-add-button" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
Account hinzufügen
</button>
</div>
@@ -6300,12 +6320,24 @@ export function App(): ReactElement {
<div className="account-modal-body">
<div className="account-dialog-step">
<div className="account-dialog-step-label">1. Account-Typ auswaehlen</div>
<input
className="account-picker-search"
placeholder="Dienst oder Typ suchen"
value={accountDialogSearch}
onChange={(event) => setAccountDialogSearch(event.target.value)}
/>
<div className="account-picker-toolbar">
<select
className="account-picker-filter"
aria-label="Account-Typ filtern"
value={accountDialogModeFilter}
onChange={(event) => setAccountDialogModeFilter(event.target.value as AccountModeFilter)}
>
<option value="all">Alle anzeigen</option>
<option value="api">Nur API</option>
<option value="web">Nur Web-Login</option>
</select>
<input
className="account-picker-search"
placeholder="Dienst oder Typ suchen"
value={accountDialogSearch}
onChange={(event) => setAccountDialogSearch(event.target.value)}
/>
</div>
<div className="account-picker-table">
<div className="account-picker-head">
<span>Account</span>
@@ -6502,7 +6534,7 @@ export function App(): ReactElement {
value={accountDialog.dailyLimitGb}
onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, dailyLimitGb: event.target.value } : prev)}
/>
<div className="account-modal-note">Ab 00:00 wird der Zähler automatisch zurückgesetzt. Wenn das Limit erreicht ist, nutzt die App den nächsten Hoster aus der Reihenfolge.</div>
<div className="account-modal-note account-daily-limit-note">Ab 00:00 wird der Zähler automatisch zurückgesetzt. Wenn das Limit erreicht ist, nutzt die App den nächsten Hoster aus der Reihenfolge.</div>
</div>
{accountDialog.kind === "debridlink-api" && parseDebridLinkApiKeys(accountDialog.token).length > 0 && (
@@ -6598,7 +6630,7 @@ export function App(): ReactElement {
</button>
)}
<button className="btn accent" disabled={actionBusy || !accountDialog.kind} onClick={() => { void onSaveAccountDialog(); }}>
Speichern
Prüfen und speichern
</button>
</div>
</>
+15
View File
@@ -0,0 +1,15 @@
export type AccountModeFilter = "all" | "api" | "web";
export interface AccountModeOption {
modeLabel: string;
}
export function matchesAccountModeFilter(option: AccountModeOption, filter: AccountModeFilter): boolean {
if (filter === "all") {
return true;
}
if (filter === "api") {
return option.modeLabel === "API";
}
return option.modeLabel.startsWith("Web");
}
+24
View File
@@ -0,0 +1,24 @@
export interface DownloadSpeedHistoryState {
history: number[];
display: number;
}
export const DOWNLOAD_SPEED_MAX_SAMPLES = 160;
export function updateDownloadSpeedHistory(
state: DownloadSpeedHistoryState,
target: number,
maxSamples = DOWNLOAD_SPEED_MAX_SAMPLES
): DownloadSpeedHistoryState {
const safeTarget = Math.max(0, Number.isFinite(target) ? target : 0);
const alpha = safeTarget > state.display ? 0.45 : 0.12;
let display = state.display + (safeTarget - state.display) * alpha;
if (display < 1) {
display = 0;
}
const history = [...state.history, display];
if (history.length > maxSamples) {
history.splice(0, history.length - maxSamples);
}
return { history, display };
}
+30
View File
@@ -475,6 +475,17 @@ body,
border-color: #f0982f;
}
.btn.account-add-button {
background: linear-gradient(180deg, #63b3ed, #3182ce);
color: #061424;
border-color: #63b3ed;
}
.btn.account-add-button:hover:not(:disabled) {
background: linear-gradient(180deg, #7cc4f4, #3b8ed8);
border-color: #90cdf4;
}
.btn.danger {
border-color: rgba(244, 63, 94, 0.7);
color: #fda4af;
@@ -547,6 +558,11 @@ body,
border-radius: 8px;
}
.speed-sparkline-hidden {
visibility: hidden;
pointer-events: none;
}
.speed-sparkline-canvas {
width: 116px;
height: 22px;
@@ -1925,6 +1941,12 @@ body,
width: 100%;
}
.account-picker-toolbar {
display: grid;
grid-template-columns: minmax(145px, 0.32fr) minmax(0, 1fr);
gap: 8px;
}
.account-modal label {
display: block;
margin-bottom: 6px;
@@ -2088,6 +2110,10 @@ body,
line-height: 1.5;
}
.account-daily-limit-note {
margin-top: 10px;
}
.account-mode-note {
padding: 10px 12px;
border-radius: 10px;
@@ -3231,6 +3257,10 @@ td {
grid-template-columns: 1fr;
}
.account-picker-toolbar {
grid-template-columns: 1fr;
}
.account-picker-head,
.account-picker-row {
grid-template-columns: 1fr;
+1 -1
View File
@@ -84,7 +84,7 @@ export interface ElectronApi {
importBestDebridCookies: () => Promise<number>;
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
checkDebridAccounts: (settings?: AppSettings) => Promise<DebridAccountStatus[]>;
checkMegaDebridAccount: (login: string, password: string) => Promise<DebridAccountStatus | null>;
retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>;
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { matchesAccountModeFilter } from "../src/renderer/account-ui";
describe("account mode filter", () => {
it("shows only API options for the API filter", () => {
expect(matchesAccountModeFilter({ modeLabel: "API" }, "api")).toBe(true);
expect(matchesAccountModeFilter({ modeLabel: "Web-Login" }, "api")).toBe(false);
});
it("shows Web-Login options for the Web filter", () => {
expect(matchesAccountModeFilter({ modeLabel: "Web-Login" }, "web")).toBe(true);
expect(matchesAccountModeFilter({ modeLabel: "API" }, "web")).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { updateDownloadSpeedHistory } from "../src/renderer/download-speed-state";
describe("download speed history", () => {
it("keeps the smoothed display and history across successive updates", () => {
const first = updateDownloadSpeedHistory({ history: [], display: 0 }, 100, 10);
const second = updateDownloadSpeedHistory(first, 100, 10);
expect(second.display).toBeGreaterThan(first.display);
expect(second.history).toHaveLength(2);
expect(second.history[0]).toBe(first.display);
});
it("keeps only the configured number of samples", () => {
let state = { history: [] as number[], display: 0 };
for (let index = 0; index < 5; index += 1) {
state = updateDownloadSpeedHistory(state, index + 1, 3);
}
expect(state.history).toHaveLength(3);
});
});