Initial public release v1.7.233
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import type { AppSettings, DebridAccountStatus } from "../shared/types";
|
||||
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
||||
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
|
||||
import { logger } from "./logger";
|
||||
import { compactErrorText } from "./utils";
|
||||
|
||||
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
|
||||
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
|
||||
const CHECK_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
||||
const CHECK_TIMEOUT_MS = 20000;
|
||||
|
||||
function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
||||
const timeout = AbortSignal.timeout(ms);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
function parseJsonSafe(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRemaining(premiumUntilMs: number | null, now: number): string {
|
||||
if (premiumUntilMs == null) {
|
||||
return "Premium-Status unbekannt";
|
||||
}
|
||||
if (premiumUntilMs <= 0) {
|
||||
return "Kein Premium";
|
||||
}
|
||||
const remainingMs = premiumUntilMs - now;
|
||||
if (remainingMs <= 0) {
|
||||
return "Premium abgelaufen";
|
||||
}
|
||||
const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
|
||||
if (days >= 1) {
|
||||
return `Premium noch ${days} Tag${days === 1 ? "" : "e"}`;
|
||||
}
|
||||
const hours = Math.max(1, Math.floor(remainingMs / (60 * 60 * 1000)));
|
||||
return `Premium noch ${hours} Std`;
|
||||
}
|
||||
|
||||
export async function checkMegaDebridAccount(
|
||||
account: MegaDebridAccountEntry,
|
||||
signal?: AbortSignal,
|
||||
now = Date.now()
|
||||
): Promise<DebridAccountStatus> {
|
||||
const base: DebridAccountStatus = {
|
||||
accountId: account.id,
|
||||
provider: "megadebrid",
|
||||
label: account.label,
|
||||
maskedLogin: account.maskedLogin,
|
||||
valid: false,
|
||||
isPremium: false,
|
||||
premiumUntilMs: null,
|
||||
message: "",
|
||||
checkedAt: now
|
||||
};
|
||||
try {
|
||||
const url = `${MEGA_DEBRID_API}?action=connectUser&login=${encodeURIComponent(account.login)}&password=${encodeURIComponent(account.password)}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": CHECK_USER_AGENT },
|
||||
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = parseJsonSafe(text);
|
||||
if (!response.ok || !payload) {
|
||||
return { ...base, message: `Login fehlgeschlagen (HTTP ${response.status})` };
|
||||
}
|
||||
if (payload.response_code !== "ok") {
|
||||
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
|
||||
return { ...base, message: `Ungueltiger Login: ${reason}` };
|
||||
}
|
||||
const vipEndRaw = Number(payload.vip_end || 0);
|
||||
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
|
||||
const isPremium = premiumUntilMs > now;
|
||||
const email = String(payload.email || "").trim() || undefined;
|
||||
return {
|
||||
...base,
|
||||
valid: true,
|
||||
isPremium,
|
||||
premiumUntilMs,
|
||||
email,
|
||||
message: formatRemaining(premiumUntilMs, now)
|
||||
};
|
||||
} catch (error) {
|
||||
const errText = compactErrorText(error);
|
||||
const aborted = signal?.aborted || /aborted/i.test(errText);
|
||||
return {
|
||||
...base,
|
||||
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDebridLinkKey(
|
||||
key: DebridLinkApiKeyEntry,
|
||||
signal?: AbortSignal,
|
||||
now = Date.now()
|
||||
): Promise<DebridAccountStatus> {
|
||||
const base: DebridAccountStatus = {
|
||||
accountId: key.id,
|
||||
provider: "debridlink",
|
||||
label: key.label,
|
||||
maskedLogin: key.masked,
|
||||
valid: false,
|
||||
isPremium: false,
|
||||
premiumUntilMs: null,
|
||||
message: "",
|
||||
checkedAt: now
|
||||
};
|
||||
try {
|
||||
const response = await fetch(`${DEBRID_LINK_API}/account/infos`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key.token}`,
|
||||
"User-Agent": CHECK_USER_AGENT
|
||||
},
|
||||
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = parseJsonSafe(text);
|
||||
if (!response.ok || !payload) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
|
||||
}
|
||||
return { ...base, message: `Pruefung fehlgeschlagen (HTTP ${response.status})` };
|
||||
}
|
||||
if (payload.success === false) {
|
||||
const reason = String(payload.error || "Key abgelehnt");
|
||||
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
|
||||
}
|
||||
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
|
||||
const premiumLeftSec = Number(value.premiumLeft || 0);
|
||||
const accountType = Number(value.accountType || 0);
|
||||
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
|
||||
const isPremium = premiumUntilMs > now || accountType > 0;
|
||||
const username = String(value.username || "").trim() || undefined;
|
||||
return {
|
||||
...base,
|
||||
valid: true,
|
||||
isPremium,
|
||||
premiumUntilMs: premiumUntilMs > 0 ? premiumUntilMs : (accountType > 0 ? null : 0),
|
||||
email: username,
|
||||
message: premiumUntilMs > 0
|
||||
? formatRemaining(premiumUntilMs, now)
|
||||
: (accountType > 0 ? "Premium aktiv" : "Kein Premium (Free)")
|
||||
};
|
||||
} catch (error) {
|
||||
const errText = compactErrorText(error);
|
||||
const aborted = signal?.aborted || /aborted/i.test(errText);
|
||||
return {
|
||||
...base,
|
||||
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAllDebridAccounts(
|
||||
settings: AppSettings,
|
||||
signal?: AbortSignal
|
||||
): Promise<DebridAccountStatus[]> {
|
||||
const now = Date.now();
|
||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
|
||||
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
||||
|
||||
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
||||
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
|
||||
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
|
||||
];
|
||||
|
||||
const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY);
|
||||
logger.info(
|
||||
`Account-Check abgeschlossen: ${results.length} Accounts geprueft ` +
|
||||
`(${results.filter((r) => r.valid).length} gueltig, ${results.filter((r) => r.isPremium).length} premium)`
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
const CHECK_CONCURRENCY = 4;
|
||||
|
||||
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
|
||||
const results: T[] = new Array(taskFns.length);
|
||||
let nextIndex = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (nextIndex < taskFns.length) {
|
||||
const current = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[current] = await taskFns[current]();
|
||||
}
|
||||
};
|
||||
const workers = Array.from({ length: Math.min(limit, taskFns.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { RotationEvent } from "../shared/types";
|
||||
|
||||
export type RotationItemSink = (event: RotationEvent) => void;
|
||||
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
||||
|
||||
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
||||
return rotationItemContext.run(sink, fn);
|
||||
}
|
||||
|
||||
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const ROTATION_EVENT_RING_MAX = 60;
|
||||
const rotationEventRing: RotationEvent[] = [];
|
||||
let rotationEventSeq = 0;
|
||||
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
|
||||
|
||||
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
|
||||
rotationEventListener = listener;
|
||||
}
|
||||
|
||||
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
|
||||
const slice = rotationEventRing.slice(-limit);
|
||||
slice.reverse();
|
||||
return slice;
|
||||
}
|
||||
|
||||
function isUiRelevantRotationEvent(event: string): boolean {
|
||||
return event !== "TEST";
|
||||
}
|
||||
|
||||
function pushRotationEvent(
|
||||
level: RotationLevel,
|
||||
provider: string,
|
||||
accountLabel: string,
|
||||
event: string,
|
||||
fields?: Record<string, unknown>,
|
||||
at = Date.now()
|
||||
): void {
|
||||
rotationEventSeq += 1;
|
||||
const entry: RotationEvent = {
|
||||
id: `rot_${at}_${rotationEventSeq}`,
|
||||
at,
|
||||
level,
|
||||
provider,
|
||||
accountLabel,
|
||||
event,
|
||||
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
|
||||
category: fields && fields.category != null ? String(fields.category) : undefined,
|
||||
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
|
||||
next: fields && fields.next != null ? String(fields.next) : undefined
|
||||
};
|
||||
|
||||
const itemSink = rotationItemContext.getStore();
|
||||
if (itemSink) {
|
||||
try {
|
||||
itemSink(entry);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUiRelevantRotationEvent(event)) {
|
||||
return;
|
||||
}
|
||||
rotationEventRing.push(entry);
|
||||
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
|
||||
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
|
||||
}
|
||||
if (rotationEventListener) {
|
||||
try {
|
||||
rotationEventListener(entry);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
||||
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
|
||||
|
||||
let rotationLogPath: string | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < ROTATION_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - ROTATION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initAccountRotationLog(baseDir: string): void {
|
||||
rotationLogPath = path.join(baseDir, "account-rotation.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(rotationLogPath), { recursive: true });
|
||||
cleanupOldBackup(rotationLogPath);
|
||||
if (!fs.existsSync(rotationLogPath)) {
|
||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(rotationLogPath);
|
||||
if (!fs.existsSync(rotationLogPath)) {
|
||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(
|
||||
rotationLogPath,
|
||||
`=== Account-Rotation Log Start: ${logTimestamp()} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
rotationLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logAccountRotation(
|
||||
level: RotationLevel,
|
||||
provider: string,
|
||||
accountLabel: string,
|
||||
event: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
pushRotationEvent(level, provider, accountLabel, event, fields);
|
||||
if (!rotationLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(rotationLogPath);
|
||||
if (!fs.existsSync(rotationLogPath)) {
|
||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||
}
|
||||
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountRotationLogPath(): string | null {
|
||||
if (!rotationLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(rotationLogPath) ? rotationLogPath : null;
|
||||
}
|
||||
|
||||
export function shutdownAccountRotationLog(): void {
|
||||
if (!rotationLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(
|
||||
rotationLogPath,
|
||||
`=== Account-Rotation Log Ende: ${logTimestamp()} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
}
|
||||
rotationLogPath = null;
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
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 {
|
||||
}
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,973 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import v8 from "node:v8";
|
||||
import { app } from "electron";
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import {
|
||||
AddLinksPayload,
|
||||
AllDebridHostInfo,
|
||||
AppSettings,
|
||||
DebridAccountStatus,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
EnableRemoteDiagnosticsInput,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
ParsedPackageInput,
|
||||
RemoteDiagnosticsInfo,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
UiSnapshot,
|
||||
UpdateCheckResult,
|
||||
UpdateInstallProgress,
|
||||
UpdateInstallResult
|
||||
} from "../shared/types";
|
||||
import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits";
|
||||
import { importDlcContainers } from "./container";
|
||||
import { APP_VERSION } from "./constants";
|
||||
import { DownloadManager } from "./download-manager";
|
||||
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
|
||||
import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check";
|
||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { parseCollectorInput } from "./link-parser";
|
||||
import { configureLogger, getLogFilePath, logger } from "./logger";
|
||||
import { AllDebridWebFallback } from "./all-debrid-web";
|
||||
import { BestDebridWebFallback } from "./bestdebrid-web";
|
||||
import { RealDebridWebFallback } from "./realdebrid-web";
|
||||
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
|
||||
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
|
||||
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
|
||||
import { MegaWebFallback } from "./mega-web-fallback";
|
||||
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||
import { runInstallWithResume } from "./update-install-flow";
|
||||
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
|
||||
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
|
||||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
||||
import { buildBackupPayload, planBackupImport, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "./backup-payload";
|
||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
||||
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
||||
import { initConversionLog, shutdownConversionLog } from "./conversion-trace";
|
||||
import { runStartupHealthCheck } from "./startup-health-check";
|
||||
import { getDebugSetupCheck } from "./debug-setup";
|
||||
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
|
||||
import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog } from "./desktop-rename-log";
|
||||
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||||
|
||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||
return Object.fromEntries(entries) as Partial<AppSettings>;
|
||||
}
|
||||
|
||||
function settingsFingerprint(settings: AppSettings): string {
|
||||
return JSON.stringify(normalizeSettings(settings));
|
||||
}
|
||||
|
||||
export class AppController {
|
||||
private settings: AppSettings;
|
||||
|
||||
private manager: DownloadManager;
|
||||
|
||||
private megaWebFallback: MegaWebFallback;
|
||||
|
||||
private realDebridWebFallback: RealDebridWebFallback;
|
||||
|
||||
private allDebridWebFallback: AllDebridWebFallback;
|
||||
|
||||
private bestDebridWebFallback: BestDebridWebFallback;
|
||||
|
||||
private lastUpdateCheck: UpdateCheckResult | null = null;
|
||||
|
||||
private lastUpdateCheckAt = 0;
|
||||
|
||||
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
|
||||
|
||||
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
|
||||
|
||||
private autoResumePending = false;
|
||||
private runtimeStatsTimer: NodeJS.Timeout | null = null;
|
||||
private lastMemoryWarnAt = 0;
|
||||
|
||||
public constructor() {
|
||||
configureLogger(this.storagePaths.baseDir);
|
||||
initSessionLog(this.storagePaths.baseDir);
|
||||
initPackageLogs(this.storagePaths.baseDir);
|
||||
initItemLogs(this.storagePaths.baseDir);
|
||||
initAuditLog(this.storagePaths.baseDir);
|
||||
initAccountRotationLog(this.storagePaths.baseDir);
|
||||
initConversionLog(this.storagePaths.baseDir);
|
||||
initRenameLog(this.storagePaths.baseDir);
|
||||
let desktopDir: string | null = null;
|
||||
try {
|
||||
desktopDir = app.getPath("desktop");
|
||||
} catch {
|
||||
desktopDir = null;
|
||||
}
|
||||
initDesktopRenameLog(desktopDir);
|
||||
initTraceLog(this.storagePaths.baseDir);
|
||||
this.settings = loadSettings(this.storagePaths);
|
||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||
const loadResult = loadSessionWithStatus(this.storagePaths);
|
||||
const session = loadResult.session;
|
||||
this.megaWebFallback = new MegaWebFallback(() => ({
|
||||
login: this.settings.megaLogin,
|
||||
password: this.settings.megaPassword
|
||||
}));
|
||||
this.realDebridWebFallback = new RealDebridWebFallback(() => this.settings.rememberToken);
|
||||
this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
|
||||
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
|
||||
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
|
||||
megaWebUnrestrict: (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => this.megaWebFallback.unrestrict(link, signal, account),
|
||||
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
|
||||
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
|
||||
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
|
||||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||||
protectEmptyClobber: loadResult.status === "empty-unreadable",
|
||||
onHistoryEntry: (entry: HistoryEntry) => {
|
||||
addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits());
|
||||
}
|
||||
});
|
||||
this.manager.on("state", (snapshot: UiSnapshot) => {
|
||||
this.onStateHandler?.(snapshot);
|
||||
});
|
||||
logger.info(`App gestartet v${APP_VERSION}`);
|
||||
logger.info(`Log-Datei: ${getLogFilePath()}`);
|
||||
logAuditEvent("INFO", "App gestartet", {
|
||||
appVersion: APP_VERSION,
|
||||
runtimeDir: this.storagePaths.baseDir
|
||||
});
|
||||
try {
|
||||
const report = runStartupHealthCheck(this.settings, this.storagePaths);
|
||||
if (report.errorCount > 0 || report.warnCount > 0) {
|
||||
logger.warn(`Health-Check: ${report.errorCount} Fehler, ${report.warnCount} Warnungen, ${report.infoCount} Info`);
|
||||
} else {
|
||||
logger.info(`Health-Check: alles OK (${report.infoCount} Info)`);
|
||||
}
|
||||
for (const finding of report.findings) {
|
||||
const line = finding.hint
|
||||
? `Health-Check [${finding.code}]: ${finding.message} — ${finding.hint}`
|
||||
: `Health-Check [${finding.code}]: ${finding.message}`;
|
||||
if (finding.severity === "ERROR") {
|
||||
logger.error(line);
|
||||
} else if (finding.severity === "WARN") {
|
||||
logger.warn(line);
|
||||
} else {
|
||||
logger.info(line);
|
||||
}
|
||||
if (finding.severity !== "INFO") {
|
||||
logAuditEvent(finding.severity, `Health-Check: ${finding.code}`, {
|
||||
message: finding.message,
|
||||
hint: finding.hint || ""
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`);
|
||||
}
|
||||
startDebugServer(this.manager, this.storagePaths.baseDir);
|
||||
this.runtimeStatsTimer = setInterval(() => {
|
||||
this.manager.persistRuntimeStats();
|
||||
this.settings = this.manager.getSettings();
|
||||
this.checkMemoryPressure();
|
||||
}, 60_000);
|
||||
this.runtimeStatsTimer.unref?.();
|
||||
|
||||
if (this.settings.autoResumeOnStart) {
|
||||
const snapshot = this.manager.getSnapshot();
|
||||
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||
if (hasPending && this.hasAnyProviderToken(this.settings)) {
|
||||
if (this.onStateHandler) {
|
||||
this.beginAutoResume();
|
||||
} else {
|
||||
this.autoResumePending = true;
|
||||
logger.info("Auto-Resume beim Start vorgemerkt");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Early-warning for OOM on a long-running process. Measured against the V8
|
||||
// heap_size_limit (the real ceiling at which the process is killed), NOT against
|
||||
// heapTotal: V8 routinely runs near-full of its current heapTotal just before it
|
||||
// grows it, so a heapUsed/heapTotal ratio would cry wolf and — since every WARN
|
||||
// now feeds the error ring — crowd real failures out. Throttled to 1 warning per
|
||||
// 5 min so a genuine sustained-pressure run does not spam the log/ring.
|
||||
private checkMemoryPressure(): void {
|
||||
try {
|
||||
const mem = process.memoryUsage();
|
||||
const heapLimit = v8.getHeapStatistics().heap_size_limit;
|
||||
const ratio = heapLimit > 0 ? mem.heapUsed / heapLimit : 0;
|
||||
if (ratio < 0.9) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - this.lastMemoryWarnAt < 5 * 60_000) {
|
||||
return;
|
||||
}
|
||||
this.lastMemoryWarnAt = now;
|
||||
const mb = (bytes: number): number => Math.round(bytes / 1048576);
|
||||
logger.warn(
|
||||
`Speicherdruck: heapUsed=${mb(mem.heapUsed)}MB von Limit ${mb(heapLimit)}MB ` +
|
||||
`(${Math.round(ratio * 100)}%), heapTotal=${mb(mem.heapTotal)}MB, rss=${mb(mem.rss)}MB, external=${mb(mem.external)}MB`
|
||||
);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
private hasAnyProviderToken(settings: AppSettings): boolean {
|
||||
return Boolean(
|
||||
settings.token.trim()
|
||||
|| settings.realDebridUseWebLogin
|
||||
|| (settings.megaLogin.trim() && settings.megaPassword.trim())
|
||||
|| settings.bestToken.trim()
|
||||
|| settings.bestDebridUseWebLogin
|
||||
|| settings.allDebridUseWebLogin
|
||||
|| settings.allDebridToken.trim()
|
||||
|| (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim())
|
||||
|| settings.oneFichierApiKey.trim()
|
||||
);
|
||||
}
|
||||
|
||||
public get onState(): ((snapshot: UiSnapshot) => void) | null {
|
||||
return this.onStateHandler;
|
||||
}
|
||||
|
||||
public set onState(handler: ((snapshot: UiSnapshot) => void) | null) {
|
||||
this.onStateHandler = handler;
|
||||
if (handler) {
|
||||
handler(this.manager.getSnapshot());
|
||||
if (this.autoResumePending) {
|
||||
this.autoResumePending = false;
|
||||
this.beginAutoResume();
|
||||
} else {
|
||||
this.manager.triggerIdleExtractions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private beginAutoResume(): void {
|
||||
void this.manager.getStartConflicts().then((conflicts) => {
|
||||
const excludePackageIds = new Set(conflicts.map((conflict) => conflict.packageId));
|
||||
if (excludePackageIds.size > 0) {
|
||||
const names = conflicts.map((conflict) => conflict.packageName).join(", ");
|
||||
logger.info(`Auto-Resume: ${excludePackageIds.size} Paket(e) mit Start-Konflikt zurückgehalten (${names}); übrige Pakete starten`);
|
||||
} else {
|
||||
logger.info("Auto-Resume beim Start aktiviert (keine Start-Konflikte)");
|
||||
}
|
||||
void this.manager.start(excludePackageIds.size > 0 ? { excludePackageIds } : undefined)
|
||||
.catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
||||
}).catch((err) => logger.warn(`Auto-Resume Konflikt-Check Fehler: ${String(err)}`));
|
||||
}
|
||||
|
||||
public getSnapshot(): UiSnapshot {
|
||||
return this.manager.getSnapshot();
|
||||
}
|
||||
|
||||
public getVersion(): string {
|
||||
return APP_VERSION;
|
||||
}
|
||||
|
||||
public getSettings(): AppSettings {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
public getAuditLogPath(): string | null {
|
||||
return getAuditLogPath();
|
||||
}
|
||||
|
||||
public getRenameLogPath(): string | null {
|
||||
return getRenameLogPath();
|
||||
}
|
||||
|
||||
public getDesktopRenameLogPath(): string | null {
|
||||
return getDesktopRenameLogPath();
|
||||
}
|
||||
|
||||
public getTraceLogPath(): string | null {
|
||||
return getTraceLogPath();
|
||||
}
|
||||
|
||||
public getTraceConfig(): SupportTraceConfig {
|
||||
return getTraceConfig();
|
||||
}
|
||||
|
||||
public rotateDebugToken(): { path: string; token: string } {
|
||||
const rotated = rotateDebugToken(this.storagePaths.baseDir);
|
||||
this.audit("WARN", "Debug-Token rotiert", { path: rotated.path });
|
||||
return rotated;
|
||||
}
|
||||
|
||||
private getSuggestedRemoteHosts(): string[] {
|
||||
const hosts: string[] = [];
|
||||
try {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const entry of Object.values(interfaces)) {
|
||||
for (const net of entry || []) {
|
||||
if (net.family === "IPv4" && !net.internal && net.address) {
|
||||
hosts.push(net.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return [...new Set(hosts)];
|
||||
}
|
||||
|
||||
public getRemoteDiagnostics(): RemoteDiagnosticsInfo {
|
||||
const status = getDebugServerRuntimeStatus();
|
||||
const meta = loadRemoteMeta(this.storagePaths.baseDir);
|
||||
const token = getActiveDebugToken();
|
||||
const allowlist = getDebugAllowlist();
|
||||
const suggestedHosts = this.getSuggestedRemoteHosts();
|
||||
const host = meta.publicHost
|
||||
|| (status.localOnly ? "127.0.0.1" : (suggestedHosts[0] || status.host));
|
||||
const code = (status.hasToken && token && host)
|
||||
? encodeConnectionCode({ host, port: status.port, token, name: meta.name || undefined })
|
||||
: null;
|
||||
return {
|
||||
status,
|
||||
code,
|
||||
publicHost: meta.publicHost,
|
||||
name: meta.name,
|
||||
allowlist,
|
||||
suggestedHosts
|
||||
};
|
||||
}
|
||||
|
||||
public async enableRemoteDiagnostics(input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> {
|
||||
const baseDir = this.storagePaths.baseDir;
|
||||
const port = input.port && Number.isInteger(input.port) && input.port >= 1024 && input.port <= 65535
|
||||
? input.port
|
||||
: 9868;
|
||||
const bindHost = input.hostMode === "network" ? "0.0.0.0" : "127.0.0.1";
|
||||
const allowlist = (input.allowlist || []).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||
if (input.hostMode === "network" && allowlist.length === 0) {
|
||||
throw new Error("Netzwerk-Freigabe erfordert mindestens eine erlaubte IP oder CIDR in der Allowlist.");
|
||||
}
|
||||
let token = getActiveDebugToken();
|
||||
if (!token || input.rotateToken) {
|
||||
token = rotateDebugToken(baseDir).token;
|
||||
}
|
||||
writeDebugServerConfig({ host: bindHost, port, allowlist });
|
||||
saveRemoteMeta(baseDir, { publicHost: (input.publicHost || "").trim(), name: (input.name || "").trim() });
|
||||
await restartDebugServer();
|
||||
this.audit("WARN", "Ferndiagnose aktiviert", {
|
||||
host: bindHost,
|
||||
port,
|
||||
allowlistCount: allowlist.length,
|
||||
localOnly: input.hostMode === "local"
|
||||
});
|
||||
return this.getRemoteDiagnostics();
|
||||
}
|
||||
|
||||
public async disableRemoteDiagnostics(): Promise<RemoteDiagnosticsInfo> {
|
||||
clearDebugToken();
|
||||
await restartDebugServer();
|
||||
this.audit("WARN", "Ferndiagnose deaktiviert (Token entfernt)");
|
||||
return this.getRemoteDiagnostics();
|
||||
}
|
||||
|
||||
public async rotateRemoteDiagnosticsToken(): Promise<RemoteDiagnosticsInfo> {
|
||||
rotateDebugToken(this.storagePaths.baseDir);
|
||||
await restartDebugServer();
|
||||
this.audit("WARN", "Ferndiagnose-Token rotiert");
|
||||
return this.getRemoteDiagnostics();
|
||||
}
|
||||
|
||||
private restoreRemoteDiagnosticsFromBackup(section: unknown, restartNow: boolean): void {
|
||||
const restore = resolveRemoteDiagnosticsRestore(section);
|
||||
if (!restore) {
|
||||
return;
|
||||
}
|
||||
writeDebugServerConfig({ host: restore.host, port: restore.port, allowlist: restore.allowlist });
|
||||
if (restartNow) {
|
||||
void restartDebugServer().catch(() => {});
|
||||
}
|
||||
this.audit("INFO", "Ferndiagnose-Einstellungen aus Backup wiederhergestellt", {
|
||||
port: restore.port ?? null,
|
||||
allowlistCount: restore.allowlist?.length ?? 0,
|
||||
host: restore.host ?? "unveraendert",
|
||||
restartNow
|
||||
});
|
||||
}
|
||||
|
||||
public getDebugSetupCheck(): DebugSetupCheckResult {
|
||||
return getDebugSetupCheck(this.storagePaths.baseDir);
|
||||
}
|
||||
|
||||
private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
|
||||
logAuditEvent(level, message, fields);
|
||||
logTraceEvent(level, "audit", message, fields);
|
||||
}
|
||||
|
||||
public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig {
|
||||
const next = setTraceEnabled(enabled, note, durationMs);
|
||||
this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note });
|
||||
return next;
|
||||
}
|
||||
|
||||
// Carry the live, runtime-maintained usage/status counters onto a settings
|
||||
// object about to be applied, so they are never rolled back to a stale snapshot.
|
||||
// All-time totals take the max; daily/total usage and account statuses are taken
|
||||
// live; per-key Debrid-Link usage is filtered to keys that still exist.
|
||||
private overlayLiveUsageCounters(target: AppSettings): void {
|
||||
const liveSettings = this.manager.getSettings();
|
||||
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
||||
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
||||
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
|
||||
target.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
|
||||
target.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
|
||||
target.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
|
||||
target.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
|
||||
);
|
||||
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
|
||||
);
|
||||
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
|
||||
}
|
||||
|
||||
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||||
const sanitizedPatch = sanitizeSettingsPatch(partial);
|
||||
const previousSettings = this.settings;
|
||||
const nextSettings = normalizeSettings({
|
||||
...previousSettings,
|
||||
...sanitizedPatch
|
||||
});
|
||||
|
||||
if (settingsFingerprint(nextSettings) === settingsFingerprint(previousSettings)) {
|
||||
return previousSettings;
|
||||
}
|
||||
|
||||
this.overlayLiveUsageCounters(nextSettings);
|
||||
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
|
||||
const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries
|
||||
|| previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays;
|
||||
this.settings = nextSettings;
|
||||
if (retentionChanged) {
|
||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||
} else if (historyLimitsChanged && this.settings.historyRetentionMode !== "never") {
|
||||
saveHistory(this.storagePaths, loadHistory(this.storagePaths), this.historyLimits());
|
||||
}
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
this.audit("INFO", "Einstellungen aktualisiert", {
|
||||
changedKeys: Object.keys(sanitizedPatch),
|
||||
accountChanges: diffAccountSummary(previousSettings, this.settings)
|
||||
});
|
||||
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) => {
|
||||
logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
|
||||
});
|
||||
void this.bestDebridWebFallback.clearSessions().catch((error) => {
|
||||
logger.warn(`BestDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
|
||||
});
|
||||
}
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
public resetProviderDailyUsage(provider: DebridProvider): AppSettings {
|
||||
const liveSettings = this.manager.getSettings();
|
||||
const nextSettings = normalizeSettings({
|
||||
...liveSettings,
|
||||
...resetProviderDailyUsage(liveSettings, provider)
|
||||
});
|
||||
this.settings = nextSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider });
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings {
|
||||
const liveSettings = this.manager.getSettings();
|
||||
const nextSettings = normalizeSettings({
|
||||
...liveSettings,
|
||||
...resetDebridLinkApiKeyDailyUsage(liveSettings, keyId)
|
||||
});
|
||||
this.settings = nextSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId });
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
public async openRealDebridLoginWindow(): Promise<void> {
|
||||
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet");
|
||||
await this.realDebridWebFallback.openLoginWindow();
|
||||
}
|
||||
|
||||
public async openAllDebridLoginWindow(): Promise<void> {
|
||||
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
|
||||
await this.allDebridWebFallback.openLoginWindow();
|
||||
}
|
||||
|
||||
public async importBestDebridCookies(filePath: string): Promise<number> {
|
||||
const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath);
|
||||
this.audit("INFO", "BestDebrid Cookies importiert", {
|
||||
filePath,
|
||||
imported
|
||||
});
|
||||
return imported;
|
||||
}
|
||||
|
||||
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 getDebridLinkHostLimits(host = "rapidgator") {
|
||||
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
|
||||
}
|
||||
|
||||
public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
const statuses = await checkAllDebridAccounts(this.settings);
|
||||
this.manager.applyDebridAccountStatuses(statuses);
|
||||
this.audit("INFO", "Debrid-Accounts geprueft", {
|
||||
total: statuses.length,
|
||||
valid: statuses.filter((s) => s.valid).length,
|
||||
premium: statuses.filter((s) => s.isPremium).length
|
||||
});
|
||||
return statuses;
|
||||
}
|
||||
|
||||
public async checkSingleMegaDebridAccount(login: string, password: string): Promise<DebridAccountStatus | null> {
|
||||
const entry = parseMegaDebridAccounts(`${login.trim()}:${password.trim()}`)[0];
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const status = await checkMegaDebridAccount(entry);
|
||||
this.manager.applyDebridAccountStatuses([status]);
|
||||
this.audit("INFO", "Mega-Debrid-Account einzeln geprueft", { valid: status.valid, premium: status.isPremium });
|
||||
return status;
|
||||
}
|
||||
public async checkUpdates(): Promise<UpdateCheckResult> {
|
||||
const result = await checkGitHubUpdate(this.settings.updateRepo);
|
||||
if (!result.error) {
|
||||
this.lastUpdateCheck = result;
|
||||
this.lastUpdateCheckAt = Date.now();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
|
||||
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
|
||||
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
|
||||
? this.lastUpdateCheck
|
||||
: undefined;
|
||||
const result = await runInstallWithResume(
|
||||
this.manager,
|
||||
() => installLatestUpdate(this.settings.updateRepo, cached, onProgress)
|
||||
);
|
||||
if (result.started) {
|
||||
this.lastUpdateCheck = null;
|
||||
this.lastUpdateCheckAt = 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } {
|
||||
const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName);
|
||||
if (parsed.length === 0) {
|
||||
this.audit("WARN", "Links hinzufügen ohne gültigen Inhalt", {
|
||||
hasPackageName: Boolean(payload.packageName)
|
||||
});
|
||||
return { addedPackages: 0, addedLinks: 0, invalidCount: 1 };
|
||||
}
|
||||
const result = this.manager.addPackages(parsed);
|
||||
this.audit("INFO", "Links hinzugefügt", {
|
||||
addedPackages: result.addedPackages,
|
||||
addedLinks: result.addedLinks,
|
||||
requestedPackages: parsed.length
|
||||
});
|
||||
return { ...result, invalidCount: 0 };
|
||||
}
|
||||
|
||||
public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> {
|
||||
const packages = await importDlcContainers(filePaths);
|
||||
const merged: ParsedPackageInput[] = packages.map((pkg) => ({
|
||||
name: pkg.name,
|
||||
links: pkg.links,
|
||||
...(pkg.fileNames ? { fileNames: pkg.fileNames } : {})
|
||||
}));
|
||||
const result = this.manager.addPackages(merged);
|
||||
this.audit("INFO", "Container importiert", {
|
||||
files: filePaths.length,
|
||||
addedPackages: result.addedPackages,
|
||||
addedLinks: result.addedLinks
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public async getStartConflicts(): Promise<StartConflictEntry[]> {
|
||||
return this.manager.getStartConflicts();
|
||||
}
|
||||
|
||||
public async resolveStartConflict(packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> {
|
||||
return this.manager.resolveStartConflict(packageId, policy);
|
||||
}
|
||||
|
||||
public clearAll(): void {
|
||||
this.audit("WARN", "Queue komplett geleert");
|
||||
this.manager.clearAll();
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
this.audit("INFO", "Session-Start ausgelöst");
|
||||
await this.manager.start();
|
||||
}
|
||||
|
||||
public async startPackages(packageIds: string[]): Promise<void> {
|
||||
this.audit("INFO", "Paket-Start ausgelöst", { packageIds });
|
||||
await this.manager.startPackages(packageIds);
|
||||
}
|
||||
|
||||
public async startItems(itemIds: string[]): Promise<void> {
|
||||
this.audit("INFO", "Item-Start ausgelöst", { itemIds });
|
||||
await this.manager.startItems(itemIds);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.audit("INFO", "Session-Stopp ausgelöst");
|
||||
this.manager.stop();
|
||||
}
|
||||
|
||||
public togglePause(): boolean {
|
||||
const paused = this.manager.togglePause();
|
||||
this.audit("INFO", "Pause umgeschaltet", { paused });
|
||||
return paused;
|
||||
}
|
||||
|
||||
public retryExtraction(packageId: string): void {
|
||||
this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
|
||||
this.manager.retryExtraction(packageId);
|
||||
}
|
||||
|
||||
public extractNow(packageId: string): void {
|
||||
this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId });
|
||||
this.manager.extractNow(packageId);
|
||||
}
|
||||
|
||||
public resetPackage(packageId: string): void {
|
||||
this.audit("INFO", "Paket zurückgesetzt", { packageId });
|
||||
this.manager.resetPackage(packageId);
|
||||
}
|
||||
|
||||
public cancelPackage(packageId: string): void {
|
||||
this.audit("WARN", "Paket abgebrochen", { packageId });
|
||||
this.manager.cancelPackage(packageId);
|
||||
}
|
||||
|
||||
public renamePackage(packageId: string, newName: string): void {
|
||||
this.audit("INFO", "Paket umbenannt", { packageId, newName });
|
||||
this.manager.renamePackage(packageId, newName);
|
||||
}
|
||||
|
||||
public reorderPackages(packageIds: string[]): void {
|
||||
this.audit("INFO", "Paketreihenfolge geändert", { packageIds });
|
||||
this.manager.reorderPackages(packageIds);
|
||||
}
|
||||
|
||||
public removeItem(itemId: string): void {
|
||||
this.audit("WARN", "Item entfernt", { itemId });
|
||||
this.manager.removeItem(itemId);
|
||||
}
|
||||
|
||||
public togglePackage(packageId: string): void {
|
||||
this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId });
|
||||
this.manager.togglePackage(packageId);
|
||||
}
|
||||
|
||||
public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
||||
const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []);
|
||||
this.audit("INFO", "Paket-Auswahl exportiert", {
|
||||
packageCount: selection.packageCount,
|
||||
linkCount: selection.linkCount,
|
||||
packageIds
|
||||
});
|
||||
return {
|
||||
text: serializeLinkExportText(selection.packages),
|
||||
defaultFileName: selection.defaultFileName,
|
||||
packageCount: selection.packageCount,
|
||||
linkCount: selection.linkCount
|
||||
};
|
||||
}
|
||||
|
||||
public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
||||
const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds);
|
||||
this.audit("INFO", "Item-Auswahl exportiert", {
|
||||
packageCount: selection.packageCount,
|
||||
linkCount: selection.linkCount,
|
||||
itemIds
|
||||
});
|
||||
return {
|
||||
text: serializeLinkExportText(selection.packages),
|
||||
defaultFileName: selection.defaultFileName,
|
||||
packageCount: selection.packageCount,
|
||||
linkCount: selection.linkCount
|
||||
};
|
||||
}
|
||||
|
||||
public exportQueue(): string {
|
||||
return this.manager.exportQueue();
|
||||
}
|
||||
|
||||
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
|
||||
const result = this.manager.importQueue(json);
|
||||
this.audit("INFO", "Import-Datei verarbeitet", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public getSessionStats(): SessionStats {
|
||||
return this.manager.getSessionStats();
|
||||
}
|
||||
|
||||
public resetSessionStats(): void {
|
||||
this.audit("INFO", "Session-Statistik zurückgesetzt");
|
||||
this.manager.resetSessionStats();
|
||||
}
|
||||
|
||||
public resetDownloadStats(): void {
|
||||
this.manager.resetDownloadStats();
|
||||
this.settings = this.manager.getSettings();
|
||||
this.audit("INFO", "Download-Statistik zurückgesetzt");
|
||||
}
|
||||
|
||||
public exportBackup(): Buffer {
|
||||
let remoteDiagnostics: BackupRemoteDiagnostics | undefined;
|
||||
if (Boolean(this.settings.backupIncludeRemoteDiagnostics)) {
|
||||
const status = getDebugServerRuntimeStatus();
|
||||
remoteDiagnostics = {
|
||||
allowlist: getDebugAllowlist(),
|
||||
port: status.port,
|
||||
hostMode: status.host === "0.0.0.0" ? "network" : "local"
|
||||
};
|
||||
}
|
||||
const payloadObj = buildBackupPayload({
|
||||
settings: { ...this.settings },
|
||||
appVersion: APP_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
session: this.manager.getSession(),
|
||||
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
|
||||
remoteDiagnostics
|
||||
});
|
||||
this.audit("INFO", "Backup exportiert", {
|
||||
kind: payloadObj.kind,
|
||||
historyEntries: payloadObj.history ? payloadObj.history.length : 0,
|
||||
sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0,
|
||||
sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0
|
||||
});
|
||||
return encryptBackup(JSON.stringify(payloadObj));
|
||||
}
|
||||
|
||||
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
|
||||
this.audit("INFO", "Support-Bundle exportiert");
|
||||
logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
|
||||
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
|
||||
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
|
||||
});
|
||||
return {
|
||||
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
|
||||
defaultFileName: getSupportBundleDefaultFileName()
|
||||
};
|
||||
}
|
||||
|
||||
public getSupportBundleDefaultFileName(): string {
|
||||
return getSupportBundleDefaultFileName();
|
||||
}
|
||||
|
||||
public importBackup(data: Buffer): { restored: boolean; relaunch: boolean; message: string } {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
const json = decryptBackup(data);
|
||||
parsed = JSON.parse(json) as Record<string, unknown>;
|
||||
} catch {
|
||||
try {
|
||||
const json = data.toString("utf8");
|
||||
parsed = JSON.parse(json) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { restored: false, relaunch: false, message: "Backup-Datei konnte nicht entschlüsselt werden" };
|
||||
}
|
||||
}
|
||||
const plan = planBackupImport(parsed);
|
||||
if (!plan.valid) {
|
||||
return { restored: false, relaunch: false, message: plan.message };
|
||||
}
|
||||
const hasSession = plan.restoreDownloads;
|
||||
|
||||
const importedSettings = parsed.settings as AppSettings;
|
||||
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
|
||||
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
|
||||
const SENSITIVE_KEYS: (keyof AppSettings)[] = [
|
||||
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
|
||||
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
|
||||
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword",
|
||||
"notifyUrl"
|
||||
];
|
||||
for (const key of SENSITIVE_KEYS) {
|
||||
const val = importedSettingsRecord[key];
|
||||
if (typeof val === "string" && val.startsWith("***")) {
|
||||
importedSettingsRecord[key] = currentSettingsRecord[key];
|
||||
}
|
||||
}
|
||||
const restoredSettings = normalizeSettings(importedSettings);
|
||||
|
||||
// Settings-only backup: keep the running queue AND the live counters untouched.
|
||||
// Overlay the live usage/status counters so they don't roll back to the backup's
|
||||
// (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the
|
||||
// backup's cleanup policy can't purge the live completed queue here (BUG B) — the
|
||||
// policy still governs FUTURE completions through the normal path. Do NOT stop the
|
||||
// manager, wipe the session, block persistence or relaunch.
|
||||
if (!hasSession) {
|
||||
this.overlayLiveUsageCounters(restoredSettings);
|
||||
this.settings = restoredSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
|
||||
this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, true);
|
||||
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
|
||||
accountSummary: buildAccountSummary(this.settings)
|
||||
});
|
||||
return {
|
||||
restored: true,
|
||||
relaunch: false,
|
||||
message: "Einstellungen wiederhergestellt"
|
||||
};
|
||||
}
|
||||
|
||||
this.settings = restoredSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
|
||||
this.manager.stop();
|
||||
this.manager.abortAllPostProcessing();
|
||||
this.manager.clearPersistTimer();
|
||||
cancelPendingAsyncSaves();
|
||||
|
||||
const restoredSession = normalizeLoadedSessionTransientFields(
|
||||
normalizeLoadedSession(parsed.session)
|
||||
);
|
||||
saveSession(this.storagePaths, restoredSession);
|
||||
|
||||
if (Array.isArray(parsed.history) && parsed.history.length > 0) {
|
||||
const normalizedHistory = (parsed.history as unknown[])
|
||||
.map((raw, idx) => normalizeHistoryEntry(raw, idx))
|
||||
.filter((entry): entry is HistoryEntry => entry !== null);
|
||||
if (normalizedHistory.length > 0) {
|
||||
saveHistory(this.storagePaths, normalizedHistory);
|
||||
logger.info(`Backup: ${normalizedHistory.length} History-Einträge wiederhergestellt`);
|
||||
}
|
||||
}
|
||||
|
||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||
|
||||
this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, false);
|
||||
|
||||
this.manager.skipShutdownPersist = true;
|
||||
this.manager.blockAllPersistence = true;
|
||||
logger.info("Backup wiederhergestellt — App startet automatisch neu");
|
||||
this.audit("WARN", "Backup importiert", {
|
||||
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
|
||||
accountSummary: buildAccountSummary(this.settings)
|
||||
});
|
||||
return { restored: true, relaunch: true, message: "Backup wiederhergestellt – App startet automatisch neu…" };
|
||||
}
|
||||
|
||||
public getSessionLogPath(): string | null {
|
||||
return getSessionLogPath();
|
||||
}
|
||||
|
||||
public getPackageLogPath(packageId: string): string | null {
|
||||
return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId);
|
||||
}
|
||||
|
||||
public getItemLogPath(itemId: string): string | null {
|
||||
return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId);
|
||||
}
|
||||
|
||||
public shutdown(): void {
|
||||
if (this.runtimeStatsTimer) {
|
||||
clearInterval(this.runtimeStatsTimer);
|
||||
this.runtimeStatsTimer = null;
|
||||
}
|
||||
stopDebugServer();
|
||||
abortActiveUpdateDownload();
|
||||
cancelPendingAsyncSaves();
|
||||
this.manager.prepareForShutdown();
|
||||
this.megaWebFallback.dispose();
|
||||
this.realDebridWebFallback.dispose();
|
||||
this.allDebridWebFallback.dispose();
|
||||
this.bestDebridWebFallback.dispose();
|
||||
shutdownSessionLog();
|
||||
shutdownPackageLogs();
|
||||
shutdownItemLogs();
|
||||
shutdownRenameLog();
|
||||
shutdownDesktopRenameLog();
|
||||
this.audit("INFO", "App beendet");
|
||||
shutdownTraceLog();
|
||||
shutdownAccountRotationLog();
|
||||
shutdownConversionLog();
|
||||
shutdownAuditLog();
|
||||
if (this.settings.historyRetentionMode === "session") {
|
||||
clearHistory(this.storagePaths);
|
||||
}
|
||||
logger.info("App beendet");
|
||||
}
|
||||
|
||||
private historyLimits(): { maxEntries: number; maxAgeDays: number } {
|
||||
return { maxEntries: this.settings.historyMaxEntries, maxAgeDays: this.settings.historyMaxAgeDays };
|
||||
}
|
||||
|
||||
public getHistory(): HistoryEntry[] {
|
||||
return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits());
|
||||
}
|
||||
|
||||
public clearHistory(): void {
|
||||
this.audit("WARN", "Verlauf geleert");
|
||||
clearHistory(this.storagePaths);
|
||||
}
|
||||
|
||||
public setPackagePriority(packageId: string, priority: PackagePriority): void {
|
||||
this.audit("INFO", "Paket-Priorität geändert", { packageId, priority });
|
||||
this.manager.setPackagePriority(packageId, priority);
|
||||
}
|
||||
|
||||
public skipItems(itemIds: string[]): void {
|
||||
this.audit("INFO", "Items übersprungen", { itemIds });
|
||||
this.manager.skipItems(itemIds);
|
||||
}
|
||||
|
||||
public resetItems(itemIds: string[]): void {
|
||||
this.audit("INFO", "Items zurückgesetzt", { itemIds });
|
||||
this.manager.resetItems(itemIds);
|
||||
}
|
||||
|
||||
public removeHistoryEntry(entryId: string): void {
|
||||
this.audit("INFO", "Verlaufseintrag entfernt", { entryId });
|
||||
removeHistoryEntry(this.storagePaths, entryId);
|
||||
}
|
||||
|
||||
public addToHistory(entry: HistoryEntry): void {
|
||||
this.audit("INFO", "Verlaufseintrag hinzugefügt", {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
status: entry.status,
|
||||
provider: entry.provider,
|
||||
fileCount: entry.fileCount
|
||||
});
|
||||
addHistoryEntry(this.storagePaths, entry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
|
||||
type AuditLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const AUDIT_LOG_MAX_FILE_BYTES = Number(process.env.RD_AUDIT_LOG_MAX_BYTES || 10 * 1024 * 1024);
|
||||
const AUDIT_LOG_RETENTION_DAYS = Number(process.env.RD_AUDIT_LOG_RETENTION_DAYS || 30);
|
||||
|
||||
let auditLogPath: string | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < AUDIT_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initAuditLog(baseDir: string): void {
|
||||
auditLogPath = path.join(baseDir, "audit.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true });
|
||||
cleanupOldBackup(auditLogPath);
|
||||
if (!fs.existsSync(auditLogPath)) {
|
||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(auditLogPath);
|
||||
if (!fs.existsSync(auditLogPath)) {
|
||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
auditLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logAuditEvent(level: AuditLevel, message: string, fields?: Record<string, unknown>): void {
|
||||
if (!auditLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(auditLogPath);
|
||||
if (!fs.existsSync(auditLogPath)) {
|
||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(
|
||||
auditLogPath,
|
||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuditLogPath(): string | null {
|
||||
if (!auditLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(auditLogPath) ? auditLogPath : null;
|
||||
}
|
||||
|
||||
export function shutdownAuditLog(): void {
|
||||
if (!auditLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
auditLogPath = null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const MAGIC = Buffer.from("MDD1");
|
||||
|
||||
function deriveKey(): Buffer {
|
||||
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
||||
}
|
||||
|
||||
export function encryptBackup(plaintext: string): Buffer {
|
||||
const key = deriveKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
|
||||
}
|
||||
|
||||
export function decryptBackup(data: Buffer): string {
|
||||
if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
|
||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
||||
}
|
||||
const magic = data.subarray(0, MAGIC.length);
|
||||
if (!magic.equals(MAGIC)) {
|
||||
throw new Error("Keine gültige MDD-Backup-Datei (falsche Signatur)");
|
||||
}
|
||||
const iv = data.subarray(MAGIC.length, MAGIC.length + IV_LENGTH);
|
||||
const authTag = data.subarray(MAGIC.length + IV_LENGTH, MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const ciphertext = data.subarray(MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
|
||||
const key = deriveKey();
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
|
||||
|
||||
export type BackupKind = "full" | "settings-only";
|
||||
|
||||
export interface BackupRemoteDiagnostics {
|
||||
allowlist: string[];
|
||||
port: number;
|
||||
hostMode: "local" | "network";
|
||||
}
|
||||
|
||||
export interface BackupPayload {
|
||||
version: 2;
|
||||
kind: BackupKind;
|
||||
appVersion: string;
|
||||
exportedAt: string;
|
||||
settings: AppSettings;
|
||||
session?: SessionState;
|
||||
history?: HistoryEntry[];
|
||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
||||
}
|
||||
|
||||
export interface BuildBackupInput {
|
||||
settings: AppSettings;
|
||||
appVersion: string;
|
||||
exportedAt: string;
|
||||
/** Only bundled when includeDownloads is true. */
|
||||
session: SessionState;
|
||||
history: HistoryEntry[];
|
||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the backup payload. By default ("Download-Liste mitsichern" off) the
|
||||
* payload contains ONLY settings — no session, no history. The download list is
|
||||
* bundled solely when settings.backupIncludeDownloads is true. An explicit kind
|
||||
* marker makes the import side unambiguous and survives hand-edited files.
|
||||
*/
|
||||
export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
||||
const includeDownloads = Boolean(input.settings.backupIncludeDownloads);
|
||||
const base: BackupPayload = {
|
||||
version: 2,
|
||||
kind: includeDownloads ? "full" : "settings-only",
|
||||
appVersion: input.appVersion,
|
||||
exportedAt: input.exportedAt,
|
||||
settings: input.settings
|
||||
};
|
||||
if (includeDownloads) {
|
||||
base.session = input.session;
|
||||
base.history = input.history;
|
||||
}
|
||||
if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) {
|
||||
base.remoteDiagnostics = input.remoteDiagnostics;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export interface RemoteDiagnosticsRestore {
|
||||
host?: "127.0.0.1" | "0.0.0.0";
|
||||
port?: number;
|
||||
allowlist?: string[];
|
||||
}
|
||||
|
||||
export function resolveRemoteDiagnosticsRestore(section: unknown): RemoteDiagnosticsRestore | null {
|
||||
if (!section || typeof section !== "object") {
|
||||
return null;
|
||||
}
|
||||
const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown };
|
||||
const allowlist = Array.isArray(s.allowlist)
|
||||
? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim())
|
||||
: undefined;
|
||||
const port = (typeof s.port === "number" && Number.isInteger(s.port) && s.port >= 1024 && s.port <= 65535) ? s.port : undefined;
|
||||
let host: "127.0.0.1" | "0.0.0.0" | undefined;
|
||||
if (s.hostMode === "network") {
|
||||
host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1";
|
||||
} else if (s.hostMode === "local") {
|
||||
host = "127.0.0.1";
|
||||
}
|
||||
if (host === undefined && port === undefined && allowlist === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { host, port, allowlist };
|
||||
}
|
||||
|
||||
export interface ImportPlan {
|
||||
valid: boolean;
|
||||
/** Restore the download list (session + history) and relaunch. */
|
||||
restoreDownloads: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide how to apply an imported backup based on what the FILE physically
|
||||
* contains — NOT the local toggle. A backup without a session restores settings
|
||||
* only (no queue wipe, no relaunch); a full backup (with session) restores the
|
||||
* queue too. This way an old full backup still restores fully even if the local
|
||||
* toggle is currently off, and a settings-only backup never disturbs a running
|
||||
* queue.
|
||||
*/
|
||||
export function planBackupImport(parsed: unknown): ImportPlan {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (!record.settings || typeof record.settings !== "object") {
|
||||
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
||||
}
|
||||
const hasSession = Boolean(record.session) && typeof record.session === "object";
|
||||
return {
|
||||
valid: true,
|
||||
restoreDownloads: hasSession,
|
||||
message: hasSession
|
||||
? "Backup wiederhergestellt – App startet automatisch neu…"
|
||||
: "Einstellungen wiederhergestellt"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import fs from "node:fs";
|
||||
import { session, type 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_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";
|
||||
|
||||
function abortError(): Error {
|
||||
return new Error("aborted:bestdebrid-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();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
interface NetscapeCookie {
|
||||
domain: string;
|
||||
includeSubdomains: boolean;
|
||||
httpOnly: boolean;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
expirationDate: number;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function normalizeCookieDomain(domain: string): string {
|
||||
return String(domain || "").trim().replace(/^\./, "").toLowerCase();
|
||||
}
|
||||
|
||||
function dedupeCookies(cookies: NetscapeCookie[]): NetscapeCookie[] {
|
||||
const deduped = new Map<string, NetscapeCookie>();
|
||||
for (const cookie of cookies) {
|
||||
const key = `${normalizeCookieDomain(cookie.domain)}\t${cookie.path}\t${cookie.name}`;
|
||||
const existing = deduped.get(key);
|
||||
if (!existing) {
|
||||
deduped.set(key, cookie);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cookie.httpOnly && !existing.httpOnly) {
|
||||
deduped.set(key, cookie);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cookie.expirationDate > existing.expirationDate) {
|
||||
deduped.set(key, cookie);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
function parseNetscapeCookieFile(text: string): NetscapeCookie[] {
|
||||
const cookies: NetscapeCookie[] = [];
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalizedLine = trimmed;
|
||||
let httpOnly = false;
|
||||
if (normalizedLine.startsWith("#HttpOnly_")) {
|
||||
httpOnly = true;
|
||||
normalizedLine = normalizedLine.slice("#HttpOnly_".length);
|
||||
} else if (normalizedLine.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const parts = normalizedLine.split("\t");
|
||||
if (parts.length < 7) {
|
||||
continue;
|
||||
}
|
||||
cookies.push({
|
||||
domain: parts[0],
|
||||
includeSubdomains: parts[1].toUpperCase() === "TRUE",
|
||||
httpOnly,
|
||||
path: parts[2],
|
||||
secure: parts[3].toUpperCase() === "TRUE",
|
||||
expirationDate: Number(parts[4]) || 0,
|
||||
name: parts[5],
|
||||
value: parts[6]
|
||||
});
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function isLikelyBestDebridAuthCookie(name: string): boolean {
|
||||
const normalized = String(name || "").trim();
|
||||
return /phpsessid|sess(?:ion)?|auth|login/i.test(normalized);
|
||||
}
|
||||
|
||||
function isAuthenticatedBestDebridHtml(html: string): boolean {
|
||||
const normalized = String(html || "");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return /href\s*=\s*["']logout["']/i.test(normalized)
|
||||
|| /title\s*=\s*["'][^"']*premium until/i.test(normalized)
|
||||
|| (/user-profile-image/i.test(normalized) && !/>\s*guest\s*</i.test(normalized));
|
||||
}
|
||||
|
||||
function looksLikeGuestAccessMessage(message: string): boolean {
|
||||
return /free users are not allowed|purchase a premium plan|premium required/i.test(String(message || ""));
|
||||
}
|
||||
|
||||
export class BestDebridWebFallback {
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
private cookiesImported = false;
|
||||
|
||||
private getRememberSession: () => boolean;
|
||||
|
||||
public constructor(getRememberSession: () => boolean) {
|
||||
this.getRememberSession = getRememberSession;
|
||||
}
|
||||
|
||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
const overallSignal = withTimeoutSignal(signal, 60_000);
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
if (!String(link || "").trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.cookiesImported) {
|
||||
throw new Error("BestDebrid: Keine Cookies importiert. Bitte zuerst über Einstellungen eine Cookie-Datei importieren.");
|
||||
}
|
||||
|
||||
const result = await this.generate(link, overallSignal);
|
||||
if (result.kind === "success") {
|
||||
return result.value;
|
||||
}
|
||||
this.cookiesImported = false;
|
||||
throw new Error("BestDebrid: Nicht eingeloggt. Bitte neue Cookie-Datei importieren.");
|
||||
}, overallSignal);
|
||||
}
|
||||
|
||||
public async importCookiesFromFile(filePath: string): Promise<number> {
|
||||
const text = fs.readFileSync(filePath, "utf-8");
|
||||
const cookies = parseNetscapeCookieFile(text);
|
||||
const bestDebridCookies = dedupeCookies(cookies.filter((c) =>
|
||||
c.domain.includes("bestdebrid.com")
|
||||
));
|
||||
|
||||
if (bestDebridCookies.length === 0) {
|
||||
throw new Error("Keine BestDebrid-Cookies in der Datei gefunden");
|
||||
}
|
||||
|
||||
if (!bestDebridCookies.some((cookie) => isLikelyBestDebridAuthCookie(cookie.name))) {
|
||||
throw new Error("BestDebrid: Cookie-Datei enthält keinen Login-Cookie. Bitte nach dem Login erneut exportieren.");
|
||||
}
|
||||
|
||||
const currentSession = session.fromPartition(this.getPartition());
|
||||
await this.clearPartitionState(currentSession);
|
||||
|
||||
for (const cookie of bestDebridCookies) {
|
||||
const url = `https://${cookie.domain.replace(/^\./, "")}${cookie.path}`;
|
||||
const details: Parameters<typeof currentSession.cookies.set>[0] = {
|
||||
url,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
path: cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
expirationDate: cookie.expirationDate > 0 ? cookie.expirationDate : undefined
|
||||
};
|
||||
if (cookie.includeSubdomains || cookie.domain.startsWith(".")) {
|
||||
details.domain = cookie.domain;
|
||||
}
|
||||
await currentSession.cookies.set(details);
|
||||
}
|
||||
|
||||
this.cookiesImported = true;
|
||||
logger.info(`BestDebrid: ${bestDebridCookies.length} Cookies importiert aus ${filePath}`);
|
||||
return bestDebridCookies.length;
|
||||
}
|
||||
|
||||
public async clearSessions(): Promise<void> {
|
||||
this.cookiesImported = false;
|
||||
for (const partition of [BESTDEBRID_PERSISTENT_PARTITION, BESTDEBRID_TRANSIENT_PARTITION]) {
|
||||
const currentSession = session.fromPartition(partition);
|
||||
try {
|
||||
await currentSession.clearStorageData({
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
}
|
||||
|
||||
private getPartition(): string {
|
||||
return this.getRememberSession() ? BESTDEBRID_PERSISTENT_PARTITION : BESTDEBRID_TRANSIENT_PARTITION;
|
||||
}
|
||||
|
||||
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(`BestDebrid-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 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, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json, text/javascript, */*; q=0.01",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
Origin: BESTDEBRID_BASE_URL,
|
||||
Referer: BESTDEBRID_DOWNLOADER_URL,
|
||||
"User-Agent": BESTDEBRID_USER_AGENT,
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
},
|
||||
body: new URLSearchParams({ link, pass: "", boxlinklist: "" }).toString(),
|
||||
signal: withTimeoutSignal(signal, 30_000)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok || text.trim().startsWith("<!") || text.trim().startsWith("<html")) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
|
||||
const payload = parseJson(text.trim());
|
||||
if (!payload) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
|
||||
const error = Number(payload.error ?? -1);
|
||||
const message = String(payload.message || "").trim();
|
||||
|
||||
if (error !== 0) {
|
||||
if (/login|log in|sign in|not logged|session|auth/i.test(message)) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
if (looksLikeGuestAccessMessage(message)) {
|
||||
const authenticated = await this.isAuthenticated(currentSession, signal).catch(() => null);
|
||||
if (authenticated === false) {
|
||||
return { kind: "login_required" };
|
||||
}
|
||||
}
|
||||
throw new Error(`BestDebrid Web: ${message || "Unbekannter Fehler"}`);
|
||||
}
|
||||
|
||||
const directUrl = String(payload.link || "").trim();
|
||||
if (!directUrl) {
|
||||
throw new Error("BestDebrid Web: Antwort ohne Download-Link");
|
||||
}
|
||||
|
||||
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
||||
const fileSizeRaw = String(payload.size || "").trim();
|
||||
let fileSize: number | null = null;
|
||||
if (fileSizeRaw) {
|
||||
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().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));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "success",
|
||||
value: {
|
||||
directUrl,
|
||||
fileName,
|
||||
fileSize,
|
||||
retriesUsed: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async isAuthenticated(currentSession: Session, signal?: AbortSignal): Promise<boolean> {
|
||||
throwIfAborted(signal);
|
||||
const response = await currentSession.fetch(BESTDEBRID_DOWNLOADER_URL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
Referer: BESTDEBRID_BASE_URL,
|
||||
"User-Agent": BESTDEBRID_USER_AGENT
|
||||
},
|
||||
signal: withTimeoutSignal(signal, 20_000)
|
||||
});
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
const text = await response.text();
|
||||
return isAuthenticatedBestDebridHtml(text);
|
||||
}
|
||||
|
||||
private async clearPartitionState(currentSession: Session): Promise<void> {
|
||||
await currentSession.clearStorageData({
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ARCHIVE_TEMP_EXTENSIONS, LINK_ARTIFACT_EXTENSIONS, MAX_LINK_ARTIFACT_BYTES, RAR_SPLIT_RE, SAMPLE_DIR_NAMES, SAMPLE_TOKEN_RE, SAMPLE_VIDEO_EXTENSIONS } from "./constants";
|
||||
|
||||
async function yieldToLoop(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
export function isArchiveOrTempFile(filePath: string): boolean {
|
||||
const lowerName = path.basename(filePath).toLowerCase();
|
||||
const ext = path.extname(lowerName);
|
||||
if (ARCHIVE_TEMP_EXTENSIONS.has(ext)) {
|
||||
return true;
|
||||
}
|
||||
if (lowerName.includes(".part") && lowerName.endsWith(".rar")) {
|
||||
return true;
|
||||
}
|
||||
return RAR_SPLIT_RE.test(lowerName);
|
||||
}
|
||||
|
||||
export function cleanupCancelledPackageArtifacts(packageDir: string): number {
|
||||
if (!fs.existsSync(packageDir)) {
|
||||
return 0;
|
||||
}
|
||||
let removed = 0;
|
||||
const stack = [packageDir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
||||
try {
|
||||
fs.rmSync(full, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function cleanupCancelledPackageArtifactsAsync(
|
||||
packageDir: string,
|
||||
options: { shouldAbort?: () => boolean } = {}
|
||||
): Promise<number> {
|
||||
try {
|
||||
await fs.promises.access(packageDir, fs.constants.F_OK);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
let touched = 0;
|
||||
const stack = [packageDir];
|
||||
while (stack.length > 0) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
const current = stack.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
||||
try {
|
||||
await fs.promises.rm(full, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
touched += 1;
|
||||
if (touched % 80 === 0) {
|
||||
await yieldToLoop();
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function removeDownloadLinkArtifacts(
|
||||
extractDir: string,
|
||||
options: { shouldAbort?: () => boolean } = {}
|
||||
): Promise<number> {
|
||||
try {
|
||||
await fs.promises.access(extractDir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
let removed = 0;
|
||||
const stack = [extractDir];
|
||||
while (stack.length > 0) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
const current = stack.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return removed;
|
||||
}
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
stack.push(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const name = entry.name.toLowerCase();
|
||||
let shouldDelete = LINK_ARTIFACT_EXTENSIONS.has(ext);
|
||||
if (!shouldDelete && [".txt", ".html", ".htm", ".nfo"].includes(ext)) {
|
||||
if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(full);
|
||||
if (stat.size <= MAX_LINK_ARTIFACT_BYTES) {
|
||||
const text = await fs.promises.readFile(full, "utf8");
|
||||
shouldDelete = /https?:\/\//i.test(text);
|
||||
}
|
||||
} catch {
|
||||
shouldDelete = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldDelete) {
|
||||
try {
|
||||
await fs.promises.rm(full, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export async function removeSampleArtifacts(
|
||||
extractDir: string,
|
||||
options: { shouldAbort?: () => boolean } = {}
|
||||
): Promise<{ files: number; dirs: number }> {
|
||||
try {
|
||||
await fs.promises.access(extractDir);
|
||||
} catch {
|
||||
return { files: 0, dirs: 0 };
|
||||
}
|
||||
|
||||
let removedFiles = 0;
|
||||
let removedDirs = 0;
|
||||
const sampleDirs: string[] = [];
|
||||
const stack = [extractDir];
|
||||
|
||||
const countFilesRecursive = async (rootDir: string): Promise<number> => {
|
||||
let count = 0;
|
||||
const dirs = [rootDir];
|
||||
while (dirs.length > 0) {
|
||||
const current = dirs.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
try {
|
||||
const stat = await fs.promises.lstat(full);
|
||||
if (stat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
dirs.push(full);
|
||||
} else if (entry.isFile()) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
while (stack.length > 0) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
const current = stack.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const base = entry.name.toLowerCase();
|
||||
if (SAMPLE_DIR_NAMES.has(base)) {
|
||||
sampleDirs.push(full);
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stem = path.parse(entry.name).name.toLowerCase();
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const isSampleVideo = SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem);
|
||||
|
||||
if (isSampleVideo) {
|
||||
try {
|
||||
await fs.promises.rm(full, { force: true });
|
||||
removedFiles += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sampleDirs.sort((a, b) => b.length - a.length);
|
||||
for (const dir of sampleDirs) {
|
||||
if (options.shouldAbort?.()) {
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
try {
|
||||
const stat = await fs.promises.lstat(dir);
|
||||
if (stat.isSymbolicLink()) {
|
||||
await fs.promises.rm(dir, { force: true });
|
||||
removedDirs += 1;
|
||||
continue;
|
||||
}
|
||||
const filesInDir = await countFilesRecursive(dir);
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
removedFiles += filesInDir;
|
||||
removedDirs += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return { files: removedFiles, dirs: removedDirs };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const PREFIX = "rddiag:v1:";
|
||||
|
||||
function base64urlEncode(value: string): string {
|
||||
return Buffer.from(value, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export interface ConnectionCodeInput {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
name?: string;
|
||||
scheme?: "http" | "https";
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export function encodeConnectionCode(input: ConnectionCodeInput): string {
|
||||
const host = String(input.host || "").trim();
|
||||
if (!host) throw new Error("Host fehlt fuer Verbindungscode");
|
||||
const port = Number(input.port);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port ungueltig fuer Verbindungscode");
|
||||
if (!input.token) throw new Error("Token fehlt fuer Verbindungscode");
|
||||
const payload: Record<string, unknown> = { v: 1, h: host, p: port, t: input.token };
|
||||
if (input.name) payload.n = String(input.name);
|
||||
if (input.fingerprint) payload.fp = String(input.fingerprint);
|
||||
if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme);
|
||||
return PREFIX + base64urlEncode(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export interface RemoteMeta {
|
||||
publicHost: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function remoteMetaPath(baseDir: string): string {
|
||||
return path.join(baseDir, "debug_remote.json");
|
||||
}
|
||||
|
||||
export function loadRemoteMeta(baseDir: string): RemoteMeta {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8"));
|
||||
return {
|
||||
publicHost: String(parsed.publicHost || ""),
|
||||
name: String(parsed.name || "")
|
||||
};
|
||||
} catch {
|
||||
return { publicHost: "", name: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void {
|
||||
fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8");
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { AppSettings } from "../shared/types";
|
||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||
import packageJson from "../../package.json";
|
||||
|
||||
export const APP_NAME = "Multi Debrid Downloader";
|
||||
export const APP_VERSION: string = packageJson.version;
|
||||
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
|
||||
|
||||
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
|
||||
export const DCRYPT_PASTE_URL = "https://dcrypt.it/decrypt/paste";
|
||||
export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}";
|
||||
export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8");
|
||||
export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
|
||||
|
||||
export const REQUEST_RETRIES = 3;
|
||||
export const CHUNK_SIZE = 512 * 1024;
|
||||
|
||||
export const WRITE_BUFFER_SIZE = 512 * 1024;
|
||||
export const WRITE_FLUSH_TIMEOUT_MS = 2000;
|
||||
export const ALLOCATION_UNIT_SIZE = 4096;
|
||||
export const STREAM_HIGH_WATER_MARK = 512 * 1024;
|
||||
export const DISK_BUSY_THRESHOLD_MS = 300;
|
||||
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500;
|
||||
|
||||
export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]);
|
||||
export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]);
|
||||
export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]);
|
||||
export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i;
|
||||
|
||||
export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz", ".rev"]);
|
||||
export const RAR_SPLIT_RE = /\.r\d{2,3}$/i;
|
||||
|
||||
export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024;
|
||||
export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
|
||||
export const SPEED_WINDOW_SECONDS = 1;
|
||||
export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/multi-debrid-downloader";
|
||||
|
||||
export function defaultSettings(): AppSettings {
|
||||
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
|
||||
return {
|
||||
token: "",
|
||||
realDebridUseWebLogin: false,
|
||||
megaLogin: "",
|
||||
megaPassword: "",
|
||||
megaCredentials: "",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridPreferApi: true,
|
||||
bestToken: "",
|
||||
bestDebridUseWebLogin: false,
|
||||
allDebridToken: "",
|
||||
allDebridUseWebLogin: false,
|
||||
ddownloadLogin: "",
|
||||
ddownloadPassword: "",
|
||||
oneFichierApiKey: "",
|
||||
debridLinkApiKeys: "",
|
||||
debridLinkDisabledKeyIds: [],
|
||||
linkSnappyLogin: "",
|
||||
linkSnappyPassword: "",
|
||||
archivePasswordList: "",
|
||||
rememberToken: true,
|
||||
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"],
|
||||
providerPrimary: "realdebrid",
|
||||
providerSecondary: "megadebrid-api",
|
||||
providerTertiary: "bestdebrid",
|
||||
autoProviderFallback: true,
|
||||
outputDir: baseDir,
|
||||
packageName: "",
|
||||
autoExtract: true,
|
||||
autoRename4sf4sj: false,
|
||||
keepGermanAudioOnly: false,
|
||||
germanAudioMode: "tag",
|
||||
extractDir: path.join(baseDir, "_entpackt"),
|
||||
collectMkvToLibrary: false,
|
||||
mkvLibraryDir: path.join(baseDir, "_mkv"),
|
||||
createExtractSubfolder: true,
|
||||
hybridExtract: true,
|
||||
cleanupMode: "none",
|
||||
extractConflictMode: "overwrite",
|
||||
removeLinkFilesAfterExtract: false,
|
||||
removeSamplesAfterExtract: false,
|
||||
enableIntegrityCheck: true,
|
||||
autoResumeOnStart: true,
|
||||
autoReconnect: false,
|
||||
reconnectWaitSeconds: 45,
|
||||
completedCleanupPolicy: "never",
|
||||
maxParallel: 4,
|
||||
maxParallelExtract: 2,
|
||||
retryLimit: 0,
|
||||
speedLimitEnabled: false,
|
||||
speedLimitKbps: 0,
|
||||
speedLimitMode: "global",
|
||||
updateRepo: DEFAULT_UPDATE_REPO,
|
||||
autoUpdateCheck: true,
|
||||
clipboardWatch: false,
|
||||
minimizeToTray: false,
|
||||
theme: "dark" as const,
|
||||
collapseNewPackages: true,
|
||||
historyRetentionMode: "permanent",
|
||||
historyMaxEntries: 500,
|
||||
historyMaxAgeDays: 0,
|
||||
accountListShowDetailedDebridLinkKeys: false,
|
||||
autoSortPackagesByProgress: true,
|
||||
autoSkipExtracted: false,
|
||||
hideExtractedItems: true,
|
||||
confirmDeleteSelection: true,
|
||||
backupIncludeDownloads: false,
|
||||
backupIncludeRemoteDiagnostics: false,
|
||||
notifyUrl: "",
|
||||
notifyMention: "",
|
||||
notifyOnPackageCompleted: false,
|
||||
notifyOnPackageFailed: false,
|
||||
notifyOnRunFinished: false,
|
||||
totalDownloadedAllTime: 0,
|
||||
totalCompletedFilesAllTime: 0,
|
||||
totalRuntimeAllTimeMs: 0,
|
||||
bandwidthSchedules: [],
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||
extractCpuPriority: "high",
|
||||
autoExtractWhenStopped: true,
|
||||
disabledProviders: [],
|
||||
hosterRouting: {},
|
||||
providerDailyLimitBytes: {},
|
||||
providerDailyUsageBytes: {},
|
||||
providerTotalUsageBytes: {},
|
||||
debridLinkApiKeyDailyLimitBytes: {},
|
||||
debridLinkApiKeyDailyUsageBytes: {},
|
||||
debridLinkApiKeyTotalUsageBytes: {},
|
||||
megaDebridDisabledAccountIds: [],
|
||||
megaDebridAccountDailyLimitBytes: {},
|
||||
megaDebridAccountDailyUsageBytes: {},
|
||||
megaDebridAccountTotalUsageBytes: {},
|
||||
debridAccountStatuses: {},
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
scheduledStartEpochMs: 0
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { DCRYPT_PASTE_URL, DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./constants";
|
||||
import { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||
import { ParsedPackageInput } from "../shared/types";
|
||||
|
||||
const MAX_DLC_FILE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function isContainerSizeValidationError(error: unknown): boolean {
|
||||
const text = compactErrorText(error);
|
||||
return /zu groß/i.test(text) || /DLC-Datei ungültig oder zu groß/i.test(text);
|
||||
}
|
||||
|
||||
function decodeDcryptPayload(responseText: string): unknown {
|
||||
let text = String(responseText || "").trim();
|
||||
const m = text.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
|
||||
if (m) {
|
||||
text = m[1].replace(/"/g, '"').replace(/&/g, "&").trim();
|
||||
}
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUrlsRecursive(data: unknown): string[] {
|
||||
if (typeof data === "string") {
|
||||
const found = data.match(/https?:\/\/[^\s"'<>]+/gi) ?? [];
|
||||
return uniquePreserveOrder(found.filter((url) => isHttpLink(url)));
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return uniquePreserveOrder(data.flatMap((item) => extractUrlsRecursive(item)));
|
||||
}
|
||||
if (data && typeof data === "object") {
|
||||
return uniquePreserveOrder(Object.values(data as Record<string, unknown>).flatMap((value) => extractUrlsRecursive(value)));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function groupLinksByName(links: string[]): ParsedPackageInput[] {
|
||||
const unique = uniquePreserveOrder(links.filter((link) => isHttpLink(link)));
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const link of unique) {
|
||||
const name = sanitizeFilename(inferPackageNameFromLinks([link]) || "Paket");
|
||||
const current = grouped.get(name) ?? [];
|
||||
current.push(link);
|
||||
grouped.set(name, current);
|
||||
}
|
||||
return Array.from(grouped.entries()).map(([name, packageLinks]) => ({ name, links: packageLinks }));
|
||||
}
|
||||
|
||||
function extractPackagesFromPayload(payload: unknown): ParsedPackageInput[] {
|
||||
const urls = extractUrlsRecursive(payload);
|
||||
if (urls.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return groupLinksByName(urls);
|
||||
}
|
||||
|
||||
function decryptRcPayload(base64Rc: string): Buffer {
|
||||
const rcBytes = Buffer.from(base64Rc, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", DLC_AES_KEY, DLC_AES_IV);
|
||||
decipher.setAutoPadding(false);
|
||||
return Buffer.concat([decipher.update(rcBytes), decipher.final()]);
|
||||
}
|
||||
|
||||
function readDlcFileWithLimit(filePath: string): Buffer {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size <= 0 || stat.size > MAX_DLC_FILE_BYTES) {
|
||||
throw new Error(`DLC-Datei ungültig oder zu groß (${Math.floor(stat.size)} B)`);
|
||||
}
|
||||
return fs.readFileSync(filePath);
|
||||
}
|
||||
|
||||
function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
||||
const packages: ParsedPackageInput[] = [];
|
||||
const packageRegex = /<package\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/package>/gi;
|
||||
|
||||
for (let m = packageRegex.exec(xml); m; m = packageRegex.exec(xml)) {
|
||||
const encodedName = m[1] || "";
|
||||
const packageBody = m[2] || "";
|
||||
let packageName = "";
|
||||
if (encodedName) {
|
||||
try {
|
||||
packageName = Buffer.from(encodedName, "base64").toString("utf8");
|
||||
} catch {
|
||||
packageName = encodedName;
|
||||
}
|
||||
}
|
||||
|
||||
const links: string[] = [];
|
||||
const fileNames: string[] = [];
|
||||
const fileRegex = /<file>([\s\S]*?)<\/file>/gi;
|
||||
for (let fm = fileRegex.exec(packageBody); fm; fm = fileRegex.exec(packageBody)) {
|
||||
const fileBody = fm[1] || "";
|
||||
const urlMatch = fileBody.match(/<url>(.*?)<\/url>/i);
|
||||
if (!urlMatch) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const url = Buffer.from((urlMatch[1] || "").trim(), "base64").toString("utf8").trim();
|
||||
if (!isHttpLink(url)) {
|
||||
continue;
|
||||
}
|
||||
let fileName = "";
|
||||
const fnMatch = fileBody.match(/<filename>(.*?)<\/filename>/i);
|
||||
if (fnMatch?.[1]) {
|
||||
try {
|
||||
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
links.push(url);
|
||||
fileNames.push(sanitizeFilename(fileName));
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (links.length === 0) {
|
||||
const urlRegex = /<url>(.*?)<\/url>/gi;
|
||||
for (let um = urlRegex.exec(packageBody); um; um = urlRegex.exec(packageBody)) {
|
||||
try {
|
||||
const url = Buffer.from((um[1] || "").trim(), "base64").toString("utf8").trim();
|
||||
if (isHttpLink(url)) {
|
||||
links.push(url);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueLinks = uniquePreserveOrder(links);
|
||||
const hasFileNames = fileNames.some((fn) => fn.length > 0);
|
||||
if (uniqueLinks.length > 0) {
|
||||
const pkg: ParsedPackageInput = {
|
||||
name: sanitizeFilename(packageName || inferPackageNameFromLinks(uniqueLinks) || `Paket-${packages.length + 1}`),
|
||||
links: uniqueLinks
|
||||
};
|
||||
if (hasFileNames) {
|
||||
pkg.fileNames = fileNames;
|
||||
}
|
||||
packages.push(pkg);
|
||||
}
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]> {
|
||||
const content = readDlcFileWithLimit(filePath).toString("ascii").trim();
|
||||
if (content.length < 89) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dlcKey = content.slice(-88);
|
||||
const dlcData = content.slice(0, -88);
|
||||
|
||||
const rcUrl = DLC_SERVICE_URL.replace("{KEY}", encodeURIComponent(dlcKey));
|
||||
const rcResponse = await fetch(rcUrl, { method: "GET", signal: AbortSignal.timeout(30000) });
|
||||
if (!rcResponse.ok) {
|
||||
return [];
|
||||
}
|
||||
const rcText = await rcResponse.text();
|
||||
const rcMatch = rcText.match(/<rc>(.*?)<\/rc>/i);
|
||||
if (!rcMatch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const realKey = decryptRcPayload(rcMatch[1]).subarray(0, 16);
|
||||
const encrypted = Buffer.from(dlcData, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", realKey, realKey);
|
||||
decipher.setAutoPadding(false);
|
||||
let decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
|
||||
if (decrypted.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const pad = decrypted[decrypted.length - 1];
|
||||
if (pad > 0 && pad <= 16 && pad <= decrypted.length) {
|
||||
let validPad = true;
|
||||
for (let index = 1; index <= pad; index += 1) {
|
||||
if (decrypted[decrypted.length - index] !== pad) {
|
||||
validPad = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (validPad) {
|
||||
decrypted = decrypted.subarray(0, decrypted.length - pad);
|
||||
}
|
||||
}
|
||||
|
||||
const xmlData = Buffer.from(decrypted.toString("utf8"), "base64").toString("utf8");
|
||||
return parsePackagesFromDlcXml(xmlData);
|
||||
}
|
||||
|
||||
function extractLinksFromResponse(text: string): string[] {
|
||||
const payload = decodeDcryptPayload(text);
|
||||
let links = extractUrlsRecursive(payload);
|
||||
if (links.length === 0) {
|
||||
links = extractUrlsRecursive(text);
|
||||
}
|
||||
return uniquePreserveOrder(links.filter((l) => isHttpLink(l)));
|
||||
}
|
||||
|
||||
async function tryDcryptUpload(fileContent: Buffer, fileName: string): Promise<string[] | null> {
|
||||
const blob = new Blob([new Uint8Array(fileContent)]);
|
||||
const form = new FormData();
|
||||
form.set("dlcfile", blob, fileName);
|
||||
|
||||
const response = await fetch(DCRYPT_UPLOAD_URL, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(30000)
|
||||
});
|
||||
if (response.status === 413) {
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(compactErrorText(text));
|
||||
}
|
||||
return extractLinksFromResponse(text);
|
||||
}
|
||||
|
||||
async function tryDcryptPaste(fileContent: Buffer): Promise<string[] | null> {
|
||||
const form = new FormData();
|
||||
form.set("content", fileContent.toString("ascii"));
|
||||
|
||||
const response = await fetch(DCRYPT_PASTE_URL, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(30000)
|
||||
});
|
||||
if (response.status === 413) {
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(compactErrorText(text));
|
||||
}
|
||||
return extractLinksFromResponse(text);
|
||||
}
|
||||
|
||||
async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput[]> {
|
||||
const fileContent = readDlcFileWithLimit(filePath);
|
||||
const fileName = path.basename(filePath);
|
||||
const packageName = sanitizeFilename(path.basename(filePath, ".dlc")) || "Paket";
|
||||
|
||||
let links = await tryDcryptUpload(fileContent, fileName);
|
||||
if (links === null) {
|
||||
links = await tryDcryptPaste(fileContent);
|
||||
}
|
||||
if (links === null) {
|
||||
throw new Error("DLC-Datei zu groß für dcrypt.it");
|
||||
}
|
||||
if (links.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [{ name: packageName, links }];
|
||||
}
|
||||
|
||||
export async function importDlcContainers(filePaths: string[]): Promise<ParsedPackageInput[]> {
|
||||
const out: ParsedPackageInput[] = [];
|
||||
const failures: string[] = [];
|
||||
let sawDlc = false;
|
||||
for (const filePath of filePaths) {
|
||||
if (path.extname(filePath).toLowerCase() !== ".dlc") {
|
||||
continue;
|
||||
}
|
||||
sawDlc = true;
|
||||
let packages: ParsedPackageInput[] = [];
|
||||
let fileFailed = false;
|
||||
let fileFailureReasons: string[] = [];
|
||||
try {
|
||||
packages = await decryptDlcLocal(filePath);
|
||||
} catch (error) {
|
||||
if (isContainerSizeValidationError(error)) {
|
||||
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
|
||||
continue;
|
||||
}
|
||||
fileFailed = true;
|
||||
fileFailureReasons.push(`lokal: ${compactErrorText(error)}`);
|
||||
packages = [];
|
||||
}
|
||||
if (packages.length === 0) {
|
||||
try {
|
||||
packages = await decryptDlcViaDcrypt(filePath);
|
||||
} catch (error) {
|
||||
if (isContainerSizeValidationError(error)) {
|
||||
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
|
||||
continue;
|
||||
}
|
||||
fileFailed = true;
|
||||
fileFailureReasons.push(`dcrypt: ${compactErrorText(error)}`);
|
||||
packages = [];
|
||||
}
|
||||
}
|
||||
if (packages.length === 0 && fileFailed) {
|
||||
failures.push(`${path.basename(filePath)}: ${fileFailureReasons.join("; ")}`);
|
||||
}
|
||||
out.push(...packages);
|
||||
}
|
||||
|
||||
if (out.length === 0 && sawDlc && failures.length > 0) {
|
||||
const details = failures.slice(0, 2).join(" | ");
|
||||
const suffix = failures.length > 2 ? ` (+${failures.length - 2} weitere)` : "";
|
||||
throw new Error(`DLC konnte nicht importiert werden: ${details}${suffix}`);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
|
||||
export interface ConversionPhase {
|
||||
atMs: number;
|
||||
phase: string;
|
||||
provider?: string;
|
||||
account?: string;
|
||||
tokenState?: string;
|
||||
queueWaitMs?: number;
|
||||
workMs?: number;
|
||||
outcome?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ConversionTrace {
|
||||
startedAt: number;
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
link: string;
|
||||
providerOrder: string;
|
||||
notes: Record<string, string | number>;
|
||||
phases: ConversionPhase[];
|
||||
}
|
||||
|
||||
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
|
||||
|
||||
function shortLink(link: string): string {
|
||||
const raw = String(link || "").trim();
|
||||
return raw.length > 90 ? `${raw.slice(0, 90)}…` : raw;
|
||||
}
|
||||
|
||||
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void {
|
||||
const trace = conversionContext.getStore();
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
trace.phases.push({ ...phase, atMs: Date.now() - trace.startedAt });
|
||||
}
|
||||
|
||||
export function traceConversionNote(key: string, value: string | number): void {
|
||||
const trace = conversionContext.getStore();
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
trace.notes[key] = value;
|
||||
}
|
||||
|
||||
export function hasActiveConversionTrace(): boolean {
|
||||
return conversionContext.getStore() !== undefined;
|
||||
}
|
||||
|
||||
export function formatConversionBlock(
|
||||
trace: ConversionTrace,
|
||||
outcome: string,
|
||||
detail: string,
|
||||
totalMs: number
|
||||
): string {
|
||||
const noteParts = Object.entries(trace.notes)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}`
|
||||
+ ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`
|
||||
+ ` | link=${shortLink(trace.link)}`;
|
||||
const lines = trace.phases.map((p) => {
|
||||
const parts: string[] = [];
|
||||
if (p.provider) parts.push(`provider=${p.provider}`);
|
||||
if (p.account) parts.push(`account=${p.account}`);
|
||||
if (p.tokenState) parts.push(`token=${p.tokenState}`);
|
||||
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
|
||||
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
|
||||
if (p.outcome) parts.push(`outcome=${p.outcome}`);
|
||||
if (p.detail) parts.push(`detail=${String(p.detail).replace(/\r?\n/g, "\\n")}`);
|
||||
return ` +${p.atMs}ms ${p.phase}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
|
||||
});
|
||||
return [header, ...lines].join("\n");
|
||||
}
|
||||
|
||||
const CONVERSION_LOG_MAX_FILE_BYTES = Number(process.env.RD_CONVERSION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
||||
const CONVERSION_LOG_RETENTION_DAYS = Number(process.env.RD_CONVERSION_LOG_RETENTION_DAYS || 14);
|
||||
|
||||
let conversionLogPath: string | null = null;
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < CONVERSION_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - CONVERSION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initConversionLog(baseDir: string): void {
|
||||
conversionLogPath = path.join(baseDir, "conversion.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(conversionLogPath), { recursive: true });
|
||||
cleanupOldBackup(conversionLogPath);
|
||||
if (!fs.existsSync(conversionLogPath)) {
|
||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(conversionLogPath);
|
||||
if (!fs.existsSync(conversionLogPath)) {
|
||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(conversionLogPath, `=== Conversion Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
conversionLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConversionLogPath(): string | null {
|
||||
if (!conversionLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(conversionLogPath) ? conversionLogPath : null;
|
||||
}
|
||||
|
||||
export function shutdownConversionLog(): void {
|
||||
if (!conversionLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(conversionLogPath, `=== Conversion Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
conversionLogPath = null;
|
||||
}
|
||||
|
||||
function writeConversionBlock(block: string): void {
|
||||
if (!conversionLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(conversionLogPath);
|
||||
if (!fs.existsSync(conversionLogPath)) {
|
||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(conversionLogPath, `${block}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWithConversionTrace<T>(
|
||||
meta: { itemId: string; itemName: string; link: string; providerOrder: string },
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: Date.now(),
|
||||
itemId: meta.itemId,
|
||||
itemName: meta.itemName,
|
||||
link: meta.link,
|
||||
providerOrder: meta.providerOrder,
|
||||
notes: {},
|
||||
phases: []
|
||||
};
|
||||
let outcome = "OK";
|
||||
let detail = "";
|
||||
try {
|
||||
const result = await conversionContext.run(trace, fn);
|
||||
return result;
|
||||
} catch (error) {
|
||||
outcome = "FAIL";
|
||||
detail = String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160);
|
||||
throw error;
|
||||
} finally {
|
||||
const totalMs = Date.now() - trace.startedAt;
|
||||
writeConversionBlock(formatConversionBlock(trace, outcome, detail, totalMs));
|
||||
}
|
||||
}
|
||||
+4156
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,435 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { getSessionLogPath } from "./session-log";
|
||||
import { createStoragePaths, loadSettings } from "./storage";
|
||||
import type {
|
||||
DebugSetupCheckResult,
|
||||
SupportBundleEstimate,
|
||||
SupportDirectorySizeInfo,
|
||||
SupportDiskSpaceInfo,
|
||||
SupportFileSizeInfo,
|
||||
SupportTraceConfig
|
||||
} from "../shared/types";
|
||||
|
||||
const DEFAULT_PORT = 9868;
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
||||
const LOW_FREE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_BYTES || 20 * 1024 * 1024 * 1024);
|
||||
const LOW_FREE_PERCENT_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT || 5);
|
||||
const LOW_FREE_PERCENT_BYTES_GUARD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT_BYTES_GUARD || 50 * 1024 * 1024 * 1024);
|
||||
const LARGE_LOG_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_LOG_BYTES || 250 * 1024 * 1024);
|
||||
const LARGE_BUNDLE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_BUNDLE_BYTES || 150 * 1024 * 1024);
|
||||
const BUNDLE_OVERVIEW_SLACK_BYTES = 256 * 1024;
|
||||
|
||||
function formatByteCount(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||
return "0 B";
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function readToken(baseDir: string): string {
|
||||
try {
|
||||
return fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function readPort(baseDir: string): number {
|
||||
try {
|
||||
const raw = Number(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim());
|
||||
if (Number.isFinite(raw) && raw >= 1024 && raw <= 65535) {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return DEFAULT_PORT;
|
||||
}
|
||||
|
||||
function readHost(baseDir: string): string {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim();
|
||||
if (!raw) {
|
||||
return DEFAULT_HOST;
|
||||
}
|
||||
if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
if (/^[a-z0-9.-]+$/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return DEFAULT_HOST;
|
||||
}
|
||||
|
||||
function readTraceConfig(baseDir: string): SupportTraceConfig {
|
||||
const fallback: SupportTraceConfig = {
|
||||
enabled: false,
|
||||
includeMainLog: true,
|
||||
includeAudit: true,
|
||||
logDebugRequests: true,
|
||||
autoDisableAt: null,
|
||||
updatedAt: new Date(0).toISOString()
|
||||
};
|
||||
try {
|
||||
const filePath = path.join(baseDir, "trace_config.json");
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<SupportTraceConfig>;
|
||||
return {
|
||||
enabled: Boolean(parsed.enabled),
|
||||
includeMainLog: parsed.includeMainLog === undefined ? true : Boolean(parsed.includeMainLog),
|
||||
includeAudit: parsed.includeAudit === undefined ? true : Boolean(parsed.includeAudit),
|
||||
logDebugRequests: parsed.logDebugRequests === undefined ? true : Boolean(parsed.logDebugRequests),
|
||||
autoDisableAt: typeof parsed.autoDisableAt === "string" && parsed.autoDisableAt.trim() ? parsed.autoDisableAt : null,
|
||||
updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt : fallback.updatedAt
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function getFileSizeInfo(filePath: string | null): SupportFileSizeInfo {
|
||||
if (!filePath) {
|
||||
return { path: null, exists: false, bytes: 0 };
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
bytes: stat.size
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
path: filePath,
|
||||
exists: false,
|
||||
bytes: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): SupportDirectorySizeInfo {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return {
|
||||
path: dirPath,
|
||||
exists: false,
|
||||
fileCount: 0,
|
||||
bytes: 0
|
||||
};
|
||||
}
|
||||
|
||||
let bytes = 0;
|
||||
let fileCount = 0;
|
||||
const queue = [dirPath];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.pop();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
queue.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (skipPath && path.resolve(fullPath) === path.resolve(skipPath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
bytes += fs.statSync(fullPath).size;
|
||||
fileCount += 1;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: dirPath,
|
||||
exists: true,
|
||||
fileCount,
|
||||
bytes
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExistingPath(targetPath: string): string {
|
||||
let current = path.resolve(targetPath);
|
||||
while (!fs.existsSync(current)) {
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function getWindowsDiskSpaceInfo(existingPath: string): SupportDiskSpaceInfo | null {
|
||||
if (process.platform !== "win32") {
|
||||
return null;
|
||||
}
|
||||
const root = path.parse(existingPath).root.replace(/[\\/]+$/g, "");
|
||||
const driveName = root.replace(":", "");
|
||||
if (!/^[A-Za-z]$/.test(driveName)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = execFileSync(
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
`$drive = Get-PSDrive -Name '${driveName}'; if ($drive) { [pscustomobject]@{ FreeSpace = [int64]$drive.Free; Size = [int64]($drive.Used + $drive.Free) } | ConvertTo-Json -Compress }`
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 3000
|
||||
}
|
||||
).trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as { FreeSpace?: number | string; Size?: number | string };
|
||||
const totalBytes = Number(parsed.Size);
|
||||
const freeBytes = Number(parsed.FreeSpace);
|
||||
const freePercent = Number.isFinite(totalBytes) && totalBytes > 0
|
||||
? Math.round((freeBytes / totalBytes) * 1000) / 10
|
||||
: null;
|
||||
return {
|
||||
path: existingPath,
|
||||
totalBytes: Number.isFinite(totalBytes) ? totalBytes : null,
|
||||
freeBytes: Number.isFinite(freeBytes) ? freeBytes : null,
|
||||
freePercent
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getDiskSpaceInfo(targetPath: string): SupportDiskSpaceInfo {
|
||||
const existingPath = resolveExistingPath(targetPath);
|
||||
try {
|
||||
const stat = fs.statfsSync(existingPath);
|
||||
const totalBytes = Number(stat.blocks) * Number(stat.bsize);
|
||||
const freeBytes = Number(stat.bavail) * Number(stat.bsize);
|
||||
const freePercent = totalBytes > 0
|
||||
? Math.round((freeBytes / totalBytes) * 1000) / 10
|
||||
: null;
|
||||
return {
|
||||
path: existingPath,
|
||||
totalBytes,
|
||||
freeBytes,
|
||||
freePercent
|
||||
};
|
||||
} catch {
|
||||
const windowsFallback = getWindowsDiskSpaceInfo(existingPath);
|
||||
if (windowsFallback) {
|
||||
return windowsFallback;
|
||||
}
|
||||
return {
|
||||
path: existingPath,
|
||||
totalBytes: null,
|
||||
freeBytes: null,
|
||||
freePercent: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getSupportBundleEstimate(
|
||||
baseDir: string,
|
||||
logSummary: DebugSetupCheckResult["logSummary"]
|
||||
): SupportBundleEstimate {
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const staticFiles = [
|
||||
path.join(baseDir, SUPPORT_MANIFEST_FILE),
|
||||
path.join(baseDir, "debug_host.txt"),
|
||||
path.join(baseDir, "debug_port.txt"),
|
||||
storagePaths.configFile,
|
||||
storagePaths.sessionFile,
|
||||
storagePaths.historyFile,
|
||||
path.join(baseDir, "trace_config.json")
|
||||
].map((filePath) => getFileSizeInfo(filePath));
|
||||
|
||||
const staticBytes = staticFiles.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
const duplicatedLiveLogBytes = logSummary.session.bytes + logSummary.packageLogs.bytes + logSummary.itemLogs.bytes;
|
||||
const estimatedEntries = 10
|
||||
+ staticFiles.filter((entry) => entry.exists).length
|
||||
+ Number(logSummary.main.exists)
|
||||
+ Number(logSummary.mainBackup.exists)
|
||||
+ Number(logSummary.audit.exists)
|
||||
+ Number(logSummary.auditBackup.exists)
|
||||
+ Number(logSummary.rename.exists)
|
||||
+ Number(logSummary.renameBackup.exists)
|
||||
+ Number(logSummary.session.exists)
|
||||
+ Number(logSummary.trace.exists)
|
||||
+ Number(logSummary.traceBackup.exists)
|
||||
+ logSummary.sessionLogs.fileCount
|
||||
+ logSummary.packageLogs.fileCount
|
||||
+ logSummary.itemLogs.fileCount
|
||||
+ logSummary.packageLogs.fileCount
|
||||
+ logSummary.itemLogs.fileCount;
|
||||
|
||||
return {
|
||||
estimatedBytes: staticBytes + logSummary.totalBytes + duplicatedLiveLogBytes + BUNDLE_OVERVIEW_SLACK_BYTES,
|
||||
estimatedEntries,
|
||||
duplicatedLiveLogBytes,
|
||||
note: "Schätzwert vor ZIP-Komprimierung; aktueller Session-Log sowie Live-Paket-/Item-Logs werden im Bundle zusätzlich gespiegelt."
|
||||
};
|
||||
}
|
||||
|
||||
export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
|
||||
const host = readHost(baseDir);
|
||||
const port = readPort(baseDir);
|
||||
const token = readToken(baseDir);
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const settings = loadSettings(storagePaths);
|
||||
const tokenPath = path.join(baseDir, "debug_token.txt");
|
||||
const supportManifestPath = path.join(baseDir, SUPPORT_MANIFEST_FILE);
|
||||
const traceConfigPath = path.join(baseDir, "trace_config.json");
|
||||
const traceLogPath = path.join(baseDir, "trace.log");
|
||||
const traceConfig = readTraceConfig(baseDir);
|
||||
const sessionLogPath = getSessionLogPath();
|
||||
const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host);
|
||||
const warnings: string[] = [];
|
||||
const notes: string[] = [];
|
||||
|
||||
const logSummary: DebugSetupCheckResult["logSummary"] = {
|
||||
main: getFileSizeInfo(path.join(baseDir, "rd_downloader.log")),
|
||||
mainBackup: getFileSizeInfo(path.join(baseDir, "rd_downloader.log.old")),
|
||||
audit: getFileSizeInfo(path.join(baseDir, "audit.log")),
|
||||
auditBackup: getFileSizeInfo(path.join(baseDir, "audit.log.old")),
|
||||
rename: getFileSizeInfo(path.join(baseDir, "rename.log")),
|
||||
renameBackup: getFileSizeInfo(path.join(baseDir, "rename.log.old")),
|
||||
session: getFileSizeInfo(sessionLogPath),
|
||||
trace: getFileSizeInfo(traceLogPath),
|
||||
traceBackup: getFileSizeInfo(path.join(baseDir, "trace.log.old")),
|
||||
sessionLogs: getDirectorySizeInfo(path.join(baseDir, "session-logs"), sessionLogPath),
|
||||
packageLogs: getDirectorySizeInfo(path.join(baseDir, "package-logs")),
|
||||
itemLogs: getDirectorySizeInfo(path.join(baseDir, "item-logs")),
|
||||
totalBytes: 0
|
||||
};
|
||||
logSummary.totalBytes = [
|
||||
logSummary.main.bytes,
|
||||
logSummary.mainBackup.bytes,
|
||||
logSummary.audit.bytes,
|
||||
logSummary.auditBackup.bytes,
|
||||
logSummary.rename.bytes,
|
||||
logSummary.renameBackup.bytes,
|
||||
logSummary.session.bytes,
|
||||
logSummary.trace.bytes,
|
||||
logSummary.traceBackup.bytes,
|
||||
logSummary.sessionLogs.bytes,
|
||||
logSummary.packageLogs.bytes,
|
||||
logSummary.itemLogs.bytes
|
||||
].reduce((sum, value) => sum + value, 0);
|
||||
|
||||
const diskSpace: DebugSetupCheckResult["diskSpace"] = {
|
||||
runtime: getDiskSpaceInfo(baseDir),
|
||||
output: getDiskSpaceInfo(settings.outputDir),
|
||||
extract: getDiskSpaceInfo(settings.extractDir)
|
||||
};
|
||||
const supportBundle = getSupportBundleEstimate(baseDir, logSummary);
|
||||
|
||||
if (!token) {
|
||||
warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht.");
|
||||
}
|
||||
if (localOnly) {
|
||||
warnings.push("Der Debug-Server ist aktuell nur lokal erreichbar. Für Remote-Support debug_host.txt auf 0.0.0.0 setzen.");
|
||||
} else {
|
||||
notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein.");
|
||||
}
|
||||
if (!fs.existsSync(supportManifestPath)) {
|
||||
warnings.push("debug_support_manifest.json fehlt. App einmal neu starten, damit das Support-Manifest neu geschrieben wird.");
|
||||
}
|
||||
if (!fs.existsSync(traceConfigPath)) {
|
||||
warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert.");
|
||||
}
|
||||
if (traceConfig.enabled && !traceConfig.autoDisableAt) {
|
||||
warnings.push("Support-Trace ist aktiv ohne automatische Abschaltzeit. Einmal neu aktivieren, damit die 2-Stunden-Begrenzung gesetzt wird.");
|
||||
}
|
||||
if (traceConfig.enabled && traceConfig.autoDisableAt) {
|
||||
notes.push(`Support-Trace aktiv bis ${traceConfig.autoDisableAt}.`);
|
||||
}
|
||||
|
||||
for (const entry of [
|
||||
{ label: "Runtime", info: diskSpace.runtime },
|
||||
{ label: "Download-Ziel", info: diskSpace.output },
|
||||
{ label: "Entpack-Ziel", info: diskSpace.extract }
|
||||
]) {
|
||||
if (entry.info.freeBytes === null || entry.info.totalBytes === null) {
|
||||
warnings.push(`${entry.label}: Freier Speicherplatz konnte nicht gelesen werden (${entry.info.path}).`);
|
||||
continue;
|
||||
}
|
||||
const lowByAbsolute = entry.info.freeBytes < LOW_FREE_BYTES_THRESHOLD;
|
||||
const lowByPercent = entry.info.freePercent !== null
|
||||
&& entry.info.freePercent < LOW_FREE_PERCENT_THRESHOLD
|
||||
&& entry.info.freeBytes < LOW_FREE_PERCENT_BYTES_GUARD;
|
||||
if (lowByAbsolute || lowByPercent) {
|
||||
warnings.push(`${entry.label}: wenig freier Speicherplatz (${formatByteCount(entry.info.freeBytes)} frei auf ${entry.info.path}).`);
|
||||
}
|
||||
}
|
||||
|
||||
if (logSummary.totalBytes >= LARGE_LOG_BYTES_THRESHOLD) {
|
||||
warnings.push(`Support-Logs sind bereits recht groß (${formatByteCount(logSummary.totalBytes)}). Rotation greift, aber ein Bundle wird entsprechend umfangreicher.`);
|
||||
} else {
|
||||
notes.push(`Aktuelle Support-Logmenge: ${formatByteCount(logSummary.totalBytes)}.`);
|
||||
}
|
||||
|
||||
if (supportBundle.estimatedBytes >= LARGE_BUNDLE_BYTES_THRESHOLD) {
|
||||
warnings.push(`Support-Bundle wird voraussichtlich groß (${formatByteCount(supportBundle.estimatedBytes)} vor ZIP-Komprimierung).`);
|
||||
} else {
|
||||
notes.push(`Support-Bundle-Schätzung: etwa ${formatByteCount(supportBundle.estimatedBytes)}.`);
|
||||
}
|
||||
|
||||
notes.push("Die App kann Netzwerk-Firewalls oder Provider-Sicherheitsgruppen nicht direkt prüfen.");
|
||||
|
||||
return {
|
||||
status: warnings.length > 0 ? "warn" : "ok",
|
||||
enabled: Boolean(token),
|
||||
runtimeBaseDir: baseDir,
|
||||
host,
|
||||
port,
|
||||
localOnly,
|
||||
tokenConfigured: Boolean(token),
|
||||
tokenPath,
|
||||
supportManifestPath,
|
||||
supportManifestPresent: fs.existsSync(supportManifestPath),
|
||||
traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null,
|
||||
traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null,
|
||||
traceEnabled: traceConfig.enabled,
|
||||
traceAutoDisableAt: traceConfig.autoDisableAt,
|
||||
diskSpace,
|
||||
logSummary,
|
||||
supportBundle,
|
||||
warnings,
|
||||
notes,
|
||||
localUrls: {
|
||||
health: `http://127.0.0.1:${port}/health?token=${token || "<TOKEN>"}`,
|
||||
meta: `http://127.0.0.1:${port}/meta?token=${token || "<TOKEN>"}`,
|
||||
diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
||||
},
|
||||
remoteUrlTemplates: {
|
||||
health: `http://<SERVER_IP_OR_DNS>:${port}/health?token=${token || "<TOKEN>"}`,
|
||||
meta: `http://<SERVER_IP_OR_DNS>:${port}/meta?token=${token || "<TOKEN>"}`,
|
||||
diagnostics: `http://<SERVER_IP_OR_DNS>:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
|
||||
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const FOLDER_NAME = "Downloader-Log";
|
||||
|
||||
let logDir: string | null = null;
|
||||
let logFilePath: string | null = null;
|
||||
let sessionHeader = "";
|
||||
|
||||
function fileTimestamp(date: Date = new Date()): string {
|
||||
const pad = (value: number): string => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
|
||||
+ `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function ensureWritable(): boolean {
|
||||
if (!logDir || !logFilePath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
if (!fs.existsSync(logFilePath)) {
|
||||
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function initDesktopRenameLog(desktopDir: string | null | undefined): void {
|
||||
try {
|
||||
const base = String(desktopDir || "").trim();
|
||||
if (!base) {
|
||||
logDir = null;
|
||||
logFilePath = null;
|
||||
return;
|
||||
}
|
||||
logDir = path.join(base, FOLDER_NAME);
|
||||
logFilePath = path.join(logDir, `rename-session_${fileTimestamp()}.txt`);
|
||||
sessionHeader = `=== Rename-Session gestartet: ${logTimestamp()} ===\n`
|
||||
+ "Diese Datei protokolliert JEDEN Umbenenn-/Verschiebevorgang dieser Programm-Sitzung\n"
|
||||
+ "und verifiziert nach jedem Vorgang, ob die Datei wirklich unter dem Zielnamen auf der\n"
|
||||
+ "Platte liegt (und die Quelle verschwunden ist). [INFO]=ok, [ERROR]=Verifikation gescheitert.\n\n";
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
||||
} catch {
|
||||
logDir = null;
|
||||
logFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void {
|
||||
if (!ensureWritable() || !logFilePath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getDesktopRenameLogPath(): string | null {
|
||||
if (!logFilePath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return fs.existsSync(logFilePath) ? logFilePath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function shutdownDesktopRenameLog(): void {
|
||||
if (ensureWritable() && logFilePath) {
|
||||
try {
|
||||
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
logDir = null;
|
||||
logFilePath = null;
|
||||
}
|
||||
|
||||
export interface RenameVerification {
|
||||
ok: boolean;
|
||||
level: "INFO" | "WARN" | "ERROR";
|
||||
targetExists: boolean;
|
||||
onDiskName: string | null;
|
||||
nameMatches: boolean;
|
||||
sourceGone: boolean;
|
||||
targetSize: number | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function toLongPath(filePath: string): string {
|
||||
const absolute = path.resolve(String(filePath || ""));
|
||||
if (process.platform !== "win32") {
|
||||
return absolute;
|
||||
}
|
||||
if (!absolute || absolute.startsWith("\\\\?\\")) {
|
||||
return absolute;
|
||||
}
|
||||
if (absolute.length < 248) {
|
||||
return absolute;
|
||||
}
|
||||
if (absolute.startsWith("\\\\")) {
|
||||
return `\\\\?\\UNC\\${absolute.slice(2)}`;
|
||||
}
|
||||
return `\\\\?\\${absolute}`;
|
||||
}
|
||||
|
||||
function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
|
||||
if (entries === null) {
|
||||
return null;
|
||||
}
|
||||
const requestedLower = requested.toLowerCase();
|
||||
return entries.find((entry) => entry === requested)
|
||||
|| entries.find((entry) => entry.toLowerCase() === requestedLower)
|
||||
|| requested;
|
||||
}
|
||||
|
||||
function buildVerification(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
facts: { targetExists: boolean; targetSize: number | null; dirEntries: string[] | null; sourceExists: boolean }
|
||||
): RenameVerification {
|
||||
const requested = path.basename(targetPath);
|
||||
const dirReadFailed = facts.targetExists && facts.dirEntries === null;
|
||||
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
|
||||
|
||||
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
|
||||
const sourceGone = samePath ? true : !facts.sourceExists;
|
||||
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
|
||||
|
||||
const problems: string[] = [];
|
||||
let level: "INFO" | "WARN" | "ERROR" = "INFO";
|
||||
if (!facts.targetExists) {
|
||||
problems.push("Zieldatei nach Rename NICHT gefunden");
|
||||
level = "ERROR";
|
||||
} else if (!dirReadFailed && !nameMatches) {
|
||||
problems.push(`On-Disk-Name weicht ab (ist "${onDiskName}", erwartet "${requested}")`);
|
||||
level = "ERROR";
|
||||
}
|
||||
if (!samePath && facts.targetExists && !sourceGone) {
|
||||
problems.push("Quelldatei existiert noch (moeglicher halb-fertiger Verschiebevorgang)");
|
||||
level = "ERROR";
|
||||
}
|
||||
if (level === "INFO" && dirReadFailed) {
|
||||
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
|
||||
level = "WARN";
|
||||
}
|
||||
|
||||
return {
|
||||
ok: level === "INFO",
|
||||
level,
|
||||
targetExists: facts.targetExists,
|
||||
onDiskName,
|
||||
nameMatches,
|
||||
sourceGone,
|
||||
targetSize: facts.targetSize,
|
||||
reason: problems.join("; ")
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyRename(sourcePath: string, targetPath: string): RenameVerification {
|
||||
const longTarget = toLongPath(targetPath);
|
||||
let targetExists = false;
|
||||
let targetSize: number | null = null;
|
||||
try {
|
||||
const stat = fs.statSync(longTarget);
|
||||
targetExists = true;
|
||||
targetSize = stat.size;
|
||||
} catch {
|
||||
targetExists = false;
|
||||
}
|
||||
let dirEntries: string[] | null = null;
|
||||
if (targetExists) {
|
||||
try {
|
||||
dirEntries = fs.readdirSync(path.dirname(longTarget));
|
||||
} catch {
|
||||
dirEntries = null;
|
||||
}
|
||||
}
|
||||
let sourceExists = false;
|
||||
try {
|
||||
fs.statSync(toLongPath(sourcePath));
|
||||
sourceExists = true;
|
||||
} catch {
|
||||
sourceExists = false;
|
||||
}
|
||||
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
||||
}
|
||||
|
||||
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
|
||||
const longTarget = toLongPath(targetPath);
|
||||
let targetExists = false;
|
||||
let targetSize: number | null = null;
|
||||
try {
|
||||
const stat = await fs.promises.stat(longTarget);
|
||||
targetExists = true;
|
||||
targetSize = stat.size;
|
||||
} catch {
|
||||
targetExists = false;
|
||||
}
|
||||
let dirEntries: string[] | null = null;
|
||||
if (targetExists) {
|
||||
try {
|
||||
dirEntries = await fs.promises.readdir(path.dirname(longTarget));
|
||||
} catch {
|
||||
dirEntries = null;
|
||||
}
|
||||
}
|
||||
let sourceExists = false;
|
||||
try {
|
||||
await fs.promises.stat(toLongPath(sourcePath));
|
||||
sourceExists = true;
|
||||
} catch {
|
||||
sourceExists = false;
|
||||
}
|
||||
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { ALLOCATION_UNIT_SIZE } from "./constants";
|
||||
|
||||
export type DownloadCompletionSource =
|
||||
| "content-range"
|
||||
| "content-length"
|
||||
| "provider-metadata"
|
||||
| "stream-end";
|
||||
|
||||
export type DownloadCompletionPlan = {
|
||||
expectedTotal: number | null;
|
||||
source: DownloadCompletionSource;
|
||||
canFinishEarly: boolean;
|
||||
};
|
||||
|
||||
export function planDownloadCompletion(args: {
|
||||
existingBytes: number;
|
||||
responseStatus: number;
|
||||
contentLength: number;
|
||||
totalFromRange: number | null;
|
||||
knownTotal: number | null;
|
||||
correctedTotal: number | null;
|
||||
}): DownloadCompletionPlan {
|
||||
const existingBytes = Math.max(0, Math.floor(Number(args.existingBytes) || 0));
|
||||
const responseStatus = Math.floor(Number(args.responseStatus) || 0);
|
||||
const contentLength = Math.max(0, Math.floor(Number(args.contentLength) || 0));
|
||||
const totalFromRange = Number.isFinite(args.totalFromRange || NaN)
|
||||
? Math.max(0, Math.floor(args.totalFromRange || 0))
|
||||
: 0;
|
||||
const correctedTotal = Number.isFinite(args.correctedTotal || NaN)
|
||||
? Math.max(0, Math.floor(args.correctedTotal || 0))
|
||||
: 0;
|
||||
const knownTotal = Number.isFinite(args.knownTotal || NaN)
|
||||
? Math.max(0, Math.floor(args.knownTotal || 0))
|
||||
: 0;
|
||||
|
||||
if (correctedTotal > 0) {
|
||||
return {
|
||||
expectedTotal: correctedTotal,
|
||||
source: totalFromRange > 0 ? "content-range" : "content-length",
|
||||
canFinishEarly: true
|
||||
};
|
||||
}
|
||||
|
||||
if (totalFromRange > 0) {
|
||||
return {
|
||||
expectedTotal: totalFromRange,
|
||||
source: "content-range",
|
||||
canFinishEarly: true
|
||||
};
|
||||
}
|
||||
|
||||
if (contentLength > 0) {
|
||||
return {
|
||||
expectedTotal: responseStatus === 206 ? existingBytes + contentLength : contentLength,
|
||||
source: "content-length",
|
||||
canFinishEarly: true
|
||||
};
|
||||
}
|
||||
|
||||
if (knownTotal > 0) {
|
||||
return {
|
||||
expectedTotal: knownTotal,
|
||||
source: "provider-metadata",
|
||||
canFinishEarly: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
expectedTotal: null,
|
||||
source: "stream-end",
|
||||
canFinishEarly: false
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileFinalizedSize(
|
||||
streamedBytes: number,
|
||||
statSize: number,
|
||||
preAllocated: boolean
|
||||
): number {
|
||||
const streamed = Math.max(0, Math.floor(Number(streamedBytes) || 0));
|
||||
if (!Number.isFinite(statSize) || statSize < 0) {
|
||||
return streamed;
|
||||
}
|
||||
const onDisk = Math.floor(statSize);
|
||||
if (preAllocated && onDisk > streamed) {
|
||||
return streamed;
|
||||
}
|
||||
return onDisk;
|
||||
}
|
||||
|
||||
export function validateDownloadedFileCompletion(args: {
|
||||
actualBytes: number;
|
||||
plan: DownloadCompletionPlan;
|
||||
toleranceBytes?: number;
|
||||
}): {
|
||||
ok: boolean;
|
||||
totalBytes: number;
|
||||
acceptedMetadataMismatch: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
const actualBytes = Math.max(0, Math.floor(Number(args.actualBytes) || 0));
|
||||
const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN)
|
||||
? Math.max(0, Math.floor(args.plan.expectedTotal || 0))
|
||||
: 0;
|
||||
const toleranceBytes = Math.max(0, Math.floor(Number(args.toleranceBytes ?? ALLOCATION_UNIT_SIZE) || 0));
|
||||
|
||||
if (
|
||||
expectedTotal > 0 &&
|
||||
(args.plan.source === "content-range" || args.plan.source === "content-length") &&
|
||||
actualBytes + toleranceBytes < expectedTotal
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
totalBytes: expectedTotal,
|
||||
acceptedMetadataMismatch: false,
|
||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||
};
|
||||
}
|
||||
|
||||
if (actualBytes <= 0 && expectedTotal > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
totalBytes: expectedTotal,
|
||||
acceptedMetadataMismatch: false,
|
||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||
};
|
||||
}
|
||||
|
||||
if (args.plan.source === "provider-metadata") {
|
||||
if (expectedTotal > 0 && actualBytes + toleranceBytes < expectedTotal) {
|
||||
return {
|
||||
ok: false,
|
||||
totalBytes: expectedTotal,
|
||||
acceptedMetadataMismatch: false,
|
||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
totalBytes: actualBytes,
|
||||
acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > toleranceBytes
|
||||
};
|
||||
}
|
||||
|
||||
if (args.plan.source === "stream-end") {
|
||||
if (actualBytes <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
totalBytes: 0,
|
||||
acceptedMetadataMismatch: false,
|
||||
error: "download_underflow:0/0"
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
totalBytes: actualBytes,
|
||||
acceptedMetadataMismatch: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
totalBytes: Math.max(actualBytes, expectedTotal),
|
||||
acceptedMetadataMismatch: false
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
export interface ErrorRingEntry {
|
||||
ts: string;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ErrorRing {
|
||||
push: (entry: ErrorRingEntry) => void;
|
||||
snapshot: () => ErrorRingEntry[];
|
||||
clear: () => void;
|
||||
size: () => number;
|
||||
}
|
||||
|
||||
export function createErrorRing(capacity: number): ErrorRing {
|
||||
const limit = Math.max(1, Math.floor(capacity));
|
||||
const buffer: ErrorRingEntry[] = [];
|
||||
return {
|
||||
push(entry: ErrorRingEntry): void {
|
||||
buffer.push(entry);
|
||||
while (buffer.length > limit) {
|
||||
buffer.shift();
|
||||
}
|
||||
},
|
||||
snapshot(): ErrorRingEntry[] {
|
||||
return buffer.slice();
|
||||
},
|
||||
clear(): void {
|
||||
buffer.length = 0;
|
||||
},
|
||||
size(): number {
|
||||
return buffer.length;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const RECENT_ERROR_CAPACITY = 200;
|
||||
const recentErrors = createErrorRing(RECENT_ERROR_CAPACITY);
|
||||
|
||||
export function recordRecentError(level: string, message: string, ts: string): void {
|
||||
recentErrors.push({ level, message, ts });
|
||||
}
|
||||
|
||||
export function getRecentErrors(): ErrorRingEntry[] {
|
||||
return recentErrors.snapshot();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
// Maps low-level filesystem/OS error codes to a human-readable cause so that a
|
||||
// generic "write failed" or "timeout" can be reported as the specific root cause
|
||||
// (disk full, permission denied, ...). Pure + side-effect-free for testing.
|
||||
|
||||
const DISK_ERROR_REASONS: Record<string, string> = {
|
||||
ENOSPC: "Festplatte voll (ENOSPC)",
|
||||
EDQUOT: "Speicher-Kontingent erschöpft (EDQUOT)",
|
||||
EROFS: "Laufwerk schreibgeschützt (EROFS)",
|
||||
EACCES: "Zugriff verweigert (EACCES)",
|
||||
EPERM: "Operation nicht erlaubt (EPERM)",
|
||||
EMFILE: "Zu viele offene Dateien (EMFILE)",
|
||||
ENFILE: "System-Limit offener Dateien erreicht (ENFILE)",
|
||||
EBUSY: "Datei/Laufwerk belegt (EBUSY)",
|
||||
ENODEV: "Gerät nicht vorhanden (ENODEV)",
|
||||
ENXIO: "Gerät getrennt (ENXIO)",
|
||||
EIO: "Ein-/Ausgabefehler des Datenträgers (EIO)"
|
||||
};
|
||||
|
||||
export function classifyDiskError(err: unknown): string | null {
|
||||
const code = extractErrorCode(err);
|
||||
if (code && DISK_ERROR_REASONS[code]) {
|
||||
return DISK_ERROR_REASONS[code];
|
||||
}
|
||||
// Some errors arrive as plain strings/messages without a `.code`; fall back to
|
||||
// scanning the text for a known code token.
|
||||
const text = errorText(err);
|
||||
for (const knownCode of Object.keys(DISK_ERROR_REASONS)) {
|
||||
if (text.includes(knownCode)) {
|
||||
return DISK_ERROR_REASONS[knownCode];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractErrorCode(err: unknown): string {
|
||||
if (err && typeof err === "object") {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (typeof code === "string") {
|
||||
return code.toUpperCase();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function errorText(err: unknown): string {
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (err && typeof err === "object") {
|
||||
const message = (err as { message?: unknown }).message;
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(err ?? "");
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { ParsedHashEntry } from "../shared/types";
|
||||
import { MAX_MANIFEST_FILE_BYTES } from "./constants";
|
||||
|
||||
const manifestCache = new Map<string, { at: number; entries: Map<string, ParsedHashEntry> }>();
|
||||
const MANIFEST_CACHE_TTL_MS = 15000;
|
||||
|
||||
function normalizeManifestKey(value: string): string {
|
||||
return String(value || "")
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\.\//, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function parseHashLine(line: string): ParsedHashEntry | null {
|
||||
const text = String(line || "").trim();
|
||||
if (!text || text.startsWith(";")) {
|
||||
return null;
|
||||
}
|
||||
const md = text.match(/^([0-9a-fA-F]{32}|[0-9a-fA-F]{40})\s+\*?(.+)$/);
|
||||
if (md) {
|
||||
const digest = md[1].toLowerCase();
|
||||
return {
|
||||
fileName: md[2].trim(),
|
||||
algorithm: digest.length === 32 ? "md5" : "sha1",
|
||||
digest
|
||||
};
|
||||
}
|
||||
const sfv = text.match(/^(.+?)\s+([0-9A-Fa-f]{8})$/);
|
||||
if (sfv) {
|
||||
return {
|
||||
fileName: sfv[1].trim(),
|
||||
algorithm: "crc32",
|
||||
digest: sfv[2].toLowerCase()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readHashManifest(packageDir: string): Map<string, ParsedHashEntry> {
|
||||
const cacheKey = path.resolve(packageDir);
|
||||
const cached = manifestCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at <= MANIFEST_CACHE_TTL_MS) {
|
||||
return new Map(cached.entries);
|
||||
}
|
||||
|
||||
const map = new Map<string, ParsedHashEntry>();
|
||||
const patterns: Array<[string, "crc32" | "md5" | "sha1"]> = [
|
||||
[".sfv", "crc32"],
|
||||
[".md5", "md5"],
|
||||
[".sha1", "sha1"]
|
||||
];
|
||||
|
||||
if (!fs.existsSync(packageDir)) {
|
||||
return map;
|
||||
}
|
||||
|
||||
const manifestFiles = fs.readdirSync(packageDir, { withFileTypes: true })
|
||||
.filter((entry) => {
|
||||
if (!entry.isFile()) {
|
||||
return false;
|
||||
}
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
return patterns.some(([pattern]) => pattern === ext);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
||||
|
||||
for (const entry of manifestFiles) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const hit = patterns.find(([pattern]) => pattern === ext);
|
||||
if (!hit) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(packageDir, entry.name);
|
||||
let lines: string[];
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size > MAX_MANIFEST_FILE_BYTES) {
|
||||
continue;
|
||||
}
|
||||
lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const line of lines) {
|
||||
const parsed = parseHashLine(line);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const key = normalizeManifestKey(parsed.fileName);
|
||||
if (map.has(key)) {
|
||||
continue;
|
||||
}
|
||||
map.set(key, parsed);
|
||||
}
|
||||
}
|
||||
manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(map) });
|
||||
return map;
|
||||
}
|
||||
|
||||
const crcTable = new Int32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) c = c & 1 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
crcTable[i] = c;
|
||||
}
|
||||
|
||||
function crc32Buffer(data: Buffer, seed = 0): number {
|
||||
let crc = seed ^ -1;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff];
|
||||
}
|
||||
return crc ^ -1;
|
||||
}
|
||||
|
||||
async function hashFile(filePath: string, algorithm: "crc32" | "md5" | "sha1"): Promise<string> {
|
||||
if (algorithm === "crc32") {
|
||||
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||
let crc = 0;
|
||||
for await (const chunk of stream) {
|
||||
crc = crc32Buffer(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), crc);
|
||||
await new Promise(r => setImmediate(r));
|
||||
}
|
||||
return (crc >>> 0).toString(16).padStart(8, "0").toLowerCase();
|
||||
}
|
||||
|
||||
const hash = crypto.createHash(algorithm);
|
||||
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
stream.on("data", (chunk: string | Buffer) => hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(hash.digest("hex").toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateFileAgainstManifest(filePath: string, packageDir: string): Promise<{ ok: boolean; message: string }> {
|
||||
const manifest = readHashManifest(packageDir);
|
||||
if (manifest.size === 0) {
|
||||
return { ok: true, message: "Kein Hash verfügbar" };
|
||||
}
|
||||
const keyByBaseName = normalizeManifestKey(path.basename(filePath));
|
||||
const keyByRelativePath = normalizeManifestKey(path.relative(packageDir, filePath));
|
||||
const entry = manifest.get(keyByRelativePath) || manifest.get(keyByBaseName);
|
||||
if (!entry) {
|
||||
return { ok: true, message: "Kein Hash für Datei" };
|
||||
}
|
||||
|
||||
const actual = await hashFile(filePath, entry.algorithm);
|
||||
if (actual === entry.digest.toLowerCase()) {
|
||||
return { ok: true, message: `${entry.algorithm.toUpperCase()} ok` };
|
||||
}
|
||||
return { ok: false, message: `${entry.algorithm.toUpperCase()} mismatch` };
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const ITEM_LOG_RETENTION_DAYS = 30;
|
||||
|
||||
type ItemLogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface ItemLogMeta {
|
||||
itemId: string;
|
||||
packageId: string;
|
||||
packageName: string;
|
||||
fileName: string;
|
||||
targetPath: string;
|
||||
}
|
||||
|
||||
let itemLogsDir: string | null = null;
|
||||
const knownLogPaths = new Map<string, string>();
|
||||
const pendingLinesByItem = new Map<string, string[]>();
|
||||
const initializedThisProcess = new Set<string>();
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function normalizeItemId(itemId: string): string {
|
||||
const trimmed = String(itemId || "").trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const safePrefix = trimmed
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.slice(0, 64)
|
||||
.replace(/^_+|_+$/g, "");
|
||||
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
|
||||
return `${safePrefix || "item"}_${hash}`;
|
||||
}
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function getItemLogFilePathFromNormalized(normalized: string): string | null {
|
||||
if (!normalized || !itemLogsDir) {
|
||||
return null;
|
||||
}
|
||||
const existing = knownLogPaths.get(normalized);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const logPath = path.join(itemLogsDir, `item_${normalized}.txt`);
|
||||
knownLogPaths.set(normalized, logPath);
|
||||
return logPath;
|
||||
}
|
||||
|
||||
function getItemLogFilePath(itemId: string): string | null {
|
||||
return getItemLogFilePathFromNormalized(normalizeItemId(itemId));
|
||||
}
|
||||
|
||||
function flushPending(): void {
|
||||
for (const [itemId, lines] of pendingLinesByItem.entries()) {
|
||||
if (lines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
||||
if (!logPath) {
|
||||
continue;
|
||||
}
|
||||
const chunk = lines.join("");
|
||||
pendingLinesByItem.set(itemId, []);
|
||||
try {
|
||||
fs.appendFileSync(logPath, chunk, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushPending();
|
||||
}, ITEM_LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function cleanupOldItemLogs(dir: string): Promise<void> {
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const cutoff = Date.now() - ITEM_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("item_") || !file.endsWith(".txt")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function appendLine(itemId: string, line: string): void {
|
||||
const normalized = normalizeItemId(itemId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const lines = pendingLinesByItem.get(normalized) || [];
|
||||
lines.push(line);
|
||||
pendingLinesByItem.set(normalized, lines);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
export function initItemLogs(baseDir: string): void {
|
||||
itemLogsDir = path.join(baseDir, "item-logs");
|
||||
try {
|
||||
fs.mkdirSync(itemLogsDir, { recursive: true });
|
||||
} catch {
|
||||
itemLogsDir = null;
|
||||
return;
|
||||
}
|
||||
void cleanupOldItemLogs(itemLogsDir);
|
||||
}
|
||||
|
||||
export function ensureItemLog(meta: ItemLogMeta): string | null {
|
||||
const normalizedItemId = normalizeItemId(meta.itemId);
|
||||
const logPath = getItemLogFilePath(meta.itemId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
if (!fs.existsSync(logPath)) {
|
||||
fs.writeFileSync(logPath, "", "utf8");
|
||||
}
|
||||
if (!initializedThisProcess.has(normalizedItemId)) {
|
||||
initializedThisProcess.add(normalizedItemId);
|
||||
const startedAt = logTimestamp();
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
|
||||
packageId: meta.packageId,
|
||||
packageName: meta.packageName,
|
||||
fileName: meta.fileName,
|
||||
targetPath: meta.targetPath
|
||||
})}\n`,
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return logPath;
|
||||
}
|
||||
|
||||
export function logItemEvent(
|
||||
itemId: string,
|
||||
level: ItemLogLevel,
|
||||
message: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
const logPath = getItemLogFilePath(itemId);
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||
appendLine(itemId, line);
|
||||
}
|
||||
|
||||
export function getItemLogPath(itemId: string): string | null {
|
||||
const logPath = getItemLogFilePath(itemId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(logPath) ? logPath : null;
|
||||
}
|
||||
|
||||
export function shutdownItemLogs(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
for (const itemId of knownLogPaths.keys()) {
|
||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
||||
if (!logPath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
pendingLinesByItem.clear();
|
||||
knownLogPaths.clear();
|
||||
initializedThisProcess.clear();
|
||||
itemLogsDir = null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ParsedPackageInput, UiSnapshot } from "../shared/types";
|
||||
import { sanitizeFilename } from "./utils";
|
||||
|
||||
export type LinkExportSelection = {
|
||||
packages: ParsedPackageInput[];
|
||||
packageCount: number;
|
||||
linkCount: number;
|
||||
defaultFileName: string;
|
||||
};
|
||||
|
||||
function formatTimestampForFileName(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
const h = String(date.getHours()).padStart(2, "0");
|
||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
||||
const s = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
||||
}
|
||||
|
||||
function buildDefaultFileName(packages: ParsedPackageInput[]): string {
|
||||
if (packages.length === 1) {
|
||||
const only = packages[0];
|
||||
if (only.links.length === 1) {
|
||||
const itemName = sanitizeFilename(only.fileNames?.[0] || only.name || "link-export");
|
||||
return `${itemName}.txt`;
|
||||
}
|
||||
return `${sanitizeFilename(only.name || "paket-export")}.txt`;
|
||||
}
|
||||
return `rd-link-export-${formatTimestampForFileName(new Date())}.txt`;
|
||||
}
|
||||
|
||||
export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: string[], itemIds: string[]): LinkExportSelection {
|
||||
const selectedPackageIds = new Set(packageIds);
|
||||
const selectedItemIds = new Set(itemIds);
|
||||
const packages: ParsedPackageInput[] = [];
|
||||
|
||||
for (const packageId of snapshot.session.packageOrder) {
|
||||
const pkg = snapshot.session.packages[packageId];
|
||||
if (!pkg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const useWholePackage = selectedPackageIds.has(packageId);
|
||||
const relevantItemIds = useWholePackage
|
||||
? pkg.itemIds
|
||||
: pkg.itemIds.filter((itemId) => selectedItemIds.has(itemId));
|
||||
|
||||
if (relevantItemIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const links: string[] = [];
|
||||
const fileNames: string[] = [];
|
||||
for (const itemId of relevantItemIds) {
|
||||
const item = snapshot.session.items[itemId];
|
||||
if (!item || !String(item.url || "").trim()) {
|
||||
continue;
|
||||
}
|
||||
links.push(String(item.url).trim());
|
||||
const rawFileName = String(item.fileName || "").trim();
|
||||
fileNames.push(rawFileName ? sanitizeFilename(rawFileName) : "");
|
||||
}
|
||||
|
||||
if (links.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const exportEntry: ParsedPackageInput = {
|
||||
name: sanitizeFilename(pkg.name || "Paket"),
|
||||
links
|
||||
};
|
||||
if (fileNames.some((fileName) => fileName.length > 0)) {
|
||||
exportEntry.fileNames = fileNames;
|
||||
}
|
||||
packages.push(exportEntry);
|
||||
}
|
||||
|
||||
const linkCount = packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
|
||||
return {
|
||||
packages,
|
||||
packageCount: packages.length,
|
||||
linkCount,
|
||||
defaultFileName: buildDefaultFileName(packages)
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeLinkExportText(packages: ParsedPackageInput[]): string {
|
||||
const lines: string[] = [
|
||||
"# rd-link-export: 1",
|
||||
"# Re-import in Real-Debrid-Downloader keeps package names and optional file names.",
|
||||
""
|
||||
];
|
||||
|
||||
for (const pkg of packages) {
|
||||
if (!pkg || !pkg.name || !Array.isArray(pkg.links) || pkg.links.length === 0) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`# package: ${sanitizeFilename(pkg.name)}`);
|
||||
for (let index = 0; index < pkg.links.length; index += 1) {
|
||||
const link = String(pkg.links[index] || "").trim();
|
||||
if (!link) {
|
||||
continue;
|
||||
}
|
||||
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
||||
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
|
||||
if (fileName) {
|
||||
lines.push(`# file: ${fileName}`);
|
||||
}
|
||||
lines.push(link);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return `${lines.join("\n").trim()}\n`;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ParsedPackageInput } from "../shared/types";
|
||||
import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||
|
||||
export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] {
|
||||
const grouped = new Map<string, { links: string[]; fileNameByLink: Map<string, string> }>();
|
||||
for (const pkg of packages) {
|
||||
const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links));
|
||||
const current = grouped.get(name) ?? { links: [], fileNameByLink: new Map<string, string>() };
|
||||
for (let index = 0; index < pkg.links.length; index += 1) {
|
||||
const link = String(pkg.links[index] || "").trim();
|
||||
if (!link) {
|
||||
continue;
|
||||
}
|
||||
if (!current.links.includes(link)) {
|
||||
current.links.push(link);
|
||||
}
|
||||
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
||||
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
|
||||
if (fileName && !current.fileNameByLink.has(link)) {
|
||||
current.fileNameByLink.set(link, fileName);
|
||||
}
|
||||
}
|
||||
grouped.set(name, current);
|
||||
}
|
||||
return Array.from(grouped.entries()).map(([name, entry]) => {
|
||||
const links = uniquePreserveOrder(entry.links);
|
||||
const fileNames = links.map((link) => entry.fileNameByLink.get(link) || "");
|
||||
return {
|
||||
name,
|
||||
links,
|
||||
...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] {
|
||||
const parsed = parsePackagesFromLinksText(rawText, packageName);
|
||||
if (parsed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return mergePackageInputs(parsed);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function logTimestamp(date: Date = new Date()): string {
|
||||
const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
|
||||
const offsetMinutes = -date.getTimezoneOffset();
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const absOffset = Math.abs(offsetMinutes);
|
||||
const offset = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
|
||||
return (
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offset}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import { recordRecentError } from "./error-ring";
|
||||
import path from "node:path";
|
||||
|
||||
export function isDebugFlagEnabled(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return /^(1|true|yes|on)$/i.test(value.trim());
|
||||
}
|
||||
|
||||
// Read once at startup. Enabling verbose DEBUG logging on the (unattended) server
|
||||
// is a deliberate support action that requires a restart — the runtime-toggleable
|
||||
// channel is the trace log, not this.
|
||||
const DEBUG_ENABLED = isDebugFlagEnabled(process.env.RD_DEBUG);
|
||||
|
||||
export function isDebugLoggingEnabled(): boolean {
|
||||
return DEBUG_ENABLED;
|
||||
}
|
||||
|
||||
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
||||
let fallbackLogFilePath: string | null = null;
|
||||
const LOG_FLUSH_INTERVAL_MS = 120;
|
||||
const LOG_BUFFER_LIMIT_CHARS = 1_000_000;
|
||||
const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const rotateCheckAtByFile = new Map<string, number>();
|
||||
|
||||
type LogListener = (line: string) => void;
|
||||
const logListeners = new Set<LogListener>();
|
||||
let legacyLogListener: LogListener | null = null;
|
||||
|
||||
let pendingLines: string[] = [];
|
||||
let pendingChars = 0;
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let flushInFlight = false;
|
||||
let exitHookAttached = false;
|
||||
|
||||
export function setLogListener(listener: LogListener | null): void {
|
||||
if (legacyLogListener) {
|
||||
logListeners.delete(legacyLogListener);
|
||||
}
|
||||
legacyLogListener = listener;
|
||||
if (listener) {
|
||||
logListeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
export function addLogListener(listener: LogListener): void {
|
||||
logListeners.add(listener);
|
||||
}
|
||||
|
||||
export function removeLogListener(listener: LogListener): void {
|
||||
logListeners.delete(listener);
|
||||
if (legacyLogListener === listener) {
|
||||
legacyLogListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function configureLogger(baseDir: string): void {
|
||||
logFilePath = path.join(baseDir, "rd_downloader.log");
|
||||
const cwdLogPath = path.resolve(process.cwd(), "rd_downloader.log");
|
||||
fallbackLogFilePath = cwdLogPath === logFilePath ? null : cwdLogPath;
|
||||
}
|
||||
|
||||
function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.appendFileSync(filePath, line, "utf8");
|
||||
return { ok: true, errorText: "" };
|
||||
} catch (error) {
|
||||
return { ok: false, errorText: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function appendChunk(filePath: string, chunk: string): Promise<{ ok: boolean; errorText: string }> {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.promises.appendFile(filePath, chunk, "utf8");
|
||||
return { ok: true, errorText: "" };
|
||||
} catch (error) {
|
||||
return { ok: false, errorText: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function writeStderr(text: string): void {
|
||||
try {
|
||||
process.stderr.write(text);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function flushSyncPending(): void {
|
||||
if (pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = pendingLines.join("");
|
||||
pendingLines = [];
|
||||
pendingChars = 0;
|
||||
|
||||
rotateIfNeeded(logFilePath);
|
||||
const primary = appendLine(logFilePath, chunk);
|
||||
if (fallbackLogFilePath) {
|
||||
rotateIfNeeded(fallbackLogFilePath);
|
||||
const fallback = appendLine(fallbackLogFilePath, chunk);
|
||||
if (!primary.ok && !fallback.ok) {
|
||||
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!primary.ok) {
|
||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(immediate = false): void {
|
||||
if (flushInFlight) {
|
||||
return;
|
||||
}
|
||||
if (immediate) {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
void flushAsync();
|
||||
return;
|
||||
}
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
void flushAsync();
|
||||
}, LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0;
|
||||
if (now - lastRotateCheckAt < 60_000) {
|
||||
return;
|
||||
}
|
||||
rotateCheckAtByFile.set(filePath, now);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateIfNeededAsync(filePath: string): Promise<void> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0;
|
||||
if (now - lastRotateCheckAt < 60_000) {
|
||||
return;
|
||||
}
|
||||
rotateCheckAtByFile.set(filePath, now);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.size < LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
await fs.promises.rm(backup, { force: true }).catch(() => {});
|
||||
await fs.promises.rename(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
if (flushInFlight || pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
flushInFlight = true;
|
||||
// Move (not copy) the pending lines out and take ownership. A concurrent write()
|
||||
// during the await below pushes new lines AND can trim the 1MB cap from the FRONT
|
||||
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
|
||||
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
|
||||
// here means await-time writes queue independently and nothing desyncs.
|
||||
const linesSnapshot = pendingLines;
|
||||
pendingLines = [];
|
||||
pendingChars = 0;
|
||||
const chunk = linesSnapshot.join("");
|
||||
|
||||
try {
|
||||
await rotateIfNeededAsync(logFilePath);
|
||||
const primary = await appendChunk(logFilePath, chunk);
|
||||
let wroteAny = primary.ok;
|
||||
if (fallbackLogFilePath) {
|
||||
await rotateIfNeededAsync(fallbackLogFilePath);
|
||||
const fallback = await appendChunk(fallbackLogFilePath, chunk);
|
||||
wroteAny = wroteAny || fallback.ok;
|
||||
if (!primary.ok && !fallback.ok) {
|
||||
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
||||
}
|
||||
} else if (!primary.ok) {
|
||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||
}
|
||||
if (!wroteAny) {
|
||||
// Write failed: requeue the unwritten lines AHEAD of anything that arrived
|
||||
// during the await (preserve order), then re-apply the buffer cap so a
|
||||
// persistent write failure cannot grow the buffer without bound.
|
||||
pendingLines = linesSnapshot.concat(pendingLines);
|
||||
pendingChars += chunk.length;
|
||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||
const removed = pendingLines.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
pendingChars = Math.max(0, pendingChars - removed.length);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushInFlight = false;
|
||||
if (pendingLines.length > 0) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureExitHook(): void {
|
||||
if (exitHookAttached) {
|
||||
return;
|
||||
}
|
||||
exitHookAttached = true;
|
||||
process.once("beforeExit", flushSyncPending);
|
||||
process.once("exit", flushSyncPending);
|
||||
}
|
||||
|
||||
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
||||
ensureExitHook();
|
||||
const ts = logTimestamp();
|
||||
const line = `${ts} [${level}] ${message}\n`;
|
||||
pendingLines.push(line);
|
||||
pendingChars += line.length;
|
||||
|
||||
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
|
||||
// "what failed recently" is answerable even after the file rotates.
|
||||
if (level === "ERROR" || level === "WARN") {
|
||||
recordRecentError(level, message, ts);
|
||||
}
|
||||
|
||||
for (const listener of logListeners) {
|
||||
try { listener(line); } catch { }
|
||||
}
|
||||
|
||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||
const removed = pendingLines.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
pendingChars = Math.max(0, pendingChars - removed.length);
|
||||
}
|
||||
|
||||
if (level === "ERROR") {
|
||||
scheduleFlush(true);
|
||||
return;
|
||||
}
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
// Gated to a no-op when RD_DEBUG is unset so verbose call sites cost nothing
|
||||
// (no formatting, no allocation) in the normal/production path.
|
||||
debug: DEBUG_ENABLED ? (msg: string): void => write("DEBUG", msg) : (_msg: string): void => {},
|
||||
info: (msg: string): void => write("INFO", msg),
|
||||
warn: (msg: string): void => write("WARN", msg),
|
||||
error: (msg: string): void => write("ERROR", msg)
|
||||
};
|
||||
|
||||
export function getLogFilePath(): string {
|
||||
return logFilePath;
|
||||
}
|
||||
@@ -0,0 +1,881 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
|
||||
import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, UpdateInstallProgress } from "../shared/types";
|
||||
import { AppController } from "./app-controller";
|
||||
import { IPC_CHANNELS } from "../shared/ipc";
|
||||
import { getLogFilePath, logger } from "./logger";
|
||||
import { getRecentErrors } from "./error-ring";
|
||||
import { sendNotification } from "./notify";
|
||||
import { APP_NAME } from "./constants";
|
||||
import { extractHttpLinksFromText } from "./utils";
|
||||
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
|
||||
|
||||
function validateString(value: unknown, name: string): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${name} muss ein String sein`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validatePlainObject(value: unknown, name: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`${name} muss ein Objekt sein`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const RENAME_PACKAGE_MAX_CHARS = 240;
|
||||
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
|
||||
"realdebrid",
|
||||
"megadebrid-api",
|
||||
"megadebrid-web",
|
||||
"bestdebrid",
|
||||
"alldebrid",
|
||||
"ddownload",
|
||||
"onefichier",
|
||||
"debridlink",
|
||||
"linksnappy"
|
||||
]);
|
||||
function validateStringArray(value: unknown, name: string): string[] {
|
||||
if (!Array.isArray(value) || !value.every(v => typeof v === "string")) {
|
||||
throw new Error(`${name} muss ein String-Array sein`);
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.exit(0);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
|
||||
});
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason);
|
||||
logger.error(`Unhandled Rejection: ${detail}`);
|
||||
});
|
||||
// Node-Warnungen (z.B. MaxListenersExceeded, DeprecationWarning) sind ein
|
||||
// Frühindikator für Leaks/Fehlnutzung in einem langlaufenden Server-Prozess.
|
||||
process.on("warning", (warning) => {
|
||||
logger.warn(`Node-Warnung: ${warning.name}: ${warning.message}${warning.stack ? ` | ${warning.stack.replace(/\s*\n\s*/g, " ⏎ ")}` : ""}`);
|
||||
});
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastClipboardText = "";
|
||||
const controller = new AppController();
|
||||
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
|
||||
|
||||
function isDevMode(): boolean {
|
||||
return process.env.NODE_ENV === "development";
|
||||
}
|
||||
|
||||
// Single owner of the scheduled-start timer. startOnPast: a past time entered
|
||||
// interactively starts right away; at boot a stale past time is cleared instead
|
||||
// (an unattended auto-start at boot would race autoResumeOnStart's conflict gate).
|
||||
function armScheduledStart(schedMs: number, opts: { startOnPast: boolean }): void {
|
||||
if (scheduledStartTimer !== null) {
|
||||
clearTimeout(scheduledStartTimer);
|
||||
scheduledStartTimer = null;
|
||||
}
|
||||
if (!schedMs || schedMs <= 0) {
|
||||
return;
|
||||
}
|
||||
const delay = schedMs - Date.now();
|
||||
if (delay <= 0) {
|
||||
if (opts.startOnPast) {
|
||||
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
|
||||
} else {
|
||||
logger.warn(`Geplanter Start (${new Date(schedMs).toLocaleString()}) lag beim App-Start in der Vergangenheit — verworfen`);
|
||||
}
|
||||
controller.updateSettings({ scheduledStartEpochMs: 0 });
|
||||
return;
|
||||
}
|
||||
scheduledStartTimer = setTimeout(() => {
|
||||
scheduledStartTimer = null;
|
||||
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
|
||||
controller.updateSettings({ scheduledStartEpochMs: 0 });
|
||||
}, delay);
|
||||
logger.info(`Geplanter Start gearmt: ${new Date(schedMs).toLocaleString()}`);
|
||||
}
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
minWidth: 1120,
|
||||
minHeight: 760,
|
||||
backgroundColor: "#070b14",
|
||||
title: `${APP_NAME} - v${controller.getVersion()}`,
|
||||
icon: path.join(app.getAppPath(), "assets", "app_icon.ico"),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, "../preload/preload.js")
|
||||
}
|
||||
});
|
||||
|
||||
if (!isDevMode()) {
|
||||
window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu https://ddownload.com https://ddl.to https://debrid-link.com"
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.setMenuBarVisibility(false);
|
||||
window.setAutoHideMenuBar(true);
|
||||
|
||||
if (isDevMode()) {
|
||||
void window.loadURL("http://localhost:5173");
|
||||
} else {
|
||||
void window.loadFile(path.join(app.getAppPath(), "build", "renderer", "index.html"));
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
let rendererReloadTimes: number[] = [];
|
||||
const RENDERER_RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||
const RENDERER_RELOAD_MAX = 3;
|
||||
|
||||
// Circuit breaker: recover from a one-off renderer crash by reloading, but stop
|
||||
// after a few crashes in a short window so a reproducible crash can't spin into a
|
||||
// reload loop that pegs an unattended server.
|
||||
function allowRendererReload(): boolean {
|
||||
const now = Date.now();
|
||||
rendererReloadTimes = rendererReloadTimes.filter((t) => now - t < RENDERER_RELOAD_WINDOW_MS);
|
||||
if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
|
||||
return false;
|
||||
}
|
||||
rendererReloadTimes.push(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindMainWindowLifecycle(window: BrowserWindow): void {
|
||||
window.on("close", (event) => {
|
||||
const settings = controller.getSettings();
|
||||
if (settings.minimizeToTray && tray) {
|
||||
event.preventDefault();
|
||||
window.hide();
|
||||
}
|
||||
});
|
||||
|
||||
window.on("closed", () => {
|
||||
if (mainWindow === window) {
|
||||
mainWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
window.webContents.on("render-process-gone", (_event, details) => {
|
||||
logger.error(`Renderer-Prozess beendet: reason=${details.reason} exitCode=${details.exitCode ?? "?"}`);
|
||||
if (details.reason === "clean-exit" || window.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
if (allowRendererReload()) {
|
||||
logger.warn("Renderer wird automatisch neu geladen (Wiederherstellung nach Absturz)");
|
||||
try {
|
||||
window.webContents.reload();
|
||||
} catch (error) {
|
||||
logger.error(`Renderer-Reload fehlgeschlagen: ${String(error)}`);
|
||||
}
|
||||
} else {
|
||||
logger.error(`Renderer-Absturz: Auto-Reload gestoppt (mehr als ${RENDERER_RELOAD_MAX} Abstürze in ${RENDERER_RELOAD_WINDOW_MS / 60000} Min) - manueller Neustart nötig`);
|
||||
}
|
||||
});
|
||||
|
||||
// Nur protokollieren, niemals killen/neu laden: "unresponsive" feuert auch
|
||||
// während legitimer langer Sync-Arbeit (große JSON-Serialisierung) und erholt
|
||||
// sich meist von selbst. Eingreifen würde einen Schluckauf zum Ausfall machen.
|
||||
window.webContents.on("unresponsive", () => {
|
||||
logger.warn("Renderer reagiert nicht (unresponsive) - evtl. langer Sync-Task, warte auf Erholung");
|
||||
});
|
||||
window.webContents.on("responsive", () => {
|
||||
logger.info("Renderer wieder reaktionsfähig (responsive)");
|
||||
});
|
||||
}
|
||||
|
||||
function createTray(): void {
|
||||
if (tray) {
|
||||
return;
|
||||
}
|
||||
const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
|
||||
try {
|
||||
tray = new Tray(iconPath);
|
||||
} catch (error) {
|
||||
logger.warn(`Tray-Icon konnte nicht erstellt werden (Headless/RDP/Service?): ${String(error)} - Minimize-to-Tray steht nicht zur Verfuegung, Fenster bleibt sichtbar.`);
|
||||
return;
|
||||
}
|
||||
tray.setToolTip(APP_NAME);
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: "Anzeigen", click: () => { mainWindow?.show(); mainWindow?.focus(); } },
|
||||
{ type: "separator" },
|
||||
{ label: "Start", click: () => { void controller.start().catch((err) => logger.warn(`Tray Start Fehler: ${String(err)}`)); } },
|
||||
{ label: "Stop", click: () => { controller.stop(); } },
|
||||
{ type: "separator" },
|
||||
{ label: "Beenden", click: () => { app.quit(); } }
|
||||
]);
|
||||
tray.setContextMenu(contextMenu);
|
||||
tray.on("double-click", () => {
|
||||
mainWindow?.show();
|
||||
mainWindow?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function destroyTray(): void {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractLinksFromText(text: string): string[] {
|
||||
return extractHttpLinksFromText(text);
|
||||
}
|
||||
|
||||
function normalizeClipboardText(text: string): string {
|
||||
const truncateUnicodeSafe = (value: string, maxChars: number): string => {
|
||||
if (value.length <= maxChars) {
|
||||
return value;
|
||||
}
|
||||
const points = Array.from(value);
|
||||
if (points.length <= maxChars) {
|
||||
return value;
|
||||
}
|
||||
return points.slice(0, maxChars).join("");
|
||||
};
|
||||
|
||||
const normalized = String(text || "");
|
||||
if (normalized.length <= CLIPBOARD_MAX_TEXT_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
const truncated = truncateUnicodeSafe(normalized, CLIPBOARD_MAX_TEXT_CHARS);
|
||||
const lastBreak = Math.max(
|
||||
truncated.lastIndexOf("\n"),
|
||||
truncated.lastIndexOf("\r"),
|
||||
truncated.lastIndexOf("\t"),
|
||||
truncated.lastIndexOf(" ")
|
||||
);
|
||||
if (lastBreak >= Math.floor(CLIPBOARD_MAX_TEXT_CHARS * 0.7)) {
|
||||
return truncated.slice(0, lastBreak);
|
||||
}
|
||||
return truncated;
|
||||
}
|
||||
|
||||
function startClipboardWatcher(): void {
|
||||
if (clipboardTimer) {
|
||||
return;
|
||||
}
|
||||
lastClipboardText = normalizeClipboardText(clipboard.readText());
|
||||
clipboardTimer = setInterval(() => {
|
||||
let text: string;
|
||||
try {
|
||||
text = normalizeClipboardText(clipboard.readText());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (text === lastClipboardText || !text.trim()) {
|
||||
return;
|
||||
}
|
||||
lastClipboardText = text;
|
||||
const links = extractLinksFromText(text);
|
||||
if (links.length > 0 && mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CLIPBOARD_DETECTED, links);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopClipboardWatcher(): void {
|
||||
if (clipboardTimer) {
|
||||
clearInterval(clipboardTimer);
|
||||
clipboardTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateClipboardWatcher(): void {
|
||||
const settings = controller.getSettings();
|
||||
if (settings.clipboardWatch) {
|
||||
startClipboardWatcher();
|
||||
} else {
|
||||
stopClipboardWatcher();
|
||||
}
|
||||
}
|
||||
|
||||
function updateTray(): void {
|
||||
const settings = controller.getSettings();
|
||||
if (settings.minimizeToTray) {
|
||||
createTray();
|
||||
} else {
|
||||
destroyTray();
|
||||
}
|
||||
}
|
||||
|
||||
function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.GET_SNAPSHOT, () => controller.getSnapshot());
|
||||
ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
|
||||
ipcMain.handle(IPC_CHANNELS.CHECK_UPDATES, async () => controller.checkUpdates());
|
||||
ipcMain.handle(IPC_CHANNELS.INSTALL_UPDATE, async () => {
|
||||
const result = await controller.installUpdate((progress: UpdateInstallProgress) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
mainWindow.webContents.send(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, progress);
|
||||
});
|
||||
if (result.started) {
|
||||
updateQuitTimer = setTimeout(() => {
|
||||
app.quit();
|
||||
}, 5000);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => {
|
||||
try {
|
||||
const parsed = new URL(String(rawUrl || "").trim());
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return false;
|
||||
}
|
||||
await shell.openExternal(parsed.toString());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
|
||||
const validated = validatePlainObject(partial ?? {}, "partial");
|
||||
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
||||
updateClipboardWatcher();
|
||||
updateTray();
|
||||
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
|
||||
const validatedProvider = validateString(provider, "provider") as DebridProvider;
|
||||
if (!RESETTABLE_PROVIDER_KEYS.has(validatedProvider)) {
|
||||
throw new Error("provider ist ungültig");
|
||||
}
|
||||
return controller.resetProviderDailyUsage(validatedProvider);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => {
|
||||
const validatedKeyId = validateString(keyId, "keyId").trim();
|
||||
if (!validatedKeyId) {
|
||||
throw new Error("keyId ist ungültig");
|
||||
}
|
||||
return controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {
|
||||
validatePlainObject(payload ?? {}, "payload");
|
||||
validateString(payload?.rawText, "rawText");
|
||||
if (payload.packageName !== undefined) {
|
||||
validateString(payload.packageName, "packageName");
|
||||
}
|
||||
if (payload.duplicatePolicy !== undefined && payload.duplicatePolicy !== "keep" && payload.duplicatePolicy !== "skip" && payload.duplicatePolicy !== "overwrite") {
|
||||
throw new Error("duplicatePolicy muss 'keep', 'skip' oder 'overwrite' sein");
|
||||
}
|
||||
return controller.addLinks(payload);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => {
|
||||
const validPaths = validateStringArray(filePaths ?? [], "filePaths");
|
||||
const safePaths = validPaths.filter((p) => path.isAbsolute(p));
|
||||
return controller.addContainers(safePaths);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts());
|
||||
ipcMain.handle(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => {
|
||||
validateString(packageId, "packageId");
|
||||
validateString(policy, "policy");
|
||||
if (policy !== "keep" && policy !== "skip" && policy !== "overwrite") {
|
||||
throw new Error("policy muss 'keep', 'skip' oder 'overwrite' sein");
|
||||
}
|
||||
return controller.resolveStartConflict(packageId, policy);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
|
||||
ipcMain.handle(IPC_CHANNELS.START, () => {
|
||||
if (scheduledStartTimer !== null) {
|
||||
clearTimeout(scheduledStartTimer);
|
||||
scheduledStartTimer = null;
|
||||
controller.updateSettings({ scheduledStartEpochMs: 0 });
|
||||
}
|
||||
return controller.start();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.START_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => {
|
||||
validateStringArray(packageIds ?? [], "packageIds");
|
||||
return controller.startPackages(packageIds ?? []);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.START_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => {
|
||||
validateStringArray(itemIds ?? [], "itemIds");
|
||||
return controller.startItems(itemIds ?? []);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop());
|
||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause());
|
||||
ipcMain.handle(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
return controller.cancelPackage(packageId);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
validateString(newName, "newName");
|
||||
if (newName.length > RENAME_PACKAGE_MAX_CHARS) {
|
||||
throw new Error(`newName zu lang (max ${RENAME_PACKAGE_MAX_CHARS} Zeichen)`);
|
||||
}
|
||||
return controller.renamePackage(packageId, newName);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => {
|
||||
validateStringArray(packageIds, "packageIds");
|
||||
return controller.reorderPackages(packageIds);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.REMOVE_ITEM, (_event: IpcMainInvokeEvent, itemId: string) => {
|
||||
validateString(itemId, "itemId");
|
||||
return controller.removeItem(itemId);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
return controller.togglePackage(packageId);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, async (_event: IpcMainInvokeEvent, packageIds: string[]) => {
|
||||
const validPackageIds = validateStringArray(packageIds ?? [], "packageIds");
|
||||
const exported = controller.exportPackageSelection(validPackageIds);
|
||||
if (exported.packageCount === 0 || exported.linkCount === 0) {
|
||||
return { saved: false, packageCount: 0, linkCount: 0 };
|
||||
}
|
||||
const options = {
|
||||
defaultPath: exported.defaultFileName,
|
||||
filters: [{ name: "Link Export", extensions: ["txt"] }]
|
||||
};
|
||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount };
|
||||
}
|
||||
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
|
||||
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.EXPORT_ITEM_SELECTION, async (_event: IpcMainInvokeEvent, itemIds: string[]) => {
|
||||
const validItemIds = validateStringArray(itemIds ?? [], "itemIds");
|
||||
const exported = controller.exportItemSelection(validItemIds);
|
||||
if (exported.packageCount === 0 || exported.linkCount === 0) {
|
||||
return { saved: false, packageCount: 0, linkCount: 0 };
|
||||
}
|
||||
const options = {
|
||||
defaultPath: exported.defaultFileName,
|
||||
filters: [{ name: "Link Export", extensions: ["txt"] }]
|
||||
};
|
||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount };
|
||||
}
|
||||
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
|
||||
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
return controller.retryExtraction(packageId);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
return controller.extractNow(packageId);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
return controller.resetPackage(packageId);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.SET_PACKAGE_PRIORITY, (_event: IpcMainInvokeEvent, packageId: string, priority: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
validateString(priority, "priority");
|
||||
if (priority !== "high" && priority !== "normal" && priority !== "low") {
|
||||
throw new Error("priority muss 'high', 'normal' oder 'low' sein");
|
||||
}
|
||||
return controller.setPackagePriority(packageId, priority);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.SKIP_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => {
|
||||
validateStringArray(itemIds ?? [], "itemIds");
|
||||
return controller.skipItems(itemIds ?? []);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.RESET_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => {
|
||||
validateStringArray(itemIds ?? [], "itemIds");
|
||||
return controller.resetItems(itemIds ?? []);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.GET_HISTORY, () => controller.getHistory());
|
||||
ipcMain.handle(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory());
|
||||
ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => {
|
||||
validateString(entryId, "entryId");
|
||||
return controller.removeHistoryEntry(entryId);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, async () => {
|
||||
const options = {
|
||||
defaultPath: `rd-queue-export.json`,
|
||||
filters: [{ name: "Queue Export", extensions: ["json"] }]
|
||||
};
|
||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { saved: false };
|
||||
}
|
||||
const json = controller.exportQueue();
|
||||
await fs.promises.writeFile(result.filePath, json, "utf8");
|
||||
return { saved: true };
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => {
|
||||
validateString(json, "json");
|
||||
const bytes = Buffer.byteLength(json, "utf8");
|
||||
if (bytes > IMPORT_QUEUE_MAX_BYTES) {
|
||||
throw new Error(`Queue-Import zu groß (max ${IMPORT_QUEUE_MAX_BYTES} Bytes)`);
|
||||
}
|
||||
return controller.importQueue(json);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => {
|
||||
const settings = controller.getSettings();
|
||||
const next = !settings.clipboardWatch;
|
||||
controller.updateSettings({ clipboardWatch: next });
|
||||
updateClipboardWatcher();
|
||||
return next;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.PICK_FOLDER, async () => {
|
||||
const options = {
|
||||
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
||||
};
|
||||
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||
return result.canceled ? null : result.filePaths[0] || null;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.PICK_CONTAINERS, async () => {
|
||||
const options = {
|
||||
properties: ["openFile", "multiSelections"] as Array<"openFile" | "multiSelections">,
|
||||
filters: [
|
||||
{ name: "Container", extensions: ["dlc"] },
|
||||
{ name: "Alle Dateien", extensions: ["*"] }
|
||||
]
|
||||
};
|
||||
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||
return result.canceled ? [] : result.filePaths;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
|
||||
ipcMain.handle(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats());
|
||||
ipcMain.handle(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.RESTART, () => {
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.QUIT, () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
|
||||
const options = {
|
||||
defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`,
|
||||
filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
|
||||
};
|
||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { saved: false };
|
||||
}
|
||||
const encrypted = controller.exportBackup();
|
||||
await fs.promises.writeFile(result.filePath, encrypted);
|
||||
return { saved: true };
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => {
|
||||
const options = {
|
||||
defaultPath: controller.getSupportBundleDefaultFileName(),
|
||||
filters: [{ name: "Support Bundle", extensions: ["zip"] }]
|
||||
};
|
||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { saved: false };
|
||||
}
|
||||
const exported = await controller.exportSupportBundle();
|
||||
await fs.promises.writeFile(result.filePath, exported.buffer);
|
||||
return { saved: true, filePath: result.filePath };
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => {
|
||||
const logPath = getLogFilePath();
|
||||
await shell.openPath(logPath);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => {
|
||||
const logPath = controller.getAuditLogPath();
|
||||
if (logPath) {
|
||||
await shell.openPath(logPath);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.OPEN_RENAME_LOG, async () => {
|
||||
const logPath = controller.getRenameLogPath();
|
||||
if (logPath) {
|
||||
await shell.openPath(logPath);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => {
|
||||
const logPath = controller.getSessionLogPath();
|
||||
if (logPath) {
|
||||
await shell.openPath(logPath);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.OPEN_TRACE_LOG, async () => {
|
||||
const logPath = controller.getTraceLogPath();
|
||||
if (logPath) {
|
||||
await shell.openPath(logPath);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.OPEN_PACKAGE_LOG, async (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
const logPath = controller.getPackageLogPath(packageId);
|
||||
if (logPath) {
|
||||
await shell.openPath(logPath);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK, async () => controller.getDebugSetupCheck());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.GET_RECENT_ERRORS, async () => getRecentErrors());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.TEST_NOTIFY, async (_event: IpcMainInvokeEvent, url: string, mention: string) => {
|
||||
validateString(url, "url");
|
||||
return sendNotification(url, {
|
||||
title: "🔔 Test-Benachrichtigung",
|
||||
message: "Webhook funktioniert — Benachrichtigungen kommen hier an.",
|
||||
mention: typeof mention === "string" ? mention : ""
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string, durationMinutes?: number) => {
|
||||
if (typeof enabled !== "boolean") {
|
||||
throw new Error("enabled muss ein Boolean sein");
|
||||
}
|
||||
if (note !== undefined) {
|
||||
validateString(note, "note");
|
||||
}
|
||||
if (durationMinutes !== undefined && (!Number.isFinite(durationMinutes) || durationMinutes <= 0)) {
|
||||
throw new Error("durationMinutes muss eine positive Zahl sein");
|
||||
}
|
||||
return controller.setTraceEnabled(enabled, note, durationMinutes ? durationMinutes * 60 * 1000 : undefined);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => {
|
||||
const rotated = controller.rotateDebugToken();
|
||||
return { path: rotated.path };
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => {
|
||||
return controller.getRemoteDiagnostics();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, async (_event: IpcMainInvokeEvent, input: EnableRemoteDiagnosticsInput) => {
|
||||
if (!input || (input.hostMode !== "local" && input.hostMode !== "network")) {
|
||||
throw new Error("hostMode muss 'local' oder 'network' sein");
|
||||
}
|
||||
const allowlist = Array.isArray(input.allowlist) ? input.allowlist.map((entry) => String(entry)) : [];
|
||||
return controller.enableRemoteDiagnostics({
|
||||
hostMode: input.hostMode,
|
||||
publicHost: String(input.publicHost || ""),
|
||||
port: input.port ? Number(input.port) : undefined,
|
||||
allowlist,
|
||||
name: input.name ? String(input.name) : undefined,
|
||||
rotateToken: Boolean(input.rotateToken)
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS, async () => {
|
||||
return controller.disableRemoteDiagnostics();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => {
|
||||
return controller.rotateRemoteDiagnosticsToken();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
|
||||
validateString(itemId, "itemId");
|
||||
const logPath = controller.getItemLogPath(itemId);
|
||||
if (logPath) {
|
||||
await shell.openPath(logPath);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => {
|
||||
await controller.openRealDebridLoginWindow();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => {
|
||||
await controller.openAllDebridLoginWindow();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES, async () => {
|
||||
const options = {
|
||||
properties: ["openFile"] as Array<"openFile">,
|
||||
filters: [
|
||||
{ name: "Cookie-Datei", extensions: ["txt"] },
|
||||
{ name: "Alle Dateien", extensions: ["*"] }
|
||||
]
|
||||
};
|
||||
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return controller.importBestDebridCookies(result.filePaths[0]);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO, async () => {
|
||||
return controller.getAllDebridHostInfo();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS, async () => {
|
||||
return controller.getDebridLinkHostLimits();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => {
|
||||
return controller.checkDebridAccounts();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, async (_event, login: string, password: string) => {
|
||||
return controller.checkSingleMegaDebridAccount(String(login || ""), String(password || ""));
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => {
|
||||
const options = {
|
||||
properties: ["openFile"] as Array<"openFile">,
|
||||
filters: [
|
||||
{ name: "MDD Backup", extensions: ["mdd"] },
|
||||
{ name: "Legacy Backup (JSON)", extensions: ["json"] },
|
||||
{ name: "Alle Dateien", extensions: ["*"] }
|
||||
]
|
||||
};
|
||||
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { restored: false, message: "Abgebrochen" };
|
||||
}
|
||||
const filePath = result.filePaths[0];
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
const BACKUP_MAX_BYTES = 50 * 1024 * 1024;
|
||||
if (stat.size > BACKUP_MAX_BYTES) {
|
||||
return { restored: false, message: `Backup-Datei zu groß (max 50 MB, Datei hat ${(stat.size / 1024 / 1024).toFixed(1)} MB)` };
|
||||
}
|
||||
const data = await fs.promises.readFile(filePath);
|
||||
const importResult = controller.importBackup(data);
|
||||
// Only a full restore (queue swapped) needs the auto-relaunch. A settings-
|
||||
// only import applied live — relaunching would be pointless and would drop
|
||||
// the running queue.
|
||||
if (importResult.restored && importResult.relaunch) {
|
||||
setTimeout(() => {
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
}, 1500);
|
||||
}
|
||||
return importResult;
|
||||
});
|
||||
|
||||
ipcMain.on(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => {
|
||||
try {
|
||||
logger.error(formatRendererErrorReport(rawReport));
|
||||
} catch (error) {
|
||||
logger.error(`[Renderer] Fehlerbericht konnte nicht verarbeitet werden: ${String(error)}`);
|
||||
}
|
||||
});
|
||||
|
||||
controller.onState = (snapshot) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
mainWindow.webContents.send(IPC_CHANNELS.STATE_UPDATE, snapshot);
|
||||
};
|
||||
}
|
||||
|
||||
function formatRendererErrorReport(rawReport: unknown): string {
|
||||
const report = (rawReport && typeof rawReport === "object" ? rawReport : {}) as Record<string, unknown>;
|
||||
const str = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||
const num = (value: unknown): string => (typeof value === "number" && Number.isFinite(value) ? String(value) : "");
|
||||
const kind = str(report.kind) || "error";
|
||||
const message = (str(report.message) || "(ohne Nachricht)").slice(0, 2000);
|
||||
const source = str(report.source);
|
||||
const line = num(report.line);
|
||||
const column = num(report.column);
|
||||
const stack = str(report.stack).slice(0, 4000);
|
||||
const componentStack = str(report.componentStack).slice(0, 4000);
|
||||
|
||||
const parts: string[] = [`[Renderer:${kind}] ${message}`];
|
||||
if (source) {
|
||||
parts.push(`@ ${source}${line ? `:${line}${column ? `:${column}` : ""}` : ""}`);
|
||||
}
|
||||
if (stack) {
|
||||
parts.push(`| stack: ${stack.replace(/\s*\n\s*/g, " ⏎ ")}`);
|
||||
}
|
||||
if (componentStack) {
|
||||
parts.push(`| react: ${componentStack.replace(/\s*\n\s*/g, " ⏎ ")}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
app.on("child-process-gone", (_event, details) => {
|
||||
const killed = details.reason !== "clean-exit" && details.reason !== "killed";
|
||||
const line = `Subprozess beendet: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? "?"}${details.name ? ` name=${details.name}` : ""}${details.serviceName ? ` service=${details.serviceName}` : ""}`;
|
||||
if (killed) {
|
||||
logger.error(line);
|
||||
} else {
|
||||
logger.warn(line);
|
||||
}
|
||||
});
|
||||
|
||||
app.on("second-instance", () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
cleanupStaleSubstDrives();
|
||||
registerIpcHandlers();
|
||||
mainWindow = createWindow();
|
||||
bindMainWindowLifecycle(mainWindow);
|
||||
updateClipboardWatcher();
|
||||
updateTray();
|
||||
// A scheduled start persists in the settings but its timer lived only in this
|
||||
// process — without re-arming it here, any restart (auto-update, reboot,
|
||||
// crash) silently swallowed the planned run.
|
||||
armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false });
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
bindMainWindowLifecycle(mainWindow);
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error("App startup failed:", error);
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; }
|
||||
stopClipboardWatcher();
|
||||
destroyTray();
|
||||
shutdownDaemon();
|
||||
try {
|
||||
controller.shutdown();
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Shutdown: ${String(error)}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
|
||||
const MEGA_API_TIMEOUT_MS = 12_000;
|
||||
|
||||
export interface MegaFileInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const NEW_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/file\/([A-Za-z0-9_-]+)#([A-Za-z0-9_-]+)/i;
|
||||
const LEGACY_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/#!([A-Za-z0-9_-]+)!([A-Za-z0-9_-]+)/i;
|
||||
|
||||
export function isMegaFileUrl(url: string): boolean {
|
||||
const s = String(url || "").trim();
|
||||
return NEW_FORMAT_RE.test(s) || LEGACY_FORMAT_RE.test(s);
|
||||
}
|
||||
|
||||
function base64UrlDecode(s: string): Buffer | null {
|
||||
let b64 = String(s || "").trim().replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (b64.length % 4 !== 0) b64 += "=";
|
||||
try {
|
||||
return Buffer.from(b64, "base64");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedMegaLink {
|
||||
id: string;
|
||||
rawKey: Buffer;
|
||||
}
|
||||
|
||||
export function parseMegaUrl(url: string): ParsedMegaLink | null {
|
||||
const s = String(url || "").trim();
|
||||
const m = NEW_FORMAT_RE.exec(s) || LEGACY_FORMAT_RE.exec(s);
|
||||
if (!m) return null;
|
||||
const id = m[1];
|
||||
const rawKey = base64UrlDecode(m[2]);
|
||||
if (!rawKey || rawKey.length !== 32) return null;
|
||||
return { id, rawKey };
|
||||
}
|
||||
|
||||
export function decryptMegaAttributes(encrypted: Buffer, aesKey: Buffer): Record<string, unknown> | null {
|
||||
if (!Buffer.isBuffer(encrypted) || encrypted.length === 0 || encrypted.length % 16 !== 0) return null;
|
||||
if (!Buffer.isBuffer(aesKey) || aesKey.length !== 16) return null;
|
||||
let plain: Buffer;
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
|
||||
decipher.setAutoPadding(false);
|
||||
plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const text = plain.toString("utf8").replace(/\0+$/, "").trim();
|
||||
if (!text.startsWith("MEGA{")) return null;
|
||||
try {
|
||||
return JSON.parse(text.slice(4));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeoutSignal(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort("mega-api-timeout"), timeoutMs);
|
||||
if (parent) {
|
||||
if (parent.aborted) {
|
||||
controller.abort(parent.reason);
|
||||
} else {
|
||||
parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true });
|
||||
}
|
||||
}
|
||||
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export async function resolveMegaFilename(
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<MegaFileInfo | null> {
|
||||
const parsed = parseMegaUrl(url);
|
||||
if (!parsed) return null;
|
||||
const aesKey = parsed.rawKey.subarray(0, 16);
|
||||
|
||||
const apiUrl = `${MEGA_API_BASE}?id=${Math.floor(Math.random() * 1e9)}`;
|
||||
const body = JSON.stringify([{ a: "g", g: 1, p: parsed.id }]);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal: withTimeoutSignal(signal, MEGA_API_TIMEOUT_MS)
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) return null;
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof payload === "number") return null;
|
||||
if (!Array.isArray(payload) || payload.length === 0) return null;
|
||||
|
||||
const first = payload[0];
|
||||
if (typeof first === "number") return null;
|
||||
if (!first || typeof first !== "object") return null;
|
||||
|
||||
const info = first as { s?: unknown; at?: unknown; e?: unknown };
|
||||
if (typeof info.e === "number" && info.e !== 0) return null;
|
||||
|
||||
const size = typeof info.s === "number" && info.s > 0 ? info.s : 0;
|
||||
if (typeof info.at !== "string" || !info.at.trim()) return null;
|
||||
|
||||
const encryptedAttrs = base64UrlDecode(info.at);
|
||||
if (!encryptedAttrs) return null;
|
||||
|
||||
const attrs = decryptMegaAttributes(encryptedAttrs, aesKey);
|
||||
if (!attrs || typeof attrs.n !== "string" || !attrs.n.trim()) return null;
|
||||
|
||||
return { name: attrs.n.trim(), size };
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import { UnrestrictedLink } from "./realdebrid";
|
||||
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
||||
import { traceConversionPhase } from "./conversion-trace";
|
||||
|
||||
type MegaCredentials = {
|
||||
login: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type CodeEntry = {
|
||||
code: string;
|
||||
linkHint: string;
|
||||
};
|
||||
|
||||
const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login";
|
||||
const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid";
|
||||
const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json";
|
||||
const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de";
|
||||
|
||||
export const MEGA_DEBRID_NO_SERVER_RE = /kein server f(?:ü|u)r diesen hoster|no server (?:is )?available for this host|aucun serveur disponible/i;
|
||||
|
||||
function normalizeLink(link: string): string {
|
||||
return link.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function parseSetCookieFromHeaders(headers: Headers): string {
|
||||
const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
|
||||
if (typeof getSetCookie === "function") {
|
||||
const values = getSetCookie.call(headers)
|
||||
.map((entry) => entry.split(";")[0].trim())
|
||||
.filter(Boolean);
|
||||
if (values.length > 0) {
|
||||
return values.join("; ");
|
||||
}
|
||||
}
|
||||
|
||||
const raw = headers.get("set-cookie") || "";
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
return raw
|
||||
.split(/,(?=[^;=]+?=)/g)
|
||||
.map((chunk) => chunk.split(";")[0].trim())
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
const PERMANENT_HOSTER_ERRORS = [
|
||||
"hosternotavailable",
|
||||
"filenotfound",
|
||||
"file_unavailable",
|
||||
"file not found",
|
||||
"link is dead",
|
||||
"file has been removed",
|
||||
"file has been deleted",
|
||||
"file was deleted",
|
||||
"file was removed",
|
||||
"not available",
|
||||
"file is no longer available"
|
||||
];
|
||||
|
||||
function parsePageErrors(html: string): string[] {
|
||||
const errors: string[] = [];
|
||||
const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = errorRegex.exec(html)) !== null) {
|
||||
const text = m[1].replace(/^Fehler:\s*/i, "").trim();
|
||||
if (text) {
|
||||
errors.push(text);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function isPermanentHosterError(errors: string[]): string | null {
|
||||
for (const err of errors) {
|
||||
const lower = err.toLowerCase();
|
||||
for (const pattern of PERMANENT_HOSTER_ERRORS) {
|
||||
if (lower.includes(pattern)) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCodes(html: string): CodeEntry[] {
|
||||
const entries: CodeEntry[] = [];
|
||||
const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi;
|
||||
let cardMatch: RegExpExecArray | null;
|
||||
while ((cardMatch = cardRegex.exec(html)) !== null) {
|
||||
const block = cardMatch[0];
|
||||
const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim();
|
||||
const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || "";
|
||||
if (!code) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ code, linkHint: normalizeLink(linkTitle) });
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = fallbackRegex.exec(html)) !== null) {
|
||||
entries.push({ code: m[1], linkHint: "" });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function pickCode(entries: CodeEntry[], link: string): string {
|
||||
if (entries.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const target = normalizeLink(link);
|
||||
const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target));
|
||||
return (match?.code || entries[0].code || "").trim();
|
||||
}
|
||||
|
||||
function parseDebridJson(text: string): { link: string; text: string } | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { link?: string; text?: string };
|
||||
return {
|
||||
link: String(parsed.link || ""),
|
||||
text: String(parsed.text || "")
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
return new Error("aborted:mega-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 });
|
||||
});
|
||||
}
|
||||
|
||||
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abortErrorFactory: () => Error = abortError): Promise<T> {
|
||||
if (!signal) {
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortErrorFactory();
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortErrorFactory());
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
promise.then((value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
}, (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export class MegaWebFallback {
|
||||
// Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen
|
||||
// seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene
|
||||
// Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts
|
||||
// gleichzeitig umgewandelt werden statt global eine nach der anderen.
|
||||
private queues = new Map<string, Promise<unknown>>();
|
||||
|
||||
private getCredentials: () => MegaCredentials;
|
||||
|
||||
private sessions = new Map<string, { cookie: string; setAt: number }>();
|
||||
|
||||
public constructor(getCredentials: () => MegaCredentials) {
|
||||
this.getCredentials = getCredentials;
|
||||
}
|
||||
|
||||
public async unrestrict(
|
||||
link: string,
|
||||
signal?: AbortSignal,
|
||||
account?: { login: string; password: string }
|
||||
): Promise<UnrestrictedLink | null> {
|
||||
const overallSignal = withTimeoutSignal(signal, 180000);
|
||||
const creds = (account && account.login.trim() && account.password.trim())
|
||||
? account
|
||||
: this.getCredentials();
|
||||
if (!creds.login.trim() || !creds.password.trim()) {
|
||||
return null;
|
||||
}
|
||||
const key = creds.login.trim().toLowerCase();
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||
|
||||
let generated = await this.generate(link, cookie, overallSignal);
|
||||
if (!generated) {
|
||||
this.sessions.delete(key);
|
||||
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||
generated = await this.generate(link, cookie, overallSignal);
|
||||
if (!generated) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
directUrl: generated.directUrl,
|
||||
fileName: generated.fileName || filenameFromUrl(link),
|
||||
fileSize: null,
|
||||
retriesUsed: 0
|
||||
};
|
||||
}, key, overallSignal);
|
||||
}
|
||||
|
||||
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||
const existing = this.sessions.get(key);
|
||||
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
|
||||
return existing.cookie;
|
||||
}
|
||||
const cookie = await this.login(login, password, signal);
|
||||
this.sessions.set(key, { cookie, setAt: Date.now() });
|
||||
return cookie;
|
||||
}
|
||||
|
||||
public invalidateSession(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
const QUEUE_WAIT_TIMEOUT_MS = 90000;
|
||||
let workStarted = false;
|
||||
const guardedJob = async (): Promise<T> => {
|
||||
throwIfAborted(signal);
|
||||
const waited = Date.now() - queuedAt;
|
||||
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
|
||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` });
|
||||
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||
}
|
||||
workStarted = true;
|
||||
const workStartedAt = Date.now();
|
||||
try {
|
||||
const result = await job();
|
||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "ok" });
|
||||
return result;
|
||||
} catch (jobError) {
|
||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) });
|
||||
throw jobError;
|
||||
}
|
||||
};
|
||||
const prev = this.queues.get(key) ?? Promise.resolve();
|
||||
const run = prev.then(guardedJob, guardedJob);
|
||||
this.queues.set(key, run.then(() => undefined, () => undefined));
|
||||
return raceWithAbort(run, signal, () =>
|
||||
workStarted
|
||||
? abortError()
|
||||
: new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor((Date.now() - queuedAt) / 1000)}s Wartezeit, Account war belegt)`)
|
||||
);
|
||||
}
|
||||
|
||||
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||
throwIfAborted(signal);
|
||||
const response = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0"
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
login,
|
||||
password,
|
||||
remember: "on"
|
||||
}),
|
||||
redirect: "manual",
|
||||
signal: withTimeoutSignal(signal, 30000)
|
||||
});
|
||||
|
||||
const cookie = parseSetCookieFromHeaders(response.headers);
|
||||
if (!cookie) {
|
||||
throw new Error("Mega-Web Login liefert kein Session-Cookie");
|
||||
}
|
||||
|
||||
const verify = await fetch(DEBRID_REFERER, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: cookie,
|
||||
Referer: DEBRID_REFERER
|
||||
},
|
||||
signal: withTimeoutSignal(signal, 30000)
|
||||
});
|
||||
const verifyHtml = await verify.text();
|
||||
const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml);
|
||||
if (!hasDebridForm) {
|
||||
throw new Error("Mega-Web Login ungültig oder Session blockiert");
|
||||
}
|
||||
|
||||
return cookie;
|
||||
}
|
||||
|
||||
private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
|
||||
throwIfAborted(signal);
|
||||
const page = await fetch(DEBRID_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: cookie,
|
||||
Referer: DEBRID_REFERER
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
links: link,
|
||||
password: "",
|
||||
showLinks: "1"
|
||||
}),
|
||||
signal: withTimeoutSignal(signal, 30000)
|
||||
});
|
||||
|
||||
const html = await page.text();
|
||||
|
||||
const pageErrors = parsePageErrors(html);
|
||||
const permanentError = isPermanentHosterError(pageErrors);
|
||||
if (permanentError) {
|
||||
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
|
||||
}
|
||||
|
||||
const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err));
|
||||
if (noServerError) {
|
||||
throw new Error(`Mega-Web: ${noServerError}`);
|
||||
}
|
||||
|
||||
const code = pickCode(parseCodes(html), link);
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= 60; attempt += 1) {
|
||||
throwIfAborted(signal);
|
||||
const res = await fetch(DEBRID_AJAX_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: cookie,
|
||||
Referer: DEBRID_REFERER
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
autodl: "0"
|
||||
}),
|
||||
signal: withTimeoutSignal(signal, 15000)
|
||||
});
|
||||
|
||||
const text = (await res.text()).trim();
|
||||
if (text === "reload") {
|
||||
await sleepWithSignal(650, signal);
|
||||
continue;
|
||||
}
|
||||
if (text === "false") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseDebridJson(text);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed.link) {
|
||||
if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) {
|
||||
await sleepWithSignal(1200, signal);
|
||||
continue;
|
||||
}
|
||||
const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) {
|
||||
throw new Error(`Mega-Web: ${serverMsg}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromText = parsed.text
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i);
|
||||
const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim();
|
||||
return {
|
||||
directUrl: parsed.link,
|
||||
fileName
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function compactMegaWebError(error: unknown): string {
|
||||
return compactErrorText(error);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { logger } from "./logger";
|
||||
|
||||
export interface NotifyPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
mention?: string;
|
||||
}
|
||||
|
||||
const NOTIFY_TIMEOUT_MS = 5000;
|
||||
const WEBHOOK_USERNAME = "Real-Debrid Downloader";
|
||||
const MIN_SEND_GAP_MS = 450;
|
||||
const RETRY_DELAYS_MS = [1000, 2500];
|
||||
const RATE_LIMIT_MAX_WAIT_MS = 15_000;
|
||||
const CONTENT_MAX_CHARS = 2000;
|
||||
|
||||
export function isNotifyUrlValid(url: string): boolean {
|
||||
return /^https?:\/\/\S+$/i.test(String(url || "").trim());
|
||||
}
|
||||
|
||||
// Accepts a bare Discord user ID (wrapped as <@id> so it actually pings),
|
||||
// @everyone/@here, or an already-formed <@...>/<@&...> mention as-is.
|
||||
export function normalizeDiscordMention(raw: string): string {
|
||||
const text = String(raw || "").trim();
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
if (/^\d{5,}$/.test(text)) {
|
||||
return `<@${text}>`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Discord counts the limit itself; slicing UTF-16 units can split a surrogate
|
||||
// pair at the boundary, which Discord rejects as invalid content.
|
||||
export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string {
|
||||
if (content.length <= maxChars) {
|
||||
return content;
|
||||
}
|
||||
let cut = content.slice(0, maxChars);
|
||||
const last = cut.charCodeAt(cut.length - 1);
|
||||
if (last >= 0xd800 && last <= 0xdbff) {
|
||||
cut = cut.slice(0, -1);
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
|
||||
export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } {
|
||||
const mention = normalizeDiscordMention(payload.mention || "");
|
||||
const content = truncateContent(`${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`);
|
||||
return {
|
||||
url: String(url || "").trim(),
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: WEBHOOK_USERNAME, content })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function delayMs(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function consumeBody(response: Response): Promise<string> {
|
||||
try {
|
||||
return await response.text();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(response: Response, bodyText: string): number {
|
||||
const headerSeconds = Number(response.headers.get("X-RateLimit-Reset-After") || response.headers.get("Retry-After") || "");
|
||||
if (Number.isFinite(headerSeconds) && headerSeconds > 0) {
|
||||
return Math.ceil(headerSeconds * 1000);
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as { retry_after?: number };
|
||||
if (typeof parsed.retry_after === "number" && parsed.retry_after > 0) {
|
||||
return Math.ceil(parsed.retry_after * 1000);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return 1500;
|
||||
}
|
||||
|
||||
async function sendOnce(url: string, payload: NotifyPayload, fetchFn: typeof fetch): Promise<{ ok: boolean; retryable: boolean; waitMs: number; detail: string }> {
|
||||
try {
|
||||
const request = buildNotifyRequest(url, payload);
|
||||
const response = await fetchFn(request.url, { ...request.init, signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS) });
|
||||
const bodyText = await consumeBody(response);
|
||||
if (response.ok) {
|
||||
return { ok: true, retryable: false, waitMs: 0, detail: "" };
|
||||
}
|
||||
if (response.status === 429) {
|
||||
const waitMs = Math.min(RATE_LIMIT_MAX_WAIT_MS, parseRetryAfterMs(response, bodyText));
|
||||
return { ok: false, retryable: true, waitMs, detail: `HTTP 429 (Rate-Limit, warte ${waitMs}ms)` };
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
return { ok: false, retryable: true, waitMs: 0, detail: `HTTP ${response.status}` };
|
||||
}
|
||||
return { ok: false, retryable: false, waitMs: 0, detail: `HTTP ${response.status}` };
|
||||
} catch (error) {
|
||||
return { ok: false, retryable: true, waitMs: 0, detail: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
// All sends share one chain: serialized with a minimum gap so burst completions
|
||||
// (many packages finishing together) stay under Discord's 5-per-2s webhook
|
||||
// bucket instead of getting dropped as 429s.
|
||||
let sendChain: Promise<void> = Promise.resolve();
|
||||
let lastSendCompletedAt = 0;
|
||||
|
||||
export async function sendNotification(
|
||||
url: string,
|
||||
payload: NotifyPayload,
|
||||
fetchFn: typeof fetch = fetch,
|
||||
sleepFn: (ms: number) => Promise<void> = delayMs
|
||||
): Promise<boolean> {
|
||||
if (!isNotifyUrlValid(url)) {
|
||||
if (String(url || "").trim()) {
|
||||
logger.warn(`Benachrichtigung nicht gesendet: ungueltige Webhook-URL (muss mit http(s):// beginnen): ${payload.title}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const result = sendChain.then(async () => {
|
||||
const sinceLast = Date.now() - lastSendCompletedAt;
|
||||
if (sinceLast < MIN_SEND_GAP_MS) {
|
||||
await sleepFn(MIN_SEND_GAP_MS - sinceLast);
|
||||
}
|
||||
let lastDetail = "";
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const outcome = await sendOnce(url, payload, fetchFn);
|
||||
if (outcome.ok) {
|
||||
return true;
|
||||
}
|
||||
lastDetail = outcome.detail;
|
||||
if (!outcome.retryable || attempt >= RETRY_DELAYS_MS.length) {
|
||||
break;
|
||||
}
|
||||
await sleepFn(outcome.waitMs > 0 ? outcome.waitMs : RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
logger.warn(`Benachrichtigung fehlgeschlagen (${lastDetail}): ${payload.title}`);
|
||||
return false;
|
||||
});
|
||||
sendChain = result.then(() => {
|
||||
lastSendCompletedAt = Date.now();
|
||||
}, () => {
|
||||
lastSendCompletedAt = Date.now();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const PACKAGE_LOG_RETENTION_DAYS = 30;
|
||||
|
||||
type PackageLogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface PackageLogMeta {
|
||||
packageId: string;
|
||||
name: string;
|
||||
outputDir: string;
|
||||
extractDir: string;
|
||||
}
|
||||
|
||||
let packageLogsDir: string | null = null;
|
||||
const knownLogPaths = new Map<string, string>();
|
||||
const pendingLinesByPackage = new Map<string, string[]>();
|
||||
const initializedThisProcess = new Set<string>();
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function normalizePackageId(packageId: string): string {
|
||||
const trimmed = String(packageId || "").trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const safePrefix = trimmed
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.slice(0, 64)
|
||||
.replace(/^_+|_+$/g, "");
|
||||
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
|
||||
return `${safePrefix || "pkg"}_${hash}`;
|
||||
}
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function getPackageLogFilePathFromNormalized(normalized: string): string | null {
|
||||
if (!normalized || !packageLogsDir) {
|
||||
return null;
|
||||
}
|
||||
const existing = knownLogPaths.get(normalized);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const logPath = path.join(packageLogsDir, `package_${normalized}.txt`);
|
||||
knownLogPaths.set(normalized, logPath);
|
||||
return logPath;
|
||||
}
|
||||
|
||||
function getPackageLogFilePath(packageId: string): string | null {
|
||||
return getPackageLogFilePathFromNormalized(normalizePackageId(packageId));
|
||||
}
|
||||
|
||||
function flushPending(): void {
|
||||
for (const [packageId, lines] of pendingLinesByPackage.entries()) {
|
||||
if (lines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
||||
if (!logPath) {
|
||||
continue;
|
||||
}
|
||||
const chunk = lines.join("");
|
||||
pendingLinesByPackage.set(packageId, []);
|
||||
try {
|
||||
fs.appendFileSync(logPath, chunk, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushPending();
|
||||
}, PACKAGE_LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function cleanupOldPackageLogs(dir: string): Promise<void> {
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const cutoff = Date.now() - PACKAGE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("package_") || !file.endsWith(".txt")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function appendLine(packageId: string, line: string): void {
|
||||
const normalized = normalizePackageId(packageId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const lines = pendingLinesByPackage.get(normalized) || [];
|
||||
lines.push(line);
|
||||
pendingLinesByPackage.set(normalized, lines);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
export function initPackageLogs(baseDir: string): void {
|
||||
packageLogsDir = path.join(baseDir, "package-logs");
|
||||
try {
|
||||
fs.mkdirSync(packageLogsDir, { recursive: true });
|
||||
} catch {
|
||||
packageLogsDir = null;
|
||||
return;
|
||||
}
|
||||
void cleanupOldPackageLogs(packageLogsDir);
|
||||
}
|
||||
|
||||
export function ensurePackageLog(meta: PackageLogMeta): string | null {
|
||||
const normalizedPackageId = normalizePackageId(meta.packageId);
|
||||
const logPath = getPackageLogFilePath(meta.packageId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
if (!fs.existsSync(logPath)) {
|
||||
fs.writeFileSync(logPath, "", "utf8");
|
||||
}
|
||||
if (!initializedThisProcess.has(normalizedPackageId)) {
|
||||
initializedThisProcess.add(normalizedPackageId);
|
||||
const startedAt = logTimestamp();
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`${logTimestamp()} [INFO] Paket-Kontext initialisiert${formatFields({
|
||||
name: meta.name,
|
||||
outputDir: meta.outputDir,
|
||||
extractDir: meta.extractDir
|
||||
})}\n`,
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return logPath;
|
||||
}
|
||||
|
||||
export function logPackageEvent(
|
||||
packageId: string,
|
||||
level: PackageLogLevel,
|
||||
message: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
const logPath = getPackageLogFilePath(packageId);
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||
appendLine(packageId, line);
|
||||
}
|
||||
|
||||
export function getPackageLogPath(packageId: string): string | null {
|
||||
const logPath = getPackageLogFilePath(packageId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(logPath) ? logPath : null;
|
||||
}
|
||||
|
||||
export function shutdownPackageLogs(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
for (const packageId of knownLogPaths.keys()) {
|
||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
||||
if (!logPath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
pendingLinesByPackage.clear();
|
||||
knownLogPaths.clear();
|
||||
initializedThisProcess.clear();
|
||||
packageLogsDir = null;
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
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 function extractPrivateTokenFromHtml(html: string): string | null {
|
||||
const normalized = String(html || "");
|
||||
if (!normalized.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 = normalized.match(pattern);
|
||||
const token = match?.[1]?.trim();
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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();
|
||||
void this.primeTokenFromWindow(window);
|
||||
}
|
||||
|
||||
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 {
|
||||
}
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = 10 * 60 * 1000 + 30_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);
|
||||
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;
|
||||
this.loginWindowPartition = "";
|
||||
}
|
||||
});
|
||||
this.loginWindow = window;
|
||||
this.loginWindowPartition = partition;
|
||||
await window.loadURL(RD_LOGIN_URL);
|
||||
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<string | null> {
|
||||
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 {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async primeTokenFromWindow(window: BrowserWindow): Promise<void> {
|
||||
try {
|
||||
await this.extractApiTokenFromWindow(window);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
|
||||
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: {
|
||||
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;
|
||||
}
|
||||
|
||||
const token = extractPrivateTokenFromHtml(html);
|
||||
if (token) {
|
||||
return this.rememberToken(token);
|
||||
}
|
||||
|
||||
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) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { API_BASE_URL, APP_VERSION, REQUEST_RETRIES } from "./constants";
|
||||
import { compactErrorText, sleep } from "./utils";
|
||||
|
||||
const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||||
|
||||
export interface UnrestrictedLink {
|
||||
fileName: string;
|
||||
directUrl: string;
|
||||
fileSize: number | null;
|
||||
retriesUsed: number;
|
||||
skipTlsVerify?: boolean;
|
||||
sourceLabel?: string;
|
||||
sourceAccountId?: string;
|
||||
sourceAccountLabel?: string;
|
||||
}
|
||||
|
||||
function shouldRetryStatus(status: number): boolean {
|
||||
return status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
function retryDelay(attempt: number): number {
|
||||
return Math.min(5000, 400 * 2 ** attempt);
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(value: string | null): number {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const asSeconds = Number(text);
|
||||
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
||||
return Math.min(120000, Math.floor(asSeconds * 1000));
|
||||
}
|
||||
|
||||
const asDate = Date.parse(text);
|
||||
if (Number.isFinite(asDate)) {
|
||||
return Math.min(120000, Math.max(0, asDate - Date.now()));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function retryDelayForResponse(response: Response, attempt: number): number {
|
||||
if (response.status !== 429) {
|
||||
return retryDelay(attempt);
|
||||
}
|
||||
const fromHeader = parseRetryAfterMs(response.headers.get("retry-after"));
|
||||
return fromHeader > 0 ? fromHeader : retryDelay(attempt);
|
||||
}
|
||||
|
||||
function readHttpStatusFromErrorText(text: string): number {
|
||||
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function isRetryableErrorText(text: string): boolean {
|
||||
const status = readHttpStatusFromErrorText(text);
|
||||
if (status === 429 || status >= 500) {
|
||||
return true;
|
||||
}
|
||||
const lower = String(text || "").toLowerCase();
|
||||
return lower.includes("timeout")
|
||||
|| lower.includes("network")
|
||||
|| lower.includes("fetch failed")
|
||||
|| lower.includes("aborted")
|
||||
|| lower.includes("econnreset")
|
||||
|| lower.includes("enotfound")
|
||||
|| lower.includes("etimedout")
|
||||
|| lower.includes("html statt json");
|
||||
}
|
||||
|
||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||
if (!signal) {
|
||||
return AbortSignal.timeout(timeoutMs);
|
||||
}
|
||||
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
|
||||
}
|
||||
|
||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await sleep(ms);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new Error("aborted");
|
||||
}
|
||||
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(new Error("aborted"));
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeHtmlResponse(contentType: string, body: string): boolean {
|
||||
const type = String(contentType || "").toLowerCase();
|
||||
if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
|
||||
return true;
|
||||
}
|
||||
return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || ""));
|
||||
}
|
||||
|
||||
function parseErrorBody(status: number, body: string, contentType: string): string {
|
||||
if (looksLikeHtmlResponse(contentType, body)) {
|
||||
return `Real-Debrid lieferte HTML statt JSON (HTTP ${status})`;
|
||||
}
|
||||
const clean = compactErrorText(body);
|
||||
return clean || `HTTP ${status}`;
|
||||
}
|
||||
|
||||
export class RealDebridClient {
|
||||
private token: string;
|
||||
|
||||
public constructor(token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||
let lastError = "";
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||
try {
|
||||
const body = new URLSearchParams({ link });
|
||||
const response = await fetch(`${API_BASE_URL}/unrestrict/link`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": DEBRID_USER_AGENT
|
||||
},
|
||||
body,
|
||||
signal: withTimeoutSignal(signal, 30000)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const contentType = String(response.headers.get("content-type") || "");
|
||||
if (!response.ok) {
|
||||
const parsed = parseErrorBody(response.status, text, contentType);
|
||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||
continue;
|
||||
}
|
||||
throw new Error(parsed);
|
||||
}
|
||||
|
||||
if (looksLikeHtmlResponse(contentType, text)) {
|
||||
throw new Error("Real-Debrid lieferte HTML statt JSON");
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error("Ungültige JSON-Antwort von Real-Debrid");
|
||||
}
|
||||
const directUrl = String(payload.download || payload.link || "").trim();
|
||||
if (!directUrl) {
|
||||
throw new Error("Unrestrict ohne Download-URL");
|
||||
}
|
||||
try {
|
||||
const parsedUrl = new URL(directUrl);
|
||||
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
|
||||
throw new Error(`Ungültiges Download-URL-Protokoll (${parsedUrl.protocol})`);
|
||||
}
|
||||
} catch (urlError) {
|
||||
if (urlError instanceof Error && urlError.message.includes("Protokoll")) throw urlError;
|
||||
throw new Error("Real-Debrid Antwort enthält keine gültige Download-URL");
|
||||
}
|
||||
|
||||
const fileName = String(payload.filename || "download.bin").trim() || "download.bin";
|
||||
const fileSizeRaw = Number(payload.filesize ?? NaN);
|
||||
return {
|
||||
fileName,
|
||||
directUrl,
|
||||
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
||||
retriesUsed: attempt - 1
|
||||
};
|
||||
} 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 || "Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
|
||||
type RenameLogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const RENAME_LOG_MAX_FILE_BYTES = Number(process.env.RD_RENAME_LOG_MAX_BYTES || 10 * 1024 * 1024);
|
||||
const RENAME_LOG_RETENTION_DAYS = Number(process.env.RD_RENAME_LOG_RETENTION_DAYS || 30);
|
||||
|
||||
let renameLogPath: string | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < RENAME_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - RENAME_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initRenameLog(baseDir: string): void {
|
||||
renameLogPath = path.join(baseDir, "rename.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(renameLogPath), { recursive: true });
|
||||
cleanupOldBackup(renameLogPath);
|
||||
if (!fs.existsSync(renameLogPath)) {
|
||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(renameLogPath);
|
||||
if (!fs.existsSync(renameLogPath)) {
|
||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
renameLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function logRenameEvent(level: RenameLogLevel, message: string, fields?: Record<string, unknown>): void {
|
||||
if (!renameLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(renameLogPath);
|
||||
if (!fs.existsSync(renameLogPath)) {
|
||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(
|
||||
renameLogPath,
|
||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getRenameLogPath(): string | null {
|
||||
if (!renameLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(renameLogPath) ? renameLogPath : null;
|
||||
}
|
||||
|
||||
export function shutdownRenameLog(): void {
|
||||
if (!renameLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
renameLogPath = null;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { setLogListener } from "./logger";
|
||||
|
||||
const SESSION_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
|
||||
let sessionLogPath: string | null = null;
|
||||
let sessionLogsDir: string | null = null;
|
||||
let pendingLines: string[] = [];
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function formatTimestamp(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const mo = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
const h = String(now.getHours()).padStart(2, "0");
|
||||
const mi = String(now.getMinutes()).padStart(2, "0");
|
||||
const s = String(now.getSeconds()).padStart(2, "0");
|
||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
||||
}
|
||||
|
||||
function flushPending(): void {
|
||||
if (pendingLines.length === 0 || !sessionLogPath) {
|
||||
return;
|
||||
}
|
||||
const chunk = pendingLines.join("");
|
||||
pendingLines = [];
|
||||
try {
|
||||
fs.appendFileSync(sessionLogPath, chunk, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushPending();
|
||||
}, SESSION_LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function appendToSessionLog(line: string): void {
|
||||
if (!sessionLogPath) {
|
||||
return;
|
||||
}
|
||||
pendingLines.push(line);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise<void> {
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("session_") || !file.endsWith(".txt")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function initSessionLog(baseDir: string): void {
|
||||
sessionLogsDir = path.join(baseDir, "session-logs");
|
||||
try {
|
||||
fs.mkdirSync(sessionLogsDir, { recursive: true });
|
||||
} catch {
|
||||
sessionLogsDir = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = formatTimestamp();
|
||||
sessionLogPath = path.join(sessionLogsDir, `session_${timestamp}.txt`);
|
||||
|
||||
const isoTimestamp = logTimestamp();
|
||||
try {
|
||||
fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8");
|
||||
} catch {
|
||||
sessionLogPath = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setLogListener((line) => appendToSessionLog(line));
|
||||
|
||||
void cleanupOldSessionLogs(sessionLogsDir, 7);
|
||||
}
|
||||
|
||||
export function getSessionLogPath(): string | null {
|
||||
return sessionLogPath;
|
||||
}
|
||||
|
||||
export function shutdownSessionLog(): void {
|
||||
if (!sessionLogPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
|
||||
const isoTimestamp = logTimestamp();
|
||||
try {
|
||||
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
|
||||
setLogListener(null);
|
||||
sessionLogPath = null;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AppSettings } from "../shared/types";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { StoragePaths } from "./storage";
|
||||
|
||||
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface HealthCheckFinding {
|
||||
severity: HealthCheckSeverity;
|
||||
code: string;
|
||||
message: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface HealthCheckReport {
|
||||
findings: HealthCheckFinding[];
|
||||
errorCount: number;
|
||||
warnCount: number;
|
||||
infoCount: number;
|
||||
}
|
||||
|
||||
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024;
|
||||
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
function safeExists(p: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(p);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFileSizeBytes(p: string): number {
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
return stat.size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function isWritable(dir: string): boolean {
|
||||
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||
try {
|
||||
fs.writeFileSync(probe, "x", { encoding: "utf8" });
|
||||
fs.rmSync(probe, { force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFreeDiskSpaceBytes(target: string): number | null {
|
||||
try {
|
||||
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
|
||||
if (typeof statfs !== "function") {
|
||||
return null;
|
||||
}
|
||||
const result = statfs(target);
|
||||
const bavail = BigInt(result.bavail);
|
||||
const bsize = BigInt(result.bsize);
|
||||
const free = bavail * bsize;
|
||||
if (free > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
return Number(free);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } {
|
||||
const providers: string[] = [];
|
||||
if (settings.token?.trim() || settings.realDebridUseWebLogin) {
|
||||
providers.push("Real-Debrid");
|
||||
}
|
||||
if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) {
|
||||
providers.push("AllDebrid");
|
||||
}
|
||||
if (settings.bestToken?.trim() || settings.bestDebridUseWebLogin) {
|
||||
providers.push("BestDebrid");
|
||||
}
|
||||
if (settings.oneFichierApiKey?.trim()) {
|
||||
providers.push("1Fichier");
|
||||
}
|
||||
if (settings.ddownloadLogin?.trim() && settings.ddownloadPassword?.trim()) {
|
||||
providers.push("DDownload");
|
||||
}
|
||||
if (settings.linkSnappyLogin?.trim() && settings.linkSnappyPassword?.trim()) {
|
||||
providers.push("LinkSnappy");
|
||||
}
|
||||
const dlKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
||||
if (dlKeys.length > 0) {
|
||||
providers.push(`Debrid-Link (${dlKeys.length} Key${dlKeys.length === 1 ? "" : "s"})`);
|
||||
}
|
||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
|
||||
const legacyMegaConfigured = Boolean(settings.megaLogin?.trim() && settings.megaPassword?.trim());
|
||||
if (megaAccounts.length > 0) {
|
||||
providers.push(`Mega-Debrid (${megaAccounts.length} Acc)`);
|
||||
} else if (legacyMegaConfigured) {
|
||||
providers.push("Mega-Debrid");
|
||||
}
|
||||
return { count: providers.length, providers };
|
||||
}
|
||||
|
||||
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
|
||||
const findings: HealthCheckFinding[] = [];
|
||||
|
||||
const outputDir = String(settings.outputDir || "").trim();
|
||||
if (!outputDir) {
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "outputDir_missing",
|
||||
message: "Kein Download-Ziel-Verzeichnis konfiguriert",
|
||||
hint: "In den Einstellungen unter 'Downloads' einen Ziel-Ordner setzen, sonst koennen keine Downloads starten."
|
||||
});
|
||||
} else if (!safeExists(outputDir)) {
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "outputDir_not_found",
|
||||
message: `Download-Ziel-Ordner existiert nicht: ${outputDir}`,
|
||||
hint: "Der Ordner wird beim ersten Download automatisch erstellt, sofern der Elternordner existiert und beschreibbar ist."
|
||||
});
|
||||
} else if (!isWritable(outputDir)) {
|
||||
findings.push({
|
||||
severity: "ERROR",
|
||||
code: "outputDir_not_writable",
|
||||
message: `Download-Ziel-Ordner ist NICHT beschreibbar: ${outputDir}`,
|
||||
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
|
||||
});
|
||||
} else {
|
||||
const freeBytes = getFreeDiskSpaceBytes(outputDir);
|
||||
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
|
||||
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "low_disk_space",
|
||||
message: `Wenig freier Speicher im Download-Ordner: ~${freeMb} MB verfuegbar (Schwelle ${LOW_DISK_SPACE_BYTES / (1024 * 1024 * 1024)} GB)`,
|
||||
hint: "Groessere Downloads koennen auf halbem Weg fehlschlagen. Vorher Platz schaffen oder anderen Ordner waehlen."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { count, providers } = countConfiguredProviders(settings);
|
||||
if (count === 0) {
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "no_provider_configured",
|
||||
message: "Kein Debrid-Provider konfiguriert — Downloads werden nicht funktionieren",
|
||||
hint: "In den Einstellungen mindestens einen Provider (Real-Debrid, Mega-Debrid, Debrid-Link, ...) einrichten."
|
||||
});
|
||||
} else {
|
||||
findings.push({
|
||||
severity: "INFO",
|
||||
code: "providers_configured",
|
||||
message: `Konfigurierte Provider: ${providers.join(", ")}`
|
||||
});
|
||||
}
|
||||
|
||||
if (safeExists(storagePaths.sessionFile)) {
|
||||
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
|
||||
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
|
||||
const sizeMb = Math.round(sizeBytes / (1024 * 1024));
|
||||
findings.push({
|
||||
severity: "WARN",
|
||||
code: "large_state_file",
|
||||
message: `State-Datei ist sehr gross: ${sizeMb} MB (${path.basename(storagePaths.sessionFile)})`,
|
||||
hint: "Alte abgeschlossene Pakete aus der Queue entfernen, damit Startup + Save schneller werden."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!safeExists(storagePaths.baseDir)) {
|
||||
findings.push({
|
||||
severity: "ERROR",
|
||||
code: "baseDir_missing",
|
||||
message: `Runtime-Verzeichnis existiert nicht: ${storagePaths.baseDir}`,
|
||||
hint: "Ohne Runtime-Verzeichnis koennen weder Settings noch Session-State persistiert werden."
|
||||
});
|
||||
} else if (!isWritable(storagePaths.baseDir)) {
|
||||
findings.push({
|
||||
severity: "ERROR",
|
||||
code: "baseDir_not_writable",
|
||||
message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`,
|
||||
hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)."
|
||||
});
|
||||
}
|
||||
|
||||
const errorCount = findings.filter((f) => f.severity === "ERROR").length;
|
||||
const warnCount = findings.filter((f) => f.severity === "WARN").length;
|
||||
const infoCount = findings.filter((f) => f.severity === "INFO").length;
|
||||
return { findings, errorCount, warnCount, infoCount };
|
||||
}
|
||||
+1340
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
import { promises as fsp } from "node:fs";
|
||||
import path from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { APP_VERSION } from "./constants";
|
||||
import { getAccountRotationLogPath } from "./account-rotation-log";
|
||||
import { getConversionLogPath } from "./conversion-trace";
|
||||
import { getAuditLogPath } from "./audit-log";
|
||||
import { getDebugSetupCheck } from "./debug-setup";
|
||||
import { getLogFilePath } from "./logger";
|
||||
import { getRecentErrors } from "./error-ring";
|
||||
import { getPackageLogPath } from "./package-log";
|
||||
import { getRenameLogPath } from "./rename-log";
|
||||
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||
import { getSessionLogPath } from "./session-log";
|
||||
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||
import type { DownloadManager } from "./download-manager";
|
||||
|
||||
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
||||
|
||||
async function safeReadJson(filePath: string): Promise<unknown> {
|
||||
try {
|
||||
return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
|
||||
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
|
||||
}
|
||||
|
||||
async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise<void> {
|
||||
if (!sourcePath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buffer = await fsp.readFile(sourcePath);
|
||||
zip.addFile(zipPath, buffer);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
const zipPath = path.posix.join(zipRoot, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await addDirectoryIfExists(zip, fullPath, zipPath);
|
||||
continue;
|
||||
}
|
||||
await addFileIfExists(zip, fullPath, zipPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise<number> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
let added = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
try {
|
||||
if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) {
|
||||
await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name));
|
||||
added += 1;
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
function formatTimestampForFileName(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
const h = String(date.getHours()).padStart(2, "0");
|
||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
||||
const s = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
||||
}
|
||||
|
||||
export function getSupportBundleDefaultFileName(): string {
|
||||
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
|
||||
}
|
||||
|
||||
type HostDiagnosticsMode = "full" | "cached" | "none";
|
||||
|
||||
interface BuildSupportBundleOptions {
|
||||
hostDiagnosticsMode?: HostDiagnosticsMode;
|
||||
}
|
||||
|
||||
function createDeferredHostDiagnostics(reason: string): unknown {
|
||||
return {
|
||||
collectedAt: new Date().toISOString(),
|
||||
supported: process.platform === "win32",
|
||||
platform: process.platform,
|
||||
crashControl: null,
|
||||
recentKernelPower: [],
|
||||
recentWerKernel: [],
|
||||
recentKernelDump: [],
|
||||
recentAppCrashes: [],
|
||||
recentMinidumps: [],
|
||||
assessmentHints: [
|
||||
reason
|
||||
],
|
||||
errors: []
|
||||
};
|
||||
}
|
||||
|
||||
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
||||
if (mode === "none") {
|
||||
return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert.");
|
||||
}
|
||||
if (mode === "cached") {
|
||||
const cached = getCachedWindowsHostDiagnostics();
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen.");
|
||||
}
|
||||
return getWindowsHostDiagnostics();
|
||||
}
|
||||
|
||||
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
|
||||
const zip = new AdmZip();
|
||||
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const settings = loadSettings(storagePaths);
|
||||
const history = loadHistory(storagePaths);
|
||||
const snapshot = manager.getSnapshot();
|
||||
const packageIds = Object.keys(snapshot.session.packages);
|
||||
const itemIds = Object.keys(snapshot.session.items);
|
||||
const debugSetup = getDebugSetupCheck(baseDir);
|
||||
|
||||
addJson(zip, "overview/meta.json", {
|
||||
appVersion: APP_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtimeBaseDir: baseDir,
|
||||
packageCount: packageIds.length,
|
||||
itemCount: itemIds.length
|
||||
});
|
||||
addJson(zip, "overview/status.json", snapshot.session);
|
||||
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings));
|
||||
addJson(zip, "overview/accounts.json", buildAccountSummary(settings));
|
||||
addJson(zip, "overview/stats.json", {
|
||||
...buildStatsPayload(snapshot),
|
||||
allTime: {
|
||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
|
||||
}
|
||||
});
|
||||
addJson(zip, "overview/debug-setup.json", debugSetup);
|
||||
addJson(zip, "overview/self-check.json", debugSetup);
|
||||
addJson(zip, "overview/history.json", {
|
||||
total: history.length,
|
||||
entries: history.map((entry) => summarizeHistoryEntry(entry))
|
||||
});
|
||||
addJson(zip, "overview/packages.json", {
|
||||
count: packageIds.length,
|
||||
packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean)
|
||||
});
|
||||
addJson(zip, "overview/items.json", {
|
||||
count: itemIds.length,
|
||||
items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean)
|
||||
});
|
||||
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
|
||||
addJson(zip, "overview/trace-config.json", getTraceConfig());
|
||||
const recentErrors = getRecentErrors();
|
||||
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
|
||||
|
||||
await addFileIfExists(zip, path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`);
|
||||
await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
|
||||
await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
||||
await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
|
||||
|
||||
await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
|
||||
await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
|
||||
await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
|
||||
await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
||||
await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
||||
await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
|
||||
await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
|
||||
await addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
|
||||
await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
||||
await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
||||
await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log");
|
||||
await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old");
|
||||
await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log");
|
||||
await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old");
|
||||
|
||||
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
||||
await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||
|
||||
for (const packageId of packageIds) {
|
||||
await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
||||
}
|
||||
for (const itemId of itemIds) {
|
||||
await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
|
||||
}
|
||||
|
||||
const supportManifest = await safeReadJson(path.join(baseDir, SUPPORT_MANIFEST_FILE));
|
||||
if (supportManifest) {
|
||||
addJson(zip, "overview/support-manifest.json", supportManifest);
|
||||
}
|
||||
|
||||
return zip.toBuffer();
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import { isNotifyUrlValid } from "./notify";
|
||||
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
|
||||
|
||||
function hasText(value: unknown): boolean {
|
||||
return String(value || "").trim().length > 0;
|
||||
}
|
||||
|
||||
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
|
||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
||||
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
|
||||
|
||||
return {
|
||||
realDebrid: {
|
||||
configured: hasText(settings.token) || settings.realDebridUseWebLogin,
|
||||
tokenConfigured: hasText(settings.token),
|
||||
webLoginEnabled: settings.realDebridUseWebLogin,
|
||||
rememberToken: settings.rememberToken
|
||||
},
|
||||
megaDebrid: {
|
||||
configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword))
|
||||
|| settings.megaDebridApiEnabled
|
||||
|| settings.megaDebridWebEnabled,
|
||||
loginConfigured: hasText(settings.megaLogin) && hasText(settings.megaPassword),
|
||||
apiEnabled: settings.megaDebridApiEnabled,
|
||||
webEnabled: settings.megaDebridWebEnabled,
|
||||
preferApi: settings.megaDebridPreferApi
|
||||
},
|
||||
bestDebrid: {
|
||||
configured: hasText(settings.bestToken) || settings.bestDebridUseWebLogin,
|
||||
tokenConfigured: hasText(settings.bestToken),
|
||||
webLoginEnabled: settings.bestDebridUseWebLogin
|
||||
},
|
||||
allDebrid: {
|
||||
configured: hasText(settings.allDebridToken) || settings.allDebridUseWebLogin,
|
||||
tokenConfigured: hasText(settings.allDebridToken),
|
||||
webLoginEnabled: settings.allDebridUseWebLogin
|
||||
},
|
||||
ddownload: {
|
||||
configured: hasText(settings.ddownloadLogin) && hasText(settings.ddownloadPassword)
|
||||
},
|
||||
oneFichier: {
|
||||
configured: hasText(settings.oneFichierApiKey)
|
||||
},
|
||||
debridLink: {
|
||||
configured: debridLinkKeyIds.length > 0,
|
||||
keyCount: debridLinkKeyIds.length,
|
||||
enabledKeyCount: debridLinkKeyIds.filter((id) => !disabledDebridLinkIds.has(id)).length,
|
||||
disabledKeyCount: debridLinkKeyIds.filter((id) => disabledDebridLinkIds.has(id)).length
|
||||
},
|
||||
linkSnappy: {
|
||||
configured: hasText(settings.linkSnappyLogin) && hasText(settings.linkSnappyPassword)
|
||||
},
|
||||
disabledProviders: [...(settings.disabledProviders || [])]
|
||||
};
|
||||
}
|
||||
|
||||
export function diffAccountSummary(previous: AppSettings, next: AppSettings): Record<string, unknown> {
|
||||
const before = buildAccountSummary(previous);
|
||||
const after = buildAccountSummary(next);
|
||||
const changes: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(after)) {
|
||||
const beforeJson = JSON.stringify(before[key]);
|
||||
const afterJson = JSON.stringify(after[key]);
|
||||
if (beforeJson !== afterJson) {
|
||||
changes[key] = after[key];
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
export function buildRedactedSettingsPayload(settings: AppSettings): Record<string, unknown> {
|
||||
return {
|
||||
paths: {
|
||||
outputDir: settings.outputDir,
|
||||
extractDir: settings.extractDir,
|
||||
mkvLibraryDir: settings.mkvLibraryDir
|
||||
},
|
||||
providers: {
|
||||
providerOrder: settings.providerOrder,
|
||||
providerPrimary: settings.providerPrimary,
|
||||
providerSecondary: settings.providerSecondary,
|
||||
providerTertiary: settings.providerTertiary,
|
||||
autoProviderFallback: settings.autoProviderFallback,
|
||||
disabledProviders: settings.disabledProviders,
|
||||
hosterRouting: settings.hosterRouting
|
||||
},
|
||||
extraction: {
|
||||
autoExtract: settings.autoExtract,
|
||||
autoExtractWhenStopped: settings.autoExtractWhenStopped,
|
||||
hybridExtract: settings.hybridExtract,
|
||||
createExtractSubfolder: settings.createExtractSubfolder,
|
||||
cleanupMode: settings.cleanupMode,
|
||||
extractConflictMode: settings.extractConflictMode,
|
||||
removeLinkFilesAfterExtract: settings.removeLinkFilesAfterExtract,
|
||||
removeSamplesAfterExtract: settings.removeSamplesAfterExtract,
|
||||
enableIntegrityCheck: settings.enableIntegrityCheck,
|
||||
archivePasswordCount: String(settings.archivePasswordList || "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.length,
|
||||
extractCpuPriority: settings.extractCpuPriority,
|
||||
maxParallelExtract: settings.maxParallelExtract
|
||||
},
|
||||
downloads: {
|
||||
maxParallel: settings.maxParallel,
|
||||
retryLimit: settings.retryLimit,
|
||||
autoResumeOnStart: settings.autoResumeOnStart,
|
||||
autoReconnect: settings.autoReconnect,
|
||||
reconnectWaitSeconds: settings.reconnectWaitSeconds,
|
||||
autoSkipExtracted: settings.autoSkipExtracted,
|
||||
completedCleanupPolicy: settings.completedCleanupPolicy
|
||||
},
|
||||
ui: {
|
||||
packageName: settings.packageName,
|
||||
theme: settings.theme,
|
||||
collapseNewPackages: settings.collapseNewPackages,
|
||||
hideExtractedItems: settings.hideExtractedItems,
|
||||
confirmDeleteSelection: settings.confirmDeleteSelection,
|
||||
clipboardWatch: settings.clipboardWatch,
|
||||
minimizeToTray: settings.minimizeToTray,
|
||||
columnOrder: settings.columnOrder
|
||||
},
|
||||
bandwidth: {
|
||||
speedLimitEnabled: settings.speedLimitEnabled,
|
||||
speedLimitKbps: settings.speedLimitKbps,
|
||||
speedLimitMode: settings.speedLimitMode,
|
||||
bandwidthSchedules: settings.bandwidthSchedules
|
||||
},
|
||||
updates: {
|
||||
updateRepo: settings.updateRepo,
|
||||
autoUpdateCheck: settings.autoUpdateCheck
|
||||
},
|
||||
notifications: {
|
||||
notifyUrlConfigured: Boolean(String(settings.notifyUrl || "").trim()),
|
||||
notifyUrlLooksValid: isNotifyUrlValid(settings.notifyUrl),
|
||||
notifyMentionConfigured: Boolean(String(settings.notifyMention || "").trim()),
|
||||
notifyOnPackageCompleted: settings.notifyOnPackageCompleted,
|
||||
notifyOnPackageFailed: settings.notifyOnPackageFailed,
|
||||
notifyOnRunFinished: settings.notifyOnRunFinished
|
||||
},
|
||||
statistics: {
|
||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs,
|
||||
providerDailyLimitBytes: settings.providerDailyLimitBytes,
|
||||
providerDailyUsageBytes: settings.providerDailyUsageBytes,
|
||||
providerTotalUsageBytes: settings.providerTotalUsageBytes,
|
||||
debridLinkApiKeyDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes,
|
||||
debridLinkApiKeyDailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes,
|
||||
debridLinkApiKeyTotalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes,
|
||||
providerDailyUsageDay: settings.providerDailyUsageDay
|
||||
},
|
||||
accounts: buildAccountSummary(settings)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStatsPayload(snapshot: UiSnapshot): Record<string, unknown> {
|
||||
return {
|
||||
session: snapshot.stats,
|
||||
totals: {
|
||||
totalPackages: Object.keys(snapshot.session.packages).length,
|
||||
totalItems: Object.keys(snapshot.session.items).length,
|
||||
speedText: snapshot.speedText,
|
||||
etaText: snapshot.etaText,
|
||||
canStart: snapshot.canStart,
|
||||
canStop: snapshot.canStop,
|
||||
canPause: snapshot.canPause
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeHistoryEntry(entry: HistoryEntry): Record<string, unknown> {
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
status: entry.status,
|
||||
provider: entry.provider,
|
||||
fileCount: entry.fileCount,
|
||||
totalBytes: entry.totalBytes,
|
||||
downloadedBytes: entry.downloadedBytes,
|
||||
durationSeconds: entry.durationSeconds,
|
||||
completedAt: entry.completedAt,
|
||||
outputDir: entry.outputDir,
|
||||
urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { addLogListener, removeLogListener } from "./logger";
|
||||
import type { SupportTraceConfig } from "../shared/types";
|
||||
|
||||
type TraceLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const TRACE_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const TRACE_CONFIG_FILE = "trace_config.json";
|
||||
const TRACE_LOG_MAX_FILE_BYTES = Number(process.env.RD_TRACE_LOG_MAX_BYTES || 10 * 1024 * 1024);
|
||||
const TRACE_LOG_RETENTION_DAYS = Number(process.env.RD_TRACE_LOG_RETENTION_DAYS || 30);
|
||||
const TRACE_DEFAULT_AUTO_DISABLE_MS = Number(process.env.RD_TRACE_AUTO_DISABLE_MS || 2 * 60 * 60 * 1000);
|
||||
|
||||
const DEFAULT_TRACE_CONFIG: SupportTraceConfig = {
|
||||
enabled: false,
|
||||
includeMainLog: true,
|
||||
includeAudit: true,
|
||||
logDebugRequests: true,
|
||||
autoDisableAt: null,
|
||||
updatedAt: new Date(0).toISOString()
|
||||
};
|
||||
|
||||
let traceLogPath: string | null = null;
|
||||
let traceConfigPath: string | null = null;
|
||||
let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG };
|
||||
let pendingLines: string[] = [];
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let autoDisableTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function flushPending(): void {
|
||||
if (!traceLogPath || pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
const chunk = pendingLines.join("");
|
||||
pendingLines = [];
|
||||
try {
|
||||
fs.appendFileSync(traceLogPath, chunk, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < TRACE_LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackup(filePath: string): void {
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
const stat = fs.statSync(backup);
|
||||
const cutoff = Date.now() - TRACE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) {
|
||||
return;
|
||||
}
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushPending();
|
||||
}, TRACE_LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function appendTraceLine(line: string): void {
|
||||
if (!traceLogPath) {
|
||||
return;
|
||||
}
|
||||
rotateIfNeeded(traceLogPath);
|
||||
if (!fs.existsSync(traceLogPath)) {
|
||||
try {
|
||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
pendingLines.push(line);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
function normalizeTraceConfig(raw: unknown): SupportTraceConfig {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { ...DEFAULT_TRACE_CONFIG };
|
||||
}
|
||||
const value = raw as Partial<SupportTraceConfig>;
|
||||
return {
|
||||
enabled: Boolean(value.enabled),
|
||||
includeMainLog: value.includeMainLog === undefined ? DEFAULT_TRACE_CONFIG.includeMainLog : Boolean(value.includeMainLog),
|
||||
includeAudit: value.includeAudit === undefined ? DEFAULT_TRACE_CONFIG.includeAudit : Boolean(value.includeAudit),
|
||||
logDebugRequests: value.logDebugRequests === undefined ? DEFAULT_TRACE_CONFIG.logDebugRequests : Boolean(value.logDebugRequests),
|
||||
autoDisableAt: typeof value.autoDisableAt === "string" && value.autoDisableAt.trim()
|
||||
? value.autoDisableAt
|
||||
: null,
|
||||
updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim()
|
||||
? value.updatedAt
|
||||
: DEFAULT_TRACE_CONFIG.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
function loadTraceConfig(): SupportTraceConfig {
|
||||
if (!traceConfigPath) {
|
||||
return { ...DEFAULT_TRACE_CONFIG };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(traceConfigPath, "utf8")) as unknown;
|
||||
return normalizeTraceConfig(parsed);
|
||||
} catch {
|
||||
return { ...DEFAULT_TRACE_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
function persistTraceConfig(): void {
|
||||
if (!traceConfigPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
const mainLogListener = (line: string): void => {
|
||||
if (!traceConfig.enabled || !traceConfig.includeMainLog) {
|
||||
return;
|
||||
}
|
||||
appendTraceLine(line);
|
||||
};
|
||||
|
||||
function clearAutoDisableTimer(): void {
|
||||
if (autoDisableTimer) {
|
||||
clearTimeout(autoDisableTimer);
|
||||
autoDisableTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function disableTraceDueToExpiry(): void {
|
||||
clearAutoDisableTimer();
|
||||
if (!traceConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
traceConfig = normalizeTraceConfig({
|
||||
...traceConfig,
|
||||
enabled: false,
|
||||
autoDisableAt: null,
|
||||
updatedAt: logTimestamp()
|
||||
});
|
||||
persistTraceConfig();
|
||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
|
||||
}
|
||||
|
||||
function scheduleAutoDisable(): void {
|
||||
clearAutoDisableTimer();
|
||||
if (!traceConfig.enabled || !traceConfig.autoDisableAt) {
|
||||
return;
|
||||
}
|
||||
const until = Date.parse(traceConfig.autoDisableAt);
|
||||
if (!Number.isFinite(until)) {
|
||||
return;
|
||||
}
|
||||
const remainingMs = until - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
disableTraceDueToExpiry();
|
||||
return;
|
||||
}
|
||||
autoDisableTimer = setTimeout(() => {
|
||||
autoDisableTimer = null;
|
||||
disableTraceDueToExpiry();
|
||||
}, Math.min(remainingMs, 2_147_483_647));
|
||||
}
|
||||
|
||||
export function initTraceLog(baseDir: string): void {
|
||||
traceLogPath = path.join(baseDir, "trace.log");
|
||||
traceConfigPath = path.join(baseDir, TRACE_CONFIG_FILE);
|
||||
try {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
cleanupOldBackup(traceLogPath);
|
||||
if (!fs.existsSync(traceLogPath)) {
|
||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
||||
}
|
||||
rotateIfNeeded(traceLogPath);
|
||||
if (!fs.existsSync(traceLogPath)) {
|
||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
||||
}
|
||||
traceConfig = loadTraceConfig();
|
||||
persistTraceConfig();
|
||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
traceLogPath = null;
|
||||
traceConfigPath = null;
|
||||
traceConfig = { ...DEFAULT_TRACE_CONFIG };
|
||||
return;
|
||||
}
|
||||
addLogListener(mainLogListener);
|
||||
scheduleAutoDisable();
|
||||
}
|
||||
|
||||
export function getTraceLogPath(): string | null {
|
||||
if (!traceLogPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(traceLogPath) ? traceLogPath : null;
|
||||
}
|
||||
|
||||
export function getTraceConfigPath(): string | null {
|
||||
if (!traceConfigPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(traceConfigPath) ? traceConfigPath : null;
|
||||
}
|
||||
|
||||
export function getTraceConfig(): SupportTraceConfig {
|
||||
return { ...traceConfig };
|
||||
}
|
||||
|
||||
export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTraceConfig {
|
||||
traceConfig = normalizeTraceConfig({
|
||||
...traceConfig,
|
||||
...patch,
|
||||
updatedAt: logTimestamp()
|
||||
});
|
||||
persistTraceConfig();
|
||||
scheduleAutoDisable();
|
||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
|
||||
return getTraceConfig();
|
||||
}
|
||||
|
||||
export function setTraceEnabled(enabled: boolean, note = "", durationMs: number = TRACE_DEFAULT_AUTO_DISABLE_MS): SupportTraceConfig {
|
||||
const autoDisableAt = enabled && durationMs > 0
|
||||
? new Date(Date.now() + durationMs).toISOString()
|
||||
: null;
|
||||
const next = updateTraceConfig({ enabled, autoDisableAt });
|
||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function logTraceEvent(
|
||||
level: TraceLevel,
|
||||
category: string,
|
||||
message: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
if (!traceConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
if (category === "audit" && !traceConfig.includeAudit) {
|
||||
return;
|
||||
}
|
||||
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
|
||||
}
|
||||
|
||||
export function shutdownTraceLog(): void {
|
||||
removeLogListener(mainLogListener);
|
||||
clearAutoDisableTimer();
|
||||
if (!traceLogPath) {
|
||||
return;
|
||||
}
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
try {
|
||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
traceLogPath = null;
|
||||
traceConfigPath = null;
|
||||
traceConfig = { ...DEFAULT_TRACE_CONFIG };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface InstallResumeManager {
|
||||
isSessionRunning(): boolean;
|
||||
stop(options: { parkForRestart: boolean }): void;
|
||||
persistNowSync(): void;
|
||||
start(): Promise<void> | void;
|
||||
}
|
||||
|
||||
export async function runInstallWithResume<T extends { started: boolean }>(
|
||||
manager: InstallResumeManager,
|
||||
doInstall: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const wasRunning = manager.isSessionRunning();
|
||||
if (wasRunning) {
|
||||
manager.stop({ parkForRestart: true });
|
||||
}
|
||||
manager.persistNowSync();
|
||||
|
||||
const resumeIfParked = async (): Promise<void> => {
|
||||
if (wasRunning && !manager.isSessionRunning()) {
|
||||
await manager.start();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await doInstall();
|
||||
if (!result.started) {
|
||||
await resumeIfParked();
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
await resumeIfParked();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+1041
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
||||
import path from "node:path";
|
||||
import { ParsedPackageInput } from "../shared/types";
|
||||
|
||||
function safeDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const WINDOWS_RESERVED_BASENAMES = new Set([
|
||||
"con", "prn", "aux", "nul",
|
||||
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
|
||||
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"
|
||||
]);
|
||||
|
||||
export function compactErrorText(message: unknown, maxLen = 220): string {
|
||||
const raw = String(message ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!raw) {
|
||||
return "Unbekannter Fehler";
|
||||
}
|
||||
const safeMaxLen = Number.isFinite(maxLen) ? Math.max(4, Math.floor(maxLen)) : 220;
|
||||
if (raw.length <= safeMaxLen) {
|
||||
return raw;
|
||||
}
|
||||
return `${raw.slice(0, safeMaxLen - 3)}...`;
|
||||
}
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
const cleaned = String(name || "")
|
||||
.replace(/\0/g, "")
|
||||
.replace(/[\\/:*?"<>|]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
let normalized = cleaned
|
||||
.replace(/^[.\s]+/g, "")
|
||||
.replace(/[.\s]+$/g, "")
|
||||
.trim();
|
||||
|
||||
if (!normalized || normalized === "." || normalized === ".." || /^\.+$/.test(normalized)) {
|
||||
return "Paket";
|
||||
}
|
||||
|
||||
const parsed = path.parse(normalized);
|
||||
const reservedBase = (parsed.name.split(".")[0] || parsed.name).toLowerCase();
|
||||
if (WINDOWS_RESERVED_BASENAMES.has(reservedBase)) {
|
||||
normalized = `${parsed.name.replace(/^([^.]*)/, "$1_")}${parsed.ext}`;
|
||||
}
|
||||
|
||||
return normalized || "Paket";
|
||||
}
|
||||
|
||||
export function isHttpLink(value: string): boolean {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(text);
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && !!url.hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractHttpLinksFromText(text: string): string[] {
|
||||
const matches = String(text || "").match(/https?:\/\/[^\s<>"']+/gi) ?? [];
|
||||
const seen = new Set<string>();
|
||||
const links: string[] = [];
|
||||
|
||||
for (const match of matches) {
|
||||
let candidate = String(match || "").trim();
|
||||
let openParen = 0;
|
||||
let closeParen = 0;
|
||||
let openBracket = 0;
|
||||
let closeBracket = 0;
|
||||
for (const char of candidate) {
|
||||
if (char === "(") {
|
||||
openParen += 1;
|
||||
} else if (char === ")") {
|
||||
closeParen += 1;
|
||||
} else if (char === "[") {
|
||||
openBracket += 1;
|
||||
} else if (char === "]") {
|
||||
closeBracket += 1;
|
||||
}
|
||||
}
|
||||
while (candidate.length > 0) {
|
||||
const lastChar = candidate[candidate.length - 1];
|
||||
if (![")", "]", ",", ".", "!", "?", ";", ":"].includes(lastChar)) {
|
||||
break;
|
||||
}
|
||||
if (lastChar === ")") {
|
||||
if (closeParen <= openParen) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastChar === "]") {
|
||||
if (closeBracket <= openBracket) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastChar === ")") {
|
||||
closeParen = Math.max(0, closeParen - 1);
|
||||
} else if (lastChar === "]") {
|
||||
closeBracket = Math.max(0, closeBracket - 1);
|
||||
}
|
||||
candidate = candidate.slice(0, -1);
|
||||
}
|
||||
if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(candidate);
|
||||
links.push(candidate);
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
export function humanSize(bytes: number): string {
|
||||
const value = Number(bytes);
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return "0 B";
|
||||
}
|
||||
if (value < 1024) {
|
||||
return `${Math.round(value)} B`;
|
||||
}
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let size = value / 1024;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function filenameFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return "download.bin";
|
||||
}
|
||||
const queryName = parsed.searchParams.get("filename")
|
||||
|| parsed.searchParams.get("file")
|
||||
|| parsed.searchParams.get("name")
|
||||
|| parsed.searchParams.get("download")
|
||||
|| parsed.searchParams.get("title")
|
||||
|| "";
|
||||
const rawName = queryName || path.basename(parsed.pathname || "");
|
||||
const decoded = safeDecodeURIComponent(rawName || "").trim();
|
||||
const normalized = decoded
|
||||
.replace(/\.(rar|zip|7z|tar|gz|bz2|xz|iso|part\d+\.rar|r\d{2,3})\.html$/i, ".$1")
|
||||
.replace(/\.(mp4|mkv|avi|mp3|flac|srt)\.html$/i, ".$1");
|
||||
return sanitizeFilename(normalized || "download.bin");
|
||||
} catch {
|
||||
return "download.bin";
|
||||
}
|
||||
}
|
||||
|
||||
export function looksLikeOpaqueFilename(name: string): boolean {
|
||||
const cleaned = sanitizeFilename(name || "").toLowerCase();
|
||||
if (!cleaned || cleaned === "download.bin") {
|
||||
return true;
|
||||
}
|
||||
const parsed = path.parse(cleaned);
|
||||
return /^[a-f0-9]{24,}$/i.test(parsed.name || cleaned);
|
||||
}
|
||||
|
||||
export function inferPackageNameFromLinks(links: string[]): string {
|
||||
if (links.length === 0) {
|
||||
return "Paket";
|
||||
}
|
||||
const names = links.map((link) => filenameFromUrl(link).toLowerCase());
|
||||
const first = names[0];
|
||||
const match = first.match(/^([a-z0-9._\- ]{3,80}?)(?:\.|-|_)(?:part\d+|r\d{2}|s\d{2}e\d{2})/i);
|
||||
if (match) {
|
||||
return sanitizeFilename(match[1]);
|
||||
}
|
||||
return sanitizeFilename(path.parse(first).name || "Paket");
|
||||
}
|
||||
|
||||
export function uniquePreserveOrder(items: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of items) {
|
||||
const trimmed = item.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parsePackagesFromLinksText(rawText: string, defaultPackageName: string): ParsedPackageInput[] {
|
||||
const lines = String(rawText || "").split(/\r?\n/);
|
||||
const packages: ParsedPackageInput[] = [];
|
||||
let currentName = String(defaultPackageName || "").trim();
|
||||
let currentLinks: string[] = [];
|
||||
let currentFileNames: string[] = [];
|
||||
let pendingFileName = "";
|
||||
|
||||
const flush = (): void => {
|
||||
const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line)));
|
||||
if (links.length > 0) {
|
||||
const normalizedCurrentName = String(currentName || "").trim();
|
||||
const fileNames = links.map((link) => {
|
||||
const firstIndex = currentLinks.findIndex((currentLink) => currentLink === link);
|
||||
return firstIndex >= 0 ? currentFileNames[firstIndex] || "" : "";
|
||||
});
|
||||
const nextPackage: ParsedPackageInput = {
|
||||
name: normalizedCurrentName
|
||||
? sanitizeFilename(normalizedCurrentName)
|
||||
: inferPackageNameFromLinks(links),
|
||||
links
|
||||
};
|
||||
if (fileNames.some((fileName) => fileName.trim().length > 0)) {
|
||||
nextPackage.fileNames = fileNames;
|
||||
}
|
||||
packages.push(nextPackage);
|
||||
}
|
||||
currentLinks = [];
|
||||
currentFileNames = [];
|
||||
pendingFileName = "";
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const text = line.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const marker = text.match(/^#\s*package\s*:\s*(.+)$/i);
|
||||
if (marker) {
|
||||
flush();
|
||||
currentName = String(marker[1] || "").trim();
|
||||
pendingFileName = "";
|
||||
continue;
|
||||
}
|
||||
const fileMarker = text.match(/^#\s*file\s*:\s*(.+)$/i);
|
||||
if (fileMarker) {
|
||||
pendingFileName = sanitizeFilename(String(fileMarker[1] || "").trim());
|
||||
continue;
|
||||
}
|
||||
if (!isHttpLink(text)) {
|
||||
continue;
|
||||
}
|
||||
currentLinks.push(text);
|
||||
currentFileNames.push(pendingFileName);
|
||||
pendingFileName = "";
|
||||
}
|
||||
|
||||
flush();
|
||||
if (packages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
export function ensureDirPath(baseDir: string, packageName: string): string {
|
||||
if (!path.isAbsolute(baseDir)) {
|
||||
throw new Error("baseDir muss ein absoluter Pfad sein");
|
||||
}
|
||||
return path.join(baseDir, sanitizeFilename(packageName));
|
||||
}
|
||||
|
||||
export function nowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error(String(signal.reason || "aborted")));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
reject(new Error(String(signal?.reason || "aborted")));
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||
return "--";
|
||||
}
|
||||
const s = Math.floor(seconds);
|
||||
const sec = s % 60;
|
||||
const minTotal = Math.floor(s / 60);
|
||||
const min = minTotal % 60;
|
||||
const hr = Math.floor(minTotal / 60);
|
||||
if (hr > 0) {
|
||||
return `${String(hr).padStart(2, "0")}:${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
return `${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
// Removes only-German audio handling for "Dual Language" (.DL.) scene releases.
|
||||
// Mirrors the user's ffmpeg script but adds: language-tag detection (with safe
|
||||
// fallbacks), disk-space pre-check, atomic temp->replace, mtime preservation,
|
||||
// abort-into-child, and "never destroy the only usable audio" safety.
|
||||
//
|
||||
// The ffmpeg/ffprobe-specific logic lives here so it is mockable in isolation;
|
||||
// the per-package iteration + filename/.DL. rename + logging stays in
|
||||
// download-manager.ts (its existing domain).
|
||||
|
||||
export type GermanAudioMode = "tag" | "first";
|
||||
|
||||
export interface ProbedAudioStream {
|
||||
language: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export type AudioTrackDecision =
|
||||
| { action: "remux"; audioRelIndex: number; reason: string }
|
||||
| { action: "single"; audioRelIndex: 0; reason: string }
|
||||
| { action: "skip"; reason: string };
|
||||
|
||||
export type VideoProcessAction =
|
||||
| "remuxed"
|
||||
| "kept-single"
|
||||
| "skipped-no-german"
|
||||
| "skipped-no-audio"
|
||||
| "skipped-no-space"
|
||||
| "skipped-no-tool"
|
||||
| "error"
|
||||
| "aborted";
|
||||
|
||||
export interface VideoProcessResult {
|
||||
action: VideoProcessAction;
|
||||
reason: string;
|
||||
keptTrackIndex?: number;
|
||||
totalAudioTracks?: number;
|
||||
audioLanguages?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ProcessVideoOptions {
|
||||
mode: GermanAudioMode;
|
||||
cpuPriority?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// Injection seam so the irreversible file-mutating body (temp -> replace ->
|
||||
// utimes -> rm-on-failure) can be exercised in tests with a fake ffmpeg/ffprobe
|
||||
// runner, without spawning real processes. Production passes nothing.
|
||||
export interface ProcessVideoDeps {
|
||||
resolveTooling?: () => Promise<{ ffmpeg: string; ffprobe: string } | null>;
|
||||
runProcess?: typeof runVideoProcess;
|
||||
// Seam for the atomic-replace rename so its failure/recovery path is testable
|
||||
// without provoking a real OS file lock. Production uses renameWithRetry.
|
||||
rename?: (from: string, to: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const VIDEO_REMUX_EXTENSIONS = new Set([".mkv", ".mp4"]);
|
||||
const PROBE_TIMEOUT_MS = 60_000;
|
||||
const STDOUT_CAP = 2 * 1024 * 1024;
|
||||
const STDERR_CAP = 64 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (no fs / no process) — unit-tested in isolation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// "X.German.DL.720p.mkv" -> "X.German.720p.mkv"; "X.DL.mkv" -> "X.mkv".
|
||||
export function stripDualLangMarker(fileName: string): string {
|
||||
const ext = path.extname(fileName);
|
||||
const base = ext ? fileName.slice(0, -ext.length) : fileName;
|
||||
const stripped = base.replace(/\.DL\./gi, ".").replace(/\.DL$/i, "");
|
||||
return stripped + ext;
|
||||
}
|
||||
|
||||
export function hasDualLangMarker(fileName: string): boolean {
|
||||
return stripDualLangMarker(fileName) !== fileName;
|
||||
}
|
||||
|
||||
export function isRemuxableVideoFile(fileName: string): boolean {
|
||||
return VIDEO_REMUX_EXTENSIONS.has(path.extname(fileName).toLowerCase());
|
||||
}
|
||||
|
||||
// True when the release name explicitly marks it as a German release. Used in
|
||||
// tag mode to fall back to the first audio track (German-first scene convention)
|
||||
// when the audio language tags are wrong (a German dub mislabeled "eng"), instead
|
||||
// of skipping. Deliberately requires an explicit german/deutsch token — the
|
||||
// ".DL." marker alone (present on every processed file) is not enough, and a bare
|
||||
// "dubbed" can mean an Italian/French dub, so it must NOT flag a German release.
|
||||
export function looksLikeGermanRelease(fileName: string): boolean {
|
||||
return /(^|[._\s-])(german|deutsch)([._\s-]|$)/i.test(fileName);
|
||||
}
|
||||
|
||||
function isGermanStream(stream: ProbedAudioStream): boolean {
|
||||
const lang = (stream.language || "").toLowerCase().trim();
|
||||
if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) {
|
||||
return true;
|
||||
}
|
||||
// Free-text title fallback (used when the language tag is missing). Full words
|
||||
// only — the 2-3 letter codes ger/deu are too ambiguous in a title and would
|
||||
// pick the wrong track to keep (which then deletes the real German one).
|
||||
if (lang) {
|
||||
return false;
|
||||
}
|
||||
const title = (stream.title || "").toLowerCase();
|
||||
return /\b(german|deutsch)\b/.test(title);
|
||||
}
|
||||
|
||||
// Decide which audio track to keep. Safety invariant: only ever choose to remux
|
||||
// (which destroys the original) when we are confident; otherwise skip untouched.
|
||||
export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMode, germanRelease = false): AudioTrackDecision {
|
||||
const total = streams.length;
|
||||
if (total === 0) {
|
||||
return { action: "skip", reason: "no-audio" };
|
||||
}
|
||||
if (mode === "first") {
|
||||
return total === 1
|
||||
? { action: "single", audioRelIndex: 0, reason: "single-audio" }
|
||||
: { action: "remux", audioRelIndex: 0, reason: "first-audio" };
|
||||
}
|
||||
// tag mode
|
||||
const germanPos = streams.findIndex(isGermanStream);
|
||||
if (germanPos >= 0) {
|
||||
return total === 1
|
||||
? { action: "single", audioRelIndex: 0, reason: "single-german" }
|
||||
: { action: "remux", audioRelIndex: germanPos, reason: "german-tag" };
|
||||
}
|
||||
const anyTagged = streams.some((s) => (s.language || "").trim().length > 0);
|
||||
if (!anyTagged) {
|
||||
// No language metadata at all -> fall back to the script's behavior.
|
||||
return total === 1
|
||||
? { action: "single", audioRelIndex: 0, reason: "single-untagged" }
|
||||
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" };
|
||||
}
|
||||
if (germanRelease) {
|
||||
// Tagged, no German track found, but the release name explicitly says German
|
||||
// -> the dub is mislabeled (German audio tagged "eng"). Trust the German-first
|
||||
// scene convention rather than skipping.
|
||||
return total === 1
|
||||
? { action: "single", audioRelIndex: 0, reason: "single-german-mislabeled" }
|
||||
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" };
|
||||
}
|
||||
// Tagged, no German track, and nothing says German -> never guess-delete.
|
||||
return { action: "skip", reason: "no-german-track" };
|
||||
}
|
||||
|
||||
export function parseFfprobeAudioStreams(jsonText: string): ProbedAudioStream[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonText);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const streams = (parsed as { streams?: unknown }).streams;
|
||||
if (!Array.isArray(streams)) {
|
||||
return [];
|
||||
}
|
||||
return streams.map((raw) => {
|
||||
const tags = (raw && typeof raw === "object" ? (raw as { tags?: unknown }).tags : undefined) as
|
||||
| { language?: unknown; title?: unknown }
|
||||
| undefined;
|
||||
return {
|
||||
language: typeof tags?.language === "string" ? tags.language : "",
|
||||
title: typeof tags?.title === "string" ? tags.title : ""
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFfprobeArgs(input: string): string[] {
|
||||
return [
|
||||
"-v", "error",
|
||||
"-select_streams", "a",
|
||||
"-show_entries", "stream=index:stream_tags=language,title",
|
||||
"-of", "json",
|
||||
input
|
||||
];
|
||||
}
|
||||
|
||||
export function buildFfmpegRemuxArgs(opts: { input: string; output: string; audioRelIndex: number; keepSubs?: boolean }): string[] {
|
||||
const args = ["-i", opts.input, "-map", "0:v:0", "-map", `0:a:${opts.audioRelIndex}`];
|
||||
if (opts.keepSubs) {
|
||||
// Optional (not enabled by current settings): keep German subtitle tracks only.
|
||||
args.push("-map", "0:s:m:language:ger?", "-map", "0:s:m:language:deu?");
|
||||
}
|
||||
// Stream-copy and keep metadata (so the kept track's language tag survives;
|
||||
// unlike the original script's -map_metadata -1 which dropped it).
|
||||
args.push("-c", "copy", "-disposition:a:0", "default", "-y", opts.output);
|
||||
return args;
|
||||
}
|
||||
|
||||
// Stream-copy remux is disk-bound; generous budget scaled by size, clamped.
|
||||
export function computeRemuxTimeoutMs(bytes: number): number {
|
||||
const perBytes = Math.ceil((Number(bytes) || 0) / (10 * 1024 * 1024)) * 1000;
|
||||
return Math.max(120_000, Math.min(60 * 60 * 1000, 120_000 + perBytes));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tooling discovery (system PATH + RD_FFMPEG_BIN/RD_FFPROBE_BIN env override).
|
||||
// Lazy probe + cache, mirroring the extractor's 7z/Java resolution convention.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface VideoTooling {
|
||||
ffmpeg: string;
|
||||
ffprobe: string;
|
||||
}
|
||||
|
||||
let cachedTooling: VideoTooling | null | undefined;
|
||||
let cachedToolingNullSince = 0;
|
||||
const TOOLING_NULL_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function ffmpegCandidate(): string {
|
||||
return String(process.env.RD_FFMPEG_BIN || "").trim() || "ffmpeg";
|
||||
}
|
||||
|
||||
function ffprobeCandidate(): string {
|
||||
return String(process.env.RD_FFPROBE_BIN || "").trim() || "ffprobe";
|
||||
}
|
||||
|
||||
async function probeVersion(command: string): Promise<boolean> {
|
||||
const result = await runVideoProcess(command, ["-version"], { timeoutMs: 10_000 });
|
||||
return result.ok && !result.missing;
|
||||
}
|
||||
|
||||
export async function resolveVideoTooling(): Promise<VideoTooling | null> {
|
||||
if (cachedTooling) {
|
||||
return cachedTooling;
|
||||
}
|
||||
if (cachedTooling === null && Date.now() - cachedToolingNullSince < TOOLING_NULL_TTL_MS) {
|
||||
return null;
|
||||
}
|
||||
const ffmpeg = ffmpegCandidate();
|
||||
const ffprobe = ffprobeCandidate();
|
||||
const [ffmpegOk, ffprobeOk] = await Promise.all([probeVersion(ffmpeg), probeVersion(ffprobe)]);
|
||||
if (ffmpegOk && ffprobeOk) {
|
||||
cachedTooling = { ffmpeg, ffprobe };
|
||||
return cachedTooling;
|
||||
}
|
||||
cachedTooling = null;
|
||||
cachedToolingNullSince = Date.now();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resetVideoToolingCache(): void {
|
||||
cachedTooling = undefined;
|
||||
cachedToolingNullSince = 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Process spawning (ffmpeg/ffprobe). ffmpeg/ffprobe exit conventions: 0 = ok,
|
||||
// anything else = real failure (NOT 7-Zip's "exit 1 = warning" semantics).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VideoSpawnResult {
|
||||
ok: boolean;
|
||||
aborted: boolean;
|
||||
timedOut: boolean;
|
||||
missing: boolean;
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function appendCapped(buffer: string, text: string, cap: number): string {
|
||||
const next = buffer + text;
|
||||
return next.length > cap ? next.slice(next.length - cap) : next;
|
||||
}
|
||||
|
||||
function applyChildPriority(pid: number | undefined, cpuPriority?: string): void {
|
||||
if (process.platform !== "win32") {
|
||||
return;
|
||||
}
|
||||
const numeric = Number(pid || 0);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const level = cpuPriority === "high" ? os.constants.priority.PRIORITY_NORMAL : os.constants.priority.PRIORITY_BELOW_NORMAL;
|
||||
os.setPriority(numeric, level);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function killChildTree(child: { pid?: number; kill: () => void }): void {
|
||||
const pid = Number(child.pid || 0);
|
||||
if (process.platform === "win32" && Number.isFinite(pid) && pid > 0) {
|
||||
try {
|
||||
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
|
||||
killer.on("error", () => { try { child.kill(); } catch {} });
|
||||
return;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function runVideoProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: { signal?: AbortSignal; timeoutMs?: number; cpuPriority?: string } = {}
|
||||
): Promise<VideoSpawnResult> {
|
||||
const { signal, timeoutMs, cpuPriority } = opts;
|
||||
if (signal?.aborted) {
|
||||
return Promise.resolve({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: null, stdout: "", stderr: "" });
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
let aborted = false;
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
const child = spawn(command, args, { windowsHide: true });
|
||||
applyChildPriority(child.pid, cpuPriority);
|
||||
|
||||
const onAbort = (): void => {
|
||||
aborted = true;
|
||||
killChildTree(child);
|
||||
};
|
||||
|
||||
const finish = (result: VideoSpawnResult): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
if (timeoutMs && timeoutMs > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killChildTree(child);
|
||||
finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: null, stdout, stderr });
|
||||
}, timeoutMs);
|
||||
}
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (chunk) => { stdout = appendCapped(stdout, String(chunk || ""), STDOUT_CAP); });
|
||||
child.stderr?.on("data", (chunk) => { stderr = appendCapped(stderr, String(chunk || ""), STDERR_CAP); });
|
||||
|
||||
child.on("error", (error) => {
|
||||
const text = String(error || "");
|
||||
finish({ ok: false, aborted: false, timedOut: false, missing: text.toLowerCase().includes("enoent"), exitCode: null, stdout, stderr: stderr || text });
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (aborted) {
|
||||
finish({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: code, stdout, stderr });
|
||||
return;
|
||||
}
|
||||
if (timedOut) {
|
||||
finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: code, stdout, stderr });
|
||||
return;
|
||||
}
|
||||
finish({ ok: code === 0, aborted: false, timedOut: false, missing: false, exitCode: code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-file orchestration: probe -> decide -> (disk check) -> remux -> atomic
|
||||
// replace -> preserve mtime. Operates IN PLACE (same filename); the .DL. rename
|
||||
// + companion handling + logging is done by the caller (download-manager).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getFreeSpaceBytes(dir: string): Promise<number | null> {
|
||||
try {
|
||||
const stat = await fs.promises.statfs(dir);
|
||||
return Number(stat.bavail) * Number(stat.bsize);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const RENAME_RETRY_DELAYS_MS = [200, 500, 1000];
|
||||
const RENAME_RETRYABLE_CODES = new Set(["EBUSY", "EACCES", "EPERM", "EEXIST"]);
|
||||
|
||||
function delayMs(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Windows file locks from antivirus, the search indexer, or a media scanner are
|
||||
// transient: a rename that hits EBUSY/EACCES/EPERM/EEXIST often succeeds a moment
|
||||
// later. Retry with backoff before giving up so a momentary lock doesn't abort
|
||||
// the atomic replace and leave the file unprocessed.
|
||||
export async function renameWithRetry(from: string, to: string): Promise<void> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
await fs.promises.rename(from, to);
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
if (!code || !RENAME_RETRYABLE_CODES.has(code) || attempt >= RENAME_RETRY_DELAYS_MS.length) {
|
||||
throw error;
|
||||
}
|
||||
await delayMs(RENAME_RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Short, unique, same-directory sidecar name (never longer than the original file
|
||||
// name) so concurrent packages / retries never collide on a fixed temp name and a
|
||||
// long scene filename + suffix cannot push the path past Windows MAX_PATH.
|
||||
function uniqueTempPath(filePath: string): string {
|
||||
const ext = path.extname(filePath);
|
||||
const token = `${process.pid.toString(36)}${crypto.randomBytes(3).toString("hex")}`;
|
||||
return path.join(path.dirname(filePath), `~rd${token}${ext}`);
|
||||
}
|
||||
|
||||
export async function processVideoFile(filePath: string, opts: ProcessVideoOptions, deps: ProcessVideoDeps = {}): Promise<VideoProcessResult> {
|
||||
const resolveTool = deps.resolveTooling || resolveVideoTooling;
|
||||
const run = deps.runProcess || runVideoProcess;
|
||||
if (opts.signal?.aborted) {
|
||||
return { action: "aborted", reason: "aborted" };
|
||||
}
|
||||
const tooling = await resolveTool();
|
||||
if (!tooling) {
|
||||
return { action: "skipped-no-tool", reason: "ffmpeg/ffprobe nicht gefunden (PATH oder RD_FFMPEG_BIN)" };
|
||||
}
|
||||
|
||||
const probe = await run(tooling.ffprobe, buildFfprobeArgs(filePath), { signal: opts.signal, timeoutMs: PROBE_TIMEOUT_MS });
|
||||
if (probe.aborted) {
|
||||
return { action: "aborted", reason: "aborted" };
|
||||
}
|
||||
if (!probe.ok) {
|
||||
return { action: "error", reason: "ffprobe fehlgeschlagen", error: probe.stderr || `exit ${String(probe.exitCode)}` };
|
||||
}
|
||||
|
||||
const streams = parseFfprobeAudioStreams(probe.stdout);
|
||||
const audioLanguages = streams.map((s) => (s.language || "").trim() || "und");
|
||||
const decision = pickAudioTrack(streams, opts.mode, looksLikeGermanRelease(path.basename(filePath)));
|
||||
if (decision.action === "skip") {
|
||||
return {
|
||||
action: decision.reason === "no-german-track" ? "skipped-no-german" : "skipped-no-audio",
|
||||
reason: decision.reason,
|
||||
totalAudioTracks: streams.length,
|
||||
audioLanguages
|
||||
};
|
||||
}
|
||||
if (decision.action === "single") {
|
||||
return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: 0 };
|
||||
}
|
||||
|
||||
// remux path
|
||||
let originalStat: fs.Stats;
|
||||
try {
|
||||
originalStat = await fs.promises.stat(filePath);
|
||||
} catch (error) {
|
||||
return { action: "error", reason: "stat fehlgeschlagen", error: String(error), audioLanguages };
|
||||
}
|
||||
const free = await getFreeSpaceBytes(path.dirname(filePath));
|
||||
if (free !== null && free < Math.ceil(originalStat.size * 1.05)) {
|
||||
return { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux", totalAudioTracks: streams.length, audioLanguages };
|
||||
}
|
||||
|
||||
const tempPath = uniqueTempPath(filePath);
|
||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||
|
||||
const remux = await run(
|
||||
tooling.ffmpeg,
|
||||
buildFfmpegRemuxArgs({ input: filePath, output: tempPath, audioRelIndex: decision.audioRelIndex, keepSubs: false }),
|
||||
{ signal: opts.signal, timeoutMs: computeRemuxTimeoutMs(originalStat.size), cpuPriority: opts.cpuPriority }
|
||||
);
|
||||
if (remux.aborted) {
|
||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||
return { action: "aborted", reason: "aborted" };
|
||||
}
|
||||
if (!remux.ok) {
|
||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||
return { action: "error", reason: "ffmpeg remux fehlgeschlagen", error: remux.stderr || `exit ${String(remux.exitCode)}`, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: decision.audioRelIndex };
|
||||
}
|
||||
|
||||
const tempStat = await fs.promises.stat(tempPath).catch(() => null);
|
||||
if (!tempStat || tempStat.size <= 0) {
|
||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||
return { action: "error", reason: "Remux ergab leere Datei", totalAudioTracks: streams.length, audioLanguages };
|
||||
}
|
||||
|
||||
const renameOp = deps.rename || renameWithRetry;
|
||||
try {
|
||||
// Atomic replace-over: libuv maps fs.rename to MoveFileEx(REPLACE_EXISTING) on
|
||||
// Windows and rename(2) on POSIX, both atomic on the same volume, so filePath
|
||||
// holds either the full original or the full remux at every instant. Retried
|
||||
// for transient locks. We must NEVER rm the original first (the old fallback
|
||||
// did): an rm-then-failed-rename left zero copies of the file on disk.
|
||||
await renameOp(tempPath, filePath);
|
||||
// Preserve original mtime so freshness gates (hybrid collect) don't skip it.
|
||||
await fs.promises.utimes(filePath, originalStat.atime, originalStat.mtime).catch(() => {});
|
||||
} catch (error) {
|
||||
// Replace failed -> the original is untouched at filePath. Drop the temp only.
|
||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||
return { action: "error", reason: "Ersetzen der Datei fehlgeschlagen", error: String(error), totalAudioTracks: streams.length, audioLanguages };
|
||||
}
|
||||
|
||||
return { action: "remuxed", reason: decision.reason, keptTrackIndex: decision.audioRelIndex, totalAudioTracks: streams.length, audioLanguages };
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import fs from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export interface WindowsHostEvent {
|
||||
timeCreated: string;
|
||||
id: number;
|
||||
providerName: string;
|
||||
levelDisplayName: string;
|
||||
message: string;
|
||||
bugcheckCode?: string;
|
||||
bugcheckCodeHex?: string;
|
||||
reportId?: string;
|
||||
}
|
||||
|
||||
export interface WindowsHostDumpFile {
|
||||
name: string;
|
||||
fullName: string;
|
||||
length: number;
|
||||
lastWriteTime: string;
|
||||
}
|
||||
|
||||
export interface WindowsCrashControlInfo {
|
||||
crashDumpEnabled: number | null;
|
||||
minidumpDir: string;
|
||||
dumpFile: string;
|
||||
overwrite: number | null;
|
||||
logEvent: number | null;
|
||||
autoReboot: number | null;
|
||||
}
|
||||
|
||||
export interface WindowsHostDiagnostics {
|
||||
collectedAt: string;
|
||||
supported: boolean;
|
||||
platform: string;
|
||||
crashControl: WindowsCrashControlInfo | null;
|
||||
recentKernelPower: WindowsHostEvent[];
|
||||
recentWerKernel: WindowsHostEvent[];
|
||||
recentKernelDump: WindowsHostEvent[];
|
||||
recentAppCrashes: WindowsHostEvent[];
|
||||
recentMinidumps: WindowsHostDumpFile[];
|
||||
assessmentHints: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 15_000;
|
||||
|
||||
let cachedAt = 0;
|
||||
let cachedValue: WindowsHostDiagnostics | null = null;
|
||||
|
||||
function createEmptyDiagnostics(): WindowsHostDiagnostics {
|
||||
return {
|
||||
collectedAt: new Date().toISOString(),
|
||||
supported: process.platform === "win32",
|
||||
platform: process.platform,
|
||||
crashControl: null,
|
||||
recentKernelPower: [],
|
||||
recentWerKernel: [],
|
||||
recentKernelDump: [],
|
||||
recentAppCrashes: [],
|
||||
recentMinidumps: [],
|
||||
assessmentHints: [],
|
||||
errors: []
|
||||
};
|
||||
}
|
||||
|
||||
function runPowerShellJson(script: string): unknown {
|
||||
const result = spawnSync(
|
||||
process.env.ComSpec && process.env.ComSpec.toLowerCase().includes("pwsh") ? process.env.ComSpec : "powershell.exe",
|
||||
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 20_000,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
}
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
const errorText = String(result.stderr || result.stdout || "").trim() || `PowerShell exited with code ${result.status}`;
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
const text = String(result.stdout || "").trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(text) as unknown;
|
||||
}
|
||||
|
||||
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 asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeEvent(value: unknown): WindowsHostEvent | null {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
timeCreated: asString(record.TimeCreated),
|
||||
id: asNumber(record.Id) || 0,
|
||||
providerName: asString(record.ProviderName),
|
||||
levelDisplayName: asString(record.LevelDisplayName),
|
||||
message: asString(record.Message),
|
||||
bugcheckCode: asString(record.BugcheckCode),
|
||||
bugcheckCodeHex: asString(record.BugcheckCodeHex),
|
||||
reportId: asString(record.ReportId)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDumpFile(value: unknown): WindowsHostDumpFile | null {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: asString(record.Name),
|
||||
fullName: asString(record.FullName),
|
||||
length: asNumber(record.Length) || 0,
|
||||
lastWriteTime: asString(record.LastWriteTime)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCrashControl(value: unknown): WindowsCrashControlInfo | null {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
crashDumpEnabled: asNumber(record.CrashDumpEnabled),
|
||||
minidumpDir: asString(record.MinidumpDir),
|
||||
dumpFile: asString(record.DumpFile),
|
||||
overwrite: asNumber(record.Overwrite),
|
||||
logEvent: asNumber(record.LogEvent),
|
||||
autoReboot: asNumber(record.AutoReboot)
|
||||
};
|
||||
}
|
||||
|
||||
function pushHints(diagnostics: WindowsHostDiagnostics): void {
|
||||
if (diagnostics.recentKernelPower.some((entry) => String(entry.bugcheckCode || "").trim() === "0")) {
|
||||
diagnostics.assessmentHints.push("Kernel-Power 41 mit BugcheckCode 0 deutet eher auf Freeze, Watchdog oder harten Reset als auf einen sauber erfassten klassischen BSOD hin.");
|
||||
}
|
||||
if (diagnostics.recentWerKernel.some((entry) => /watchdog/i.test(entry.message))) {
|
||||
diagnostics.assessmentHints.push("WER-Kernel meldet WATCHDOG-Live-Dumps. Das spricht eher fuer Kernel-, Treiber- oder Hardware-Stalls als fuer einen normalen User-Mode-App-Crash.");
|
||||
}
|
||||
if (diagnostics.recentAppCrashes.length === 0) {
|
||||
diagnostics.assessmentHints.push("Keine passenden Application-Error- oder Windows-Error-Reporting-Eintraege fuer den Downloader/Electron in den letzten Tagen gefunden.");
|
||||
}
|
||||
if (diagnostics.recentMinidumps.length === 0) {
|
||||
diagnostics.assessmentHints.push("Keine aktuellen Minidumps gefunden. Falls der Server erneut abstuerzt, sollte geprueft werden, ob Windows den Dump wirklich schreiben darf.");
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromPowerShell(): WindowsHostDiagnostics {
|
||||
const script = String.raw`
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
function Convert-EventRecord($eventRecord) {
|
||||
$map = @{}
|
||||
try {
|
||||
[xml]$xml = $eventRecord.ToXml()
|
||||
foreach ($node in $xml.Event.EventData.Data) {
|
||||
if ($node.Name) {
|
||||
$map[$node.Name] = [string]$node.'#text'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
$reportId = ""
|
||||
if ([string]$eventRecord.Message -match "ReportId\s+([^,\r\n]+)") {
|
||||
$reportId = $Matches[1]
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
TimeCreated = if ($eventRecord.TimeCreated) { $eventRecord.TimeCreated.ToUniversalTime().ToString("o") } else { "" }
|
||||
Id = [int]$eventRecord.Id
|
||||
ProviderName = [string]$eventRecord.ProviderName
|
||||
LevelDisplayName = [string]$eventRecord.LevelDisplayName
|
||||
Message = [string]$eventRecord.Message
|
||||
BugcheckCode = if ($map.ContainsKey("BugcheckCode")) { [string]$map["BugcheckCode"] } else { "" }
|
||||
BugcheckCodeHex = if ($map.ContainsKey("BugcheckCode") -and [int64]$map["BugcheckCode"] -gt 0) { ("0x{0:X}" -f [int64]$map["BugcheckCode"]) } else { "" }
|
||||
ReportId = $reportId
|
||||
}
|
||||
}
|
||||
|
||||
$startTime = (Get-Date).AddDays(-7)
|
||||
$crashControl = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl"
|
||||
|
||||
$kernelPower = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "System"; Id = 41; StartTime = $startTime } -MaxEvents 5 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
)
|
||||
|
||||
$werKernel = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-WerKernel/Operational"; StartTime = $startTime } -MaxEvents 30 |
|
||||
Where-Object { $_.Message -match "WATCHDOG|dump|bugcheck|blue|memory" } |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
)
|
||||
|
||||
$kernelDump = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-Kernel-Dump/Operational"; StartTime = $startTime } -MaxEvents 20 |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
)
|
||||
|
||||
$appCrashes = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 |
|
||||
Where-Object {
|
||||
($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and
|
||||
($_.Message -match "Real-Debrid-Downloader|electron|node\.exe|main\.js")
|
||||
} |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
)
|
||||
|
||||
$dumpFiles = @()
|
||||
foreach ($dir in @("C:\Windows\Minidump", "C:\Windows\Minidumps")) {
|
||||
if (Test-Path $dir) {
|
||||
$dumpFiles += Get-ChildItem -Path $dir -File |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
Name = $_.Name
|
||||
FullName = $_.FullName
|
||||
Length = [int64]$_.Length
|
||||
LastWriteTime = $_.LastWriteTimeUtc.ToString("o")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
CrashControl = [PSCustomObject]@{
|
||||
CrashDumpEnabled = if ($null -ne $crashControl.CrashDumpEnabled) { [int]$crashControl.CrashDumpEnabled } else { $null }
|
||||
MinidumpDir = [string]$crashControl.MinidumpDir
|
||||
DumpFile = [string]$crashControl.DumpFile
|
||||
Overwrite = if ($null -ne $crashControl.Overwrite) { [int]$crashControl.Overwrite } else { $null }
|
||||
LogEvent = if ($null -ne $crashControl.LogEvent) { [int]$crashControl.LogEvent } else { $null }
|
||||
AutoReboot = if ($null -ne $crashControl.AutoReboot) { [int]$crashControl.AutoReboot } else { $null }
|
||||
}
|
||||
RecentKernelPower = @($kernelPower)
|
||||
RecentWerKernel = @($werKernel)
|
||||
RecentKernelDump = @($kernelDump)
|
||||
RecentAppCrashes = @($appCrashes)
|
||||
RecentMinidumps = @($dumpFiles)
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
`;
|
||||
|
||||
const raw = runPowerShellJson(script);
|
||||
const parsed = asRecord(raw);
|
||||
const diagnostics = createEmptyDiagnostics();
|
||||
diagnostics.crashControl = normalizeCrashControl(parsed?.CrashControl ?? null);
|
||||
diagnostics.recentKernelPower = Array.isArray(parsed?.RecentKernelPower) ? parsed!.RecentKernelPower.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
||||
diagnostics.recentWerKernel = Array.isArray(parsed?.RecentWerKernel) ? parsed!.RecentWerKernel.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
||||
diagnostics.recentKernelDump = Array.isArray(parsed?.RecentKernelDump) ? parsed!.RecentKernelDump.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
||||
diagnostics.recentAppCrashes = Array.isArray(parsed?.RecentAppCrashes) ? parsed!.RecentAppCrashes.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
||||
diagnostics.recentMinidumps = Array.isArray(parsed?.RecentMinidumps) ? parsed!.RecentMinidumps.map(normalizeDumpFile).filter(Boolean) as WindowsHostDumpFile[] : [];
|
||||
diagnostics.collectedAt = new Date().toISOString();
|
||||
pushHints(diagnostics);
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function getWindowsHostDiagnostics(forceRefresh = false): WindowsHostDiagnostics {
|
||||
if (!forceRefresh && cachedValue && Date.now() - cachedAt < CACHE_TTL_MS) {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
const diagnostics = createEmptyDiagnostics();
|
||||
if (process.platform !== "win32") {
|
||||
diagnostics.assessmentHints.push("Windows-Host-Diagnose ist nur unter Windows verfuegbar.");
|
||||
cachedAt = Date.now();
|
||||
cachedValue = diagnostics;
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
try {
|
||||
const loaded = loadFromPowerShell();
|
||||
cachedAt = Date.now();
|
||||
cachedValue = loaded;
|
||||
return loaded;
|
||||
} catch (error) {
|
||||
diagnostics.errors.push(String(error instanceof Error ? error.message : error));
|
||||
diagnostics.assessmentHints.push("Host-Diagnose konnte nicht vollstaendig geladen werden.");
|
||||
cachedAt = Date.now();
|
||||
cachedValue = diagnostics;
|
||||
return diagnostics;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedWindowsHostDiagnostics(): WindowsHostDiagnostics | null {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
export function resetWindowsHostDiagnosticsCache(): void {
|
||||
cachedAt = 0;
|
||||
cachedValue = null;
|
||||
}
|
||||
|
||||
export function hasRecentWindowsMinidumps(): boolean {
|
||||
for (const dir of ["C:\\Windows\\Minidump", "C:\\Windows\\Minidumps"]) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
if (entries.some((entry) => entry.isFile())) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import {
|
||||
AddLinksPayload,
|
||||
AllDebridHostInfo,
|
||||
AppSettings,
|
||||
DebridAccountStatus,
|
||||
DebridLinkHostLimitInfo,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
EnableRemoteDiagnosticsInput,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
RemoteDiagnosticsInfo,
|
||||
RendererErrorReport,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
UiSnapshot,
|
||||
UpdateCheckResult,
|
||||
UpdateInstallProgress
|
||||
} from "../shared/types";
|
||||
import { IPC_CHANNELS } from "../shared/ipc";
|
||||
import { ElectronApi } from "../shared/preload-api";
|
||||
|
||||
const api: ElectronApi = {
|
||||
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
|
||||
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
|
||||
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
|
||||
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
|
||||
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
|
||||
resetProviderDailyUsage: (provider: DebridProvider): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider),
|
||||
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId),
|
||||
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
|
||||
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths),
|
||||
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
|
||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy),
|
||||
clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
|
||||
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
|
||||
startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds),
|
||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
|
||||
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
|
||||
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
|
||||
renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName),
|
||||
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
|
||||
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
|
||||
togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId),
|
||||
exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds),
|
||||
exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds),
|
||||
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
|
||||
importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
|
||||
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
||||
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
|
||||
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
|
||||
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
|
||||
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
|
||||
importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
|
||||
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
||||
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||
openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG),
|
||||
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
||||
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
|
||||
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
|
||||
openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId),
|
||||
getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK),
|
||||
getRecentErrors: () => ipcRenderer.invoke(IPC_CHANNELS.GET_RECENT_ERRORS),
|
||||
testNotification: (url: string, mention: string) => ipcRenderer.invoke(IPC_CHANNELS.TEST_NOTIFY, url, mention),
|
||||
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
|
||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
|
||||
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
|
||||
getRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS),
|
||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input),
|
||||
disableRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS),
|
||||
rotateRemoteDiagnosticsToken: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN),
|
||||
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
||||
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
||||
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
|
||||
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
|
||||
checkDebridAccounts: (): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS),
|
||||
checkMegaDebridAccount: (login: string, password: string): Promise<DebridAccountStatus | null> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password),
|
||||
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, 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),
|
||||
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
|
||||
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
|
||||
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
|
||||
setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority),
|
||||
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
|
||||
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
|
||||
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),
|
||||
reportRendererError: (report: RendererErrorReport): void => ipcRenderer.send(IPC_CHANNELS.LOG_RENDERER_ERROR, report),
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
||||
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||
};
|
||||
},
|
||||
onClipboardDetected: (callback: (links: string[]) => void): (() => void) => {
|
||||
const listener = (_event: unknown, links: string[]): void => callback(links);
|
||||
ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
||||
};
|
||||
},
|
||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => {
|
||||
const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress);
|
||||
ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("rd", api);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
import React from "react";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Catches render-time errors in the component tree so a crash shows a minimal
|
||||
// recovery surface instead of a silent white screen, and forwards the error to
|
||||
// the main process log. Kept deliberately dead-simple and state-independent: an
|
||||
// error inside the error path is how you get a second white screen or a loop.
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, message: "" };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
|
||||
return { hasError: true, message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, info: React.ErrorInfo): void {
|
||||
try {
|
||||
window.rd?.reportRendererError({
|
||||
kind: "react",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
componentStack: info?.componentStack || undefined
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
private handleReload = (): void => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (!this.state.hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
const overlay: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
padding: 32,
|
||||
background: "#070b14",
|
||||
color: "#e6edf6",
|
||||
fontFamily: "Segoe UI, system-ui, sans-serif",
|
||||
textAlign: "center"
|
||||
};
|
||||
const pre: React.CSSProperties = {
|
||||
maxWidth: 640,
|
||||
maxHeight: 200,
|
||||
overflow: "auto",
|
||||
padding: 12,
|
||||
background: "#0d1422",
|
||||
border: "1px solid #243049",
|
||||
borderRadius: 6,
|
||||
color: "#ff9a8c",
|
||||
fontSize: 12,
|
||||
whiteSpace: "pre-wrap",
|
||||
textAlign: "left"
|
||||
};
|
||||
const button: React.CSSProperties = {
|
||||
padding: "8px 20px",
|
||||
background: "#2d5cff",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
fontSize: 14
|
||||
};
|
||||
return (
|
||||
<div style={overlay}>
|
||||
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1>
|
||||
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}>
|
||||
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
|
||||
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
|
||||
</p>
|
||||
<pre style={pre}>{this.state.message}</pre>
|
||||
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Multi Debrid Downloader</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { ErrorBoundary } from "./error-boundary";
|
||||
import "./styles.css";
|
||||
|
||||
// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise
|
||||
// rejections) to the main process log. Without this, a renderer crash leaves no
|
||||
// trace anywhere on an unattended server.
|
||||
function reportRendererError(report: Parameters<typeof window.rd.reportRendererError>[0]): void {
|
||||
try {
|
||||
window.rd?.reportRendererError(report);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
reportRendererError({
|
||||
kind: "error",
|
||||
message: event.message || String(event.error || "Unbekannter Fehler"),
|
||||
stack: event.error instanceof Error ? event.error.stack : undefined,
|
||||
source: event.filename || undefined,
|
||||
line: typeof event.lineno === "number" ? event.lineno : undefined,
|
||||
column: typeof event.colno === "number" ? event.colno : undefined
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event.reason;
|
||||
reportRendererError({
|
||||
kind: "unhandledrejection",
|
||||
message: reason instanceof Error ? reason.message : String(reason),
|
||||
stack: reason instanceof Error ? reason.stack : undefined
|
||||
});
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element fehlt");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types";
|
||||
|
||||
const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["downloading", "validating", "integrity_check", "extracting"]);
|
||||
|
||||
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
||||
const fromIndex = order.indexOf(draggedPackageId);
|
||||
const toIndex = order.indexOf(targetPackageId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||||
return order;
|
||||
}
|
||||
const next = [...order];
|
||||
const [dragged] = next.splice(fromIndex, 1);
|
||||
const insertIndex = Math.max(0, Math.min(next.length, toIndex));
|
||||
next.splice(insertIndex, 0, dragged);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function sortPackageOrderByName(order: string[], packages: Record<string, PackageEntry>, descending: boolean): string[] {
|
||||
const sorted = [...order];
|
||||
sorted.sort((a, b) => {
|
||||
const nameA = (packages[a]?.name ?? "").toLowerCase();
|
||||
const nameB = (packages[b]?.name ?? "").toLowerCase();
|
||||
const cmp = nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: "base" });
|
||||
return descending ? -cmp : cmp;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function sortPackagesForDisplay(
|
||||
packages: PackageEntry[],
|
||||
itemsById: Record<string, DownloadItem>,
|
||||
running: boolean,
|
||||
autoSortPackagesByProgress: boolean
|
||||
): PackageEntry[] {
|
||||
if (!running || !autoSortPackagesByProgress || packages.length <= 1) {
|
||||
return packages;
|
||||
}
|
||||
|
||||
const active: PackageEntry[] = [];
|
||||
const rest: PackageEntry[] = [];
|
||||
|
||||
// Float packages that have an active item to the top, but keep BOTH groups in
|
||||
// their original (queue) order. Earlier this sorted the active group by live
|
||||
// completedRatio/downloadedBytes — which change on every progress tick (every
|
||||
// 150-700ms), so active packages visibly reshuffled the whole time. A package
|
||||
// entering/leaving the active bucket is a real, discrete event (start/finish);
|
||||
// ranking *within* the bucket by live bytes was pure jitter nobody needs.
|
||||
for (const pkg of packages) {
|
||||
const hasActive = pkg.itemIds.some((id) => {
|
||||
const item = itemsById[id];
|
||||
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
|
||||
});
|
||||
(hasActive ? active : rest).push(pkg);
|
||||
}
|
||||
|
||||
if (active.length === 0 || active.length === packages.length) {
|
||||
return packages;
|
||||
}
|
||||
|
||||
return [...active, ...rest];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SessionState } from "../shared/types";
|
||||
|
||||
/**
|
||||
* Drop selected ids whose package OR item no longer exists in the session.
|
||||
* The selection set mixes package and item ids; when entries vanish (delta
|
||||
* removal, backup-driven session swap, completed-cleanup) a stale id would
|
||||
* otherwise inflate the selection count and the "(N)" action labels and keep
|
||||
* "multi" styling alive for ghosts.
|
||||
*
|
||||
* Returns the SAME set instance when nothing changed, so callers can use it
|
||||
* directly as a React state updater without forcing a re-render.
|
||||
*/
|
||||
export function pruneSelection(
|
||||
selected: ReadonlySet<string>,
|
||||
session: Pick<SessionState, "packages" | "items">
|
||||
): Set<string> {
|
||||
if (selected.size === 0) {
|
||||
return selected as Set<string>;
|
||||
}
|
||||
const next = new Set<string>();
|
||||
for (const id of selected) {
|
||||
if (session.packages[id] || session.items[id]) {
|
||||
next.add(id);
|
||||
}
|
||||
}
|
||||
return next.size === selected.size ? (selected as Set<string>) : next;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { ElectronApi } from "../shared/preload-api";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
rd: ElectronApi;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface DebridLinkApiKeyEntry {
|
||||
id: string;
|
||||
token: string;
|
||||
index: number;
|
||||
label: string;
|
||||
masked: string;
|
||||
}
|
||||
|
||||
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||
const FNV64_PRIME = 0x100000001b3n;
|
||||
const FNV64_MASK = 0xffffffffffffffffn;
|
||||
|
||||
function fnv1a64(text: string): string {
|
||||
let hash = FNV64_OFFSET_BASIS;
|
||||
for (const char of text) {
|
||||
hash ^= BigInt(char.codePointAt(0) || 0);
|
||||
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
export function maskDebridLinkApiKey(token: string): string {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) {
|
||||
return "Nicht hinterlegt";
|
||||
}
|
||||
if (trimmed.length <= 6) {
|
||||
return "*".repeat(trimmed.length);
|
||||
}
|
||||
return `${trimmed.slice(0, 3)}${"*".repeat(Math.max(4, trimmed.length - 6))}${trimmed.slice(-3)}`;
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyId(token: string): string {
|
||||
return `dlk_${fnv1a64(token.trim())}`;
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyLabel(index: number): string {
|
||||
return `Key ${index + 1}`;
|
||||
}
|
||||
|
||||
export function parseDebridLinkApiKeys(raw: string): DebridLinkApiKeyEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const tokens = String(raw || "")
|
||||
.split(/[\n,]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.filter((token) => {
|
||||
if (seen.has(token)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(token);
|
||||
return true;
|
||||
});
|
||||
|
||||
return tokens.map((token, index) => ({
|
||||
id: getDebridLinkApiKeyId(token),
|
||||
token,
|
||||
index,
|
||||
label: getDebridLinkApiKeyLabel(index),
|
||||
masked: maskDebridLinkApiKey(token)
|
||||
}));
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyIds(raw: string): string[] {
|
||||
return parseDebridLinkApiKeys(raw).map((entry) => entry.id);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export const IPC_CHANNELS = {
|
||||
GET_SNAPSHOT: "app:get-snapshot",
|
||||
GET_VERSION: "app:get-version",
|
||||
CHECK_UPDATES: "app:check-updates",
|
||||
INSTALL_UPDATE: "app:install-update",
|
||||
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
|
||||
OPEN_EXTERNAL: "app:open-external",
|
||||
UPDATE_SETTINGS: "app:update-settings",
|
||||
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
|
||||
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
|
||||
ADD_LINKS: "queue:add-links",
|
||||
ADD_CONTAINERS: "queue:add-containers",
|
||||
GET_START_CONFLICTS: "queue:get-start-conflicts",
|
||||
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
|
||||
CLEAR_ALL: "queue:clear-all",
|
||||
START: "queue:start",
|
||||
START_PACKAGES: "queue:start-packages",
|
||||
STOP: "queue:stop",
|
||||
TOGGLE_PAUSE: "queue:toggle-pause",
|
||||
CANCEL_PACKAGE: "queue:cancel-package",
|
||||
RENAME_PACKAGE: "queue:rename-package",
|
||||
REORDER_PACKAGES: "queue:reorder-packages",
|
||||
REMOVE_ITEM: "queue:remove-item",
|
||||
TOGGLE_PACKAGE: "queue:toggle-package",
|
||||
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
|
||||
EXPORT_ITEM_SELECTION: "queue:export-item-selection",
|
||||
EXPORT_QUEUE: "queue:export",
|
||||
IMPORT_QUEUE: "queue:import",
|
||||
PICK_FOLDER: "dialog:pick-folder",
|
||||
PICK_CONTAINERS: "dialog:pick-containers",
|
||||
STATE_UPDATE: "state:update",
|
||||
CLIPBOARD_DETECTED: "clipboard:detected",
|
||||
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
||||
GET_SESSION_STATS: "stats:get-session-stats",
|
||||
RESET_SESSION_STATS: "stats:reset-session",
|
||||
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
||||
RESTART: "app:restart",
|
||||
QUIT: "app:quit",
|
||||
EXPORT_BACKUP: "app:export-backup",
|
||||
IMPORT_BACKUP: "app:import-backup",
|
||||
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
|
||||
OPEN_LOG: "app:open-log",
|
||||
OPEN_AUDIT_LOG: "app:open-audit-log",
|
||||
OPEN_RENAME_LOG: "app:open-rename-log",
|
||||
OPEN_SESSION_LOG: "app:open-session-log",
|
||||
OPEN_TRACE_LOG: "app:open-trace-log",
|
||||
OPEN_PACKAGE_LOG: "app:open-package-log",
|
||||
OPEN_ITEM_LOG: "app:open-item-log",
|
||||
GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check",
|
||||
GET_RECENT_ERRORS: "app:get-recent-errors",
|
||||
TEST_NOTIFY: "app:test-notify",
|
||||
GET_TRACE_CONFIG: "app:get-trace-config",
|
||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
||||
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
||||
GET_REMOTE_DIAGNOSTICS: "app:get-remote-diagnostics",
|
||||
ENABLE_REMOTE_DIAGNOSTICS: "app:enable-remote-diagnostics",
|
||||
DISABLE_REMOTE_DIAGNOSTICS: "app:disable-remote-diagnostics",
|
||||
ROTATE_REMOTE_DIAGNOSTICS_TOKEN: "app:rotate-remote-diagnostics-token",
|
||||
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
||||
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
||||
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
||||
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
|
||||
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
|
||||
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
|
||||
CHECK_MEGA_DEBRID_ACCOUNT: "app:check-mega-debrid-account",
|
||||
RETRY_EXTRACTION: "queue:retry-extraction",
|
||||
EXTRACT_NOW: "queue:extract-now",
|
||||
RESET_PACKAGE: "queue:reset-package",
|
||||
GET_HISTORY: "history:get",
|
||||
CLEAR_HISTORY: "history:clear",
|
||||
REMOVE_HISTORY_ENTRY: "history:remove-entry",
|
||||
SET_PACKAGE_PRIORITY: "queue:set-package-priority",
|
||||
SKIP_ITEMS: "queue:skip-items",
|
||||
RESET_ITEMS: "queue:reset-items",
|
||||
START_ITEMS: "queue:start-items",
|
||||
LOG_RENDERER_ERROR: "log:renderer-error"
|
||||
} as const;
|
||||
@@ -0,0 +1,90 @@
|
||||
export interface MegaDebridAccountEntry {
|
||||
id: string;
|
||||
login: string;
|
||||
password: string;
|
||||
index: number;
|
||||
label: string;
|
||||
maskedLogin: string;
|
||||
}
|
||||
|
||||
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||
const FNV64_PRIME = 0x100000001b3n;
|
||||
const FNV64_MASK = 0xffffffffffffffffn;
|
||||
|
||||
function fnv1a64(text: string): string {
|
||||
let hash = FNV64_OFFSET_BASIS;
|
||||
for (const char of text) {
|
||||
hash ^= BigInt(char.codePointAt(0) || 0);
|
||||
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountId(login: string): string {
|
||||
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
|
||||
}
|
||||
|
||||
export function maskMegaDebridLogin(login: string): string {
|
||||
const trimmed = login.trim();
|
||||
if (!trimmed) {
|
||||
return "Nicht hinterlegt";
|
||||
}
|
||||
if (trimmed.length <= 4) {
|
||||
return `${trimmed[0]}${"*".repeat(trimmed.length - 1)}`;
|
||||
}
|
||||
return `${trimmed.slice(0, 2)}${"*".repeat(Math.max(3, trimmed.length - 4))}${trimmed.slice(-2)}`;
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountLabel(index: number): string {
|
||||
return `Account ${index + 1}`;
|
||||
}
|
||||
|
||||
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const lines = String(raw || "")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const entries: MegaDebridAccountEntry[] = [];
|
||||
for (const line of lines) {
|
||||
const colonIdx = line.indexOf(":");
|
||||
let login: string;
|
||||
let password: string;
|
||||
if (colonIdx >= 0) {
|
||||
login = line.slice(0, colonIdx).trim();
|
||||
password = line.slice(colonIdx + 1).trim();
|
||||
} else {
|
||||
login = line;
|
||||
password = legacyPassword;
|
||||
}
|
||||
if (!login || !password) {
|
||||
continue;
|
||||
}
|
||||
const key = login.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
entries.push({
|
||||
id: getMegaDebridAccountId(login),
|
||||
login,
|
||||
password,
|
||||
index: entries.length,
|
||||
label: getMegaDebridAccountLabel(entries.length),
|
||||
maskedLogin: maskMegaDebridLogin(login)
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function serializeMegaDebridAccounts(accounts: { login: string; password: string }[]): string {
|
||||
return accounts
|
||||
.filter((a) => a.login.trim() && a.password.trim())
|
||||
.map((a) => `${a.login.trim()}:${a.password.trim()}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] {
|
||||
return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function isMegaDebridResolveFailure(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return /supprim/.test(text)
|
||||
|| text.includes("introuvable")
|
||||
|| text.includes("n'existe plus")
|
||||
|| text.includes("n existe plus")
|
||||
|| text.includes("fichier inexistant");
|
||||
}
|
||||
|
||||
export function isMegaDebridTransientResolveFailure(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return isMegaDebridResolveFailure(text)
|
||||
|| text.includes("datei beim hoster gerade nicht abrufbar")
|
||||
|| text.includes("datei beim hoster nicht gefunden");
|
||||
}
|
||||
|
||||
export function germanMegaDebridResolveReason(errorText: string): string {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
if (text.includes("datei beim hoster nicht gefunden")
|
||||
|| text.includes("introuvable") || text.includes("fichier inexistant") || text.includes("n'existe plus") || text.includes("n existe plus")) {
|
||||
return "Datei beim Hoster nicht gefunden";
|
||||
}
|
||||
return "Datei beim Hoster gerade nicht abrufbar";
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
AddLinksPayload,
|
||||
AllDebridHostInfo,
|
||||
AppSettings,
|
||||
DebridAccountStatus,
|
||||
DebugSetupCheckResult,
|
||||
DebridLinkHostLimitInfo,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
EnableRemoteDiagnosticsInput,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
RemoteDiagnosticsInfo,
|
||||
RendererErrorReport,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
SupportTraceConfig,
|
||||
UiSnapshot,
|
||||
UpdateCheckResult,
|
||||
UpdateInstallProgress,
|
||||
UpdateInstallResult
|
||||
} from "./types";
|
||||
|
||||
export interface ElectronApi {
|
||||
getSnapshot: () => Promise<UiSnapshot>;
|
||||
getVersion: () => Promise<string>;
|
||||
checkUpdates: () => Promise<UpdateCheckResult>;
|
||||
installUpdate: () => Promise<UpdateInstallResult>;
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
|
||||
resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>;
|
||||
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>;
|
||||
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
|
||||
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||
getStartConflicts: () => Promise<StartConflictEntry[]>;
|
||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
|
||||
clearAll: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
startPackages: (packageIds: string[]) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
togglePause: () => Promise<boolean>;
|
||||
cancelPackage: (packageId: string) => Promise<void>;
|
||||
renamePackage: (packageId: string, newName: string) => Promise<void>;
|
||||
reorderPackages: (packageIds: string[]) => Promise<void>;
|
||||
removeItem: (itemId: string) => Promise<void>;
|
||||
togglePackage: (packageId: string) => Promise<void>;
|
||||
exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
|
||||
exportItemSelection: (itemIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
|
||||
exportQueue: () => Promise<{ saved: boolean }>;
|
||||
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||
toggleClipboard: () => Promise<boolean>;
|
||||
pickFolder: () => Promise<string | null>;
|
||||
pickContainers: () => Promise<string[]>;
|
||||
getSessionStats: () => Promise<SessionStats>;
|
||||
resetSessionStats: () => Promise<void>;
|
||||
resetDownloadStats: () => Promise<void>;
|
||||
restart: () => Promise<void>;
|
||||
quit: () => Promise<void>;
|
||||
exportBackup: () => Promise<{ saved: boolean }>;
|
||||
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
|
||||
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
||||
openLog: () => Promise<void>;
|
||||
openAuditLog: () => Promise<void>;
|
||||
openRenameLog: () => Promise<void>;
|
||||
openSessionLog: () => Promise<void>;
|
||||
openTraceLog: () => Promise<void>;
|
||||
openPackageLog: (packageId: string) => Promise<void>;
|
||||
openItemLog: (itemId: string) => Promise<void>;
|
||||
getDebugSetupCheck: () => Promise<DebugSetupCheckResult>;
|
||||
getRecentErrors: () => Promise<Array<{ ts: string; level: string; message: string }>>;
|
||||
testNotification: (url: string, mention: string) => Promise<boolean>;
|
||||
getTraceConfig: () => Promise<SupportTraceConfig>;
|
||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
|
||||
rotateDebugToken: () => Promise<{ path: string }>;
|
||||
getRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
|
||||
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
|
||||
openRealDebridLogin: () => Promise<void>;
|
||||
openAllDebridLogin: () => Promise<void>;
|
||||
importBestDebridCookies: () => Promise<number>;
|
||||
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
|
||||
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
|
||||
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
|
||||
checkMegaDebridAccount: (login: string, password: string) => Promise<DebridAccountStatus | null>;
|
||||
retryExtraction: (packageId: string) => Promise<void>;
|
||||
extractNow: (packageId: string) => Promise<void>;
|
||||
resetPackage: (packageId: string) => Promise<void>;
|
||||
getHistory: () => Promise<HistoryEntry[]>;
|
||||
clearHistory: () => Promise<void>;
|
||||
removeHistoryEntry: (entryId: string) => Promise<void>;
|
||||
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
|
||||
skipItems: (itemIds: string[]) => Promise<void>;
|
||||
resetItems: (itemIds: string[]) => Promise<void>;
|
||||
startItems: (itemIds: string[]) => Promise<void>;
|
||||
reportRendererError: (report: RendererErrorReport) => void;
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import type { AppSettings, DebridProvider } from "./types";
|
||||
|
||||
export type ProviderByteMap = Partial<Record<DebridProvider, number>>;
|
||||
export type DebridLinkKeyByteMap = Record<string, number>;
|
||||
|
||||
type ProviderDailySettings =
|
||||
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
|
||||
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
|
||||
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
|
||||
|
||||
type ProviderUsageSettings =
|
||||
ProviderDailySettings
|
||||
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
|
||||
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
|
||||
|
||||
function normalizePositiveBytes(value: unknown): number {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
export function getProviderUsageDayKey(epochMs = Date.now()): string {
|
||||
const current = new Date(epochMs);
|
||||
const year = current.getFullYear();
|
||||
const month = String(current.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(current.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function getProviderDailyLimitBytes(settings: ProviderDailySettings, provider: DebridProvider): number {
|
||||
return normalizePositiveBytes(settings.providerDailyLimitBytes?.[provider]);
|
||||
}
|
||||
|
||||
export function getProviderDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
provider: DebridProvider,
|
||||
epochMs = Date.now()
|
||||
): number {
|
||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||
return 0;
|
||||
}
|
||||
return normalizePositiveBytes(settings.providerDailyUsageBytes?.[provider]);
|
||||
}
|
||||
|
||||
export function getProviderDailyRemainingBytes(
|
||||
settings: ProviderDailySettings,
|
||||
provider: DebridProvider,
|
||||
epochMs = Date.now()
|
||||
): number | null {
|
||||
const limit = getProviderDailyLimitBytes(settings, provider);
|
||||
if (limit <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, limit - getProviderDailyUsageBytes(settings, provider, epochMs));
|
||||
}
|
||||
|
||||
export function isProviderDailyLimitReached(
|
||||
settings: ProviderDailySettings,
|
||||
provider: DebridProvider,
|
||||
epochMs = Date.now()
|
||||
): boolean {
|
||||
const limit = getProviderDailyLimitBytes(settings, provider);
|
||||
return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit;
|
||||
}
|
||||
|
||||
export function getProviderTotalUsageBytes(settings: ProviderUsageSettings, provider: DebridProvider): number {
|
||||
return normalizePositiveBytes(settings.providerTotalUsageBytes?.[provider]);
|
||||
}
|
||||
|
||||
export function resetProviderDailyUsage(
|
||||
settings: ProviderDailySettings,
|
||||
provider?: DebridProvider,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
if (!provider) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
providerDailyUsageBytes: {}
|
||||
};
|
||||
}
|
||||
|
||||
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.providerDailyUsageBytes || {}) }
|
||||
: {};
|
||||
delete nextUsageBytes[provider];
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
providerDailyUsageBytes: nextUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addProviderDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
provider: DebridProvider,
|
||||
byteDelta: number,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.providerDailyUsageBytes || {}) }
|
||||
: {};
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
providerDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
const nextUsageBytes = currentUsageBytes;
|
||||
nextUsageBytes[provider] = normalizePositiveBytes(nextUsageBytes[provider]) + increment;
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
providerDailyUsageBytes: nextUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addProviderTotalUsageBytes(
|
||||
settings: ProviderUsageSettings,
|
||||
provider: DebridProvider,
|
||||
byteDelta: number
|
||||
): Pick<AppSettings, "providerTotalUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const currentUsageBytes = { ...(settings.providerTotalUsageBytes || {}) };
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[provider] = normalizePositiveBytes(currentUsageBytes[provider]) + increment;
|
||||
|
||||
return {
|
||||
providerTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number {
|
||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]);
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
keyId: string,
|
||||
epochMs = Date.now()
|
||||
): number {
|
||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||
return 0;
|
||||
}
|
||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyUsageBytes?.[keyId]);
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyDailyRemainingBytes(
|
||||
settings: ProviderDailySettings,
|
||||
keyId: string,
|
||||
epochMs = Date.now()
|
||||
): number | null {
|
||||
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
||||
if (limit <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, limit - getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs));
|
||||
}
|
||||
|
||||
export function isDebridLinkApiKeyDailyLimitReached(
|
||||
settings: ProviderDailySettings,
|
||||
keyId: string,
|
||||
epochMs = Date.now()
|
||||
): boolean {
|
||||
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
||||
return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit;
|
||||
}
|
||||
|
||||
export function getDebridLinkApiKeyTotalUsageBytes(settings: ProviderUsageSettings, keyId: string): number {
|
||||
return normalizePositiveBytes(settings.debridLinkApiKeyTotalUsageBytes?.[keyId]);
|
||||
}
|
||||
|
||||
export function resetDebridLinkApiKeyDailyUsage(
|
||||
settings: ProviderDailySettings,
|
||||
keyId?: string,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
if (!keyId) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
debridLinkApiKeyDailyUsageBytes: {}
|
||||
};
|
||||
}
|
||||
|
||||
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
||||
: {};
|
||||
delete nextUsageBytes[keyId];
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
debridLinkApiKeyDailyUsageBytes: nextUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addDebridLinkApiKeyDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
keyId: string,
|
||||
byteDelta: number,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
||||
: {};
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addDebridLinkApiKeyTotalUsageBytes(
|
||||
settings: ProviderUsageSettings,
|
||||
keyId: string,
|
||||
byteDelta: number
|
||||
): Pick<AppSettings, "debridLinkApiKeyTotalUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const currentUsageBytes = { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) };
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
||||
|
||||
return {
|
||||
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
|
||||
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number {
|
||||
return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
accountId: string,
|
||||
epochMs = Date.now()
|
||||
): number {
|
||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||
return 0;
|
||||
}
|
||||
return normalizePositiveBytes(settings.megaDebridAccountDailyUsageBytes?.[accountId]);
|
||||
}
|
||||
|
||||
export function isMegaDebridAccountDailyLimitReached(
|
||||
settings: ProviderDailySettings,
|
||||
accountId: string,
|
||||
epochMs = Date.now()
|
||||
): boolean {
|
||||
const limit = getMegaDebridAccountDailyLimitBytes(settings, accountId);
|
||||
return limit > 0 && getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit;
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number {
|
||||
return normalizePositiveBytes(settings.megaDebridAccountTotalUsageBytes?.[accountId]);
|
||||
}
|
||||
|
||||
export function addMegaDebridAccountDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
accountId: string,
|
||||
byteDelta: number,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "megaDebridAccountDailyUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.megaDebridAccountDailyUsageBytes || {}) }
|
||||
: {};
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addMegaDebridAccountTotalUsageBytes(
|
||||
settings: ProviderUsageSettings,
|
||||
accountId: string,
|
||||
byteDelta: number
|
||||
): Pick<AppSettings, "megaDebridAccountTotalUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) };
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
||||
|
||||
return {
|
||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
export type DownloadStatus =
|
||||
| "queued"
|
||||
| "validating"
|
||||
| "downloading"
|
||||
| "paused"
|
||||
| "reconnect_wait"
|
||||
| "extracting"
|
||||
| "integrity_check"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export type CleanupMode = "none" | "trash" | "delete";
|
||||
export type ConflictMode = "overwrite" | "skip" | "rename" | "ask";
|
||||
export type SpeedMode = "global" | "per_download";
|
||||
export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done";
|
||||
export type DebridProvider =
|
||||
| "realdebrid"
|
||||
| "megadebrid"
|
||||
| "megadebrid-api"
|
||||
| "megadebrid-web"
|
||||
| "bestdebrid"
|
||||
| "alldebrid"
|
||||
| "ddownload"
|
||||
| "onefichier"
|
||||
| "debridlink"
|
||||
| "linksnappy";
|
||||
export type DebridFallbackProvider = DebridProvider | "none";
|
||||
export type AppTheme = "dark" | "light";
|
||||
export type PackagePriority = "high" | "normal" | "low";
|
||||
export type ExtractCpuPriority = "high" | "middle" | "low";
|
||||
export type HistoryRetentionMode = "never" | "session" | "permanent";
|
||||
|
||||
export interface BandwidthScheduleEntry {
|
||||
id: string;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
speedLimitKbps: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadStats {
|
||||
totalDownloaded: number;
|
||||
totalDownloadedAllTime: number;
|
||||
totalFiles?: number;
|
||||
totalFilesSession: number;
|
||||
totalFilesAllTime: number;
|
||||
totalPackages: number;
|
||||
sessionStartedAt: number;
|
||||
appSessionStartedAt: number;
|
||||
sessionRuntimeMs: number;
|
||||
totalRuntimeMs: number;
|
||||
runtimeMeasuredAt: number;
|
||||
}
|
||||
|
||||
export interface DebridAccountStatus {
|
||||
accountId: string;
|
||||
provider: "megadebrid" | "debridlink";
|
||||
label: string;
|
||||
maskedLogin: string;
|
||||
valid: boolean;
|
||||
isPremium: boolean;
|
||||
premiumUntilMs: number | null;
|
||||
email?: string;
|
||||
message: string;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
token: string;
|
||||
realDebridUseWebLogin: boolean;
|
||||
megaLogin: string;
|
||||
megaPassword: string;
|
||||
megaCredentials: string;
|
||||
megaDebridApiEnabled: boolean;
|
||||
megaDebridWebEnabled: boolean;
|
||||
megaDebridPreferApi: boolean;
|
||||
bestToken: string;
|
||||
bestDebridUseWebLogin: boolean;
|
||||
allDebridToken: string;
|
||||
allDebridUseWebLogin: boolean;
|
||||
ddownloadLogin: string;
|
||||
ddownloadPassword: string;
|
||||
oneFichierApiKey: string;
|
||||
debridLinkApiKeys: string;
|
||||
debridLinkDisabledKeyIds: string[];
|
||||
linkSnappyLogin: string;
|
||||
linkSnappyPassword: string;
|
||||
archivePasswordList: string;
|
||||
rememberToken: boolean;
|
||||
providerOrder: readonly DebridProvider[];
|
||||
providerPrimary: DebridProvider;
|
||||
providerSecondary: DebridFallbackProvider;
|
||||
providerTertiary: DebridFallbackProvider;
|
||||
autoProviderFallback: boolean;
|
||||
outputDir: string;
|
||||
packageName: string;
|
||||
autoExtract: boolean;
|
||||
autoRename4sf4sj: boolean;
|
||||
keepGermanAudioOnly: boolean;
|
||||
germanAudioMode: "tag" | "first";
|
||||
extractDir: string;
|
||||
collectMkvToLibrary: boolean;
|
||||
mkvLibraryDir: string;
|
||||
createExtractSubfolder: boolean;
|
||||
hybridExtract: boolean;
|
||||
cleanupMode: CleanupMode;
|
||||
extractConflictMode: ConflictMode;
|
||||
removeLinkFilesAfterExtract: boolean;
|
||||
removeSamplesAfterExtract: boolean;
|
||||
enableIntegrityCheck: boolean;
|
||||
autoResumeOnStart: boolean;
|
||||
autoReconnect: boolean;
|
||||
reconnectWaitSeconds: number;
|
||||
completedCleanupPolicy: FinishedCleanupPolicy;
|
||||
maxParallel: number;
|
||||
maxParallelExtract: number;
|
||||
retryLimit: number;
|
||||
speedLimitEnabled: boolean;
|
||||
speedLimitKbps: number;
|
||||
speedLimitMode: SpeedMode;
|
||||
updateRepo: string;
|
||||
autoUpdateCheck: boolean;
|
||||
clipboardWatch: boolean;
|
||||
minimizeToTray: boolean;
|
||||
theme: AppTheme;
|
||||
collapseNewPackages: boolean;
|
||||
historyRetentionMode: HistoryRetentionMode;
|
||||
historyMaxEntries: number;
|
||||
historyMaxAgeDays: number;
|
||||
accountListShowDetailedDebridLinkKeys: boolean;
|
||||
autoSortPackagesByProgress: boolean;
|
||||
autoSkipExtracted: boolean;
|
||||
hideExtractedItems: boolean;
|
||||
confirmDeleteSelection: boolean;
|
||||
backupIncludeDownloads: boolean;
|
||||
backupIncludeRemoteDiagnostics: boolean;
|
||||
notifyUrl: string;
|
||||
notifyMention: string;
|
||||
notifyOnPackageCompleted: boolean;
|
||||
notifyOnPackageFailed: boolean;
|
||||
notifyOnRunFinished: boolean;
|
||||
totalDownloadedAllTime: number;
|
||||
totalCompletedFilesAllTime: number;
|
||||
totalRuntimeAllTimeMs: number;
|
||||
bandwidthSchedules: BandwidthScheduleEntry[];
|
||||
columnOrder: string[];
|
||||
extractCpuPriority: ExtractCpuPriority;
|
||||
autoExtractWhenStopped: boolean;
|
||||
disabledProviders: DebridProvider[];
|
||||
hosterRouting: Record<string, DebridProvider>;
|
||||
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
|
||||
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
|
||||
providerTotalUsageBytes: Partial<Record<DebridProvider, number>>;
|
||||
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
|
||||
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
|
||||
debridLinkApiKeyTotalUsageBytes: Record<string, number>;
|
||||
megaDebridDisabledAccountIds: string[];
|
||||
megaDebridAccountDailyLimitBytes: Record<string, number>;
|
||||
megaDebridAccountDailyUsageBytes: Record<string, number>;
|
||||
megaDebridAccountTotalUsageBytes: Record<string, number>;
|
||||
debridAccountStatuses: Record<string, DebridAccountStatus>;
|
||||
providerDailyUsageDay: string;
|
||||
scheduledStartEpochMs: number;
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
id: string;
|
||||
packageId: string;
|
||||
url: string;
|
||||
provider: DebridProvider | null;
|
||||
providerLabel?: string;
|
||||
providerAccountId?: string;
|
||||
providerAccountLabel?: string;
|
||||
status: DownloadStatus;
|
||||
retries: number;
|
||||
speedBps: number;
|
||||
downloadedBytes: number;
|
||||
totalBytes: number | null;
|
||||
progressPercent: number;
|
||||
fileName: string;
|
||||
targetPath: string;
|
||||
resumable: boolean;
|
||||
attempts: number;
|
||||
lastError: string;
|
||||
fullStatus: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
onlineStatus?: "online" | "offline" | "checking";
|
||||
}
|
||||
|
||||
export interface AudioStripFileResult {
|
||||
name: string;
|
||||
action: string;
|
||||
reason: string;
|
||||
languages?: string;
|
||||
}
|
||||
|
||||
export interface AudioStripSummary {
|
||||
at: number;
|
||||
candidates: number;
|
||||
remuxed: number;
|
||||
keptSingle: number;
|
||||
skippedNoGerman: number;
|
||||
skippedNoTool: number;
|
||||
failed: number;
|
||||
files: AudioStripFileResult[];
|
||||
}
|
||||
|
||||
export interface PackageEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
outputDir: string;
|
||||
extractDir: string;
|
||||
status: DownloadStatus;
|
||||
itemIds: string[];
|
||||
cancelled: boolean;
|
||||
enabled: boolean;
|
||||
priority?: PackagePriority;
|
||||
postProcessLabel?: string;
|
||||
audioStripSummary?: AudioStripSummary;
|
||||
downloadStartedAt?: number;
|
||||
downloadCompletedAt?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
version: number;
|
||||
packageOrder: string[];
|
||||
packages: Record<string, PackageEntry>;
|
||||
items: Record<string, DownloadItem>;
|
||||
runStartedAt: number;
|
||||
totalDownloadedBytes: number;
|
||||
summaryText: string;
|
||||
reconnectUntil: number;
|
||||
reconnectReason: string;
|
||||
paused: boolean;
|
||||
running: boolean;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface DownloadSummary {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
cancelled: number;
|
||||
extracted: number;
|
||||
durationSeconds: number;
|
||||
averageSpeedBps: number;
|
||||
}
|
||||
|
||||
export interface ParsedPackageInput {
|
||||
name: string;
|
||||
links: string[];
|
||||
fileNames?: string[];
|
||||
}
|
||||
|
||||
export interface ContainerImportResult {
|
||||
packages: ParsedPackageInput[];
|
||||
source: "dlc";
|
||||
}
|
||||
|
||||
export interface RotationEvent {
|
||||
id: string;
|
||||
at: number;
|
||||
level: "INFO" | "WARN" | "ERROR";
|
||||
provider: string;
|
||||
accountLabel: string;
|
||||
event: string;
|
||||
reason?: string;
|
||||
category?: string;
|
||||
cooldownSec?: number;
|
||||
next?: string;
|
||||
}
|
||||
|
||||
export interface UiSnapshot {
|
||||
settings: AppSettings;
|
||||
session: SessionState;
|
||||
summary: DownloadSummary | null;
|
||||
stats: DownloadStats;
|
||||
speedText: string;
|
||||
etaText: string;
|
||||
canStart: boolean;
|
||||
canStop: boolean;
|
||||
canPause: boolean;
|
||||
clipboardActive: boolean;
|
||||
reconnectSeconds: number;
|
||||
packageSpeedBps: Record<string, number>;
|
||||
payloadKind?: "full" | "delta";
|
||||
removedItemIds?: string[];
|
||||
removedPackageIds?: string[];
|
||||
rotationEvents?: RotationEvent[];
|
||||
}
|
||||
|
||||
export interface AddLinksPayload {
|
||||
rawText: string;
|
||||
packageName?: string;
|
||||
duplicatePolicy?: DuplicatePolicy;
|
||||
}
|
||||
|
||||
export interface AddContainerPayload {
|
||||
filePaths: string[];
|
||||
}
|
||||
|
||||
export type DuplicatePolicy = "keep" | "skip" | "overwrite";
|
||||
|
||||
export interface QueueAddResult {
|
||||
addedPackages: number;
|
||||
addedLinks: number;
|
||||
skippedExistingPackages: string[];
|
||||
overwrittenPackages: string[];
|
||||
}
|
||||
|
||||
export interface ContainerConflictResult {
|
||||
conflicts: string[];
|
||||
packageCount: number;
|
||||
linkCount: number;
|
||||
}
|
||||
|
||||
export interface StartConflictEntry {
|
||||
packageId: string;
|
||||
packageName: string;
|
||||
extractDir: string;
|
||||
}
|
||||
|
||||
export interface StartConflictResolutionResult {
|
||||
skipped: boolean;
|
||||
overwritten: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
updateAvailable: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
latestTag: string;
|
||||
releaseUrl: string;
|
||||
setupAssetUrl?: string;
|
||||
setupAssetName?: string;
|
||||
setupAssetDigest?: string;
|
||||
releaseNotes?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpdateInstallResult {
|
||||
started: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UpdateInstallProgress {
|
||||
stage: "starting" | "downloading" | "verifying" | "launching" | "done" | "error";
|
||||
percent: number | null;
|
||||
downloadedBytes: number;
|
||||
totalBytes: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown";
|
||||
export type AllDebridHostInfoSource = "api" | "web";
|
||||
export type DebridLinkHostState = "up" | "down" | "unknown";
|
||||
export type DebridLinkKeyState = "ready" | "cooldown" | "invalid" | "quota" | "rate_limit" | "error" | "unknown";
|
||||
|
||||
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 DebridLinkHostLimitInfo {
|
||||
keyId: string;
|
||||
keyLabel: string;
|
||||
host: string;
|
||||
fetchedAt: number;
|
||||
trafficCurrentBytes: number | null;
|
||||
trafficMaxBytes: number | null;
|
||||
linksCurrent: number | null;
|
||||
linksMax: number | null;
|
||||
note: string;
|
||||
state: DebridLinkKeyState;
|
||||
stateLabel: string;
|
||||
stateDetail: string;
|
||||
cooldownUntil: number | null;
|
||||
cooldownRemainingMs: number;
|
||||
lastCheckedAt: number | null;
|
||||
hostState: DebridLinkHostState;
|
||||
hostStateLabel: string;
|
||||
hostNote: string;
|
||||
}
|
||||
|
||||
export interface ParsedHashEntry {
|
||||
fileName: string;
|
||||
algorithm: "crc32" | "md5" | "sha1";
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export interface BandwidthSample {
|
||||
timestamp: number;
|
||||
speedBps: number;
|
||||
}
|
||||
|
||||
export interface BandwidthStats {
|
||||
samples: BandwidthSample[];
|
||||
currentSpeedBps: number;
|
||||
averageSpeedBps: number;
|
||||
maxSpeedBps: number;
|
||||
totalBytesSession: number;
|
||||
sessionDurationSeconds: number;
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
bandwidth: BandwidthStats;
|
||||
totalDownloads: number;
|
||||
completedDownloads: number;
|
||||
failedDownloads: number;
|
||||
activeDownloads: number;
|
||||
queuedDownloads: number;
|
||||
}
|
||||
|
||||
export interface SupportTraceConfig {
|
||||
enabled: boolean;
|
||||
includeMainLog: boolean;
|
||||
includeAudit: boolean;
|
||||
logDebugRequests: boolean;
|
||||
autoDisableAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SupportFileSizeInfo {
|
||||
path: string | null;
|
||||
exists: boolean;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface SupportDirectorySizeInfo {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
fileCount: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface SupportDiskSpaceInfo {
|
||||
path: string;
|
||||
totalBytes: number | null;
|
||||
freeBytes: number | null;
|
||||
freePercent: number | null;
|
||||
}
|
||||
|
||||
export interface SupportBundleEstimate {
|
||||
estimatedBytes: number;
|
||||
estimatedEntries: number;
|
||||
duplicatedLiveLogBytes: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface DebugSetupCheckResult {
|
||||
status: "ok" | "warn";
|
||||
enabled: boolean;
|
||||
runtimeBaseDir: string;
|
||||
host: string;
|
||||
port: number;
|
||||
localOnly: boolean;
|
||||
tokenConfigured: boolean;
|
||||
tokenPath: string;
|
||||
supportManifestPath: string;
|
||||
supportManifestPresent: boolean;
|
||||
traceConfigPath: string | null;
|
||||
traceLogPath: string | null;
|
||||
traceEnabled: boolean;
|
||||
traceAutoDisableAt: string | null;
|
||||
diskSpace: {
|
||||
runtime: SupportDiskSpaceInfo;
|
||||
output: SupportDiskSpaceInfo;
|
||||
extract: SupportDiskSpaceInfo;
|
||||
};
|
||||
logSummary: {
|
||||
totalBytes: number;
|
||||
main: SupportFileSizeInfo;
|
||||
mainBackup: SupportFileSizeInfo;
|
||||
audit: SupportFileSizeInfo;
|
||||
auditBackup: SupportFileSizeInfo;
|
||||
rename: SupportFileSizeInfo;
|
||||
renameBackup: SupportFileSizeInfo;
|
||||
session: SupportFileSizeInfo;
|
||||
trace: SupportFileSizeInfo;
|
||||
traceBackup: SupportFileSizeInfo;
|
||||
sessionLogs: SupportDirectorySizeInfo;
|
||||
packageLogs: SupportDirectorySizeInfo;
|
||||
itemLogs: SupportDirectorySizeInfo;
|
||||
};
|
||||
supportBundle: SupportBundleEstimate;
|
||||
warnings: string[];
|
||||
notes: string[];
|
||||
localUrls: {
|
||||
health: string;
|
||||
meta: string;
|
||||
diagnostics: string;
|
||||
};
|
||||
remoteUrlTemplates: {
|
||||
health: string;
|
||||
meta: string;
|
||||
diagnostics: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
totalBytes: number;
|
||||
downloadedBytes: number;
|
||||
fileCount: number;
|
||||
provider: DebridProvider | null;
|
||||
completedAt: number;
|
||||
durationSeconds: number;
|
||||
status: "completed" | "deleted";
|
||||
outputDir: string;
|
||||
urls?: string[];
|
||||
}
|
||||
|
||||
export interface HistoryState {
|
||||
entries: HistoryEntry[];
|
||||
maxEntries: number;
|
||||
}
|
||||
|
||||
export interface RendererErrorReport {
|
||||
kind: "error" | "unhandledrejection" | "react";
|
||||
message: string;
|
||||
stack?: string;
|
||||
source?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
componentStack?: string;
|
||||
}
|
||||
|
||||
export interface RemoteDiagnosticsStatus {
|
||||
running: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
hasToken: boolean;
|
||||
localOnly: boolean;
|
||||
allowlistCount: number;
|
||||
}
|
||||
|
||||
export interface RemoteDiagnosticsInfo {
|
||||
status: RemoteDiagnosticsStatus;
|
||||
code: string | null;
|
||||
publicHost: string;
|
||||
name: string;
|
||||
allowlist: string[];
|
||||
suggestedHosts: string[];
|
||||
}
|
||||
|
||||
export interface EnableRemoteDiagnosticsInput {
|
||||
hostMode: "local" | "network";
|
||||
publicHost: string;
|
||||
port?: number;
|
||||
allowlist: string[];
|
||||
name?: string;
|
||||
rotateToken?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user