Release v1.7.1

This commit is contained in:
Sucukdeluxe 2026-03-07 03:52:41 +01:00
parent 576be53b83
commit 7737a4b0da
16 changed files with 831 additions and 134 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.6.90", "version": "1.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.6.90", "version": "1.7.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.0", "version": "1.7.1",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -22,7 +22,7 @@ import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../sha
import { importDlcContainers } from "./container"; import { importDlcContainers } from "./container";
import { APP_VERSION } from "./constants"; import { APP_VERSION } from "./constants";
import { DownloadManager } from "./download-manager"; import { DownloadManager } from "./download-manager";
import { fetchAllDebridHostInfo } from "./debrid"; import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
import { parseCollectorInput } from "./link-parser"; import { parseCollectorInput } from "./link-parser";
import { configureLogger, getLogFilePath, logger } from "./logger"; import { configureLogger, getLogFilePath, logger } from "./logger";
import { AllDebridWebFallback } from "./all-debrid-web"; import { AllDebridWebFallback } from "./all-debrid-web";
@ -248,6 +248,10 @@ export class AppController {
return fetchAllDebridHostInfo(token, host); return fetchAllDebridHostInfo(token, host);
} }
public async getDebridLinkHostLimits(host = "rapidgator") {
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
}
public async checkUpdates(): Promise<UpdateCheckResult> { public async checkUpdates(): Promise<UpdateCheckResult> {
const result = await checkGitHubUpdate(this.settings.updateRepo); const result = await checkGitHubUpdate(this.settings.updateRepo);
if (!result.error) { if (!result.error) {

View File

@ -56,6 +56,7 @@ export function defaultSettings(): AppSettings {
ddownloadPassword: "", ddownloadPassword: "",
oneFichierApiKey: "", oneFichierApiKey: "",
debridLinkApiKeys: "", debridLinkApiKeys: "",
debridLinkDisabledKeyIds: [],
linkSnappyLogin: "", linkSnappyLogin: "",
linkSnappyPassword: "", linkSnappyPassword: "",
archivePasswordList: "", archivePasswordList: "",

View File

@ -1,5 +1,5 @@
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; 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 { isDebridLinkApiKeyDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits";
import { APP_VERSION, REQUEST_RETRIES } from "./constants"; import { APP_VERSION, REQUEST_RETRIES } from "./constants";
import { logger } from "./logger"; import { logger } from "./logger";
@ -68,6 +68,7 @@ function cloneSettings(settings: AppSettings): AppSettings {
return { return {
...settings, ...settings,
bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })), bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })),
debridLinkDisabledKeyIds: [...(settings.debridLinkDisabledKeyIds || [])],
providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) }, providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) },
providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) }, providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) },
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }, 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( 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"; return "Unbekannt";
} }
function normalizeDebridLinkHostKey(value: string): string {
return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
}
function parseDebridLinkSuccess(payload: Record<string, unknown> | null): boolean {
if (!payload) {
return false;
}
if (typeof payload.success === "boolean") {
return payload.success;
}
return pickString(payload, ["result"]).toUpperCase() === "OK";
}
function parseDebridLinkHosters(payload: Record<string, unknown> | null): Record<string, unknown>[] {
const value = asRecord(payload?.value);
const hosters = value?.hosters ?? payload?.hosters;
if (Array.isArray(hosters)) {
return hosters.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry))).map((entry) => entry as Record<string, unknown>);
}
return [];
}
function findDebridLinkHostEntry(payload: Record<string, unknown> | null, host: string): Record<string, unknown> | 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<DebridLinkHostLimitInfo> {
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[] { function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] {
const seen = new Set<DebridProvider>(); const seen = new Set<DebridProvider>();
const result: DebridProvider[] = []; const result: DebridProvider[] = [];
@ -1314,6 +1439,19 @@ export async function fetchAllDebridHostInfo(token: string, host = "rapidgator",
return new AllDebridClient(token).getHostInfo(host, signal); return new AllDebridClient(token).getHostInfo(host, signal);
} }
export async function fetchDebridLinkHostLimits(apiKeysRaw: string, host = "rapidgator", signal?: AbortSignal): Promise<DebridLinkHostLimitInfo[]> {
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 ── // ── Debrid-Link Client ──
class DebridLinkClient { class DebridLinkClient {
@ -1330,7 +1468,7 @@ class DebridLinkClient {
} }
if (getAvailableDebridLinkApiKeys(settings).length === 0) { 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; let checkedKeys = 0;
@ -1338,7 +1476,13 @@ class DebridLinkClient {
const apiKey = this.apiKeys[this.currentKeyIndex]; const apiKey = this.apiKeys[this.currentKeyIndex];
checkedKeys += 1; checkedKeys += 1;
const keyLabel = this.apiKeys.length > 1 ? ` (${apiKey.label})` : ""; 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)) { 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; this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length;
continue; continue;
} }
@ -1364,6 +1508,7 @@ class DebridLinkClient {
const errorDesc = String(json.error_description || json.error || "Unbekannter Debrid-Link-Fehler"); const errorDesc = String(json.error_description || json.error || "Unbekannter Debrid-Link-Fehler");
if (DEBRID_LINK_QUOTA_ERRORS.has(errorCode)) { 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}`); logger.warn(`Debrid-Link Quota erreicht${keyLabel}: ${errorCode} ${errorDesc}`);
break; break;
} }
@ -1411,6 +1556,7 @@ class DebridLinkClient {
if (/Ungültig|abgelaufen/i.test(lastError)) { if (/Ungültig|abgelaufen/i.test(lastError)) {
throw error; throw error;
} }
logger.warn(`Debrid-Link${keyLabel}: Fehler bei Unrestrict-Versuch ${attempt}/${REQUEST_RETRIES}: ${lastError}`);
if (attempt < REQUEST_RETRIES) { if (attempt < REQUEST_RETRIES) {
await sleep(retryDelay(attempt), signal); await sleep(retryDelay(attempt), signal);
} }
@ -1418,9 +1564,14 @@ class DebridLinkClient {
} }
this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length; 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 { private formatProviderLimitMessage(settings: AppSettings, provider: DebridProvider): string {
const effectiveProvider = resolveMegaDebridProvider(settings, provider); const effectiveProvider = resolveMegaDebridProvider(settings, provider);
if (effectiveProvider === "debridlink" && parseDebridLinkApiKeys(settings.debridLinkApiKeys).length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) { 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`; return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
} }
@ -2084,11 +2235,13 @@ export class DebridService {
configuredFound = true; configuredFound = true;
if (this.isProviderDailyLimited(settings, provider)) { if (this.isProviderDailyLimited(settings, provider)) {
limitReachedFound = true; limitReachedFound = true;
logger.info(`Provider-Kette: ${PROVIDER_LABELS[provider]} uebersprungen (${this.formatProviderLimitMessage(settings, provider)})`);
attempts.push(this.formatProviderLimitMessage(settings, provider)); attempts.push(this.formatProviderLimitMessage(settings, provider));
continue; continue;
} }
try { try {
logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`);
const result = await this.unrestrictViaProvider(settings, provider, link, signal); const result = await this.unrestrictViaProvider(settings, provider, link, signal);
let fileName = result.fileName; let fileName = result.fileName;
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { 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))) { if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error; 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)}`); attempts.push(`${PROVIDER_LABELS[provider]}: ${compactErrorText(error)}`);
} }
} }

