Add multi-provider fallback with AllDebrid and fix packaged UI path
Some checks are pending
Build and Release / build (push) Waiting to run
Some checks are pending
Build and Release / build (push) Waiting to run
This commit is contained in:
parent
f27584d6ee
commit
cbc1ffa18b
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
39
scripts/debrid_service_smoke.ts
Normal file
39
scripts/debrid_service_smoke.ts
Normal file
@ -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<void> {
|
||||
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();
|
||||
245
scripts/provider_smoke_check.mjs
Normal file
245
scripts/provider_smoke_check.mjs
Normal file
@ -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();
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
408
src/main/debrid.ts
Normal file
408
src/main/debrid.ts
Normal file
@ -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<DebridProvider, string> = {
|
||||
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<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pickString(payload: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<DebridProvider>();
|
||||
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<UnrestrictedLink> {
|
||||
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<UnrestrictedLink> {
|
||||
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<UnrestrictedLink> {
|
||||
let lastError = "";
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"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<UnrestrictedLink> {
|
||||
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<ProviderUnrestrictedLink> {
|
||||
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<UnrestrictedLink> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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<string, ActiveTask>();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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<string, string> = {
|
||||
package_done: "Sobald Paket fertig ist"
|
||||
};
|
||||
|
||||
const providerLabels: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid",
|
||||
megadebrid: "Mega-Debrid",
|
||||
bestdebrid: "BestDebrid",
|
||||
alldebrid: "AllDebrid"
|
||||
};
|
||||
|
||||
export function App(): ReactElement {
|
||||
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
||||
const [tab, setTab] = useState<Tab>("collector");
|
||||
@ -144,8 +158,8 @@ export function App(): ReactElement {
|
||||
<div className="app-shell">
|
||||
<header className="top-header">
|
||||
<div className="title-block">
|
||||
<h1>Real-Debrid Download Manager</h1>
|
||||
<span>JDownloader-Style Workflow</span>
|
||||
<h1>Debrid Download Manager</h1>
|
||||
<span>Multi-Provider Workflow</span>
|
||||
</div>
|
||||
<div className="metrics">
|
||||
<div>{snapshot.speedText}</div>
|
||||
@ -197,20 +211,64 @@ export function App(): ReactElement {
|
||||
{tab === "collector" && (
|
||||
<section className="grid-two">
|
||||
<article className="card">
|
||||
<h3>Authentifizierung</h3>
|
||||
<label>API Token</label>
|
||||
<h3>Debrid Provider</h3>
|
||||
<label>Real-Debrid API Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settingsDraft.token}
|
||||
onChange={(event) => setText("token", event.target.value)}
|
||||
/>
|
||||
<label>Mega-Debrid API Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settingsDraft.megaToken}
|
||||
onChange={(event) => setText("megaToken", event.target.value)}
|
||||
/>
|
||||
<label>BestDebrid API Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settingsDraft.bestToken}
|
||||
onChange={(event) => setText("bestToken", event.target.value)}
|
||||
/>
|
||||
<label>AllDebrid API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settingsDraft.allDebridToken}
|
||||
onChange={(event) => setText("allDebridToken", event.target.value)}
|
||||
/>
|
||||
<label>Primärer Provider</label>
|
||||
<select value={settingsDraft.providerPrimary} onChange={(event) => setText("providerPrimary", event.target.value)}>
|
||||
{Object.entries(providerLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<label>Sekundärer Provider</label>
|
||||
<select value={settingsDraft.providerSecondary} onChange={(event) => setText("providerSecondary", event.target.value)}>
|
||||
{Object.entries(providerLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<label>Tertiärer Provider</label>
|
||||
<select value={settingsDraft.providerTertiary} onChange={(event) => setText("providerTertiary", event.target.value)}>
|
||||
{Object.entries(providerLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settingsDraft.autoProviderFallback}
|
||||
onChange={(event) => setBool("autoProviderFallback", event.target.checked)}
|
||||
/>
|
||||
Bei Fehler/Fair-Use automatisch zum nächsten Provider wechseln
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settingsDraft.rememberToken}
|
||||
onChange={(event) => setBool("rememberToken", event.target.checked)}
|
||||
/>
|
||||
Token lokal speichern
|
||||
API Keys lokal speichern
|
||||
</label>
|
||||
<label>GitHub Repo</label>
|
||||
<input value={settingsDraft.updateRepo} onChange={(event) => setText("updateRepo", event.target.value)} />
|
||||
@ -363,6 +421,7 @@ function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: Downl
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datei</th>
|
||||
<th>Provider</th>
|
||||
<th>Status</th>
|
||||
<th>Fortschritt</th>
|
||||
<th>Speed</th>
|
||||
@ -373,6 +432,7 @@ function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: Downl
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.fileName}</td>
|
||||
<td>{item.provider ? providerLabels[item.provider] : "-"}</td>
|
||||
<td title={item.fullStatus}>{item.fullStatus}</td>
|
||||
<td>{item.progressPercent}%</td>
|
||||
<td>{item.speedBps > 0 ? `${Math.floor(item.speedBps / 1024)} KB/s` : "0 KB/s"}</td>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Real-Debrid Download Manager</title>
|
||||
<title>Debrid Download Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -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;
|
||||
|
||||
145
tests/debrid.test.ts
Normal file
145
tests/debrid.test.ts
Normal file
@ -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<Response> => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user