diff --git a/package-lock.json b/package-lock.json index e167215..e308763 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.1.9", + "version": "1.1.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.1.9", + "version": "1.1.12", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 6876a65..dbfae8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.1.11", + "version": "1.1.12", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/scripts/debrid_service_smoke.ts b/scripts/debrid_service_smoke.ts new file mode 100644 index 0000000..fb2a8ff --- /dev/null +++ b/scripts/debrid_service_smoke.ts @@ -0,0 +1,39 @@ +import { DebridService } from "../src/main/debrid"; +import { defaultSettings } from "../src/main/constants"; + +const links = [ + "https://rapidgator.net/file/837ef967aede4935e3e0374c4e663b40/GTHDERTPIIP7P401.part1.rar.html", + "https://rapidgator.net/file/ef3c9d64c899f801d69d6888dad89dcd/GTHDERTPIIP7P401.part2.rar.html", + "https://rapidgator.net/file/b38130fcf1e8448953250b9a1ed7958d/GTHDERTPIIP7P401.part3.rar.html" +]; + +const settings = { + ...defaultSettings(), + token: process.env.RD_TOKEN || "", + megaToken: process.env.MEGA_TOKEN || "", + bestToken: process.env.BEST_TOKEN || "", + allDebridToken: process.env.ALLDEBRID_TOKEN || "", + providerPrimary: "alldebrid" as const, + providerSecondary: "realdebrid" as const, + providerTertiary: "megadebrid" as const, + autoProviderFallback: true +}; + +if (!settings.token && !settings.megaToken && !settings.bestToken && !settings.allDebridToken) { + console.error("No provider tokens set. Use RD_TOKEN/MEGA_TOKEN/BEST_TOKEN/ALLDEBRID_TOKEN."); + process.exit(1); +} + +async function main(): Promise { + const service = new DebridService(settings); + for (const link of links) { + try { + const result = await service.unrestrictLink(link); + console.log(`[OK] ${result.providerLabel} -> ${result.fileName}`); + } catch (error) { + console.log(`[FAIL] ${String(error)}`); + } + } +} + +void main(); diff --git a/scripts/provider_smoke_check.mjs b/scripts/provider_smoke_check.mjs new file mode 100644 index 0000000..6b019e0 --- /dev/null +++ b/scripts/provider_smoke_check.mjs @@ -0,0 +1,245 @@ +const RAPIDGATOR_LINKS = [ + "https://rapidgator.net/file/837ef967aede4935e3e0374c4e663b40/GTHDERTPIIP7P401.part1.rar.html", + "https://rapidgator.net/file/ef3c9d64c899f801d69d6888dad89dcd/GTHDERTPIIP7P401.part2.rar.html", + "https://rapidgator.net/file/b38130fcf1e8448953250b9a1ed7958d/GTHDERTPIIP7P401.part3.rar.html" +]; + +const rdToken = process.env.RD_TOKEN || ""; +const megaToken = process.env.MEGA_TOKEN || ""; +const bestToken = process.env.BEST_TOKEN || ""; +const allDebridToken = process.env.ALLDEBRID_TOKEN || ""; + +if (!rdToken && !megaToken && !bestToken && !allDebridToken) { + console.error("No provider token configured. Set RD_TOKEN and/or MEGA_TOKEN and/or BEST_TOKEN and/or ALLDEBRID_TOKEN."); + process.exit(1); +} + +function asRecord(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value; +} + +function pickString(payload, keys) { + if (!payload) { + return ""; + } + for (const key of keys) { + const value = payload[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return ""; +} + +function parseResponseError(status, bodyText, payload) { + return pickString(payload, ["response_text", "error", "message", "error_description"]) || bodyText || `HTTP ${status}`; +} + +async function callRealDebrid(link) { + const response = await fetch("https://api.real-debrid.com/rest/1.0/unrestrict/link", { + method: "POST", + headers: { + Authorization: `Bearer ${rdToken}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "RD-Node-Downloader/1.1.12" + }, + body: new URLSearchParams({ link }) + }); + const text = await response.text(); + const payload = asRecord(safeJson(text)); + if (!response.ok) { + return { ok: false, error: parseResponseError(response.status, text, payload) }; + } + const direct = pickString(payload, ["download", "link"]); + if (!direct) { + return { ok: false, error: "Real-Debrid returned no download URL" }; + } + return { + ok: true, + direct, + fileName: pickString(payload, ["filename", "fileName"]) + }; +} + +async function callMegaDebrid(link) { + const response = await fetch(`https://www.mega-debrid.eu/api.php?action=getLink&token=${encodeURIComponent(megaToken)}`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "RD-Node-Downloader/1.1.12" + }, + body: new URLSearchParams({ link }) + }); + const text = await response.text(); + const payload = asRecord(safeJson(text)); + if (!response.ok) { + return { ok: false, error: parseResponseError(response.status, text, payload) }; + } + const code = pickString(payload, ["response_code"]); + if (code && code.toLowerCase() !== "ok") { + return { ok: false, error: pickString(payload, ["response_text"]) || code }; + } + const direct = pickString(payload, ["debridLink", "download", "link"]); + if (!direct) { + return { ok: false, error: "Mega-Debrid returned no debridLink" }; + } + return { + ok: true, + direct, + fileName: pickString(payload, ["filename", "fileName"]) + }; +} + +async function callBestDebrid(link) { + const encoded = encodeURIComponent(link); + const requests = [ + { + url: `https://bestdebrid.com/api/v1/generateLink?link=${encoded}`, + useHeader: true + }, + { + url: `https://bestdebrid.com/api/v1/generateLink?auth=${encodeURIComponent(bestToken)}&link=${encoded}`, + useHeader: false + } + ]; + + let lastError = "Unknown BestDebrid error"; + for (const req of requests) { + const headers = { + "User-Agent": "RD-Node-Downloader/1.1.12" + }; + if (req.useHeader) { + headers.Authorization = bestToken; + } + const response = await fetch(req.url, { + method: "GET", + headers + }); + const text = await response.text(); + const parsed = safeJson(text); + const payload = Array.isArray(parsed) ? asRecord(parsed[0]) : asRecord(parsed); + + if (!response.ok) { + lastError = parseResponseError(response.status, text, payload); + continue; + } + + const direct = pickString(payload, ["download", "debridLink", "link"]); + if (!direct) { + lastError = pickString(payload, ["response_text", "message", "error"]) || "BestDebrid returned no download URL"; + continue; + } + return { + ok: true, + direct, + fileName: pickString(payload, ["filename", "fileName"]) + }; + } + return { ok: false, error: lastError }; +} + +async function callAllDebrid(link) { + const response = await fetch("https://api.alldebrid.com/v4/link/unlock", { + method: "POST", + headers: { + Authorization: `Bearer ${allDebridToken}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "RD-Node-Downloader/1.1.12" + }, + body: new URLSearchParams({ link }) + }); + + const text = await response.text(); + const payload = asRecord(safeJson(text)); + if (!response.ok) { + return { ok: false, error: parseResponseError(response.status, text, payload) }; + } + + if (pickString(payload, ["status"]) === "error") { + const err = asRecord(payload?.error); + return { ok: false, error: pickString(err, ["message", "code"]) || "AllDebrid API error" }; + } + + const data = asRecord(payload?.data); + const direct = pickString(data, ["link"]); + if (!direct) { + return { ok: false, error: "AllDebrid returned no download URL" }; + } + return { + ok: true, + direct, + fileName: pickString(data, ["filename"]) + }; +} + +function safeJson(text) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function hostFromUrl(url) { + try { + return new URL(url).host; + } catch { + return "invalid-url"; + } +} + +async function main() { + const providers = []; + if (rdToken) { + providers.push({ name: "Real-Debrid", run: callRealDebrid }); + } + if (megaToken) { + providers.push({ name: "Mega-Debrid", run: callMegaDebrid }); + } + if (bestToken) { + providers.push({ name: "BestDebrid", run: callBestDebrid }); + } + if (allDebridToken) { + providers.push({ name: "AllDebrid", run: callAllDebrid }); + } + + let failures = 0; + + for (const link of RAPIDGATOR_LINKS) { + console.log(`\nLink: ${link}`); + const results = []; + for (const provider of providers) { + try { + const result = await provider.run(link); + results.push({ provider: provider.name, ...result }); + } catch (error) { + results.push({ provider: provider.name, ok: false, error: String(error) }); + } + } + + for (const result of results) { + if (result.ok) { + console.log(` [OK] ${result.provider} -> ${hostFromUrl(result.direct)} ${result.fileName ? `(${result.fileName})` : ""}`); + } else { + console.log(` [FAIL] ${result.provider} -> ${result.error}`); + } + } + + const fallbackPick = results.find((entry) => entry.ok); + if (fallbackPick) { + console.log(` [AUTO] Selected by fallback order: ${fallbackPick.provider}`); + } else { + failures += 1; + console.log(" [AUTO] No provider could unrestrict this link"); + } + } + + if (failures > 0) { + process.exitCode = 2; + } +} + +await main(); diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index d49e0cd..0f1031a 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -28,13 +28,17 @@ export class AppController { if (this.settings.autoResumeOnStart) { const snapshot = this.manager.getSnapshot(); const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait"); - if (hasPending && this.settings.token.trim()) { + if (hasPending && this.hasAnyProviderToken(this.settings)) { this.manager.start(); logger.info("Auto-Resume beim Start aktiviert"); } } } + private hasAnyProviderToken(settings: AppSettings): boolean { + return Boolean(settings.token.trim() || settings.megaToken.trim() || settings.bestToken.trim() || settings.allDebridToken.trim()); + } + public onState: ((snapshot: UiSnapshot) => void) | null = null; public getSnapshot(): UiSnapshot { diff --git a/src/main/constants.ts b/src/main/constants.ts index 4fc7527..a2d26e4 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -2,8 +2,8 @@ import path from "node:path"; import os from "node:os"; import { AppSettings } from "../shared/types"; -export const APP_NAME = "Real-Debrid Download Manager"; -export const APP_VERSION = "1.1.11"; +export const APP_NAME = "Debrid Download Manager"; +export const APP_VERSION = "1.1.12"; export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0"; export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload"; @@ -28,7 +28,14 @@ export function defaultSettings(): AppSettings { const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid"); return { token: "", + megaToken: "", + bestToken: "", + allDebridToken: "", rememberToken: true, + providerPrimary: "realdebrid", + providerSecondary: "megadebrid", + providerTertiary: "bestdebrid", + autoProviderFallback: true, outputDir: baseDir, packageName: "", autoExtract: true, diff --git a/src/main/debrid.ts b/src/main/debrid.ts new file mode 100644 index 0000000..d873141 --- /dev/null +++ b/src/main/debrid.ts @@ -0,0 +1,408 @@ +import { AppSettings, DebridProvider } from "../shared/types"; +import { REQUEST_RETRIES } from "./constants"; +import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; +import { compactErrorText, filenameFromUrl, sleep } from "./utils"; + +const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php"; +const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1"; +const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4"; + +const PROVIDER_LABELS: Record = { + realdebrid: "Real-Debrid", + megadebrid: "Mega-Debrid", + bestdebrid: "BestDebrid", + alldebrid: "AllDebrid" +}; + +interface ProviderUnrestrictedLink extends UnrestrictedLink { + provider: DebridProvider; + providerLabel: string; +} + +type BestDebridRequest = { + url: string; + useAuthHeader: boolean; +}; + +function shouldRetryStatus(status: number): boolean { + return status === 429 || status >= 500; +} + +function retryDelay(attempt: number): number { + return Math.min(5000, 400 * 2 ** attempt); +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } +} + +function pickString(payload: Record | null, keys: string[]): string { + if (!payload) { + return ""; + } + for (const key of keys) { + const value = payload[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return ""; +} + +function pickNumber(payload: Record | null, keys: string[]): number | null { + if (!payload) { + return null; + } + for (const key of keys) { + const value = Number(payload[key] ?? NaN); + if (Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + } + return null; +} + +function parseError(status: number, responseText: string, payload: Record | null): string { + const fromPayload = pickString(payload, ["response_text", "error", "message", "detail", "error_description"]); + if (fromPayload) { + return fromPayload; + } + const compact = compactErrorText(responseText); + if (compact && compact !== "Unbekannter Fehler") { + return compact; + } + return `HTTP ${status}`; +} + +function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] { + const seen = new Set(); + const result: DebridProvider[] = []; + for (const provider of order) { + if (seen.has(provider)) { + continue; + } + seen.add(provider); + result.push(provider); + } + return result; +} + +function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] { + const linkParam = encodeURIComponent(link); + const authParam = encodeURIComponent(token); + return [ + { + url: `${BEST_DEBRID_API_BASE}/generateLink?link=${linkParam}`, + useAuthHeader: true + }, + { + url: `${BEST_DEBRID_API_BASE}/generateLink?auth=${authParam}&link=${linkParam}`, + useAuthHeader: false + } + ]; +} + +class MegaDebridClient { + private token: string; + + public constructor(token: string) { + this.token = token; + } + + public async unrestrictLink(link: string): Promise { + let lastError = ""; + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + const body = new URLSearchParams({ link }); + const response = await fetch(`${MEGA_DEBRID_API}?action=getLink&token=${encodeURIComponent(this.token)}`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "RD-Node-Downloader/1.1.12" + }, + body + }); + const text = await response.text(); + const payload = asRecord(parseJson(text)); + + if (!response.ok) { + const reason = parseError(response.status, text, payload); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleep(retryDelay(attempt)); + continue; + } + throw new Error(reason); + } + + const responseCode = pickString(payload, ["response_code"]); + if (responseCode && responseCode.toLowerCase() !== "ok") { + throw new Error(pickString(payload, ["response_text"]) || responseCode); + } + + const directUrl = pickString(payload, ["debridLink", "download", "link"]); + if (!directUrl) { + throw new Error("Mega-Debrid Antwort ohne debridLink"); + } + + const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(link); + const fileSize = pickNumber(payload, ["filesize", "size"]); + return { + fileName, + directUrl, + fileSize, + retriesUsed: attempt - 1 + }; + } catch (error) { + lastError = compactErrorText(error); + if (attempt >= REQUEST_RETRIES) { + break; + } + await sleep(retryDelay(attempt)); + } + } + throw new Error(lastError || "Mega-Debrid Unrestrict fehlgeschlagen"); + } +} + +class BestDebridClient { + private token: string; + + public constructor(token: string) { + this.token = token; + } + + public async unrestrictLink(link: string): Promise { + const requests = buildBestDebridRequests(link, this.token); + let lastError = ""; + + for (const request of requests) { + try { + return await this.tryRequest(request, link); + } catch (error) { + lastError = compactErrorText(error); + } + } + + throw new Error(lastError || "BestDebrid Unrestrict fehlgeschlagen"); + } + + private async tryRequest(request: BestDebridRequest, originalLink: string): Promise { + let lastError = ""; + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + const headers: Record = { + "User-Agent": "RD-Node-Downloader/1.1.12" + }; + if (request.useAuthHeader) { + headers.Authorization = this.token; + } + + const response = await fetch(request.url, { + method: "GET", + headers + }); + const text = await response.text(); + const parsed = parseJson(text); + const payload = Array.isArray(parsed) ? asRecord(parsed[0]) : asRecord(parsed); + + if (!response.ok) { + const reason = parseError(response.status, text, payload); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleep(retryDelay(attempt)); + continue; + } + throw new Error(reason); + } + + const directUrl = pickString(payload, ["download", "debridLink", "link"]); + if (directUrl) { + const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(originalLink); + const fileSize = pickNumber(payload, ["filesize", "size", "bytes"]); + return { + fileName, + directUrl, + fileSize, + retriesUsed: attempt - 1 + }; + } + + const message = pickString(payload, ["response_text", "message", "error"]); + if (message) { + throw new Error(message); + } + + throw new Error("BestDebrid Antwort ohne Download-Link"); + } catch (error) { + lastError = compactErrorText(error); + if (attempt >= REQUEST_RETRIES) { + break; + } + await sleep(retryDelay(attempt)); + } + } + throw new Error(lastError || "BestDebrid Request fehlgeschlagen"); + } +} + +class AllDebridClient { + private token: string; + + public constructor(token: string) { + this.token = token; + } + + public async unrestrictLink(link: string): Promise { + let lastError = ""; + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + const response = await fetch(`${ALL_DEBRID_API_BASE}/link/unlock`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "RD-Node-Downloader/1.1.12" + }, + body: new URLSearchParams({ link }) + }); + const text = await response.text(); + const payload = asRecord(parseJson(text)); + + if (!response.ok) { + const reason = parseError(response.status, text, payload); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleep(retryDelay(attempt)); + continue; + } + throw new Error(reason); + } + + const status = pickString(payload, ["status"]); + if (status && status.toLowerCase() === "error") { + const errorObj = asRecord(payload?.error); + throw new Error(pickString(errorObj, ["message", "code"]) || "AllDebrid API error"); + } + + const data = asRecord(payload?.data); + const directUrl = pickString(data, ["link"]); + if (!directUrl) { + throw new Error("AllDebrid Antwort ohne Download-Link"); + } + + return { + fileName: pickString(data, ["filename"]) || filenameFromUrl(link), + directUrl, + fileSize: pickNumber(data, ["filesize"]), + retriesUsed: attempt - 1 + }; + } catch (error) { + lastError = compactErrorText(error); + if (attempt >= REQUEST_RETRIES) { + break; + } + await sleep(retryDelay(attempt)); + } + } + + throw new Error(lastError || "AllDebrid Unrestrict fehlgeschlagen"); + } +} + +export class DebridService { + private settings: AppSettings; + + private realDebridClient: RealDebridClient; + + private allDebridClient: AllDebridClient; + + public constructor(settings: AppSettings) { + this.settings = settings; + this.realDebridClient = new RealDebridClient(settings.token); + this.allDebridClient = new AllDebridClient(settings.allDebridToken); + } + + public setSettings(next: AppSettings): void { + this.settings = next; + this.realDebridClient = new RealDebridClient(next.token); + this.allDebridClient = new AllDebridClient(next.allDebridToken); + } + + public async unrestrictLink(link: string): Promise { + const order = uniqueProviderOrder([ + this.settings.providerPrimary, + this.settings.providerSecondary, + this.settings.providerTertiary, + "realdebrid", + "megadebrid", + "bestdebrid", + "alldebrid" + ]); + + let configuredFound = false; + const attempts: string[] = []; + + for (const provider of order) { + const token = this.getProviderToken(provider).trim(); + if (!token) { + continue; + } + configuredFound = true; + if (!this.settings.autoProviderFallback && attempts.length > 0) { + break; + } + + try { + const result = await this.unrestrictViaProvider(provider, link, token); + return { + ...result, + provider, + providerLabel: PROVIDER_LABELS[provider] + }; + } catch (error) { + attempts.push(`${PROVIDER_LABELS[provider]}: ${compactErrorText(error)}`); + } + } + + if (!configuredFound) { + throw new Error("Kein Debrid-Provider konfiguriert (API-Key fehlt)"); + } + + throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`); + } + + private getProviderToken(provider: DebridProvider): string { + if (provider === "realdebrid") { + return this.settings.token; + } + if (provider === "megadebrid") { + return this.settings.megaToken; + } + if (provider === "alldebrid") { + return this.settings.allDebridToken; + } + return this.settings.bestToken; + } + + private async unrestrictViaProvider(provider: DebridProvider, link: string, token: string): Promise { + if (provider === "realdebrid") { + return this.realDebridClient.unrestrictLink(link); + } + if (provider === "megadebrid") { + return new MegaDebridClient(token).unrestrictLink(link); + } + if (provider === "alldebrid") { + return this.allDebridClient.unrestrictLink(link); + } + return new BestDebridClient(token).unrestrictLink(link); + } +} diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 953ec72..ea10431 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -6,10 +6,10 @@ import { v4 as uuidv4 } from "uuid"; import { AppSettings, DownloadItem, DownloadSummary, DownloadStatus, PackageEntry, ParsedPackageInput, SessionState, UiSnapshot } from "../shared/types"; import { CHUNK_SIZE, REQUEST_RETRIES } from "./constants"; import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; +import { DebridService } from "./debrid"; import { extractPackageArchives } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { logger } from "./logger"; -import { RealDebridClient } from "./realdebrid"; import { StoragePaths, saveSession } from "./storage"; import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, nowMs, sanitizeFilename, sleep } from "./utils"; @@ -47,6 +47,22 @@ function isFinishedStatus(status: DownloadStatus): boolean { return status === "completed" || status === "failed" || status === "cancelled"; } +function providerLabel(provider: DownloadItem["provider"]): string { + if (provider === "realdebrid") { + return "Real-Debrid"; + } + if (provider === "megadebrid") { + return "Mega-Debrid"; + } + if (provider === "bestdebrid") { + return "BestDebrid"; + } + if (provider === "alldebrid") { + return "AllDebrid"; + } + return "Debrid"; +} + function nextAvailablePath(targetPath: string): string { if (!fs.existsSync(targetPath)) { return targetPath; @@ -69,7 +85,7 @@ export class DownloadManager extends EventEmitter { private storagePaths: StoragePaths; - private rdClient: RealDebridClient; + private debridService: DebridService; private activeTasks = new Map(); @@ -88,17 +104,14 @@ export class DownloadManager extends EventEmitter { this.settings = settings; this.session = cloneSession(session); this.storagePaths = storagePaths; - this.rdClient = new RealDebridClient(settings.token); + this.debridService = new DebridService(settings); this.applyOnStartCleanupPolicy(); this.normalizeSessionStatuses(); } public setSettings(next: AppSettings): void { - const tokenChanged = next.token !== this.settings.token; this.settings = next; - if (tokenChanged) { - this.rdClient = new RealDebridClient(next.token); - } + this.debridService.setSettings(next); this.emitState(); } @@ -180,6 +193,7 @@ export class DownloadManager extends EventEmitter { id: itemId, packageId, url: link, + provider: null, status: "queued", retries: 0, speedBps: 0, @@ -278,6 +292,9 @@ export class DownloadManager extends EventEmitter { private normalizeSessionStatuses(): void { for (const item of Object.values(this.session.items)) { + if (item.provider !== "realdebrid" && item.provider !== "megadebrid" && item.provider !== "bestdebrid" && item.provider !== "alldebrid") { + item.provider = null; + } if (item.status === "downloading" || item.status === "validating" || item.status === "extracting" || item.status === "integrity_check") { item.status = "queued"; item.speedBps = 0; @@ -440,7 +457,7 @@ export class DownloadManager extends EventEmitter { } item.status = "validating"; - item.fullStatus = "Link wird via Real-Debrid umgewandelt"; + item.fullStatus = "Link wird umgewandelt"; item.updatedAt = nowMs(); pkg.status = "downloading"; pkg.updatedAt = nowMs(); @@ -475,14 +492,15 @@ export class DownloadManager extends EventEmitter { } try { - const unrestricted = await this.rdClient.unrestrictLink(item.url); + const unrestricted = await this.debridService.unrestrictLink(item.url); + item.provider = unrestricted.provider; item.retries = unrestricted.retriesUsed; item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url)); fs.mkdirSync(pkg.outputDir, { recursive: true }); item.targetPath = nextAvailablePath(path.join(pkg.outputDir, item.fileName)); item.totalBytes = unrestricted.fileSize; item.status = "downloading"; - item.fullStatus = "Download läuft"; + item.fullStatus = `Download läuft (${unrestricted.providerLabel})`; item.updatedAt = nowMs(); this.emitState(); @@ -693,7 +711,7 @@ export class DownloadManager extends EventEmitter { item.speedBps = Math.max(0, Math.floor(speed)); item.downloadedBytes = written; item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0; - item.fullStatus = "Download läuft"; + item.fullStatus = `Download läuft (${providerLabel(item.provider)})`; item.updatedAt = nowMs(); this.emitState(); } diff --git a/src/main/main.ts b/src/main/main.ts index 5ae8243..bc5abc8 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -4,6 +4,7 @@ import { AddLinksPayload, AppSettings } from "../shared/types"; import { AppController } from "./app-controller"; import { IPC_CHANNELS } from "../shared/ipc"; import { logger } from "./logger"; +import { APP_NAME } from "./constants"; let mainWindow: BrowserWindow | null = null; const controller = new AppController(); @@ -19,7 +20,7 @@ function createWindow(): BrowserWindow { minWidth: 1120, minHeight: 760, backgroundColor: "#070b14", - title: `Real-Debrid Download Manager v${controller.getVersion()}`, + title: `${APP_NAME} v${controller.getVersion()}`, webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -30,7 +31,7 @@ function createWindow(): BrowserWindow { if (isDevMode()) { void window.loadURL("http://localhost:5173"); } else { - void window.loadFile(path.join(__dirname, "../renderer/index.html")); + void window.loadFile(path.join(app.getAppPath(), "build", "renderer", "index.html")); } return window; diff --git a/src/main/realdebrid.ts b/src/main/realdebrid.ts index e70f838..7d09941 100644 --- a/src/main/realdebrid.ts +++ b/src/main/realdebrid.ts @@ -38,7 +38,7 @@ export class RealDebridClient { headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "RD-Node-Downloader/1.1.9" + "User-Agent": "RD-Node-Downloader/1.1.12" }, body }); diff --git a/src/main/storage.ts b/src/main/storage.ts index 92cced4..fbddcb8 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -4,6 +4,8 @@ import { AppSettings, SessionState } from "../shared/types"; import { defaultSettings } from "./constants"; import { logger } from "./logger"; +const VALID_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid"]); + export interface StoragePaths { baseDir: string; configFile: string; @@ -33,6 +35,16 @@ export function loadSettings(paths: StoragePaths): AppSettings { ...defaultSettings(), ...parsed }; + if (!VALID_PROVIDERS.has(merged.providerPrimary)) { + merged.providerPrimary = "realdebrid"; + } + if (!VALID_PROVIDERS.has(merged.providerSecondary)) { + merged.providerSecondary = "megadebrid"; + } + if (!VALID_PROVIDERS.has(merged.providerTertiary)) { + merged.providerTertiary = "bestdebrid"; + } + merged.autoProviderFallback = Boolean(merged.autoProviderFallback); merged.maxParallel = Math.max(1, Math.min(50, Number(merged.maxParallel) || 4)); merged.speedLimitKbps = Math.max(0, Math.min(500000, Number(merged.speedLimitKbps) || 0)); merged.reconnectWaitSeconds = Math.max(10, Math.min(600, Number(merged.reconnectWaitSeconds) || 45)); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index becb8aa..94cf791 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,12 +1,19 @@ import { DragEvent, ReactElement, useEffect, useMemo, useState } from "react"; -import type { AppSettings, DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; +import type { AppSettings, DebridProvider, DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; type Tab = "collector" | "downloads" | "settings"; const emptySnapshot = (): UiSnapshot => ({ settings: { token: "", + megaToken: "", + bestToken: "", + allDebridToken: "", rememberToken: true, + providerPrimary: "realdebrid", + providerSecondary: "megadebrid", + providerTertiary: "bestdebrid", + autoProviderFallback: true, outputDir: "", packageName: "", autoExtract: true, @@ -58,6 +65,13 @@ const cleanupLabels: Record = { package_done: "Sobald Paket fertig ist" }; +const providerLabels: Record = { + realdebrid: "Real-Debrid", + megadebrid: "Mega-Debrid", + bestdebrid: "BestDebrid", + alldebrid: "AllDebrid" +}; + export function App(): ReactElement { const [snapshot, setSnapshot] = useState(emptySnapshot); const [tab, setTab] = useState("collector"); @@ -144,8 +158,8 @@ export function App(): ReactElement {
-

Real-Debrid Download Manager

- JDownloader-Style Workflow +

Debrid Download Manager

+ Multi-Provider Workflow
{snapshot.speedText}
@@ -197,20 +211,64 @@ export function App(): ReactElement { {tab === "collector" && (
-

Authentifizierung

- +

Debrid Provider

+ setText("token", event.target.value)} /> + + setText("megaToken", event.target.value)} + /> + + setText("bestToken", event.target.value)} + /> + + setText("allDebridToken", event.target.value)} + /> + + + + + + + setText("updateRepo", event.target.value)} /> @@ -363,6 +421,7 @@ function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: Downl Datei + Provider Status Fortschritt Speed @@ -373,6 +432,7 @@ function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: Downl {items.map((item) => ( {item.fileName} + {item.provider ? providerLabels[item.provider] : "-"} {item.fullStatus} {item.progressPercent}% {item.speedBps > 0 ? `${Math.floor(item.speedBps / 1024)} KB/s` : "0 KB/s"} diff --git a/src/renderer/index.html b/src/renderer/index.html index 32464b3..1341022 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -3,7 +3,7 @@ - Real-Debrid Download Manager + Debrid Download Manager
diff --git a/src/shared/types.ts b/src/shared/types.ts index ed62986..556a81d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -14,10 +14,18 @@ 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"; export interface AppSettings { token: string; + megaToken: string; + bestToken: string; + allDebridToken: string; rememberToken: boolean; + providerPrimary: DebridProvider; + providerSecondary: DebridProvider; + providerTertiary: DebridProvider; + autoProviderFallback: boolean; outputDir: string; packageName: string; autoExtract: boolean; @@ -45,6 +53,7 @@ export interface DownloadItem { id: string; packageId: string; url: string; + provider: DebridProvider | null; status: DownloadStatus; retries: number; speedBps: number; diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts new file mode 100644 index 0000000..b21c659 --- /dev/null +++ b/tests/debrid.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { DebridService } from "../src/main/debrid"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("debrid service", () => { + it("falls back to Mega-Debrid when Real-Debrid fails", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + megaToken: "mega-token", + bestToken: "", + providerPrimary: "realdebrid" as const, + providerSecondary: "megadebrid" as const, + providerTertiary: "bestdebrid" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response(JSON.stringify({ error: "traffic_limit" }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("mega-debrid.eu/api.php?action=getLink")) { + return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega.example/file.bin" }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://rapidgator.net/file/example.part1.rar.html"); + expect(result.provider).toBe("megadebrid"); + expect(result.directUrl).toBe("https://mega.example/file.bin"); + }); + + it("does not fallback when auto fallback is disabled", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + megaToken: "mega-token", + providerPrimary: "realdebrid" as const, + providerSecondary: "megadebrid" as const, + providerTertiary: "bestdebrid" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response("traffic exhausted", { status: 429 }); + } + return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega.example/file.bin" }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://rapidgator.net/file/example.part2.rar.html")).rejects.toThrow(); + }); + + it("supports BestDebrid auth query fallback", async () => { + const settings = { + ...defaultSettings(), + token: "", + megaToken: "", + bestToken: "best-token", + providerPrimary: "bestdebrid" as const, + providerSecondary: "realdebrid" as const, + providerTertiary: "megadebrid" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/api/v1/generateLink?link=")) { + return new Response(JSON.stringify({ message: "Bad token, expired, or invalid" }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("/api/v1/generateLink?auth=")) { + return new Response(JSON.stringify({ download: "https://best.example/file.bin", filename: "file.bin", filesize: 2048 }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://rapidgator.net/file/example.part3.rar.html"); + expect(result.provider).toBe("bestdebrid"); + expect(result.fileSize).toBe(2048); + }); + + it("supports AllDebrid unlock", async () => { + const settings = { + ...defaultSettings(), + token: "", + megaToken: "", + bestToken: "", + allDebridToken: "ad-token", + providerPrimary: "alldebrid" as const, + providerSecondary: "realdebrid" as const, + providerTertiary: "megadebrid" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.alldebrid.com/v4/link/unlock")) { + return new Response(JSON.stringify({ + status: "success", + data: { + link: "https://alldebrid.example/file.bin", + filename: "file.bin", + filesize: 4096 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://rapidgator.net/file/example.part4.rar.html"); + expect(result.provider).toBe("alldebrid"); + expect(result.directUrl).toBe("https://alldebrid.example/file.bin"); + expect(result.fileSize).toBe(4096); + }); +});