diff --git a/src/main/realdebrid-web.ts b/src/main/realdebrid-web.ts index 79df52b..035ae28 100644 --- a/src/main/realdebrid-web.ts +++ b/src/main/realdebrid-web.ts @@ -79,6 +79,31 @@ function looksLikeHtmlResponse(text: string): boolean { return trimmed.startsWith("]*value=['"]([^'"]+)['"]/i, + /value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i + ]; + + for (const pattern of patterns) { + const match = normalized.match(pattern); + const token = match?.[1]?.trim(); + if (token) { + return token; + } + } + + return null; +} + export class RealDebridWebFallback { private queue: Promise = Promise.resolve(); @@ -119,6 +144,7 @@ export class RealDebridWebFallback { } window.show(); window.focus(); + void this.primeTokenFromWindow(window); } public async clearSessions(): Promise { @@ -161,7 +187,7 @@ export class RealDebridWebFallback { private async runExclusive(job: () => Promise, signal?: AbortSignal): Promise { const queuedAt = Date.now(); - const queueWaitTimeoutMs = 90_000; + const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000; const guardedJob = async (): Promise => { throwIfAborted(signal); const waited = Date.now() - queuedAt; @@ -201,6 +227,15 @@ export class RealDebridWebFallback { }); window.setMenuBarVisibility(false); window.webContents.setUserAgent(RD_USER_AGENT); + const primeFromWindow = (): void => { + void this.primeTokenFromWindow(window); + }; + window.webContents.on("did-finish-load", primeFromWindow); + window.webContents.on("did-navigate", primeFromWindow); + window.webContents.on("did-navigate-in-page", primeFromWindow); + window.on("close", () => { + void this.primeTokenFromWindow(window); + }); window.on("closed", () => { if (this.loginWindow === window) { this.loginWindow = null; @@ -213,6 +248,92 @@ export class RealDebridWebFallback { return window; } + private rememberToken(token: string): string { + this.cachedToken = token; + this.cachedTokenAt = Date.now(); + return token; + } + + private getActiveLoginWindow(): BrowserWindow | null { + const window = this.loginWindow; + if (!window || window.isDestroyed()) { + return null; + } + if (this.loginWindowPartition !== this.getPartition()) { + return null; + } + return window; + } + + private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal): Promise { + throwIfAborted(signal); + + try { + const rawResult = await window.webContents.executeJavaScript(` + (async () => { + const readTokenFromHtml = (html) => { + const text = String(html || ""); + const patterns = [ + /private_token['"]\\]\\[0\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i, + /getElementsByName\\(\\s*['"]private_token['"]\\s*\\)\\s*\\[\\s*0\\s*\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i, + /querySelector(?:All)?\\(\\s*['"][^'"]*private_token[^'"]*['"]\\s*\\)(?:\\s*\\[\\s*0\\s*\\])?\\.value\\s*=\\s*['"]([^'"]+)['"]/i, + /name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i, + /value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + if (match && match[1]) { + return String(match[1]).trim(); + } + } + return ""; + }; + + const directInput = document.querySelector('input[name="private_token"]'); + if (directInput instanceof HTMLInputElement && directInput.value.trim()) { + return directInput.value.trim(); + } + + const html = document.documentElement ? document.documentElement.outerHTML : ""; + const directToken = readTokenFromHtml(html); + if (directToken) { + return directToken; + } + + try { + const response = await fetch(${JSON.stringify(RD_APITOKEN_URL)}, { + credentials: "include", + cache: "no-store", + headers: { + "X-Requested-With": "XMLHttpRequest" + } + }); + const tokenHtml = await response.text(); + return readTokenFromHtml(tokenHtml); + } catch { + return ""; + } + })(); + `, true); + const token = String(rawResult || "").trim(); + if (token) { + return this.rememberToken(token); + } + } catch { + // ignore window scraping errors and fall back to session fetch + } + + return null; + } + + private async primeTokenFromWindow(window: BrowserWindow): Promise { + try { + await this.extractApiTokenFromWindow(window); + } catch { + // ignore best-effort token warmup failures + } + } + private async extractApiToken(signal?: AbortSignal): Promise { throwIfAborted(signal); @@ -221,6 +342,14 @@ export class RealDebridWebFallback { return this.cachedToken; } + const activeLoginWindow = this.getActiveLoginWindow(); + if (activeLoginWindow) { + const windowToken = await this.extractApiTokenFromWindow(activeLoginWindow, signal); + if (windowToken) { + return windowToken; + } + } + const currentSession = session.fromPartition(this.getPartition()); const response = await currentSession.fetch(RD_APITOKEN_URL, { headers: { @@ -236,21 +365,9 @@ export class RealDebridWebFallback { 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; + const token = extractPrivateTokenFromHtml(html); + if (token) { + return this.rememberToken(token); } return null; diff --git a/tests/realdebrid-web.test.ts b/tests/realdebrid-web.test.ts new file mode 100644 index 0000000..7a3d842 --- /dev/null +++ b/tests/realdebrid-web.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockSessionFetch, + mockClearStorageData, + mockClearCache, + mockFromPartition, + mockBrowserWindow, + mockBrowserWindowCtor, + mockExecuteJavaScript, + mockLoadURL, + mockShow, + mockFocus +} = vi.hoisted(() => { + const sessionFetch = vi.fn(); + const clearStorageData = vi.fn(); + const clearCache = vi.fn(); + const fromPartition = vi.fn(); + const executeJavaScript = vi.fn(); + const loadURL = vi.fn(async () => {}); + const show = vi.fn(); + const focus = vi.fn(); + const webContentsEvents: Record void> = {}; + const windowEvents: Record void> = {}; + let destroyed = false; + + const browserWindow = { + isDestroyed: vi.fn(() => destroyed), + isMinimized: vi.fn(() => false), + restore: vi.fn(), + show, + focus, + close: vi.fn(() => { + destroyed = true; + windowEvents.closed?.(); + }), + setMenuBarVisibility: vi.fn(), + loadURL, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + windowEvents[event] = handler; + return browserWindow; + }), + webContents: { + setUserAgent: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + webContentsEvents[event] = handler; + }), + executeJavaScript + } + }; + + const BrowserWindowCtor = vi.fn(() => { + destroyed = false; + return browserWindow; + }); + + return { + mockSessionFetch: sessionFetch, + mockClearStorageData: clearStorageData, + mockClearCache: clearCache, + mockFromPartition: fromPartition, + mockBrowserWindow: browserWindow, + mockBrowserWindowCtor: BrowserWindowCtor, + mockExecuteJavaScript: executeJavaScript, + mockLoadURL: loadURL, + mockShow: show, + mockFocus: focus + }; +}); + +vi.mock("electron", () => ({ + session: { + fromPartition: mockFromPartition + }, + BrowserWindow: mockBrowserWindowCtor +})); + +import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web"; + +describe("realdebrid-web", () => { + const mockSession = { + fetch: mockSessionFetch, + clearStorageData: mockClearStorageData, + clearCache: mockClearCache + }; + + beforeEach(() => { + mockFromPartition.mockReturnValue(mockSession); + mockExecuteJavaScript.mockReset(); + mockLoadURL.mockClear(); + mockShow.mockClear(); + mockFocus.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + mockFromPartition.mockReturnValue(mockSession); + }); + + it("extracts private tokens from current Real-Debrid HTML patterns", () => { + expect(extractPrivateTokenFromHtml("document.querySelectorAll('input[name=private_token]')[0].value = 'abc123';")) + .toBe("abc123"); + expect(extractPrivateTokenFromHtml("")) + .toBe("def456"); + expect(extractPrivateTokenFromHtml("")) + .toBe("ghi789"); + }); + + it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => { + const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + download: "https://cdn.real-debrid.example/file.bin", + filename: "file.bin", + filesize: 12345 + }), { status: 200 })); + vi.stubGlobal("fetch", apiFetch); + + mockExecuteJavaScript.mockResolvedValue("token-from-window"); + + const fallback = new RealDebridWebFallback(() => true); + await fallback.openLoginWindow(); + + const result = await fallback.unrestrict("https://rapidgator.net/file/abc"); + + expect(result).toEqual({ + directUrl: "https://cdn.real-debrid.example/file.bin", + fileName: "file.bin", + fileSize: 12345, + retriesUsed: 0 + }); + expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1); + expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com"); + expect(mockShow).toHaveBeenCalled(); + expect(mockFocus).toHaveBeenCalled(); + expect(mockSessionFetch).not.toHaveBeenCalled(); + expect(apiFetch).toHaveBeenCalledTimes(1); + expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link"); + expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled(); + }); +});