Release v1.6.69
This commit is contained in:
parent
18e4b6cd58
commit
e4a60a033b
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.5.66",
|
"version": "1.6.69",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.5.66",
|
"version": "1.6.69",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.6.66",
|
"version": "1.6.69",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
484
src/main/all-debrid-web.ts
Normal file
484
src/main/all-debrid-web.ts
Normal file
@ -0,0 +1,484 @@
|
|||||||
|
import { BrowserWindow, session } from "electron";
|
||||||
|
import { AllDebridHostInfo } from "../shared/types";
|
||||||
|
import { UnrestrictedLink } from "./realdebrid";
|
||||||
|
import { filenameFromUrl, sleep } from "./utils";
|
||||||
|
|
||||||
|
const ALLDEBRID_BASE_URL = "https://alldebrid.com";
|
||||||
|
const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`;
|
||||||
|
const ALLDEBRID_SERVICE_URL = `${ALLDEBRID_BASE_URL}/service.php`;
|
||||||
|
const ALLDEBRID_SERVICE_REFERER = `${ALLDEBRID_BASE_URL}/service/?from=de`;
|
||||||
|
const ALLDEBRID_DELAYED_URL = `${ALLDEBRID_BASE_URL}/internalapi/v4/link/delayed`;
|
||||||
|
const ALLDEBRID_STATUS_URL = `${ALLDEBRID_BASE_URL}/status/`;
|
||||||
|
const ALLDEBRID_PERSISTENT_PARTITION = "persist:alldebrid-web";
|
||||||
|
const ALLDEBRID_TRANSIENT_PARTITION = "alldebrid-web";
|
||||||
|
const ALLDEBRID_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 DelayedStatusPayload = {
|
||||||
|
status: number;
|
||||||
|
link: string;
|
||||||
|
timeLeft: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GenerateOutcome =
|
||||||
|
| { kind: "success"; value: UnrestrictedLink }
|
||||||
|
| { kind: "login_required" };
|
||||||
|
|
||||||
|
function abortError(): Error {
|
||||||
|
return new Error("aborted:alldebrid-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 asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 parseJson(text: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
return asRecord(JSON.parse(text) as unknown);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHostName(value: string): string {
|
||||||
|
return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHostStateFromIcon(url: string): AllDebridHostInfo["state"] {
|
||||||
|
const normalized = String(url || "").toLowerCase();
|
||||||
|
if (normalized.includes("up.gif")) {
|
||||||
|
return "up";
|
||||||
|
}
|
||||||
|
if (normalized.includes("down.gif")) {
|
||||||
|
return "down";
|
||||||
|
}
|
||||||
|
if (normalized.includes("not.tracked")) {
|
||||||
|
return "not_tracked";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHostStatusLabel(state: AllDebridHostInfo["state"]): string {
|
||||||
|
if (state === "up") {
|
||||||
|
return "Verfügbar";
|
||||||
|
}
|
||||||
|
if (state === "down") {
|
||||||
|
return "Unverfügbar";
|
||||||
|
}
|
||||||
|
if (state === "not_tracked") {
|
||||||
|
return "Nicht getrackt";
|
||||||
|
}
|
||||||
|
return "Unbekannt";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHostInfoFromStatusPage(html: string, host: string): AllDebridHostInfo | null {
|
||||||
|
const wanted = normalizeHostName(host);
|
||||||
|
const rowRegex = /<tr class=['"]g1['"]>\s*<td[^>]*>[\s\S]*?<i[^>]*alt=['"]([^'"]+)['"][^>]*>[\s\S]*?<\/td>\s*<td[^>]*class=['"]comparatif_content['"][^>]*>[\s\S]*?<img[^>]*src=['"]([^'"]+)['"][^>]*>[\s\S]*?\((?:<span[^>]*data-fdate=['"](\d+)['"][^>]*><\/span>|([^<)]*))\)/gi;
|
||||||
|
|
||||||
|
for (let match = rowRegex.exec(html); match; match = rowRegex.exec(html)) {
|
||||||
|
const hostAlt = normalizeHostName(match[1] || "");
|
||||||
|
if (hostAlt !== wanted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = toHostStateFromIcon(match[2] || "");
|
||||||
|
const lastCheckedSeconds = Number(match[3] ?? NaN);
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
source: "web",
|
||||||
|
state,
|
||||||
|
statusLabel: toHostStatusLabel(state),
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
lastCheckedAt: Number.isFinite(lastCheckedSeconds) ? lastCheckedSeconds * 1000 : null,
|
||||||
|
quota: null,
|
||||||
|
quotaMax: null,
|
||||||
|
quotaType: "",
|
||||||
|
limitSimuDl: null,
|
||||||
|
note: "Quota und Simultan-Slots sind per Web-Login nicht öffentlich verfügbar."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AllDebridWebFallback {
|
||||||
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
private loginWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
private loginWindowPartition = "";
|
||||||
|
|
||||||
|
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 getHostInfo(host: string): Promise<AllDebridHostInfo> {
|
||||||
|
const currentSession = session.fromPartition(this.getPartition());
|
||||||
|
const response = await currentSession.fetch(ALLDEBRID_STATUS_URL, {
|
||||||
|
headers: {
|
||||||
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
Referer: ALLDEBRID_SERVICE_REFERER,
|
||||||
|
"User-Agent": ALLDEBRID_USER_AGENT
|
||||||
|
},
|
||||||
|
signal: withTimeoutSignal(undefined, 30_000)
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`AllDebrid Web Status HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
if (!/id=['"]statusContainer['"]/i.test(text)) {
|
||||||
|
throw new Error("AllDebrid Web-Status nicht verfügbar. Bitte zuerst im AllDebrid-Fenster einloggen.");
|
||||||
|
}
|
||||||
|
const info = extractHostInfoFromStatusPage(text, host);
|
||||||
|
if (!info) {
|
||||||
|
throw new Error(`AllDebrid Web-Status für ${host} nicht gefunden`);
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearSessions(): Promise<void> {
|
||||||
|
this.disposeLoginWindow();
|
||||||
|
for (const partition of [ALLDEBRID_PERSISTENT_PARTITION, ALLDEBRID_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() ? ALLDEBRID_PERSISTENT_PARTITION : ALLDEBRID_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(`AllDebrid-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: "AllDebrid Web-Login",
|
||||||
|
webPreferences: {
|
||||||
|
partition,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.setMenuBarVisibility(false);
|
||||||
|
window.on("closed", () => {
|
||||||
|
if (this.loginWindow === window) {
|
||||||
|
this.loginWindow = null;
|
||||||
|
this.loginWindowPartition = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.loginWindow = window;
|
||||||
|
this.loginWindowPartition = partition;
|
||||||
|
await window.loadURL(ALLDEBRID_LOGIN_URL);
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postForm(
|
||||||
|
url: string,
|
||||||
|
body: URLSearchParams,
|
||||||
|
referer: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ response: Response; text: string }> {
|
||||||
|
const currentSession = session.fromPartition(this.getPartition());
|
||||||
|
const response = await currentSession.fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json, text/javascript, */*; q=0.01",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
Origin: ALLDEBRID_BASE_URL,
|
||||||
|
Referer: referer,
|
||||||
|
"User-Agent": ALLDEBRID_USER_AGENT,
|
||||||
|
"X-Requested-With": "XMLHttpRequest"
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
signal: withTimeoutSignal(signal, 30_000)
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
return { response, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
link,
|
||||||
|
nb: "0",
|
||||||
|
json: "true",
|
||||||
|
pw: ""
|
||||||
|
});
|
||||||
|
const { response, text } = await this.postForm(ALLDEBRID_SERVICE_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`AllDebrid Web HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed === "login") {
|
||||||
|
return { kind: "login_required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseJson(trimmed);
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error("AllDebrid Web lieferte keine JSON-Antwort");
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorText = pickString(payload, ["error"]);
|
||||||
|
if (errorText) {
|
||||||
|
if (errorText.toLowerCase() === "premium") {
|
||||||
|
throw new Error("AllDebrid Web: Premium erforderlich");
|
||||||
|
}
|
||||||
|
throw new Error(`AllDebrid Web: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directUrl = pickString(payload, ["link"]);
|
||||||
|
const fileName = pickString(payload, ["filename"]);
|
||||||
|
const fileSize = pickNumber(payload, ["filesize"]);
|
||||||
|
if (directUrl) {
|
||||||
|
return {
|
||||||
|
kind: "success",
|
||||||
|
value: {
|
||||||
|
directUrl,
|
||||||
|
fileName: fileName || filenameFromUrl(directUrl) || filenameFromUrl(link),
|
||||||
|
fileSize,
|
||||||
|
retriesUsed: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayedId = payload.delayed;
|
||||||
|
if (delayedId !== undefined && delayedId !== null && delayedId !== false && String(delayedId).trim()) {
|
||||||
|
const delayed = await this.waitForDelayedLink(String(delayedId).trim(), signal);
|
||||||
|
return {
|
||||||
|
kind: "success",
|
||||||
|
value: {
|
||||||
|
directUrl: delayed.link,
|
||||||
|
fileName: fileName || filenameFromUrl(delayed.link) || filenameFromUrl(link),
|
||||||
|
fileSize: fileSize,
|
||||||
|
retriesUsed: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.streams) && payload.streams.length > 0) {
|
||||||
|
throw new Error("AllDebrid Web: Streaming-Auswahl wird derzeit nicht unterstützt");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("AllDebrid Web: Antwort ohne Download-Link");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForDelayedLink(delayedId: string, signal?: AbortSignal): Promise<DelayedStatusPayload> {
|
||||||
|
for (let attempt = 1; attempt <= 120; attempt += 1) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const body = new URLSearchParams({ id: delayedId });
|
||||||
|
const { response, text } = await this.postForm(ALLDEBRID_DELAYED_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`AllDebrid Web delayed HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const payload = parseJson(text.trim());
|
||||||
|
const data = asRecord(payload?.data);
|
||||||
|
if (pickString(payload, ["status"]).toLowerCase() !== "success" || !data) {
|
||||||
|
throw new Error("AllDebrid Web: Delayed-Status ungültig");
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = Number(data.status ?? NaN);
|
||||||
|
if (!Number.isFinite(status)) {
|
||||||
|
throw new Error("AllDebrid Web: Delayed-Status ohne Status");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 2) {
|
||||||
|
const link = pickString(data, ["link"]);
|
||||||
|
if (!link) {
|
||||||
|
throw new Error("AllDebrid Web: Delayed-Link fehlt");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
link,
|
||||||
|
timeLeft: Math.max(0, Number(data.time_left ?? 0) || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeLeft = Math.max(0, Number(data.time_left ?? 0) || 0);
|
||||||
|
const delayMs = timeLeft > 0 ? Math.min(5_000, Math.max(1_500, timeLeft * 250)) : 2_000;
|
||||||
|
await sleepWithSignal(delayMs, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("AllDebrid Web: Delayed-Link Timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
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("AllDebrid 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("AllDebrid Web-Login Timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import path from "node:path";
|
|||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import {
|
import {
|
||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
@ -18,8 +19,10 @@ import {
|
|||||||
import { importDlcContainers } from "./container";
|
import { importDlcContainers } from "./container";
|
||||||
import { APP_VERSION } from "./constants";
|
import { APP_VERSION } from "./constants";
|
||||||
import { DownloadManager } from "./download-manager";
|
import { DownloadManager } from "./download-manager";
|
||||||
|
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 { 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";
|
||||||
@ -42,6 +45,8 @@ export class AppController {
|
|||||||
|
|
||||||
private megaWebFallback: MegaWebFallback;
|
private megaWebFallback: MegaWebFallback;
|
||||||
|
|
||||||
|
private allDebridWebFallback: AllDebridWebFallback;
|
||||||
|
|
||||||
private lastUpdateCheck: UpdateCheckResult | null = null;
|
private lastUpdateCheck: UpdateCheckResult | null = null;
|
||||||
|
|
||||||
private lastUpdateCheckAt = 0;
|
private lastUpdateCheckAt = 0;
|
||||||
@ -61,8 +66,10 @@ export class AppController {
|
|||||||
login: this.settings.megaLogin,
|
login: this.settings.megaLogin,
|
||||||
password: this.settings.megaPassword
|
password: this.settings.megaPassword
|
||||||
}));
|
}));
|
||||||
|
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),
|
||||||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||||||
onHistoryEntry: (entry: HistoryEntry) => {
|
onHistoryEntry: (entry: HistoryEntry) => {
|
||||||
addHistoryEntry(this.storagePaths, entry);
|
addHistoryEntry(this.storagePaths, entry);
|
||||||
@ -104,6 +111,7 @@ export class AppController {
|
|||||||
settings.token.trim()
|
settings.token.trim()
|
||||||
|| (settings.megaLogin.trim() && settings.megaPassword.trim())
|
|| (settings.megaLogin.trim() && settings.megaPassword.trim())
|
||||||
|| settings.bestToken.trim()
|
|| settings.bestToken.trim()
|
||||||
|
|| settings.allDebridUseWebLogin
|
||||||
|| settings.allDebridToken.trim()
|
|| settings.allDebridToken.trim()
|
||||||
|| (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim())
|
|| (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim())
|
||||||
|| settings.oneFichierApiKey.trim()
|
|| settings.oneFichierApiKey.trim()
|
||||||
@ -143,13 +151,14 @@ export class AppController {
|
|||||||
|
|
||||||
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||||||
const sanitizedPatch = sanitizeSettingsPatch(partial);
|
const sanitizedPatch = sanitizeSettingsPatch(partial);
|
||||||
|
const previousSettings = this.settings;
|
||||||
const nextSettings = normalizeSettings({
|
const nextSettings = normalizeSettings({
|
||||||
...this.settings,
|
...previousSettings,
|
||||||
...sanitizedPatch
|
...sanitizedPatch
|
||||||
});
|
});
|
||||||
|
|
||||||
if (settingsFingerprint(nextSettings) === settingsFingerprint(this.settings)) {
|
if (settingsFingerprint(nextSettings) === settingsFingerprint(previousSettings)) {
|
||||||
return this.settings;
|
return previousSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve the live totalDownloadedAllTime from the download manager
|
// Preserve the live totalDownloadedAllTime from the download manager
|
||||||
@ -158,9 +167,29 @@ export class AppController {
|
|||||||
this.settings = nextSettings;
|
this.settings = nextSettings;
|
||||||
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) {
|
||||||
|
void this.allDebridWebFallback.clearSessions().catch((error) => {
|
||||||
|
logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
return this.settings;
|
return this.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async openAllDebridLoginWindow(): Promise<void> {
|
||||||
|
await this.allDebridWebFallback.openLoginWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> {
|
||||||
|
if (this.settings.allDebridUseWebLogin) {
|
||||||
|
return this.allDebridWebFallback.getHostInfo(host);
|
||||||
|
}
|
||||||
|
const token = this.settings.allDebridToken.trim();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("AllDebrid ist nicht konfiguriert");
|
||||||
|
}
|
||||||
|
return fetchAllDebridHostInfo(token, host);
|
||||||
|
}
|
||||||
|
|
||||||
public async checkUpdates(): Promise<UpdateCheckResult> {
|
public async checkUpdates(): Promise<UpdateCheckResult> {
|
||||||
const result = await checkGitHubUpdate(this.settings.updateRepo);
|
const result = await checkGitHubUpdate(this.settings.updateRepo);
|
||||||
if (!result.error) {
|
if (!result.error) {
|
||||||
@ -350,6 +379,7 @@ export class AppController {
|
|||||||
abortActiveUpdateDownload();
|
abortActiveUpdateDownload();
|
||||||
this.manager.prepareForShutdown();
|
this.manager.prepareForShutdown();
|
||||||
this.megaWebFallback.dispose();
|
this.megaWebFallback.dispose();
|
||||||
|
this.allDebridWebFallback.dispose();
|
||||||
shutdownSessionLog();
|
shutdownSessionLog();
|
||||||
logger.info("App beendet");
|
logger.info("App beendet");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export function defaultSettings(): AppSettings {
|
|||||||
megaPassword: "",
|
megaPassword: "",
|
||||||
bestToken: "",
|
bestToken: "",
|
||||||
allDebridToken: "",
|
allDebridToken: "",
|
||||||
|
allDebridUseWebLogin: false,
|
||||||
ddownloadLogin: "",
|
ddownloadLogin: "",
|
||||||
ddownloadPassword: "",
|
ddownloadPassword: "",
|
||||||
oneFichierApiKey: "",
|
oneFichierApiKey: "",
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types";
|
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types";
|
||||||
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
|
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
||||||
@ -10,6 +10,7 @@ const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024;
|
|||||||
|
|
||||||
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
|
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
|
||||||
const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4";
|
const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4";
|
||||||
|
const ALL_DEBRID_API_BASE_V41 = "https://api.alldebrid.com/v4.1";
|
||||||
|
|
||||||
const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1";
|
const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1";
|
||||||
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
|
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
|
||||||
@ -29,9 +30,11 @@ 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>;
|
||||||
|
|
||||||
interface DebridServiceOptions {
|
interface DebridServiceOptions {
|
||||||
megaWebUnrestrict?: MegaWebUnrestrictor;
|
megaWebUnrestrict?: MegaWebUnrestrictor;
|
||||||
|
allDebridWebUnrestrict?: AllDebridWebUnrestrictor;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneSettings(settings: AppSettings): AppSettings {
|
function cloneSettings(settings: AppSettings): AppSettings {
|
||||||
@ -201,6 +204,43 @@ function parseAllDebridError(payload: Record<string, unknown> | null): string {
|
|||||||
return pickString(errorObj, ["message", "code"]) || "AllDebrid API error";
|
return pickString(errorObj, ["message", "code"]) || "AllDebrid API error";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAllDebridHostKey(value: string): string {
|
||||||
|
return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAllDebridHostState(value: unknown): AllDebridHostInfo["state"] {
|
||||||
|
if (value === true) {
|
||||||
|
return "up";
|
||||||
|
}
|
||||||
|
if (value === false) {
|
||||||
|
return "down";
|
||||||
|
}
|
||||||
|
const normalized = String(value || "").trim().toLowerCase();
|
||||||
|
if (normalized === "up" || normalized === "online" || normalized === "available") {
|
||||||
|
return "up";
|
||||||
|
}
|
||||||
|
if (normalized === "down" || normalized === "offline" || normalized === "unavailable") {
|
||||||
|
return "down";
|
||||||
|
}
|
||||||
|
if (normalized === "not_tracked" || normalized === "not tracked") {
|
||||||
|
return "not_tracked";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAllDebridHostStatusLabel(state: AllDebridHostInfo["state"]): string {
|
||||||
|
if (state === "up") {
|
||||||
|
return "Verfügbar";
|
||||||
|
}
|
||||||
|
if (state === "down") {
|
||||||
|
return "Unverfügbar";
|
||||||
|
}
|
||||||
|
if (state === "not_tracked") {
|
||||||
|
return "Nicht getrackt";
|
||||||
|
}
|
||||||
|
return "Unbekannt";
|
||||||
|
}
|
||||||
|
|
||||||
function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] {
|
function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] {
|
||||||
const seen = new Set<DebridProvider>();
|
const seen = new Set<DebridProvider>();
|
||||||
const result: DebridProvider[] = [];
|
const result: DebridProvider[] = [];
|
||||||
@ -886,6 +926,105 @@ class AllDebridClient {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getHostInfo(host: string, signal?: AbortSignal): Promise<AllDebridHostInfo> {
|
||||||
|
const wanted = normalizeAllDebridHostKey(host);
|
||||||
|
let lastError = "";
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${ALL_DEBRID_API_BASE_V41}/user/hosts`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"User-Agent": DEBRID_USER_AGENT
|
||||||
|
},
|
||||||
|
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||||
|
});
|
||||||
|
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 sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text);
|
||||||
|
if (looksHtml) {
|
||||||
|
throw new Error("AllDebrid lieferte HTML statt JSON");
|
||||||
|
}
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error("AllDebrid Antwort ist kein JSON-Objekt");
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = pickString(payload, ["status"]);
|
||||||
|
if (status && status.toLowerCase() === "error") {
|
||||||
|
throw new Error(parseAllDebridError(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = asRecord(payload.data);
|
||||||
|
const hosts = asRecord(data?.hosts);
|
||||||
|
if (!hosts) {
|
||||||
|
throw new Error("AllDebrid Antwort ohne Host-Liste");
|
||||||
|
}
|
||||||
|
|
||||||
|
let hostEntry = asRecord(hosts[host]) || asRecord(hosts[wanted]);
|
||||||
|
if (!hostEntry) {
|
||||||
|
for (const entry of Object.values(hosts)) {
|
||||||
|
const candidate = asRecord(entry);
|
||||||
|
const candidateName = normalizeAllDebridHostKey(pickString(candidate, ["name"]));
|
||||||
|
if (candidateName === wanted) {
|
||||||
|
hostEntry = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostEntry) {
|
||||||
|
throw new Error(`AllDebrid Host ${host} nicht gefunden`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = toAllDebridHostState(hostEntry.status);
|
||||||
|
const quota = pickNumber(hostEntry, ["quota"]);
|
||||||
|
const quotaMax = pickNumber(hostEntry, ["quotaMax"]);
|
||||||
|
const limitSimuDl = pickNumber(hostEntry, ["limitSimuDl"]);
|
||||||
|
const quotaType = pickString(hostEntry, ["quotaType"]);
|
||||||
|
const note = quota === null && quotaMax === null && limitSimuDl === null
|
||||||
|
? "AllDebrid liefert für diesen Host aktuell keine Quota- oder Slot-Daten."
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
host: pickString(hostEntry, ["name"]) || host,
|
||||||
|
source: "api",
|
||||||
|
state,
|
||||||
|
statusLabel: toAllDebridHostStatusLabel(state),
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
lastCheckedAt: null,
|
||||||
|
quota,
|
||||||
|
quotaMax,
|
||||||
|
quotaType,
|
||||||
|
limitSimuDl,
|
||||||
|
note
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastError = compactErrorText(error);
|
||||||
|
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleepWithSignal(retryDelay(attempt), signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(String(lastError || "AllDebrid Host-Info fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
||||||
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
@ -963,6 +1102,10 @@ class AllDebridClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAllDebridHostInfo(token: string, host = "rapidgator", signal?: AbortSignal): Promise<AllDebridHostInfo> {
|
||||||
|
return new AllDebridClient(token).getHostInfo(host, signal);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 1Fichier Client ──
|
// ── 1Fichier Client ──
|
||||||
|
|
||||||
class OneFichierClient {
|
class OneFichierClient {
|
||||||
@ -1290,6 +1433,10 @@ export class DebridService {
|
|||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldUseAllDebridWeb(settings: AppSettings): boolean {
|
||||||
|
return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict);
|
||||||
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
|
||||||
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
||||||
|
|
||||||
@ -1415,7 +1562,7 @@ export class DebridService {
|
|||||||
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim() && this.options.megaWebUnrestrict);
|
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim() && this.options.megaWebUnrestrict);
|
||||||
}
|
}
|
||||||
if (provider === "alldebrid") {
|
if (provider === "alldebrid") {
|
||||||
return Boolean(settings.allDebridToken.trim());
|
return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim());
|
||||||
}
|
}
|
||||||
if (provider === "ddownload") {
|
if (provider === "ddownload") {
|
||||||
return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim());
|
return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim());
|
||||||
@ -1434,6 +1581,13 @@ export class DebridService {
|
|||||||
return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link, signal);
|
return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link, signal);
|
||||||
}
|
}
|
||||||
if (provider === "alldebrid") {
|
if (provider === "alldebrid") {
|
||||||
|
if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) {
|
||||||
|
const result = await this.options.allDebridWebUnrestrict(link, signal);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("AllDebrid-Web-Fallback nicht verfügbar");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
return new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal);
|
return new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal);
|
||||||
}
|
}
|
||||||
if (provider === "ddownload") {
|
if (provider === "ddownload") {
|
||||||
|
|||||||
@ -38,7 +38,7 @@ function releaseTlsSkip(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
import { DebridService, MegaWebUnrestrictor, checkRapidgatorOnline } from "./debrid";
|
import { AllDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, 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";
|
||||||
@ -157,6 +157,7 @@ type HistoryEntryCallback = (entry: HistoryEntry) => void;
|
|||||||
|
|
||||||
type DownloadManagerOptions = {
|
type DownloadManagerOptions = {
|
||||||
megaWebUnrestrict?: MegaWebUnrestrictor;
|
megaWebUnrestrict?: MegaWebUnrestrictor;
|
||||||
|
allDebridWebUnrestrict?: AllDebridWebUnrestrictor;
|
||||||
invalidateMegaSession?: () => void;
|
invalidateMegaSession?: () => void;
|
||||||
onHistoryEntry?: HistoryEntryCallback;
|
onHistoryEntry?: HistoryEntryCallback;
|
||||||
};
|
};
|
||||||
@ -948,7 +949,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.session = cloneSession(session);
|
this.session = cloneSession(session);
|
||||||
this.itemCount = Object.keys(this.session.items).length;
|
this.itemCount = Object.keys(this.session.items).length;
|
||||||
this.storagePaths = storagePaths;
|
this.storagePaths = storagePaths;
|
||||||
this.debridService = new DebridService(settings, { megaWebUnrestrict: options.megaWebUnrestrict });
|
this.debridService = new DebridService(settings, {
|
||||||
|
megaWebUnrestrict: options.megaWebUnrestrict,
|
||||||
|
allDebridWebUnrestrict: options.allDebridWebUnrestrict
|
||||||
|
});
|
||||||
this.invalidateMegaSessionFn = options.invalidateMegaSession;
|
this.invalidateMegaSessionFn = options.invalidateMegaSession;
|
||||||
this.onHistoryEntryCallback = options.onHistoryEntry;
|
this.onHistoryEntryCallback = options.onHistoryEntry;
|
||||||
this.applyOnStartCleanupPolicy();
|
this.applyOnStartCleanupPolicy();
|
||||||
|
|||||||
@ -446,6 +446,14 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => {
|
||||||
|
await controller.openAllDebridLoginWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO, async () => {
|
||||||
|
return controller.getAllDebridHostInfo();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => {
|
ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
properties: ["openFile"] as Array<"openFile">,
|
properties: ["openFile"] as Array<"openFile">,
|
||||||
|
|||||||
@ -111,6 +111,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
megaPassword: asText(settings.megaPassword),
|
megaPassword: asText(settings.megaPassword),
|
||||||
bestToken: asText(settings.bestToken),
|
bestToken: asText(settings.bestToken),
|
||||||
allDebridToken: asText(settings.allDebridToken),
|
allDebridToken: asText(settings.allDebridToken),
|
||||||
|
allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin),
|
||||||
ddownloadLogin: asText(settings.ddownloadLogin),
|
ddownloadLogin: asText(settings.ddownloadLogin),
|
||||||
ddownloadPassword: asText(settings.ddownloadPassword),
|
ddownloadPassword: asText(settings.ddownloadPassword),
|
||||||
oneFichierApiKey: asText(settings.oneFichierApiKey),
|
oneFichierApiKey: asText(settings.oneFichierApiKey),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
import {
|
import {
|
||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
@ -51,6 +52,8 @@ 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),
|
||||||
|
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||||
|
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),
|
||||||
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
|
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
|
||||||
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
|
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type {
|
import type {
|
||||||
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AppTheme,
|
AppTheme,
|
||||||
BandwidthScheduleEntry,
|
BandwidthScheduleEntry,
|
||||||
@ -62,7 +63,7 @@ const emptyStats = (): DownloadStats => ({
|
|||||||
|
|
||||||
const emptySnapshot = (): UiSnapshot => ({
|
const emptySnapshot = (): UiSnapshot => ({
|
||||||
settings: {
|
settings: {
|
||||||
token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "", ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "",
|
token: "", 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: "",
|
||||||
@ -141,6 +142,35 @@ function humanSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(3)} TB`;
|
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(3)} TB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAllDebridSourceLabel(source: AllDebridHostInfo["source"]): string {
|
||||||
|
return source === "web" ? "Web-Login" : "API-Key";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAllDebridQuota(info: AllDebridHostInfo): string {
|
||||||
|
const suffix = info.quotaType ? ` (${info.quotaType})` : "";
|
||||||
|
if (info.quota !== null && info.quotaMax !== null) {
|
||||||
|
return `${info.quota} / ${info.quotaMax}${suffix}`;
|
||||||
|
}
|
||||||
|
if (info.quota !== null) {
|
||||||
|
return `${info.quota}${suffix}`;
|
||||||
|
}
|
||||||
|
if (info.quotaMax !== null) {
|
||||||
|
return `max. ${info.quotaMax}${suffix}`;
|
||||||
|
}
|
||||||
|
return info.source === "web" ? "Nur per API-Key sichtbar" : "Nicht angegeben";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAllDebridSimuLimit(info: AllDebridHostInfo): string {
|
||||||
|
if (info.limitSimuDl === null) {
|
||||||
|
return info.source === "web" ? "Nur per API-Key sichtbar" : "Nicht angegeben";
|
||||||
|
}
|
||||||
|
return String(info.limitSimuDl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAllDebridTimestamp(info: AllDebridHostInfo): string {
|
||||||
|
return formatDateTime(info.lastCheckedAt || info.fetchedAt);
|
||||||
|
}
|
||||||
|
|
||||||
interface BandwidthChartProps {
|
interface BandwidthChartProps {
|
||||||
items: Record<string, DownloadItem>;
|
items: Record<string, DownloadItem>;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
@ -528,6 +558,9 @@ export function App(): ReactElement {
|
|||||||
const [selectedHistoryIds, setSelectedHistoryIds] = useState<Set<string>>(new Set());
|
const [selectedHistoryIds, setSelectedHistoryIds] = useState<Set<string>>(new Set());
|
||||||
const [historyCtxMenu, setHistoryCtxMenu] = useState<{ x: number; y: number; entryId: string } | null>(null);
|
const [historyCtxMenu, setHistoryCtxMenu] = useState<{ x: number; y: number; entryId: string } | null>(null);
|
||||||
const historyCtxMenuRef = useRef<HTMLDivElement>(null);
|
const historyCtxMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [allDebridHostInfo, setAllDebridHostInfo] = useState<AllDebridHostInfo | null>(null);
|
||||||
|
const [allDebridHostLoading, setAllDebridHostLoading] = useState(false);
|
||||||
|
const allDebridHostRequestRef = useRef(0);
|
||||||
|
|
||||||
// Load history when tab changes to history
|
// Load history when tab changes to history
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -612,6 +645,31 @@ export function App(): ReactElement {
|
|||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadAllDebridHostInfo = useCallback(async (silent = false): Promise<void> => {
|
||||||
|
const requestId = allDebridHostRequestRef.current + 1;
|
||||||
|
allDebridHostRequestRef.current = requestId;
|
||||||
|
setAllDebridHostLoading(true);
|
||||||
|
try {
|
||||||
|
const info = await window.rd.getAllDebridHostInfo();
|
||||||
|
if (!mountedRef.current || allDebridHostRequestRef.current !== requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAllDebridHostInfo(info);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mountedRef.current || allDebridHostRequestRef.current !== requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAllDebridHostInfo(null);
|
||||||
|
if (!silent) {
|
||||||
|
showToast(`AllDebrid Status fehlgeschlagen: ${String(error)}`, 3200);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current && allDebridHostRequestRef.current === requestId) {
|
||||||
|
setAllDebridHostLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
const clearImportQueueFocusListener = useCallback((): void => {
|
const clearImportQueueFocusListener = useCallback((): void => {
|
||||||
const handler = importQueueFocusHandlerRef.current;
|
const handler = importQueueFocusHandlerRef.current;
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
@ -866,12 +924,28 @@ export function App(): ReactElement {
|
|||||||
return [...active, ...rest];
|
return [...active, ...rest];
|
||||||
}, [packages, snapshot.session.running, snapshot.session.items]);
|
}, [packages, snapshot.session.running, snapshot.session.items]);
|
||||||
|
|
||||||
|
const hasSavedAllDebridAccount = Boolean(snapshot.settings.allDebridUseWebLogin || snapshot.settings.allDebridToken.trim());
|
||||||
|
const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin
|
||||||
|
|| snapshot.settings.allDebridToken !== settingsDraft.allDebridToken;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot.session.running) {
|
if (!snapshot.session.running) {
|
||||||
setShowAllPackages(false);
|
setShowAllPackages(false);
|
||||||
}
|
}
|
||||||
}, [snapshot.session.running]);
|
}, [snapshot.session.running]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsSubTab !== "accounts") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasSavedAllDebridAccount) {
|
||||||
|
setAllDebridHostInfo(null);
|
||||||
|
setAllDebridHostLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadAllDebridHostInfo(true);
|
||||||
|
}, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]);
|
||||||
|
|
||||||
// Auto-expand packages that are currently extracting (only once per extraction cycle)
|
// Auto-expand packages that are currently extracting (only once per extraction cycle)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const extractingPkgIds: string[] = [];
|
const extractingPkgIds: string[] = [];
|
||||||
@ -917,11 +991,11 @@ export function App(): ReactElement {
|
|||||||
if (settingsDraft.bestToken.trim()) {
|
if (settingsDraft.bestToken.trim()) {
|
||||||
list.push("bestdebrid");
|
list.push("bestdebrid");
|
||||||
}
|
}
|
||||||
if (settingsDraft.allDebridToken.trim()) {
|
if (settingsDraft.allDebridUseWebLogin || settingsDraft.allDebridToken.trim()) {
|
||||||
list.push("alldebrid");
|
list.push("alldebrid");
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken]);
|
}, [settingsDraft.token, 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
|
||||||
@ -1062,6 +1136,16 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onOpenAllDebridLogin = async (): Promise<void> => {
|
||||||
|
await performQuickAction(async () => {
|
||||||
|
await persistDraftSettings();
|
||||||
|
await window.rd.openAllDebridLogin();
|
||||||
|
showToast("AllDebrid Login-Fenster geöffnet", 2200);
|
||||||
|
}, (error) => {
|
||||||
|
showToast(`AllDebrid Login fehlgeschlagen: ${String(error)}`, 2800);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onCheckUpdates = async (): Promise<void> => {
|
const onCheckUpdates = async (): Promise<void> => {
|
||||||
let updateResult: UpdateCheckResult | null = null;
|
let updateResult: UpdateCheckResult | null = null;
|
||||||
await performQuickAction(async () => {
|
await performQuickAction(async () => {
|
||||||
@ -2744,6 +2828,62 @@ export function App(): ReactElement {
|
|||||||
<input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} />
|
<input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} />
|
||||||
<label>AllDebrid API Key</label>
|
<label>AllDebrid API Key</label>
|
||||||
<input type="password" value={settingsDraft.allDebridToken} onChange={(e) => setText("allDebridToken", e.target.value)} />
|
<input type="password" value={settingsDraft.allDebridToken} onChange={(e) => setText("allDebridToken", e.target.value)} />
|
||||||
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.allDebridUseWebLogin} onChange={(e) => setBool("allDebridUseWebLogin", e.target.checked)} /> AllDebrid per Web-Login statt API-Key verwenden</label>
|
||||||
|
{settingsDraft.allDebridUseWebLogin && (
|
||||||
|
<>
|
||||||
|
<div className="hint">Beim ersten Link oder über den Button unten öffnet sich ein echtes AllDebrid-Browserfenster. Der Login läuft dort manuell, damit reCAPTCHA sauber funktioniert.</div>
|
||||||
|
<button className="btn" disabled={actionBusy} onClick={() => { void onOpenAllDebridLogin(); }}>AllDebrid Web-Login öffnen</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: 12, padding: 14, borderRadius: 14, border: "1px solid rgba(83, 168, 255, 0.22)", background: "rgba(10, 20, 35, 0.32)" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
||||||
|
<strong>AllDebrid Rapidgator Status</strong>
|
||||||
|
<button className="btn" disabled={allDebridHostLoading || !hasSavedAllDebridAccount} onClick={() => { void loadAllDebridHostInfo(false); }}>
|
||||||
|
{allDebridHostLoading ? "Lade..." : "Status aktualisieren"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!hasSavedAllDebridAccount && (
|
||||||
|
<div className="hint" style={{ marginTop: 10 }}>Nach dem Speichern eines AllDebrid-Accounts wird hier der Rapidgator-Status angezeigt.</div>
|
||||||
|
)}
|
||||||
|
{hasSavedAllDebridAccount && !allDebridHostInfo && !allDebridHostLoading && (
|
||||||
|
<div className="hint" style={{ marginTop: 10 }}>Noch keine Host-Information geladen.</div>
|
||||||
|
)}
|
||||||
|
{hasSavedAllDebridAccount && allDebridHostLoading && !allDebridHostInfo && (
|
||||||
|
<div className="hint" style={{ marginTop: 10 }}>Rapidgator-Status wird geladen...</div>
|
||||||
|
)}
|
||||||
|
{allDebridHostInfo && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12, marginTop: 12 }}>
|
||||||
|
<div>
|
||||||
|
<div className="hint" style={{ margin: 0 }}>Status</div>
|
||||||
|
<div>{allDebridHostInfo.statusLabel}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="hint" style={{ margin: 0 }}>Quelle</div>
|
||||||
|
<div>{formatAllDebridSourceLabel(allDebridHostInfo.source)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="hint" style={{ margin: 0 }}>Letztes Update</div>
|
||||||
|
<div>{formatAllDebridTimestamp(allDebridHostInfo)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="hint" style={{ margin: 0 }}>Quota</div>
|
||||||
|
<div>{formatAllDebridQuota(allDebridHostInfo)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="hint" style={{ margin: 0 }}>Simultan-Downloads</div>
|
||||||
|
<div>{formatAllDebridSimuLimit(allDebridHostInfo)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{allDebridHostInfo.note && (
|
||||||
|
<div className="hint" style={{ marginTop: 10 }}>{allDebridHostInfo.note}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{allDebridSettingsDirty && hasSavedAllDebridAccount && (
|
||||||
|
<div className="hint" style={{ marginTop: 10 }}>Status basiert auf den zuletzt gespeicherten AllDebrid-Einstellungen.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<label>DDownload Login</label>
|
<label>DDownload Login</label>
|
||||||
<input value={settingsDraft.ddownloadLogin || ""} onChange={(e) => setText("ddownloadLogin", e.target.value)} />
|
<input value={settingsDraft.ddownloadLogin || ""} onChange={(e) => setText("ddownloadLogin", e.target.value)} />
|
||||||
<label>DDownload Passwort</label>
|
<label>DDownload Passwort</label>
|
||||||
|
|||||||
@ -34,6 +34,8 @@ 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_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
||||||
|
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
|
||||||
RETRY_EXTRACTION: "queue:retry-extraction",
|
RETRY_EXTRACTION: "queue:retry-extraction",
|
||||||
EXTRACT_NOW: "queue:extract-now",
|
EXTRACT_NOW: "queue:extract-now",
|
||||||
RESET_PACKAGE: "queue:reset-package",
|
RESET_PACKAGE: "queue:reset-package",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
@ -46,6 +47,8 @@ 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>;
|
||||||
|
openAllDebridLogin: () => Promise<void>;
|
||||||
|
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
|
||||||
retryExtraction: (packageId: string) => Promise<void>;
|
retryExtraction: (packageId: string) => Promise<void>;
|
||||||
extractNow: (packageId: string) => Promise<void>;
|
extractNow: (packageId: string) => Promise<void>;
|
||||||
resetPackage: (packageId: string) => Promise<void>;
|
resetPackage: (packageId: string) => Promise<void>;
|
||||||
|
|||||||
@ -42,6 +42,7 @@ export interface AppSettings {
|
|||||||
megaPassword: string;
|
megaPassword: string;
|
||||||
bestToken: string;
|
bestToken: string;
|
||||||
allDebridToken: string;
|
allDebridToken: string;
|
||||||
|
allDebridUseWebLogin: boolean;
|
||||||
ddownloadLogin: string;
|
ddownloadLogin: string;
|
||||||
ddownloadPassword: string;
|
ddownloadPassword: string;
|
||||||
oneFichierApiKey: string;
|
oneFichierApiKey: string;
|
||||||
@ -240,6 +241,23 @@ export interface UpdateInstallProgress {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown";
|
||||||
|
export type AllDebridHostInfoSource = "api" | "web";
|
||||||
|
|
||||||
|
export interface AllDebridHostInfo {
|
||||||
|
host: string;
|
||||||
|
source: AllDebridHostInfoSource;
|
||||||
|
state: AllDebridHostState;
|
||||||
|
statusLabel: string;
|
||||||
|
fetchedAt: number;
|
||||||
|
lastCheckedAt: number | null;
|
||||||
|
quota: number | null;
|
||||||
|
quotaMax: number | null;
|
||||||
|
quotaType: string;
|
||||||
|
limitSimuDl: number | null;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ParsedHashEntry {
|
export interface ParsedHashEntry {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
algorithm: "crc32" | "md5" | "sha1";
|
algorithm: "crc32" | "md5" | "sha1";
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
||||||
import { DebridService, extractRapidgatorFilenameFromHtml, filenameFromRapidgatorUrlPath, normalizeResolvedFilename } from "../src/main/debrid";
|
import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, filenameFromRapidgatorUrlPath, normalizeResolvedFilename } from "../src/main/debrid";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@ -243,6 +243,88 @@ describe("debrid service", () => {
|
|||||||
expect(result.fileSize).toBe(4096);
|
expect(result.fileSize).toBe(4096);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads AllDebrid host info via api", async () => {
|
||||||
|
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.1/user/hosts")) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
status: "success",
|
||||||
|
data: {
|
||||||
|
hosts: {
|
||||||
|
rapidgator: {
|
||||||
|
name: "rapidgator",
|
||||||
|
status: false,
|
||||||
|
quota: 1200,
|
||||||
|
quotaMax: 2400,
|
||||||
|
quotaType: "traffic",
|
||||||
|
limitSimuDl: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not-found", { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const info = await fetchAllDebridHostInfo("ad-token", "rapidgator");
|
||||||
|
expect(info.source).toBe("api");
|
||||||
|
expect(info.host).toBe("rapidgator");
|
||||||
|
expect(info.state).toBe("down");
|
||||||
|
expect(info.statusLabel).toBe("Unverfügbar");
|
||||||
|
expect(info.quota).toBe(1200);
|
||||||
|
expect(info.quotaMax).toBe(2400);
|
||||||
|
expect(info.quotaType).toBe("traffic");
|
||||||
|
expect(info.limitSimuDl).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses AllDebrid web path when enabled", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
allDebridToken: "ad-token",
|
||||||
|
allDebridUseWebLogin: true,
|
||||||
|
providerPrimary: "alldebrid" 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 allDebridWeb = vi.fn(async () => ({
|
||||||
|
fileName: "from-web.rar",
|
||||||
|
directUrl: "https://df4ea4.debrid.it/dl/example/from-web.rar",
|
||||||
|
fileSize: 1234,
|
||||||
|
retriesUsed: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = new DebridService(settings, { allDebridWebUnrestrict: allDebridWeb });
|
||||||
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part4.rar.html");
|
||||||
|
expect(result.provider).toBe("alldebrid");
|
||||||
|
expect(result.directUrl).toContain("debrid.it/dl/");
|
||||||
|
expect(result.fileSize).toBe(1234);
|
||||||
|
expect(allDebridWeb).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats AllDebrid web mode as not configured when callback is unavailable", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
allDebridToken: "",
|
||||||
|
allDebridUseWebLogin: true,
|
||||||
|
providerPrimary: "alldebrid" 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-alldebrid-web")).rejects.toThrow(/nicht konfiguriert/i);
|
||||||
|
});
|
||||||
|
|
||||||
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 AllDebrid web login to disabled and normalizes the flag", () => {
|
||||||
|
expect(defaultSettings().allDebridUseWebLogin).toBe(false);
|
||||||
|
|
||||||
|
const normalizedEnabled = normalizeSettings({
|
||||||
|
...defaultSettings(),
|
||||||
|
allDebridUseWebLogin: 1 as unknown as boolean
|
||||||
|
});
|
||||||
|
expect(normalizedEnabled.allDebridUseWebLogin).toBe(true);
|
||||||
|
|
||||||
|
const normalizedDisabled = normalizeSettings({
|
||||||
|
...defaultSettings(),
|
||||||
|
allDebridUseWebLogin: 0 as unknown as boolean
|
||||||
|
});
|
||||||
|
expect(normalizedDisabled.allDebridUseWebLogin).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("assigns and preserves bandwidth schedule ids", () => {
|
it("assigns and preserves bandwidth schedule ids", () => {
|
||||||
const normalized = normalizeSettings({
|
const normalized = normalizeSettings({
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user