diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8d49473..68388c8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,5 @@ import { CSSProperties, DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import type { AllDebridHostInfo, AppSettings, @@ -16,7 +17,16 @@ import type { UpdateCheckResult, UpdateInstallProgress } from "../shared/types"; -import { reorderPackageOrderByDrop, sortPackageOrderByName } from "./package-order"; +import { + getDebridLinkApiKeyDailyLimitBytes, + getDebridLinkApiKeyDailyRemainingBytes, + getDebridLinkApiKeyDailyUsageBytes, + getProviderDailyLimitBytes, + getProviderDailyRemainingBytes, + getProviderDailyUsageBytes, + getProviderUsageDayKey +} from "../shared/provider-daily-limits"; +import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order"; type Tab = "collector" | "downloads" | "history" | "statistics" | "settings"; type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates"; @@ -88,17 +98,35 @@ interface AccountDialogState { token: string; login: string; password: string; + dailyLimitGb: string; + keyDailyLimitGbById: Record; +} + +interface DebridLinkAccountKeyEntry { + id: string; + label: string; + masked: string; + dailyUsedBytes: number; + dailyLimitBytes: number; + dailyRemainingBytes: number | null; + dailyLimitReached: boolean; } interface ConfiguredAccountEntry { kind: AccountKind; service: AccountService; + provider: DebridProvider; serviceLabel: string; modeLabel: string; statusLabel: string; summary: string; note: string; disabled: boolean; + dailyUsedBytes: number; + dailyLimitBytes: number; + dailyRemainingBytes: number | null; + dailyLimitReached: boolean; + debridLinkKeys: DebridLinkAccountKeyEntry[]; } const ACCOUNT_OPTIONS: AccountOption[] = [ @@ -202,7 +230,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [ kind: "linksnappy-login", service: "linksnappy", serviceLabel: "LinkSnappy", - title: "LinkSnappy Login", + title: "LinkSnappy Web", modeLabel: "Login", pickerDescription: "Login für linksnappy.com mit Benutzername und Passwort.", needsCredentials: true @@ -210,6 +238,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [ ]; const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]; +const ACCOUNT_LIMIT_BYTES_PER_GIB = 1024 * 1024 * 1024; const ACCOUNT_COLUMN_STORAGE_KEY = "rd-account-column-widths"; const ACCOUNT_COLUMN_DEFAULT_WIDTHS: Record = { service: 220, @@ -253,6 +282,40 @@ function findAccountOption(kind: AccountKind): AccountOption { return option; } +function getAccountServiceProvider(service: AccountService): DebridProvider { + return service as DebridProvider; +} + +function formatAccountDailyLimitInput(limitBytes: number): string { + if (limitBytes <= 0) { + return ""; + } + const gib = limitBytes / ACCOUNT_LIMIT_BYTES_PER_GIB; + const precision = gib >= 100 ? 0 : gib >= 10 ? 1 : 2; + return gib.toFixed(precision).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1"); +} + +function parseAccountDailyLimitInputBytes(value: string): number | null { + const normalized = value.trim().replace(",", "."); + if (!normalized) { + return null; + } + const parsed = Number(normalized); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.floor(parsed * ACCOUNT_LIMIT_BYTES_PER_GIB); +} + +function buildDebridLinkKeyLimitInputs(rawKeys: string, values?: Record, settings?: AppSettings): Record { + const next: Record = {}; + for (const key of parseDebridLinkApiKeys(rawKeys)) { + next[key.id] = values?.[key.id] + ?? formatAccountDailyLimitInput(settings?.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0); + } + return next; +} + function getAccountPickerFunctionLabel(option: AccountOption): string { switch (option.kind) { case "realdebrid-api": @@ -418,35 +481,47 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n kind: null, token: "", login: "", - password: "" + password: "", + dailyLimitGb: "", + keyDailyLimitGbById: {} }; } + const provider = getAccountServiceProvider(findAccountOption(kind).service); + const dailyLimitGb = formatAccountDailyLimitInput(getProviderDailyLimitBytes(settings, provider)); switch (kind) { case "realdebrid-api": - return { mode, kind, token: settings.token, login: "", password: "" }; + return { mode, kind, token: settings.token, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; case "realdebrid-web": - return { mode, kind, token: "", login: "", password: "" }; + return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; case "megadebrid-api": case "megadebrid-web": - return { mode, kind, token: "", login: settings.megaLogin, password: settings.megaPassword }; + return { mode, kind, token: "", login: settings.megaLogin, password: settings.megaPassword, dailyLimitGb, keyDailyLimitGbById: {} }; case "bestdebrid-api": - return { mode, kind, token: settings.bestToken, login: "", password: "" }; + return { mode, kind, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; case "bestdebrid-web": - return { mode, kind, token: "", login: "", password: "" }; + return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; case "alldebrid-api": - return { mode, kind, token: settings.allDebridToken, login: "", password: "" }; + return { mode, kind, token: settings.allDebridToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; case "alldebrid-web": - return { mode, kind, token: "", login: "", password: "" }; + return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; case "ddownload-login": - return { mode, kind, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword }; + return { mode, kind, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword, dailyLimitGb, keyDailyLimitGbById: {} }; case "onefichier-api": - return { mode, kind, token: settings.oneFichierApiKey, login: "", password: "" }; + return { mode, kind, token: settings.oneFichierApiKey, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; case "debridlink-api": - return { mode, kind, token: settings.debridLinkApiKeys || "", login: "", password: "" }; + return { + mode, + kind, + token: settings.debridLinkApiKeys || "", + login: "", + password: "", + dailyLimitGb, + keyDailyLimitGbById: buildDebridLinkKeyLimitInputs(settings.debridLinkApiKeys || "", undefined, settings) + }; case "linksnappy-login": - return { mode, kind, token: "", login: settings.linkSnappyLogin || "", password: settings.linkSnappyPassword || "" }; + return { mode, kind, token: "", login: settings.linkSnappyLogin || "", password: settings.linkSnappyPassword || "", dailyLimitGb, keyDailyLimitGbById: {} }; default: - return { mode, kind, token: "", login: "", password: "" }; + return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; } } @@ -457,60 +532,101 @@ function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDial const token = dialog.token.trim(); const login = dialog.login.trim(); const password = dialog.password; + const provider = getAccountServiceProvider(findAccountOption(dialog.kind).service); + const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) }; + const nextDebridLinkApiKeyDailyLimitBytes = dialog.kind === "debridlink-api" + ? Object.fromEntries( + parseDebridLinkApiKeys(dialog.token).flatMap((entry) => { + const limitBytes = parseAccountDailyLimitInputBytes(dialog.keyDailyLimitGbById?.[entry.id] || ""); + return limitBytes && limitBytes > 0 ? [[entry.id, limitBytes]] : []; + }) + ) as Record + : { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }; + const dailyLimitBytes = parseAccountDailyLimitInputBytes(dialog.dailyLimitGb); + if (dailyLimitBytes && dailyLimitBytes > 0) { + nextProviderDailyLimitBytes[provider] = dailyLimitBytes; + } else { + delete nextProviderDailyLimitBytes[provider]; + } switch (dialog.kind) { case "realdebrid-api": - return { ...settings, token, realDebridUseWebLogin: false }; + return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "realdebrid-web": - return { ...settings, token: "", realDebridUseWebLogin: true }; + return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "megadebrid-api": - return { ...settings, megaLogin: login, megaPassword: password, megaDebridApiEnabled: true, megaDebridPreferApi: true }; + return { ...settings, megaLogin: login, megaPassword: password, megaDebridApiEnabled: true, megaDebridPreferApi: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "megadebrid-web": - return { ...settings, megaLogin: login, megaPassword: password, megaDebridWebEnabled: true, megaDebridPreferApi: false }; + return { ...settings, megaLogin: login, megaPassword: password, megaDebridWebEnabled: true, megaDebridPreferApi: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "bestdebrid-api": - return { ...settings, bestToken: token, bestDebridUseWebLogin: false }; + return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "bestdebrid-web": - return { ...settings, bestToken: "", bestDebridUseWebLogin: true }; + return { ...settings, bestToken: "", bestDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "alldebrid-api": - return { ...settings, allDebridToken: token, allDebridUseWebLogin: false }; + return { ...settings, allDebridToken: token, allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "alldebrid-web": - return { ...settings, allDebridToken: "", allDebridUseWebLogin: true }; + return { ...settings, allDebridToken: "", allDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "ddownload-login": - return { ...settings, ddownloadLogin: login, ddownloadPassword: password }; + return { ...settings, ddownloadLogin: login, ddownloadPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "onefichier-api": - return { ...settings, oneFichierApiKey: token }; + return { ...settings, oneFichierApiKey: token, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "debridlink-api": - return { ...settings, debridLinkApiKeys: token }; + return { + ...settings, + debridLinkApiKeys: token, + providerDailyLimitBytes: nextProviderDailyLimitBytes, + debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes + }; case "linksnappy-login": - return { ...settings, linkSnappyLogin: login, linkSnappyPassword: password }; + return { ...settings, linkSnappyLogin: login, linkSnappyPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes }; default: return settings; } } function clearAccountServiceFromSettings(settings: AppSettings, service: AccountService): AppSettings { + const provider = getAccountServiceProvider(service); + const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) }; + const nextProviderDailyUsageBytes = { ...(settings.providerDailyUsageBytes || {}) }; + const nextDebridLinkApiKeyDailyLimitBytes = { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }; + const nextDebridLinkApiKeyDailyUsageBytes = { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }; + delete nextProviderDailyLimitBytes[provider]; + delete nextProviderDailyUsageBytes[provider]; + if (service === "debridlink") { + for (const key of parseDebridLinkApiKeys(settings.debridLinkApiKeys || "")) { + delete nextDebridLinkApiKeyDailyLimitBytes[key.id]; + delete nextDebridLinkApiKeyDailyUsageBytes[key.id]; + } + } switch (service) { case "realdebrid": - return { ...settings, token: "", realDebridUseWebLogin: false }; + return { ...settings, token: "", realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "megadebrid-api": return settings.megaDebridWebEnabled - ? { ...settings, megaDebridApiEnabled: false } - : { ...settings, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false }; + ? { ...settings, megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes } + : { ...settings, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "megadebrid-web": return settings.megaDebridApiEnabled - ? { ...settings, megaDebridWebEnabled: false } - : { ...settings, megaLogin: "", megaPassword: "", megaDebridWebEnabled: false }; + ? { ...settings, megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes } + : { ...settings, megaLogin: "", megaPassword: "", megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "bestdebrid": - return { ...settings, bestToken: "", bestDebridUseWebLogin: false }; + return { ...settings, bestToken: "", bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "alldebrid": - return { ...settings, allDebridToken: "", allDebridUseWebLogin: false }; + return { ...settings, allDebridToken: "", allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "ddownload": - return { ...settings, ddownloadLogin: "", ddownloadPassword: "" }; + return { ...settings, ddownloadLogin: "", ddownloadPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "onefichier": - return { ...settings, oneFichierApiKey: "" }; + return { ...settings, oneFichierApiKey: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "debridlink": - return { ...settings, debridLinkApiKeys: "" }; + return { + ...settings, + debridLinkApiKeys: "", + providerDailyLimitBytes: nextProviderDailyLimitBytes, + providerDailyUsageBytes: nextProviderDailyUsageBytes, + debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes, + debridLinkApiKeyDailyUsageBytes: nextDebridLinkApiKeyDailyUsageBytes + }; case "linksnappy": - return { ...settings, linkSnappyLogin: "", linkSnappyPassword: "" }; + return { ...settings, linkSnappyLogin: "", linkSnappyPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; default: return settings; } @@ -532,6 +648,24 @@ function validateAccountDialog(dialog: AccountDialogState): string | null { return `${option.title}: Bitte Passwort eintragen.`; } } + if (dialog.dailyLimitGb.trim()) { + const parsed = Number(dialog.dailyLimitGb.trim().replace(",", ".")); + if (!Number.isFinite(parsed) || parsed < 0) { + return `${option.title}: Tageslimit muss eine Zahl >= 0 sein.`; + } + } + if (dialog.kind === "debridlink-api") { + for (const key of parseDebridLinkApiKeys(dialog.token)) { + const raw = dialog.keyDailyLimitGbById?.[key.id] || ""; + if (!raw.trim()) { + continue; + } + const parsed = Number(raw.trim().replace(",", ".")); + if (!Number.isFinite(parsed) || parsed < 0) { + return `${option.title}: ${key.label} Limit muss eine Zahl >= 0 sein.`; + } + } + } return null; } @@ -556,12 +690,18 @@ const emptySnapshot = (): UiSnapshot => ({ autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, - theme: "dark", collapseNewPackages: true, autoSkipExtracted: false, confirmDeleteSelection: true, + theme: "dark", collapseNewPackages: true, autoSortPackagesByProgress: true, autoSkipExtracted: false, confirmDeleteSelection: true, bandwidthSchedules: [], totalDownloadedAllTime: 0, columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], autoExtractWhenStopped: true, disabledProviders: [], - hosterRouting: {} + hosterRouting: {}, + providerDailyLimitBytes: {}, + providerDailyUsageBytes: {}, + debridLinkApiKeyDailyLimitBytes: {}, + debridLinkApiKeyDailyUsageBytes: {}, + providerDailyUsageDay: getProviderUsageDayKey(), + scheduledStartEpochMs: 0 }, session: { version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0, @@ -917,8 +1057,8 @@ function sortPackageOrderBySize(order: string[], packages: Record, items: Record, descending: boolean): string[] { const sorted = [...order]; sorted.sort((a, b) => { - const hosterA = [...new Set((packages[a]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url ?? "")).filter(Boolean))].join(",").toLowerCase(); - const hosterB = [...new Set((packages[b]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url ?? "")).filter(Boolean))].join(",").toLowerCase(); + const hosterA = [...new Set((packages[a]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url || "")).filter(Boolean))].join(",").toLowerCase(); + const hosterB = [...new Set((packages[b]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url || "")).filter(Boolean))].join(",").toLowerCase(); const cmp = hosterA.localeCompare(hosterB); return descending ? -cmp : cmp; }); @@ -1484,41 +1624,13 @@ export function App(): ReactElement { ? Math.max(0, totalPackageCount - packages.length) : 0; const visiblePackages = useMemo(() => { - if (!snapshot.session.running || packages.length <= 1) { - return packages; - } - const activeStatuses = new Set(["downloading", "validating", "integrity_check", "extracting"]); - const active: PackageEntry[] = []; - const rest: PackageEntry[] = []; - for (const pkg of packages) { - const hasActive = pkg.itemIds.some((id) => { - const item = snapshot.session.items[id]; - return item && activeStatuses.has(item.status); - }); - if (hasActive) { - active.push(pkg); - } else { - rest.push(pkg); - } - } - if (active.length === 0 || active.length === packages.length) { - return packages; - } - // Sort active packages: highest completion percentage first - active.sort((a, b) => { - const aItems = a.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean); - const bItems = b.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean); - const aPct = aItems.length > 0 ? aItems.filter((i) => i.status === "completed").length / aItems.length : 0; - const bPct = bItems.length > 0 ? bItems.filter((i) => i.status === "completed").length / bItems.length : 0; - if (aPct !== bPct) { - return bPct - aPct; - } - const aBytes = aItems.reduce((s, i) => s + (i.downloadedBytes || 0), 0); - const bBytes = bItems.reduce((s, i) => s + (i.downloadedBytes || 0), 0); - return bBytes - aBytes; - }); - return [...active, ...rest]; - }, [packages, snapshot.session.running, snapshot.session.items]); + return sortPackagesForDisplay( + packages, + snapshot.session.items, + snapshot.session.running, + settingsDraft.autoSortPackagesByProgress + ); + }, [packages, settingsDraft.autoSortPackagesByProgress, snapshot.session.running, snapshot.session.items]); const hasSavedAllDebridAccount = Boolean(snapshot.settings.allDebridUseWebLogin || snapshot.settings.allDebridToken.trim()); const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin @@ -1647,23 +1759,75 @@ export function App(): ReactElement { } } if (kind === "debridlink-api") { - const keyCount = (settingsDraft.debridLinkApiKeys || "").split(/[\n,]+/).filter((k: string) => k.trim()).length; + const keyCount = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").length; statusLabel = keyCount > 1 ? `${keyCount} API-Keys` : "Konfiguriert"; } - const isDisabled = (settingsDraft.disabledProviders || []).includes(service as DebridProvider); + const provider = getAccountServiceProvider(service); + const dailyUsedBytes = getProviderDailyUsageBytes(snapshot.settings, provider); + const dailyLimitBytes = getProviderDailyLimitBytes(settingsDraft, provider); + const dailyRemainingBytes = getProviderDailyRemainingBytes({ + providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes, + providerDailyUsageBytes: snapshot.settings.providerDailyUsageBytes, + providerDailyUsageDay: snapshot.settings.providerDailyUsageDay + }, provider); + let dailyLimitReached = dailyLimitBytes > 0 && dailyUsedBytes >= dailyLimitBytes; + const isDisabled = (settingsDraft.disabledProviders || []).includes(provider); + const debridLinkKeys = kind === "debridlink-api" + ? parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").map((key) => { + const keyDailyUsedBytes = getDebridLinkApiKeyDailyUsageBytes(snapshot.settings, key.id); + const keyDailyLimitBytes = getDebridLinkApiKeyDailyLimitBytes(settingsDraft, key.id); + const keyDailyRemainingBytes = getDebridLinkApiKeyDailyRemainingBytes({ + debridLinkApiKeyDailyLimitBytes: settingsDraft.debridLinkApiKeyDailyLimitBytes, + debridLinkApiKeyDailyUsageBytes: snapshot.settings.debridLinkApiKeyDailyUsageBytes, + providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes, + providerDailyUsageBytes: snapshot.settings.providerDailyUsageBytes, + providerDailyUsageDay: snapshot.settings.providerDailyUsageDay + }, key.id); + return { + id: key.id, + label: key.label, + masked: key.masked, + dailyUsedBytes: keyDailyUsedBytes, + dailyLimitBytes: keyDailyLimitBytes, + dailyRemainingBytes: keyDailyRemainingBytes, + dailyLimitReached: keyDailyLimitBytes > 0 && keyDailyUsedBytes >= keyDailyLimitBytes + }; + }) + : []; + if (kind === "debridlink-api" && debridLinkKeys.length > 0) { + const limitedCount = debridLinkKeys.filter((entry) => entry.dailyLimitReached).length; + if (limitedCount > 0) { + const limitNote = `${limitedCount}/${debridLinkKeys.length} API-Keys am Limit.`; + note = note ? `${limitNote} ${note}` : limitNote; + } + if (limitedCount === debridLinkKeys.length) { + dailyLimitReached = true; + } + } + if (dailyLimitReached) { + note = note + ? `Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster. ${note}` + : "Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster."; + } entries.push({ kind, service, + provider, serviceLabel: option.serviceLabel, modeLabel: option.modeLabel, statusLabel: isDisabled ? "Deaktiviert" : statusLabel, summary: summarizeAccount(kind, settingsDraft), note, - disabled: isDisabled + disabled: isDisabled, + dailyUsedBytes, + dailyLimitBytes, + dailyRemainingBytes, + dailyLimitReached, + debridLinkKeys }); } return entries; - }, [settingsDraft, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]); + }, [settingsDraft, snapshot.settings, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]); const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]); const availableAccountOptions = useMemo(() => ( @@ -1827,6 +1991,21 @@ export function App(): ReactElement { applyTheme(result.theme); }; + const syncLiveProviderUsageSettings = (result: AppSettings): void => { + setSnapshot((prev) => ({ ...prev, settings: result })); + if (!settingsDirtyRef.current) { + applyPersistedSettings(result); + return; + } + setSettingsDraft((prev) => ({ + ...prev, + totalDownloadedAllTime: Math.max(prev.totalDownloadedAllTime, result.totalDownloadedAllTime), + providerDailyUsageDay: result.providerDailyUsageDay, + providerDailyUsageBytes: { ...(result.providerDailyUsageBytes || {}) }, + debridLinkApiKeyDailyUsageBytes: { ...(result.debridLinkApiKeyDailyUsageBytes || {}) } + })); + }; + const persistSpecificSettings = async (nextDraft: AppSettings): Promise => { const normalizedDraft = { ...nextDraft, @@ -1841,16 +2020,16 @@ export function App(): ReactElement { switch (action) { case "realdebrid-login": await window.rd.openRealDebridLogin(); - showToast("Real-Debrid Login-Fenster geoeffnet", 2200); + showToast("Real-Debrid Login-Fenster geöffnet", 2200); return; case "bestdebrid-cookies": { const count = await window.rd.importBestDebridCookies(); - showToast(count > 0 ? `${count} BestDebrid-Cookies importiert` : "Keine Cookie-Datei ausgewaehlt", 2200); + showToast(count > 0 ? `${count} BestDebrid-Cookies importiert` : "Keine Cookie-Datei ausgewählt", 2200); return; } case "alldebrid-login": await window.rd.openAllDebridLogin(); - showToast("AllDebrid Login-Fenster geoeffnet", 2200); + showToast("AllDebrid Login-Fenster geöffnet", 2200); return; case "alldebrid-status": await loadAllDebridHostInfo(false); @@ -1898,6 +2077,7 @@ export function App(): ReactElement { next.login = prev.login; next.password = prev.password; } + next.dailyLimitGb = prev.dailyLimitGb; return next; }); }; @@ -1954,6 +2134,26 @@ export function App(): ReactElement { }); }; + const onResetAccountDailyUsage = async (entry: ConfiguredAccountEntry): Promise => { + await performQuickAction(async () => { + const result = await window.rd.resetProviderDailyUsage(getAccountServiceProvider(entry.service)); + syncLiveProviderUsageSettings(result); + showToast(`${entry.serviceLabel}: Tageszähler zurückgesetzt`, 2200); + }, (error) => { + showToast(`${entry.serviceLabel}: Reset fehlgeschlagen: ${String(error)}`, 3200); + }); + }; + + const onResetDebridLinkApiKeyDailyUsage = async (entry: ConfiguredAccountEntry, keyId: string, keyLabel: string): Promise => { + await performQuickAction(async () => { + const result = await window.rd.resetDebridLinkApiKeyDailyUsage(keyId); + syncLiveProviderUsageSettings(result); + showToast(`${entry.serviceLabel} ${keyLabel}: Tageszähler zurückgesetzt`, 2200); + }, (error) => { + showToast(`${entry.serviceLabel} ${keyLabel}: Reset fehlgeschlagen: ${String(error)}`, 3200); + }); + }; + const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise => { const meta = getAccountQuickActionMeta(entry.kind); if (!meta) { @@ -2495,7 +2695,7 @@ export function App(): ReactElement { pendingPackageOrderRef.current = [...order]; pendingPackageOrderAtRef.current = Date.now(); packageOrderRef.current = [...order]; - // Optimistic UI update — apply the new order immediately so the user + // Optimistic UI update ? apply the new order immediately so the user // sees the change without waiting for the backend round-trip. setSnapshot((prev) => { if (!prev) return prev; @@ -2812,7 +3012,7 @@ export function App(): ReactElement { if (e.key === "Escape") { const target = e.target as HTMLElement; if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") { - // Don't clear selection if an overlay is open — let the overlay close first + // Don't clear selection if an overlay is open ? let the overlay close first if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return; if (tabRef.current === "downloads") setSelectedIds(new Set()); else if (tabRef.current === "history") setSelectedHistoryIds(new Set()); @@ -3223,8 +3423,8 @@ export function App(): ReactElement {
{(snapshot.settings.scheduledStartEpochMs || 0) > 0 ? (
- ⏰ {scheduleCountdown || new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - + ⏰ {scheduleCountdown || new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} +
) : (
{configuredAccounts.length} aktiv - {availableAccountOptions.length} weitere Typen verfuegbar + {availableAccountOptions.length} weitere Typen verfügbar
{configuredAccounts.length === 0 && (
Noch keine Accounts hinterlegt - Fuege ueber "Account hinzufuegen" den ersten Dienst hinzu. Danach erscheinen hier Status, Zugang und Aktionen als Liste. + Füge über "Account hinzufügen" den ersten Dienst hinzu. Danach erscheinen hier Status, Zugang und Aktionen als Liste.
)} @@ -3795,6 +3996,46 @@ export function App(): ReactElement {
{entry.statusLabel} {entry.note && {entry.note}} +
+ Heute: {humanSize(entry.dailyUsedBytes)} + {entry.dailyLimitBytes > 0 ? `Limit: ${humanSize(entry.dailyLimitBytes)}` : "Kein Tageslimit"} + {entry.dailyLimitBytes > 0 && ( + {entry.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(entry.dailyRemainingBytes || 0)}`} + )} + {entry.dailyLimitBytes <= 0 && entry.dailyLimitReached && entry.debridLinkKeys.length > 0 && ( + Fallback aktiv + )} +
+ {entry.debridLinkKeys.length > 0 && ( +
+ {entry.debridLinkKeys.map((key) => ( +
+
+
+ {key.label} + {key.masked} +
+
+ Heute: {humanSize(key.dailyUsedBytes)} + {key.dailyLimitBytes > 0 ? `Limit: ${humanSize(key.dailyLimitBytes)}` : "Kein Limit"} + {key.dailyLimitBytes > 0 && ( + {key.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(key.dailyRemainingBytes || 0)}`} + )} +
+
+
+ +
+
+ ))} +
+ )}
{entry.summary} @@ -3813,6 +4054,9 @@ export function App(): ReactElement { + @@ -3855,7 +4099,7 @@ export function App(): ReactElement { setProviderOrder(next); }} title="Nach oben" - >▲ + >? + >?
))} @@ -3955,7 +4199,7 @@ export function App(): ReactElement { {availableHosters.map((h) => ( ))} - + @@ -4073,7 +4317,7 @@ export function App(): ReactElement { - +
setNum("maxParallelExtract", Math.max(1, Math.min(8, Number(e.target.value) || 2)))} />
setDeleteConfirm((prev) => prev ? { ...prev, dontAsk: e.target.checked } : prev)} /> Nicht mehr anzeigen @@ -4238,7 +4482,7 @@ export function App(): ReactElement {

{startConflictPrompt.entry.packageName} ist im Ziel bereits vorhanden.

-

Bei "Überspringen" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.

+

Bei "überspringen" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.

{startConflictPrompt.entry.extractDir}

@@ -4275,7 +4519,7 @@ export function App(): ReactElement {
event.stopPropagation()}>
-

{accountDialog.mode === "edit" ? "Account bearbeiten" : "Account hinzufuegen"}

+

{accountDialog.mode === "edit" ? "Account bearbeiten" : "Account hinzufügen"}

Wie in JDownloader: oben Account-Typ auswaehlen, unten Zugangsdaten direkt eintragen.

@@ -4299,7 +4543,7 @@ export function App(): ReactElement { Kein passender Account-Typ gefunden {accountDialogSelectableOptions.length === 0 - ? "Alle verfuegbaren Typen sind bereits vorhanden." + ? "Alle verfügbaren Typen sind bereits vorhanden." : "Passe den Suchbegriff an oder waehle einen Eintrag aus der Liste."}
@@ -4347,7 +4591,17 @@ export function App(): ReactElement {
{accountDialogOption.service === "debridlink" ? ( -