Add Real-Debrid web-login as alternative to manual API token
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e4a60a033b
commit
2b322968d9
@ -23,6 +23,7 @@ import { fetchAllDebridHostInfo } from "./debrid";
|
|||||||
import { parseCollectorInput } from "./link-parser";
|
import { parseCollectorInput } from "./link-parser";
|
||||||
import { configureLogger, getLogFilePath, logger } from "./logger";
|
import { configureLogger, getLogFilePath, logger } from "./logger";
|
||||||
import { AllDebridWebFallback } from "./all-debrid-web";
|
import { AllDebridWebFallback } from "./all-debrid-web";
|
||||||
|
import { RealDebridWebFallback } from "./realdebrid-web";
|
||||||
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
|
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
|
||||||
import { MegaWebFallback } from "./mega-web-fallback";
|
import { MegaWebFallback } from "./mega-web-fallback";
|
||||||
import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage";
|
import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage";
|
||||||
@ -45,6 +46,8 @@ export class AppController {
|
|||||||
|
|
||||||
private megaWebFallback: MegaWebFallback;
|
private megaWebFallback: MegaWebFallback;
|
||||||
|
|
||||||
|
private realDebridWebFallback: RealDebridWebFallback;
|
||||||
|
|
||||||
private allDebridWebFallback: AllDebridWebFallback;
|
private allDebridWebFallback: AllDebridWebFallback;
|
||||||
|
|
||||||
private lastUpdateCheck: UpdateCheckResult | null = null;
|
private lastUpdateCheck: UpdateCheckResult | null = null;
|
||||||
@ -66,10 +69,12 @@ export class AppController {
|
|||||||
login: this.settings.megaLogin,
|
login: this.settings.megaLogin,
|
||||||
password: this.settings.megaPassword
|
password: this.settings.megaPassword
|
||||||
}));
|
}));
|
||||||
|
this.realDebridWebFallback = new RealDebridWebFallback(() => this.settings.rememberToken);
|
||||||
this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
|
this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
|
||||||
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
|
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
|
||||||
megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal),
|
megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal),
|
||||||
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
|
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
|
||||||
|
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
|
||||||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||||||
onHistoryEntry: (entry: HistoryEntry) => {
|
onHistoryEntry: (entry: HistoryEntry) => {
|
||||||
addHistoryEntry(this.storagePaths, entry);
|
addHistoryEntry(this.storagePaths, entry);
|
||||||
@ -109,6 +114,7 @@ export class AppController {
|
|||||||
private hasAnyProviderToken(settings: AppSettings): boolean {
|
private hasAnyProviderToken(settings: AppSettings): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
settings.token.trim()
|
settings.token.trim()
|
||||||
|
|| settings.realDebridUseWebLogin
|
||||||
|| (settings.megaLogin.trim() && settings.megaPassword.trim())
|
|| (settings.megaLogin.trim() && settings.megaPassword.trim())
|
||||||
|| settings.bestToken.trim()
|
|| settings.bestToken.trim()
|
||||||
|| settings.allDebridUseWebLogin
|
|| settings.allDebridUseWebLogin
|
||||||
@ -168,6 +174,9 @@ export class AppController {
|
|||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
this.manager.setSettings(this.settings);
|
this.manager.setSettings(this.settings);
|
||||||
if (previousSettings.rememberToken && !this.settings.rememberToken) {
|
if (previousSettings.rememberToken && !this.settings.rememberToken) {
|
||||||
|
void this.realDebridWebFallback.clearSessions().catch((error) => {
|
||||||
|
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
|
||||||
|
});
|
||||||
void this.allDebridWebFallback.clearSessions().catch((error) => {
|
void this.allDebridWebFallback.clearSessions().catch((error) => {
|
||||||
logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
|
logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
|
||||||
});
|
});
|
||||||
@ -175,6 +184,10 @@ export class AppController {
|
|||||||
return this.settings;
|
return this.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async openRealDebridLoginWindow(): Promise<void> {
|
||||||
|
await this.realDebridWebFallback.openLoginWindow();
|
||||||
|
}
|
||||||
|
|
||||||
public async openAllDebridLoginWindow(): Promise<void> {
|
public async openAllDebridLoginWindow(): Promise<void> {
|
||||||
await this.allDebridWebFallback.openLoginWindow();
|
await this.allDebridWebFallback.openLoginWindow();
|
||||||
}
|
}
|
||||||
@ -379,6 +392,7 @@ export class AppController {
|
|||||||
abortActiveUpdateDownload();
|
abortActiveUpdateDownload();
|
||||||
this.manager.prepareForShutdown();
|
this.manager.prepareForShutdown();
|
||||||
this.megaWebFallback.dispose();
|
this.megaWebFallback.dispose();
|
||||||
|
this.realDebridWebFallback.dispose();
|
||||||
this.allDebridWebFallback.dispose();
|
this.allDebridWebFallback.dispose();
|
||||||
shutdownSessionLog();
|
shutdownSessionLog();
|
||||||
logger.info("App beendet");
|
logger.info("App beendet");
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export function defaultSettings(): AppSettings {
|
|||||||
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
|
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
|
||||||
return {
|
return {
|
||||||
token: "",
|
token: "",
|
||||||
|
realDebridUseWebLogin: false,
|
||||||
megaLogin: "",
|
megaLogin: "",
|
||||||
megaPassword: "",
|
megaPassword: "",
|
||||||
bestToken: "",
|
bestToken: "",
|
||||||
|
|||||||
@ -31,10 +31,12 @@ interface ProviderUnrestrictedLink extends UnrestrictedLink {
|
|||||||
|
|
||||||
export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
|
export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
|
||||||
export type AllDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
|
export type AllDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
|
||||||
|
export type RealDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
|
||||||
|
|
||||||
interface DebridServiceOptions {
|
interface DebridServiceOptions {
|
||||||
megaWebUnrestrict?: MegaWebUnrestrictor;
|
megaWebUnrestrict?: MegaWebUnrestrictor;
|
||||||
allDebridWebUnrestrict?: AllDebridWebUnrestrictor;
|
allDebridWebUnrestrict?: AllDebridWebUnrestrictor;
|
||||||
|
realDebridWebUnrestrict?: RealDebridWebUnrestrictor;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneSettings(settings: AppSettings): AppSettings {
|
function cloneSettings(settings: AppSettings): AppSettings {
|
||||||
@ -1433,6 +1435,10 @@ export class DebridService {
|
|||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldUseRealDebridWeb(settings: AppSettings): boolean {
|
||||||
|
return Boolean(settings.realDebridUseWebLogin && this.options.realDebridWebUnrestrict);
|
||||||
|
}
|
||||||
|
|
||||||
private shouldUseAllDebridWeb(settings: AppSettings): boolean {
|
private shouldUseAllDebridWeb(settings: AppSettings): boolean {
|
||||||
return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict);
|
return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict);
|
||||||
}
|
}
|
||||||
@ -1556,7 +1562,7 @@ export class DebridService {
|
|||||||
|
|
||||||
private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean {
|
private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean {
|
||||||
if (provider === "realdebrid") {
|
if (provider === "realdebrid") {
|
||||||
return Boolean(settings.token.trim());
|
return Boolean(this.shouldUseRealDebridWeb(settings) || settings.token.trim());
|
||||||
}
|
}
|
||||||
if (provider === "megadebrid") {
|
if (provider === "megadebrid") {
|
||||||
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim() && this.options.megaWebUnrestrict);
|
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim() && this.options.megaWebUnrestrict);
|
||||||
@ -1575,6 +1581,13 @@ export class DebridService {
|
|||||||
|
|
||||||
private async unrestrictViaProvider(settings: AppSettings, provider: DebridProvider, link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
private async unrestrictViaProvider(settings: AppSettings, provider: DebridProvider, link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||||
if (provider === "realdebrid") {
|
if (provider === "realdebrid") {
|
||||||
|
if (this.shouldUseRealDebridWeb(settings) && this.options.realDebridWebUnrestrict) {
|
||||||
|
const result = await this.options.realDebridWebUnrestrict(link, signal);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("Real-Debrid-Web-Fallback nicht verfügbar");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
return new RealDebridClient(settings.token).unrestrictLink(link, signal);
|
return new RealDebridClient(settings.token).unrestrictLink(link, signal);
|
||||||
}
|
}
|
||||||
if (provider === "megadebrid") {
|
if (provider === "megadebrid") {
|
||||||
|
|||||||
@ -38,7 +38,7 @@ function releaseTlsSkip(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
import { AllDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, checkRapidgatorOnline } from "./debrid";
|
import { AllDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline } from "./debrid";
|
||||||
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree } from "./extractor";
|
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree } from "./extractor";
|
||||||
import { validateFileAgainstManifest } from "./integrity";
|
import { validateFileAgainstManifest } from "./integrity";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
@ -158,6 +158,7 @@ type HistoryEntryCallback = (entry: HistoryEntry) => void;
|
|||||||
type DownloadManagerOptions = {
|
type DownloadManagerOptions = {
|
||||||
megaWebUnrestrict?: MegaWebUnrestrictor;
|
megaWebUnrestrict?: MegaWebUnrestrictor;
|
||||||
allDebridWebUnrestrict?: AllDebridWebUnrestrictor;
|
allDebridWebUnrestrict?: AllDebridWebUnrestrictor;
|
||||||
|
realDebridWebUnrestrict?: RealDebridWebUnrestrictor;
|
||||||
invalidateMegaSession?: () => void;
|
invalidateMegaSession?: () => void;
|
||||||
onHistoryEntry?: HistoryEntryCallback;
|
onHistoryEntry?: HistoryEntryCallback;
|
||||||
};
|
};
|
||||||
@ -951,7 +952,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.storagePaths = storagePaths;
|
this.storagePaths = storagePaths;
|
||||||
this.debridService = new DebridService(settings, {
|
this.debridService = new DebridService(settings, {
|
||||||
megaWebUnrestrict: options.megaWebUnrestrict,
|
megaWebUnrestrict: options.megaWebUnrestrict,
|
||||||
allDebridWebUnrestrict: options.allDebridWebUnrestrict
|
allDebridWebUnrestrict: options.allDebridWebUnrestrict,
|
||||||
|
realDebridWebUnrestrict: options.realDebridWebUnrestrict
|
||||||
});
|
});
|
||||||
this.invalidateMegaSessionFn = options.invalidateMegaSession;
|
this.invalidateMegaSessionFn = options.invalidateMegaSession;
|
||||||
this.onHistoryEntryCallback = options.onHistoryEntry;
|
this.onHistoryEntryCallback = options.onHistoryEntry;
|
||||||
|
|||||||
@ -446,6 +446,10 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => {
|
||||||
|
await controller.openRealDebridLoginWindow();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => {
|
ipcMain.handle(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => {
|
||||||
await controller.openAllDebridLoginWindow();
|
await controller.openAllDebridLoginWindow();
|
||||||
});
|
});
|
||||||
|
|||||||
366
src/main/realdebrid-web.ts
Normal file
366
src/main/realdebrid-web.ts
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
import { BrowserWindow, session } from "electron";
|
||||||
|
import { UnrestrictedLink } from "./realdebrid";
|
||||||
|
import { filenameFromUrl, sleep } from "./utils";
|
||||||
|
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
||||||
|
|
||||||
|
const RD_BASE_URL = "https://real-debrid.com";
|
||||||
|
const RD_LOGIN_URL = RD_BASE_URL;
|
||||||
|
const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`;
|
||||||
|
const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`;
|
||||||
|
const RD_PERSISTENT_PARTITION = "persist:realdebrid-web";
|
||||||
|
const RD_TRANSIENT_PARTITION = "realdebrid-web";
|
||||||
|
const RD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
type GenerateOutcome =
|
||||||
|
| { kind: "success"; value: UnrestrictedLink }
|
||||||
|
| { kind: "login_required" };
|
||||||
|
|
||||||
|
function abortError(): Error {
|
||||||
|
return new Error("aborted:realdebrid-web");
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||||
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||||
|
if (!signal) {
|
||||||
|
return timeoutSignal;
|
||||||
|
}
|
||||||
|
return AbortSignal.any([signal, timeoutSignal]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfAborted(signal?: AbortSignal): void {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw abortError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
if (!signal) {
|
||||||
|
await sleep(ms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw abortError();
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, Math.max(0, ms));
|
||||||
|
|
||||||
|
const onAbort = (): void => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
reject(abortError());
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(text: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeHtmlResponse(text: string): boolean {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RealDebridWebFallback {
|
||||||
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
private loginWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
private loginWindowPartition = "";
|
||||||
|
|
||||||
|
private cachedToken = "";
|
||||||
|
|
||||||
|
private cachedTokenAt = 0;
|
||||||
|
|
||||||
|
private getRememberSession: () => boolean;
|
||||||
|
|
||||||
|
public constructor(getRememberSession: () => boolean) {
|
||||||
|
this.getRememberSession = getRememberSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||||
|
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
|
||||||
|
return this.runExclusive(async () => {
|
||||||
|
throwIfAborted(overallSignal);
|
||||||
|
if (!String(link || "").trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = await this.generate(link, overallSignal);
|
||||||
|
if (initial.kind === "success") {
|
||||||
|
return initial.value;
|
||||||
|
}
|
||||||
|
return this.waitForLoginAndGenerate(link, overallSignal);
|
||||||
|
}, overallSignal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async openLoginWindow(): Promise<void> {
|
||||||
|
const window = await this.ensureLoginWindow();
|
||||||
|
if (window.isMinimized()) {
|
||||||
|
window.restore();
|
||||||
|
}
|
||||||
|
window.show();
|
||||||
|
window.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearSessions(): Promise<void> {
|
||||||
|
this.disposeLoginWindow();
|
||||||
|
this.cachedToken = "";
|
||||||
|
this.cachedTokenAt = 0;
|
||||||
|
for (const partition of [RD_PERSISTENT_PARTITION, RD_TRANSIENT_PARTITION]) {
|
||||||
|
const currentSession = session.fromPartition(partition);
|
||||||
|
try {
|
||||||
|
await currentSession.clearStorageData({
|
||||||
|
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await currentSession.clearCache();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
this.disposeLoginWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPartition(): string {
|
||||||
|
return this.getRememberSession() ? RD_PERSISTENT_PARTITION : RD_TRANSIENT_PARTITION;
|
||||||
|
}
|
||||||
|
|
||||||
|
private disposeLoginWindow(): void {
|
||||||
|
const current = this.loginWindow;
|
||||||
|
this.loginWindow = null;
|
||||||
|
this.loginWindowPartition = "";
|
||||||
|
if (current && !current.isDestroyed()) {
|
||||||
|
current.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
|
const queuedAt = Date.now();
|
||||||
|
const queueWaitTimeoutMs = 90_000;
|
||||||
|
const guardedJob = async (): Promise<T> => {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const waited = Date.now() - queuedAt;
|
||||||
|
if (waited > queueWaitTimeoutMs) {
|
||||||
|
throw new Error(`Real-Debrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||||
|
}
|
||||||
|
return job();
|
||||||
|
};
|
||||||
|
const run = this.queue.then(guardedJob, guardedJob);
|
||||||
|
this.queue = run.then(() => undefined, () => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
||||||
|
const partition = this.getPartition();
|
||||||
|
const existing = this.loginWindow;
|
||||||
|
if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
existing.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
width: 1120,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 980,
|
||||||
|
minHeight: 760,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
title: "Real-Debrid Web-Login",
|
||||||
|
webPreferences: {
|
||||||
|
partition,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.setMenuBarVisibility(false);
|
||||||
|
window.webContents.setUserAgent(RD_USER_AGENT);
|
||||||
|
window.on("closed", () => {
|
||||||
|
if (this.loginWindow === window) {
|
||||||
|
this.loginWindow = null;
|
||||||
|
this.loginWindowPartition = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.loginWindow = window;
|
||||||
|
this.loginWindowPartition = partition;
|
||||||
|
await window.loadURL(RD_LOGIN_URL);
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
|
||||||
|
// Return cached token if fresh (max 30 min)
|
||||||
|
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
|
||||||
|
return this.cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSession = session.fromPartition(this.getPartition());
|
||||||
|
const response = await currentSession.fetch(RD_APITOKEN_URL, {
|
||||||
|
headers: {
|
||||||
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
Referer: RD_BASE_URL + "/",
|
||||||
|
"User-Agent": RD_USER_AGENT
|
||||||
|
},
|
||||||
|
signal: withTimeoutSignal(signal, 30_000)
|
||||||
|
});
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
if (!response.ok || response.status === 403) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real-Debrid sets the token via inline JS:
|
||||||
|
// document.querySelectorAll('input[name=private_token]')[0].value = 'TOKEN_HERE';
|
||||||
|
const tokenMatch = html.match(/private_token['"]\]\[0\]\.value\s*=\s*'([^']+)'/);
|
||||||
|
if (tokenMatch && tokenMatch[1]) {
|
||||||
|
this.cachedToken = tokenMatch[1];
|
||||||
|
this.cachedTokenAt = Date.now();
|
||||||
|
return this.cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: look for the token in an input value attribute
|
||||||
|
const inputMatch = html.match(/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/);
|
||||||
|
if (inputMatch && inputMatch[1]) {
|
||||||
|
this.cachedToken = inputMatch[1];
|
||||||
|
this.cachedTokenAt = Date.now();
|
||||||
|
return this.cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
|
||||||
|
const token = await this.extractApiToken(signal);
|
||||||
|
if (!token) {
|
||||||
|
return { kind: "login_required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
try {
|
||||||
|
const body = new URLSearchParams({ link });
|
||||||
|
const response = await fetch(RD_UNRESTRICT_API, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"User-Agent": RD_USER_AGENT
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: withTimeoutSignal(signal, 30_000)
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
// Token expired or revoked — invalidate cache
|
||||||
|
this.cachedToken = "";
|
||||||
|
this.cachedTokenAt = 0;
|
||||||
|
return { kind: "login_required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if ((response.status === 429 || response.status >= 500) && attempt < REQUEST_RETRIES) {
|
||||||
|
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Real-Debrid Web HTTP ${response.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (looksLikeHtmlResponse(text)) {
|
||||||
|
throw new Error("Real-Debrid Web lieferte HTML statt JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseJson(text.trim());
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error("Ungültige JSON-Antwort von Real-Debrid Web");
|
||||||
|
}
|
||||||
|
|
||||||
|
const directUrl = String(payload.download || payload.link || "").trim();
|
||||||
|
if (!directUrl) {
|
||||||
|
throw new Error("Real-Debrid Web: Antwort ohne Download-URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
||||||
|
const fileSizeRaw = Number(payload.filesize ?? NaN);
|
||||||
|
return {
|
||||||
|
kind: "success",
|
||||||
|
value: {
|
||||||
|
directUrl,
|
||||||
|
fileName,
|
||||||
|
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
||||||
|
retriesUsed: attempt - 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw abortError();
|
||||||
|
}
|
||||||
|
if (attempt >= REQUEST_RETRIES) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Real-Debrid Web: Unrestrict fehlgeschlagen");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||||
|
const window = await this.ensureLoginWindow();
|
||||||
|
if (window.isMinimized()) {
|
||||||
|
window.restore();
|
||||||
|
}
|
||||||
|
window.show();
|
||||||
|
window.focus();
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < 10 * 60 * 1000) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
if (window.isDestroyed()) {
|
||||||
|
throw new Error("Real-Debrid Web-Login abgebrochen");
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await this.generate(link, signal);
|
||||||
|
if (outcome.kind === "success") {
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
return outcome.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleepWithSignal(1_500, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Real-Debrid Web-Login Timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -107,6 +107,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
const defaults = defaultSettings();
|
const defaults = defaultSettings();
|
||||||
const normalized: AppSettings = {
|
const normalized: AppSettings = {
|
||||||
token: asText(settings.token),
|
token: asText(settings.token),
|
||||||
|
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
|
||||||
megaLogin: asText(settings.megaLogin),
|
megaLogin: asText(settings.megaLogin),
|
||||||
megaPassword: asText(settings.megaPassword),
|
megaPassword: asText(settings.megaPassword),
|
||||||
bestToken: asText(settings.bestToken),
|
bestToken: asText(settings.bestToken),
|
||||||
@ -201,6 +202,7 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings {
|
|||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
token: "",
|
token: "",
|
||||||
|
realDebridUseWebLogin: settings.realDebridUseWebLogin,
|
||||||
megaLogin: "",
|
megaLogin: "",
|
||||||
megaPassword: "",
|
megaPassword: "",
|
||||||
bestToken: "",
|
bestToken: "",
|
||||||
|
|||||||
@ -52,6 +52,7 @@ const api: ElectronApi = {
|
|||||||
importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
|
importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
|
||||||
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||||
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
||||||
|
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
||||||
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||||
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
|
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
|
||||||
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
||||||
|
|||||||
@ -63,7 +63,7 @@ const emptyStats = (): DownloadStats => ({
|
|||||||
|
|
||||||
const emptySnapshot = (): UiSnapshot => ({
|
const emptySnapshot = (): UiSnapshot => ({
|
||||||
settings: {
|
settings: {
|
||||||
token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "",
|
token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "",
|
||||||
archivePasswordList: "",
|
archivePasswordList: "",
|
||||||
rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid",
|
rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid",
|
||||||
providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "",
|
providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "",
|
||||||
@ -982,7 +982,7 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const configuredProviders = useMemo(() => {
|
const configuredProviders = useMemo(() => {
|
||||||
const list: DebridProvider[] = [];
|
const list: DebridProvider[] = [];
|
||||||
if (settingsDraft.token.trim()) {
|
if (settingsDraft.realDebridUseWebLogin || settingsDraft.token.trim()) {
|
||||||
list.push("realdebrid");
|
list.push("realdebrid");
|
||||||
}
|
}
|
||||||
if (settingsDraft.megaLogin.trim() && settingsDraft.megaPassword.trim()) {
|
if (settingsDraft.megaLogin.trim() && settingsDraft.megaPassword.trim()) {
|
||||||
@ -995,7 +995,7 @@ export function App(): ReactElement {
|
|||||||
list.push("alldebrid");
|
list.push("alldebrid");
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken, settingsDraft.allDebridUseWebLogin]);
|
}, [settingsDraft.token, settingsDraft.realDebridUseWebLogin, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken, settingsDraft.allDebridUseWebLogin]);
|
||||||
|
|
||||||
// DDownload is a direct file hoster (not a debrid service) and is used automatically
|
// DDownload is a direct file hoster (not a debrid service) and is used automatically
|
||||||
// for ddownload.com/ddl.to URLs. It counts as a configured account but does not
|
// for ddownload.com/ddl.to URLs. It counts as a configured account but does not
|
||||||
@ -1136,6 +1136,16 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onOpenRealDebridLogin = async (): Promise<void> => {
|
||||||
|
await performQuickAction(async () => {
|
||||||
|
await persistDraftSettings();
|
||||||
|
await window.rd.openRealDebridLogin();
|
||||||
|
showToast("Real-Debrid Login-Fenster geöffnet", 2200);
|
||||||
|
}, (error) => {
|
||||||
|
showToast(`Real-Debrid Login fehlgeschlagen: ${String(error)}`, 2800);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onOpenAllDebridLogin = async (): Promise<void> => {
|
const onOpenAllDebridLogin = async (): Promise<void> => {
|
||||||
await performQuickAction(async () => {
|
await performQuickAction(async () => {
|
||||||
await persistDraftSettings();
|
await persistDraftSettings();
|
||||||
@ -2820,6 +2830,13 @@ export function App(): ReactElement {
|
|||||||
<h3>Accounts</h3>
|
<h3>Accounts</h3>
|
||||||
<label>Real-Debrid API Token</label>
|
<label>Real-Debrid API Token</label>
|
||||||
<input type="password" value={settingsDraft.token} onChange={(e) => setText("token", e.target.value)} />
|
<input type="password" value={settingsDraft.token} onChange={(e) => setText("token", e.target.value)} />
|
||||||
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.realDebridUseWebLogin} onChange={(e) => setBool("realDebridUseWebLogin", e.target.checked)} /> Real-Debrid per Web-Login statt API-Token verwenden</label>
|
||||||
|
{settingsDraft.realDebridUseWebLogin && (
|
||||||
|
<>
|
||||||
|
<div className="hint">Beim ersten Link oder über den Button unten öffnet sich ein Real-Debrid-Browserfenster. Der Login läuft dort manuell über die Website.</div>
|
||||||
|
<button className="btn" disabled={actionBusy} onClick={() => { void onOpenRealDebridLogin(); }}>Real-Debrid Web-Login öffnen</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<label>Mega-Debrid Login</label>
|
<label>Mega-Debrid Login</label>
|
||||||
<input value={settingsDraft.megaLogin} onChange={(e) => setText("megaLogin", e.target.value)} />
|
<input value={settingsDraft.megaLogin} onChange={(e) => setText("megaLogin", e.target.value)} />
|
||||||
<label>Mega-Debrid Passwort</label>
|
<label>Mega-Debrid Passwort</label>
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export const IPC_CHANNELS = {
|
|||||||
IMPORT_BACKUP: "app:import-backup",
|
IMPORT_BACKUP: "app:import-backup",
|
||||||
OPEN_LOG: "app:open-log",
|
OPEN_LOG: "app:open-log",
|
||||||
OPEN_SESSION_LOG: "app:open-session-log",
|
OPEN_SESSION_LOG: "app:open-session-log",
|
||||||
|
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
||||||
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
||||||
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
|
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
|
||||||
RETRY_EXTRACTION: "queue:retry-extraction",
|
RETRY_EXTRACTION: "queue:retry-extraction",
|
||||||
|
|||||||
@ -47,6 +47,7 @@ export interface ElectronApi {
|
|||||||
importBackup: () => Promise<{ restored: boolean; message: string }>;
|
importBackup: () => Promise<{ restored: boolean; message: string }>;
|
||||||
openLog: () => Promise<void>;
|
openLog: () => Promise<void>;
|
||||||
openSessionLog: () => Promise<void>;
|
openSessionLog: () => Promise<void>;
|
||||||
|
openRealDebridLogin: () => Promise<void>;
|
||||||
openAllDebridLogin: () => Promise<void>;
|
openAllDebridLogin: () => Promise<void>;
|
||||||
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
|
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
|
||||||
retryExtraction: (packageId: string) => Promise<void>;
|
retryExtraction: (packageId: string) => Promise<void>;
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export interface DownloadStats {
|
|||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
token: string;
|
token: string;
|
||||||
|
realDebridUseWebLogin: boolean;
|
||||||
megaLogin: string;
|
megaLogin: string;
|
||||||
megaPassword: string;
|
megaPassword: string;
|
||||||
bestToken: string;
|
bestToken: string;
|
||||||
|
|||||||
@ -325,6 +325,78 @@ describe("debrid service", () => {
|
|||||||
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-alldebrid-web")).rejects.toThrow(/nicht konfiguriert/i);
|
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-alldebrid-web")).rejects.toThrow(/nicht konfiguriert/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses Real-Debrid web path when enabled", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
realDebridUseWebLogin: true,
|
||||||
|
providerPrimary: "realdebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 }));
|
||||||
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const realDebridWeb = vi.fn(async () => ({
|
||||||
|
fileName: "from-rd-web.rar",
|
||||||
|
directUrl: "https://download.real-debrid.com/d/example/from-rd-web.rar",
|
||||||
|
fileSize: 5678,
|
||||||
|
retriesUsed: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = new DebridService(settings, { realDebridWebUnrestrict: realDebridWeb });
|
||||||
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html");
|
||||||
|
expect(result.provider).toBe("realdebrid");
|
||||||
|
expect(result.directUrl).toContain("real-debrid.com/d/");
|
||||||
|
expect(result.fileSize).toBe(5678);
|
||||||
|
expect(realDebridWeb).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats Real-Debrid web mode as not configured when callback is unavailable and no token", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
realDebridUseWebLogin: true,
|
||||||
|
providerPrimary: "realdebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new DebridService(settings);
|
||||||
|
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-rd-web")).rejects.toThrow(/nicht konfiguriert/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to API token when Real-Debrid web login is disabled", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
realDebridUseWebLogin: false,
|
||||||
|
providerPrimary: "realdebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.fetch = (async () => new Response(JSON.stringify({
|
||||||
|
download: "https://download.real-debrid.com/d/test/file.rar",
|
||||||
|
filename: "file.rar",
|
||||||
|
filesize: 9999
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
})) as typeof fetch;
|
||||||
|
|
||||||
|
const realDebridWeb = vi.fn(async () => null);
|
||||||
|
const service = new DebridService(settings, { realDebridWebUnrestrict: realDebridWeb });
|
||||||
|
const result = await service.unrestrictLink("https://rapidgator.net/file/test.rar.html");
|
||||||
|
expect(result.provider).toBe("realdebrid");
|
||||||
|
expect(realDebridWeb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("treats MegaDebrid as not configured when web fallback callback is unavailable", async () => {
|
it("treats MegaDebrid as not configured when web fallback callback is unavailable", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
|
|||||||
@ -155,6 +155,22 @@ describe("settings storage", () => {
|
|||||||
expect(normalized.archivePasswordList).toBe("one\ntwo\nthree");
|
expect(normalized.archivePasswordList).toBe("one\ntwo\nthree");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults Real-Debrid web login to disabled and normalizes the flag", () => {
|
||||||
|
expect(defaultSettings().realDebridUseWebLogin).toBe(false);
|
||||||
|
|
||||||
|
const normalizedEnabled = normalizeSettings({
|
||||||
|
...defaultSettings(),
|
||||||
|
realDebridUseWebLogin: 1 as unknown as boolean
|
||||||
|
});
|
||||||
|
expect(normalizedEnabled.realDebridUseWebLogin).toBe(true);
|
||||||
|
|
||||||
|
const normalizedDisabled = normalizeSettings({
|
||||||
|
...defaultSettings(),
|
||||||
|
realDebridUseWebLogin: 0 as unknown as boolean
|
||||||
|
});
|
||||||
|
expect(normalizedDisabled.realDebridUseWebLogin).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults AllDebrid web login to disabled and normalizes the flag", () => {
|
it("defaults AllDebrid web login to disabled and normalizes the flag", () => {
|
||||||
expect(defaultSettings().allDebridUseWebLogin).toBe(false);
|
expect(defaultSettings().allDebridUseWebLogin).toBe(false);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user