chore: remove all source code comments and internal artifacts
Strip every comment from the source (parsed with the TypeScript compiler so strings, template literals, regex literals and JSX are never touched), and drop internal/working artifacts that do not belong in the public repository (design mockups, internal analysis docs, a stray backup file and an old log). No functional change: build is green, the full test suite passes.
This commit is contained in:
@@ -4,18 +4,6 @@ import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/de
|
||||
import { logger } from "./logger";
|
||||
import { compactErrorText } from "./utils";
|
||||
|
||||
/**
|
||||
* Account-Validity + Premium-Check fuer Multi-Account-Provider.
|
||||
*
|
||||
* Standalone (eigene fetch-Calls, kein Import aus debrid.ts) damit es ohne
|
||||
* Zirkular-Abhaengigkeit von der "Check all"-IPC und beim Programmstart genutzt
|
||||
* werden kann.
|
||||
*
|
||||
* Verifizierte API-Felder (Live-Probe):
|
||||
* - Mega-Debrid connectUser -> { response_code:"ok", token, vip_end (Unix-ts), email }
|
||||
* - Debrid-Link /account/infos -> { success, value: { accountType, premiumLeft (s), username } }
|
||||
*/
|
||||
|
||||
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 =
|
||||
@@ -55,7 +43,6 @@ function formatRemaining(premiumUntilMs: number | null, now: number): string {
|
||||
return `Premium noch ${hours} Std`;
|
||||
}
|
||||
|
||||
/** Check a single Mega-Debrid account via connectUser. */
|
||||
export async function checkMegaDebridAccount(
|
||||
account: MegaDebridAccountEntry,
|
||||
signal?: AbortSignal,
|
||||
@@ -87,7 +74,6 @@ export async function checkMegaDebridAccount(
|
||||
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
|
||||
return { ...base, message: `Ungueltiger Login: ${reason}` };
|
||||
}
|
||||
// vip_end is a Unix timestamp (seconds). 0 / missing => no premium.
|
||||
const vipEndRaw = Number(payload.vip_end || 0);
|
||||
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
|
||||
const isPremium = premiumUntilMs > now;
|
||||
@@ -110,7 +96,6 @@ export async function checkMegaDebridAccount(
|
||||
}
|
||||
}
|
||||
|
||||
/** Check a single Debrid-Link API key via /account/infos. */
|
||||
export async function checkDebridLinkKey(
|
||||
key: DebridLinkApiKeyEntry,
|
||||
signal?: AbortSignal,
|
||||
@@ -138,7 +123,6 @@ export async function checkDebridLinkKey(
|
||||
const text = await response.text();
|
||||
const payload = parseJsonSafe(text);
|
||||
if (!response.ok || !payload) {
|
||||
// 401 = bad/expired token
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
|
||||
}
|
||||
@@ -149,7 +133,6 @@ export async function checkDebridLinkKey(
|
||||
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
|
||||
}
|
||||
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
|
||||
// premiumLeft = seconds of premium remaining. accountType>0 also indicates premium.
|
||||
const premiumLeftSec = Number(value.premiumLeft || 0);
|
||||
const accountType = Number(value.accountType || 0);
|
||||
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
|
||||
@@ -175,8 +158,6 @@ export async function checkDebridLinkKey(
|
||||
}
|
||||
}
|
||||
|
||||
/** Check ALL configured multi-account credentials (Mega-Debrid accounts +
|
||||
* Debrid-Link keys) concurrently. Returns one status per account id. */
|
||||
export async function checkAllDebridAccounts(
|
||||
settings: AppSettings,
|
||||
signal?: AbortSignal
|
||||
@@ -185,9 +166,6 @@ export async function checkAllDebridAccounts(
|
||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
|
||||
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
||||
|
||||
// Each task is a thunk so we can throttle concurrency. Firing all accounts at
|
||||
// once (e.g. 9+ Debrid-Link keys) can trip provider rate-limits and produce
|
||||
// false "invalid" badges, so cap at CHECK_CONCURRENCY parallel checks.
|
||||
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
||||
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
|
||||
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
|
||||
@@ -203,7 +181,6 @@ export async function checkAllDebridAccounts(
|
||||
|
||||
const CHECK_CONCURRENCY = 4;
|
||||
|
||||
/** Run thunks with a bounded number in flight, preserving result order. */
|
||||
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
|
||||
const results: T[] = new Array(taskFns.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
@@ -4,51 +4,30 @@ import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { RotationEvent } from "../shared/types";
|
||||
|
||||
/** Item-scoped sink: while a single item's link-unrestrict runs, the
|
||||
* download-manager wraps it in runWithRotationItemSink() so EVERY rotation
|
||||
* event for that item (Account 1 wird versucht, fehlgeschlagen, → Account 2)
|
||||
* lands in that item's own log — exactly where the user looks. AsyncLocalStorage
|
||||
* keeps this correct even with 8 items unrestricting in parallel: each runs in
|
||||
* its own async context, so events never cross-attribute. */
|
||||
export type RotationItemSink = (event: RotationEvent) => void;
|
||||
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
||||
|
||||
/** Run `fn` with an item-scoped rotation sink active for its whole async chain. */
|
||||
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
||||
return rotationItemContext.run(sink, fn);
|
||||
}
|
||||
|
||||
/** Dedicated log file for multi-account/key rotation events:
|
||||
* Mega-Debrid account selection, Debrid-Link key selection, per-attempt
|
||||
* test result, cooldown set, fallback to next account/key, etc.
|
||||
* Separate from rd_downloader.log so the user can see the rotation flow
|
||||
* without the noise of normal download activity. */
|
||||
|
||||
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
/** In-memory ring buffer of the most recent rotation events so the UI can show
|
||||
* a live "which account was tried and why it failed" panel — the same events
|
||||
* written to account-rotation.log, but surfaced to the renderer via snapshot. */
|
||||
const ROTATION_EVENT_RING_MAX = 60;
|
||||
const rotationEventRing: RotationEvent[] = [];
|
||||
let rotationEventSeq = 0;
|
||||
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
|
||||
|
||||
/** Register a callback fired whenever a new rotation event is recorded (used by
|
||||
* the download-manager to push a fresh snapshot to the UI immediately). */
|
||||
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
|
||||
rotationEventListener = listener;
|
||||
}
|
||||
|
||||
/** Returns the recent rotation events, newest first. */
|
||||
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
|
||||
const slice = rotationEventRing.slice(-limit);
|
||||
slice.reverse();
|
||||
return slice;
|
||||
}
|
||||
|
||||
/** Events that are noise for the UI panel (per-attempt TEST markers). The panel
|
||||
* focuses on outcomes: OK / FAILED / FATAL / skips. */
|
||||
function isUiRelevantRotationEvent(event: string): boolean {
|
||||
return event !== "TEST";
|
||||
}
|
||||
@@ -75,20 +54,14 @@ function pushRotationEvent(
|
||||
next: fields && fields.next != null ? String(fields.next) : undefined
|
||||
};
|
||||
|
||||
// Always route to the item-scoped sink (if any) — the per-item log wants the
|
||||
// FULL trail including "TEST" (Account X wird versucht), so the user sees the
|
||||
// rotation right where they look.
|
||||
const itemSink = rotationItemContext.getStore();
|
||||
if (itemSink) {
|
||||
try {
|
||||
itemSink(entry);
|
||||
} catch {
|
||||
// never let item logging break the rotation flow
|
||||
}
|
||||
}
|
||||
|
||||
// The global UI panel ring + live push skip noisy per-attempt TEST markers;
|
||||
// it focuses on outcomes (OK / FAILED / FATAL / skips).
|
||||
if (!isUiRelevantRotationEvent(event)) {
|
||||
return;
|
||||
}
|
||||
@@ -100,7 +73,6 @@ function pushRotationEvent(
|
||||
try {
|
||||
rotationEventListener(entry);
|
||||
} catch {
|
||||
// never let a UI push break the rotation flow
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,11 +119,9 @@ function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +134,6 @@ function cleanupOldBackup(filePath: string): void {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,13 +159,6 @@ export function initAccountRotationLog(baseDir: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Record an account/key rotation event. The format is intentionally compact
|
||||
* and grep-friendly: timestamp + level + provider + accountLabel + event + fields.
|
||||
* Example output:
|
||||
* 2026-04-19T20:48:50.000Z [INFO] Mega-Debrid Web | Account 2 (fa**david@...) | TEST | link=https://...
|
||||
* 2026-04-19T20:48:52.000Z [WARN] Mega-Debrid Web | Account 2 (fa**david@...) | FAILED reason="Antwort leer" cooldownSec=30 | link=https://...
|
||||
* 2026-04-19T20:48:53.000Z [INFO] Mega-Debrid Web | Account 3 (am**@example.com) | TEST | link=https://...
|
||||
* 2026-04-19T20:48:55.000Z [INFO] Mega-Debrid Web | Account 3 (am**@example.com) | OK directLink=https://... | link=https://... */
|
||||
export function logAccountRotation(
|
||||
level: RotationLevel,
|
||||
provider: string,
|
||||
@@ -204,7 +166,6 @@ export function logAccountRotation(
|
||||
event: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
// Surface to the UI ring buffer regardless of whether the file log is ready.
|
||||
pushRotationEvent(level, provider, accountLabel, event, fields);
|
||||
if (!rotationLogPath) {
|
||||
return;
|
||||
@@ -217,7 +178,6 @@ export function logAccountRotation(
|
||||
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
||||
} catch {
|
||||
// ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +199,6 @@ export function shutdownAccountRotationLog(): void {
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
rotationLogPath = null;
|
||||
}
|
||||
|
||||
@@ -243,12 +243,10 @@ export class AllDebridWebFallback {
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+782
-834
File diff suppressed because it is too large
Load Diff
@@ -46,11 +46,9 @@ function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +61,6 @@ function cleanupOldBackup(filePath: string): void {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +97,6 @@ export function logAuditEvent(level: AuditLevel, message: string, fields?: Recor
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
// ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +114,6 @@ export function shutdownAuditLog(): void {
|
||||
try {
|
||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
auditLogPath = null;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
// Fixed app key — like JDownloader 2: deterministic, works on any machine.
|
||||
// Not meant to protect against reverse-engineering, just prevents casual
|
||||
// plaintext snooping when someone opens the backup file.
|
||||
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12; // 96-bit IV for GCM
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const MAGIC = Buffer.from("MDD1"); // file signature
|
||||
const MAGIC = Buffer.from("MDD1");
|
||||
|
||||
function deriveKey(): Buffer {
|
||||
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a UTF-8 string into an MDD backup buffer.
|
||||
* Format: MAGIC(4) | IV(12) | AUTH_TAG(16) | CIPHERTEXT(…)
|
||||
*/
|
||||
export function encryptBackup(plaintext: string): Buffer {
|
||||
const key = deriveKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
@@ -26,10 +19,6 @@ export function encryptBackup(plaintext: string): Buffer {
|
||||
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an MDD backup buffer back to a UTF-8 string.
|
||||
* Throws on invalid/corrupted data.
|
||||
*/
|
||||
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");
|
||||
|
||||
@@ -212,18 +212,15 @@ export class BestDebridWebFallback {
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
// nothing to clean up
|
||||
}
|
||||
|
||||
private getPartition(): string {
|
||||
@@ -344,7 +341,6 @@ export class BestDebridWebFallback {
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
// ignore cache clear failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
|
||||
fs.rmSync(full, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +83,6 @@ export async function cleanupCancelledPackageArtifactsAsync(
|
||||
await fs.promises.rm(full, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +148,6 @@ export async function removeDownloadLinkArtifacts(
|
||||
await fs.promises.rm(full, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,7 +237,6 @@ export async function removeSampleArtifacts(
|
||||
await fs.promises.rm(full, { force: true });
|
||||
removedFiles += 1;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,7 +259,6 @@ export async function removeSampleArtifacts(
|
||||
removedFiles += filesInDir;
|
||||
removedDirs += 1;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+130
-130
@@ -1,130 +1,130 @@
|
||||
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; // 512 KB write buffer (JDownloader: 500 KB)
|
||||
export const WRITE_FLUSH_TIMEOUT_MS = 2000; // 2s flush timeout
|
||||
export const ALLOCATION_UNIT_SIZE = 4096; // 4 KB NTFS alignment
|
||||
export const STREAM_HIGH_WATER_MARK = 512 * 1024; // 512 KB stream buffer — lower than before (2 MB) so backpressure triggers sooner when disk is slow
|
||||
export const DISK_BUSY_THRESHOLD_MS = 300; // Internal detection threshold for disk backpressure
|
||||
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; // Delay UI/log display for brief disk-write spikes
|
||||
|
||||
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 = "Administrator/real-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,
|
||||
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",
|
||||
accountListShowDetailedDebridLinkKeys: false,
|
||||
autoSortPackagesByProgress: true,
|
||||
autoSkipExtracted: false,
|
||||
hideExtractedItems: true,
|
||||
confirmDeleteSelection: true,
|
||||
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
|
||||
};
|
||||
}
|
||||
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 = "Administrator/real-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,
|
||||
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",
|
||||
accountListShowDetailedDebridLinkKeys: false,
|
||||
autoSortPackagesByProgress: true,
|
||||
autoSkipExtracted: false,
|
||||
hideExtractedItems: true,
|
||||
confirmDeleteSelection: true,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,13 +113,11 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
||||
try {
|
||||
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
links.push(url);
|
||||
fileNames.push(sanitizeFilename(fileName));
|
||||
} catch {
|
||||
// skip broken entries
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +130,6 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
||||
links.push(url);
|
||||
}
|
||||
} catch {
|
||||
// skip broken entries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-237
@@ -24,20 +24,13 @@ const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1";
|
||||
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
|
||||
|
||||
const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2";
|
||||
/** Truly key-wide quota errors: the whole key is exhausted regardless of host. */
|
||||
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
|
||||
/** Per-(key, host) quota errors: only this host is exhausted for this key — the
|
||||
* key remains usable for other hosters. */
|
||||
const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]);
|
||||
/** Backward-compat union — includes BOTH key-wide and per-host quota codes.
|
||||
* Use this only for "is it a quota error of any kind?" checks; for behavior
|
||||
* branches use the more specific sets above. */
|
||||
const DEBRID_LINK_QUOTA_ERRORS = new Set([...DEBRID_LINK_KEY_QUOTA_ERRORS, ...DEBRID_LINK_HOST_QUOTA_ERRORS]);
|
||||
const DEBRID_LINK_INVALID_TOKEN_ERRORS = new Set(["badToken", "hidedToken", "expired_token"]);
|
||||
const DEBRID_LINK_RATE_LIMIT_ERRORS = new Set(["floodDetected"]);
|
||||
const DEBRID_LINK_RETRYABLE_ERRORS = new Set(["internalError", "server_error"]);
|
||||
const DEBRID_LINK_PROVIDER_WIDE_ERRORS = new Set(["notDebrid"]);
|
||||
/** Errors where the key can't handle this link — skip to next key immediately, no retries */
|
||||
const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([
|
||||
"disabledServerHost",
|
||||
"notFreeHost",
|
||||
@@ -48,7 +41,6 @@ const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([
|
||||
"fileNotAvailable"
|
||||
]);
|
||||
const DEBRID_LINK_FATAL_LINK_ERRORS = new Set(["badArguments", "badFileUrl", "badFilePassword", "fileNotFound", "hostNotValid"]);
|
||||
/** Per-key cooldown cache: keyId → expiry timestamp. Parallel items skip keys that recently failed. */
|
||||
const debridLinkKeyCooldowns = new Map<string, number>();
|
||||
type DebridLinkCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
|
||||
type DebridLinkCooldownDetail = { message: string; category: DebridLinkCooldownCategory };
|
||||
@@ -60,7 +52,7 @@ type DebridLinkRuntimeStatus = {
|
||||
};
|
||||
const debridLinkKeyCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
|
||||
const debridLinkKeyRuntimeStatuses = new Map<string, DebridLinkRuntimeStatus>();
|
||||
const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000; // 2 min cooldown per failed key
|
||||
const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000;
|
||||
const DEBRID_LINK_INVALID_KEY_COOLDOWN_MS = 60 * 60 * 1000;
|
||||
const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000;
|
||||
|
||||
@@ -72,9 +64,6 @@ export function resetDebridLinkRuntimeStateForTests(): void {
|
||||
debridLinkKeyHostCooldownDetails.clear();
|
||||
}
|
||||
|
||||
/** Drop all Debrid-Link cooldown/runtime entries for key IDs that are no
|
||||
* longer in the active key set. Called when settings change so removed
|
||||
* keys don't keep blocking the system if they're re-added later. */
|
||||
export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void {
|
||||
for (const keyId of debridLinkKeyCooldowns.keys()) {
|
||||
if (!activeKeyIds.has(keyId)) {
|
||||
@@ -87,9 +76,6 @@ export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): v
|
||||
debridLinkKeyRuntimeStatuses.delete(keyId);
|
||||
}
|
||||
}
|
||||
// Per-(key, host) cooldown keys have format `${keyId}|${hoster}` — drop any
|
||||
// whose keyId is no longer in the active set so removed keys don't keep
|
||||
// memory state around if they're re-added later.
|
||||
for (const stateKey of debridLinkKeyHostCooldowns.keys()) {
|
||||
const sepIdx = stateKey.indexOf("|");
|
||||
const keyId = sepIdx >= 0 ? stateKey.slice(0, sepIdx) : stateKey;
|
||||
@@ -100,12 +86,9 @@ export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): v
|
||||
}
|
||||
}
|
||||
|
||||
/** Periodic cleanup of expired Debrid-Link cooldown/runtime entries.
|
||||
* Without this, module-level Maps grow unbounded over 24/7 operation.
|
||||
* Removes entries whose cooldown expired more than 1 hour ago. */
|
||||
export function pruneExpiredDebridLinkRuntimeState(now = Date.now()): number {
|
||||
let removed = 0;
|
||||
const grace = 60 * 60 * 1000; // keep 1h grace for debugging
|
||||
const grace = 60 * 60 * 1000;
|
||||
for (const [keyId, until] of debridLinkKeyCooldowns) {
|
||||
if (until + grace < now) {
|
||||
debridLinkKeyCooldowns.delete(keyId);
|
||||
@@ -178,13 +161,6 @@ function setDebridLinkKeyCooldownState(
|
||||
clearDebridLinkKeyCooldownState(keyId);
|
||||
return;
|
||||
}
|
||||
// Cooldown set: max-wins. When 8 parallel items hit floodDetected on the
|
||||
// same key, each computes its own retry-after and calls setDebridLinkKey
|
||||
// CooldownState. Without max-wins, the LAST setter could shorten the
|
||||
// cooldown (e.g. one item got a 1h Retry-After header, another got the
|
||||
// default 2 min — without max-wins the 2 min would overwrite the 1h).
|
||||
// Quota and rate_limit categories take priority over generic temporary
|
||||
// cooldowns regardless of duration to preserve the more-specific signal.
|
||||
const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
|
||||
const existingUntil = Number(debridLinkKeyCooldowns.get(keyId) || 0);
|
||||
const existingDetail = debridLinkKeyCooldownDetails.get(keyId);
|
||||
@@ -192,7 +168,6 @@ function setDebridLinkKeyCooldownState(
|
||||
const existingIsStrongCategory = existingDetail
|
||||
? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid")
|
||||
: false;
|
||||
// Keep existing if it's still active and either lasts longer or has a stronger category
|
||||
if (existingUntil > Date.now()) {
|
||||
if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) {
|
||||
return;
|
||||
@@ -227,9 +202,6 @@ function getDebridLinkKeyCooldownState(
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-(key, host) cooldown cache. When a key hits maxLinkHost / maxDataHost
|
||||
* for a specific host, only that combination should be blocked — the key
|
||||
* itself stays usable for other hosters. Map key format: `${keyId}|${hoster}`. */
|
||||
const debridLinkKeyHostCooldowns = new Map<string, number>();
|
||||
const debridLinkKeyHostCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
|
||||
|
||||
@@ -251,8 +223,6 @@ function setDebridLinkKeyHostCooldownState(
|
||||
category: DebridLinkCooldownCategory
|
||||
): void {
|
||||
if (!hoster) {
|
||||
// Fall back to key-wide cooldown when we can't determine the hoster — better
|
||||
// a slightly broader block than letting the key thrash on the same failure.
|
||||
setDebridLinkKeyCooldownState(keyId, cooldownMs, message, category);
|
||||
return;
|
||||
}
|
||||
@@ -261,10 +231,6 @@ function setDebridLinkKeyHostCooldownState(
|
||||
return;
|
||||
}
|
||||
const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
|
||||
// Same max-wins semantics as setDebridLinkKeyCooldownState — parallel items
|
||||
// hitting maxDataHost on the same (key, host) shouldn't shorten an existing
|
||||
// longer cooldown. Strong categories (quota / rate_limit / invalid) win over
|
||||
// generic temporary regardless of duration.
|
||||
const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
|
||||
const existingUntil = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0);
|
||||
const existingDetail = debridLinkKeyHostCooldownDetails.get(stateKey);
|
||||
@@ -282,8 +248,6 @@ function setDebridLinkKeyHostCooldownState(
|
||||
}
|
||||
debridLinkKeyHostCooldowns.set(stateKey, newUntil);
|
||||
debridLinkKeyHostCooldownDetails.set(stateKey, { message, category });
|
||||
// Intentionally NOT updating setDebridLinkKeyRuntimeStatus here — the key
|
||||
// is still healthy for other hosters, only this (key, host) is blocked.
|
||||
}
|
||||
|
||||
function getDebridLinkKeyHostCooldownState(
|
||||
@@ -313,37 +277,21 @@ function getDebridLinkKeyHostCooldownState(
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-account cooldown cache for Mega-Debrid: accountId → expiry timestamp.
|
||||
* untilRestart: ein Tageslimit-Account wird fuer den REST der Laufzeit uebersprungen
|
||||
* (nicht alle 20s/2min neu getestet) und kommt erst nach einem Neustart zurueck — die
|
||||
* Map liegt nur im RAM, ein Neustart loescht sie also automatisch. */
|
||||
type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
|
||||
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
|
||||
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
|
||||
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; // 2 min cooldown per failed account
|
||||
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
|
||||
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Zaehlt aufeinanderfolgende "Antwort leer"-Fehlversuche je Account-Key. Ein
|
||||
* tageslimitierter Mega-Debrid-Account liefert im Web-Pfad KEINE unterscheidbare
|
||||
* Meldung ("Kein Server" taucht in echten Logs nie auf — immer nur "Antwort leer"),
|
||||
* ist aber daran erkennbar, dass er PERSISTENT leer antwortet. Nach
|
||||
* MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART aufeinanderfolgenden Leer-Antworten wird der
|
||||
* Account bis Neustart geparkt; ein einzelner transienter Blip (Streak < Schwelle)
|
||||
* behaelt den kurzen 20s-Cooldown. Ein Erfolg oder ein anderer Fehlertyp setzt den
|
||||
* Zaehler zurueck. */
|
||||
const megaDebridEmptyResponseStreaks = new Map<string, number>();
|
||||
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
|
||||
|
||||
/** Verbucht eine "Antwort leer"-Antwort fuer den Account-Key und liefert die neue
|
||||
* Streak-Laenge. Ab MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART parkt der Aufrufer den
|
||||
* Account bis Neustart. Exportiert fuer deterministische Tests. */
|
||||
export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
|
||||
const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
|
||||
megaDebridEmptyResponseStreaks.set(accountId, streak);
|
||||
return streak;
|
||||
}
|
||||
|
||||
/** Setzt die "Antwort leer"-Streak zurueck (bei Erfolg oder einem anderen Fehlertyp). */
|
||||
export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
|
||||
megaDebridEmptyResponseStreaks.delete(accountId);
|
||||
}
|
||||
@@ -353,7 +301,6 @@ export function resetMegaDebridRuntimeStateForTests(): void {
|
||||
megaDebridEmptyResponseStreaks.clear();
|
||||
}
|
||||
|
||||
/** Periodic cleanup of expired Mega-Debrid cooldown entries. */
|
||||
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
|
||||
let removed = 0;
|
||||
const grace = 60 * 60 * 1000;
|
||||
@@ -370,7 +317,6 @@ export function primeMegaDebridRuntimeCooldownForTests(accountId: string, cooldo
|
||||
setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary");
|
||||
}
|
||||
|
||||
/** Parkt einen Account-Key bis Neustart (Tageslimit). Exportiert fuer Tests. */
|
||||
export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis Neustart gesperrt"): void {
|
||||
setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true);
|
||||
}
|
||||
@@ -387,9 +333,6 @@ function setMegaDebridAccountCooldownState(
|
||||
untilRestart = false
|
||||
): void {
|
||||
if (untilRestart) {
|
||||
// Bis-Neustart-Sperre: never expires in-process (Number.MAX_SAFE_INTEGER liegt
|
||||
// ausserhalb des gueltigen Date-Bereichs → Anzeige wird via untilRestart-Flag
|
||||
// gesondert behandelt, nicht ueber new Date(until)).
|
||||
megaDebridAccountCooldowns.set(accountId, {
|
||||
until: Number.MAX_SAFE_INTEGER,
|
||||
message,
|
||||
@@ -500,21 +443,17 @@ export function getAvailableDebridLinkApiKeys(settings: AppSettings, epochMs = D
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns Mega-Debrid accounts that are not disabled and not daily-limited. */
|
||||
export function getAvailableMegaDebridAccounts(settings: AppSettings, epochMs = Date.now()): MegaDebridAccountEntry[] {
|
||||
return getMegaDebridAccountList(settings).filter(
|
||||
(entry) => !isMegaDebridAccountDisabled(settings, entry.id) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs)
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves the full list of Mega-Debrid accounts from settings (multi-account or legacy single). */
|
||||
function getMegaDebridAccountList(settings: AppSettings): MegaDebridAccountEntry[] {
|
||||
// Multi-account format: newline-separated "login:password" pairs in megaCredentials
|
||||
const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
|
||||
if (multiAccounts.length > 0) {
|
||||
return multiAccounts;
|
||||
}
|
||||
// Backward compat: single legacy megaLogin/megaPassword
|
||||
if (settings.megaLogin?.trim() && settings.megaPassword?.trim()) {
|
||||
return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim());
|
||||
}
|
||||
@@ -575,7 +514,6 @@ function parseRetryAfterMs(value: string | null): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Cap at 1 hour — floodDetected can mandate "retry after 1 hour"
|
||||
const maxRetryMs = 60 * 60 * 1000;
|
||||
const asSeconds = Number(text);
|
||||
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
||||
@@ -1416,8 +1354,6 @@ export function extractRapidgatorFilenameFromHtml(html: string): string {
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
// Some patterns have multiple capture groups for attribute-order independence;
|
||||
// pick the first non-empty group.
|
||||
const raw = match?.[1] || match?.[2] || "";
|
||||
const normalized = normalizeResolvedFilename(raw);
|
||||
if (normalized) {
|
||||
@@ -1499,12 +1435,10 @@ async function readResponseTextLimited(response: Response, maxBytes: number, sig
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1536,7 +1470,7 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
|
||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||
});
|
||||
if (!response.ok) {
|
||||
try { await response.body?.cancel(); } catch { /* drain socket */ }
|
||||
try { await response.body?.cancel(); } catch { }
|
||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
|
||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||
continue;
|
||||
@@ -1552,11 +1486,11 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
|
||||
&& !contentType.includes("text/plain")
|
||||
&& !contentType.includes("text/xml")
|
||||
&& !contentType.includes("application/xml")) {
|
||||
try { await response.body?.cancel(); } catch { /* drain socket */ }
|
||||
try { await response.body?.cancel(); } catch { }
|
||||
return "";
|
||||
}
|
||||
if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) {
|
||||
try { await response.body?.cancel(); } catch { /* drain socket */ }
|
||||
try { await response.body?.cancel(); } catch { }
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1613,7 +1547,6 @@ export async function checkRapidgatorOnline(
|
||||
"Accept-Language": "en-US,en;q=0.9,de;q=0.8"
|
||||
};
|
||||
|
||||
// Fast path: HEAD request (no body download, much faster)
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
||||
try {
|
||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||
@@ -1634,30 +1567,26 @@ export async function checkRapidgatorOnline(
|
||||
if (!finalUrl.includes(fileId)) {
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
}
|
||||
// HEAD 200 + URL still contains file ID → online
|
||||
const fileName = filenameFromRapidgatorUrlPath(link);
|
||||
return { online: true, fileName, fileSize: null };
|
||||
}
|
||||
|
||||
// Non-OK, non-404: retry or give up
|
||||
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
|
||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
// HEAD inconclusive — fall through to GET
|
||||
break;
|
||||
} catch (error) {
|
||||
const errorText = compactErrorText(error);
|
||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
|
||||
if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
|
||||
break; // fall through to GET
|
||||
break;
|
||||
}
|
||||
await sleepWithSignal(retryDelay(attempt), signal);
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: GET request (downloads HTML, more thorough)
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
||||
try {
|
||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||
@@ -1670,12 +1599,12 @@ export async function checkRapidgatorOnline(
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
try { await response.body?.cancel(); } catch { /* drain socket */ }
|
||||
try { await response.body?.cancel(); } catch { }
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
try { await response.body?.cancel(); } catch { /* drain socket */ }
|
||||
try { await response.body?.cancel(); } catch { }
|
||||
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
|
||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||
continue;
|
||||
@@ -1685,7 +1614,7 @@ export async function checkRapidgatorOnline(
|
||||
|
||||
const finalUrl = response.url || link;
|
||||
if (!finalUrl.includes(fileId)) {
|
||||
try { await response.body?.cancel(); } catch { /* drain socket */ }
|
||||
try { await response.body?.cancel(); } catch { }
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
}
|
||||
|
||||
@@ -1739,14 +1668,10 @@ class MegaDebridClient {
|
||||
|
||||
private allowApiFallback: boolean;
|
||||
|
||||
/** Per-account API token cache: login (lowercase) → { token, timestamp } */
|
||||
private static cachedApiTokens = new Map<string, { token: string; at: number }>();
|
||||
|
||||
/** Per-account pending connect deduplication: login (lowercase) → promise */
|
||||
private static pendingConnects = new Map<string, Promise<string | null>>();
|
||||
|
||||
/** Clear cached tokens for accounts whose login is no longer in the given set.
|
||||
* Called when settings change so removed accounts don't keep stale tokens. */
|
||||
public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void {
|
||||
const keep = new Set<string>();
|
||||
for (const login of activeLogins) {
|
||||
@@ -1764,8 +1689,6 @@ class MegaDebridClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-clear the API token for a specific login (e.g. when its password
|
||||
* changes — same login, but cached token is now invalid for new password). */
|
||||
public static clearCachedApiToken(login: string): void {
|
||||
const key = String(login || "").toLowerCase();
|
||||
MegaDebridClient.cachedApiTokens.delete(key);
|
||||
@@ -1786,13 +1709,11 @@ class MegaDebridClient {
|
||||
|
||||
private async connectApi(signal?: AbortSignal): Promise<string | null> {
|
||||
const key = this.cacheKey;
|
||||
// Return cached token if fresh (max 20 min)
|
||||
const cached = MegaDebridClient.cachedApiTokens.get(key);
|
||||
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
// Deduplicate parallel connectUser calls — only one in-flight request per account
|
||||
const pending = MegaDebridClient.pendingConnects.get(key);
|
||||
if (pending) {
|
||||
return pending;
|
||||
@@ -1855,7 +1776,6 @@ class MegaDebridClient {
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
// Token might be invalid, clear cache
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
this.clearTokenCache();
|
||||
}
|
||||
@@ -1863,7 +1783,6 @@ class MegaDebridClient {
|
||||
}
|
||||
const payload = parseJsonSafe(text);
|
||||
if (!payload || payload.response_code !== "ok") {
|
||||
// Token expired — clear cache for next attempt
|
||||
if (payload && String(payload.response_code || "").includes("token")) {
|
||||
this.clearTokenCache();
|
||||
}
|
||||
@@ -1915,10 +1834,6 @@ class MegaDebridClient {
|
||||
if (!lastError) {
|
||||
lastError = "Mega-Web Antwort leer";
|
||||
}
|
||||
// Don't retry permanent hoster errors (dead link, file removed, etc.) — and
|
||||
// don't hammer a "Kein Server für diesen Hoster" (account hoster quota) message:
|
||||
// immediate retries are futile (the limit persists) and waste the shared
|
||||
// rotation budget, so break and let the rotation move to the next account.
|
||||
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError) || MEGA_DEBRID_NO_SERVER_RE.test(lastError)) {
|
||||
break;
|
||||
}
|
||||
@@ -1954,12 +1869,6 @@ class MegaDebridClient {
|
||||
return this.unrestrictViaWeb(link, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-account rotation for Mega-Debrid, following the same pattern as Debrid-Link multi-key rotation.
|
||||
* Iterates through all configured accounts, skipping disabled/daily-limited/cooldown accounts.
|
||||
* On success: clears cooldown, returns result with sourceAccountId/sourceAccountLabel.
|
||||
* On failure: classifies error, sets cooldown, tries next account.
|
||||
*/
|
||||
public static async unrestrictWithAccounts(
|
||||
settings: AppSettings,
|
||||
mode: "api" | "web",
|
||||
@@ -1986,11 +1895,8 @@ class MegaDebridClient {
|
||||
const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
|
||||
const linkShort = String(link || "").slice(0, 80);
|
||||
|
||||
// Always start from first account — use first available, skip disabled/limited/cooldown.
|
||||
for (let idx = 0; idx < accounts.length; idx += 1) {
|
||||
const account = accounts[idx];
|
||||
// Always show account number — even with 1 account — so user can tell at a
|
||||
// glance which account is in play. Format: "(Account 2/3, fa**david@...)"
|
||||
const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`;
|
||||
const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;
|
||||
|
||||
@@ -2004,7 +1910,6 @@ class MegaDebridClient {
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" });
|
||||
continue;
|
||||
}
|
||||
// Cooldown key includes mode so API failures don't block Web attempts
|
||||
const cooldownKey = `${account.id}:${mode}`;
|
||||
const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey);
|
||||
if (accountCooldownState) {
|
||||
@@ -2021,9 +1926,6 @@ class MegaDebridClient {
|
||||
until: untilStr
|
||||
});
|
||||
cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`);
|
||||
// Eine Bis-Neustart-Sperre traegt NICHT zu earliestCooldownUntil bei: es gibt
|
||||
// keinen sinnvollen endlichen Retry-Zeitpunkt (der Account kommt erst nach
|
||||
// Neustart zurueck). Sonst wuerde MAX_SAFE_INTEGER einen absurden Retry-Timer setzen.
|
||||
if (accountCooldownState.untilRestart) {
|
||||
parkedUntilRestartSeen = true;
|
||||
} else if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) {
|
||||
@@ -2032,9 +1934,6 @@ class MegaDebridClient {
|
||||
continue;
|
||||
}
|
||||
|
||||
// CLEAR per-account TEST log line BEFORE the network call, so the user
|
||||
// can always see exactly which account is currently being tested for
|
||||
// link generation — even if the call hangs or times out.
|
||||
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
|
||||
const testStartedAt = Date.now();
|
||||
@@ -2063,11 +1962,6 @@ class MegaDebridClient {
|
||||
const elapsedMs = Date.now() - testStartedAt;
|
||||
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
|
||||
|
||||
// "Antwort leer"-Streak fuehren: ein tageslimitierter Account antwortet
|
||||
// PERSISTENT leer (siehe Kommentar an megaDebridEmptyResponseStreaks). Erreicht
|
||||
// der Account die Schwelle, wird er bis Neustart geparkt — statt alle 20s neu
|
||||
// getestet zu werden. failure.untilRestart deckt zusaetzlich den Fall ab, dass
|
||||
// generate() die "Kein Server"-Meldung doch mal direkt liefert.
|
||||
let parkUntilRestart = false;
|
||||
let parkMessage = failure.message;
|
||||
if (failure.limitSignal) {
|
||||
@@ -2101,7 +1995,6 @@ class MegaDebridClient {
|
||||
: failure.cooldownMs > 0
|
||||
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
|
||||
: "";
|
||||
// Find the next account that will be tried (for clearer log)
|
||||
let nextLabel = "ENDE";
|
||||
for (let nextIdx = idx + 1; nextIdx < accounts.length; nextIdx += 1) {
|
||||
const nextAcc = accounts[nextIdx];
|
||||
@@ -2128,9 +2021,6 @@ class MegaDebridClient {
|
||||
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
|
||||
}
|
||||
if (parkedUntilRestartSeen) {
|
||||
// Alle (verbliebenen) Accounts haben ihr Tageslimit erreicht und sind bis
|
||||
// Neustart gesperrt. KEIN mega_debrid_cooldown:<ms> — es gibt keinen sinnvollen
|
||||
// Retry-Zeitpunkt; ein endlicher Timer wuerde nur erneut leer pollen.
|
||||
throw new Error(`Mega-Debrid: Alle Accounts am Tageslimit (bis Neustart gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`);
|
||||
}
|
||||
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
|
||||
@@ -2138,21 +2028,15 @@ class MegaDebridClient {
|
||||
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify error from a single Mega-Debrid account attempt.
|
||||
* Returns whether the error is fatal (stop all accounts) and how long to cool down.
|
||||
*/
|
||||
private static classifyAccountFailure(
|
||||
error: unknown
|
||||
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
|
||||
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||
|
||||
// Abort — don't retry other accounts
|
||||
if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
|
||||
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
|
||||
}
|
||||
|
||||
// Auth/login failures — long cooldown, try next account
|
||||
if (/login|password|auth|credentials|unauthorized|forbidden/i.test(errorText) || /connectUser/i.test(errorText)) {
|
||||
return {
|
||||
fatal: false,
|
||||
@@ -2162,12 +2046,10 @@ class MegaDebridClient {
|
||||
};
|
||||
}
|
||||
|
||||
// Permanent hoster errors — fatal, don't try other accounts
|
||||
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) {
|
||||
return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
|
||||
}
|
||||
|
||||
// Quota/limit errors — cooldown, try next account
|
||||
if (/quota|limit|exceeded|bandwidth/i.test(errorText)) {
|
||||
return {
|
||||
fatal: false,
|
||||
@@ -2177,11 +2059,6 @@ class MegaDebridClient {
|
||||
};
|
||||
}
|
||||
|
||||
// "Kein Server für diesen Hoster verfügbar" = Account-Tageslimit erschöpft (oder der
|
||||
// Hoster ist kurz nicht bedient — laut Kommentar an MEGA_DEBRID_NO_SERVER_RE moeglich).
|
||||
// Wie "Antwort leer" ein Limit-Signal: feedet die Streak (limitSignal). Erst nach
|
||||
// mehreren Treffern wird der Account bis Neustart geparkt — ein einzelner (evtl.
|
||||
// transienter) Treffer erzwingt KEINEN Neustart, behaelt aber den 2-Min-Cooldown.
|
||||
if (MEGA_DEBRID_NO_SERVER_RE.test(errorText)) {
|
||||
return {
|
||||
fatal: false,
|
||||
@@ -2192,7 +2069,6 @@ class MegaDebridClient {
|
||||
};
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
if (/rate.?limit|too.?many|429/i.test(errorText)) {
|
||||
return {
|
||||
fatal: false,
|
||||
@@ -2202,12 +2078,6 @@ class MegaDebridClient {
|
||||
};
|
||||
}
|
||||
|
||||
// Mega-Web "Antwort leer" / empty body — kann zweierlei sein: (a) ein transienter
|
||||
// Blip (erholt sich in Sekunden → kurzer 20s-Cooldown reicht) ODER (b) ein
|
||||
// tageslimitierter Account, der PERSISTENT leer antwortet. Da beide Faelle auf
|
||||
// Message-Ebene identisch aussehen (in echten Logs taucht "Kein Server" nie auf,
|
||||
// immer nur "Antwort leer"), markieren wir emptyResponse=true; der Aufrufer zaehlt
|
||||
// die Streak und parkt den Account erst nach mehreren Leer-Antworten bis Neustart.
|
||||
if (/antwort\s+leer|empty\s+response|leere\s+antwort/i.test(errorText)) {
|
||||
return {
|
||||
fatal: false,
|
||||
@@ -2218,8 +2088,6 @@ class MegaDebridClient {
|
||||
};
|
||||
}
|
||||
|
||||
// Temporary/transport errors — short cooldown, try next account.
|
||||
// Plain network blips deserve a much shorter cooldown than 2 min.
|
||||
if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) {
|
||||
return {
|
||||
fatal: false,
|
||||
@@ -2229,7 +2097,6 @@ class MegaDebridClient {
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown errors — short cooldown, try next account (non-fatal)
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: 30_000,
|
||||
@@ -2660,8 +2527,6 @@ export async function fetchDebridLinkHostLimits(apiKeysRaw: string, host = "rapi
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── Debrid-Link Client ──
|
||||
|
||||
class DebridLinkClient {
|
||||
private apiKeys: ReturnType<typeof parseDebridLinkApiKeys>;
|
||||
|
||||
@@ -2689,12 +2554,8 @@ class DebridLinkClient {
|
||||
const linkShort = String(link || "").slice(0, 80);
|
||||
const linkHoster = extractHosterFromUrl(link);
|
||||
|
||||
// Always start from first key — use first available, skip disabled/limited/cooldown.
|
||||
// This ensures all parallel items use the same key until it's actually exhausted.
|
||||
for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) {
|
||||
const apiKey = this.apiKeys[keyIdx];
|
||||
// Always show key number — even with 1 key — so user can tell at a
|
||||
// glance which key is in play. Format: "(Key 2/3, abc***xyz)"
|
||||
const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`;
|
||||
const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`;
|
||||
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
|
||||
@@ -2722,9 +2583,6 @@ class DebridLinkClient {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Per-(key, host) cooldown — set when a previous attempt for THIS host
|
||||
// returned maxLinkHost / maxDataHost. The key itself is healthy for other
|
||||
// hosters, so we only skip it for this specific link.
|
||||
const hostCooldownState = linkHoster ? getDebridLinkKeyHostCooldownState(apiKey.id, linkHoster) : null;
|
||||
if (hostCooldownState) {
|
||||
const untilStr = new Date(hostCooldownState.until).toLocaleTimeString();
|
||||
@@ -2742,9 +2600,6 @@ class DebridLinkClient {
|
||||
continue;
|
||||
}
|
||||
|
||||
// CLEAR per-key TEST log line BEFORE the network call, so the user
|
||||
// can always see exactly which key is currently being tested for
|
||||
// link generation — even if the call hangs or times out.
|
||||
logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`);
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
|
||||
const testStartedAt = Date.now();
|
||||
@@ -2778,10 +2633,6 @@ class DebridLinkClient {
|
||||
failures.push(`Debrid-Link${keyLabel}: ${failure.message}`);
|
||||
if (failure.cooldownMs > 0) {
|
||||
if (failure.hostOnly) {
|
||||
// Per-(key, host) quota — block only this combination, not the
|
||||
// whole key. The key remains "ready" for other hosters. If the
|
||||
// hoster couldn't be parsed from the URL, the helper falls back
|
||||
// to a key-wide cooldown (better safe than thrashing).
|
||||
setDebridLinkKeyHostCooldownState(
|
||||
apiKey.id,
|
||||
failure.hoster || "",
|
||||
@@ -2797,10 +2648,6 @@ class DebridLinkClient {
|
||||
if (failure.category === "invalid") {
|
||||
setDebridLinkKeyRuntimeStatus(apiKey.id, "invalid", failure.message);
|
||||
} else if (failure.category !== "skip") {
|
||||
// "skip" means the LINK or HOST is unavailable (fileNotAvailable,
|
||||
// disabledServerHost, notFreeHost, freeServerOverload, ...), NOT
|
||||
// that the key is broken. The key responded normally — leave its
|
||||
// runtime status alone so the UI doesn't flag it as errored.
|
||||
setDebridLinkKeyRuntimeStatus(apiKey.id, "error", failure.message);
|
||||
}
|
||||
}
|
||||
@@ -2814,8 +2661,6 @@ class DebridLinkClient {
|
||||
throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`);
|
||||
}
|
||||
if (failure.providerWide) {
|
||||
// Host-level issue (e.g. notDebrid) — rotating to other keys is pointless.
|
||||
// Break immediately and apply a longer cooldown (5 min) to avoid burning all keys.
|
||||
const providerWideCooldownMs = 5 * 60 * 1000;
|
||||
logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`);
|
||||
logAccountRotation("ERROR", providerName, rotationLabel, "PROVIDER_WIDE", {
|
||||
@@ -2827,11 +2672,9 @@ class DebridLinkClient {
|
||||
});
|
||||
throw new Error(`debrid_link_cooldown:${providerWideCooldownMs}:Debrid-Link${keyLabel}: ${failure.message}`);
|
||||
}
|
||||
// Track consecutive transport failures (timeout/network) to detect cascades.
|
||||
const isTransport = isRetryableErrorText(failure.message) && !(error instanceof DebridLinkApiError);
|
||||
consecutiveTransportFailures = isTransport ? consecutiveTransportFailures + 1 : 0;
|
||||
if (consecutiveTransportFailures >= 2) {
|
||||
// 2+ keys timed out in a row — likely a server/network issue, not key-specific.
|
||||
const cascadeCooldownMs = 3 * 60 * 1000;
|
||||
logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`);
|
||||
logAccountRotation("ERROR", providerName, rotationLabel, "TRANSPORT_CASCADE", {
|
||||
@@ -2842,7 +2685,6 @@ class DebridLinkClient {
|
||||
});
|
||||
throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`);
|
||||
}
|
||||
// Find the next key that will be tried (for clearer log)
|
||||
let nextLabel = "ENDE";
|
||||
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
|
||||
const nextKey = this.apiKeys[nextIdx];
|
||||
@@ -2968,8 +2810,6 @@ class DebridLinkClient {
|
||||
return chosen;
|
||||
}
|
||||
|
||||
// Poll up to 5 times with 2s delay — Debrid-Link sometimes needs a few
|
||||
// seconds to generate the download URL after /downloader/add.
|
||||
const maxPolls = 5;
|
||||
for (let poll = 0; poll < maxPolls; poll++) {
|
||||
if (signal?.aborted) {
|
||||
@@ -2987,7 +2827,6 @@ class DebridLinkClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return last fetched entry (caller will detect missing URL and throw)
|
||||
return (await this.fetchDownloaderEntry(apiKey, id, signal)) || chosen;
|
||||
}
|
||||
|
||||
@@ -3045,9 +2884,6 @@ class DebridLinkClient {
|
||||
};
|
||||
}
|
||||
if (DEBRID_LINK_HOST_QUOTA_ERRORS.has(code)) {
|
||||
// Per-(key, host) quota — only this host is exhausted for this key.
|
||||
// The key remains usable for other hosters, so we mark the failure
|
||||
// hostOnly and let the rotation loop apply a per-(key, host) cooldown.
|
||||
const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
|
||||
const hosterRaw = extractHosterFromUrl(link);
|
||||
const hosterLabel = hosterRaw || "host";
|
||||
@@ -3061,7 +2897,6 @@ class DebridLinkClient {
|
||||
};
|
||||
}
|
||||
if (DEBRID_LINK_KEY_QUOTA_ERRORS.has(code)) {
|
||||
// Key-wide quota — whole key is exhausted, blocks all hosters.
|
||||
const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
|
||||
return {
|
||||
fatal: false,
|
||||
@@ -3071,7 +2906,6 @@ class DebridLinkClient {
|
||||
};
|
||||
}
|
||||
if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) {
|
||||
// notDebrid = host-level issue — affects ALL keys equally, do NOT rotate.
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
||||
@@ -3110,8 +2944,6 @@ class DebridLinkClient {
|
||||
};
|
||||
}
|
||||
|
||||
// Treat missing/expired download URLs as temporary — the server may need
|
||||
// more time or another key might succeed immediately.
|
||||
if (/keine gueltige download-url/i.test(errorText)) {
|
||||
return {
|
||||
fatal: false,
|
||||
@@ -3122,11 +2954,6 @@ class DebridLinkClient {
|
||||
}
|
||||
|
||||
if (isRetryableErrorText(errorText) || /debrid-link.*(json|html)/i.test(errorText)) {
|
||||
// Distinguish a single transient transport error (timeout, network blip,
|
||||
// ECONNRESET) from a real API/server problem. Single timeouts shouldn't
|
||||
// park a key for 2 full minutes — that just delays parallel work for
|
||||
// no reason. Use a short 15s cooldown for transport, full 2min only
|
||||
// for things that look like server-side faults (5xx HTML pages, etc).
|
||||
const isTransport = /timeout|network|fetch failed|aborted|econnreset|enotfound|etimedout|socket/i.test(errorText)
|
||||
&& !(error instanceof DebridLinkApiError);
|
||||
return {
|
||||
@@ -3136,9 +2963,6 @@ class DebridLinkClient {
|
||||
};
|
||||
}
|
||||
|
||||
// HTTP 200 with success:false but no recognizable error code: don't kill
|
||||
// the item permanently. Treat as a temporary blip — same key can be tried
|
||||
// again after a short cooldown, or another key picked up.
|
||||
if (errorText && /success.*false|kein.*json|empty.*response/i.test(errorText)) {
|
||||
return {
|
||||
fatal: false,
|
||||
@@ -3156,8 +2980,6 @@ class DebridLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ── LinkSnappy Client ──
|
||||
|
||||
class LinkSnappyClient {
|
||||
private username: string;
|
||||
private password: string;
|
||||
@@ -3249,7 +3071,6 @@ class LinkSnappyClient {
|
||||
if (!directUrl) {
|
||||
throw new Error("LinkSnappy: Keine Download-URL in Antwort");
|
||||
}
|
||||
// LinkSnappy liefert http:// URLs – auf https:// upgraden (deren Server unterstützt beides)
|
||||
if (directUrl.startsWith("http://")) {
|
||||
directUrl = directUrl.replace("http://", "https://");
|
||||
}
|
||||
@@ -3300,8 +3121,6 @@ function parseFileSizeString(s: string): number {
|
||||
return Math.floor(num * (multipliers[unit] || 1));
|
||||
}
|
||||
|
||||
// ── 1Fichier Client ──
|
||||
|
||||
class OneFichierClient {
|
||||
private apiKey: string;
|
||||
|
||||
@@ -3375,7 +3194,6 @@ class DdownloadClient {
|
||||
}
|
||||
|
||||
private async webLogin(signal?: AbortSignal): Promise<void> {
|
||||
// Step 1: GET login page to extract form token
|
||||
const loginPageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/login.html`, {
|
||||
headers: { "User-Agent": DDOWNLOAD_WEB_UA },
|
||||
redirect: "manual",
|
||||
@@ -3385,7 +3203,6 @@ class DdownloadClient {
|
||||
const tokenMatch = loginPageHtml.match(/name="token" value="([^"]+)"/);
|
||||
const pageCookies = (loginPageRes.headers.getSetCookie?.() || []).map((c: string) => c.split(";")[0]).join("; ");
|
||||
|
||||
// Step 2: POST login
|
||||
const body = new URLSearchParams({
|
||||
op: "login",
|
||||
token: tokenMatch?.[1] || "",
|
||||
@@ -3406,8 +3223,7 @@ class DdownloadClient {
|
||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
// Drain body
|
||||
try { await loginRes.text(); } catch { /* ignore */ }
|
||||
try { await loginRes.text(); } catch { }
|
||||
|
||||
const setCookies = loginRes.headers.getSetCookie?.() || [];
|
||||
const xfss = setCookies.find((c: string) => c.startsWith("xfss="));
|
||||
@@ -3430,12 +3246,10 @@ class DdownloadClient {
|
||||
try {
|
||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||
|
||||
// Login if no session yet
|
||||
if (!this.cookies) {
|
||||
await this.webLogin(signal);
|
||||
}
|
||||
|
||||
// Step 1: GET file page to extract form fields
|
||||
const filePageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, {
|
||||
headers: {
|
||||
"User-Agent": DDOWNLOAD_WEB_UA,
|
||||
@@ -3445,10 +3259,9 @@ class DdownloadClient {
|
||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
// Premium with direct downloads enabled → redirect immediately
|
||||
if (filePageRes.status >= 300 && filePageRes.status < 400) {
|
||||
const directUrl = filePageRes.headers.get("location") || "";
|
||||
try { await filePageRes.text(); } catch { /* drain */ }
|
||||
try { await filePageRes.text(); } catch { }
|
||||
if (directUrl) {
|
||||
return {
|
||||
fileName: filenameFromUrl(directUrl) || filenameFromUrl(link),
|
||||
@@ -3462,18 +3275,15 @@ class DdownloadClient {
|
||||
|
||||
const html = await filePageRes.text();
|
||||
|
||||
// Check for file not found
|
||||
if (/File Not Found|file was removed|file was banned/i.test(html)) {
|
||||
throw new Error("DDownload: Datei nicht gefunden");
|
||||
}
|
||||
|
||||
// Extract form fields
|
||||
const idVal = html.match(/name="id" value="([^"]+)"/)?.[1] || fileCode;
|
||||
const randVal = html.match(/name="rand" value="([^"]+)"/)?.[1] || "";
|
||||
const fileNameMatch = html.match(/class="file-info-name"[^>]*>([^<]+)</);
|
||||
const fileName = fileNameMatch?.[1]?.trim() || filenameFromUrl(link);
|
||||
|
||||
// Step 2: POST download2 for premium download
|
||||
const dlBody = new URLSearchParams({
|
||||
op: "download2",
|
||||
id: idVal,
|
||||
@@ -3498,7 +3308,7 @@ class DdownloadClient {
|
||||
|
||||
if (dlRes.status >= 300 && dlRes.status < 400) {
|
||||
const directUrl = dlRes.headers.get("location") || "";
|
||||
try { await dlRes.text(); } catch { /* drain */ }
|
||||
try { await dlRes.text(); } catch { }
|
||||
if (directUrl) {
|
||||
return {
|
||||
fileName: fileName || filenameFromUrl(directUrl),
|
||||
@@ -3511,7 +3321,6 @@ class DdownloadClient {
|
||||
}
|
||||
|
||||
const dlHtml = await dlRes.text();
|
||||
// Try to find direct URL in response HTML
|
||||
const directMatch = dlHtml.match(/https?:\/\/[a-z0-9]+\.(?:dstorage\.org|ddownload\.com|ddl\.to|ucdn\.to)[^\s"'<>]+/i);
|
||||
if (directMatch) {
|
||||
return {
|
||||
@@ -3523,7 +3332,6 @@ class DdownloadClient {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for error messages
|
||||
const errMatch = dlHtml.match(/class="err"[^>]*>([^<]+)</i);
|
||||
if (errMatch) {
|
||||
throw new Error(`DDownload: ${errMatch[1].trim()}`);
|
||||
@@ -3535,7 +3343,6 @@ class DdownloadClient {
|
||||
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
||||
break;
|
||||
}
|
||||
// Re-login on auth errors
|
||||
if (/login|session|cookie/i.test(lastError)) {
|
||||
this.cookies = "";
|
||||
}
|
||||
@@ -3571,10 +3378,6 @@ export class DebridService {
|
||||
const prev = this.settings;
|
||||
this.settings = cloneSettings(next);
|
||||
|
||||
// Invalidate cached provider clients whose credentials/keys changed.
|
||||
// Without this, switching API keys or session-cookie-bound accounts
|
||||
// (LinkSnappy, Ddownload) would keep using the previous Client instance
|
||||
// — which holds the OLD session cookies — until the app is restarted.
|
||||
if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) {
|
||||
this.cachedDebridLinkClient = null;
|
||||
this.cachedDebridLinkKey = "";
|
||||
@@ -3588,12 +3391,6 @@ export class DebridService {
|
||||
this.cachedDdownloadKey = "";
|
||||
}
|
||||
|
||||
// Mega-Debrid token cache (static, module-level): tokens are keyed by
|
||||
// login (lowercase). When credentials change, drop tokens for logins
|
||||
// that are no longer in the active account list, AND force-clear any
|
||||
// login whose password changed. Otherwise stale tokens linger up to
|
||||
// 20 minutes and the new credentials won't be tried until the cached
|
||||
// token starts returning 401/403.
|
||||
const prevAccounts = parseMegaDebridAccounts(prev.megaCredentials || "", prev.megaPassword || "");
|
||||
const nextAccounts = parseMegaDebridAccounts(next.megaCredentials || "", next.megaPassword || "");
|
||||
const nextLogins = new Set<string>();
|
||||
@@ -3602,17 +3399,13 @@ export class DebridService {
|
||||
nextLogins.add(acc.login.toLowerCase());
|
||||
nextPasswordByLogin.set(acc.login.toLowerCase(), acc.password);
|
||||
}
|
||||
// Drop tokens for logins no longer present
|
||||
MegaDebridClient.pruneCachedTokensNotIn(nextLogins);
|
||||
// For logins still present but with a changed password, force-clear the token
|
||||
for (const prevAcc of prevAccounts) {
|
||||
const loginKey = prevAcc.login.toLowerCase();
|
||||
if (nextLogins.has(loginKey) && nextPasswordByLogin.get(loginKey) !== prevAcc.password) {
|
||||
MegaDebridClient.clearCachedApiToken(prevAcc.login);
|
||||
}
|
||||
}
|
||||
// Also prune module-level Debrid-Link cooldowns for keys that no longer exist —
|
||||
// otherwise a key removed and re-added later would still show its old cooldown.
|
||||
const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
|
||||
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
|
||||
}
|
||||
@@ -3683,14 +3476,9 @@ export class DebridService {
|
||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||
throw error;
|
||||
}
|
||||
// ignore and continue with host page fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Mega.nz Pre-Resolve via Public API (kein Mega-Debrid-Quota-Verbrauch).
|
||||
// Liefert echten Filename sobald Links in die Queue kommen, anstatt erst
|
||||
// beim Unrestrict. Concurrency 4 — Mega's Public API ist tolerant gegen
|
||||
// kleine Bursts.
|
||||
const megaLinks = unresolved.filter((link) => !clean.has(link) && isMegaFileUrl(link));
|
||||
if (megaLinks.length > 0) {
|
||||
await runWithConcurrency(megaLinks, 4, async (link) => {
|
||||
@@ -3704,8 +3492,6 @@ export class DebridService {
|
||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||
throw error;
|
||||
}
|
||||
// Schluck — Public API kann fehlen oder rate-limiten; faellt auf
|
||||
// den normalen Mega-Debrid Unrestrict-Pfad zurueck.
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3766,7 +3552,6 @@ export class DebridService {
|
||||
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
|
||||
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
||||
|
||||
// Hoster-Zuordnung: prüfe ob für diesen Hoster ein bestimmter Provider konfiguriert ist
|
||||
const routing = settings.hosterRouting || {};
|
||||
const hosterKey = extractHosterFromUrl(link);
|
||||
if (hosterKey && routing[hosterKey]) {
|
||||
@@ -3795,7 +3580,6 @@ export class DebridService {
|
||||
throw new Error(`Hoster-Zuordnung fehlgeschlagen (${hosterKey} → ${PROVIDER_LABELS[routedProvider]}): ${errorText}`);
|
||||
}
|
||||
logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} fehlgeschlagen, Fallback auf Provider-Kette: ${errorText}`);
|
||||
// Fall through to normal provider chain
|
||||
}
|
||||
} else if (this.isProviderConfiguredFor(settings, routedProvider) && this.isProviderDailyLimited(settings, routedProvider)) {
|
||||
logger.info(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} übersprungen (${this.formatProviderLimitMessage(settings, routedProvider)})`);
|
||||
@@ -3804,8 +3588,6 @@ export class DebridService {
|
||||
}
|
||||
}
|
||||
|
||||
// 1Fichier is a direct file hoster. If the link is a 1fichier.com URL
|
||||
// and the API key is configured, use 1Fichier directly before debrid providers.
|
||||
if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) {
|
||||
try {
|
||||
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
|
||||
@@ -3819,13 +3601,9 @@ export class DebridService {
|
||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||
throw error;
|
||||
}
|
||||
// Fall through to normal provider chain
|
||||
}
|
||||
}
|
||||
|
||||
// DDownload is a direct file hoster, not a debrid service.
|
||||
// If the link is a ddownload.com/ddl.to URL and the account is configured,
|
||||
// use DDownload directly before trying any debrid providers.
|
||||
if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) {
|
||||
try {
|
||||
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
|
||||
@@ -3839,11 +3617,9 @@ export class DebridService {
|
||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||
throw error;
|
||||
}
|
||||
// Fall through to normal provider chain (debrid services may also support ddownload links)
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamische Reihenfolge: providerOrder hat Vorrang, Fallback auf altes primary/secondary/tertiary
|
||||
const order: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0)
|
||||
? uniqueProviderOrder(settings.providerOrder)
|
||||
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
|
||||
|
||||
@@ -116,7 +116,6 @@ function getPort(baseDir: string): number {
|
||||
return n;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return DEFAULT_PORT;
|
||||
}
|
||||
@@ -135,7 +134,6 @@ function getHost(baseDir: string): string {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return DEFAULT_HOST;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ function readPort(baseDir: string): number {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return DEFAULT_PORT;
|
||||
}
|
||||
@@ -71,7 +70,6 @@ function readHost(baseDir: string): string {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return DEFAULT_HOST;
|
||||
}
|
||||
@@ -158,7 +156,6 @@ function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): Suppor
|
||||
bytes += fs.statSync(fullPath).size;
|
||||
fileCount += 1;
|
||||
} catch {
|
||||
// ignore unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,26 +2,6 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
|
||||
/**
|
||||
* Session-eigenes Rename-Protokoll auf dem DESKTOP des Nutzers.
|
||||
*
|
||||
* Ziel (User-Anforderung): bei zukuenftigen Renaming-Problemen eine luekenlose,
|
||||
* sofort auffindbare Uebersicht haben — JEDER Umbenenn-/Verschiebevorgang wird
|
||||
* protokolliert UND danach verifiziert (liegt die Datei wirklich unter dem
|
||||
* Zielnamen auf der Platte? ist die Quelle weg?). Nur weil fs.rename "ok" meldet,
|
||||
* heisst das nicht, dass das Ergebnis stimmt (Gross-/Kleinschreibung, Unicode-
|
||||
* Normalisierung, halb-fertiger EXDEV-Copy ohne geloeschte Quelle, ...).
|
||||
*
|
||||
* - Pro Programm-Sitzung eine eigene Datei: <Desktop>/Downloader-Log/rename-session_<ts>.txt
|
||||
* - Der Ordner wird beim Start angelegt UND vor JEDEM Schreibvorgang selbstheilend
|
||||
* neu angelegt (mkdir recursive) — wird er zur Laufzeit geloescht, ist er beim
|
||||
* naechsten Rename sofort wieder da, inkl. neu geschriebenem Session-Header.
|
||||
* - Synchroner Append (wie rename-log.ts), kein gepufferter Flush: Renames sind
|
||||
* selten genug, und so gibt es kein "geloescht-waehrend-Flush"-Zeitfenster.
|
||||
* - Schlaegt das Logging fehl, wird der Fehler verschluckt — Logging darf einen
|
||||
* Download niemals abbrechen.
|
||||
*/
|
||||
|
||||
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const FOLDER_NAME = "Downloader-Log";
|
||||
@@ -30,8 +10,6 @@ let logDir: string | null = null;
|
||||
let logFilePath: string | null = null;
|
||||
let sessionHeader = "";
|
||||
|
||||
/** Lokaler Zeitstempel fuer den DATEINAMEN (keine Doppelpunkte — unter Windows
|
||||
* in Dateinamen verboten): YYYY-MM-DD_HH-MM-SS in lokaler Zeit. */
|
||||
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())}_`
|
||||
@@ -65,9 +43,6 @@ function formatFields(fields?: Record<string, unknown>): string {
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
/** Stellt sicher, dass Ordner UND Session-Datei existieren (selbstheilend, auch
|
||||
* wenn beides zur Laufzeit geloescht wurde). Gibt false zurueck, wenn das
|
||||
* Logging nicht initialisiert ist oder das Anlegen scheitert. */
|
||||
function ensureWritable(): boolean {
|
||||
if (!logDir || !logFilePath) {
|
||||
return false;
|
||||
@@ -83,9 +58,6 @@ function ensureWritable(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Initialisiert das Desktop-Rename-Log fuer diese Sitzung. `desktopDir` ist der
|
||||
* Desktop-Pfad (app.getPath("desktop")). Faellt still auf no-op zurueck, wenn der
|
||||
* Pfad fehlt oder nicht beschreibbar ist. */
|
||||
export function initDesktopRenameLog(desktopDir: string | null | undefined): void {
|
||||
try {
|
||||
const base = String(desktopDir || "").trim();
|
||||
@@ -108,9 +80,6 @@ export function initDesktopRenameLog(desktopDir: string | null | undefined): voi
|
||||
}
|
||||
}
|
||||
|
||||
/** Schreibt eine Zeile ins Desktop-Rename-Log. Tut nichts, wenn nicht
|
||||
* initialisiert; verschluckt jeden Schreibfehler (darf nie einen Download
|
||||
* abbrechen). */
|
||||
export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void {
|
||||
if (!ensureWritable() || !logFilePath) {
|
||||
return;
|
||||
@@ -118,7 +87,6 @@ export function logDesktopRename(level: DesktopRenameLevel, message: string, fie
|
||||
try {
|
||||
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
|
||||
} catch {
|
||||
// Logging darf einen Download niemals abbrechen.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +106,6 @@ export function shutdownDesktopRenameLog(): void {
|
||||
try {
|
||||
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
logDir = null;
|
||||
@@ -146,34 +113,16 @@ export function shutdownDesktopRenameLog(): void {
|
||||
}
|
||||
|
||||
export interface RenameVerification {
|
||||
/** Gesamtergebnis: Datei liegt unter dem EXAKT erwarteten Namen vor und (sofern kein
|
||||
* In-Place-Rename) die Quelle ist verschwunden. */
|
||||
ok: boolean;
|
||||
/** Empfohlenes Log-Level: ERROR (Rename nicht vollzogen / falscher Name),
|
||||
* WARN (vollzogen, aber Schreibweise nicht pruefbar), INFO (alles ok). */
|
||||
level: "INFO" | "WARN" | "ERROR";
|
||||
/** Zieldatei (egal welche Schreibweise) auf der Platte vorhanden? */
|
||||
targetExists: boolean;
|
||||
/** Tatsaechlicher Name auf der Platte (Gross-/Kleinschreibung wie wirklich
|
||||
* gespeichert), oder null wenn nicht gefunden / Verzeichnis nicht lesbar. */
|
||||
onDiskName: string | null;
|
||||
/** onDiskName === erwarteter Zielname (exakt, case-sensitive)? */
|
||||
nameMatches: boolean;
|
||||
/** Quelldatei verschwunden (Rename wirklich vollzogen, kein halb-fertiger Copy)? */
|
||||
sourceGone: boolean;
|
||||
/** Groesse der Zieldatei in Bytes, oder null. */
|
||||
targetSize: number | null;
|
||||
/** Menschenlesbarer Grund, wenn nicht sauber INFO. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Repliziert download-manager.toWindowsLongPathIfNeeded (ein Import waere zirkulaer:
|
||||
* download-manager -> desktop-rename-log). Node fs-Aufrufe scheitern unter Windows fuer
|
||||
* absolute Pfade >=248 Zeichen, sofern nicht mit \\?\ / \\?\UNC\ praefixiert — und genau
|
||||
* solche langen Scene-Release-Pfade benennt diese App um. OHNE dieses Prefix wuerden
|
||||
* statSync/readdirSync in der Verifikation auf langen Pfaden faelschlich scheitern
|
||||
* (falsches "Ziel nicht gefunden" UND falsches "Quelle weg" -> falsches OK, das einen
|
||||
* halb-fertigen Verschiebevorgang maskiert). */
|
||||
function toLongPath(filePath: string): string {
|
||||
const absolute = path.resolve(String(filePath || ""));
|
||||
if (process.platform !== "win32") {
|
||||
@@ -191,9 +140,6 @@ function toLongPath(filePath: string): string {
|
||||
return `\\\\?\\${absolute}`;
|
||||
}
|
||||
|
||||
/** Echter On-Disk-Name (korrekte Schreibweise) fuer `requested` aus den
|
||||
* Verzeichnis-Eintraegen, oder null wenn das Verzeichnis nicht lesbar war
|
||||
* (entries===null) bzw. nichts passt. */
|
||||
function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
|
||||
if (entries === null) {
|
||||
return null;
|
||||
@@ -204,8 +150,6 @@ function resolveOnDiskName(requested: string, entries: string[] | null): string
|
||||
|| requested;
|
||||
}
|
||||
|
||||
/** Baut das Verifikations-Ergebnis aus den (sync ODER async) erhobenen Roh-Fakten.
|
||||
* `dirEntries`=null bedeutet "Zielverzeichnis war nicht lesbar". */
|
||||
function buildVerification(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
@@ -215,8 +159,6 @@ function buildVerification(
|
||||
const dirReadFailed = facts.targetExists && facts.dirEntries === null;
|
||||
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
|
||||
|
||||
// In-Place-Rename (reine Gross-/Kleinschreibungs-Korrektur auf case-insensitivem FS):
|
||||
// Quelle == Ziel -> "Quelle weg" gilt nicht.
|
||||
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
|
||||
const sourceGone = samePath ? true : !facts.sourceExists;
|
||||
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
|
||||
@@ -235,7 +177,6 @@ function buildVerification(
|
||||
level = "ERROR";
|
||||
}
|
||||
if (level === "INFO" && dirReadFailed) {
|
||||
// Datei da + Quelle weg, aber Schreibweise ungeprueft — KEIN stilles OK.
|
||||
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
|
||||
level = "WARN";
|
||||
}
|
||||
@@ -252,10 +193,6 @@ function buildVerification(
|
||||
};
|
||||
}
|
||||
|
||||
/** Verifiziert NACH einem Rename SYNCHRON, ob das Ergebnis wirklich stimmt — der Kern
|
||||
* der User-Anforderung ("nur weil er renaming sagt heisst es nicht das es klappt").
|
||||
* Fuer die synchronen Rename-Sites (startup-Dedup, Suffix-Fix, Deobfuskation). Rein
|
||||
* lesend, wirft nie. fs-Aufrufe ueber toLongPath (lange Windows-Pfade!). */
|
||||
export function verifyRename(sourcePath: string, targetPath: string): RenameVerification {
|
||||
const longTarget = toLongPath(targetPath);
|
||||
let targetExists = false;
|
||||
@@ -285,9 +222,6 @@ export function verifyRename(sourcePath: string, targetPath: string): RenameVeri
|
||||
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
||||
}
|
||||
|
||||
/** Asynchrone Verifikation — fuer den Media-Rename-Hot-Path (renamePathWithExdevFallback),
|
||||
* damit KEIN synchrones statSync/readdirSync den Electron-Main-Loop in Saison-Pack-
|
||||
* Rename-Schleifen blockiert (Projekt-Regel: kein sync I/O in Hot Paths). Wirft nie. */
|
||||
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
|
||||
const longTarget = toLongPath(targetPath);
|
||||
let targetExists = false;
|
||||
|
||||
@@ -127,11 +127,6 @@ export function validateDownloadedFileCompletion(args: {
|
||||
}
|
||||
|
||||
if (args.plan.source === "stream-end") {
|
||||
// H3: Kein Content-Length, keine Provider-Größe UND 0 Bytes empfangen → der
|
||||
// Hoster hat die Verbindung sofort geschlossen. Das ist ein fehlgeschlagener
|
||||
// Download, kein gültiges "fertig" — sonst gilt eine leere Datei als komplett
|
||||
// und es gibt keinen Auto-Redownload. Verhält sich jetzt wie der bereits
|
||||
// behandelte Fall actualBytes<=0 mit bekannter Größe (oben).
|
||||
if (actualBytes <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
+11933
-12834
File diff suppressed because it is too large
Load Diff
+6
-164
@@ -1,7 +1,3 @@
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 1 — Imports & Konstanten
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
@@ -47,10 +43,6 @@ const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000;
|
||||
const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80;
|
||||
let currentExtractCpuPriority: string | undefined;
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 2 — Types & Interfaces
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface ExtractOptions {
|
||||
packageDir: string;
|
||||
targetDir: string;
|
||||
@@ -169,20 +161,15 @@ interface DaemonRequest {
|
||||
passwordCount: number;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 3 — Subst Drive Mapping (Windows long-path workaround)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const activeSubstDrives = new Set<string>();
|
||||
|
||||
function findFreeSubstDrive(): string | null {
|
||||
if (process.platform !== "win32") return null;
|
||||
for (let code = 90; code >= 71; code--) { // Z to G
|
||||
for (let code = 90; code >= 71; code--) {
|
||||
const letter = String.fromCharCode(code);
|
||||
if (activeSubstDrives.has(letter)) continue;
|
||||
try {
|
||||
fs.accessSync(`${letter}:\\`);
|
||||
// Drive exists, skip
|
||||
} catch {
|
||||
return letter;
|
||||
}
|
||||
@@ -226,14 +213,9 @@ export function cleanupStaleSubstDrives(): void {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore — subst cleanup is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 4 — Archiv-Erkennung & Kandidaten
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function detectArchiveSignature(filePath: string): Promise<ArchiveSignature> {
|
||||
let fd: fs.promises.FileHandle | null = null;
|
||||
try {
|
||||
@@ -368,7 +350,6 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
|
||||
return !fileNamesLower.has(`${fileName}.001`.toLowerCase());
|
||||
});
|
||||
const tarCompressed = files.filter((filePath) => /\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(filePath));
|
||||
// Generic .001 splits (HJSplit etc.) — exclude already-recognized .zip.001 and .7z.001
|
||||
const genericSplit = files.filter((filePath) => {
|
||||
const fileName = archiveDetectionName(filePath).toLowerCase();
|
||||
if (!/\.001$/.test(fileName)) return false;
|
||||
@@ -406,10 +387,6 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
|
||||
return unique;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 5 — Cleanup & Dateisystem
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -438,8 +415,6 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
|
||||
}
|
||||
};
|
||||
|
||||
// Companion metadata files (.sfv, .nfo, .md5, etc.) share the same base stem
|
||||
// as the archive and should be cleaned up together with the archive parts.
|
||||
const COMPANION_EXTS_RE = /\.(?:sfv|nfo|nzb|md5|sha1|sha256|crc|srr)$/i;
|
||||
const addCompanions = (stemRe: string): void => {
|
||||
for (const candidate of filesInDir) {
|
||||
@@ -504,12 +479,10 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
|
||||
return Array.from(targets);
|
||||
}
|
||||
|
||||
// Tar compound archives (.tar.gz, .tar.bz2, .tar.xz, .tgz, .tbz2, .txz)
|
||||
if (/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(fileName)) {
|
||||
return Array.from(targets);
|
||||
}
|
||||
|
||||
// Generic .NNN split files (HJSplit etc.)
|
||||
const genericSplit = fileName.match(/^(.*)\.(\d{3})$/i);
|
||||
if (genericSplit) {
|
||||
const stem = escapeRegex(genericSplit[1]);
|
||||
@@ -572,7 +545,6 @@ export async function cleanupArchives(
|
||||
index += 1;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -595,7 +567,6 @@ export async function cleanupArchives(
|
||||
await fs.promises.rm(filePath, { force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
@@ -684,16 +655,11 @@ export async function removeEmptyDirectoryTree(rootDir: string): Promise<number>
|
||||
removed += 1;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 6 — Passwort-Management (LRU-Cache & Kandidaten)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function packagePasswordCacheKey(packageDir: string, packageId?: string): string {
|
||||
const normalizedPackageId = String(packageId || "").trim();
|
||||
if (normalizedPackageId) {
|
||||
@@ -715,7 +681,6 @@ function readCachedPackagePassword(cacheKey: string): string {
|
||||
if (!cached) {
|
||||
return "";
|
||||
}
|
||||
// Refresh insertion order to keep recently used package caches alive.
|
||||
packageLearnedPasswords.delete(cacheKey);
|
||||
packageLearnedPasswords.set(cacheKey, cached);
|
||||
return cached;
|
||||
@@ -742,24 +707,6 @@ function clearCachedPackagePassword(cacheKey: string): void {
|
||||
packageLearnedPasswords.delete(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den Extractor-Zustand zurück, wenn der User die Archiv-Passwortliste
|
||||
* ändert. Repliziert, was ein App-Neustart am Extractor-Subsystem tut:
|
||||
* - leert den In-Memory Learned-Password-Cache (gelernte Passwörter aller Pakete)
|
||||
* - fährt den langlebigen JVM-Daemon herunter (sofern nicht gerade beschäftigt),
|
||||
* damit die nächste Extraktion mit einem frischen Prozess + frischen Passwörtern
|
||||
* startet.
|
||||
*
|
||||
* Hintergrund: User-Report — ein neu hinzugefügtes Passwort griff bei "Jetzt
|
||||
* entpacken" erst NACH App-Neustart. Die gesamte TS/Java-Kette propagiert die
|
||||
* Liste pro Request korrekt; die einzige zustandsbehaftete Komponente, die ein
|
||||
* Neustart zurücksetzt (und dieser Aufruf ebenfalls), ist der Daemon-Prozess.
|
||||
*
|
||||
* Bewusst KEIN Shutdown eines beschäftigten Daemons: läuft gerade eine Extraktion
|
||||
* (z.B. weil Settings während des Entpackens gespeichert werden), bleibt sie
|
||||
* unangetastet — der nächste Lauf bekommt dann ggf. noch den alten Daemon, aber
|
||||
* der häufige Fall (Liste im Leerlauf ändern) wird sauber abgedeckt.
|
||||
*/
|
||||
export function resetExtractorCachesForPasswordChange(): { learnedCleared: number; daemonRestarted: boolean } {
|
||||
const learnedCleared = packageLearnedPasswords.size;
|
||||
packageLearnedPasswords.clear();
|
||||
@@ -820,10 +767,6 @@ function prioritizePassword(passwords: string[], successful: string): string[] {
|
||||
return next;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 7 — Fehler-Klassifizierung
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function cleanErrorText(text: string): string {
|
||||
const normalized = String(text || "").replace(/\s+/g, " ").trim();
|
||||
if (normalized.length <= 500) {
|
||||
@@ -964,10 +907,6 @@ function isJvmRuntimeMissingError(errorText: string): boolean {
|
||||
|| text.includes("enoent");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 8 — Backend-Modus (auto / jvm / legacy)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function resolveExtractorBackendMode(
|
||||
rawValue?: string | null,
|
||||
isVitestEnv = Boolean(process.env.VITEST)
|
||||
@@ -993,9 +932,6 @@ export function resolveExtractorBackendModeForArchive(
|
||||
if (requestedMode !== "auto") {
|
||||
return requestedMode;
|
||||
}
|
||||
// On Windows, multipart RAR extraction feels significantly snappier with the
|
||||
// native CLI path than with the JVM backend, and we already harden that path
|
||||
// with subst + flat-mode fallback.
|
||||
if (String(platform || "").toLowerCase() === "win32" && isRarArchivePath(archivePath)) {
|
||||
return "legacy";
|
||||
}
|
||||
@@ -1014,10 +950,6 @@ function isRarArchivePath(filePath: string): boolean {
|
||||
return /\.(?:rar|r\d{2,3})$/i.test(String(filePath || ""));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 9 — Native Extractor Resolution (7-Zip / WinRAR)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function is7zCommand(command: string): boolean {
|
||||
const lower = command.toLowerCase();
|
||||
return lower.includes("7z") && !lower.includes("unrar") && !lower.includes("winrar");
|
||||
@@ -1229,12 +1161,6 @@ async function findAlternativeExtractor(currentCommand: string, archivePath = ""
|
||||
return null;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 10 — CPU / Thread / Priority
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Compute a safe JVM -Xmx value based on available physical RAM.
|
||||
* Reserves 4 GB for Windows + Electron + other processes, caps at 16 GB. */
|
||||
function jvmMaxHeapArg(): string {
|
||||
const totalGb = os.totalmem() / (1024 ** 3);
|
||||
const heapGb = Math.max(1, Math.min(Math.floor(totalGb - 4), 16));
|
||||
@@ -1301,21 +1227,15 @@ function lowerExtractProcessPriority(childPid: number | undefined, cpuPriority?:
|
||||
try {
|
||||
os.setPriority(pid, extractOsPriority(cpuPriority));
|
||||
} catch {
|
||||
// ignore: priority lowering is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 11 — Prozess-Ausführung (spawn, kill, progress parsing)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
||||
const pid = Number(child.pid || 0);
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1330,14 +1250,12 @@ function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -1346,7 +1264,6 @@ function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1503,10 +1420,6 @@ function runExtractCommand(
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 12 — JVM Backend & Daemon
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let cachedJvmLayout: JvmExtractorLayout | null | undefined;
|
||||
let cachedJvmLayoutNullSince = 0;
|
||||
const JVM_LAYOUT_NULL_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -1628,10 +1541,6 @@ function parseJvmLine(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persistent JVM Daemon ──
|
||||
// Keeps a single JVM process alive across multiple extraction requests,
|
||||
// eliminating the ~5s JVM boot overhead per archive.
|
||||
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
let daemonReady = false;
|
||||
let daemonBusy = false;
|
||||
@@ -1645,8 +1554,8 @@ let daemonLayout: JvmExtractorLayout | null = null;
|
||||
|
||||
export function shutdownDaemon(): void {
|
||||
if (daemonProcess) {
|
||||
try { daemonProcess.stdin?.end(); } catch { /* ignore */ }
|
||||
try { killProcessTree(daemonProcess); } catch { /* ignore */ }
|
||||
try { daemonProcess.stdin?.end(); } catch { }
|
||||
try { killProcessTree(daemonProcess); } catch { }
|
||||
daemonProcess = null;
|
||||
}
|
||||
daemonReady = false;
|
||||
@@ -1822,7 +1731,6 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
|
||||
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
|
||||
});
|
||||
}
|
||||
// Clean up tmp dir
|
||||
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
|
||||
daemonProcess = null;
|
||||
daemonReady = false;
|
||||
@@ -1845,7 +1753,6 @@ function isDaemonAvailable(layout: JvmExtractorLayout): boolean {
|
||||
return Boolean(daemonProcess && daemonReady && !daemonBusy);
|
||||
}
|
||||
|
||||
/** Wait for the daemon to become ready (boot phase) or free (busy phase), with timeout. */
|
||||
function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
@@ -1958,14 +1865,12 @@ async function runJvmExtractCommand(
|
||||
});
|
||||
}
|
||||
|
||||
// Try persistent daemon first — saves ~5s JVM boot per archive
|
||||
if (isDaemonAvailable(layout)) {
|
||||
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
||||
logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`);
|
||||
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
|
||||
}
|
||||
|
||||
// Daemon exists but is still booting or busy — wait up to 15s for it
|
||||
if (daemonProcess) {
|
||||
const reason = !daemonReady ? "booting" : "busy";
|
||||
const waitStartedAt = Date.now();
|
||||
@@ -1980,7 +1885,6 @@ async function runJvmExtractCommand(
|
||||
logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`);
|
||||
}
|
||||
|
||||
// Fallback: spawn a new JVM process (daemon not available after waiting)
|
||||
logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}`);
|
||||
|
||||
const mode = effectiveConflictMode(conflictMode);
|
||||
@@ -2149,10 +2053,6 @@ async function runJvmExtractCommand(
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 13 — Legacy Extraction (buildExternalExtractArgs, runExternalExtract*)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function buildExternalExtractArgs(
|
||||
command: string,
|
||||
archivePath: string,
|
||||
@@ -2179,7 +2079,6 @@ export function buildExternalExtractArgs(
|
||||
return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`];
|
||||
}
|
||||
|
||||
// Delay helper for extraction retries
|
||||
const extractRetryDelay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function runExternalExtractInner(
|
||||
@@ -2213,7 +2112,6 @@ async function runExternalExtractInner(
|
||||
let createErrorText = "";
|
||||
let createErrorPassword = "";
|
||||
|
||||
// Skip normal extraction loop if flat mode is already known to be needed for this package
|
||||
if (forceFlatMode) {
|
||||
logger.info(`Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
||||
onLog?.("INFO", `Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
||||
@@ -2330,8 +2228,6 @@ async function runExternalExtractInner(
|
||||
lastError = result.errorText;
|
||||
}
|
||||
|
||||
// Some archives store internal paths with a leading \, causing invalid \\ paths.
|
||||
// Retry in flat mode ("e" instead of "x") which strips all archive paths.
|
||||
const pathCreateError = createErrorText || (lastError.includes("Cannot create") ? lastError : "");
|
||||
if (pathCreateError) {
|
||||
const flatPasswords = createErrorPassword
|
||||
@@ -2455,7 +2351,6 @@ async function runExternalExtract(
|
||||
}
|
||||
}
|
||||
|
||||
// Use a short drive mapping for legacy native extractors on Windows.
|
||||
subst = createSubstMapping(targetDir);
|
||||
const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir;
|
||||
if (subst) {
|
||||
@@ -2508,7 +2403,6 @@ async function runExternalExtract(
|
||||
const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password";
|
||||
let finalLegacyError: Error;
|
||||
|
||||
// Retry once after a short delay to let Windows flush freshly completed archive parts.
|
||||
if (isCrcOrWrongPw && !signal?.aborted) {
|
||||
const retryDelayMs = 2500;
|
||||
logger.warn(
|
||||
@@ -2634,10 +2528,6 @@ async function runExternalExtract(
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 14 – ZIP Extraction (AdmZip)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function isZipSafetyGuardError(error: unknown): boolean {
|
||||
const text = String(error || "").toLowerCase();
|
||||
return text.includes("path traversal")
|
||||
@@ -2726,9 +2616,6 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
|
||||
let outputKey = pathSetKey(outputPath);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
// TOCTOU note: There is a small race between access and writeFile below.
|
||||
// This is acceptable here because zip extraction is single-threaded and we need
|
||||
// the exists check to implement skip/rename conflict resolution semantics.
|
||||
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
|
||||
if (outputExists) {
|
||||
if (mode === "skip") {
|
||||
@@ -2778,10 +2665,6 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 15 – Disk Space, Timeout & Memory Limits
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function estimateArchivesTotalBytes(candidates: string[]): Promise<number> {
|
||||
let total = 0;
|
||||
for (const archivePath of candidates) {
|
||||
@@ -2789,7 +2672,7 @@ async function estimateArchivesTotalBytes(candidates: string[]): Promise<number>
|
||||
for (const part of parts) {
|
||||
try {
|
||||
total += (await fs.promises.stat(part)).size;
|
||||
} catch { /* missing part, ignore */ }
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
return total;
|
||||
@@ -2852,7 +2735,6 @@ async function computeExtractTimeoutMs(archivePath: string): Promise<number> {
|
||||
try {
|
||||
totalBytes += (await fs.promises.stat(filePath)).size;
|
||||
} catch {
|
||||
// ignore missing parts
|
||||
}
|
||||
}
|
||||
if (totalBytes <= 0) {
|
||||
@@ -2866,10 +2748,6 @@ async function computeExtractTimeoutMs(archivePath: string): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 16 – Resume State
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function extractProgressFilePath(packageDir: string, packageId?: string): string {
|
||||
if (packageId) {
|
||||
return path.join(packageDir, `.rd_extract_progress_${packageId}.json`);
|
||||
@@ -2905,7 +2783,6 @@ async function writeExtractResumeState(packageDir: string, completedArchives: Se
|
||||
const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp";
|
||||
await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
||||
await fs.promises.rename(tmpPath, progressPath).catch(async () => {
|
||||
// rename may fail if another writer renamed tmpPath first (parallel workers)
|
||||
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -2917,14 +2794,9 @@ export async function clearExtractResumeState(packageDir: string, packageId?: st
|
||||
try {
|
||||
await fs.promises.rm(extractProgressFilePath(packageDir, packageId), { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 17 – Progress & Conflict Helpers
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function emitExtractLog(
|
||||
onLog: ExtractOptions["onLog"] | undefined,
|
||||
level: "INFO" | "WARN" | "ERROR",
|
||||
@@ -2950,10 +2822,6 @@ function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip"
|
||||
return "skip";
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Sektion 18 – extractPackageArchives (Orchestrierung)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> {
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("aborted:extract");
|
||||
@@ -2969,12 +2837,11 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||||
options.onLog?.("INFO", `Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||||
|
||||
// Disk space pre-check
|
||||
if (candidates.length > 0) {
|
||||
options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" });
|
||||
try {
|
||||
await fs.promises.mkdir(options.targetDir, { recursive: true });
|
||||
} catch { /* ignore */ }
|
||||
} catch { }
|
||||
await checkDiskSpaceForExtraction(options.targetDir, candidates);
|
||||
}
|
||||
|
||||
@@ -3096,9 +2963,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
|
||||
emitProgress(extracted, "", "extracting");
|
||||
|
||||
// Emit "done" progress for archives already completed via resume state
|
||||
// so the caller's onProgress handler can mark their items as "Done" immediately
|
||||
// rather than leaving them as "Entpacken - Ausstehend" until all extraction finishes.
|
||||
for (const archivePath of candidates) {
|
||||
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
||||
emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true });
|
||||
@@ -3131,8 +2995,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||
}, 1100);
|
||||
const hybrid = Boolean(options.hybridMode);
|
||||
// Before the first successful extraction, filename-derived candidates are useful.
|
||||
// After a known password is learned, try that first to avoid per-archive delays.
|
||||
const filenamePasswords = archiveFilenamePasswords(archiveName);
|
||||
const nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== "");
|
||||
const orderedNonEmpty = learnedPassword
|
||||
@@ -3150,7 +3012,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||
};
|
||||
|
||||
// Validate generic .001 splits via file signature before attempting extraction
|
||||
const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName);
|
||||
if (isGenericSplit) {
|
||||
const sig = await detectArchiveSignature(archivePath);
|
||||
@@ -3185,7 +3046,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
}
|
||||
: undefined;
|
||||
try {
|
||||
// Set module-level priority before each extract call (race-safe: spawn is synchronous)
|
||||
currentExtractCpuPriority = options.extractCpuPriority;
|
||||
const ext = path.extname(archivePath).toLowerCase();
|
||||
if (ext === ".zip") {
|
||||
@@ -3297,7 +3157,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
if (options.signal?.aborted || noExtractorEncountered) break;
|
||||
await extractSingleArchive(archivePath);
|
||||
}
|
||||
// Count remaining archives as failed when no extractor was found
|
||||
if (noExtractorEncountered) {
|
||||
const remaining = candidates.length - (extracted + failed);
|
||||
if (remaining > 0) {
|
||||
@@ -3306,8 +3165,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Password discovery: extract first archive serially to find the correct password,
|
||||
// then run remaining archives in parallel with the promoted password order.
|
||||
let parallelQueue = pendingCandidates;
|
||||
if (passwordCandidates.length > 1 && pendingCandidates.length > 1) {
|
||||
logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
|
||||
@@ -3318,7 +3175,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
} catch (err) {
|
||||
const errText = String(err);
|
||||
if (/aborted:extract/i.test(errText)) throw err;
|
||||
// noextractor:skipped — handled by noExtractorEncountered flag below
|
||||
}
|
||||
parallelQueue = pendingCandidates.slice(1);
|
||||
if (parallelQueue.length > 0) {
|
||||
@@ -3327,7 +3183,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
}
|
||||
|
||||
if (parallelQueue.length > 0 && !options.signal?.aborted && !noExtractorEncountered) {
|
||||
// Parallel extraction pool: N workers pull from a shared queue
|
||||
const queue = [...parallelQueue];
|
||||
let nextIdx = 0;
|
||||
let abortError: Error | null = null;
|
||||
@@ -3342,24 +3197,20 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
} catch (error) {
|
||||
const errText = String(error);
|
||||
if (errText.includes("noextractor:skipped")) {
|
||||
break; // handled by noExtractorEncountered flag after the pool
|
||||
break;
|
||||
}
|
||||
if (isExtractAbortError(errText)) {
|
||||
abortError = error instanceof Error ? error : new Error(errText);
|
||||
break;
|
||||
}
|
||||
// Non-abort errors are already handled inside extractSingleArchive
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workerCount = Math.min(maxParallel, parallelQueue.length);
|
||||
logger.info(`Parallele Extraktion: ${workerCount} gleichzeitige Worker für ${parallelQueue.length} Archive`);
|
||||
// Snapshot passwordCandidates before parallel extraction to avoid concurrent mutation.
|
||||
// Each worker reads the same promoted order from the serial password-discovery pass.
|
||||
const frozenPasswords = [...passwordCandidates];
|
||||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||
// Restore passwordCandidates from frozen snapshot (parallel mutations are discarded).
|
||||
passwordCandidates = frozenPasswords;
|
||||
|
||||
if (abortError) throw new Error("aborted:extract");
|
||||
@@ -3391,11 +3242,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// ── Retry failed wrong_password archives serially ──
|
||||
// Parallel UnRAR processes writing to the same target directory can cause
|
||||
// CRC mismatches that are misreported as "Incorrect password".
|
||||
// If any archive succeeded (i.e. the password is known), retry the failed
|
||||
// ones one-at-a-time to eliminate false positives from I/O contention.
|
||||
if (failed > 0 && extracted > 0) {
|
||||
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap))));
|
||||
if (failedArchives.length > 0) {
|
||||
@@ -3404,14 +3250,12 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
for (const archivePath of failedArchives) {
|
||||
if (options.signal?.aborted || noExtractorEncountered) break;
|
||||
try {
|
||||
// Reset failed count for this archive before retry
|
||||
failed -= 1;
|
||||
await extractSingleArchive(archivePath);
|
||||
retryRecovered += 1;
|
||||
} catch (retryError) {
|
||||
const errText = String(retryError);
|
||||
if (isExtractAbortError(errText)) throw retryError;
|
||||
// extractSingleArchive already incremented failed and logged the error
|
||||
}
|
||||
}
|
||||
if (retryRecovered > 0) {
|
||||
@@ -3430,7 +3274,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nested extraction: extract archives found inside the output (1 level) ──
|
||||
if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) {
|
||||
try {
|
||||
const nestedCandidates = (await findArchiveCandidates(options.targetDir))
|
||||
@@ -3559,7 +3402,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
await fs.promises.rm(options.targetDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -94,7 +94,6 @@ function flushPending(): void {
|
||||
try {
|
||||
fs.appendFileSync(logPath, chunk, "utf8");
|
||||
} catch {
|
||||
// ignore write errors
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,11 +123,9 @@ async function cleanupOldItemLogs(dir: string): Promise<void> {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
// ignore locked/missing files
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore missing dir
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +223,6 @@ export function shutdownItemLogs(): void {
|
||||
try {
|
||||
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
pendingLinesByItem.clear();
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
/**
|
||||
* Zeitstempel für Log-Dateien in LOKALER Zeit mit explizitem UTC-Offset
|
||||
* (ISO 8601, z. B. "2026-05-31T19:29:43.605+02:00").
|
||||
*
|
||||
* Vorher nutzten alle Logger `new Date().toISOString()` → UTC ("...Z"). Auf einem
|
||||
* CEST-Server (UTC+2) las der User dadurch z. B. "17:29:43" statt der erwarteten
|
||||
* lokalen "19:29:43". Lokale Zeit MIT Offset bleibt eindeutig + maschinell parsebar
|
||||
* (Date.parse versteht den Offset), zeigt dem User aber die Uhrzeit seiner Zeitzone.
|
||||
*/
|
||||
export function logTimestamp(date: Date = new Date()): string {
|
||||
const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
|
||||
// getTimezoneOffset() liefert Minuten, die man zur LOKALEN Zeit ADDIEREN muss, um
|
||||
// UTC zu erhalten — also negiert = Offset der lokalen Zone gegenüber UTC.
|
||||
const offsetMinutes = -date.getTimezoneOffset();
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const absOffset = Math.abs(offsetMinutes);
|
||||
|
||||
+1
-5
@@ -70,7 +70,6 @@ function writeStderr(text: string): void {
|
||||
try {
|
||||
process.stderr.write(text);
|
||||
} catch {
|
||||
// ignore stderr failures
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,11 +135,9 @@ function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
// ignore - file may not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +157,6 @@ async function rotateIfNeededAsync(filePath: string): Promise<void> {
|
||||
await fs.promises.rm(backup, { force: true }).catch(() => {});
|
||||
await fs.promises.rename(filePath, backup);
|
||||
} catch {
|
||||
// ignore - file may not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +211,7 @@ function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
|
||||
pendingChars += line.length;
|
||||
|
||||
for (const listener of logListeners) {
|
||||
try { listener(line); } catch { /* ignore */ }
|
||||
try { listener(line); } catch { }
|
||||
}
|
||||
|
||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||
|
||||
+729
-744
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,3 @@
|
||||
// Mega.nz Public API: Filename + Size aus Public-Link ohne Mega-Debrid-Account.
|
||||
//
|
||||
// Erlaubt Pre-Resolve von Filenames sobald Links in die Queue kommen — ohne
|
||||
// Mega-Debrid-Quota anzufassen. Funktioniert fuer jeden public mega.nz Link
|
||||
// (mit Decryption-Key im URL-Fragment).
|
||||
//
|
||||
// Protokoll: https://g.api.mega.co.nz/cs
|
||||
// Request: POST [{"a":"g","g":1,"p":"<file-id>"}]
|
||||
// Response: [{"s": <size>, "at": <base64url encrypted attributes>, ...}]
|
||||
// Attribute-Decryption: AES-128-CBC, key = file-key[0..16], IV = 16x \0
|
||||
// Plaintext startet mit "MEGA" gefolgt von JSON: {"n": "filename.mkv", ...}
|
||||
//
|
||||
// Datei-Key im URL-Fragment ist 32 Bytes (base64url-encoded). Bytes 0-15
|
||||
// sind der AES-Schluessel, 16-23 der CTR-Nonce, 24-31 die Meta-MAC. Fuer
|
||||
// Attribut-Decryption brauchen wir nur den AES-Teil.
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
|
||||
@@ -53,7 +37,6 @@ export function parseMegaUrl(url: string): ParsedMegaLink | null {
|
||||
if (!m) return null;
|
||||
const id = m[1];
|
||||
const rawKey = base64UrlDecode(m[2]);
|
||||
// Files: 32 Bytes (256 bit). Folders: 16 Bytes — wir behandeln nur Files.
|
||||
if (!rawKey || rawKey.length !== 32) return null;
|
||||
return { id, rawKey };
|
||||
}
|
||||
@@ -123,8 +106,6 @@ export async function resolveMegaFilename(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mega gibt entweder ein Array mit File-Infos oder eine numerische Error-ID
|
||||
// zurueck (z.B. -9 ENOENT, -11 EACCESS, -14 EKEY, -16 EBLOCKED, -25 EOVERQUOTA).
|
||||
if (typeof payload === "number") return null;
|
||||
if (!Array.isArray(payload) || payload.length === 0) return null;
|
||||
|
||||
|
||||
+437
-463
@@ -1,463 +1,437 @@
|
||||
import { UnrestrictedLink } from "./realdebrid";
|
||||
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
* Mega-Debrid-Antwort "Kein Server für diesen Hoster verfügbar". Kommt zurück, wenn
|
||||
* das Tageslimit DIESES Accounts für den Hoster erschöpft ist (oder der Hoster kurz
|
||||
* nicht bedient wird). KEIN Session-/Leer-Fall — der Account soll schnell scheitern,
|
||||
* damit die Multi-Account-Rotation sofort zum nächsten (nicht limitierten) Account
|
||||
* wechselt, statt re-Login + Retry-Sturm das geteilte Unrestrict-Budget zu fressen.
|
||||
*/
|
||||
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): Promise<T> {
|
||||
if (!signal) {
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
|
||||
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 {
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
private getCredentials: () => MegaCredentials;
|
||||
|
||||
// Per-Login Session-Cache: login(lowercase) → { cookie, setAt }. Multi-Account-
|
||||
// Rotation: jeder Account nutzt SEINE eigene Session. Frueher gab es nur EINE
|
||||
// geteilte Cookie-Session → der Web-Unrestrict lief fuer JEDEN rotierten Account mit
|
||||
// den Creds des ersten/Legacy-Accounts (settings.megaLogin); der naechste Account
|
||||
// wurde nie wirklich verwendet (Rotation war wirkungslos).
|
||||
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);
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
// Per-Account-Creds aus der Rotation bevorzugen; sonst Legacy-Default.
|
||||
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();
|
||||
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||
|
||||
let generated = await this.generate(link, cookie, overallSignal);
|
||||
if (!generated) {
|
||||
// Session evtl. abgelaufen → fuer DIESEN Login neu einloggen + einmal erneut.
|
||||
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
|
||||
};
|
||||
}, overallSignal);
|
||||
}
|
||||
|
||||
/** Liefert ein gueltiges Session-Cookie fuer den gegebenen Login (aus Cache oder
|
||||
* via frischem Login). Cache-TTL 20 min. */
|
||||
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>, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
const QUEUE_WAIT_TIMEOUT_MS = 90000;
|
||||
const guardedJob = async (): Promise<T> => {
|
||||
throwIfAborted(signal);
|
||||
const waited = Date.now() - queuedAt;
|
||||
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
|
||||
throw new Error(`Mega-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 raceWithAbort(run, signal);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Check for permanent hoster errors before looking for debrid codes
|
||||
const pageErrors = parsePageErrors(html);
|
||||
const permanentError = isPermanentHosterError(pageErrors);
|
||||
if (permanentError) {
|
||||
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
|
||||
}
|
||||
|
||||
// Tageslimit dieses Accounts: die DEBRID-Seite enthaelt dann KEINEN processDebrid-
|
||||
// Code (parseCodes leer → wir wuerden gleich null/"Antwort leer" liefern). Steht die
|
||||
// "Kein Server für diesen Hoster"-Meldung als Page-Error im HTML, surface sie hier,
|
||||
// damit die Rotation den Account als tageslimitiert erkennt statt als Leer-Blip.
|
||||
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();
|
||||
// "Kein Server für diesen Hoster verfügbar" = Account-Tageslimit erschöpft.
|
||||
// Surface die Meldung statt null zurückzugeben — sonst re-loggt unrestrict()
|
||||
// ein + pollt erneut (Retry-Sturm), was bei einem limitierten Account zwecklos
|
||||
// ist und das geteilte Rotations-Budget verbrennt. So scheitert der Account
|
||||
// schnell und die Rotation nutzt den nächsten Account.
|
||||
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);
|
||||
}
|
||||
import { UnrestrictedLink } from "./realdebrid";
|
||||
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
||||
|
||||
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): Promise<T> {
|
||||
if (!signal) {
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
|
||||
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 {
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
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);
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
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();
|
||||
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
|
||||
};
|
||||
}, 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>, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
const QUEUE_WAIT_TIMEOUT_MS = 90000;
|
||||
const guardedJob = async (): Promise<T> => {
|
||||
throwIfAborted(signal);
|
||||
const waited = Date.now() - queuedAt;
|
||||
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
|
||||
throw new Error(`Mega-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 raceWithAbort(run, signal);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,6 @@ function flushPending(): void {
|
||||
try {
|
||||
fs.appendFileSync(logPath, chunk, "utf8");
|
||||
} catch {
|
||||
// ignore write errors
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,11 +122,9 @@ async function cleanupOldPackageLogs(dir: string): Promise<void> {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
// ignore locked/missing files
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore missing dir
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +221,6 @@ export function shutdownPackageLogs(): void {
|
||||
try {
|
||||
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
pendingLinesByPackage.clear();
|
||||
|
||||
@@ -158,12 +158,10 @@ export class RealDebridWebFallback {
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await currentSession.clearCache();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,7 +318,6 @@ export class RealDebridWebFallback {
|
||||
return this.rememberToken(token);
|
||||
}
|
||||
} catch {
|
||||
// ignore window scraping errors and fall back to session fetch
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -330,14 +327,12 @@ export class RealDebridWebFallback {
|
||||
try {
|
||||
await this.extractApiTokenFromWindow(window);
|
||||
} catch {
|
||||
// ignore best-effort token warmup failures
|
||||
}
|
||||
}
|
||||
|
||||
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
// Return cached token if fresh (max 30 min)
|
||||
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
|
||||
return this.cachedToken;
|
||||
}
|
||||
@@ -399,7 +394,6 @@ export class RealDebridWebFallback {
|
||||
const text = await response.text();
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Token expired or revoked — invalidate cache
|
||||
this.cachedToken = "";
|
||||
this.cachedTokenAt = 0;
|
||||
return { kind: "login_required" };
|
||||
|
||||
@@ -82,8 +82,6 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
|
||||
await sleep(ms);
|
||||
return;
|
||||
}
|
||||
// Check before entering the Promise constructor to avoid a race where the timer
|
||||
// resolves before the aborted check runs (especially when ms=0).
|
||||
if (signal.aborted) {
|
||||
throw new Error("aborted");
|
||||
}
|
||||
|
||||
@@ -46,11 +46,9 @@ function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +61,6 @@ function cleanupOldBackup(filePath: string): void {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +97,6 @@ export function logRenameEvent(level: RenameLogLevel, message: string, fields?:
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
// ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +114,6 @@ export function shutdownRenameLog(): void {
|
||||
try {
|
||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
renameLogPath = null;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ function flushPending(): void {
|
||||
try {
|
||||
fs.appendFileSync(sessionLogPath, chunk, "utf8");
|
||||
} catch {
|
||||
// ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,11 +66,9 @@ async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise<v
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
// ignore - file may be locked
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore - dir may not exist
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,19 +106,16 @@ export function shutdownSessionLog(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush any pending lines
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
|
||||
// Write closing line
|
||||
const isoTimestamp = logTimestamp();
|
||||
try {
|
||||
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
setLogListener(null);
|
||||
|
||||
@@ -5,17 +5,6 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { StoragePaths } from "./storage";
|
||||
|
||||
/** Startup Health-Check: runs once at app boot and surfaces potential problem
|
||||
* states BEFORE the user hits them mid-download.
|
||||
*
|
||||
* Goals:
|
||||
* - Warn on missing / unreachable download directory
|
||||
* - Warn on low disk space (< 5 GB free)
|
||||
* - Warn when no debrid provider is configured (app is effectively offline)
|
||||
* - Warn when state file is suspiciously large (>50 MB → pruning recommended)
|
||||
*
|
||||
* Non-goals: blocking startup. The check only logs — the app continues. */
|
||||
|
||||
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface HealthCheckFinding {
|
||||
@@ -32,8 +21,8 @@ export interface HealthCheckReport {
|
||||
infoCount: number;
|
||||
}
|
||||
|
||||
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
|
||||
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024; // 50 MB
|
||||
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024;
|
||||
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
function safeExists(p: string): boolean {
|
||||
try {
|
||||
@@ -52,9 +41,6 @@ function getFileSizeBytes(p: string): number {
|
||||
}
|
||||
}
|
||||
|
||||
/** Attempt a tiny write-probe in the given directory. Returns true on
|
||||
* success, false if the directory isn't writable. We write and immediately
|
||||
* delete a uniquely-named temp file so we never leave garbage behind. */
|
||||
function isWritable(dir: string): boolean {
|
||||
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||
try {
|
||||
@@ -66,12 +52,8 @@ function isWritable(dir: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Query free disk space for a given path. Returns null if unsupported or
|
||||
* the query fails — callers treat null as "unknown" and skip the check. */
|
||||
function getFreeDiskSpaceBytes(target: string): number | null {
|
||||
try {
|
||||
// fs.statfsSync is available on Node 18.15+; on Windows it still maps to
|
||||
// the underlying volume so it works for download dirs on any drive.
|
||||
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
|
||||
if (typeof statfs !== "function") {
|
||||
return null;
|
||||
@@ -123,12 +105,9 @@ function countConfiguredProviders(settings: AppSettings): { count: number; provi
|
||||
return { count: providers.length, providers };
|
||||
}
|
||||
|
||||
/** Pure check function: takes inputs, returns findings. Kept side-effect-free
|
||||
* so it's trivial to unit-test — the caller handles logging / persistence. */
|
||||
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
|
||||
const findings: HealthCheckFinding[] = [];
|
||||
|
||||
// ── 1. Download directory ───────────────────────────────────────────────
|
||||
const outputDir = String(settings.outputDir || "").trim();
|
||||
if (!outputDir) {
|
||||
findings.push({
|
||||
@@ -152,7 +131,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
||||
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
|
||||
});
|
||||
} else {
|
||||
// Check available disk space only when the directory is actually usable
|
||||
const freeBytes = getFreeDiskSpaceBytes(outputDir);
|
||||
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
|
||||
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
||||
@@ -165,7 +143,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Provider-Credentials ─────────────────────────────────────────────
|
||||
const { count, providers } = countConfiguredProviders(settings);
|
||||
if (count === 0) {
|
||||
findings.push({
|
||||
@@ -182,7 +159,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. State-File-Groesse ──────────────────────────────────────────────
|
||||
if (safeExists(storagePaths.sessionFile)) {
|
||||
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
|
||||
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
|
||||
@@ -196,7 +172,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Storage-Basis-Verzeichnis muss beschreibbar sein (fuer Logs) ────
|
||||
if (!safeExists(storagePaths.baseDir)) {
|
||||
findings.push({
|
||||
severity: "ERROR",
|
||||
|
||||
+1191
-1229
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,6 @@ function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): vo
|
||||
}
|
||||
}
|
||||
|
||||
/** Wie addDirectoryIfExists, aber nur Dateien die in den letzten maxAgeMs ms geaendert wurden. */
|
||||
function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): number {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return 0;
|
||||
@@ -68,7 +67,7 @@ function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string,
|
||||
zip.addLocalFile(fullPath, zipRoot, entry.name);
|
||||
added += 1;
|
||||
}
|
||||
} catch { /* ignorieren */ }
|
||||
} catch { }
|
||||
}
|
||||
return added;
|
||||
}
|
||||
@@ -187,16 +186,11 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
|
||||
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
||||
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
||||
|
||||
// Granulare Per-Item/-Package/-Session-Logs nur der letzten 8h.
|
||||
// Vorher wurden alle logs-Unterordner rekursiv gepackt → tausende Item-Logs
|
||||
// → 200+ MB, unhandlich zum Verschicken. Mit 8h-Fenster bleibt das Bundle
|
||||
// klein genug und enthaelt alles fuer aktuelle Fehler + Rename-Probleme.
|
||||
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
||||
addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
|
||||
addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||
addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||
|
||||
// Live-Logs der aktiven Queue (aktuelle Session) immer vollstaendig mitsichern.
|
||||
for (const packageId of packageIds) {
|
||||
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ function flushPending(): void {
|
||||
try {
|
||||
fs.appendFileSync(traceLogPath, chunk, "utf8");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,11 +77,9 @@ function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +92,6 @@ function cleanupOldBackup(filePath: string): void {
|
||||
fs.rmSync(backup, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +159,6 @@ function persistTraceConfig(): void {
|
||||
try {
|
||||
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +305,6 @@ export function shutdownTraceLog(): void {
|
||||
try {
|
||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
traceLogPath = null;
|
||||
traceConfigPath = null;
|
||||
|
||||
+3
-81
@@ -8,8 +8,6 @@ import { UpdateCheckResult, UpdateInstallProgress, UpdateInstallResult } from ".
|
||||
import { compactErrorText, humanSize } from "./utils";
|
||||
import { logger } from "./logger";
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const RELEASE_FETCH_TIMEOUT_MS = 12_000;
|
||||
const CONNECT_TIMEOUT_MS = 30_000;
|
||||
const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000;
|
||||
@@ -18,8 +16,6 @@ const RETRY_DELAY_MS = 1_500;
|
||||
const MAX_DOWNLOAD_PASSES = 3;
|
||||
const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type UpdateSource = {
|
||||
name: string;
|
||||
webBase: string;
|
||||
@@ -40,8 +36,6 @@ type ExpectedDigest = {
|
||||
encoding: "hex" | "base64";
|
||||
};
|
||||
|
||||
// ─── Update Sources ────────────────────────────────────────────────────────────
|
||||
|
||||
const UPDATE_SOURCES: UpdateSource[] = [
|
||||
{ name: "git24", webBase: "https://git.24-music.de", apiBase: "https://git.24-music.de/api/v1" },
|
||||
{ name: "codeberg", webBase: "https://codeberg.org", apiBase: "https://codeberg.org/api/v1" },
|
||||
@@ -52,23 +46,16 @@ const PRIMARY_SOURCE = UPDATE_SOURCES[0];
|
||||
const WEB_BASE = PRIMARY_SOURCE.webBase;
|
||||
const API_BASE = PRIMARY_SOURCE.apiBase;
|
||||
|
||||
// ─── Module State ──────────────────────────────────────────────────────────────
|
||||
|
||||
let activeAbortController: AbortController | null = null;
|
||||
|
||||
// ─── Progress Helper ───────────────────────────────────────────────────────────
|
||||
|
||||
function emitProgress(cb: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void {
|
||||
if (!cb) return;
|
||||
try {
|
||||
cb(progress);
|
||||
} catch {
|
||||
// ignore renderer callback errors
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Version Utilities ─────────────────────────────────────────────────────────
|
||||
|
||||
export function parseVersionParts(version: string): number[] {
|
||||
const cleaned = version.replace(/^v/i, "").trim();
|
||||
return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0"));
|
||||
@@ -87,8 +74,6 @@ export function isRemoteNewer(currentVersion: string, latestVersion: string): bo
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Repository Normalization ──────────────────────────────────────────────────
|
||||
|
||||
function isValidRepoPart(value: string): boolean {
|
||||
const part = String(value || "").trim();
|
||||
if (!part || part === "." || part === ".." || part.includes("..")) return false;
|
||||
@@ -127,15 +112,12 @@ export function normalizeUpdateRepo(repo: string): string {
|
||||
if (result) return result;
|
||||
}
|
||||
} catch {
|
||||
// not a URL, try as plain text
|
||||
}
|
||||
|
||||
const result = extractOwnerRepo(raw);
|
||||
return result || DEFAULT_UPDATE_REPO;
|
||||
}
|
||||
|
||||
// ─── Network Utilities ─────────────────────────────────────────────────────────
|
||||
|
||||
function timeoutController(ms: number): { signal: AbortSignal; clear: () => void } {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(new Error(`timeout:${ms}`)), ms);
|
||||
@@ -190,25 +172,18 @@ function getBodyIdleTimeout(): number {
|
||||
return DOWNLOAD_BODY_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// ─── Digest Parsing & Verification ─────────────────────────────────────────────
|
||||
//
|
||||
// SHA-256 = 32 bytes → hex: 64 chars, base64: 43-44 chars (+ up to 1 padding =)
|
||||
// SHA-512 = 64 bytes → hex: 128 chars, base64: 86-88 chars (+ up to 2 padding =)
|
||||
|
||||
function normalizeBase64(raw: string): string {
|
||||
return String(raw || "")
|
||||
.trim()
|
||||
.replace(/-/g, "+") // URL-safe → standard
|
||||
.replace(/_/g, "/") // URL-safe → standard
|
||||
.replace(/=+$/g, ""); // strip padding for consistent comparison
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
||||
const text = String(raw || "").trim();
|
||||
if (!text) return null;
|
||||
|
||||
// ── Prefixed: sha256:<value> ──
|
||||
|
||||
const pre256hex = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
|
||||
if (pre256hex) {
|
||||
return { algorithm: "sha256", digest: pre256hex[1].toLowerCase(), encoding: "hex" };
|
||||
@@ -219,8 +194,6 @@ export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
||||
return { algorithm: "sha256", digest: normalizeBase64(pre256b64[1]), encoding: "base64" };
|
||||
}
|
||||
|
||||
// ── Prefixed: sha512:<value> ──
|
||||
|
||||
const pre512hex = text.match(/^sha512:([a-fA-F0-9]{128})$/i);
|
||||
if (pre512hex) {
|
||||
return { algorithm: "sha512", digest: pre512hex[1].toLowerCase(), encoding: "hex" };
|
||||
@@ -231,8 +204,6 @@ export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
||||
return { algorithm: "sha512", digest: normalizeBase64(pre512b64[1]), encoding: "base64" };
|
||||
}
|
||||
|
||||
// ── Plain hex ──
|
||||
|
||||
if (/^[a-fA-F0-9]{64}$/.test(text)) {
|
||||
return { algorithm: "sha256", digest: text.toLowerCase(), encoding: "hex" };
|
||||
}
|
||||
@@ -240,8 +211,6 @@ export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
||||
return { algorithm: "sha512", digest: text.toLowerCase(), encoding: "hex" };
|
||||
}
|
||||
|
||||
// ── Plain base64 (SHA-512 first since it's longer → won't accidentally match SHA-256) ──
|
||||
|
||||
const plain512b64 = text.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
|
||||
if (plain512b64) {
|
||||
return { algorithm: "sha512", digest: normalizeBase64(plain512b64[1]), encoding: "base64" };
|
||||
@@ -271,8 +240,6 @@ async function hashFile(filePath: string, algorithm: "sha256" | "sha512", encodi
|
||||
});
|
||||
}
|
||||
|
||||
// ─── latest.yml Parsing ────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeNameForMatch(value: string): string {
|
||||
const name = String(value || "").trim().split(/[\\/]/g).filter(Boolean).pop() || "";
|
||||
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
@@ -284,10 +251,8 @@ function stripYamlQuotes(raw: string): string {
|
||||
|
||||
function extractSha512Value(raw: string): string {
|
||||
const stripped = stripYamlQuotes(raw);
|
||||
// Base64 SHA-512: 86-88 chars + optional padding
|
||||
const b64 = stripped.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
|
||||
if (b64) return b64[1];
|
||||
// Hex SHA-512: exactly 128 hex chars
|
||||
const hex = stripped.match(/^([a-fA-F0-9]{128})$/);
|
||||
if (hex) return hex[1];
|
||||
return "";
|
||||
@@ -305,28 +270,24 @@ function parseSha512FromLatestYml(content: string, setupAssetName: string): stri
|
||||
for (const rawLine of lines) {
|
||||
const line = String(rawLine);
|
||||
|
||||
// File entry URL (inside files: array)
|
||||
const fileUrlItem = line.match(/^\s*-\s*url\s*:\s*(.+)\s*$/i);
|
||||
if (fileUrlItem?.[1]) {
|
||||
currentFileUrl = stripYamlQuotes(fileUrlItem[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Top-level or non-array URL
|
||||
const urlMatch = line.match(/^\s*url\s*:\s*(.+)\s*$/i);
|
||||
if (urlMatch?.[1]) {
|
||||
currentFileUrl = stripYamlQuotes(urlMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Top-level path
|
||||
const pathMatch = line.match(/^\s*path\s*:\s*(.+)\s*$/i);
|
||||
if (pathMatch?.[1]) {
|
||||
topLevelPath = stripYamlQuotes(pathMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// SHA-512 value (handles quoted and unquoted)
|
||||
const shaMatch = line.match(/^\s*sha512\s*:\s*(.+)\s*$/i);
|
||||
if (!shaMatch?.[1]) continue;
|
||||
|
||||
@@ -345,7 +306,6 @@ function parseSha512FromLatestYml(content: string, setupAssetName: string): stri
|
||||
if (!topLevelSha) topLevelSha = sha;
|
||||
}
|
||||
|
||||
// Try matching via top-level path
|
||||
if (target && topLevelPath && topLevelSha) {
|
||||
if (normalizeNameForMatch(topLevelPath) === target) {
|
||||
return topLevelSha;
|
||||
@@ -355,8 +315,6 @@ function parseSha512FromLatestYml(content: string, setupAssetName: string): stri
|
||||
return topLevelSha || firstFileSha || "";
|
||||
}
|
||||
|
||||
// ─── Installer Verification ───────────────────────────────────────────────────
|
||||
|
||||
async function verifyBinaryShape(filePath: string): Promise<void> {
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
if (!Number.isFinite(stats.size) || stats.size < 128 * 1024) {
|
||||
@@ -402,8 +360,6 @@ async function verifyDownloadedInstaller(filePath: string, digestRaw: string): P
|
||||
logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`);
|
||||
}
|
||||
|
||||
// ─── Release API ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchRelease(repo: string, endpoint: string): Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
@@ -475,8 +431,6 @@ function parseReleasePayload(payload: Record<string, unknown>, fallbackUrl: stri
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Download Candidates ───────────────────────────────────────────────────────
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
@@ -555,8 +509,6 @@ function deriveFileName(check: UpdateCheckResult, url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Error Classification ──────────────────────────────────────────────────────
|
||||
|
||||
function httpStatusFromError(error: unknown): number {
|
||||
const match = String(error || "").match(/HTTP\s+(\d{3})/i);
|
||||
return match ? Number(match[1]) : 0;
|
||||
@@ -585,8 +537,6 @@ function isIntegrityError(error: unknown): boolean {
|
||||
return text.includes("integrit") || text.includes("mismatch");
|
||||
}
|
||||
|
||||
// ─── Sleep ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
if (signal.aborted) throw new Error("aborted:update_shutdown");
|
||||
@@ -611,8 +561,6 @@ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Download Engine ───────────────────────────────────────────────────────────
|
||||
|
||||
async function downloadFile(
|
||||
url: string,
|
||||
targetPath: string,
|
||||
@@ -623,7 +571,6 @@ async function downloadFile(
|
||||
|
||||
logger.info(`Update-Download versucht: ${url}`);
|
||||
|
||||
// Connect with timeout
|
||||
const tc = timeoutController(CONNECT_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -640,11 +587,9 @@ async function downloadFile(
|
||||
throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
// Parse content-length
|
||||
const clRaw = Number(response.headers.get("content-length") || NaN);
|
||||
const totalBytes = Number.isFinite(clRaw) && clRaw > 0 ? Math.max(0, Math.floor(clRaw)) : null;
|
||||
|
||||
// Progress tracking
|
||||
let downloadedBytes = 0;
|
||||
let lastProgressAt = 0;
|
||||
|
||||
@@ -663,13 +608,11 @@ async function downloadFile(
|
||||
|
||||
reportProgress(true);
|
||||
|
||||
// Prepare filesystem
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
const tempPath = `${targetPath}.tmp`;
|
||||
const writeStream = fs.createWriteStream(tempPath);
|
||||
const reader = response.body.getReader();
|
||||
|
||||
// Idle timeout tracking
|
||||
const idleMs = getBodyIdleTimeout();
|
||||
let idleTimer: NodeJS.Timeout | null = null;
|
||||
let idleTimedOut = false;
|
||||
@@ -691,7 +634,6 @@ async function downloadFile(
|
||||
}
|
||||
};
|
||||
|
||||
// Stream body to disk
|
||||
try {
|
||||
resetIdle();
|
||||
for (;;) {
|
||||
@@ -721,25 +663,21 @@ async function downloadFile(
|
||||
clearIdle();
|
||||
}
|
||||
|
||||
// Flush and close write stream
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.end(() => resolve());
|
||||
writeStream.on("error", reject);
|
||||
});
|
||||
|
||||
// Handle idle timeout on clean reader exit
|
||||
if (idleTimedOut) {
|
||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||
throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
|
||||
}
|
||||
|
||||
// Verify completeness
|
||||
if (totalBytes && downloadedBytes !== totalBytes) {
|
||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||
throw new Error(`Update Download unvollständig (${downloadedBytes} / ${totalBytes} Bytes)`);
|
||||
}
|
||||
|
||||
// Atomic rename temp → final
|
||||
await fs.promises.rename(tempPath, targetPath);
|
||||
reportProgress(true);
|
||||
logger.info(`Update-Download abgeschlossen: ${targetPath} (${downloadedBytes} Bytes)`);
|
||||
@@ -803,8 +741,6 @@ async function downloadFromCandidates(
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// ─── Asset Resolution Helpers ──────────────────────────────────────────────────
|
||||
|
||||
async function resolveAssetFromApi(repo: string, tag: string): Promise<{
|
||||
setupAssetUrl: string;
|
||||
setupAssetName: string;
|
||||
@@ -827,7 +763,6 @@ async function resolveAssetFromApi(repo: string, tag: string): Promise<{
|
||||
setupAssetDigest: setup.digest,
|
||||
};
|
||||
} catch {
|
||||
// try next endpoint
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -863,14 +798,11 @@ async function resolveDigestFromYml(repo: string, tag: string, setupName: string
|
||||
const sha = parseSha512FromLatestYml(yamlText, setupName);
|
||||
if (sha) return `sha512:${sha}`;
|
||||
} catch {
|
||||
// try next endpoint
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildInstallerLaunchArgs(): string[] {
|
||||
return ["/S", "--updated", "--force-run"];
|
||||
}
|
||||
@@ -903,7 +835,6 @@ export async function installLatestUpdate(
|
||||
prechecked?: UpdateCheckResult,
|
||||
onProgress?: UpdateProgressCallback,
|
||||
): Promise<UpdateInstallResult> {
|
||||
// Prevent concurrent updates
|
||||
if (activeAbortController && !activeAbortController.signal.aborted) {
|
||||
emitProgress(onProgress, {
|
||||
stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
|
||||
@@ -916,7 +847,6 @@ export async function installLatestUpdate(
|
||||
activeAbortController = abortCtrl;
|
||||
const safeRepo = normalizeUpdateRepo(repo);
|
||||
|
||||
// Resolve update check
|
||||
const check = prechecked && !prechecked.error
|
||||
? prechecked
|
||||
: await checkGitHubUpdate(safeRepo);
|
||||
@@ -939,7 +869,6 @@ export async function installLatestUpdate(
|
||||
return { started: false, message: "Kein neues Update verfügbar" };
|
||||
}
|
||||
|
||||
// Mutable effective state for enrichment
|
||||
let effective: UpdateCheckResult = {
|
||||
...check,
|
||||
setupAssetUrl: String(check.setupAssetUrl || ""),
|
||||
@@ -947,7 +876,6 @@ export async function installLatestUpdate(
|
||||
setupAssetDigest: String(check.setupAssetDigest || ""),
|
||||
};
|
||||
|
||||
// Enrich: resolve asset from API if needed
|
||||
if (!effective.setupAssetUrl || !effective.setupAssetDigest) {
|
||||
const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag);
|
||||
if (refreshed) {
|
||||
@@ -960,7 +888,6 @@ export async function installLatestUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich: resolve digest from latest.yml if still missing
|
||||
if (!effective.setupAssetDigest && effective.setupAssetUrl) {
|
||||
const digest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || "");
|
||||
if (digest) {
|
||||
@@ -969,7 +896,6 @@ export async function installLatestUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
// Build download candidates
|
||||
let candidates = buildCandidates(safeRepo, effective);
|
||||
if (candidates.length === 0) {
|
||||
activeAbortController = null;
|
||||
@@ -991,7 +917,6 @@ export async function installLatestUpdate(
|
||||
|
||||
if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown");
|
||||
|
||||
// ── Download + verify with retry passes ──
|
||||
let verified = false;
|
||||
let lastVerifyError: unknown = null;
|
||||
let integrityError: unknown = null;
|
||||
@@ -1030,7 +955,6 @@ export async function installLatestUpdate(
|
||||
|
||||
if (verified) break;
|
||||
|
||||
// Refresh candidates on 404 or integrity mismatch
|
||||
const status = httpStatusFromError(lastVerifyError);
|
||||
const shouldRefresh = pass < MAX_DOWNLOAD_PASSES - 1 && (status === 404 || integrityError !== null);
|
||||
if (!shouldRefresh) break;
|
||||
@@ -1073,7 +997,6 @@ export async function installLatestUpdate(
|
||||
throw integrityError || lastVerifyError || new Error("Update-Download fehlgeschlagen");
|
||||
}
|
||||
|
||||
// ── Launch installer ──
|
||||
emitProgress(onProgress, {
|
||||
stage: "launching", percent: 100, downloadedBytes: 0, totalBytes: null,
|
||||
message: "Starte stille Update-Installation",
|
||||
@@ -1099,7 +1022,6 @@ export async function installLatestUpdate(
|
||||
try {
|
||||
await fs.promises.rm(targetPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const releaseUrl = String(effective.releaseUrl || "").trim();
|
||||
const hint = releaseUrl ? ` – Manuell: ${releaseUrl}` : "";
|
||||
|
||||
@@ -319,7 +319,6 @@ export function hasRecentWindowsMinidumps(): boolean {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
||||
+114
-114
@@ -1,114 +1,114 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import {
|
||||
AddLinksPayload,
|
||||
AllDebridHostInfo,
|
||||
AppSettings,
|
||||
DebridAccountStatus,
|
||||
DebridLinkHostLimitInfo,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
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; 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),
|
||||
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),
|
||||
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),
|
||||
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);
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import {
|
||||
AddLinksPayload,
|
||||
AllDebridHostInfo,
|
||||
AppSettings,
|
||||
DebridAccountStatus,
|
||||
DebridLinkHostLimitInfo,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
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; 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),
|
||||
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),
|
||||
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),
|
||||
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);
|
||||
|
||||
+3
-80
@@ -115,8 +115,6 @@ interface AccountDialogState {
|
||||
megaAccounts: MegaDialogAccount[];
|
||||
megaNewLogin: string;
|
||||
megaNewPassword: string;
|
||||
// IDs der im Bearbeiten-Dialog (temporär) deaktivierten Mega-Debrid-Accounts.
|
||||
// Draft-State: wird erst beim Speichern in settings.megaDebridDisabledAccountIds übernommen.
|
||||
megaDisabledIds: string[];
|
||||
}
|
||||
|
||||
@@ -467,17 +465,12 @@ function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[]
|
||||
return getConfiguredProvidersFromSettings(settings).filter((p) => !disabled.has(p));
|
||||
}
|
||||
|
||||
// Leitet die aktive Provider-Reihenfolge aus providerOrder ab,
|
||||
// gefiltert auf tatsächlich konfigurierte und nicht deaktivierte Provider.
|
||||
// Direkt-Hoster (onefichier, ddownload) werden ausgeschlossen.
|
||||
const DIRECT_HOSTERS: ReadonlySet<DebridProvider> = new Set(["onefichier", "ddownload"]);
|
||||
|
||||
function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] {
|
||||
const active = new Set(getActiveProvidersFromSettings(settings).filter((p) => !DIRECT_HOSTERS.has(p)));
|
||||
// Behalte bestehende Reihenfolge aus providerOrder, filtere nicht-konfigurierte heraus
|
||||
const ordered = (settings.providerOrder || []).filter((p) => active.has(p));
|
||||
const inOrder = new Set(ordered);
|
||||
// Füge neue Provider hinten an, die noch nicht in der Reihenfolge sind
|
||||
for (const p of active) {
|
||||
if (!inOrder.has(p)) ordered.push(p);
|
||||
}
|
||||
@@ -609,7 +602,6 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
|
||||
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||
case "megadebrid-api":
|
||||
case "megadebrid-web": {
|
||||
// Populate megaAccounts from megaCredentials, or build from legacy megaLogin/megaPassword
|
||||
let megaToken = (settings.megaCredentials || "").trim();
|
||||
if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) {
|
||||
megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`;
|
||||
@@ -1288,7 +1280,6 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
||||
maxSpeed = Math.max(maxSpeed, 1024 * 1024);
|
||||
const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed)));
|
||||
|
||||
// Measure widest label to set dynamic left padding
|
||||
ctx.font = "11px 'Manrope', sans-serif";
|
||||
let maxLabelWidth = 0;
|
||||
for (let i = 0; i <= 5; i += 1) {
|
||||
@@ -1371,12 +1362,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
||||
}, [running, paused]);
|
||||
|
||||
useEffect(() => {
|
||||
// Always draw once on mount / when running/paused state changes so the
|
||||
// chart shows the latest history.
|
||||
drawChart();
|
||||
// Only schedule periodic redraws while actively downloading — when
|
||||
// stopped or paused the speed history doesn't change, so polling
|
||||
// every 250ms would just burn CPU on the renderer process.
|
||||
if (!running || paused) {
|
||||
return;
|
||||
}
|
||||
@@ -1387,7 +1373,6 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
||||
}, [drawChart, running, paused]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only record samples while the session is running and not paused
|
||||
if (!running || paused) return;
|
||||
|
||||
const now = Date.now();
|
||||
@@ -1443,7 +1428,6 @@ function createScheduleId(): string {
|
||||
return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
|
||||
function sortPackageOrderBySize(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
|
||||
const sorted = [...order];
|
||||
sorted.sort((a, b) => {
|
||||
@@ -1578,10 +1562,6 @@ export function App(): ReactElement {
|
||||
const settingsDraftRevisionRef = useRef(0);
|
||||
const panelDirtyRevisionRef = useRef(0);
|
||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||
// Master state used to apply incoming delta payloads. The wire format from
|
||||
// the main process sends only changed items/packages (with payloadKind="delta")
|
||||
// most of the time and a full snapshot every 30s for safety. Without this
|
||||
// master, we'd only see the changed slice each emit.
|
||||
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
||||
const snapshotRef = useRef(snapshot);
|
||||
snapshotRef.current = snapshot;
|
||||
@@ -1616,7 +1596,6 @@ export function App(): ReactElement {
|
||||
const [showAllPackages, setShowAllPackages] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
|
||||
// Account-IDs, die gerade beim Hinzufügen einzeln geprüft werden (Mega-Debrid).
|
||||
const [megaCheckingIds, setMegaCheckingIds] = useState<Set<string>>(() => new Set());
|
||||
const actionBusyRef = useRef(false);
|
||||
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -1691,7 +1670,6 @@ export function App(): ReactElement {
|
||||
window.addEventListener("mouseup", stopAccountColumnResize);
|
||||
}, [accountColumnWidths, onAccountColumnResizeMove, stopAccountColumnResize]);
|
||||
|
||||
// Load history when tab changes to history
|
||||
useEffect(() => {
|
||||
if (tab !== "history") return;
|
||||
const loadHistory = async (): Promise<void> => {
|
||||
@@ -1713,7 +1691,6 @@ export function App(): ReactElement {
|
||||
try {
|
||||
window.localStorage.setItem(ACCOUNT_COLUMN_STORAGE_KEY, JSON.stringify(accountColumnWidths));
|
||||
} catch {
|
||||
// Ignore local persistence failures for optional UI state.
|
||||
}
|
||||
}, [accountColumnWidths]);
|
||||
|
||||
@@ -1722,16 +1699,10 @@ export function App(): ReactElement {
|
||||
try {
|
||||
window.localStorage.removeItem(ACCOUNT_COLUMN_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore local persistence failures for optional UI state.
|
||||
}
|
||||
showToast("Accounts-Spalten zurückgesetzt", 1800);
|
||||
}, []);
|
||||
|
||||
// Sync column order from settings. Avoid JSON.stringify on every render
|
||||
// (which was a 7-element array stringify per snapshot tick). A simple
|
||||
// join() is one O(n) string concat without Object/Array allocation overhead,
|
||||
// and useMemo caches the resulting key so React only sees a new dep when the
|
||||
// contents actually changed.
|
||||
const columnOrderKey = useMemo(
|
||||
() => (snapshot.settings.columnOrder || []).join("|"),
|
||||
[snapshot.settings.columnOrder]
|
||||
@@ -1741,7 +1712,6 @@ export function App(): ReactElement {
|
||||
if (order && order.length > 0) {
|
||||
setColumnOrder(order);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [columnOrderKey]);
|
||||
|
||||
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
||||
@@ -1917,7 +1887,6 @@ export function App(): ReactElement {
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
// Seed the master snapshot — incoming delta payloads will merge into this.
|
||||
masterSnapshotRef.current = state;
|
||||
setSnapshot(state);
|
||||
if (state.settings.columnOrder?.length > 0) {
|
||||
@@ -1940,14 +1909,6 @@ export function App(): ReactElement {
|
||||
showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800);
|
||||
});
|
||||
unsubscribe = window.rd.onStateUpdate((wireState) => {
|
||||
// Merge delta payloads into the master snapshot. Full payloads replace
|
||||
// the master entirely (initial sync + periodic 30s resync).
|
||||
// NOTE: `settings` and `rotationEvents` are NOT delta-filtered — every emit
|
||||
// (full or delta) carries the complete `settings` object and recent
|
||||
// rotationEvents. The account-validity badges read
|
||||
// `snapshot.settings.debridAccountStatuses` and the rotation panel reads
|
||||
// `snapshot.rotationEvents`; if `settings` is ever delta-optimized, both
|
||||
// must keep flowing on every emit or those views go stale.
|
||||
let merged: UiSnapshot;
|
||||
const master = masterSnapshotRef.current;
|
||||
if (wireState.payloadKind === "delta" && master) {
|
||||
@@ -2151,11 +2112,6 @@ export function App(): ReactElement {
|
||||
const hiddenPackageCount = shouldLimitPackageRendering
|
||||
? Math.max(0, totalPackageCount - packages.length)
|
||||
: 0;
|
||||
// The sort-by-progress logic only runs when the session is running AND auto-sort
|
||||
// is enabled AND there's more than one package. When any of those isn't true,
|
||||
// the items reference is irrelevant — passing null here makes useMemo skip the
|
||||
// re-evaluation that previously fired on EVERY item update (progress, status,
|
||||
// speed) even when the sort would have returned the original `packages` array.
|
||||
const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1)
|
||||
? snapshot.session.items
|
||||
: null;
|
||||
@@ -2193,7 +2149,6 @@ export function App(): ReactElement {
|
||||
void loadAllDebridHostInfo(true);
|
||||
}, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]);
|
||||
|
||||
// Auto-expand packages that are currently extracting (only once per extraction cycle)
|
||||
useEffect(() => {
|
||||
const extractingPkgIds: string[] = [];
|
||||
const currentlyExtracting = new Set<string>();
|
||||
@@ -2210,7 +2165,6 @@ export function App(): ReactElement {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset tracking for packages no longer extracting
|
||||
for (const id of autoExpandedPkgsRef.current) {
|
||||
if (!currentlyExtracting.has(id)) {
|
||||
autoExpandedPkgsRef.current.delete(id);
|
||||
@@ -2231,9 +2185,6 @@ export function App(): ReactElement {
|
||||
|
||||
const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]);
|
||||
|
||||
// DDownload is a direct file hoster (not a debrid service) and is used automatically
|
||||
// for ddownload.com/ddl.to URLs. It counts as a configured account but does not
|
||||
// appear in the primary/secondary/tertiary provider dropdowns.
|
||||
const hasDdownloadAccount = useMemo(() =>
|
||||
Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()),
|
||||
[settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]);
|
||||
@@ -2244,10 +2195,8 @@ export function App(): ReactElement {
|
||||
|
||||
const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0);
|
||||
|
||||
// Dynamische Provider-Reihenfolge (ersetzt altes primary/secondary/tertiary)
|
||||
const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]);
|
||||
|
||||
// Setzt providerOrder + backwards-kompatible Felder synchron
|
||||
const setProviderOrder = useCallback((newOrder: DebridProvider[]) => {
|
||||
settingsDraftRevisionRef.current += 1;
|
||||
panelDirtyRevisionRef.current += 1;
|
||||
@@ -3332,7 +3281,7 @@ export function App(): ReactElement {
|
||||
const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => {
|
||||
let shouldRename = false;
|
||||
setEditingPackageId((prev) => {
|
||||
if (prev !== packageId) return prev; // already finished (e.g. blur after Enter key)
|
||||
if (prev !== packageId) return prev;
|
||||
shouldRename = true;
|
||||
return null;
|
||||
});
|
||||
@@ -3418,8 +3367,6 @@ export function App(): ReactElement {
|
||||
pendingPackageOrderRef.current = [...order];
|
||||
pendingPackageOrderAtRef.current = Date.now();
|
||||
packageOrderRef.current = [...order];
|
||||
// Optimistic UI update ? apply the new order immediately so the user
|
||||
// sees the change without waiting for the backend round-trip.
|
||||
setSnapshot((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, session: { ...prev.session, packageOrder: [...order] } };
|
||||
@@ -3428,7 +3375,6 @@ export function App(): ReactElement {
|
||||
pendingPackageOrderRef.current = null;
|
||||
pendingPackageOrderAtRef.current = 0;
|
||||
packageOrderRef.current = serverPackageOrderRef.current;
|
||||
// Rollback: restore original order from server
|
||||
setSnapshot((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
|
||||
@@ -3578,7 +3524,6 @@ export function App(): ReactElement {
|
||||
const dragDidMoveRef = useRef(false);
|
||||
const lastClickedIdRef = useRef<string | null>(null);
|
||||
|
||||
// Flat list of all visible IDs (package headers + their visible items) in display order
|
||||
const visibleOrderIds = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
for (const pkg of visiblePackages) {
|
||||
@@ -3595,7 +3540,7 @@ export function App(): ReactElement {
|
||||
}, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]);
|
||||
|
||||
const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => {
|
||||
if (dragDidMoveRef.current) return; // drag handled it, skip click
|
||||
if (dragDidMoveRef.current) return;
|
||||
if (shiftKey && lastClickedIdRef.current) {
|
||||
const anchorIdx = visibleOrderIds.indexOf(lastClickedIdRef.current);
|
||||
const targetIdx = visibleOrderIds.indexOf(id);
|
||||
@@ -3642,7 +3587,6 @@ export function App(): ReactElement {
|
||||
if (!dragSelectRef.current) return;
|
||||
if (!dragDidMoveRef.current) {
|
||||
dragDidMoveRef.current = true;
|
||||
// Add anchor item now that we know it's a drag
|
||||
const anchor = dragAnchorRef.current;
|
||||
if (anchor) {
|
||||
setSelectedIds((prev) => { if (prev.has(anchor)) return prev; const next = new Set(prev); next.add(anchor); return next; });
|
||||
@@ -3655,7 +3599,6 @@ export function App(): ReactElement {
|
||||
const sel = selectedIds;
|
||||
const currentPackages = snapshotRef.current.session.packages;
|
||||
const currentItems = snapshotRef.current.session.items;
|
||||
// Multi-select: collect links from all selected packages/items
|
||||
if (sel.size > 1) {
|
||||
const allLinks: { name: string; url: string }[] = [];
|
||||
for (const id of sel) {
|
||||
@@ -3785,7 +3728,6 @@ export function App(): ReactElement {
|
||||
useEffect(() => {
|
||||
if (!colHeaderCtx) return;
|
||||
const close = (e: MouseEvent): void => {
|
||||
// Don't close if click is inside the menu or on the header bar (re-position instead)
|
||||
if (colHeaderCtxRef.current && colHeaderCtxRef.current.contains(e.target as Node)) return;
|
||||
if (colHeaderBarRef.current && colHeaderBarRef.current.contains(e.target as Node)) return;
|
||||
setColHeaderCtx(null);
|
||||
@@ -3856,7 +3798,6 @@ export function App(): ReactElement {
|
||||
if (e.key === "Escape") {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
||||
// Don't clear selection if an overlay is open ? let the overlay close first
|
||||
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
||||
if (tabRef.current === "downloads") setSelectedIds(new Set());
|
||||
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
|
||||
@@ -4524,7 +4465,7 @@ export function App(): ReactElement {
|
||||
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
|
||||
</div>
|
||||
)}
|
||||
{/* Action buttons moved to footer */}
|
||||
{}
|
||||
<div ref={colHeaderBarRef} className="pkg-column-header" style={{ gridTemplateColumns: gridTemplate }} onContextMenu={(e) => { e.preventDefault(); setColHeaderCtx({ x: e.clientX, y: e.clientY }); }}>
|
||||
{columnOrder.map((col) => {
|
||||
const def = COLUMN_DEFS[col];
|
||||
@@ -5713,8 +5654,6 @@ export function App(): ReactElement {
|
||||
const nextAccounts = [...prev.megaAccounts, { login, password }];
|
||||
return { ...prev, megaAccounts: nextAccounts, megaNewLogin: "", megaNewPassword: "", token: serializeMegaDebridAccounts(nextAccounts) };
|
||||
});
|
||||
// Sofort beim Anlegen pruefen (Gueltigkeit + Premium-Restlaufzeit) —
|
||||
// Badge aktualisiert sich via Snapshot, ohne Tab schliessen / "Alle pruefen".
|
||||
if (!exists) {
|
||||
void runMegaAccountCheck(login, password);
|
||||
}
|
||||
@@ -6139,7 +6078,6 @@ export function App(): ReactElement {
|
||||
if (isVisible) {
|
||||
newOrder = columnOrder.filter((c) => c !== col);
|
||||
} else {
|
||||
// Insert at original default position relative to existing columns
|
||||
newOrder = [...columnOrder];
|
||||
const defaultIdx = ALL_COLUMN_KEYS.indexOf(col);
|
||||
let insertAt = newOrder.length;
|
||||
@@ -6338,8 +6276,6 @@ export function App(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** Computes the user-facing status text for an item, applying business rules
|
||||
* about which states are visible while the session is stopped. */
|
||||
function computeDisplayedItemStatus(item: DownloadItem, sessionRunning: boolean): string {
|
||||
const statusText = String(item.fullStatus || "").trim();
|
||||
if (statusText === "Wartet") return "";
|
||||
@@ -6365,9 +6301,6 @@ interface ItemRowProps {
|
||||
onContextMenu: (packageId: string, itemId: string | undefined, x: number, y: number) => void;
|
||||
}
|
||||
|
||||
/** Per-item row, memoized so a status update on one item doesn't re-render
|
||||
* every other item in the same package (the bottleneck on packages with
|
||||
* many episodes). Custom equality only checks the fields actually rendered. */
|
||||
const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunning, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onContextMenu }: ItemRowProps): ReactElement {
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -6385,10 +6318,7 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
|
||||
e.stopPropagation();
|
||||
onContextMenu(packageId, item.id, e.clientX, e.clientY);
|
||||
}, [packageId, item.id, onContextMenu]);
|
||||
// Memoize the date string so it doesn't get re-formatted on every re-render
|
||||
// when only progress/speed changed but createdAt is stable.
|
||||
const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]);
|
||||
// Memoize the displayed status so we don't compute it twice (title + body)
|
||||
const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]);
|
||||
const statusTitle = displayStatus ? (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus) : "";
|
||||
|
||||
@@ -6453,7 +6383,6 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
|
||||
</div>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
// Skip re-render unless something visible actually changed for THIS item.
|
||||
if (prev.item !== next.item) {
|
||||
const a = prev.item;
|
||||
const b = next.item;
|
||||
@@ -6521,8 +6450,6 @@ interface PackageCardProps {
|
||||
}
|
||||
|
||||
const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripeVariant, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, sessionRunning, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
||||
// Single-pass aggregation: replaces 5 separate filter()/some() + 2 reduce() calls.
|
||||
// For a package with N items this is O(N) instead of O(7N) per render.
|
||||
const stats = useMemo(() => {
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
@@ -6688,10 +6615,6 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe
|
||||
|| prev.gridTemplate !== next.gridTemplate) {
|
||||
return false;
|
||||
}
|
||||
// selectedIds is a Set that gets a new reference on every selection change
|
||||
// anywhere in the app. Only re-render this card if the selection state
|
||||
// changed for an item that ACTUALLY belongs to this package — that way
|
||||
// selecting an item in a different package doesn't re-render all 200+ cards.
|
||||
if (prev.selectedIds !== next.selectedIds) {
|
||||
for (const itemId of next.pkg.itemIds) {
|
||||
if (prev.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) {
|
||||
|
||||
+3170
-3180
File diff suppressed because it is too large
Load Diff
Vendored
-2
@@ -1,5 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { ElectronApi } from "../shared/preload-api";
|
||||
|
||||
declare global {
|
||||
|
||||
+70
-70
@@ -1,70 +1,70 @@
|
||||
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_TRACE_CONFIG: "app:get-trace-config",
|
||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
||||
ROTATE_DEBUG_TOKEN: "app:rotate-debug-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"
|
||||
} as const;
|
||||
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_TRACE_CONFIG: "app:get-trace-config",
|
||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
||||
ROTATE_DEBUG_TOKEN: "app:rotate-debug-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"
|
||||
} as const;
|
||||
|
||||
@@ -39,11 +39,6 @@ export function getMegaDebridAccountLabel(index: number): string {
|
||||
return `Account ${index + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse newline-separated "login:password" pairs.
|
||||
* Falls back to treating the entire string as a single login if no colon
|
||||
* is found (backward compat with old megaLogin field).
|
||||
*/
|
||||
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const lines = String(raw || "")
|
||||
@@ -60,7 +55,6 @@ export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaD
|
||||
login = line.slice(0, colonIdx).trim();
|
||||
password = line.slice(colonIdx + 1).trim();
|
||||
} else {
|
||||
// Legacy format: just a login, use the provided fallback password
|
||||
login = line;
|
||||
password = legacyPassword;
|
||||
}
|
||||
|
||||
@@ -250,8 +250,6 @@ export function addDebridLinkApiKeyTotalUsageBytes(
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mega-Debrid per-account limits ──
|
||||
|
||||
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
|
||||
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
|
||||
}
|
||||
|
||||
+500
-523
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user