feat: preserve speed history and improve account setup
This commit is contained in:
@@ -6,9 +6,17 @@ Desktop downloader for Windows with package-based queue management, multi-provid
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Why this tool?
|
||||

|
||||
|
||||
## 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.
|
||||
- Multiple debrid accounts in one app, including provider order, automatic fallback, and per-hoster routing.
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
+12
-10
@@ -546,16 +546,18 @@ 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
|
||||
});
|
||||
return statuses;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
public async checkSingleMegaDebridAccount(login: string, password: string): Promise<DebridAccountStatus | null> {
|
||||
const entry = parseMegaDebridAccounts(`${login.trim()}:${password.trim()}`)[0];
|
||||
|
||||
+3
-3
@@ -747,9 +747,9 @@ 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) => {
|
||||
return controller.checkSingleMegaDebridAccount(String(login || ""), String(password || ""));
|
||||
|
||||
@@ -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),
|
||||
|
||||
+124
-92
@@ -36,8 +36,12 @@ import {
|
||||
getProviderDailyUsageBytes,
|
||||
getProviderUsageDayKey
|
||||
} from "../shared/provider-daily-limits";
|
||||
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
||||
import { pruneSelection } from "./selection";
|
||||
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
|
||||
}
|
||||
@@ -802,8 +806,11 @@ function validateAccountDialog(dialog: AccountDialogState): string | null {
|
||||
return `${option.title}: Tageslimit muss eine Zahl >= 0 sein.`;
|
||||
}
|
||||
}
|
||||
if (dialog.kind === "debridlink-api") {
|
||||
for (const key of parseDebridLinkApiKeys(dialog.token)) {
|
||||
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()) {
|
||||
continue;
|
||||
@@ -1482,20 +1489,18 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
||||
);
|
||||
});
|
||||
|
||||
interface DownloadSpeedSparklineProps {
|
||||
items: Record<string, DownloadItem>;
|
||||
running: boolean;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
const SPARKLINE_MAX_SAMPLES = 160;
|
||||
|
||||
const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, running, paused }: 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);
|
||||
interface DownloadSpeedSparklineProps {
|
||||
items: Record<string, DownloadItem>;
|
||||
running: boolean;
|
||||
paused: boolean;
|
||||
speedStateRef: React.MutableRefObject<DownloadSpeedHistoryState>;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, running, paused, speedStateRef, hidden = false }: DownloadSpeedSparklineProps): ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const itemsRef = useRef(items);
|
||||
const activeRef = useRef(running && !paused);
|
||||
const [liveSpeed, setLiveSpeed] = useState(0);
|
||||
itemsRef.current = items;
|
||||
activeRef.current = running && !paused;
|
||||
@@ -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,24 +1563,17 @@ 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);
|
||||
setLiveSpeed(target);
|
||||
draw();
|
||||
speedStateRef.current = updateDownloadSpeedHistory(speedStateRef.current, target);
|
||||
setLiveSpeed(target);
|
||||
draw();
|
||||
};
|
||||
|
||||
const id = window.setInterval(tick, 250);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="speed-sparkline" title="Aktuelle Download-Geschwindigkeit (geglättet)">
|
||||
return (
|
||||
<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>
|
||||
@@ -1783,9 +1781,10 @@ export function App(): ReactElement {
|
||||
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const ctxMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null);
|
||||
const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null);
|
||||
const [accountDialogSearch, setAccountDialogSearch] = useState("");
|
||||
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);
|
||||
@@ -2766,12 +2765,15 @@ export function App(): ReactElement {
|
||||
}
|
||||
return availableAccountOptions;
|
||||
}, [accountDialog, accountDialogOption, availableAccountOptions]);
|
||||
const accountDialogSearchQuery = accountDialogSearch.trim().toLowerCase();
|
||||
const filteredAccountDialogOptions = useMemo(() => (
|
||||
accountDialogSelectableOptions.filter((option) => {
|
||||
if (!accountDialogSearchQuery) {
|
||||
return true;
|
||||
}
|
||||
const accountDialogSearchQuery = accountDialogSearch.trim().toLowerCase();
|
||||
const filteredAccountDialogOptions = useMemo(() => (
|
||||
accountDialogSelectableOptions.filter((option) => {
|
||||
if (!matchesAccountModeFilter(option, accountDialogModeFilter)) {
|
||||
return false;
|
||||
}
|
||||
if (!accountDialogSearchQuery) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
option.title,
|
||||
option.serviceLabel,
|
||||
@@ -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`,
|
||||
@@ -3000,15 +3002,17 @@ export function App(): ReactElement {
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
const openCreateAccountDialog = (): void => {
|
||||
setAccountDialogSearch("");
|
||||
setAccountDialog(createAccountDialogState("create", null, settingsDraft));
|
||||
};
|
||||
|
||||
const openEditAccountDialog = (kind: AccountKind): void => {
|
||||
setAccountDialogSearch("");
|
||||
setAccountDialog(createAccountDialogState("edit", kind, settingsDraft));
|
||||
};
|
||||
const openCreateAccountDialog = (): void => {
|
||||
setAccountDialogSearch("");
|
||||
setAccountDialogModeFilter("all");
|
||||
setAccountDialog(createAccountDialogState("create", null, settingsDraft));
|
||||
};
|
||||
|
||||
const openEditAccountDialog = (kind: AccountKind): void => {
|
||||
setAccountDialogSearch("");
|
||||
setAccountDialogModeFilter("all");
|
||||
setAccountDialog(createAccountDialogState("edit", kind, settingsDraft));
|
||||
};
|
||||
|
||||
const updateAccountDialogKind = (kind: AccountKind): void => {
|
||||
setAccountDialog((prev) => {
|
||||
@@ -3033,10 +3037,11 @@ export function App(): ReactElement {
|
||||
});
|
||||
};
|
||||
|
||||
const closeAccountDialog = useCallback((): void => {
|
||||
setAccountDialog(null);
|
||||
setAccountDialogSearch("");
|
||||
}, []);
|
||||
const closeAccountDialog = useCallback((): void => {
|
||||
setAccountDialog(null);
|
||||
setAccountDialogSearch("");
|
||||
setAccountDialogModeFilter("all");
|
||||
}, []);
|
||||
|
||||
const onSaveAccountDialog = async (quickAction?: AccountQuickAction): Promise<void> => {
|
||||
if (!accountDialog) {
|
||||
@@ -3048,10 +3053,24 @@ export function App(): ReactElement {
|
||||
return;
|
||||
}
|
||||
const dialogSnapshot = accountDialog;
|
||||
const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null;
|
||||
await performQuickAction(async () => {
|
||||
const nextDraft = applyAccountDialogToSettings(settingsDraft, dialogSnapshot);
|
||||
await persistSpecificSettings(nextDraft);
|
||||
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) {
|
||||
await runAccountQuickAction(quickAction);
|
||||
@@ -3916,7 +3935,8 @@ export function App(): ReactElement {
|
||||
setContextMenu({ x, y, packageId, itemId });
|
||||
}, []);
|
||||
|
||||
const speedHistoryRef = useRef<{ time: number; speed: number }[]>([]);
|
||||
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);
|
||||
@@ -4998,14 +5018,14 @@ export function App(): ReactElement {
|
||||
<button className={tab === "settings" ? "tab active" : "tab"} onClick={() => setTab("settings")}>Einstellungen</button>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
<div className="tab-actions">
|
||||
<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,9 +5569,9 @@ 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}>
|
||||
Account hinzufügen
|
||||
</button>
|
||||
<button className="btn account-add-button" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
|
||||
Account hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</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 && (
|
||||
@@ -6597,9 +6629,9 @@ export function App(): ReactElement {
|
||||
Speichern + Login
|
||||
</button>
|
||||
)}
|
||||
<button className="btn accent" disabled={actionBusy || !accountDialog.kind} onClick={() => { void onSaveAccountDialog(); }}>
|
||||
Speichern
|
||||
</button>
|
||||
<button className="btn accent" disabled={actionBusy || !accountDialog.kind} onClick={() => { void onSaveAccountDialog(); }}>
|
||||
Prüfen und speichern
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
+49
-19
@@ -469,11 +469,22 @@ body,
|
||||
background: var(--button-bg-hover);
|
||||
}
|
||||
|
||||
.btn.accent {
|
||||
background: linear-gradient(180deg, #f9a948, #e9881f);
|
||||
color: #1c1206;
|
||||
border-color: #f0982f;
|
||||
}
|
||||
.btn.accent {
|
||||
background: linear-gradient(180deg, #f9a948, #e9881f);
|
||||
color: #1c1206;
|
||||
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);
|
||||
@@ -536,7 +547,7 @@ body,
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.speed-sparkline {
|
||||
.speed-sparkline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -544,8 +555,13 @@ body,
|
||||
padding: 0 10px;
|
||||
background: var(--field);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.speed-sparkline-hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.speed-sparkline-canvas {
|
||||
width: 116px;
|
||||
@@ -1921,9 +1937,15 @@ body,
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.account-picker-search {
|
||||
width: 100%;
|
||||
}
|
||||
.account-picker-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.account-picker-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(145px, 0.32fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-modal label {
|
||||
display: block;
|
||||
@@ -2078,15 +2100,19 @@ body,
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-modal-note {
|
||||
.account-modal-note {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.account-daily-limit-note {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.account-mode-note {
|
||||
padding: 10px 12px;
|
||||
@@ -3227,11 +3253,15 @@ td {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.account-picker-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-picker-head,
|
||||
.account-picker-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-picker-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-picker-head,
|
||||
.account-picker-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user