diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 2c742c2..76bffcc 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -1,9 +1,11 @@ import path from "node:path"; import { app } from "electron"; +import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { AddLinksPayload, AllDebridHostInfo, AppSettings, + DebridProvider, DuplicatePolicy, HistoryEntry, PackagePriority, @@ -16,6 +18,7 @@ import { UpdateInstallProgress, UpdateInstallResult } from "../shared/types"; +import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits"; import { importDlcContainers } from "./container"; import { APP_VERSION } from "./constants"; import { DownloadManager } from "./download-manager"; @@ -176,6 +179,11 @@ export class AppController { // Preserve the live totalDownloadedAllTime from the download manager const liveSettings = this.manager.getSettings(); nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0); + nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay; + nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) }; + nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries( + Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId)) + ); this.settings = nextSettings; saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); @@ -193,6 +201,30 @@ export class AppController { return this.settings; } + public resetProviderDailyUsage(provider: DebridProvider): AppSettings { + const liveSettings = this.manager.getSettings(); + const nextSettings = normalizeSettings({ + ...liveSettings, + ...resetProviderDailyUsage(liveSettings, provider) + }); + this.settings = nextSettings; + saveSettings(this.storagePaths, this.settings); + this.manager.setSettings(this.settings); + return this.settings; + } + + public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings { + const liveSettings = this.manager.getSettings(); + const nextSettings = normalizeSettings({ + ...liveSettings, + ...resetDebridLinkApiKeyDailyUsage(liveSettings, keyId) + }); + this.settings = nextSettings; + saveSettings(this.storagePaths, this.settings); + this.manager.setSettings(this.settings); + return this.settings; + } + public async openRealDebridLoginWindow(): Promise { await this.realDebridWebFallback.openLoginWindow(); } diff --git a/src/main/constants.ts b/src/main/constants.ts index 581b41b..f40321c 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -1,6 +1,7 @@ import path from "node:path"; import os from "node:os"; import { AppSettings } from "../shared/types"; +import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import packageJson from "../../package.json"; export const APP_NAME = "Multi Debrid Downloader"; @@ -94,6 +95,8 @@ export function defaultSettings(): AppSettings { minimizeToTray: false, theme: "dark" as const, collapseNewPackages: true, + accountListShowDetailedDebridLinkKeys: false, + autoSortPackagesByProgress: true, autoSkipExtracted: false, confirmDeleteSelection: true, totalDownloadedAllTime: 0, @@ -103,6 +106,11 @@ export function defaultSettings(): AppSettings { autoExtractWhenStopped: true, disabledProviders: [], hosterRouting: {}, + providerDailyLimitBytes: {}, + providerDailyUsageBytes: {}, + debridLinkApiKeyDailyLimitBytes: {}, + debridLinkApiKeyDailyUsageBytes: {}, + providerDailyUsageDay: getProviderUsageDayKey(), scheduledStartEpochMs: 0 }; } diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 2a3cfa5..afc4e45 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1,4 +1,6 @@ +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types"; +import { isDebridLinkApiKeyDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; import { APP_VERSION, REQUEST_RETRIES } from "./constants"; import { logger } from "./logger"; import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; @@ -65,10 +67,20 @@ interface DebridServiceOptions { function cloneSettings(settings: AppSettings): AppSettings { return { ...settings, - bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })) + bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })), + providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) }, + providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) }, + debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }, + debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) } }; } +function getAvailableDebridLinkApiKeys(settings: AppSettings, epochMs = Date.now()) { + return parseDebridLinkApiKeys(settings.debridLinkApiKeys).filter( + (entry) => !isDebridLinkApiKeyDailyLimitReached(settings, entry.id, epochMs) + ); +} + function hasMegaDebridCredentials(settings: AppSettings): boolean { return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim()); } @@ -1305,27 +1317,31 @@ export async function fetchAllDebridHostInfo(token: string, host = "rapidgator", // ── Debrid-Link Client ── class DebridLinkClient { - private apiKeys: string[]; + private apiKeys: ReturnType; private currentKeyIndex: number = 0; public constructor(apiKeysRaw: string) { - this.apiKeys = apiKeysRaw - .split(/[\n,]+/) - .map((k) => k.trim()) - .filter(Boolean); + this.apiKeys = parseDebridLinkApiKeys(apiKeysRaw); } - public async unrestrictLink(link: string, signal?: AbortSignal): Promise { + public async unrestrictLink(link: string, settings: AppSettings, signal?: AbortSignal): Promise { if (this.apiKeys.length === 0) { throw new Error("Debrid-Link: Kein API-Key konfiguriert"); } - const startIndex = this.currentKeyIndex; - let triedAll = false; + if (getAvailableDebridLinkApiKeys(settings).length === 0) { + throw new Error(`Debrid-Link: Alle ${this.apiKeys.length} API-Keys haben ihr Tageslimit erreicht`); + } - while (!triedAll) { + let checkedKeys = 0; + while (checkedKeys < this.apiKeys.length) { const apiKey = this.apiKeys[this.currentKeyIndex]; - const keyLabel = this.apiKeys.length > 1 ? ` #${this.currentKeyIndex + 1}` : ""; + checkedKeys += 1; + const keyLabel = this.apiKeys.length > 1 ? ` (${apiKey.label})` : ""; + if (isDebridLinkApiKeyDailyLimitReached(settings, apiKey.id)) { + this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length; + continue; + } let lastError = ""; for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { @@ -1335,7 +1351,7 @@ class DebridLinkClient { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Bearer ${apiKey}` + Authorization: `Bearer ${apiKey.token}` }, body: `url=${encodeURIComponent(link)}`, signal: withTimeoutSignal(signal, API_TIMEOUT_MS) @@ -1383,7 +1399,9 @@ class DebridLinkClient { directUrl, fileSize, retriesUsed: attempt - 1, - sourceLabel: keyLabel ? `#${this.currentKeyIndex + 1}` : "API" + sourceLabel: apiKey.label, + sourceAccountId: apiKey.id, + sourceAccountLabel: apiKey.label }; } catch (error) { lastError = compactErrorText(error); @@ -1400,9 +1418,6 @@ class DebridLinkClient { } this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length; - if (this.currentKeyIndex === startIndex) { - triedAll = true; - } } throw new Error(`Debrid-Link: Alle ${this.apiKeys.length} API-Keys haben ihr Limit erreicht`); @@ -1915,6 +1930,29 @@ export class DebridService { return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict); } + private isProviderDailyLimited(settings: AppSettings, provider: DebridProvider): boolean { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if (effectiveProvider === "debridlink") { + const configuredKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys); + if (configuredKeys.length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) { + return true; + } + } + return isProviderDailyLimitReached(settings, effectiveProvider); + } + + private isProviderSelectableFor(settings: AppSettings, provider: DebridProvider): boolean { + return this.isProviderConfiguredFor(settings, provider) && !this.isProviderDailyLimited(settings, provider); + } + + private formatProviderLimitMessage(settings: AppSettings, provider: DebridProvider): string { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if (effectiveProvider === "debridlink" && parseDebridLinkApiKeys(settings.debridLinkApiKeys).length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) { + return "Debrid-Link Tageslimit erreicht (alle API-Keys ausgeschopft)"; + } + return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`; + } + public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise { const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings); @@ -1923,7 +1961,7 @@ export class DebridService { const hosterKey = extractHosterFromUrl(link); if (hosterKey && routing[hosterKey]) { const routedProvider = routing[hosterKey]; - if (this.isProviderConfiguredFor(settings, routedProvider)) { + if (this.isProviderSelectableFor(settings, routedProvider)) { logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`); try { const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal); @@ -1949,6 +1987,8 @@ export class DebridService { logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} fehlgeschlagen, Fallback auf Provider-Kette: ${errorText}`); // Fall through to normal provider chain } + } else if (this.isProviderConfiguredFor(settings, routedProvider) && this.isProviderDailyLimited(settings, routedProvider)) { + logger.info(`Hoster-Zuordnung ${hosterKey} ? ${PROVIDER_LABELS[routedProvider]} ?bersprungen (${this.formatProviderLimitMessage(settings, routedProvider)})`); } else { logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} übersprungen (Provider nicht konfiguriert/deaktiviert)`); } @@ -1956,7 +1996,7 @@ export class DebridService { // 1Fichier is a direct file hoster. If the link is a 1fichier.com URL // and the API key is configured, use 1Fichier directly before debrid providers. - if (ONEFICHIER_URL_RE.test(link) && this.isProviderConfiguredFor(settings, "onefichier")) { + if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) { try { const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal); return { @@ -1976,7 +2016,7 @@ export class DebridService { // DDownload is a direct file hoster, not a debrid service. // If the link is a ddownload.com/ddl.to URL and the account is configured, // use DDownload directly before trying any debrid providers. - if (DDOWNLOAD_URL_RE.test(link) && this.isProviderConfiguredFor(settings, "ddownload")) { + if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) { try { const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal); return { @@ -2003,8 +2043,14 @@ export class DebridService { if (!this.isProviderConfiguredFor(settings, primary)) { throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`); } + const selectedProvider = this.isProviderDailyLimited(settings, primary) + ? order.find((provider) => provider !== primary && this.isProviderSelectableFor(settings, provider)) + : primary; + if (!selectedProvider) { + throw new Error(this.formatProviderLimitMessage(settings, primary)); + } try { - const result = await this.unrestrictViaProvider(settings, primary, link, signal); + const result = await this.unrestrictViaProvider(settings, selectedProvider, link, signal); let fileName = result.fileName; if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { const fromPage = await resolveRapidgatorFilename(link, signal); @@ -2015,19 +2061,20 @@ export class DebridService { return { ...result, fileName, - provider: primary, - providerLabel: PROVIDER_LABELS[primary] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") + provider: selectedProvider, + providerLabel: PROVIDER_LABELS[selectedProvider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") }; } catch (error) { const errorText = compactErrorText(error); if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { throw error; } - throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[primary]}: ${errorText}`); + throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[selectedProvider]}: ${errorText}`); } } let configuredFound = false; + let limitReachedFound = false; const attempts: string[] = []; for (const provider of order) { @@ -2035,6 +2082,11 @@ export class DebridService { continue; } configuredFound = true; + if (this.isProviderDailyLimited(settings, provider)) { + limitReachedFound = true; + attempts.push(this.formatProviderLimitMessage(settings, provider)); + continue; + } try { const result = await this.unrestrictViaProvider(settings, provider, link, signal); @@ -2063,6 +2115,9 @@ export class DebridService { if (!configuredFound) { throw new Error("Kein Debrid-Provider konfiguriert"); } + if (limitReachedFound && attempts.every((entry) => /Tageslimit erreicht$/i.test(entry))) { + throw new Error("Alle konfigurierten Provider haben ihr Tageslimit erreicht"); + } throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`); } @@ -2138,7 +2193,7 @@ export class DebridService { return new OneFichierClient(settings.oneFichierApiKey).unrestrictLink(link, signal); } if (effectiveProvider === "debridlink") { - const dlResult = await this.getDebridLinkClient(settings.debridLinkApiKeys).unrestrictLink(link, signal); + const dlResult = await this.getDebridLinkClient(settings.debridLinkApiKeys).unrestrictLink(link, settings, signal); dlResult.sourceLabel = dlResult.sourceLabel || "API"; return dlResult; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index e4b87cb..7fa3d03 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -20,6 +20,8 @@ import { StartConflictResolutionResult, UiSnapshot } from "../shared/types"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { addDebridLinkApiKeyDailyUsageBytes, addProviderDailyUsageBytes, getProviderUsageDayKey, isDebridLinkApiKeyDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS } from "./constants"; // Reference counter for NODE_TLS_REJECT_UNAUTHORIZED to avoid race conditions @@ -77,7 +79,7 @@ const DEFAULT_LOW_THROUGHPUT_TIMEOUT_MS = 120000; const DEFAULT_LOW_THROUGHPUT_MIN_BYTES = 64 * 1024; -const MINI_DOWNLOAD_RETRY_THRESHOLD_BYTES = 1024 * 1024; +const MINI_DOWNLOAD_RETRY_THRESHOLD_BYTES = 100 * 1024; const ALLDEBRID_HOST_INFO_TTL_MS = 60000; @@ -198,7 +200,11 @@ function cloneSession(session: SessionState): SessionState { function cloneSettings(settings: AppSettings): AppSettings { return { ...settings, - bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })) + bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })), + providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) }, + providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) }, + debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }, + debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) } }; } @@ -1069,6 +1075,7 @@ export class DownloadManager extends EventEmitter { const previous = this.settings; next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); this.settings = next; + this.ensureProviderDailyUsageFresh(nowMs()); this.debridService.setSettings(next); this.allDebridHostInfoCache.clear(); @@ -1136,6 +1143,7 @@ export class DownloadManager extends EventEmitter { public getSnapshot(): UiSnapshot { const now = nowMs(); + this.ensureProviderDailyUsageFresh(now, true); this.pruneSpeedEvents(now); const paused = this.session.running && this.session.paused; const speedBps = !this.session.running || paused ? 0 : this.speedBytesLastWindow / SPEED_WINDOW_SECONDS; @@ -4410,11 +4418,46 @@ export class DownloadManager extends EventEmitter { return remaining; } + private ensureProviderDailyUsageFresh(now = nowMs(), persist = false): void { + const currentDay = getProviderUsageDayKey(now); + if (this.settings.providerDailyUsageDay === currentDay) { + return; + } + this.settings.providerDailyUsageDay = currentDay; + this.settings.providerDailyUsageBytes = {}; + this.settings.debridLinkApiKeyDailyUsageBytes = {}; + this.statsCache = null; + this.statsCacheAt = 0; + if (persist) { + this.lastSettingsPersistAt = now; + void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler: ${compactErrorText(err as Error)}`)); + } + } + + private recordProviderDownloadedBytes(provider: DownloadItem["provider"], byteDelta: number, providerAccountId?: string): void { + if (!provider) { + return; + } + const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider; + const nextUsage = addProviderDailyUsageBytes(this.settings, effectiveProvider, byteDelta); + this.settings.providerDailyUsageDay = nextUsage.providerDailyUsageDay; + this.settings.providerDailyUsageBytes = nextUsage.providerDailyUsageBytes; + if (effectiveProvider === "debridlink" && providerAccountId) { + const nextKeyUsage = addDebridLinkApiKeyDailyUsageBytes(this.settings, providerAccountId, byteDelta); + this.settings.providerDailyUsageDay = nextKeyUsage.providerDailyUsageDay; + this.settings.debridLinkApiKeyDailyUsageBytes = nextKeyUsage.debridLinkApiKeyDailyUsageBytes; + } + } + private isProviderConfigured(provider: DebridProvider): boolean { + this.ensureProviderDailyUsageFresh(nowMs()); const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider; if ((this.settings.disabledProviders || []).includes(provider) || (this.settings.disabledProviders || []).includes(effectiveProvider)) { return false; } + if (isProviderDailyLimitReached(this.settings, effectiveProvider)) { + return false; + } if (effectiveProvider === "realdebrid") { return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim()); } @@ -4439,7 +4482,8 @@ export class DownloadManager extends EventEmitter { return Boolean(this.settings.oneFichierApiKey.trim()); } if (effectiveProvider === "debridlink") { - return Boolean(this.settings.debridLinkApiKeys.trim()); + const configuredKeys = parseDebridLinkApiKeys(this.settings.debridLinkApiKeys); + return configuredKeys.some((entry) => !isDebridLinkApiKeyDailyLimitReached(this.settings, entry.id)); } if (provider === "linksnappy") { return Boolean(this.settings.linkSnappyLogin.trim() && this.settings.linkSnappyPassword.trim()); @@ -4471,7 +4515,10 @@ export class DownloadManager extends EventEmitter { private getExpectedProviderForItem(item: DownloadItem): DebridProvider | null { if (item.provider) { - return resolveMegaDebridProvider(this.settings, item.provider); + const resolvedProvider = resolveMegaDebridProvider(this.settings, item.provider); + if (resolvedProvider && this.isProviderConfigured(resolvedProvider)) { + return resolvedProvider; + } } const hosterKey = extractHosterKey(item.url); @@ -5232,6 +5279,8 @@ export class DownloadManager extends EventEmitter { this.recordProviderSuccess(this.getProviderFailureKeyForItem(item, unrestricted.provider)); item.provider = unrestricted.provider; item.providerLabel = unrestricted.providerLabel; + item.providerAccountId = unrestricted.sourceAccountId; + item.providerAccountLabel = unrestricted.sourceAccountLabel; item.retries += unrestricted.retriesUsed; item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url)); try { @@ -5341,7 +5390,7 @@ export class DownloadManager extends EventEmitter { item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null; item.speedBps = 0; item.updatedAt = nowMs(); - throw new Error(`Datei zu klein (${humanSize(fileSizeOnDisk)}, erwartet ${item.totalBytes ? humanSize(item.totalBytes) : ">= 1 MB"})`); + throw new Error(`Datei zu klein (${humanSize(fileSizeOnDisk)}, erwartet ${item.totalBytes ? humanSize(item.totalBytes) : ">= 100 KB"})`); } done = true; @@ -6154,6 +6203,7 @@ export class DownloadManager extends EventEmitter { this.session.totalDownloadedBytes += buffer.length; this.sessionDownloadedBytes += buffer.length; this.settings.totalDownloadedAllTime += buffer.length; + this.recordProviderDownloadedBytes(item.provider, buffer.length, item.providerAccountId); this.itemContributedBytes.set(active.itemId, (this.itemContributedBytes.get(active.itemId) || 0) + buffer.length); this.recordSpeed(buffer.length, item.packageId); throughputWindowBytes += buffer.length; @@ -6998,7 +7048,7 @@ export class DownloadManager extends EventEmitter { // Show transitional label while next archive initializes const done = currentCount; if (done < progress.total) { - pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Naechstes Archiv...`; + pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Nächstes Archiv...`; this.emitState(); } } else { @@ -7375,7 +7425,7 @@ export class DownloadManager extends EventEmitter { // Show transitional label while next archive initializes const done = currentCount; if (done < progress.total) { - emitExtractStatus(`Entpacken (${done}/${progress.total}) - Naechstes Archiv...`, true); + emitExtractStatus(`Entpacken (${done}/${progress.total}) - Nächstes Archiv...`, true); } } else { // Update this archive's items with per-archive progress diff --git a/src/main/main.ts b/src/main/main.ts index e9a071c..d1d7701 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron"; -import { AddLinksPayload, AppSettings, UpdateInstallProgress } from "../shared/types"; +import { AddLinksPayload, AppSettings, DebridProvider, UpdateInstallProgress } from "../shared/types"; import { AppController } from "./app-controller"; import { IPC_CHANNELS } from "../shared/ipc"; import { getLogFilePath, logger } from "./logger"; @@ -26,6 +26,17 @@ function validatePlainObject(value: unknown, name: string): Record([ + "realdebrid", + "megadebrid-api", + "megadebrid-web", + "bestdebrid", + "alldebrid", + "ddownload", + "onefichier", + "debridlink", + "linksnappy" +]); function validateStringArray(value: unknown, name: string): string[] { if (!Array.isArray(value) || !value.every(v => typeof v === "string")) { throw new Error(`${name} muss ein String-Array sein`); @@ -289,6 +300,20 @@ function registerIpcHandlers(): void { } return result; }); + ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => { + const validatedProvider = validateString(provider, "provider") as DebridProvider; + if (!RESETTABLE_PROVIDER_KEYS.has(validatedProvider)) { + throw new Error("provider ist ungültig"); + } + return controller.resetProviderDailyUsage(validatedProvider); + }); + ipcMain.handle(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => { + const validatedKeyId = validateString(keyId, "keyId").trim(); + if (!validatedKeyId) { + throw new Error("keyId ist ung?ltig"); + } + return controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId); + }); ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { validatePlainObject(payload ?? {}, "payload"); validateString(payload?.rawText, "rawText"); diff --git a/src/main/realdebrid.ts b/src/main/realdebrid.ts index 6b1fe85..49d36de 100644 --- a/src/main/realdebrid.ts +++ b/src/main/realdebrid.ts @@ -10,6 +10,8 @@ export interface UnrestrictedLink { retriesUsed: number; skipTlsVerify?: boolean; sourceLabel?: string; + sourceAccountId?: string; + sourceAccountLabel?: string; } function shouldRetryStatus(status: number): boolean { diff --git a/src/main/storage.ts b/src/main/storage.ts index 75c1b54..03ea3af 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1,7 +1,9 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; +import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { AppSettings, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, PackageEntry, PackagePriority, SessionState } from "../shared/types"; +import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { defaultSettings } from "./constants"; import { logger } from "./logger"; @@ -143,6 +145,57 @@ function normalizeDisabledProviders(raw: unknown): DebridProvider[] { return result; } +function normalizeProviderByteMap( + raw: unknown, + megaDebridPreferApi: boolean, + megaDebridApiEnabled: boolean, + megaDebridWebEnabled: boolean, + mergeMode: "max" | "sum" +): Partial> { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return {}; + } + + const result: Partial> = {}; + for (const [key, value] of Object.entries(raw as Record)) { + const provider = normalizeConfiguredProvider(key, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled); + if (!provider) { + continue; + } + const bytes = clampNumber(value, 0, 0, Number.MAX_SAFE_INTEGER); + if (bytes <= 0) { + continue; + } + if (mergeMode === "sum") { + result[provider] = (result[provider] || 0) + bytes; + } else { + result[provider] = Math.max(result[provider] || 0, bytes); + } + } + return result; +} + +function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Record { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return {}; + } + + const allowed = new Set(allowedKeys); + const result: Record = {}; + for (const [key, value] of Object.entries(raw as Record)) { + const normalizedKey = String(key || "").trim(); + if (!normalizedKey || !allowed.has(normalizedKey)) { + continue; + } + const bytes = clampNumber(value, 0, 0, Number.MAX_SAFE_INTEGER); + if (bytes <= 0) { + continue; + } + result[normalizedKey] = bytes; + } + return result; +} + function normalizeHosterRouting(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): Record { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; const result: Record = {}; @@ -205,6 +258,7 @@ function migrateUpdateRepo(raw: string, fallback: string): string { export function normalizeSettings(settings: AppSettings): AppSettings { const defaults = defaultSettings(); + const currentUsageDay = getProviderUsageDayKey(); const megaLogin = asText(settings.megaLogin); const megaPassword = asText(settings.megaPassword); const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true; @@ -215,6 +269,24 @@ export function normalizeSettings(settings: AppSettings): AppSettings { const megaDebridWebEnabled = settings.megaDebridWebEnabled !== undefined ? Boolean(settings.megaDebridWebEnabled) : (hasMegaCreds ? !megaDebridPreferApi : defaults.megaDebridWebEnabled); + const providerDailyUsageDayRaw = asText(settings.providerDailyUsageDay); + const providerDailyUsageDay = /^\d{4}-\d{2}-\d{2}$/.test(providerDailyUsageDayRaw) + ? providerDailyUsageDayRaw + : currentUsageDay; + const debridLinkApiKeyIds = getDebridLinkApiKeyIds(String(settings.debridLinkApiKeys ?? "")); + const providerDailyUsageBytes = normalizeProviderByteMap( + settings.providerDailyUsageBytes, + megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled, + "sum" + ); + const debridLinkApiKeyDailyLimitBytes = normalizeNamedByteMap( + settings.debridLinkApiKeyDailyLimitBytes, + debridLinkApiKeyIds + ); + const debridLinkApiKeyDailyUsageBytes = normalizeNamedByteMap( + settings.debridLinkApiKeyDailyUsageBytes, + debridLinkApiKeyIds + ); const normalized: AppSettings = { token: asText(settings.token), realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), @@ -273,6 +345,10 @@ export function normalizeSettings(settings: AppSettings): AppSettings { clipboardWatch: Boolean(settings.clipboardWatch), minimizeToTray: Boolean(settings.minimizeToTray), collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages, + accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys !== undefined + ? Boolean(settings.accountListShowDetailedDebridLinkKeys) + : defaults.accountListShowDetailedDebridLinkKeys, + autoSortPackagesByProgress: settings.autoSortPackagesByProgress !== undefined ? Boolean(settings.autoSortPackagesByProgress) : defaults.autoSortPackagesByProgress, autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted, confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime, @@ -282,7 +358,17 @@ export function normalizeSettings(settings: AppSettings): AppSettings { extractCpuPriority: settings.extractCpuPriority, autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped, disabledProviders: normalizeDisabledProviders(settings.disabledProviders), - hosterRouting: normalizeHosterRouting(settings.hosterRouting, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled) + hosterRouting: normalizeHosterRouting(settings.hosterRouting, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled), + providerDailyLimitBytes: normalizeProviderByteMap( + settings.providerDailyLimitBytes, + megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled, + "max" + ), + providerDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? providerDailyUsageBytes : {}, + debridLinkApiKeyDailyLimitBytes, + debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {}, + providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, + scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) }; if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) { @@ -414,6 +500,9 @@ export function normalizeLoadedSession(raw: unknown): SessionState { packageId, url, provider: VALID_ITEM_PROVIDERS.has(providerRaw) ? providerRaw : null, + providerLabel: asText(item.providerLabel) || undefined, + providerAccountId: asText(item.providerAccountId) || undefined, + providerAccountLabel: asText(item.providerAccountLabel) || undefined, status, retries: clampNumber(item.retries, 0, 0, 1_000_000), speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000), diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 841cd3e..d34926d 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -3,6 +3,7 @@ import { AddLinksPayload, AllDebridHostInfo, AppSettings, + DebridProvider, DuplicatePolicy, HistoryEntry, PackagePriority, @@ -23,6 +24,8 @@ const api: ElectronApi = { installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE), openExternal: (url: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url), updateSettings: (settings: Partial): Promise => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), + resetProviderDailyUsage: (provider: DebridProvider): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider), + resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId), addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> => ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload), addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 54ae935..fc300d1 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -120,6 +120,7 @@ interface ConfiguredAccountEntry { modeLabel: string; statusLabel: string; summary: string; + summaryLines: string[]; note: string; disabled: boolean; dailyUsedBytes: number; @@ -474,6 +475,16 @@ function summarizeAccount(kind: AccountKind, settings: AppSettings): string { } } +function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string[] { + if (kind === "debridlink-api" && settings.accountListShowDetailedDebridLinkKeys) { + const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); + if (keys.length > 1) { + return keys.map((entry) => `${entry.label}: ${entry.masked}`); + } + } + return [summarizeAccount(kind, settings)]; +} + function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState { if (!kind) { return { @@ -691,6 +702,7 @@ const emptySnapshot = (): UiSnapshot => ({ 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, autoSortPackagesByProgress: true, autoSkipExtracted: false, confirmDeleteSelection: true, + accountListShowDetailedDebridLinkKeys: false, bandwidthSchedules: [], totalDownloadedAllTime: 0, columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], autoExtractWhenStopped: true, @@ -1817,6 +1829,7 @@ export function App(): ReactElement { modeLabel: option.modeLabel, statusLabel: isDisabled ? "Deaktiviert" : statusLabel, summary: summarizeAccount(kind, settingsDraft), + summaryLines: summarizeAccountLines(kind, settingsDraft), note, disabled: isDisabled, dailyUsedBytes, @@ -3922,6 +3935,15 @@ export function App(): ReactElement { {availableAccountOptions.length} weitere Typen verfügbar + + {configuredAccounts.length === 0 && (
Noch keine Accounts hinterlegt @@ -4038,7 +4060,15 @@ export function App(): ReactElement { )}
- {entry.summary} + {entry.summaryLines.length > 1 ? ( +
+ {entry.summaryLines.map((line) => ( + {line} + ))} +
+ ) : ( + {entry.summary} + )}
{showStatusButton && ( diff --git a/src/renderer/package-order.ts b/src/renderer/package-order.ts index 25a7a06..01afa39 100644 --- a/src/renderer/package-order.ts +++ b/src/renderer/package-order.ts @@ -1,4 +1,6 @@ -import type { PackageEntry } from "../shared/types"; +import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types"; + +const ACTIVE_PACKAGE_STATUSES = new Set(["downloading", "validating", "integrity_check", "extracting"]); export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] { const fromIndex = order.indexOf(draggedPackageId); @@ -23,3 +25,49 @@ export function sortPackageOrderByName(order: string[], packages: Record, + running: boolean, + autoSortPackagesByProgress: boolean +): PackageEntry[] { + if (!running || !autoSortPackagesByProgress || packages.length <= 1) { + return packages; + } + + const active: Array<{ pkg: PackageEntry; index: number; completedRatio: number; downloadedBytes: number }> = []; + const rest: PackageEntry[] = []; + + packages.forEach((pkg, index) => { + const items = pkg.itemIds + .map((id) => itemsById[id]) + .filter((item): item is DownloadItem => Boolean(item)); + const hasActive = items.some((item) => ACTIVE_PACKAGE_STATUSES.has(item.status)); + if (!hasActive) { + rest.push(pkg); + return; + } + const completedRatio = items.length > 0 + ? items.filter((item) => item.status === "completed").length / items.length + : 0; + const downloadedBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); + active.push({ pkg, index, completedRatio, downloadedBytes }); + }); + + if (active.length === 0 || active.length === packages.length) { + return packages; + } + + active.sort((a, b) => { + if (a.completedRatio !== b.completedRatio) { + return b.completedRatio - a.completedRatio; + } + if (a.downloadedBytes !== b.downloadedBytes) { + return b.downloadedBytes - a.downloadedBytes; + } + return a.index - b.index; + }); + + return [...active.map((entry) => entry.pkg), ...rest]; +} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 695207d..8ed5ecc 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -624,7 +624,7 @@ body, overflow: auto; display: flex; flex-direction: column; - gap: 10px; + gap: 0; } .downloads-toolbar { @@ -632,6 +632,7 @@ body, align-items: center; gap: 10px; flex-wrap: wrap; + margin-bottom: 8px; } .downloads-toolbar-actions { @@ -649,14 +650,16 @@ body, display: grid; /* grid-template-columns set via inline style from columnOrder */ gap: 8px; - padding: 5px 12px; - background: var(--card); - border: 1px solid var(--border); - border-radius: 8px; + padding: 4px 10px; + background: color-mix(in srgb, var(--card) 58%, transparent); + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--border) 62%, transparent); + border-radius: 0; font-size: 12px; font-weight: 700; color: var(--muted); user-select: none; + margin-bottom: 1px; } .pkg-column-header .pkg-col-progress, @@ -1150,6 +1153,10 @@ body, flex-wrap: wrap; } +.account-display-toggle { + width: fit-content; +} + .account-inline-stat { display: inline-flex; align-items: center; @@ -1290,6 +1297,19 @@ body, line-height: 1.4; } +.account-usage-stats { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.account-usage-stats.warning { + color: color-mix(in srgb, #f59e0b 78%, white 8%); +} + .account-mode-pill, .account-status-pill { display: inline-flex; @@ -1366,6 +1386,74 @@ body, opacity: 1; } +.account-subkey-list { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.account-subkey-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + padding: 7px 10px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + background: color-mix(in srgb, var(--field) 72%, transparent); +} + +.account-subkey-row.warning { + border-color: color-mix(in srgb, #f59e0b 42%, transparent); +} + +.account-subkey-main { + display: grid; + gap: 4px; + min-width: 0; +} + +.account-subkey-head { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.account-subkey-head strong { + font-size: 12px; + flex: 0 0 auto; +} + +.account-subkey-head span { + color: var(--muted); + font-size: 11px; + font-family: "JetBrains Mono", "Consolas", monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.account-subkey-stats { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + +.account-subkey-actions { + display: flex; + justify-content: flex-end; +} + +.account-subkey-actions .btn { + padding: 4px 8px; + font-size: 11px; +} + .hoster-routing-table { display: flex; flex-direction: column; @@ -1681,6 +1769,54 @@ body, line-height: 1.5; } +.account-dl-key-limit-list { + display: grid; + gap: 8px; +} + +.account-dl-key-limit-row { + display: grid; + grid-template-columns: minmax(160px, 220px) minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + background: color-mix(in srgb, var(--field) 84%, transparent); +} + +.account-dl-key-meta { + display: grid; + gap: 2px; + min-width: 0; +} + +.account-dl-key-meta strong { + font-size: 13px; +} + +.account-dl-key-meta span { + color: var(--muted); + font-size: 12px; + font-family: "JetBrains Mono", "Consolas", monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-secret.account-secret-multiline { + display: grid; + align-items: start; + gap: 4px; + padding: 10px 12px; + white-space: normal; +} + +.account-secret.account-secret-multiline span { + display: block; + line-height: 1.35; +} + .account-status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); @@ -1730,9 +1866,22 @@ body, .package-card { border: 1px solid var(--border); - border-radius: 14px; + border-radius: 11px; background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 96%, transparent)); - padding: 8px 12px; + padding: 6px 10px; +} + +.queue-package-card { + border: 0; + border-radius: 0; + background: transparent; + padding: 0; + box-shadow: none; + border-bottom: 1px solid color-mix(in srgb, var(--border) 54%, transparent); +} + +.queue-package-card:hover { + background: color-mix(in srgb, var(--accent) 3%, transparent); } .package-card[draggable="true"] { @@ -1752,6 +1901,12 @@ body, box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); } +.queue-package-card.pkg-selected { + border-color: transparent; + box-shadow: inset 2px 0 0 0 var(--accent); + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + .item-selected { background: color-mix(in srgb, var(--accent) 12%, transparent); } @@ -1759,18 +1914,23 @@ body, .package-card header { display: flex; justify-content: space-between; - gap: 12px; + gap: 8px; align-items: center; } +.queue-package-card header { + min-height: 28px; + padding: 3px 10px; +} + .package-card h4 { margin: 0; - font-size: 15px; + font-size: 14px; } .package-card header span { color: var(--muted); - font-size: 13px; + font-size: 12px; } .package-card header .progress-inline-text-filled, @@ -1798,6 +1958,15 @@ body, transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; } +.queue-package-card .pkg-toggle { + width: 16px; + height: 16px; + border-radius: 3px; + font-size: 12px; + background: transparent; + border-color: color-mix(in srgb, var(--border) 42%, transparent); +} + .pkg-toggle:hover { border-color: var(--accent); color: var(--text); @@ -1837,14 +2006,21 @@ body, } .progress { - margin-top: 8px; - height: 7px; + margin-top: 6px; + height: 6px; border-radius: 999px; background: var(--progress-track); overflow: hidden; display: flex; } +.queue-package-card .progress { + margin-top: 0; + height: 2px; + border-radius: 0; + background: color-mix(in srgb, var(--progress-track) 80%, transparent); +} + .progress-dl { height: 100%; background: linear-gradient(90deg, #3bc9ff, #22d3ee); @@ -1945,12 +2121,22 @@ td { /* grid-template-columns set via inline style from columnOrder */ gap: 8px; align-items: center; - margin: 0 -12px; - padding: 4px 12px; - font-size: 13px; + margin: 0 -10px; + padding: 3px 10px; + font-size: 12px; border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent); } +.queue-package-card .item-row { + margin: 0; + padding: 2px 10px 2px 10px; + border-top: 0; +} + +.queue-package-card .item-row + .item-row { + border-top: 1px solid color-mix(in srgb, var(--border) 24%, transparent); +} + .item-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); } @@ -1960,7 +2146,7 @@ td { text-overflow: ellipsis; white-space: nowrap; color: var(--muted); - font-size: 13px; + font-size: 12px; text-align: center; } @@ -1970,7 +2156,7 @@ td { .item-row .pkg-col-name { color: var(--text); - padding-left: 32px; + padding-left: 28px; } .link-status-dot { @@ -2522,6 +2708,17 @@ td { justify-content: flex-start; } + .account-subkey-row, + .account-dl-key-limit-row { + grid-template-columns: 1fr; + } + + .account-subkey-head { + flex-direction: column; + align-items: flex-start; + gap: 2px; + } + .account-picker-list { grid-template-columns: 1fr; } diff --git a/src/shared/debrid-link-keys.ts b/src/shared/debrid-link-keys.ts new file mode 100644 index 0000000..9c1cc66 --- /dev/null +++ b/src/shared/debrid-link-keys.ts @@ -0,0 +1,66 @@ +export interface DebridLinkApiKeyEntry { + id: string; + token: string; + index: number; + label: string; + masked: string; +} + +const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n; +const FNV64_PRIME = 0x100000001b3n; +const FNV64_MASK = 0xffffffffffffffffn; + +function fnv1a64(text: string): string { + let hash = FNV64_OFFSET_BASIS; + for (const char of text) { + hash ^= BigInt(char.codePointAt(0) || 0); + hash = (hash * FNV64_PRIME) & FNV64_MASK; + } + return hash.toString(36); +} + +export function maskDebridLinkApiKey(token: string): string { + const trimmed = token.trim(); + if (!trimmed) { + return "Nicht hinterlegt"; + } + if (trimmed.length <= 6) { + return "*".repeat(trimmed.length); + } + return `${trimmed.slice(0, 3)}${"*".repeat(Math.max(4, trimmed.length - 6))}${trimmed.slice(-3)}`; +} + +export function getDebridLinkApiKeyId(token: string): string { + return `dlk_${fnv1a64(token.trim())}`; +} + +export function getDebridLinkApiKeyLabel(index: number): string { + return `Key ${index + 1}`; +} + +export function parseDebridLinkApiKeys(raw: string): DebridLinkApiKeyEntry[] { + const seen = new Set(); + const tokens = String(raw || "") + .split(/[\n,]+/) + .map((entry) => entry.trim()) + .filter(Boolean) + .filter((token) => { + if (seen.has(token)) { + return false; + } + seen.add(token); + return true; + }); + + return tokens.map((token, index) => ({ + id: getDebridLinkApiKeyId(token), + token, + index, + label: getDebridLinkApiKeyLabel(index), + masked: maskDebridLinkApiKey(token) + })); +} + +export function getDebridLinkApiKeyIds(raw: string): string[] { + return parseDebridLinkApiKeys(raw).map((entry) => entry.id); +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index d39ed65..952a1c0 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -6,6 +6,8 @@ export const IPC_CHANNELS = { UPDATE_INSTALL_PROGRESS: "app:update-install-progress", OPEN_EXTERNAL: "app:open-external", UPDATE_SETTINGS: "app:update-settings", + RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage", + RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage", ADD_LINKS: "queue:add-links", ADD_CONTAINERS: "queue:add-containers", GET_START_CONFLICTS: "queue:get-start-conflicts", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 1703bb0..a3fccd2 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -2,6 +2,7 @@ import type { AddLinksPayload, AllDebridHostInfo, AppSettings, + DebridProvider, DuplicatePolicy, HistoryEntry, PackagePriority, @@ -21,6 +22,8 @@ export interface ElectronApi { installUpdate: () => Promise; openExternal: (url: string) => Promise; updateSettings: (settings: Partial) => Promise; + resetProviderDailyUsage: (provider: DebridProvider) => Promise; + resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise; addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>; addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; getStartConflicts: () => Promise; diff --git a/src/shared/provider-daily-limits.ts b/src/shared/provider-daily-limits.ts new file mode 100644 index 0000000..1be261b --- /dev/null +++ b/src/shared/provider-daily-limits.ts @@ -0,0 +1,197 @@ +import type { AppSettings, DebridProvider } from "./types"; + +export type ProviderByteMap = Partial>; +export type DebridLinkKeyByteMap = Record; + +type ProviderDailySettings = + Pick + & Partial>; + +function normalizePositiveBytes(value: unknown): number { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return 0; + } + return Math.floor(numeric); +} + +export function getProviderUsageDayKey(epochMs = Date.now()): string { + const current = new Date(epochMs); + const year = current.getFullYear(); + const month = String(current.getMonth() + 1).padStart(2, "0"); + const day = String(current.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function getProviderDailyLimitBytes(settings: ProviderDailySettings, provider: DebridProvider): number { + return normalizePositiveBytes(settings.providerDailyLimitBytes?.[provider]); +} + +export function getProviderDailyUsageBytes( + settings: ProviderDailySettings, + provider: DebridProvider, + epochMs = Date.now() +): number { + if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) { + return 0; + } + return normalizePositiveBytes(settings.providerDailyUsageBytes?.[provider]); +} + +export function getProviderDailyRemainingBytes( + settings: ProviderDailySettings, + provider: DebridProvider, + epochMs = Date.now() +): number | null { + const limit = getProviderDailyLimitBytes(settings, provider); + if (limit <= 0) { + return null; + } + return Math.max(0, limit - getProviderDailyUsageBytes(settings, provider, epochMs)); +} + +export function isProviderDailyLimitReached( + settings: ProviderDailySettings, + provider: DebridProvider, + epochMs = Date.now() +): boolean { + const limit = getProviderDailyLimitBytes(settings, provider); + return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit; +} + +export function resetProviderDailyUsage( + settings: ProviderDailySettings, + provider?: DebridProvider, + epochMs = Date.now() +): Pick { + const dayKey = getProviderUsageDayKey(epochMs); + if (!provider) { + return { + providerDailyUsageDay: dayKey, + providerDailyUsageBytes: {} + }; + } + + const nextUsageBytes = settings.providerDailyUsageDay === dayKey + ? { ...(settings.providerDailyUsageBytes || {}) } + : {}; + delete nextUsageBytes[provider]; + + return { + providerDailyUsageDay: dayKey, + providerDailyUsageBytes: nextUsageBytes + }; +} + +export function addProviderDailyUsageBytes( + settings: ProviderDailySettings, + provider: DebridProvider, + byteDelta: number, + epochMs = Date.now() +): Pick { + const increment = normalizePositiveBytes(byteDelta); + const dayKey = getProviderUsageDayKey(epochMs); + const currentUsageBytes = settings.providerDailyUsageDay === dayKey + ? { ...(settings.providerDailyUsageBytes || {}) } + : {}; + if (increment <= 0) { + return { + providerDailyUsageDay: dayKey, + providerDailyUsageBytes: currentUsageBytes + }; + } + + const nextUsageBytes = currentUsageBytes; + nextUsageBytes[provider] = normalizePositiveBytes(nextUsageBytes[provider]) + increment; + + return { + providerDailyUsageDay: dayKey, + providerDailyUsageBytes: nextUsageBytes + }; +} + +export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number { + return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]); +} + +export function getDebridLinkApiKeyDailyUsageBytes( + settings: ProviderDailySettings, + keyId: string, + epochMs = Date.now() +): number { + if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) { + return 0; + } + return normalizePositiveBytes(settings.debridLinkApiKeyDailyUsageBytes?.[keyId]); +} + +export function getDebridLinkApiKeyDailyRemainingBytes( + settings: ProviderDailySettings, + keyId: string, + epochMs = Date.now() +): number | null { + const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId); + if (limit <= 0) { + return null; + } + return Math.max(0, limit - getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs)); +} + +export function isDebridLinkApiKeyDailyLimitReached( + settings: ProviderDailySettings, + keyId: string, + epochMs = Date.now() +): boolean { + const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId); + return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit; +} + +export function resetDebridLinkApiKeyDailyUsage( + settings: ProviderDailySettings, + keyId?: string, + epochMs = Date.now() +): Pick { + const dayKey = getProviderUsageDayKey(epochMs); + if (!keyId) { + return { + providerDailyUsageDay: dayKey, + debridLinkApiKeyDailyUsageBytes: {} + }; + } + + const nextUsageBytes = settings.providerDailyUsageDay === dayKey + ? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) } + : {}; + delete nextUsageBytes[keyId]; + + return { + providerDailyUsageDay: dayKey, + debridLinkApiKeyDailyUsageBytes: nextUsageBytes + }; +} + +export function addDebridLinkApiKeyDailyUsageBytes( + settings: ProviderDailySettings, + keyId: string, + byteDelta: number, + epochMs = Date.now() +): Pick { + const increment = normalizePositiveBytes(byteDelta); + const dayKey = getProviderUsageDayKey(epochMs); + const currentUsageBytes = settings.providerDailyUsageDay === dayKey + ? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) } + : {}; + if (increment <= 0) { + return { + providerDailyUsageDay: dayKey, + debridLinkApiKeyDailyUsageBytes: currentUsageBytes + }; + } + + currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment; + + return { + providerDailyUsageDay: dayKey, + debridLinkApiKeyDailyUsageBytes: currentUsageBytes + }; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index a5a2a1d..b2236a1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -101,6 +101,8 @@ export interface AppSettings { minimizeToTray: boolean; theme: AppTheme; collapseNewPackages: boolean; + accountListShowDetailedDebridLinkKeys: boolean; + autoSortPackagesByProgress: boolean; autoSkipExtracted: boolean; confirmDeleteSelection: boolean; totalDownloadedAllTime: number; @@ -110,6 +112,11 @@ export interface AppSettings { autoExtractWhenStopped: boolean; disabledProviders: DebridProvider[]; hosterRouting: Record; + providerDailyLimitBytes: Partial>; + providerDailyUsageBytes: Partial>; + debridLinkApiKeyDailyLimitBytes: Record; + debridLinkApiKeyDailyUsageBytes: Record; + providerDailyUsageDay: string; scheduledStartEpochMs: number; } @@ -119,6 +126,8 @@ export interface DownloadItem { url: string; provider: DebridProvider | null; providerLabel?: string; + providerAccountId?: string; + providerAccountLabel?: string; status: DownloadStatus; retries: number; speedBps: number; diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 05ba6a2..b6ad2e9 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants"; +import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; +import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, filenameFromRapidgatorUrlPath, normalizeResolvedFilename } from "../src/main/debrid"; const originalFetch = globalThis.fetch; @@ -81,6 +83,100 @@ describe("debrid service", () => { expect(megaWeb).toHaveBeenCalledTimes(0); }); + it("skips a provider whose daily limit is already reached and uses the next provider", async () => { + const calledUrls: string[] = []; + const settings = { + ...defaultSettings(), + token: "rd-token", + debridLinkApiKeys: "dl-token", + providerOrder: ["realdebrid", "debridlink"] as const, + providerPrimary: "realdebrid" as const, + providerSecondary: "debridlink" as const, + providerTertiary: "none" as const, + autoProviderFallback: true, + providerDailyLimitBytes: { realdebrid: 100 }, + providerDailyUsageBytes: { realdebrid: 100 }, + providerDailyUsageDay: getProviderUsageDayKey() + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calledUrls.push(url); + if (url.includes("debrid-link.com/api/v2/downloader/add")) { + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/file.bin", + name: "file.bin", + size: 1234 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + throw new Error("Real-Debrid should have been skipped due to daily limit"); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/file.bin"); + expect(result.provider).toBe("debridlink"); + expect(result.directUrl).toBe("https://debrid-link.example/file.bin"); + expect(calledUrls.some((url) => url.includes("api.real-debrid.com/rest/1.0/unrestrict/link"))).toBe(false); + }); + + it("uses the next Debrid-Link key when the first key hit its local daily limit", async () => { + const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two"); + let usedAuthHeader = ""; + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + debridLinkApiKeyDailyLimitBytes: { + [keys[0].id]: 100 + }, + debridLinkApiKeyDailyUsageBytes: { + [keys[0].id]: 100 + }, + providerDailyUsageDay: getProviderUsageDayKey() + }; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const headers = init?.headers; + if (headers instanceof Headers) { + usedAuthHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + usedAuthHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + usedAuthHeader = String((headers as Record | undefined)?.Authorization || ""); + } + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/file.bin", + name: "file.bin", + size: 1234 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/file.bin"); + + expect(usedAuthHeader).toBe("Bearer dl-key-two"); + expect(result.provider).toBe("debridlink"); + expect(result.providerLabel).toContain("Key 2"); + }); + it("uses BestDebrid auth header without token query fallback", async () => { const settings = { ...defaultSettings(), diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 0506508..7629f04 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -7,6 +7,8 @@ import AdmZip from "adm-zip"; import { afterEach, describe, expect, it } from "vitest"; import { DownloadManager } from "../src/main/download-manager"; import { defaultSettings } from "../src/main/constants"; +import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; +import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { createStoragePaths, emptySession } from "../src/main/storage"; const tempDirs: string[] = []; @@ -2835,7 +2837,7 @@ describe("download manager", () => { } }); - it("retries suspicious mini files under 1 MB until the full file arrives", async () => { + it("retries suspicious mini files under 100 KB until the full file arrives", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const binary = Buffer.alloc(2 * 1024 * 1024, 21); @@ -4857,4 +4859,62 @@ describe("download manager", () => { expect(internal.speedEventsHead).toBe(0); expect(internal.speedBytesLastWindow).toBe(0); }); + + it("tracks daily usage on the actual provider key without touching other providers", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const manager = new DownloadManager( + { + ...defaultSettings(), + megaLogin: "mega-user", + megaPassword: "mega-pass", + megaDebridApiEnabled: true, + providerDailyUsageDay: getProviderUsageDayKey(), + providerDailyUsageBytes: { realdebrid: 512 } + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + const internal = manager as unknown as { + recordProviderDownloadedBytes: (provider: "megadebrid", bytes: number) => void; + settings: ReturnType; + }; + + internal.recordProviderDownloadedBytes("megadebrid", 1024); + + expect(internal.settings.providerDailyUsageBytes.realdebrid).toBe(512); + expect(internal.settings.providerDailyUsageBytes["megadebrid-api"]).toBe(1024); + expect((internal.settings.providerDailyUsageBytes as Record).megadebrid).toBeUndefined(); + }); + + it("tracks daily usage on the actual Debrid-Link key without touching other keys", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const [firstKey, secondKey] = parseDebridLinkApiKeys("dl-key-one\ndl-key-two"); + + const manager = new DownloadManager( + { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerDailyUsageDay: getProviderUsageDayKey(), + providerDailyUsageBytes: { debridlink: 256 }, + debridLinkApiKeyDailyUsageBytes: { [secondKey.id]: 512 } + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + const internal = manager as unknown as { + recordProviderDownloadedBytes: (provider: "debridlink", bytes: number, providerAccountId?: string) => void; + settings: ReturnType; + }; + + internal.recordProviderDownloadedBytes("debridlink", 1024, firstKey.id); + + expect(internal.settings.providerDailyUsageBytes.debridlink).toBe(1280); + expect(internal.settings.debridLinkApiKeyDailyUsageBytes[firstKey.id]).toBe(1024); + expect(internal.settings.debridLinkApiKeyDailyUsageBytes[secondKey.id]).toBe(512); + }); }); diff --git a/tests/package-order.test.ts b/tests/package-order.test.ts new file mode 100644 index 0000000..53d15ea --- /dev/null +++ b/tests/package-order.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { DownloadItem, PackageEntry } from "../src/shared/types"; +import { sortPackagesForDisplay } from "../src/renderer/package-order"; + +function createPackage(id: string, itemIds: string[]): PackageEntry { + const now = Date.now(); + return { + id, + name: id, + outputDir: "", + extractDir: "", + status: "queued", + itemIds, + cancelled: false, + enabled: true, + priority: "normal", + createdAt: now, + updatedAt: now + }; +} + +function createItem(id: string, packageId: string, status: DownloadItem["status"], downloadedBytes: number): DownloadItem { + const now = Date.now(); + return { + id, + packageId, + url: `https://hoster.example/${id}`, + provider: null, + status, + retries: 0, + speedBps: 0, + downloadedBytes, + totalBytes: downloadedBytes, + progressPercent: downloadedBytes > 0 ? 50 : 0, + fileName: `${id}.bin`, + targetPath: "", + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "", + createdAt: now, + updatedAt: now + }; +} + +describe("sortPackagesForDisplay", () => { + it("moves active packages with more progress to the top when auto sort is enabled", () => { + const packages = [ + createPackage("pkg-a", ["a1", "a2"]), + createPackage("pkg-b", ["b1", "b2"]), + createPackage("pkg-c", ["c1"]) + ]; + const items: Record = { + a1: createItem("a1", "pkg-a", "downloading", 250), + a2: createItem("a2", "pkg-a", "completed", 500), + b1: createItem("b1", "pkg-b", "downloading", 800), + b2: createItem("b2", "pkg-b", "completed", 900), + c1: createItem("c1", "pkg-c", "queued", 0) + }; + + const sorted = sortPackagesForDisplay(packages, items, true, true); + + expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-a", "pkg-c"]); + }); + + it("keeps package order untouched when auto sort is disabled", () => { + const packages = [ + createPackage("pkg-a", ["a1"]), + createPackage("pkg-b", ["b1"]), + createPackage("pkg-c", ["c1"]) + ]; + const items: Record = { + a1: createItem("a1", "pkg-a", "queued", 0), + b1: createItem("b1", "pkg-b", "downloading", 500), + c1: createItem("c1", "pkg-c", "queued", 0) + }; + + const sorted = sortPackagesForDisplay(packages, items, true, false); + + expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]); + }); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 83e523b..8e49f52 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -2,6 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; +import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { AppSettings } from "../src/shared/types"; import { defaultSettings } from "../src/main/constants"; import { createStoragePaths, emptySession, loadSession, loadSettings, normalizeSettings, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage"; @@ -120,7 +122,8 @@ describe("settings storage", () => { retryLimit: "-3", reconnectWaitSeconds: "1", speedLimitMode: "not-valid", - updateRepo: "" + updateRepo: "", + autoSortPackagesByProgress: false }), "utf8" ); @@ -133,6 +136,7 @@ describe("settings storage", () => { expect(loaded.reconnectWaitSeconds).toBe(10); expect(loaded.speedLimitMode).toBe("global"); expect(loaded.updateRepo).toBe(defaultSettings().updateRepo); + expect(loaded.autoSortPackagesByProgress).toBe(false); }); it("keeps explicit none as fallback provider choice", () => { @@ -176,6 +180,43 @@ describe("settings storage", () => { expect(webNormalized.hosterRouting.rapidgator).toBe("megadebrid-web"); }); + it("normalizes provider daily limits and resets stale daily usage", () => { + const [debridLinkKey] = parseDebridLinkApiKeys("dl-key-one"); + const normalized = normalizeSettings({ + ...defaultSettings(), + megaLogin: "mega-user", + megaPassword: "mega-pass", + megaDebridApiEnabled: true, + debridLinkApiKeys: "dl-key-one", + providerDailyLimitBytes: { + realdebrid: 1024, + megadebrid: 2048 + } as AppSettings["providerDailyLimitBytes"], + debridLinkApiKeyDailyLimitBytes: { + [debridLinkKey.id]: 3072, + stale: 1234 + }, + providerDailyUsageDay: "2001-01-01", + providerDailyUsageBytes: { + realdebrid: 4096, + megadebrid: 8192 + } as AppSettings["providerDailyUsageBytes"], + debridLinkApiKeyDailyUsageBytes: { + [debridLinkKey.id]: 8192, + stale: 9999 + } + }); + + expect(normalized.providerDailyLimitBytes.realdebrid).toBe(1024); + expect(normalized.providerDailyLimitBytes["megadebrid-api"]).toBe(2048); + expect(normalized.debridLinkApiKeyDailyLimitBytes).toEqual({ + [debridLinkKey.id]: 3072 + }); + expect(normalized.providerDailyUsageDay).toBe(getProviderUsageDayKey()); + expect(normalized.providerDailyUsageBytes).toEqual({}); + expect(normalized.debridLinkApiKeyDailyUsageBytes).toEqual({}); + }); + it("normalizes archive password list line endings", () => { const normalized = normalizeSettings({ ...defaultSettings(),