feat: add Debrid-Link provider with multi-account key rotation
- New DebridLinkClient with automatic API key rotation on quota errors (maxLink, maxLinkHost, maxData, maxDataHost, maxAttempts, maxTransfer) - Multi-account support: comma or newline-separated API keys - Full UI integration: account settings, provider dropdowns, summary display - Safe fallback for undefined debridLinkApiKeys on settings upgrade Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fac17497f0
commit
af188d96c4
@ -52,6 +52,7 @@ export function defaultSettings(): AppSettings {
|
||||
ddownloadLogin: "",
|
||||
ddownloadPassword: "",
|
||||
oneFichierApiKey: "",
|
||||
debridLinkApiKeys: "",
|
||||
archivePasswordList: "",
|
||||
rememberToken: true,
|
||||
providerPrimary: "realdebrid",
|
||||
|
||||
@ -17,13 +17,17 @@ const MEGA_DEBRID_API_BASE = "https://www.mega-debrid.eu/api.php";
|
||||
const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1";
|
||||
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
|
||||
|
||||
const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2";
|
||||
const DEBRID_LINK_QUOTA_ERRORS = new Set(["maxLink", "maxLinkHost", "maxData", "maxDataHost", "maxAttempts", "maxTransfer"]);
|
||||
|
||||
const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid",
|
||||
megadebrid: "Mega-Debrid",
|
||||
bestdebrid: "BestDebrid",
|
||||
alldebrid: "AllDebrid",
|
||||
ddownload: "DDownload",
|
||||
onefichier: "1Fichier"
|
||||
onefichier: "1Fichier",
|
||||
debridlink: "Debrid-Link"
|
||||
};
|
||||
|
||||
interface ProviderUnrestrictedLink extends UnrestrictedLink {
|
||||
@ -1252,6 +1256,111 @@ export async function fetchAllDebridHostInfo(token: string, host = "rapidgator",
|
||||
return new AllDebridClient(token).getHostInfo(host, signal);
|
||||
}
|
||||
|
||||
// ── Debrid-Link Client ──
|
||||
|
||||
class DebridLinkClient {
|
||||
private apiKeys: string[];
|
||||
private currentKeyIndex: number = 0;
|
||||
|
||||
public constructor(apiKeysRaw: string) {
|
||||
this.apiKeys = apiKeysRaw
|
||||
.split(/[\n,]+/)
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||
if (this.apiKeys.length === 0) {
|
||||
throw new Error("Debrid-Link: Kein API-Key konfiguriert");
|
||||
}
|
||||
|
||||
const startIndex = this.currentKeyIndex;
|
||||
let triedAll = false;
|
||||
|
||||
while (!triedAll) {
|
||||
const apiKey = this.apiKeys[this.currentKeyIndex];
|
||||
const keyLabel = this.apiKeys.length > 1 ? ` (Key ${this.currentKeyIndex + 1}/${this.apiKeys.length})` : "";
|
||||
|
||||
let lastError = "";
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||
try {
|
||||
const res = await fetch(`${DEBRID_LINK_API_BASE}/downloader/add`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
},
|
||||
body: `url=${encodeURIComponent(link)}`,
|
||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
const json = await res.json() as Record<string, unknown>;
|
||||
|
||||
if (!json.success) {
|
||||
const errorCode = String(json.error || "");
|
||||
const errorDesc = String(json.error_description || json.error || "Unbekannter Debrid-Link-Fehler");
|
||||
|
||||
if (DEBRID_LINK_QUOTA_ERRORS.has(errorCode)) {
|
||||
logger.warn(`Debrid-Link Quota erreicht${keyLabel}: ${errorCode} – ${errorDesc}`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (errorCode === "badToken" || errorCode === "expired_token") {
|
||||
throw new Error(`Debrid-Link${keyLabel}: Ungueltiger oder abgelaufener API-Key`);
|
||||
}
|
||||
if (errorCode === "floodDetected") {
|
||||
await sleep(retryDelay(attempt), signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Debrid-Link${keyLabel}: ${errorDesc}`);
|
||||
}
|
||||
|
||||
const value = json.value as Record<string, unknown> | undefined;
|
||||
if (!value) {
|
||||
throw new Error(`Debrid-Link${keyLabel}: Keine Daten in Antwort`);
|
||||
}
|
||||
|
||||
const directUrl = String(value.downloadUrl || "");
|
||||
if (!directUrl) {
|
||||
throw new Error(`Debrid-Link${keyLabel}: Keine Download-URL in Antwort`);
|
||||
}
|
||||
|
||||
const fileName = String(value.name || "") || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
||||
const fileSize = typeof value.size === "number" && value.size > 0 ? value.size : null;
|
||||
|
||||
return {
|
||||
fileName,
|
||||
directUrl,
|
||||
fileSize,
|
||||
retriesUsed: attempt - 1,
|
||||
sourceLabel: `API${keyLabel}`
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = compactErrorText(error);
|
||||
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
||||
throw error;
|
||||
}
|
||||
if (/Ungueltig|abgelaufen/i.test(lastError)) {
|
||||
throw error;
|
||||
}
|
||||
if (attempt < REQUEST_RETRIES) {
|
||||
await sleep(retryDelay(attempt), signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.currentKeyIndex = (this.currentKeyIndex + 1) % this.apiKeys.length;
|
||||
if (this.currentKeyIndex === startIndex) {
|
||||
triedAll = true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Debrid-Link: Alle ${this.apiKeys.length} API-Keys haben ihr Limit erreicht`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1Fichier Client ──
|
||||
|
||||
class OneFichierClient {
|
||||
@ -1509,6 +1618,8 @@ export class DebridService {
|
||||
|
||||
private cachedDdownloadClient: DdownloadClient | null = null;
|
||||
private cachedDdownloadKey = "";
|
||||
private cachedDebridLinkClient: DebridLinkClient | null = null;
|
||||
private cachedDebridLinkKey = "";
|
||||
|
||||
public constructor(settings: AppSettings, options: DebridServiceOptions = {}) {
|
||||
this.settings = cloneSettings(settings);
|
||||
@ -1519,6 +1630,15 @@ export class DebridService {
|
||||
this.settings = cloneSettings(next);
|
||||
}
|
||||
|
||||
private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient {
|
||||
if (this.cachedDebridLinkClient && this.cachedDebridLinkKey === apiKeysRaw) {
|
||||
return this.cachedDebridLinkClient;
|
||||
}
|
||||
this.cachedDebridLinkClient = new DebridLinkClient(apiKeysRaw);
|
||||
this.cachedDebridLinkKey = apiKeysRaw;
|
||||
return this.cachedDebridLinkClient;
|
||||
}
|
||||
|
||||
private getDdownloadClient(login: string, password: string): DdownloadClient {
|
||||
const key = `${login}\0${password}`;
|
||||
if (this.cachedDdownloadClient && this.cachedDdownloadKey === key) {
|
||||
@ -1724,6 +1844,9 @@ export class DebridService {
|
||||
if (provider === "onefichier") {
|
||||
return Boolean(settings.oneFichierApiKey.trim());
|
||||
}
|
||||
if (provider === "debridlink") {
|
||||
return Boolean(settings.debridLinkApiKeys.trim());
|
||||
}
|
||||
return Boolean(this.shouldUseBestDebridWeb(settings) || settings.bestToken.trim());
|
||||
}
|
||||
|
||||
@ -1763,6 +1886,11 @@ export class DebridService {
|
||||
if (provider === "onefichier") {
|
||||
return new OneFichierClient(settings.oneFichierApiKey).unrestrictLink(link, signal);
|
||||
}
|
||||
if (provider === "debridlink") {
|
||||
const dlResult = await this.getDebridLinkClient(settings.debridLinkApiKeys).unrestrictLink(link, signal);
|
||||
dlResult.sourceLabel = dlResult.sourceLabel || "API";
|
||||
return dlResult;
|
||||
}
|
||||
if (this.shouldUseBestDebridWeb(settings) && this.options.bestDebridWebUnrestrict) {
|
||||
const bdResult = await this.options.bestDebridWebUnrestrict(link, signal);
|
||||
if (!bdResult) {
|
||||
|
||||
@ -5,8 +5,8 @@ import { AppSettings, BandwidthScheduleEntry, DebridProvider, DownloadItem, Down
|
||||
import { defaultSettings } from "./constants";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier"]);
|
||||
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier"]);
|
||||
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
|
||||
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
|
||||
const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]);
|
||||
const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
|
||||
const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]);
|
||||
@ -17,7 +17,7 @@ const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
|
||||
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
|
||||
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
|
||||
]);
|
||||
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier"]);
|
||||
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
|
||||
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
|
||||
|
||||
function asText(value: unknown): string {
|
||||
@ -118,6 +118,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
ddownloadLogin: asText(settings.ddownloadLogin),
|
||||
ddownloadPassword: asText(settings.ddownloadPassword),
|
||||
oneFichierApiKey: asText(settings.oneFichierApiKey),
|
||||
debridLinkApiKeys: String(settings.debridLinkApiKeys ?? "").replace(/\r\n|\r/g, "\n").trim(),
|
||||
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"),
|
||||
rememberToken: Boolean(settings.rememberToken),
|
||||
providerPrimary: settings.providerPrimary,
|
||||
@ -212,7 +213,8 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings {
|
||||
allDebridToken: "",
|
||||
ddownloadLogin: "",
|
||||
ddownloadPassword: "",
|
||||
oneFichierApiKey: ""
|
||||
oneFichierApiKey: "",
|
||||
debridLinkApiKeys: ""
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@ interface LinkPopupState {
|
||||
isPackage: boolean;
|
||||
}
|
||||
|
||||
type AccountService = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier";
|
||||
type AccountService = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink";
|
||||
type AccountKind =
|
||||
| "realdebrid-api"
|
||||
| "realdebrid-web"
|
||||
@ -64,7 +64,8 @@ type AccountKind =
|
||||
| "alldebrid-api"
|
||||
| "alldebrid-web"
|
||||
| "ddownload-login"
|
||||
| "onefichier-api";
|
||||
| "onefichier-api"
|
||||
| "debridlink-api";
|
||||
|
||||
type AccountQuickAction = "realdebrid-login" | "bestdebrid-cookies" | "alldebrid-login" | "alldebrid-status";
|
||||
type AccountColumnKey = "service" | "mode" | "status" | "secret";
|
||||
@ -185,10 +186,19 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
modeLabel: "API",
|
||||
pickerDescription: "API-Key fuer 1fichier.com.",
|
||||
needsToken: true
|
||||
},
|
||||
{
|
||||
kind: "debridlink-api",
|
||||
service: "debridlink",
|
||||
serviceLabel: "Debrid-Link",
|
||||
title: "Debrid-Link API",
|
||||
modeLabel: "API",
|
||||
pickerDescription: "API-Key(s) fuer debrid-link.com. Mehrere Keys zeilenweise fuer Multi-Account.",
|
||||
needsToken: true
|
||||
}
|
||||
];
|
||||
|
||||
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier"];
|
||||
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"];
|
||||
const ACCOUNT_COLUMN_STORAGE_KEY = "rd-account-column-widths";
|
||||
const ACCOUNT_COLUMN_DEFAULT_WIDTHS: Record<AccountColumnKey, number> = {
|
||||
service: 220,
|
||||
@ -270,6 +280,9 @@ function getConfiguredProvidersFromSettings(settings: AppSettings): DebridProvid
|
||||
if (settings.allDebridUseWebLogin || settings.allDebridToken.trim()) {
|
||||
list.push("alldebrid");
|
||||
}
|
||||
if ((settings.debridLinkApiKeys || "").trim()) {
|
||||
list.push("debridlink");
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@ -311,6 +324,8 @@ function getConfiguredAccountKind(settings: AppSettings, service: AccountService
|
||||
return settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim() ? "ddownload-login" : null;
|
||||
case "onefichier":
|
||||
return settings.oneFichierApiKey.trim() ? "onefichier-api" : null;
|
||||
case "debridlink":
|
||||
return (settings.debridLinkApiKeys || "").trim() ? "debridlink-api" : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@ -346,6 +361,11 @@ function summarizeAccount(kind: AccountKind, settings: AppSettings): string {
|
||||
return settings.ddownloadLogin.trim() ? maskValue(settings.ddownloadLogin.trim(), 2, 6) : "Login + Passwort";
|
||||
case "onefichier-api":
|
||||
return maskValue(settings.oneFichierApiKey, 3, 3);
|
||||
case "debridlink-api": {
|
||||
const keys = (settings.debridLinkApiKeys || "").split(/[\n,]+/).filter((k: string) => k.trim());
|
||||
if (keys.length > 1) return `${keys.length} API-Keys`;
|
||||
return keys.length === 1 ? maskValue(keys[0].trim(), 3, 3) : "Nicht hinterlegt";
|
||||
}
|
||||
default:
|
||||
return "Konfiguriert";
|
||||
}
|
||||
@ -381,6 +401,8 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
|
||||
return { mode, kind, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword };
|
||||
case "onefichier-api":
|
||||
return { mode, kind, token: settings.oneFichierApiKey, login: "", password: "" };
|
||||
case "debridlink-api":
|
||||
return { mode, kind, token: settings.debridLinkApiKeys || "", login: "", password: "" };
|
||||
default:
|
||||
return { mode, kind, token: "", login: "", password: "" };
|
||||
}
|
||||
@ -414,6 +436,8 @@ function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDial
|
||||
return { ...settings, ddownloadLogin: login, ddownloadPassword: password };
|
||||
case "onefichier-api":
|
||||
return { ...settings, oneFichierApiKey: token };
|
||||
case "debridlink-api":
|
||||
return { ...settings, debridLinkApiKeys: token };
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
@ -433,6 +457,8 @@ function clearAccountServiceFromSettings(settings: AppSettings, service: Account
|
||||
return { ...settings, ddownloadLogin: "", ddownloadPassword: "" };
|
||||
case "onefichier":
|
||||
return { ...settings, oneFichierApiKey: "" };
|
||||
case "debridlink":
|
||||
return { ...settings, debridLinkApiKeys: "" };
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
@ -499,7 +525,7 @@ const cleanupLabels: Record<string, string> = {
|
||||
const AUTO_RENDER_PACKAGE_LIMIT = 260;
|
||||
|
||||
const providerLabels: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid", ddownload: "DDownload", onefichier: "1Fichier"
|
||||
realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid", ddownload: "DDownload", onefichier: "1Fichier", debridlink: "Debrid-Link"
|
||||
};
|
||||
|
||||
function formatDateTime(ts: number): string {
|
||||
|
||||
@ -14,7 +14,7 @@ export type CleanupMode = "none" | "trash" | "delete";
|
||||
export type ConflictMode = "overwrite" | "skip" | "rename" | "ask";
|
||||
export type SpeedMode = "global" | "per_download";
|
||||
export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done";
|
||||
export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier";
|
||||
export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink";
|
||||
export type DebridFallbackProvider = DebridProvider | "none";
|
||||
export type AppTheme = "dark" | "light";
|
||||
export type PackagePriority = "high" | "normal" | "low";
|
||||
@ -49,6 +49,7 @@ export interface AppSettings {
|
||||
ddownloadLogin: string;
|
||||
ddownloadPassword: string;
|
||||
oneFichierApiKey: string;
|
||||
debridLinkApiKeys: string;
|
||||
archivePasswordList: string;
|
||||
rememberToken: boolean;
|
||||
providerPrimary: DebridProvider;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user