View File

@ -21,7 +21,7 @@ import {
UiSnapshot UiSnapshot
} from "../shared/types"; } from "../shared/types";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; 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"; 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 // 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 { 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 { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree } from "./extractor";
import { validateFileAgainstManifest } from "./integrity"; import { validateFileAgainstManifest } from "./integrity";
import { logger } from "./logger"; import { logger } from "./logger";
@ -459,6 +459,7 @@ function toWindowsLongPathIfNeeded(filePath: string): string {
const SCENE_RELEASE_FOLDER_RE = /-(?:4sf|4sj)$/i; 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_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_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_ONLY_RE = /(^|[._\-\s])s\d{1,2}(?=[._\-\s]|$)/i;
const SCENE_SEASON_CAPTURE_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; 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 { 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) { if (!match) {
return null; return null;
} }
@ -4483,7 +4485,7 @@ export class DownloadManager extends EventEmitter {
} }
if (effectiveProvider === "debridlink") { if (effectiveProvider === "debridlink") {
const configuredKeys = parseDebridLinkApiKeys(this.settings.debridLinkApiKeys); 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") { if (provider === "linksnappy") {
return Boolean(this.settings.linkSnappyLogin.trim() && this.settings.linkSnappyPassword.trim()); return Boolean(this.settings.linkSnappyLogin.trim() && this.settings.linkSnappyPassword.trim());

View File

@ -74,8 +74,8 @@ function isDevMode(): boolean {
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
const window = new BrowserWindow({ const window = new BrowserWindow({
width: 1440, width: 1920,
height: 940, height: 1080,
minWidth: 1120, minWidth: 1120,
minHeight: 760, minHeight: 760,
backgroundColor: "#070b14", backgroundColor: "#070b14",
@ -94,7 +94,7 @@ function createWindow(): BrowserWindow {
responseHeaders: { responseHeaders: {
...details.responseHeaders, ...details.responseHeaders,
"Content-Security-Policy": [ "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(); return controller.getAllDebridHostInfo();
}); });
ipcMain.handle(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS, async () => {
return controller.getDebridLinkHostLimits();
});
ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => { ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => {
const options = { const options = {
properties: ["openFile"] as Array<"openFile">, properties: ["openFile"] as Array<"openFile">,

View File

@ -196,6 +196,25 @@ function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Re
return result; return result;
} }
function normalizeStringList(raw: unknown, allowedKeys: readonly string[]): string[] {
if (!Array.isArray(raw)) {
return [];
}
const allowed = new Set(allowedKeys);
const seen = new Set<string>();
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<string, DebridProvider> { function normalizeHosterRouting(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): Record<string, DebridProvider> {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const result: Record<string, DebridProvider> = {}; const result: Record<string, DebridProvider> = {};
@ -287,6 +306,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
settings.debridLinkApiKeyDailyUsageBytes, settings.debridLinkApiKeyDailyUsageBytes,
debridLinkApiKeyIds debridLinkApiKeyIds
); );
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
const normalized: AppSettings = { const normalized: AppSettings = {
token: asText(settings.token), token: asText(settings.token),
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
@ -303,6 +323,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
ddownloadPassword: asText(settings.ddownloadPassword), ddownloadPassword: asText(settings.ddownloadPassword),
oneFichierApiKey: asText(settings.oneFichierApiKey), oneFichierApiKey: asText(settings.oneFichierApiKey),
debridLinkApiKeys: String(settings.debridLinkApiKeys ?? "").replace(/\r\n|\r/g, "\n").trim(), debridLinkApiKeys: String(settings.debridLinkApiKeys ?? "").replace(/\r\n|\r/g, "\n").trim(),
debridLinkDisabledKeyIds,
linkSnappyLogin: asText(settings.linkSnappyLogin), linkSnappyLogin: asText(settings.linkSnappyLogin),
linkSnappyPassword: asText(settings.linkSnappyPassword), linkSnappyPassword: asText(settings.linkSnappyPassword),
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"), archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"),

View File

@ -3,6 +3,7 @@ import {
AddLinksPayload, AddLinksPayload,
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
@ -59,6 +60,7 @@ const api: ElectronApi = {
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),

View File

@ -6,6 +6,7 @@ import type {
AppTheme, AppTheme,
BandwidthScheduleEntry, BandwidthScheduleEntry,
DebridFallbackProvider, DebridFallbackProvider,
DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
DownloadItem, DownloadItem,
DownloadStats, DownloadStats,
@ -105,7 +106,9 @@ interface AccountDialogState {
interface DebridLinkAccountKeyEntry { interface DebridLinkAccountKeyEntry {
id: string; id: string;
label: string; label: string;
token: string;
masked: string; masked: string;
disabled: boolean;
dailyUsedBytes: number; dailyUsedBytes: number;
dailyLimitBytes: number; dailyLimitBytes: number;
dailyRemainingBytes: number | null; dailyRemainingBytes: number | null;
@ -691,6 +694,7 @@ const emptyStats = (): DownloadStats => ({
const emptySnapshot = (): UiSnapshot => ({ const emptySnapshot = (): UiSnapshot => ({
settings: { settings: {
token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "", 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: "", archivePasswordList: "",
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "", providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "",
@ -875,6 +879,39 @@ function formatAllDebridTimestamp(info: AllDebridHostInfo): string {
return formatDateTime(info.lastCheckedAt || info.fetchedAt); 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 { interface BandwidthChartProps {
items: Record<string, DownloadItem>; items: Record<string, DownloadItem>;
running: boolean; running: boolean;
@ -1254,6 +1291,10 @@ export function App(): ReactElement {
const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null); const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null);
const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null); const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null);
const [accountDialogSearch, setAccountDialogSearch] = useState(""); const [accountDialogSearch, setAccountDialogSearch] = useState("");
const [keyStatsPopup, setKeyStatsPopup] = useState<string | null>(null);
const [debridLinkHostLimits, setDebridLinkHostLimits] = useState<Record<string, DebridLinkHostLimitInfo>>({});
const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false);
const [debridLinkHostLimitsError, setDebridLinkHostLimitsError] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set<string>; dontAsk: boolean } | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set<string>; dontAsk: boolean } | null>(null);
const [columnOrder, setColumnOrder] = useState<string[]>(() => DEFAULT_COLUMN_ORDER); const [columnOrder, setColumnOrder] = useState<string[]>(() => DEFAULT_COLUMN_ORDER);
@ -1271,6 +1312,7 @@ export function App(): ReactElement {
const [allDebridHostInfo, setAllDebridHostInfo] = useState<AllDebridHostInfo | null>(null); const [allDebridHostInfo, setAllDebridHostInfo] = useState<AllDebridHostInfo | null>(null);
const [allDebridHostLoading, setAllDebridHostLoading] = useState(false); const [allDebridHostLoading, setAllDebridHostLoading] = useState(false);
const allDebridHostRequestRef = useRef(0); const allDebridHostRequestRef = useRef(0);
const debridLinkHostLimitsRequestRef = useRef(0);
const accountColumnResizeRef = useRef<{ key: AccountColumnKey; startX: number; startWidth: number } | null>(null); const accountColumnResizeRef = useRef<{ key: AccountColumnKey; startX: number; startWidth: number } | null>(null);
const onAccountColumnResizeMove = useCallback((event: MouseEvent): void => { const onAccountColumnResizeMove = useCallback((event: MouseEvent): void => {
const active = accountColumnResizeRef.current; const active = accountColumnResizeRef.current;
@ -1435,6 +1477,149 @@ export function App(): ReactElement {
} }
}, [showToast]); }, [showToast]);
const loadDebridLinkHostLimits = useCallback(async (silent = false): Promise<void> => {
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 clearImportQueueFocusListener = useCallback((): void => {
const handler = importQueueFocusHandlerRef.current; const handler = importQueueFocusHandlerRef.current;
if (!handler) { if (!handler) {
@ -1764,7 +1949,7 @@ export function App(): ReactElement {
continue; continue;
} }
const option = findAccountOption(kind); const option = findAccountOption(kind);
let statusLabel = "Konfiguriert"; let statusLabel = "Aktiviert";
let note = ""; let note = "";
if (kind === "megadebrid-api") { if (kind === "megadebrid-api") {
note = "Nur API aktiv. Kein Web-Fallback."; note = "Nur API aktiv. Kein Web-Fallback.";
@ -1790,7 +1975,7 @@ export function App(): ReactElement {
} }
if (kind === "debridlink-api") { if (kind === "debridlink-api") {
const keyCount = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").length; 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 provider = getAccountServiceProvider(service);
const dailyUsedBytes = getProviderDailyUsageBytes(snapshot.settings, provider); const dailyUsedBytes = getProviderDailyUsageBytes(snapshot.settings, provider);
@ -1816,7 +2001,9 @@ export function App(): ReactElement {
return { return {
id: key.id, id: key.id,
label: key.label, label: key.label,
token: key.token,
masked: key.masked, masked: key.masked,
disabled: (settingsDraft.debridLinkDisabledKeyIds || []).includes(key.id),
dailyUsedBytes: keyDailyUsedBytes, dailyUsedBytes: keyDailyUsedBytes,
dailyLimitBytes: keyDailyLimitBytes, dailyLimitBytes: keyDailyLimitBytes,
dailyRemainingBytes: keyDailyRemainingBytes, dailyRemainingBytes: keyDailyRemainingBytes,
@ -1826,11 +2013,19 @@ export function App(): ReactElement {
: []; : [];
if (kind === "debridlink-api" && debridLinkKeys.length > 0) { if (kind === "debridlink-api" && debridLinkKeys.length > 0) {
const limitedCount = debridLinkKeys.filter((entry) => entry.dailyLimitReached).length; const limitedCount = debridLinkKeys.filter((entry) => entry.dailyLimitReached).length;
const disabledKeyCount = debridLinkKeys.filter((entry) => entry.disabled).length;
const keyNotes: string[] = [];
if (limitedCount > 0) { if (limitedCount > 0) {
const limitNote = `${limitedCount}/${debridLinkKeys.length} API-Keys am Limit.`; keyNotes.push(`${limitedCount}/${debridLinkKeys.length} API-Keys am Limit.`);
note = note ? `${limitNote} ${note}` : limitNote;
} }
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; dailyLimitReached = true;
} }
} }
@ -2185,6 +2380,28 @@ export function App(): ReactElement {
}); });
}; };
const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): Promise<void> => {
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<void> => { const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise<void> => {
const meta = getAccountQuickActionMeta(entry.kind); const meta = getAccountQuickActionMeta(entry.kind);
if (!meta) { if (!meta) {
@ -4005,6 +4222,9 @@ export function App(): ReactElement {
}} }}
/> />
</div> </div>
<div className="account-header-cell">
<span>Info</span>
</div>
<div className="account-header-cell"> <div className="account-header-cell">
<span>Zugang</span> <span>Zugang</span>
<button <button
@ -4016,7 +4236,9 @@ export function App(): ReactElement {
}} }}
/> />
</div> </div>
<span>Aktionen</span> <div className="account-header-cell">
<span>Aktionen</span>
</div>
</div> </div>
{configuredAccounts.map((entry) => { {configuredAccounts.map((entry) => {
const option = findAccountOption(entry.kind); const option = findAccountOption(entry.kind);
@ -4034,46 +4256,21 @@ export function App(): ReactElement {
<span className="account-mode-pill">{entry.modeLabel}</span> <span className="account-mode-pill">{entry.modeLabel}</span>
</div> </div>
<div className="account-cell account-status-cell"> <div className="account-cell account-status-cell">
<span className={`account-status-pill${allDebridStateClass}`}>{entry.statusLabel}</span> <span className={`account-status-pill${entry.disabled ? " account-status-disabled" : ""}${allDebridStateClass}`}>{entry.statusLabel}</span>
{entry.note && <span className="account-note">{entry.note}</span>} {entry.note && <span className="account-note">{entry.note}</span>}
<div className={`account-usage-stats${entry.dailyLimitReached ? " warning" : ""}`}> </div>
<span>Heute: {humanSize(entry.dailyUsedBytes)}</span> <div className="account-cell account-info-cell">
<span>{entry.dailyLimitBytes > 0 ? `Limit: ${humanSize(entry.dailyLimitBytes)}` : "Kein Tageslimit"}</span> {entry.debridLinkKeys.length > 0 ? (
{entry.dailyLimitBytes > 0 && ( <button className="btn btn-sm" onClick={() => setKeyStatsPopup(entry.service)}>
<span>{entry.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(entry.dailyRemainingBytes || 0)}`}</span> Statistik
)} </button>
{entry.dailyLimitBytes <= 0 && entry.dailyLimitReached && entry.debridLinkKeys.length > 0 && ( ) : (
<span>Fallback aktiv</span> <div className={`account-usage-stats${entry.dailyLimitReached ? " warning" : ""}`}>
)} <span>Heute: {humanSize(entry.dailyUsedBytes)}</span>
</div> <span>{entry.dailyLimitBytes > 0 ? `Limit: ${humanSize(entry.dailyLimitBytes)}` : "Kein Tageslimit"}</span>
{entry.debridLinkKeys.length > 0 && ( {entry.dailyLimitBytes > 0 && (
<div className="account-subkey-list"> <span>{entry.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(entry.dailyRemainingBytes || 0)}`}</span>
{entry.debridLinkKeys.map((key) => ( )}
<div key={key.id} className={`account-subkey-row${key.dailyLimitReached ? " warning" : ""}`}>
<div className="account-subkey-main">
<div className="account-subkey-head">
<strong>{key.label}</strong>
<span>{key.masked}</span>
</div>
<div className="account-subkey-stats">
<span>Heute: {humanSize(key.dailyUsedBytes)}</span>
<span>{key.dailyLimitBytes > 0 ? `Limit: ${humanSize(key.dailyLimitBytes)}` : "Kein Limit"}</span>
{key.dailyLimitBytes > 0 && (
<span>{key.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(key.dailyRemainingBytes || 0)}`}</span>
)}
</div>
</div>
<div className="account-subkey-actions">
<button
className="btn"
disabled={actionBusy || key.dailyUsedBytes <= 0}
onClick={() => { void onResetDebridLinkApiKeyDailyUsage(entry, key.id, key.label); }}
>
Reset
</button>
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>
@ -5016,6 +5213,91 @@ export function App(): ReactElement {
</div> </div>
); );
})()} })()}
{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 (
<div className="modal-backdrop" onClick={() => setKeyStatsPopup(null)}>
<div className="modal-card key-stats-popup" onClick={(e) => e.stopPropagation()}>
<div className="key-stats-popup-header">
<div>
<h3>API-Key Statistik</h3>
<p className="key-stats-summary">
{entry.debridLinkKeys.length} Keys &middot; Heute: {humanSize(totalUsed)}
{limitedCount > 0 && <span className="key-stats-warn"> &middot; {limitedCount} am Limit</span>}
{disabledCount > 0 && <span className="key-stats-warn"> &middot; {disabledCount} deaktiviert</span>}
{debridLinkHostLimitsLoading && <span> &middot; Rapidgator-Quota wird geladen ({loadedQuotaCount}/{entry.debridLinkKeys.length})</span>}
{!debridLinkHostLimitsLoading && !debridLinkHostLimitsError && <span> &middot; Rapidgator API-Quota</span>}
{debridLinkHostLimitsError && <span className="key-stats-warn"> &middot; API-Quota konnte nicht geladen werden</span>}
</p>
</div>
<button className="update-popup-close" onClick={() => setKeyStatsPopup(null)}>&times;</button>
</div>
<div className="account-subkey-table">
<div className="account-subkey-table-head">
<span className="col-key">#</span>
<span className="col-masked">Key</span>
<span className="col-usage">Heute</span>
<span className="col-limit">Lokal</span>
<span className="col-traffic">RG Traffic</span>
<span className="col-links">RG Links</span>
<span className="col-action"></span>
</div>
{entry.debridLinkKeys.map((key, ki) => (
<div key={key.id} className={`account-subkey-table-row${key.dailyLimitReached ? " warning" : ""}${key.disabled ? " disabled" : ""}`}>
{(() => {
const hostInfo = debridLinkHostLimits[key.id];
return (
<>
<span className="col-key">{ki + 1}</span>
<span
className="col-masked link-popup-click"
title={`${key.masked}\nKlicken zum Kopieren`}
onClick={() => {
void navigator.clipboard.writeText(key.token)
.then(() => showToast(`${key.label} kopiert`, 1800))
.catch(() => showToast("Kopieren fehlgeschlagen", 2200));
}}
>
{key.masked}
</span>
<span className="col-usage">{humanSize(key.dailyUsedBytes)}</span>
<span className="col-limit">{key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"}</span>
<span className="col-traffic" title={hostInfo?.note || ""}>{formatDebridLinkTraffic(hostInfo)}</span>
<span className="col-links" title={hostInfo?.note || ""}>{formatDebridLinkCountQuota(hostInfo)}</span>
<span className="col-action">
<button
className={`btn btn-sm ${key.disabled ? "success" : "danger"}`}
disabled={actionBusy}
onClick={() => { void onToggleDebridLinkApiKeyEnabled(entry, key); }}
>
{key.disabled ? "Aktivieren" : "Deaktivieren"}
</button>
<button
className="btn btn-sm"
disabled={actionBusy || key.dailyUsedBytes <= 0}
onClick={() => { void onResetDebridLinkApiKeyDailyUsage(entry, key.id, key.label); }}
>
Reset
</button>
</span>
</>
);
})()}
</div>
))}
</div>
<div className="modal-actions">
<button className="btn" onClick={() => setKeyStatsPopup(null)}>Schließen</button>
</div>
</div>
</div>
);
})()}
{linkPopup && ( {linkPopup && (
<div className="modal-backdrop" onClick={() => setLinkPopup(null)}> <div className="modal-backdrop" onClick={() => setLinkPopup(null)}>
<div className="modal-card link-popup" onClick={(e) => e.stopPropagation()}> <div className="modal-card link-popup" onClick={(e) => e.stopPropagation()}>

View File

@ -482,11 +482,21 @@ body,
color: #fda4af; color: #fda4af;
} }
.btn.success {
border-color: rgba(74, 222, 128, 0.7);
color: #86efac;
}
:root[data-theme="light"] .btn.danger { :root[data-theme="light"] .btn.danger {
border-color: color-mix(in srgb, var(--danger) 60%, transparent); border-color: color-mix(in srgb, var(--danger) 60%, transparent);
color: var(--danger); color: var(--danger);
} }
:root[data-theme="light"] .btn.success {
border-color: color-mix(in srgb, #16a34a 60%, transparent);
color: #15803d;
}
.btn.btn-active { .btn.btn-active {
border-color: var(--accent); border-color: var(--accent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent); 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-service: 220px;
--account-col-mode: 96px; --account-col-mode: 96px;
--account-col-status: 300px; --account-col-status: 300px;
--account-col-info: 220px;
--account-col-secret: 180px; --account-col-secret: 180px;
--account-col-actions: 400px;
width: 100%; width: 100%;
} }
@ -1206,30 +1218,41 @@ body,
.account-row { .account-row {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
minmax(180px, var(--account-col-service)) minmax(130px, var(--account-col-service))
minmax(80px, var(--account-col-mode)) minmax(72px, var(--account-col-mode))
minmax(180px, var(--account-col-status)) minmax(140px, var(--account-col-status))
minmax(120px, var(--account-col-secret)) minmax(160px, var(--account-col-info))
minmax(260px, 1fr); minmax(180px, var(--account-col-secret))
minmax(280px, var(--account-col-actions));
gap: 10px; gap: 10px;
align-items: center; align-items: center;
width: 100%; width: 100%;
} }
.account-table-head { .account-table-head {
padding: 0 4px; padding: 6px 12px;
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; 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 { .account-header-cell {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
min-width: 0; min-width: 0;
width: 100%;
} }
.account-header-cell > span { .account-header-cell > span {
@ -1248,9 +1271,11 @@ body,
background: transparent; background: transparent;
cursor: col-resize; cursor: col-resize;
padding: 0; padding: 0;
z-index: 2;
} }
.account-resize-handle::after { .account-resize-handle:hover::after,
.account-resize-handle:focus-visible::after {
content: ""; content: "";
position: absolute; position: absolute;
top: 6px; top: 6px;
@ -1259,17 +1284,11 @@ body,
width: 2px; width: 2px;
transform: translateX(-50%); transform: translateX(-50%);
border-radius: 999px; 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); background: var(--accent);
} }
.account-row { .account-row {
padding: 12px; padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
border-radius: 14px; border-radius: 14px;
background: linear-gradient(180deg, color-mix(in srgb, var(--card) 97%, transparent), color-mix(in srgb, var(--surface) 96%, transparent)); 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 { .account-cell {
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
} }
.account-service-cell, .account-row > .account-cell:nth-child(n+2) {
.account-status-cell { 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; display: grid;
gap: 3px; 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 { .account-service-cell strong {
font-size: 14px; font-size: 14px;
} }
.account-service-cell span, .account-service-cell span {
.account-note {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
} }
.account-note {
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.account-usage-stats { .account-usage-stats {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 6px 10px; align-items: center;
gap: 4px;
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 11px;
line-height: 1.4; white-space: nowrap;
}
.account-usage-stats span + span::before {
content: "·";
margin-right: 4px;
} }
.account-usage-stats.warning { .account-usage-stats.warning {
@ -1335,6 +1396,12 @@ body,
color: var(--text); 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 { .account-status-pill.account-status-down {
background: color-mix(in srgb, var(--danger) 12%, transparent); background: color-mix(in srgb, var(--danger) 12%, transparent);
border-color: color-mix(in srgb, var(--danger) 40%, transparent); border-color: color-mix(in srgb, var(--danger) 40%, transparent);
@ -1349,7 +1416,7 @@ body,
.account-secret { .account-secret {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
width: 100%; justify-content: center;
min-height: 34px; min-height: 34px;
padding: 0 12px; padding: 0 12px;
border-radius: 12px; border-radius: 12px;
@ -1366,91 +1433,178 @@ body,
.account-row-actions { .account-row-actions {
display: flex; display: flex;
justify-content: flex-start; justify-content: center;
align-items: flex-start; align-items: center;
gap: 6px; gap: 6px;
flex-wrap: nowrap; flex-wrap: nowrap;
align-content: center;
text-align: center;
} }
.account-row-actions .btn { .account-row-actions .btn {
padding: 5px 8px; padding: 5px 8px;
font-size: 11px; font-size: 11px;
white-space: nowrap; white-space: nowrap;
min-width: 82px;
justify-content: center;
} }
.account-row-disabled { .account-row-disabled {
opacity: 0.45; opacity: 0.65;
} }
.account-row-disabled .account-row-actions { .account-row-disabled .account-row-actions {
opacity: 1; opacity: 1;
} }
.account-subkey-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.account-subkey-row { .key-stats-popup {
display: grid; width: min(1360px, calc(100vw - 20px));
grid-template-columns: minmax(0, 1fr) auto; max-width: min(1360px, calc(100vw - 20px));
gap: 8px; max-height: calc(100vh - 24px);
align-items: center; overflow: hidden;
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; display: flex;
align-items: baseline; flex-direction: column;
gap: 8px; gap: 12px;
min-width: 0;
} }
.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; font-size: 12px;
flex: 0 0 auto; color: var(--muted);
} }
.account-subkey-head span { .key-stats-warn {
color: var(--muted); 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; 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; font-family: "JetBrains Mono", "Consolas", monospace;
color: var(--muted);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-width: 0; min-width: 0;
} }
.account-subkey-stats { .account-subkey-table-row .col-masked.link-popup-click {
display: flex; display: block;
flex-wrap: wrap;
gap: 4px 10px;
color: var(--muted);
font-size: 11px;
line-height: 1.35;
} }
.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; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 6px;
} }
.account-subkey-actions .btn { .account-subkey-table-row .col-action .btn {
padding: 4px 8px; padding: 1px 6px;
font-size: 10px;
}
.btn-sm {
padding: 3px 8px;
font-size: 11px; font-size: 11px;
} }
@ -2708,7 +2862,13 @@ td {
justify-content: flex-start; 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 { .account-dl-key-limit-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@ -40,6 +40,7 @@ export const IPC_CHANNELS = {
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login", OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies", IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info", GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
RETRY_EXTRACTION: "queue:retry-extraction", RETRY_EXTRACTION: "queue:retry-extraction",
EXTRACT_NOW: "queue:extract-now", EXTRACT_NOW: "queue:extract-now",
RESET_PACKAGE: "queue:reset-package", RESET_PACKAGE: "queue:reset-package",

View File

@ -2,6 +2,7 @@ import type {
AddLinksPayload, AddLinksPayload,
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
@ -54,6 +55,7 @@ export interface ElectronApi {
openAllDebridLogin: () => Promise<void>; openAllDebridLogin: () => Promise<void>;
importBestDebridCookies: () => Promise<number>; importBestDebridCookies: () => Promise<number>;
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>; getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
retryExtraction: (packageId: string) => Promise<void>; retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>; extractNow: (packageId: string) => Promise<void>;
resetPackage: (packageId: string) => Promise<void>; resetPackage: (packageId: string) => Promise<void>;

View File

@ -62,6 +62,7 @@ export interface AppSettings {
ddownloadPassword: string; ddownloadPassword: string;
oneFichierApiKey: string; oneFichierApiKey: string;
debridLinkApiKeys: string; debridLinkApiKeys: string;
debridLinkDisabledKeyIds: string[];
linkSnappyLogin: string; linkSnappyLogin: string;
linkSnappyPassword: string; linkSnappyPassword: string;
archivePasswordList: string; archivePasswordList: string;
@ -290,6 +291,18 @@ export interface AllDebridHostInfo {
note: string; 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 { export interface ParsedHashEntry {
fileName: string; fileName: string;
algorithm: "crc32" | "md5" | "sha1"; algorithm: "crc32" | "md5" | "sha1";

View File

@ -78,6 +78,10 @@ describe("extractEpisodeToken", () => {
it("extracts double episode with single-digit numbers", () => { it("extracts double episode with single-digit numbers", () => {
expect(extractEpisodeToken("show-s1e1e2-720p")).toBe("S01E01E02"); 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", () => { describe("applyEpisodeTokenToFolderName", () => {
@ -691,4 +695,13 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
); );
expect(result).toBe("Room.104.S04E01.GERMAN.DL.720p.WEBRiP.x264-LAW"); 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");
});
}); });

View File

@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants"; import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; 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; const originalFetch = globalThis.fetch;
@ -379,6 +379,39 @@ describe("debrid service", () => {
expect(info.limitSimuDl).toBe(2); expect(info.limitSimuDl).toBe(2);
}); });
it("loads Debrid-Link rapidgator limits per api key", async () => {
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
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 () => { it("uses AllDebrid web path when enabled", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),