♻️ refactor(bestdebrid): switch from browser login to cookie file import

Replace BrowserWindow-based login flow with Netscape cookie file import
for BestDebrid authentication. Cloudflare Turnstile captcha cannot be
solved in Electron's embedded browser, so users export cookies from
their real browser and import them here.

- Rewrite bestdebrid-web.ts: remove BrowserWindow/CDP code, add
  parseNetscapeCookieFile() and importCookiesFromFile()
- Add file picker dialog for .txt cookie files in main IPC handler
- Update IPC channel from OPEN_BESTDEBRID_LOGIN to IMPORT_BESTDEBRID_COOKIES
- Update preload bridge and renderer UI with cookie import button
- Fix pLabel scope in downloadToFile (pass as parameter from processItem)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe
2026-03-06 11:44:23 +01:00
co-authored by Claude Opus 4.6
parent d776ad589d
commit 153318274d
8 changed files with 123 additions and 184 deletions
+84 -162
View File
@@ -1,18 +1,16 @@
import { BrowserWindow, session } from "electron";
import fs from "node:fs";
import { session } from "electron";
import { UnrestrictedLink } from "./realdebrid";
import { filenameFromUrl, sleep } from "./utils";
import { logger } from "./logger";
const BESTDEBRID_BASE_URL = "https://bestdebrid.com";
const BESTDEBRID_LOGIN_URL = `${BESTDEBRID_BASE_URL}/en/downloader/`;
const BESTDEBRID_DOWNLOADER_URL = `${BESTDEBRID_BASE_URL}/en/downloader/`;
const BESTDEBRID_GENERATE_URL = `${BESTDEBRID_BASE_URL}/api/v1/generateLink`;
const BESTDEBRID_PERSISTENT_PARTITION = "persist:bestdebrid-web";
const BESTDEBRID_TRANSIENT_PARTITION = "bestdebrid-web";
const BESTDEBRID_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:bestdebrid-web");
}
@@ -31,35 +29,6 @@ function throwIfAborted(signal?: AbortSignal): void {
}
}
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;
@@ -72,12 +41,44 @@ function parseJson(text: string): Record<string, unknown> | null {
}
}
interface NetscapeCookie {
domain: string;
httpOnly: boolean;
path: string;
secure: boolean;
expirationDate: number;
name: string;
value: string;
}
function parseNetscapeCookieFile(text: string): NetscapeCookie[] {
const cookies: NetscapeCookie[] = [];
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const parts = trimmed.split("\t");
if (parts.length < 7) {
continue;
}
cookies.push({
domain: parts[0],
httpOnly: parts[1].toUpperCase() === "TRUE",
path: parts[2],
secure: parts[3].toUpperCase() === "TRUE",
expirationDate: Number(parts[4]) || 0,
name: parts[5],
value: parts[6]
});
}
return cookies;
}
export class BestDebridWebFallback {
private queue: Promise<unknown> = Promise.resolve();
private loginWindow: BrowserWindow | null = null;
private loginWindowPartition = "";
private cookiesImported = false;
private getRememberSession: () => boolean;
@@ -86,32 +87,60 @@ export class BestDebridWebFallback {
}
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
const overallSignal = withTimeoutSignal(signal, 60_000);
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;
if (!this.cookiesImported) {
throw new Error("BestDebrid: Keine Cookies importiert. Bitte zuerst über Einstellungen eine Cookie-Datei importieren.");
}
return this.waitForLoginAndGenerate(link, overallSignal);
const result = await this.generate(link, overallSignal);
if (result.kind === "success") {
return result.value;
}
throw new Error("BestDebrid: Nicht eingeloggt. Bitte neue Cookie-Datei importieren.");
}, overallSignal);
}
public async openLoginWindow(): Promise<void> {
const window = await this.ensureLoginWindow();
if (window.isMinimized()) {
window.restore();
public async importCookiesFromFile(filePath: string): Promise<number> {
const text = fs.readFileSync(filePath, "utf-8");
const cookies = parseNetscapeCookieFile(text);
const bestDebridCookies = cookies.filter((c) =>
c.domain.includes("bestdebrid.com")
);
if (bestDebridCookies.length === 0) {
throw new Error("Keine BestDebrid-Cookies in der Datei gefunden");
}
window.show();
window.focus();
const currentSession = session.fromPartition(this.getPartition());
currentSession.setUserAgent(BESTDEBRID_USER_AGENT);
for (const cookie of bestDebridCookies) {
const url = `https://${cookie.domain.replace(/^\./, "")}${cookie.path}`;
await currentSession.cookies.set({
url,
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
expirationDate: cookie.expirationDate > 0 ? cookie.expirationDate : undefined
});
}
this.cookiesImported = true;
logger.info(`BestDebrid: ${bestDebridCookies.length} Cookies importiert aus ${filePath}`);
return bestDebridCookies.length;
}
public async clearSessions(): Promise<void> {
this.disposeLoginWindow();
this.cookiesImported = false;
for (const partition of [BESTDEBRID_PERSISTENT_PARTITION, BESTDEBRID_TRANSIENT_PARTITION]) {
const currentSession = session.fromPartition(partition);
try {
@@ -130,22 +159,13 @@ export class BestDebridWebFallback {
}
public dispose(): void {
this.disposeLoginWindow();
// nothing to clean up
}
private getPartition(): string {
return this.getRememberSession() ? BESTDEBRID_PERSISTENT_PARTITION : BESTDEBRID_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;
@@ -162,72 +182,7 @@ export class BestDebridWebFallback {
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();
}
// Set user agent on session level so Cloudflare Turnstile sees a real Chrome
const currentSession = session.fromPartition(partition);
currentSession.setUserAgent(BESTDEBRID_USER_AGENT);
const window = new BrowserWindow({
width: 1120,
height: 900,
minWidth: 980,
minHeight: 760,
autoHideMenuBar: true,
title: "BestDebrid Web-Login",
webPreferences: {
partition,
contextIsolation: true,
nodeIntegration: false
}
});
window.webContents.setUserAgent(BESTDEBRID_USER_AGENT);
window.setMenuBarVisibility(false);
// Inject anti-fingerprint patches via CDP before any page JS runs.
// The debugger must stay attached until after page load so the
// registered scripts actually execute on every new document.
let debuggerAttached = false;
try {
window.webContents.debugger.attach("1.3");
debuggerAttached = true;
await window.webContents.debugger.sendCommand("Page.addScriptToEvaluateOnNewDocument", {
source: [
"Object.defineProperty(navigator, 'webdriver', { get: () => false });",
"Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });",
"Object.defineProperty(navigator, 'languages', { get: () => ['de-DE', 'de', 'en-US', 'en'] });",
"window.chrome = { runtime: {}, loadTimes: function() {}, csi: function() {} };"
].join("\n")
});
} catch {
// CDP not available — continue without patches
}
window.on("closed", () => {
if (debuggerAttached) {
try { window.webContents.debugger.detach(); } catch { /* already detached */ }
}
if (this.loginWindow === window) {
this.loginWindow = null;
this.loginWindowPartition = "";
}
});
this.loginWindow = window;
this.loginWindowPartition = partition;
await window.loadURL(BESTDEBRID_LOGIN_URL);
return window;
}
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> {
throwIfAborted(signal);
const currentSession = session.fromPartition(this.getPartition());
const response = await currentSession.fetch(BESTDEBRID_GENERATE_URL, {
@@ -236,7 +191,7 @@ export class BestDebridWebFallback {
Accept: "application/json, text/javascript, */*; q=0.01",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
Origin: BESTDEBRID_BASE_URL,
Referer: BESTDEBRID_LOGIN_URL,
Referer: BESTDEBRID_DOWNLOADER_URL,
"User-Agent": BESTDEBRID_USER_AGENT,
"X-Requested-With": "XMLHttpRequest"
},
@@ -246,7 +201,6 @@ export class BestDebridWebFallback {
const text = await response.text();
// Not logged in — BestDebrid redirects or returns HTML login page
if (!response.ok || text.trim().startsWith("<!") || text.trim().startsWith("<html")) {
return { kind: "login_required" };
}
@@ -259,9 +213,7 @@ export class BestDebridWebFallback {
const error = Number(payload.error ?? -1);
const message = String(payload.message || "").trim();
// error != 0 means failure
if (error !== 0) {
// Check if it's a login/auth issue
if (/login|log in|sign in|not logged|session|auth/i.test(message)) {
return { kind: "login_required" };
}
@@ -277,11 +229,10 @@ export class BestDebridWebFallback {
const fileSizeRaw = String(payload.size || "").trim();
let fileSize: number | null = null;
if (fileSizeRaw) {
// Size might be like "96.63 MB" — parse it
const match = fileSizeRaw.match(/([\d.]+)\s*(KB|MB|GB|TB|B)/i);
const match = fileSizeRaw.match(/([\d.]+)\s*(KB|KiB|MB|MiB|GB|GiB|TB|TiB|B)/i);
if (match) {
const value = parseFloat(match[1]);
const unit = match[2].toUpperCase();
const unit = match[2].toUpperCase().replace("IB", "B");
const multipliers: Record<string, number> = { B: 1, KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 };
fileSize = Math.floor(value * (multipliers[unit] || 1));
}
@@ -297,33 +248,4 @@ export class BestDebridWebFallback {
}
};
}
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("BestDebrid 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("BestDebrid Web-Login Timeout");
}
}