diff --git a/package-lock.json b/package-lock.json index 3fed182..f674391 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.6.90", + "version": "1.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.6.90", + "version": "1.7.1", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index eb0fc07..c072fe7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.7.0", + "version": "1.7.1", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 76bffcc..2bf7db5 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -22,7 +22,7 @@ import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../sha import { importDlcContainers } from "./container"; import { APP_VERSION } from "./constants"; import { DownloadManager } from "./download-manager"; -import { fetchAllDebridHostInfo } from "./debrid"; +import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid"; import { parseCollectorInput } from "./link-parser"; import { configureLogger, getLogFilePath, logger } from "./logger"; import { AllDebridWebFallback } from "./all-debrid-web"; @@ -248,6 +248,10 @@ export class AppController { return fetchAllDebridHostInfo(token, host); } + public async getDebridLinkHostLimits(host = "rapidgator") { + return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host); + } + public async checkUpdates(): Promise { const result = await checkGitHubUpdate(this.settings.updateRepo); if (!result.error) { diff --git a/src/main/constants.ts b/src/main/constants.ts index f40321c..efcbb80 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -56,6 +56,7 @@ export function defaultSettings(): AppSettings { ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", + debridLinkDisabledKeyIds: [], linkSnappyLogin: "", linkSnappyPassword: "", archivePasswordList: "", diff --git a/src/main/debrid.ts b/src/main/debrid.ts index f244688..cb2d678 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1,5 +1,5 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types"; +import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types"; import { isDebridLinkApiKeyDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; import { APP_VERSION, REQUEST_RETRIES } from "./constants"; import { logger } from "./logger"; @@ -68,6 +68,7 @@ function cloneSettings(settings: AppSettings): AppSettings { return { ...settings, bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })), + debridLinkDisabledKeyIds: [...(settings.debridLinkDisabledKeyIds || [])], providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) }, providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) }, debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }, @@ -75,9 +76,13 @@ function cloneSettings(settings: AppSettings): AppSettings { }; } -function getAvailableDebridLinkApiKeys(settings: AppSettings, epochMs = Date.now()) { +export function isDebridLinkApiKeyDisabled(settings: AppSettings, keyId: string): boolean { + return (settings.debridLinkDisabledKeyIds || []).includes(keyId); +} + +export function getAvailableDebridLinkApiKeys(settings: AppSettings, epochMs = Date.now()) { return parseDebridLinkApiKeys(settings.debridLinkApiKeys).filter( - (entry) => !isDebridLinkApiKeyDailyLimitReached(settings, entry.id, epochMs) + (entry) => !isDebridLinkApiKeyDisabled(settings, entry.id) && !isDebridLinkApiKeyDailyLimitReached(settings, entry.id, epochMs) ); } @@ -312,6 +317,126 @@ function toAllDebridHostStatusLabel(state: AllDebridHostInfo["state"]): string { return "Unbekannt"; } +function normalizeDebridLinkHostKey(value: string): string { + return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function parseDebridLinkSuccess(payload: Record | null): boolean { + if (!payload) { + return false; + } + if (typeof payload.success === "boolean") { + return payload.success; + } + return pickString(payload, ["result"]).toUpperCase() === "OK"; +} + +function parseDebridLinkHosters(payload: Record | null): Record[] { + const value = asRecord(payload?.value); + const hosters = value?.hosters ?? payload?.hosters; + if (Array.isArray(hosters)) { + return hosters.filter((entry): entry is Record => Boolean(asRecord(entry))).map((entry) => entry as Record); + } + return []; +} + +function findDebridLinkHostEntry(payload: Record | null, host: string): Record | null { + const wanted = normalizeDebridLinkHostKey(host); + for (const entry of parseDebridLinkHosters(payload)) { + const name = normalizeDebridLinkHostKey(pickString(entry, ["name", "host"])); + if (name === wanted) { + return entry; + } + } + return null; +} + +async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: string; token: string }, host: string, signal?: AbortSignal): Promise { + let lastError = ""; + const hostLabel = host.trim() || "rapidgator"; + const endpoints = [`${DEBRID_LINK_API_BASE}/downloader/limits/all`, `${DEBRID_LINK_API_BASE}/downloader/limits`]; + + for (const endpoint of endpoints) { + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + const response = await fetch(endpoint, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey.token}`, + "User-Agent": DEBRID_USER_AGENT + }, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const text = await response.text(); + const payload = parseJsonSafe(text); + + if (response.status === 404 && endpoint.endsWith("/all")) { + break; + } + + if (!response.ok) { + const reason = parseError(response.status, text, payload); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + throw new Error(reason); + } + + if (!payload) { + throw new Error("Debrid-Link Limits Antwort ist kein JSON-Objekt"); + } + if (!parseDebridLinkSuccess(payload)) { + throw new Error(pickString(payload, ["error_description", "error", "message"]) || "Debrid-Link Limits fehlgeschlagen"); + } + + const hostEntry = findDebridLinkHostEntry(payload, hostLabel); + if (!hostEntry) { + if (endpoint.endsWith("/all")) { + return { + keyId: apiKey.id, + keyLabel: apiKey.label, + host: hostLabel, + fetchedAt: Date.now(), + trafficCurrentBytes: null, + trafficMaxBytes: null, + linksCurrent: null, + linksMax: null, + note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.` + }; + } + break; + } + + const daySize = asRecord(hostEntry.daySize); + const dayCount = asRecord(hostEntry.dayCount); + return { + keyId: apiKey.id, + keyLabel: apiKey.label, + host: pickString(hostEntry, ["name", "host"]) || hostLabel, + fetchedAt: Date.now(), + trafficCurrentBytes: pickNumber(daySize, ["current"]), + trafficMaxBytes: pickNumber(daySize, ["value", "max"]), + linksCurrent: pickNumber(dayCount, ["current"]), + linksMax: pickNumber(dayCount, ["value", "max"]), + note: "" + }; + } catch (error) { + lastError = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) { + break; + } + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { + break; + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + } + + throw new Error(String(lastError || `Debrid-Link Limits für ${apiKey.label} fehlgeschlagen`).replace(/^Error:\s*/i, "")); +} + function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] { const seen = new Set(); const result: DebridProvider[] = []; @@ -1314,6 +1439,19 @@ export async function fetchAllDebridHostInfo(token: string, host = "rapidgator", return new AllDebridClient(token).getHostInfo(host, signal); } +export async function fetchDebridLinkHostLimits(apiKeysRaw: string, host = "rapidgator", signal?: AbortSignal): Promise { + const apiKeys = parseDebridLinkApiKeys(apiKeysRaw); + if (apiKeys.length === 0) { + throw new Error("Debrid-Link ist nicht konfiguriert"); + } + + const results: DebridLinkHostLimitInfo[] = []; + for (const apiKey of apiKeys) { + results.push(await fetchDebridLinkHostLimitForKey(apiKey, host, signal)); + } + return results; +} + // ── Debrid-Link Client ── class DebridLinkClient { @@ -1330,7 +1468,7 @@ class DebridLinkClient { } if (getAvailableDebridLinkApiKeys(settings).length === 0) { - throw new Error(`Debrid-Link: Alle ${this.apiKeys.length} API-Keys haben ihr Tageslimit erreicht`); + throw new Error("Debrid-Link: Kein aktiver API-Key verfuegbar (deaktiviert oder am Tageslimit)"); } let checkedKeys = 0; @@ -1338,7 +1476,13 @@ class DebridLinkClient { const apiKey = this.apiKeys[this.currentKeyIndex]; checkedKeys += 1; const keyLabel = this.apiKeys.length > 1 ? ` (${apiKey.label})` : ""; + if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) { + logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`); + this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length; + continue; + } if (isDebridLinkApiKeyDailyLimitReached(settings, apiKey.id)) { + logger.info(`Debrid-Link${keyLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Key`); this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length; continue; } @@ -1364,6 +1508,7 @@ class DebridLinkClient { const errorDesc = String(json.error_description || json.error || "Unbekannter Debrid-Link-Fehler"); if (DEBRID_LINK_QUOTA_ERRORS.has(errorCode)) { + logger.warn(`Debrid-Link${keyLabel}: API-Quota erreicht (${errorCode}: ${errorDesc}), wechsle zum naechsten Key`); logger.warn(`Debrid-Link Quota erreicht${keyLabel}: ${errorCode} – ${errorDesc}`); break; } @@ -1411,6 +1556,7 @@ class DebridLinkClient { if (/Ungültig|abgelaufen/i.test(lastError)) { throw error; } + logger.warn(`Debrid-Link${keyLabel}: Fehler bei Unrestrict-Versuch ${attempt}/${REQUEST_RETRIES}: ${lastError}`); if (attempt < REQUEST_RETRIES) { await sleep(retryDelay(attempt), signal); } @@ -1418,9 +1564,14 @@ class DebridLinkClient { } this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length; + if (checkedKeys < this.apiKeys.length) { + const nextKey = this.apiKeys[this.currentKeyIndex]; + const nextKeyLabel = this.apiKeys.length > 1 ? ` (${nextKey.label})` : ""; + logger.info(`Debrid-Link${keyLabel}: kein Erfolg, wechsle zu naechstem Key${nextKeyLabel}`); + } } - throw new Error(`Debrid-Link: Alle ${this.apiKeys.length} API-Keys haben ihr Limit erreicht`); + throw new Error("Debrid-Link: Kein aktiver API-Key verfuegbar"); } } @@ -1948,7 +2099,7 @@ export class DebridService { 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 "Debrid-Link nicht verfuegbar (alle aktiven API-Keys deaktiviert oder ausgeschopft)"; } return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`; } @@ -2084,11 +2235,13 @@ export class DebridService { configuredFound = true; if (this.isProviderDailyLimited(settings, provider)) { limitReachedFound = true; + logger.info(`Provider-Kette: ${PROVIDER_LABELS[provider]} uebersprungen (${this.formatProviderLimitMessage(settings, provider)})`); attempts.push(this.formatProviderLimitMessage(settings, provider)); continue; } try { + logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`); const result = await this.unrestrictViaProvider(settings, provider, link, signal); let fileName = result.fileName; if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { @@ -2108,6 +2261,12 @@ export class DebridService { if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { throw error; } + const nextProvider = order.slice(order.indexOf(provider) + 1).find((candidate) => this.isProviderSelectableFor(settings, candidate)); + if (nextProvider) { + logger.warn(`Provider-Kette: ${PROVIDER_LABELS[provider]} fehlgeschlagen (${errorText}), Fallback auf ${PROVIDER_LABELS[nextProvider]}`); + } else { + logger.warn(`Provider-Kette: ${PROVIDER_LABELS[provider]} fehlgeschlagen (${errorText}), kein weiterer Provider verfuegbar`); + } attempts.push(`${PROVIDER_LABELS[provider]}: ${compactErrorText(error)}`); } } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 7fa3d03..45b921d 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -21,7 +21,7 @@ import { UiSnapshot } from "../shared/types"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { addDebridLinkApiKeyDailyUsageBytes, addProviderDailyUsageBytes, getProviderUsageDayKey, isDebridLinkApiKeyDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; +import { addDebridLinkApiKeyDailyUsageBytes, addProviderDailyUsageBytes, getProviderUsageDayKey, 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 @@ -41,7 +41,7 @@ function releaseTlsSkip(): void { } } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; -import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo } from "./debrid"; +import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys } from "./debrid"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { logger } from "./logger"; @@ -459,6 +459,7 @@ function toWindowsLongPathIfNeeded(filePath: string): string { const SCENE_RELEASE_FOLDER_RE = /-(?:4sf|4sj)$/i; const SCENE_GROUP_SUFFIX_RE = /-(?=[A-Za-z0-9]{2,}$)(?=[A-Za-z0-9]*[A-Z])[A-Za-z0-9]+$/; const SCENE_EPISODE_RE = /(?:^|[._\-\s])s(\d{1,2})e(\d{1,3})(?:e(\d{1,3}))?(?:[._\-\s]|$)/i; +const SCENE_EPISODE_JOINED_RE = /s(\d{1,2})e(\d{1,3})(?:e(\d{1,3}))?(?:[._\-\s]|$)/i; const SCENE_SEASON_ONLY_RE = /(^|[._\-\s])s\d{1,2}(?=[._\-\s]|$)/i; const SCENE_SEASON_CAPTURE_RE = /(?:^|[._\-\s])s(\d{1,2})(?=[._\-\s]|$)/i; const SCENE_EPISODE_ONLY_RE = /(?:^|[._\-\s])e(?:p(?:isode)?)?\s*0*(\d{1,3})(?:[._\-\s]|$)/i; @@ -518,7 +519,8 @@ function hasSceneGroupSuffix(fileName: string): boolean { } export function extractEpisodeToken(fileName: string): string | null { - const match = String(fileName || "").match(SCENE_EPISODE_RE); + const text = String(fileName || ""); + const match = text.match(SCENE_EPISODE_RE) || text.match(SCENE_EPISODE_JOINED_RE); if (!match) { return null; } @@ -4483,7 +4485,7 @@ export class DownloadManager extends EventEmitter { } if (effectiveProvider === "debridlink") { const configuredKeys = parseDebridLinkApiKeys(this.settings.debridLinkApiKeys); - return configuredKeys.some((entry) => !isDebridLinkApiKeyDailyLimitReached(this.settings, entry.id)); + return configuredKeys.length > 0 && getAvailableDebridLinkApiKeys(this.settings).length > 0; } if (provider === "linksnappy") { return Boolean(this.settings.linkSnappyLogin.trim() && this.settings.linkSnappyPassword.trim()); diff --git a/src/main/main.ts b/src/main/main.ts index c232b80..5bf958c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -74,8 +74,8 @@ function isDevMode(): boolean { function createWindow(): BrowserWindow { const window = new BrowserWindow({ - width: 1440, - height: 940, + width: 1920, + height: 1080, minWidth: 1120, minHeight: 760, backgroundColor: "#070b14", @@ -94,7 +94,7 @@ function createWindow(): BrowserWindow { responseHeaders: { ...details.responseHeaders, "Content-Security-Policy": [ - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu https://git.24-music.de https://ddownload.com https://ddl.to" + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu https://git.24-music.de https://ddownload.com https://ddl.to https://debrid-link.com" ] } }); @@ -527,6 +527,10 @@ function registerIpcHandlers(): void { return controller.getAllDebridHostInfo(); }); + ipcMain.handle(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS, async () => { + return controller.getDebridLinkHostLimits(); + }); + ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => { const options = { properties: ["openFile"] as Array<"openFile">, diff --git a/src/main/storage.ts b/src/main/storage.ts index 03ea3af..bb0613a 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -196,6 +196,25 @@ function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Re return result; } +function normalizeStringList(raw: unknown, allowedKeys: readonly string[]): string[] { + if (!Array.isArray(raw)) { + return []; + } + + const allowed = new Set(allowedKeys); + const seen = new Set(); + const result: string[] = []; + for (const entry of raw) { + const normalized = String(entry || "").trim(); + if (!normalized || !allowed.has(normalized) || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + 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 = {}; @@ -287,6 +306,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { settings.debridLinkApiKeyDailyUsageBytes, debridLinkApiKeyIds ); + const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds); const normalized: AppSettings = { token: asText(settings.token), realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), @@ -303,6 +323,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { ddownloadPassword: asText(settings.ddownloadPassword), oneFichierApiKey: asText(settings.oneFichierApiKey), debridLinkApiKeys: String(settings.debridLinkApiKeys ?? "").replace(/\r\n|\r/g, "\n").trim(), + debridLinkDisabledKeyIds, linkSnappyLogin: asText(settings.linkSnappyLogin), linkSnappyPassword: asText(settings.linkSnappyPassword), archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"), diff --git a/src/preload/preload.ts b/src/preload/preload.ts index d34926d..91686f5 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -3,6 +3,7 @@ import { AddLinksPayload, AllDebridHostInfo, AppSettings, + DebridLinkHostLimitInfo, DebridProvider, DuplicatePolicy, HistoryEntry, @@ -59,6 +60,7 @@ const api: ElectronApi = { openAllDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), importBestDebridCookies: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), getAllDebridHostInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), + getDebridLinkHostLimits: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), extractNow: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), resetPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6c6ed20..b248d57 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6,6 +6,7 @@ import type { AppTheme, BandwidthScheduleEntry, DebridFallbackProvider, + DebridLinkHostLimitInfo, DebridProvider, DownloadItem, DownloadStats, @@ -105,7 +106,9 @@ interface AccountDialogState { interface DebridLinkAccountKeyEntry { id: string; label: string; + token: string; masked: string; + disabled: boolean; dailyUsedBytes: number; dailyLimitBytes: number; dailyRemainingBytes: number | null; @@ -691,6 +694,7 @@ const emptyStats = (): DownloadStats => ({ const emptySnapshot = (): UiSnapshot => ({ settings: { token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "", + debridLinkDisabledKeyIds: [], archivePasswordList: "", rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "", @@ -875,6 +879,39 @@ function formatAllDebridTimestamp(info: AllDebridHostInfo): string { return formatDateTime(info.lastCheckedAt || info.fetchedAt); } +function formatDebridLinkTraffic(info: DebridLinkHostLimitInfo | null | undefined): string { + if (!info) { + return "Lade..."; + } + const toGb = (bytes: number): string => `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + if (info.trafficCurrentBytes !== null && info.trafficMaxBytes !== null) { + return `${toGb(info.trafficCurrentBytes)} / ${toGb(info.trafficMaxBytes)}`; + } + if (info.trafficMaxBytes !== null) { + return `max. ${toGb(info.trafficMaxBytes)}`; + } + if (info.trafficCurrentBytes !== null) { + return toGb(info.trafficCurrentBytes); + } + return info.note || "Nicht verfügbar"; +} + +function formatDebridLinkCountQuota(info: DebridLinkHostLimitInfo | null | undefined): string { + if (!info) { + return "Lade..."; + } + if (info.linksCurrent !== null && info.linksMax !== null) { + return `${info.linksCurrent} / ${info.linksMax}`; + } + if (info.linksMax !== null) { + return `max. ${info.linksMax}`; + } + if (info.linksCurrent !== null) { + return String(info.linksCurrent); + } + return info.note || "Nicht verfügbar"; +} + interface BandwidthChartProps { items: Record; running: boolean; @@ -1254,6 +1291,10 @@ export function App(): ReactElement { const [linkPopup, setLinkPopup] = useState(null); const [accountDialog, setAccountDialog] = useState(null); const [accountDialogSearch, setAccountDialogSearch] = useState(""); + const [keyStatsPopup, setKeyStatsPopup] = useState(null); + const [debridLinkHostLimits, setDebridLinkHostLimits] = useState>({}); + const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false); + const [debridLinkHostLimitsError, setDebridLinkHostLimitsError] = useState(""); const [selectedIds, setSelectedIds] = useState>(new Set()); const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set; dontAsk: boolean } | null>(null); const [columnOrder, setColumnOrder] = useState(() => DEFAULT_COLUMN_ORDER); @@ -1271,6 +1312,7 @@ export function App(): ReactElement { const [allDebridHostInfo, setAllDebridHostInfo] = useState(null); const [allDebridHostLoading, setAllDebridHostLoading] = useState(false); const allDebridHostRequestRef = useRef(0); + const debridLinkHostLimitsRequestRef = useRef(0); const accountColumnResizeRef = useRef<{ key: AccountColumnKey; startX: number; startWidth: number } | null>(null); const onAccountColumnResizeMove = useCallback((event: MouseEvent): void => { const active = accountColumnResizeRef.current; @@ -1435,6 +1477,149 @@ export function App(): ReactElement { } }, [showToast]); + const loadDebridLinkHostLimits = useCallback(async (silent = false): Promise => { + const requestId = debridLinkHostLimitsRequestRef.current + 1; + debridLinkHostLimitsRequestRef.current = requestId; + setDebridLinkHostLimitsLoading(true); + setDebridLinkHostLimitsError(""); + setDebridLinkHostLimits({}); + try { + const apiKeys = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || ""); + if (apiKeys.length === 0) { + throw new Error("Debrid-Link ist nicht konfiguriert"); + } + + let loadedAny = false; + let firstError = ""; + for (let index = 0; index < apiKeys.length; index += 1) { + if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) { + return; + } + + const apiKey = apiKeys[index]; + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 8000); + let info: DebridLinkHostLimitInfo; + try { + const readLimitsPayload = async (path: "limits" | "limits/all") => { + const response = await fetch(`https://debrid-link.com/api/v2/downloader/${path}`, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey.token}` + }, + signal: controller.signal + }); + const payload = await response.json() as { + success?: boolean; + value?: { + hosters?: Array<{ + name?: string; + displayName?: string; + daySize?: { current?: number; value?: number }; + dayCount?: { current?: number; value?: number }; + }>; + }; + error?: string; + error_description?: string; + }; + if (!response.ok || !payload?.success) { + throw new Error(String(payload?.error_description || payload?.error || `HTTP ${response.status}`)); + } + return payload; + }; + + let payload = await readLimitsPayload("limits/all"); + let hostEntry = (payload.value?.hosters || []).find((entry) => String(entry.name || "").toLowerCase() === "rapidgator"); + if (!hostEntry) { + payload = await readLimitsPayload("limits"); + hostEntry = (payload.value?.hosters || []).find((entry) => String(entry.name || "").toLowerCase() === "rapidgator"); + } + if (!hostEntry) { + info = { + keyId: apiKey.id, + keyLabel: apiKey.label, + host: "rapidgator", + fetchedAt: Date.now(), + trafficCurrentBytes: null, + trafficMaxBytes: null, + linksCurrent: null, + linksMax: null, + note: "Rapidgator nicht in der API-Antwort gefunden." + }; + } else { + info = { + keyId: apiKey.id, + keyLabel: apiKey.label, + host: String(hostEntry.displayName || hostEntry.name || "rapidgator"), + fetchedAt: Date.now(), + trafficCurrentBytes: typeof hostEntry.daySize?.current === "number" ? hostEntry.daySize.current : null, + trafficMaxBytes: typeof hostEntry.daySize?.value === "number" ? hostEntry.daySize.value : null, + linksCurrent: typeof hostEntry.dayCount?.current === "number" ? hostEntry.dayCount.current : null, + linksMax: typeof hostEntry.dayCount?.value === "number" ? hostEntry.dayCount.value : null, + note: "" + }; + } + } catch (error) { + const message = String(error || "Quota konnte nicht geladen werden"); + if (!firstError) { + firstError = message; + } + info = { + keyId: apiKey.id, + keyLabel: apiKey.label, + host: "rapidgator", + fetchedAt: Date.now(), + trafficCurrentBytes: null, + trafficMaxBytes: null, + linksCurrent: null, + linksMax: null, + note: message + }; + } finally { + window.clearTimeout(timer); + } + + if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) { + return; + } + + loadedAny = true; + setDebridLinkHostLimits((prev) => ({ + ...prev, + [info.keyId]: info + })); + + } + + if (!loadedAny && firstError) { + throw new Error(firstError); + } + } catch (error) { + if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) { + return; + } + setDebridLinkHostLimits({}); + setDebridLinkHostLimitsError(String(error)); + if (!silent) { + showToast(`Debrid-Link Quota fehlgeschlagen: ${String(error)}`, 3200); + } + } finally { + if (mountedRef.current && debridLinkHostLimitsRequestRef.current === requestId) { + setDebridLinkHostLimitsLoading(false); + } + } + }, [settingsDraft.debridLinkApiKeys, showToast]); + + useEffect(() => { + if (keyStatsPopup !== "debridlink") { + setDebridLinkHostLimits({}); + setDebridLinkHostLimitsError(""); + setDebridLinkHostLimitsLoading(false); + return; + } + void loadDebridLinkHostLimits(true); + }, [keyStatsPopup, loadDebridLinkHostLimits]); + const clearImportQueueFocusListener = useCallback((): void => { const handler = importQueueFocusHandlerRef.current; if (!handler) { @@ -1764,7 +1949,7 @@ export function App(): ReactElement { continue; } const option = findAccountOption(kind); - let statusLabel = "Konfiguriert"; + let statusLabel = "Aktiviert"; let note = ""; if (kind === "megadebrid-api") { note = "Nur API aktiv. Kein Web-Fallback."; @@ -1790,7 +1975,7 @@ export function App(): ReactElement { } if (kind === "debridlink-api") { const keyCount = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").length; - statusLabel = keyCount > 1 ? `${keyCount} API-Keys` : "Konfiguriert"; + statusLabel = keyCount > 1 ? `${keyCount} API-Keys` : "Aktiviert"; } const provider = getAccountServiceProvider(service); const dailyUsedBytes = getProviderDailyUsageBytes(snapshot.settings, provider); @@ -1816,7 +2001,9 @@ export function App(): ReactElement { return { id: key.id, label: key.label, + token: key.token, masked: key.masked, + disabled: (settingsDraft.debridLinkDisabledKeyIds || []).includes(key.id), dailyUsedBytes: keyDailyUsedBytes, dailyLimitBytes: keyDailyLimitBytes, dailyRemainingBytes: keyDailyRemainingBytes, @@ -1826,11 +2013,19 @@ export function App(): ReactElement { : []; if (kind === "debridlink-api" && debridLinkKeys.length > 0) { const limitedCount = debridLinkKeys.filter((entry) => entry.dailyLimitReached).length; + const disabledKeyCount = debridLinkKeys.filter((entry) => entry.disabled).length; + const keyNotes: string[] = []; if (limitedCount > 0) { - const limitNote = `${limitedCount}/${debridLinkKeys.length} API-Keys am Limit.`; - note = note ? `${limitNote} ${note}` : limitNote; + keyNotes.push(`${limitedCount}/${debridLinkKeys.length} API-Keys am Limit.`); } - if (limitedCount === debridLinkKeys.length) { + if (disabledKeyCount > 0) { + keyNotes.push(`${disabledKeyCount}/${debridLinkKeys.length} API-Keys deaktiviert.`); + } + if (keyNotes.length > 0) { + const combinedKeyNote = keyNotes.join(" "); + note = note ? `${combinedKeyNote} ${note}` : combinedKeyNote; + } + if (debridLinkKeys.every((entry) => entry.disabled || entry.dailyLimitReached)) { dailyLimitReached = true; } } @@ -2185,6 +2380,28 @@ export function App(): ReactElement { }); }; + const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): Promise => { + await performQuickAction(async () => { + const currentDisabledIds = settingsDraft.debridLinkDisabledKeyIds || []; + const nextDisabledIds = key.disabled + ? currentDisabledIds.filter((existingId) => existingId !== key.id) + : [...currentDisabledIds, key.id]; + const nextDraft: AppSettings = { + ...settingsDraft, + debridLinkDisabledKeyIds: nextDisabledIds + }; + await persistSpecificSettings(nextDraft); + showToast( + key.disabled + ? `${entry.serviceLabel} ${key.label} aktiviert` + : `${entry.serviceLabel} ${key.label} deaktiviert`, + 2200 + ); + }, (error) => { + showToast(`${entry.serviceLabel} ${key.label}: Umschalten fehlgeschlagen: ${String(error)}`, 3200); + }); + }; + const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise => { const meta = getAccountQuickActionMeta(entry.kind); if (!meta) { @@ -4005,6 +4222,9 @@ export function App(): ReactElement { }} /> +
+ Info +
Zugang
- Aktionen +
+ Aktionen +
{configuredAccounts.map((entry) => { const option = findAccountOption(entry.kind); @@ -4034,46 +4256,21 @@ export function App(): ReactElement { {entry.modeLabel}
- {entry.statusLabel} + {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.debridLinkKeys.length > 0 ? ( + + ) : ( +
+ 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)}`} + )}
)}
@@ -5016,6 +5213,91 @@ export function App(): ReactElement {
); })()} + {keyStatsPopup && (() => { + const entry = configuredAccounts.find((a) => a.service === keyStatsPopup); + if (!entry || entry.debridLinkKeys.length === 0) return null; + const totalUsed = entry.debridLinkKeys.reduce((s, k) => s + k.dailyUsedBytes, 0); + const limitedCount = entry.debridLinkKeys.filter((k) => k.dailyLimitReached).length; + const disabledCount = entry.debridLinkKeys.filter((k) => k.disabled).length; + const loadedQuotaCount = entry.debridLinkKeys.filter((k) => Boolean(debridLinkHostLimits[k.id])).length; + return ( +
setKeyStatsPopup(null)}> +
e.stopPropagation()}> +
+
+

API-Key Statistik

+

+ {entry.debridLinkKeys.length} Keys · Heute: {humanSize(totalUsed)} + {limitedCount > 0 && · {limitedCount} am Limit} + {disabledCount > 0 && · {disabledCount} deaktiviert} + {debridLinkHostLimitsLoading && · Rapidgator-Quota wird geladen ({loadedQuotaCount}/{entry.debridLinkKeys.length})} + {!debridLinkHostLimitsLoading && !debridLinkHostLimitsError && · Rapidgator API-Quota} + {debridLinkHostLimitsError && · API-Quota konnte nicht geladen werden} +

+
+ +
+
+
+ # + Key + Heute + Lokal + RG Traffic + RG Links + +
+ {entry.debridLinkKeys.map((key, ki) => ( +
+ {(() => { + const hostInfo = debridLinkHostLimits[key.id]; + return ( + <> + {ki + 1} + { + void navigator.clipboard.writeText(key.token) + .then(() => showToast(`${key.label} kopiert`, 1800)) + .catch(() => showToast("Kopieren fehlgeschlagen", 2200)); + }} + > + {key.masked} + + {humanSize(key.dailyUsedBytes)} + {key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"} + {formatDebridLinkTraffic(hostInfo)} + {formatDebridLinkCountQuota(hostInfo)} + + + + + + ); + })()} +
+ ))} +
+
+ +
+
+
+ ); + })()} {linkPopup && (
setLinkPopup(null)}>
e.stopPropagation()}> diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 8ed5ecc..cba7e54 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -482,11 +482,21 @@ body, color: #fda4af; } +.btn.success { + border-color: rgba(74, 222, 128, 0.7); + color: #86efac; +} + :root[data-theme="light"] .btn.danger { border-color: color-mix(in srgb, var(--danger) 60%, transparent); color: var(--danger); } +:root[data-theme="light"] .btn.success { + border-color: color-mix(in srgb, #16a34a 60%, transparent); + color: #15803d; +} + .btn.btn-active { border-color: var(--accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent); @@ -1198,7 +1208,9 @@ body, --account-col-service: 220px; --account-col-mode: 96px; --account-col-status: 300px; + --account-col-info: 220px; --account-col-secret: 180px; + --account-col-actions: 400px; width: 100%; } @@ -1206,30 +1218,41 @@ body, .account-row { display: grid; grid-template-columns: - minmax(180px, var(--account-col-service)) - minmax(80px, var(--account-col-mode)) - minmax(180px, var(--account-col-status)) - minmax(120px, var(--account-col-secret)) - minmax(260px, 1fr); + minmax(130px, var(--account-col-service)) + minmax(72px, var(--account-col-mode)) + minmax(140px, var(--account-col-status)) + minmax(160px, var(--account-col-info)) + minmax(180px, var(--account-col-secret)) + minmax(280px, var(--account-col-actions)); gap: 10px; align-items: center; width: 100%; } .account-table-head { - padding: 0 4px; + padding: 6px 12px; color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; + align-items: center; +} + +.account-table-head > *:nth-child(n+2) { + border-left: 2px solid color-mix(in srgb, var(--border) 60%, transparent); + padding-left: 10px; + padding-right: 10px; + box-sizing: border-box; } .account-header-cell { position: relative; display: flex; align-items: center; + justify-content: center; min-width: 0; + width: 100%; } .account-header-cell > span { @@ -1248,9 +1271,11 @@ body, background: transparent; cursor: col-resize; padding: 0; + z-index: 2; } -.account-resize-handle::after { +.account-resize-handle:hover::after, +.account-resize-handle:focus-visible::after { content: ""; position: absolute; top: 6px; @@ -1259,17 +1284,11 @@ body, width: 2px; transform: translateX(-50%); border-radius: 999px; - background: color-mix(in srgb, var(--border) 92%, transparent); - transition: background 0.12s ease; -} - -.account-resize-handle:hover::after, -.account-resize-handle:focus-visible::after { background: var(--accent); } .account-row { - padding: 12px; + padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); border-radius: 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--card) 97%, transparent), color-mix(in srgb, var(--surface) 96%, transparent)); @@ -1278,32 +1297,74 @@ body, .account-cell { min-width: 0; overflow-wrap: anywhere; + text-align: center; + display: flex; + align-items: center; + justify-content: center; } -.account-service-cell, -.account-status-cell { +.account-row > .account-cell:nth-child(n+2) { + border-left: 2px solid color-mix(in srgb, var(--border) 60%, transparent); + padding-left: 10px; + padding-right: 10px; + box-sizing: border-box; +} + +.account-row > .account-cell.account-row-actions { + justify-content: center; +} + +.account-service-cell { display: grid; gap: 3px; + justify-items: center; + align-content: center; +} + +.account-status-cell { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 4px 8px; +} + +.account-info-cell { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; } .account-service-cell strong { font-size: 14px; } -.account-service-cell span, -.account-note { +.account-service-cell span { color: var(--muted); font-size: 12px; line-height: 1.4; } +.account-note { + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} + .account-usage-stats { display: flex; - flex-wrap: wrap; - gap: 6px 10px; + flex-wrap: nowrap; + align-items: center; + gap: 4px; color: var(--muted); - font-size: 12px; - line-height: 1.4; + font-size: 11px; + white-space: nowrap; +} + +.account-usage-stats span + span::before { + content: "·"; + margin-right: 4px; } .account-usage-stats.warning { @@ -1335,6 +1396,12 @@ body, color: var(--text); } +.account-status-pill.account-status-disabled { + background: color-mix(in srgb, var(--danger) 12%, transparent); + border-color: color-mix(in srgb, var(--danger) 40%, transparent); + color: color-mix(in srgb, var(--danger) 75%, var(--text)); +} + .account-status-pill.account-status-down { background: color-mix(in srgb, var(--danger) 12%, transparent); border-color: color-mix(in srgb, var(--danger) 40%, transparent); @@ -1349,7 +1416,7 @@ body, .account-secret { display: inline-flex; align-items: center; - width: 100%; + justify-content: center; min-height: 34px; padding: 0 12px; border-radius: 12px; @@ -1366,91 +1433,178 @@ body, .account-row-actions { display: flex; - justify-content: flex-start; - align-items: flex-start; + justify-content: center; + align-items: center; gap: 6px; flex-wrap: nowrap; + align-content: center; + text-align: center; } .account-row-actions .btn { padding: 5px 8px; font-size: 11px; white-space: nowrap; + min-width: 82px; + justify-content: center; } .account-row-disabled { - opacity: 0.45; + opacity: 0.65; } .account-row-disabled .account-row-actions { 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 { +.key-stats-popup { + width: min(1360px, calc(100vw - 20px)); + max-width: min(1360px, calc(100vw - 20px)); + max-height: calc(100vh - 24px); + overflow: hidden; display: flex; - align-items: baseline; - gap: 8px; - min-width: 0; + flex-direction: column; + gap: 12px; } -.account-subkey-head strong { +.modal-card.key-stats-popup { + width: min(1360px, calc(100vw - 20px)); + max-width: min(1360px, calc(100vw - 20px)); +} + +.key-stats-popup-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.key-stats-popup-header h3 { + margin: 0; + font-size: 14px; +} + +.key-stats-summary { + margin: 2px 0 0; font-size: 12px; - flex: 0 0 auto; + color: var(--muted); } -.account-subkey-head span { - color: var(--muted); +.key-stats-warn { + color: #f59e0b; +} + +.key-stats-popup .account-subkey-table { + overflow-y: auto; + max-height: calc(100vh - 140px); +} + +.account-subkey-table { + margin-top: 6px; + border: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + border-radius: 8px; + overflow: hidden; font-size: 11px; +} + +.account-subkey-table-head, +.account-subkey-table-row { + display: grid; + grid-template-columns: 24px minmax(340px, 1fr) 72px 96px 180px 110px 168px; + align-items: center; + padding: 3px 8px; + gap: 10px; +} + +.account-subkey-table-head { + background: color-mix(in srgb, var(--field) 90%, transparent); + color: var(--muted); + font-weight: 600; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.03em; + border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + padding: 4px 8px; +} + +.account-subkey-table-head .col-usage, +.account-subkey-table-head .col-limit, +.account-subkey-table-head .col-traffic, +.account-subkey-table-head .col-links, +.account-subkey-table-row .col-usage, +.account-subkey-table-row .col-limit, +.account-subkey-table-row .col-traffic, +.account-subkey-table-row .col-links { + text-align: right; + justify-self: center; +} + +.account-subkey-table-row { + border-bottom: 1px solid color-mix(in srgb, var(--border) 30%, transparent); +} + +.account-subkey-table-row:last-child { + border-bottom: none; +} + +.account-subkey-table-row.warning { + background: color-mix(in srgb, #f59e0b 8%, transparent); +} + +.account-subkey-table-row.disabled { + background: color-mix(in srgb, var(--field) 45%, transparent); +} + +.account-subkey-table-row .col-key { + font-weight: 600; + color: var(--muted); +} + +.account-subkey-table-row .col-masked { font-family: "JetBrains Mono", "Consolas", monospace; + color: var(--muted); 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-table-row .col-masked.link-popup-click { + display: block; } -.account-subkey-actions { +.account-subkey-table-row .col-usage { + text-align: center; +} + +.account-subkey-table-row .col-limit { + text-align: center; + color: var(--muted); +} + +.account-subkey-table-row .col-traffic, +.account-subkey-table-row .col-links { + text-align: center; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.account-subkey-table-row .col-action { display: flex; justify-content: flex-end; + gap: 6px; } -.account-subkey-actions .btn { - padding: 4px 8px; +.account-subkey-table-row .col-action .btn { + padding: 1px 6px; + font-size: 10px; +} + +.btn-sm { + padding: 3px 8px; font-size: 11px; } @@ -2708,7 +2862,13 @@ td { justify-content: flex-start; } - .account-subkey-row, + .account-subkey-table-head, + .account-subkey-table-row { + grid-template-columns: 24px minmax(0, 1fr) 64px 56px 44px; + font-size: 10px; + padding: 2px 6px; + } + .account-dl-key-limit-row { grid-template-columns: 1fr; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 952a1c0..0f95cf5 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -40,6 +40,7 @@ export const IPC_CHANNELS = { OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login", IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies", GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info", + GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits", RETRY_EXTRACTION: "queue:retry-extraction", EXTRACT_NOW: "queue:extract-now", RESET_PACKAGE: "queue:reset-package", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index a3fccd2..2427b8d 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -2,6 +2,7 @@ import type { AddLinksPayload, AllDebridHostInfo, AppSettings, + DebridLinkHostLimitInfo, DebridProvider, DuplicatePolicy, HistoryEntry, @@ -54,6 +55,7 @@ export interface ElectronApi { openAllDebridLogin: () => Promise; importBestDebridCookies: () => Promise; getAllDebridHostInfo: () => Promise; + getDebridLinkHostLimits: () => Promise; retryExtraction: (packageId: string) => Promise; extractNow: (packageId: string) => Promise; resetPackage: (packageId: string) => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index b2236a1..805396f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -62,6 +62,7 @@ export interface AppSettings { ddownloadPassword: string; oneFichierApiKey: string; debridLinkApiKeys: string; + debridLinkDisabledKeyIds: string[]; linkSnappyLogin: string; linkSnappyPassword: string; archivePasswordList: string; @@ -290,6 +291,18 @@ export interface AllDebridHostInfo { note: string; } +export interface DebridLinkHostLimitInfo { + keyId: string; + keyLabel: string; + host: string; + fetchedAt: number; + trafficCurrentBytes: number | null; + trafficMaxBytes: number | null; + linksCurrent: number | null; + linksMax: number | null; + note: string; +} + export interface ParsedHashEntry { fileName: string; algorithm: "crc32" | "md5" | "sha1"; diff --git a/tests/auto-rename.test.ts b/tests/auto-rename.test.ts index a67c793..c732c88 100644 --- a/tests/auto-rename.test.ts +++ b/tests/auto-rename.test.ts @@ -78,6 +78,10 @@ describe("extractEpisodeToken", () => { it("extracts double episode with single-digit numbers", () => { expect(extractEpisodeToken("show-s1e1e2-720p")).toBe("S01E01E02"); }); + + it("extracts episode when title and season token are joined", () => { + expect(extractEpisodeToken("mdgp-carters02e01-720p")).toBe("S02E01"); + }); }); describe("applyEpisodeTokenToFolderName", () => { @@ -691,4 +695,13 @@ describe("buildAutoRenameBaseNameFromFolders", () => { ); expect(result).toBe("Room.104.S04E01.GERMAN.DL.720p.WEBRiP.x264-LAW"); }); + + it("renames Carter when source joins title and season token", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + ["Carter.S02.GERMAN.DL.720p.HDTV.x264-MDGP"], + "mdgp-carters02e01-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Carter.S02E01.GERMAN.DL.720p.HDTV.x264-MDGP"); + }); }); diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index b6ad2e9..2dd05b2 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -2,7 +2,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"; +import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, normalizeResolvedFilename } from "../src/main/debrid"; const originalFetch = globalThis.fetch; @@ -379,6 +379,39 @@ describe("debrid service", () => { expect(info.limitSimuDl).toBe(2); }); + it("loads Debrid-Link rapidgator limits per api key", async () => { + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) { + return new Response(JSON.stringify({ + success: true, + value: { + hosters: [ + { + name: "rapidgator", + daySize: { current: 0, value: 150323855360 }, + dayCount: { current: 0, value: 500 } + } + ] + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const info = await fetchDebridLinkHostLimits("key-a", "rapidgator"); + expect(info).toHaveLength(1); + expect(info[0].keyLabel).toBe("Key 1"); + expect(info[0].host).toBe("rapidgator"); + expect(info[0].trafficCurrentBytes).toBe(0); + expect(info[0].trafficMaxBytes).toBe(150323855360); + expect(info[0].linksCurrent).toBe(0); + expect(info[0].linksMax).toBe(500); + }); + it("uses AllDebrid web path when enabled", async () => { const settings = { ...defaultSettings(),