Compare commits

..

No commits in common. "main" and "v1.7.77" have entirely different histories.

98 changed files with 4077 additions and 17855 deletions

13
.gitignore vendored
View File

@ -19,6 +19,7 @@ apply_update.cmd
.claude/
.github/
docs/plans/
CHANGELOG.md
node_modules/
@ -28,6 +29,7 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Forgejo deployment runtime files
deploy/forgejo/.env
deploy/forgejo/forgejo/
deploy/forgejo/postgres/
@ -36,14 +38,3 @@ deploy/forgejo/caddy/config/
deploy/forgejo/caddy/logs/
deploy/forgejo/backups/
.secrets
*.log.old
*.bak
rust-postprocess/
electron-postprocess/
python-postprocess/
scripts/*.py
scripts/*.ps1
scripts/*.md
scripts/fix-library-renames.mjs

View File

@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "1.7.190",
"version": "1.7.77",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",

View File

@ -98,12 +98,13 @@ public final class JBindExtractorMain {
System.out.flush();
}
} catch (IOException ignored) {
// stdin closed parent process exited
}
}
private static ExtractionRequest parseDaemonRequest(String jsonLine) {
// Minimal JSON parsing without external dependencies.
// Expected format: {"archive":"...","target":"...","conflict":"...","backend":"...","passwords":["...","..."]}
ExtractionRequest request = new ExtractionRequest();
request.archiveFile = new File(extractJsonString(jsonLine, "archive"));
request.targetDir = new File(extractJsonString(jsonLine, "target"));
@ -115,7 +116,7 @@ public final class JBindExtractorMain {
if (backend.length() > 0) {
request.backend = Backend.fromValue(backend);
}
// Parse passwords array
int pwStart = jsonLine.indexOf("\"passwords\"");
if (pwStart >= 0) {
int arrStart = jsonLine.indexOf('[', pwStart);
@ -160,7 +161,7 @@ public final class JBindExtractorMain {
for (int i = from; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\\') {
i++;
i++; // skip escaped character
continue;
}
if (c == '"') return i;
@ -366,6 +367,7 @@ public final class JBindExtractorMain {
throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath());
}
// Pre-scan: collect file indices, sizes, output paths, and detect encryption
long totalUnits = 0;
boolean encrypted = false;
List<Integer> fileIndices = new ArrayList<Integer>();
@ -389,7 +391,7 @@ public final class JBindExtractorMain {
Boolean isEncrypted = (Boolean) archive.getProperty(i, PropID.ENCRYPTED);
encrypted = encrypted || Boolean.TRUE.equals(isEncrypted);
} catch (Throwable ignored) {
// ignore encrypted flag read issues
}
Long rawSize = (Long) archive.getProperty(i, PropID.SIZE);
@ -398,12 +400,12 @@ public final class JBindExtractorMain {
File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
fileIndices.add(i);
outputFiles.add(output);
outputFiles.add(output); // null if skipped
fileSizes.add(itemSize);
}
if (fileIndices.isEmpty()) {
// All items are folders or skipped
ProgressTracker progress = new ProgressTracker(1);
progress.emitStart();
progress.emitDone();
@ -413,16 +415,19 @@ public final class JBindExtractorMain {
ProgressTracker progress = new ProgressTracker(totalUnits);
progress.emitStart();
// Build index array for bulk extract
int[] indices = new int[fileIndices.size()];
for (int i = 0; i < fileIndices.size(); i++) {
indices[i] = fileIndices.get(i);
}
// Map from archive index to our position in fileIndices/outputFiles
Map<Integer, Integer> indexToPos = new HashMap<Integer, Integer>();
for (int i = 0; i < fileIndices.size(); i++) {
indexToPos.put(fileIndices.get(i), i);
}
// Bulk extraction state
final boolean encryptedFinal = encrypted;
final String effectivePassword = password == null ? "" : password;
final File[] currentOutput = new File[1];
@ -669,7 +674,7 @@ public final class JBindExtractorMain {
if (entry.length() == 0) {
return fallback;
}
// Sanitize Windows special characters from each path segment
String[] segments = entry.split("/", -1);
StringBuilder sanitized = new StringBuilder();
for (int i = 0; i < segments.length; i++) {
@ -703,7 +708,7 @@ public final class JBindExtractorMain {
if (Files.isSymbolicLink(file.toPath())) {
throw new IOException("Zieldatei ist ein Symlink, Schreiben verweigert: " + file.getAbsolutePath());
}
// Also check parent directories for symlinks
File parent = file.getParentFile();
while (parent != null) {
if (Files.isSymbolicLink(parent.toPath())) {
@ -874,6 +879,12 @@ public final class JBindExtractorMain {
private final List<String> passwords = new ArrayList<String>();
}
/**
* Bulk extraction callback that implements both IArchiveExtractCallback and
* ICryptoGetTextPassword. Using the bulk IInArchive.extract() API instead of
* per-item extractSlow() is critical for performance solid RAR archives
* otherwise re-decode from the beginning for every single item.
*/
private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword {
private final IInArchive archive;
private final Map<Integer, Integer> indexToPos;
@ -919,12 +930,12 @@ public final class JBindExtractorMain {
@Override
public void setTotal(long total) {
// 7z reports total compressed bytes; we track uncompressed via ProgressTracker
}
@Override
public void setCompleted(long complete) {
// Not used we track per-write progress
}
@Override
@ -979,7 +990,7 @@ public final class JBindExtractorMain {
@Override
public void prepareOperation(ExtractAskMode extractAskMode) {
// no-op
}
@Override
@ -1000,7 +1011,7 @@ public final class JBindExtractorMain {
currentOutput[0].setLastModified(modified.getTime());
}
} catch (Throwable ignored) {
// best effort
}
}
} else {
@ -1168,12 +1179,12 @@ public final class JBindExtractorMain {
@Override
public void setTotal(Long files, Long bytes) {
// no-op
}
@Override
public void setCompleted(Long files, Long bytes) {
// no-op
}
@Override
@ -1185,7 +1196,8 @@ public final class JBindExtractorMain {
if (filename == null || filename.trim().length() == 0) {
return null;
}
// Always resolve relative to the archive's parent directory.
// Never accept absolute paths to prevent path traversal.
String baseName = new File(filename).getName();
if (archiveDir != null) {
File relative = new File(archiveDir, baseName);

View File

@ -66,6 +66,8 @@ async function callRealDebrid(link) {
};
}
// megaCookie is intentionally cached at module scope so that multiple
// callMegaDebrid() invocations reuse the same session cookie.
async function callMegaDebrid(link) {
if (!megaCookie) {
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {

View File

@ -116,6 +116,7 @@ function getGiteaRepo() {
}
return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` };
} catch {
// try next remote
}
}
@ -255,13 +256,15 @@ async function createOrGetRelease(baseApi, tag, authHeader, notes) {
target_commitish: "main",
name: tag,
body: notes || `Release ${tag}`,
draft: true,
draft: false,
prerelease: false
};
const created = await apiRequest("POST", `${baseApi}/releases`, authHeader, JSON.stringify(payload));
if (created.ok) {
return created.body;
}
// Gitea can return 409/422/500 UNIQUE when the release was already partially created.
// Retry the GET — it may now exist.
if (created.status === 409 || created.status === 422 || created.status === 500) {
const retry = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
if (retry.ok) {
@ -273,60 +276,42 @@ async function createOrGetRelease(baseApi, tag, authHeader, notes) {
}
async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, files) {
const MAX_ATTEMPTS = 3;
for (const fileName of files) {
const filePath = path.join(releaseDir, fileName);
const fileSize = fs.statSync(filePath).size;
const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
const fileStream = fs.createReadStream(filePath);
let response;
try {
response = await fetch(uploadUrl, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: authHeader,
"Content-Type": "application/octet-stream",
"Content-Length": String(fileSize)
},
body: fileStream,
duplex: "half"
});
} catch (error) {
fileStream.destroy();
if (attempt < MAX_ATTEMPTS) {
process.stdout.write(`Upload ${fileName} abgebrochen (Netzwerk, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
continue;
}
throw new Error(`Asset upload failed for ${fileName} after ${MAX_ATTEMPTS} attempts: ${String(error?.message || error)}`);
}
// Stream large files instead of loading them entirely into memory
const fileStream = fs.createReadStream(filePath);
const response = await fetch(uploadUrl, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: authHeader,
"Content-Type": "application/octet-stream",
"Content-Length": String(fileSize)
},
body: fileStream,
duplex: "half"
});
const text = await response.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (response.ok) {
process.stdout.write(`Uploaded: ${fileName}\n`);
break;
}
if (response.status === 409 || response.status === 422) {
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
break;
}
if (response.status >= 500 && attempt < MAX_ATTEMPTS) {
process.stdout.write(`Upload ${fileName} fehlgeschlagen (${response.status}, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
continue;
}
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(parsed)}`);
const text = await response.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (response.ok) {
process.stdout.write(`Uploaded: ${fileName}\n`);
continue;
}
if (response.status === 409 || response.status === 422) {
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
continue;
}
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(parsed)}`);
}
}
@ -378,11 +363,6 @@ async function main() {
const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes);
await uploadReleaseAssets(baseApi, release.id, authHeader, assets.releaseDir, assets.files);
const published = await apiRequest("PATCH", `${baseApi}/releases/${release.id}`, authHeader, JSON.stringify({ draft: false }));
if (!published.ok) {
throw new Error(`Failed to publish release (${published.status}): ${JSON.stringify(published.body)}`);
}
process.stdout.write(`Release published: ${release.html_url || `${repo.baseUrl}/${repo.owner}/${repo.repo}/releases/tag/${tag}`}\n`);
}

View File

@ -1,197 +0,0 @@
import type { AppSettings, DebridAccountStatus } from "../shared/types";
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
import { logger } from "./logger";
import { compactErrorText } from "./utils";
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
const CHECK_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
const CHECK_TIMEOUT_MS = 20000;
function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal {
const timeout = AbortSignal.timeout(ms);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
function parseJsonSafe(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text) as unknown;
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
function formatRemaining(premiumUntilMs: number | null, now: number): string {
if (premiumUntilMs == null) {
return "Premium-Status unbekannt";
}
if (premiumUntilMs <= 0) {
return "Kein Premium";
}
const remainingMs = premiumUntilMs - now;
if (remainingMs <= 0) {
return "Premium abgelaufen";
}
const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
if (days >= 1) {
return `Premium noch ${days} Tag${days === 1 ? "" : "e"}`;
}
const hours = Math.max(1, Math.floor(remainingMs / (60 * 60 * 1000)));
return `Premium noch ${hours} Std`;
}
export async function checkMegaDebridAccount(
account: MegaDebridAccountEntry,
signal?: AbortSignal,
now = Date.now()
): Promise<DebridAccountStatus> {
const base: DebridAccountStatus = {
accountId: account.id,
provider: "megadebrid",
label: account.label,
maskedLogin: account.maskedLogin,
valid: false,
isPremium: false,
premiumUntilMs: null,
message: "",
checkedAt: now
};
try {
const url = `${MEGA_DEBRID_API}?action=connectUser&login=${encodeURIComponent(account.login)}&password=${encodeURIComponent(account.password)}`;
const response = await fetch(url, {
headers: { "User-Agent": CHECK_USER_AGENT },
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
});
const text = await response.text();
const payload = parseJsonSafe(text);
if (!response.ok || !payload) {
return { ...base, message: `Login fehlgeschlagen (HTTP ${response.status})` };
}
if (payload.response_code !== "ok") {
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
return { ...base, message: `Ungueltiger Login: ${reason}` };
}
const vipEndRaw = Number(payload.vip_end || 0);
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
const isPremium = premiumUntilMs > now;
const email = String(payload.email || "").trim() || undefined;
return {
...base,
valid: true,
isPremium,
premiumUntilMs,
email,
message: formatRemaining(premiumUntilMs, now)
};
} catch (error) {
const errText = compactErrorText(error);
const aborted = signal?.aborted || /aborted/i.test(errText);
return {
...base,
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
};
}
}
export async function checkDebridLinkKey(
key: DebridLinkApiKeyEntry,
signal?: AbortSignal,
now = Date.now()
): Promise<DebridAccountStatus> {
const base: DebridAccountStatus = {
accountId: key.id,
provider: "debridlink",
label: key.label,
maskedLogin: key.masked,
valid: false,
isPremium: false,
premiumUntilMs: null,
message: "",
checkedAt: now
};
try {
const response = await fetch(`${DEBRID_LINK_API}/account/infos`, {
headers: {
Authorization: `Bearer ${key.token}`,
"User-Agent": CHECK_USER_AGENT
},
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
});
const text = await response.text();
const payload = parseJsonSafe(text);
if (!response.ok || !payload) {
if (response.status === 401 || response.status === 403) {
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
}
return { ...base, message: `Pruefung fehlgeschlagen (HTTP ${response.status})` };
}
if (payload.success === false) {
const reason = String(payload.error || "Key abgelehnt");
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
}
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
const premiumLeftSec = Number(value.premiumLeft || 0);
const accountType = Number(value.accountType || 0);
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
const isPremium = premiumUntilMs > now || accountType > 0;
const username = String(value.username || "").trim() || undefined;
return {
...base,
valid: true,
isPremium,
premiumUntilMs: premiumUntilMs > 0 ? premiumUntilMs : (accountType > 0 ? null : 0),
email: username,
message: premiumUntilMs > 0
? formatRemaining(premiumUntilMs, now)
: (accountType > 0 ? "Premium aktiv" : "Kein Premium (Free)")
};
} catch (error) {
const errText = compactErrorText(error);
const aborted = signal?.aborted || /aborted/i.test(errText);
return {
...base,
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
};
}
}
export async function checkAllDebridAccounts(
settings: AppSettings,
signal?: AbortSignal
): Promise<DebridAccountStatus[]> {
const now = Date.now();
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
];
const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY);
logger.info(
`Account-Check abgeschlossen: ${results.length} Accounts geprueft ` +
`(${results.filter((r) => r.valid).length} gueltig, ${results.filter((r) => r.isPremium).length} premium)`
);
return results;
}
const CHECK_CONCURRENCY = 4;
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
const results: T[] = new Array(taskFns.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (nextIndex < taskFns.length) {
const current = nextIndex;
nextIndex += 1;
results[current] = await taskFns[current]();
}
};
const workers = Array.from({ length: Math.min(limit, taskFns.length) }, () => worker());
await Promise.all(workers);
return results;
}

View File

@ -1,204 +0,0 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
import { AsyncLocalStorage } from "node:async_hooks";
import type { RotationEvent } from "../shared/types";
export type RotationItemSink = (event: RotationEvent) => void;
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
return rotationItemContext.run(sink, fn);
}
type RotationLevel = "INFO" | "WARN" | "ERROR";
const ROTATION_EVENT_RING_MAX = 60;
const rotationEventRing: RotationEvent[] = [];
let rotationEventSeq = 0;
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
rotationEventListener = listener;
}
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
const slice = rotationEventRing.slice(-limit);
slice.reverse();
return slice;
}
function isUiRelevantRotationEvent(event: string): boolean {
return event !== "TEST";
}
function pushRotationEvent(
level: RotationLevel,
provider: string,
accountLabel: string,
event: string,
fields?: Record<string, unknown>,
at = Date.now()
): void {
rotationEventSeq += 1;
const entry: RotationEvent = {
id: `rot_${at}_${rotationEventSeq}`,
at,
level,
provider,
accountLabel,
event,
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
category: fields && fields.category != null ? String(fields.category) : undefined,
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
next: fields && fields.next != null ? String(fields.next) : undefined
};
const itemSink = rotationItemContext.getStore();
if (itemSink) {
try {
itemSink(entry);
} catch {
}
}
if (!isUiRelevantRotationEvent(event)) {
return;
}
rotationEventRing.push(entry);
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
}
if (rotationEventListener) {
try {
rotationEventListener(entry);
} catch {
}
}
}
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
let rotationLogPath: string | null = null;
function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n");
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
} catch {
return String(value);
}
}
function formatFields(fields?: Record<string, unknown>): string {
if (!fields) {
return "";
}
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function rotateIfNeeded(filePath: string): void {
try {
const stat = fs.statSync(filePath);
if (stat.size < ROTATION_LOG_MAX_FILE_BYTES) {
return;
}
const backup = `${filePath}.old`;
try {
fs.rmSync(backup, { force: true });
} catch {
}
fs.renameSync(filePath, backup);
} catch {
}
}
function cleanupOldBackup(filePath: string): void {
const backup = `${filePath}.old`;
try {
const stat = fs.statSync(backup);
const cutoff = Date.now() - ROTATION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (stat.mtimeMs < cutoff) {
fs.rmSync(backup, { force: true });
}
} catch {
}
}
export function initAccountRotationLog(baseDir: string): void {
rotationLogPath = path.join(baseDir, "account-rotation.log");
try {
fs.mkdirSync(path.dirname(rotationLogPath), { recursive: true });
cleanupOldBackup(rotationLogPath);
if (!fs.existsSync(rotationLogPath)) {
fs.writeFileSync(rotationLogPath, "", "utf8");
}
rotateIfNeeded(rotationLogPath);
if (!fs.existsSync(rotationLogPath)) {
fs.writeFileSync(rotationLogPath, "", "utf8");
}
fs.appendFileSync(
rotationLogPath,
`=== Account-Rotation Log Start: ${logTimestamp()} ===\n`,
"utf8"
);
} catch {
rotationLogPath = null;
}
}
export function logAccountRotation(
level: RotationLevel,
provider: string,
accountLabel: string,
event: string,
fields?: Record<string, unknown>
): void {
pushRotationEvent(level, provider, accountLabel, event, fields);
if (!rotationLogPath) {
return;
}
try {
rotateIfNeeded(rotationLogPath);
if (!fs.existsSync(rotationLogPath)) {
fs.writeFileSync(rotationLogPath, "", "utf8");
}
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
} catch {
}
}
export function getAccountRotationLogPath(): string | null {
if (!rotationLogPath) {
return null;
}
return fs.existsSync(rotationLogPath) ? rotationLogPath : null;
}
export function shutdownAccountRotationLog(): void {
if (!rotationLogPath) {
return;
}
try {
fs.appendFileSync(
rotationLogPath,
`=== Account-Rotation Log Ende: ${logTimestamp()} ===\n`,
"utf8"
);
} catch {
}
rotationLogPath = null;
}

View File

@ -243,10 +243,12 @@ export class AllDebridWebFallback {
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
});
} catch {
// ignore
}
try {
await currentSession.clearCache();
} catch {
// ignore
}
}
}

View File

@ -1,12 +1,10 @@
import path from "node:path";
import v8 from "node:v8";
import { app } from "electron";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import {
AddLinksPayload,
AllDebridHostInfo,
AppSettings,
DebridAccountStatus,
DebridProvider,
DuplicatePolicy,
HistoryEntry,
@ -25,8 +23,6 @@ import { importDlcContainers } from "./container";
import { APP_VERSION } from "./constants";
import { DownloadManager } from "./download-manager";
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check";
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { parseCollectorInput } from "./link-parser";
import { configureLogger, getLogFilePath, logger } from "./logger";
import { AllDebridWebFallback } from "./all-debrid-web";
@ -36,18 +32,14 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
import { MegaWebFallback } from "./mega-web-fallback";
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveHistory, saveSession, saveSettings } from "./storage";
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
import { encryptBackup, decryptBackup } from "./backup-crypto";
import { buildBackupPayload, planBackupImport } from "./backup-payload";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
import { runStartupHealthCheck } from "./startup-health-check";
import { getDebugSetupCheck } from "./debug-setup";
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog } from "./desktop-rename-log";
import { buildAccountSummary, diffAccountSummary } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
@ -84,8 +76,6 @@ export class AppController {
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
private autoResumePending = false;
private runtimeStatsTimer: NodeJS.Timeout | null = null;
private lastMemoryWarnAt = 0;
public constructor() {
configureLogger(this.storagePaths.baseDir);
@ -93,18 +83,9 @@ export class AppController {
initPackageLogs(this.storagePaths.baseDir);
initItemLogs(this.storagePaths.baseDir);
initAuditLog(this.storagePaths.baseDir);
initAccountRotationLog(this.storagePaths.baseDir);
initRenameLog(this.storagePaths.baseDir);
let desktopDir: string | null = null;
try {
desktopDir = app.getPath("desktop");
} catch {
desktopDir = null;
}
initDesktopRenameLog(desktopDir);
initTraceLog(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths);
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
const session = loadSession(this.storagePaths);
this.megaWebFallback = new MegaWebFallback(() => ({
login: this.settings.megaLogin,
@ -114,13 +95,13 @@ export class AppController {
this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
megaWebUnrestrict: (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => this.megaWebFallback.unrestrict(link, signal, account),
megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal),
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
onHistoryEntry: (entry: HistoryEntry) => {
addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry);
addHistoryEntry(this.storagePaths, entry);
}
});
this.manager.on("state", (snapshot: UiSnapshot) => {
@ -132,41 +113,7 @@ export class AppController {
appVersion: APP_VERSION,
runtimeDir: this.storagePaths.baseDir
});
try {
const report = runStartupHealthCheck(this.settings, this.storagePaths);
if (report.errorCount > 0 || report.warnCount > 0) {
logger.warn(`Health-Check: ${report.errorCount} Fehler, ${report.warnCount} Warnungen, ${report.infoCount} Info`);
} else {
logger.info(`Health-Check: alles OK (${report.infoCount} Info)`);
}
for (const finding of report.findings) {
const line = finding.hint
? `Health-Check [${finding.code}]: ${finding.message}${finding.hint}`
: `Health-Check [${finding.code}]: ${finding.message}`;
if (finding.severity === "ERROR") {
logger.error(line);
} else if (finding.severity === "WARN") {
logger.warn(line);
} else {
logger.info(line);
}
if (finding.severity !== "INFO") {
logAuditEvent(finding.severity, `Health-Check: ${finding.code}`, {
message: finding.message,
hint: finding.hint || ""
});
}
}
} catch (err) {
logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`);
}
startDebugServer(this.manager, this.storagePaths.baseDir);
this.runtimeStatsTimer = setInterval(() => {
this.manager.persistRuntimeStats();
this.settings = this.manager.getSettings();
this.checkMemoryPressure();
}, 60_000);
this.runtimeStatsTimer.unref?.();
if (this.settings.autoResumeOnStart) {
const snapshot = this.manager.getSnapshot();
@ -175,6 +122,8 @@ export class AppController {
void this.manager.getStartConflicts().then((conflicts) => {
const hasConflicts = conflicts.length > 0;
if (this.hasAnyProviderToken(this.settings) && !hasConflicts) {
// If the onState handler is already set (renderer connected), start immediately.
// Otherwise mark as pending so the onState setter triggers the start.
if (this.onStateHandler) {
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)");
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
@ -190,34 +139,6 @@ export class AppController {
}
}
// Early-warning for OOM on a long-running process. Measured against the V8
// heap_size_limit (the real ceiling at which the process is killed), NOT against
// heapTotal: V8 routinely runs near-full of its current heapTotal just before it
// grows it, so a heapUsed/heapTotal ratio would cry wolf and — since every WARN
// now feeds the error ring — crowd real failures out. Throttled to 1 warning per
// 5 min so a genuine sustained-pressure run does not spam the log/ring.
private checkMemoryPressure(): void {
try {
const mem = process.memoryUsage();
const heapLimit = v8.getHeapStatistics().heap_size_limit;
const ratio = heapLimit > 0 ? mem.heapUsed / heapLimit : 0;
if (ratio < 0.9) {
return;
}
const now = Date.now();
if (now - this.lastMemoryWarnAt < 5 * 60_000) {
return;
}
this.lastMemoryWarnAt = now;
const mb = (bytes: number): number => Math.round(bytes / 1048576);
logger.warn(
`Speicherdruck: heapUsed=${mb(mem.heapUsed)}MB von Limit ${mb(heapLimit)}MB ` +
`(${Math.round(ratio * 100)}%), heapTotal=${mb(mem.heapTotal)}MB, rss=${mb(mem.rss)}MB, external=${mb(mem.external)}MB`
);
} catch {
}
}
private hasAnyProviderToken(settings: AppSettings): boolean {
return Boolean(
settings.token.trim()
@ -245,6 +166,7 @@ export class AppController {
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
logger.info("Auto-Resume beim Start aktiviert");
} else {
// Trigger pending extractions without starting the session
this.manager.triggerIdleExtractions();
}
}
@ -270,10 +192,6 @@ export class AppController {
return getRenameLogPath();
}
public getDesktopRenameLogPath(): string | null {
return getDesktopRenameLogPath();
}
public getTraceLogPath(): string | null {
return getTraceLogPath();
}
@ -303,27 +221,6 @@ export class AppController {
return next;
}
// Carry the live, runtime-maintained usage/status counters onto a settings
// object about to be applied, so they are never rolled back to a stale snapshot.
// All-time totals take the max; daily/total usage and account statuses are taken
// live; per-key Debrid-Link usage is filtered to keys that still exist.
private overlayLiveUsageCounters(target: AppSettings): void {
const liveSettings = this.manager.getSettings();
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
target.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
target.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
target.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
target.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
);
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
);
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
}
public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial);
const previousSettings = this.settings;
@ -336,12 +233,20 @@ export class AppController {
return previousSettings;
}
this.overlayLiveUsageCounters(nextSettings);
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
// Preserve the live all-time counters from the download manager
const liveSettings = this.manager.getSettings();
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
nextSettings.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
);
nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
);
this.settings = nextSettings;
if (retentionChanged) {
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
}
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
this.audit("INFO", "Einstellungen aktualisiert", {
@ -422,27 +327,6 @@ export class AppController {
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
}
public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
const statuses = await checkAllDebridAccounts(this.settings);
this.manager.applyDebridAccountStatuses(statuses);
this.audit("INFO", "Debrid-Accounts geprueft", {
total: statuses.length,
valid: statuses.filter((s) => s.valid).length,
premium: statuses.filter((s) => s.isPremium).length
});
return statuses;
}
public async checkSingleMegaDebridAccount(login: string, password: string): Promise<DebridAccountStatus | null> {
const entry = parseMegaDebridAccounts(`${login.trim()}:${password.trim()}`)[0];
if (!entry) {
return null;
}
const status = await checkMegaDebridAccount(entry);
this.manager.applyDebridAccountStatuses([status]);
this.audit("INFO", "Mega-Debrid-Account einzeln geprueft", { valid: status.valid, premium: status.isPremium });
return status;
}
public async checkUpdates(): Promise<UpdateCheckResult> {
const result = await checkGitHubUpdate(this.settings.updateRepo);
if (!result.error) {
@ -453,10 +337,11 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
}
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
// Stop active downloads before installing. Extractions may continue briefly
// until prepareForShutdown() is called during app quit.
if (this.manager.isSessionRunning()) {
this.manager.stop({ parkForRestart: true });
this.manager.stop();
}
this.manager.persistNowSync();
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
@ -638,21 +523,23 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
}
public exportBackup(): Buffer {
const includeDownloads = Boolean(this.settings.backupIncludeDownloads);
const payloadObj = buildBackupPayload({
settings: { ...this.settings },
const settings = { ...this.settings };
const session = this.manager.getSession();
const history = loadHistory(this.storagePaths);
const payload = JSON.stringify({
version: 2,
appVersion: APP_VERSION,
exportedAt: new Date().toISOString(),
session: this.manager.getSession(),
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode)
settings,
session,
history
});
this.audit("INFO", "Backup exportiert", {
kind: payloadObj.kind,
historyEntries: payloadObj.history ? payloadObj.history.length : 0,
sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0,
sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0
historyEntries: history.length,
sessionItems: Object.keys(session.items).length,
sessionPackages: Object.keys(session.packages).length
});
return encryptBackup(JSON.stringify(payloadObj));
return encryptBackup(payload);
}
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
@ -662,85 +549,62 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
});
return {
buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir),
defaultFileName: getSupportBundleDefaultFileName()
};
}
public getSupportBundleDefaultFileName(): string {
return getSupportBundleDefaultFileName();
}
public importBackup(data: Buffer): { restored: boolean; relaunch: boolean; message: string } {
public importBackup(data: Buffer): { restored: boolean; message: string } {
let parsed: Record<string, unknown>;
try {
// Try encrypted MDD format first
const json = decryptBackup(data);
parsed = JSON.parse(json) as Record<string, unknown>;
} catch {
// Fallback: try legacy plaintext JSON (old backups)
try {
const json = data.toString("utf8");
parsed = JSON.parse(json) as Record<string, unknown>;
} catch {
return { restored: false, relaunch: false, message: "Backup-Datei konnte nicht entschlüsselt werden" };
return { restored: false, message: "Backup-Datei konnte nicht entschlüsselt werden" };
}
}
const plan = planBackupImport(parsed);
if (!plan.valid) {
return { restored: false, relaunch: false, message: plan.message };
if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) {
return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" };
}
const hasSession = plan.restoreDownloads;
// Restore settings — ALL credentials are included (no more masking)
const importedSettings = parsed.settings as AppSettings;
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
// Legacy backup compatibility: if credentials were masked with ***, keep current values
const SENSITIVE_KEYS: (keyof AppSettings)[] = [
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword"
];
for (const key of SENSITIVE_KEYS) {
const val = importedSettingsRecord[key];
const val = (importedSettings as Record<string, unknown>)[key];
if (typeof val === "string" && val.startsWith("***")) {
importedSettingsRecord[key] = currentSettingsRecord[key];
(importedSettings as Record<string, unknown>)[key] = (this.settings as Record<string, unknown>)[key];
}
}
const restoredSettings = normalizeSettings(importedSettings);
// Settings-only backup: keep the running queue AND the live counters untouched.
// Overlay the live usage/status counters so they don't roll back to the backup's
// (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the
// backup's cleanup policy can't purge the live completed queue here (BUG B) — the
// policy still governs FUTURE completions through the normal path. Do NOT stop the
// manager, wipe the session, block persistence or relaunch.
if (!hasSession) {
this.overlayLiveUsageCounters(restoredSettings);
this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
accountSummary: buildAccountSummary(this.settings)
});
return {
restored: true,
relaunch: false,
message: "Einstellungen wiederhergestellt"
};
}
this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
// Full stop including extraction abort
this.manager.stop();
this.manager.abortAllPostProcessing();
this.manager.clearPersistTimer();
cancelPendingAsyncSaves();
// Restore session
const restoredSession = normalizeLoadedSessionTransientFields(
normalizeLoadedSession(parsed.session)
);
saveSession(this.storagePaths, restoredSession);
// Restore history (if present in backup)
if (Array.isArray(parsed.history) && parsed.history.length > 0) {
const normalizedHistory = (parsed.history as unknown[])
.map((raw, idx) => normalizeHistoryEntry(raw, idx))
@ -751,16 +615,15 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
}
}
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
// Prevent prepareForShutdown from overwriting the restored data
this.manager.skipShutdownPersist = true;
this.manager.blockAllPersistence = true;
logger.info("Backup wiederhergestellt — App startet automatisch neu");
logger.info("Backup wiederhergestellt (verschlüsseltes Format)");
this.audit("WARN", "Backup importiert", {
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
accountSummary: buildAccountSummary(this.settings)
});
return { restored: true, relaunch: true, message: "Backup wiederhergestellt App startet automatisch neu…" };
return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." };
}
public getSessionLogPath(): string | null {
@ -776,10 +639,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
}
public shutdown(): void {
if (this.runtimeStatsTimer) {
clearInterval(this.runtimeStatsTimer);
this.runtimeStatsTimer = null;
}
stopDebugServer();
abortActiveUpdateDownload();
this.manager.prepareForShutdown();
@ -791,19 +650,14 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
shutdownPackageLogs();
shutdownItemLogs();
shutdownRenameLog();
shutdownDesktopRenameLog();
this.audit("INFO", "App beendet");
shutdownTraceLog();
shutdownAccountRotationLog();
shutdownAuditLog();
if (this.settings.historyRetentionMode === "session") {
clearHistory(this.storagePaths);
}
logger.info("App beendet");
}
public getHistory(): HistoryEntry[] {
return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
return loadHistory(this.storagePaths);
}
public clearHistory(): void {

View File

@ -1,5 +1,4 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
type AuditLevel = "INFO" | "WARN" | "ERROR";
@ -46,9 +45,11 @@ function rotateIfNeeded(filePath: string): void {
try {
fs.rmSync(backup, { force: true });
} catch {
// ignore
}
fs.renameSync(filePath, backup);
} catch {
// ignore
}
}
@ -61,6 +62,7 @@ function cleanupOldBackup(filePath: string): void {
fs.rmSync(backup, { force: true });
}
} catch {
// ignore
}
}
@ -76,7 +78,7 @@ export function initAuditLog(baseDir: string): void {
if (!fs.existsSync(auditLogPath)) {
fs.writeFileSync(auditLogPath, "", "utf8");
}
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${logTimestamp()} ===\n`, "utf8");
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
auditLogPath = null;
}
@ -93,10 +95,11 @@ export function logAuditEvent(level: AuditLevel, message: string, fields?: Recor
}
fs.appendFileSync(
auditLogPath,
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
`${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`,
"utf8"
);
} catch {
// ignore write errors
}
}
@ -112,8 +115,9 @@ export function shutdownAuditLog(): void {
return;
}
try {
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
// ignore
}
auditLogPath = null;
}

View File

@ -1,15 +1,22 @@
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;
const IV_LENGTH = 12; // 96-bit IV for GCM
const AUTH_TAG_LENGTH = 16;
const MAGIC = Buffer.from("MDD1");
const MAGIC = Buffer.from("MDD1"); // file signature
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);
@ -19,6 +26,10 @@ 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");

View File

@ -1,77 +0,0 @@
import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
export type BackupKind = "full" | "settings-only";
export interface BackupPayload {
version: 2;
kind: BackupKind;
appVersion: string;
exportedAt: string;
settings: AppSettings;
session?: SessionState;
history?: HistoryEntry[];
}
export interface BuildBackupInput {
settings: AppSettings;
appVersion: string;
exportedAt: string;
/** Only bundled when includeDownloads is true. */
session: SessionState;
history: HistoryEntry[];
}
/**
* Build the backup payload. By default ("Download-Liste mitsichern" off) the
* payload contains ONLY settings no session, no history. The download list is
* bundled solely when settings.backupIncludeDownloads is true. An explicit kind
* marker makes the import side unambiguous and survives hand-edited files.
*/
export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
const includeDownloads = Boolean(input.settings.backupIncludeDownloads);
const base: BackupPayload = {
version: 2,
kind: includeDownloads ? "full" : "settings-only",
appVersion: input.appVersion,
exportedAt: input.exportedAt,
settings: input.settings
};
if (includeDownloads) {
base.session = input.session;
base.history = input.history;
}
return base;
}
export interface ImportPlan {
valid: boolean;
/** Restore the download list (session + history) and relaunch. */
restoreDownloads: boolean;
message: string;
}
/**
* Decide how to apply an imported backup based on what the FILE physically
* contains NOT the local toggle. A backup without a session restores settings
* only (no queue wipe, no relaunch); a full backup (with session) restores the
* queue too. This way an old full backup still restores fully even if the local
* toggle is currently off, and a settings-only backup never disturbs a running
* queue.
*/
export function planBackupImport(parsed: unknown): ImportPlan {
if (!parsed || typeof parsed !== "object") {
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
}
const record = parsed as Record<string, unknown>;
if (!record.settings || typeof record.settings !== "object") {
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
}
const hasSession = Boolean(record.session) && typeof record.session === "object";
return {
valid: true,
restoreDownloads: hasSession,
message: hasSession
? "Backup wiederhergestellt App startet automatisch neu…"
: "Einstellungen wiederhergestellt"
};
}

View File

@ -212,15 +212,18 @@ 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 {
@ -341,6 +344,7 @@ export class BestDebridWebFallback {
try {
await currentSession.clearCache();
} catch {
// ignore cache clear failures
}
}
}

View File

@ -39,6 +39,7 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
fs.rmSync(full, { force: true });
removed += 1;
} catch {
// ignore
}
}
}
@ -46,10 +47,7 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
return removed;
}
export async function cleanupCancelledPackageArtifactsAsync(
packageDir: string,
options: { shouldAbort?: () => boolean } = {}
): Promise<number> {
export async function cleanupCancelledPackageArtifactsAsync(packageDir: string): Promise<number> {
try {
await fs.promises.access(packageDir, fs.constants.F_OK);
} catch {
@ -60,9 +58,6 @@ export async function cleanupCancelledPackageArtifactsAsync(
let touched = 0;
const stack = [packageDir];
while (stack.length > 0) {
if (options.shouldAbort?.()) {
return removed;
}
const current = stack.pop() as string;
let entries: fs.Dirent[] = [];
try {
@ -72,9 +67,6 @@ export async function cleanupCancelledPackageArtifactsAsync(
}
for (const entry of entries) {
if (options.shouldAbort?.()) {
return removed;
}
const full = path.join(current, entry.name);
if (entry.isDirectory() && !entry.isSymbolicLink()) {
stack.push(full);
@ -83,6 +75,7 @@ export async function cleanupCancelledPackageArtifactsAsync(
await fs.promises.rm(full, { force: true });
removed += 1;
} catch {
// ignore
}
}
@ -95,10 +88,7 @@ export async function cleanupCancelledPackageArtifactsAsync(
return removed;
}
export async function removeDownloadLinkArtifacts(
extractDir: string,
options: { shouldAbort?: () => boolean } = {}
): Promise<number> {
export async function removeDownloadLinkArtifacts(extractDir: string): Promise<number> {
try {
await fs.promises.access(extractDir);
} catch {
@ -107,16 +97,10 @@ export async function removeDownloadLinkArtifacts(
let removed = 0;
const stack = [extractDir];
while (stack.length > 0) {
if (options.shouldAbort?.()) {
return removed;
}
const current = stack.pop() as string;
let entries: fs.Dirent[] = [];
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
for (const entry of entries) {
if (options.shouldAbort?.()) {
return removed;
}
const full = path.join(current, entry.name);
if (entry.isDirectory() && !entry.isSymbolicLink()) {
stack.push(full);
@ -148,6 +132,7 @@ export async function removeDownloadLinkArtifacts(
await fs.promises.rm(full, { force: true });
removed += 1;
} catch {
// ignore
}
}
}
@ -155,10 +140,7 @@ export async function removeDownloadLinkArtifacts(
return removed;
}
export async function removeSampleArtifacts(
extractDir: string,
options: { shouldAbort?: () => boolean } = {}
): Promise<{ files: number; dirs: number }> {
export async function removeSampleArtifacts(extractDir: string): Promise<{ files: number; dirs: number }> {
try {
await fs.promises.access(extractDir);
} catch {
@ -202,16 +184,10 @@ export async function removeSampleArtifacts(
};
while (stack.length > 0) {
if (options.shouldAbort?.()) {
return { files: removedFiles, dirs: removedDirs };
}
const current = stack.pop() as string;
let entries: fs.Dirent[] = [];
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
for (const entry of entries) {
if (options.shouldAbort?.()) {
return { files: removedFiles, dirs: removedDirs };
}
const full = path.join(current, entry.name);
if (entry.isDirectory() || entry.isSymbolicLink()) {
const base = entry.name.toLowerCase();
@ -237,6 +213,7 @@ export async function removeSampleArtifacts(
await fs.promises.rm(full, { force: true });
removedFiles += 1;
} catch {
// ignore
}
}
}
@ -244,9 +221,6 @@ export async function removeSampleArtifacts(
sampleDirs.sort((a, b) => b.length - a.length);
for (const dir of sampleDirs) {
if (options.shouldAbort?.()) {
return { files: removedFiles, dirs: removedDirs };
}
try {
const stat = await fs.promises.lstat(dir);
if (stat.isSymbolicLink()) {
@ -259,6 +233,7 @@ export async function removeSampleArtifacts(
removedFiles += filesInDir;
removedDirs += 1;
} catch {
// ignore
}
}

View File

@ -17,12 +17,12 @@ 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 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"]);
@ -46,7 +46,6 @@ export function defaultSettings(): AppSettings {
realDebridUseWebLogin: false,
megaLogin: "",
megaPassword: "",
megaCredentials: "",
megaDebridApiEnabled: false,
megaDebridWebEnabled: false,
megaDebridPreferApi: true,
@ -72,8 +71,6 @@ export function defaultSettings(): AppSettings {
packageName: "",
autoExtract: true,
autoRename4sf4sj: false,
keepGermanAudioOnly: false,
germanAudioMode: "tag",
extractDir: path.join(baseDir, "_entpackt"),
collectMkvToLibrary: false,
mkvLibraryDir: path.join(baseDir, "_mkv"),
@ -100,16 +97,13 @@ export function defaultSettings(): AppSettings {
minimizeToTray: false,
theme: "dark" as const,
collapseNewPackages: true,
historyRetentionMode: "permanent",
accountListShowDetailedDebridLinkKeys: false,
autoSortPackagesByProgress: true,
autoSkipExtracted: false,
hideExtractedItems: true,
confirmDeleteSelection: true,
backupIncludeDownloads: false,
totalDownloadedAllTime: 0,
totalCompletedFilesAllTime: 0,
totalRuntimeAllTimeMs: 0,
bandwidthSchedules: [],
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
extractCpuPriority: "high",
@ -122,11 +116,6 @@ export function defaultSettings(): AppSettings {
debridLinkApiKeyDailyLimitBytes: {},
debridLinkApiKeyDailyUsageBytes: {},
debridLinkApiKeyTotalUsageBytes: {},
megaDebridDisabledAccountIds: [],
megaDebridAccountDailyLimitBytes: {},
megaDebridAccountDailyUsageBytes: {},
megaDebridAccountTotalUsageBytes: {},
debridAccountStatuses: {},
providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0
};

View File

@ -113,11 +113,13 @@ 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
}
}
@ -130,6 +132,7 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
links.push(url);
}
} catch {
// skip broken entries
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup";
import { logger, getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring";
import { getItemLogPath as getPersistedItemLogPath } from "./item-log";
import { getSessionLogPath } from "./session-log";
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
@ -45,7 +44,6 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
{ method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." },
{ method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." },
{ method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." },
{ method: "GET", path: "/trace/config", queryExample: "enable=1&note=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
@ -78,21 +76,6 @@ function readSupportHistory() {
return loadHistory(getStoragePaths());
}
function extractDebugClientIp(req: http.IncomingMessage): string {
const forwarded = req.headers["x-forwarded-for"];
const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const forwardedIp = String(forwardedValue || "").split(",")[0]?.trim();
if (forwardedIp) {
return forwardedIp;
}
const realIp = String(req.headers["x-real-ip"] || "").trim();
if (realIp) {
return realIp;
}
const remote = String(req.socket.remoteAddress || req.socket.address()?.address || "").trim();
return remote.replace(/^::ffff:/i, "");
}
function getAiManifestPath(baseDir: string = runtimeBaseDir): string {
return path.join(baseDir, AI_MANIFEST_FILE);
}
@ -118,6 +101,7 @@ function getPort(baseDir: string): number {
return n;
}
} catch {
// ignore
}
return DEFAULT_PORT;
}
@ -136,6 +120,7 @@ function getHost(baseDir: string): string {
return raw;
}
} catch {
// ignore
}
return DEFAULT_HOST;
}
@ -446,8 +431,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("INFO", "debug-http", "Request", {
method: req.method || "GET",
url: sanitizeRequestUrlForTrace(req.url || "/"),
clientIp: extractDebugClientIp(req)
url: sanitizeRequestUrlForTrace(req.url || "/")
});
}
@ -465,8 +449,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
method: req.method || "GET",
url: sanitizeRequestUrlForTrace(req.url || "/"),
clientIp: extractDebugClientIp(req)
url: sanitizeRequestUrlForTrace(req.url || "/")
});
}
jsonResponse(res, 401, { error: "Unauthorized" });
@ -530,18 +513,6 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
if (pathname === "/errors") {
const levelFilter = (url.searchParams.get("level") || "").toUpperCase();
const limit = normalizeLinesParam(url.searchParams.get("limit"), 100);
let entries = getRecentErrors();
if (levelFilter === "ERROR" || levelFilter === "WARN") {
entries = entries.filter((entry) => entry.level === levelFilter);
}
const limited = entries.slice(-limit);
jsonResponse(res, 200, { count: limited.length, total: entries.length, entries: limited });
return;
}
if (pathname === "/logs/audit") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
@ -710,8 +681,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
...buildStatsPayload(snapshot),
allTime: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime
}
});
return;

View File

@ -53,6 +53,7 @@ function readPort(baseDir: string): number {
return raw;
}
} catch {
// ignore
}
return DEFAULT_PORT;
}
@ -70,6 +71,7 @@ function readHost(baseDir: string): string {
return raw;
}
} catch {
// ignore
}
return DEFAULT_HOST;
}
@ -156,6 +158,7 @@ function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): Suppor
bytes += fs.statSync(fullPath).size;
fileCount += 1;
} catch {
// ignore unreadable files
}
}
}

View File

@ -1,252 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { logTimestamp } from "./log-timestamp";
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
const FOLDER_NAME = "Downloader-Log";
let logDir: string | null = null;
let logFilePath: string | null = null;
let sessionHeader = "";
function fileTimestamp(date: Date = new Date()): string {
const pad = (value: number): string => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
+ `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
}
function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n");
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
} catch {
return String(value);
}
}
function formatFields(fields?: Record<string, unknown>): string {
if (!fields) {
return "";
}
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function ensureWritable(): boolean {
if (!logDir || !logFilePath) {
return false;
}
try {
fs.mkdirSync(logDir, { recursive: true });
if (!fs.existsSync(logFilePath)) {
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
}
return true;
} catch {
return false;
}
}
export function initDesktopRenameLog(desktopDir: string | null | undefined): void {
try {
const base = String(desktopDir || "").trim();
if (!base) {
logDir = null;
logFilePath = null;
return;
}
logDir = path.join(base, FOLDER_NAME);
logFilePath = path.join(logDir, `rename-session_${fileTimestamp()}.txt`);
sessionHeader = `=== Rename-Session gestartet: ${logTimestamp()} ===\n`
+ "Diese Datei protokolliert JEDEN Umbenenn-/Verschiebevorgang dieser Programm-Sitzung\n"
+ "und verifiziert nach jedem Vorgang, ob die Datei wirklich unter dem Zielnamen auf der\n"
+ "Platte liegt (und die Quelle verschwunden ist). [INFO]=ok, [ERROR]=Verifikation gescheitert.\n\n";
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
} catch {
logDir = null;
logFilePath = null;
}
}
export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void {
if (!ensureWritable() || !logFilePath) {
return;
}
try {
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
} catch {
}
}
export function getDesktopRenameLogPath(): string | null {
if (!logFilePath) {
return null;
}
try {
return fs.existsSync(logFilePath) ? logFilePath : null;
} catch {
return null;
}
}
export function shutdownDesktopRenameLog(): void {
if (ensureWritable() && logFilePath) {
try {
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
} catch {
}
}
logDir = null;
logFilePath = null;
}
export interface RenameVerification {
ok: boolean;
level: "INFO" | "WARN" | "ERROR";
targetExists: boolean;
onDiskName: string | null;
nameMatches: boolean;
sourceGone: boolean;
targetSize: number | null;
reason: string;
}
function toLongPath(filePath: string): string {
const absolute = path.resolve(String(filePath || ""));
if (process.platform !== "win32") {
return absolute;
}
if (!absolute || absolute.startsWith("\\\\?\\")) {
return absolute;
}
if (absolute.length < 248) {
return absolute;
}
if (absolute.startsWith("\\\\")) {
return `\\\\?\\UNC\\${absolute.slice(2)}`;
}
return `\\\\?\\${absolute}`;
}
function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
if (entries === null) {
return null;
}
const requestedLower = requested.toLowerCase();
return entries.find((entry) => entry === requested)
|| entries.find((entry) => entry.toLowerCase() === requestedLower)
|| requested;
}
function buildVerification(
sourcePath: string,
targetPath: string,
facts: { targetExists: boolean; targetSize: number | null; dirEntries: string[] | null; sourceExists: boolean }
): RenameVerification {
const requested = path.basename(targetPath);
const dirReadFailed = facts.targetExists && facts.dirEntries === null;
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
const sourceGone = samePath ? true : !facts.sourceExists;
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
const problems: string[] = [];
let level: "INFO" | "WARN" | "ERROR" = "INFO";
if (!facts.targetExists) {
problems.push("Zieldatei nach Rename NICHT gefunden");
level = "ERROR";
} else if (!dirReadFailed && !nameMatches) {
problems.push(`On-Disk-Name weicht ab (ist "${onDiskName}", erwartet "${requested}")`);
level = "ERROR";
}
if (!samePath && facts.targetExists && !sourceGone) {
problems.push("Quelldatei existiert noch (moeglicher halb-fertiger Verschiebevorgang)");
level = "ERROR";
}
if (level === "INFO" && dirReadFailed) {
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
level = "WARN";
}
return {
ok: level === "INFO",
level,
targetExists: facts.targetExists,
onDiskName,
nameMatches,
sourceGone,
targetSize: facts.targetSize,
reason: problems.join("; ")
};
}
export function verifyRename(sourcePath: string, targetPath: string): RenameVerification {
const longTarget = toLongPath(targetPath);
let targetExists = false;
let targetSize: number | null = null;
try {
const stat = fs.statSync(longTarget);
targetExists = true;
targetSize = stat.size;
} catch {
targetExists = false;
}
let dirEntries: string[] | null = null;
if (targetExists) {
try {
dirEntries = fs.readdirSync(path.dirname(longTarget));
} catch {
dirEntries = null;
}
}
let sourceExists = false;
try {
fs.statSync(toLongPath(sourcePath));
sourceExists = true;
} catch {
sourceExists = false;
}
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
}
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
const longTarget = toLongPath(targetPath);
let targetExists = false;
let targetSize: number | null = null;
try {
const stat = await fs.promises.stat(longTarget);
targetExists = true;
targetSize = stat.size;
} catch {
targetExists = false;
}
let dirEntries: string[] | null = null;
if (targetExists) {
try {
dirEntries = await fs.promises.readdir(path.dirname(longTarget));
} catch {
dirEntries = null;
}
}
let sourceExists = false;
try {
await fs.promises.stat(toLongPath(sourcePath));
sourceExists = true;
} catch {
sourceExists = false;
}
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
}

View File

@ -1,150 +0,0 @@
import { ALLOCATION_UNIT_SIZE } from "./constants";
export type DownloadCompletionSource =
| "content-range"
| "content-length"
| "provider-metadata"
| "stream-end";
export type DownloadCompletionPlan = {
expectedTotal: number | null;
source: DownloadCompletionSource;
canFinishEarly: boolean;
};
export function planDownloadCompletion(args: {
existingBytes: number;
responseStatus: number;
contentLength: number;
totalFromRange: number | null;
knownTotal: number | null;
correctedTotal: number | null;
}): DownloadCompletionPlan {
const existingBytes = Math.max(0, Math.floor(Number(args.existingBytes) || 0));
const responseStatus = Math.floor(Number(args.responseStatus) || 0);
const contentLength = Math.max(0, Math.floor(Number(args.contentLength) || 0));
const totalFromRange = Number.isFinite(args.totalFromRange || NaN)
? Math.max(0, Math.floor(args.totalFromRange || 0))
: 0;
const correctedTotal = Number.isFinite(args.correctedTotal || NaN)
? Math.max(0, Math.floor(args.correctedTotal || 0))
: 0;
const knownTotal = Number.isFinite(args.knownTotal || NaN)
? Math.max(0, Math.floor(args.knownTotal || 0))
: 0;
if (correctedTotal > 0) {
return {
expectedTotal: correctedTotal,
source: totalFromRange > 0 ? "content-range" : "content-length",
canFinishEarly: true
};
}
if (totalFromRange > 0) {
return {
expectedTotal: totalFromRange,
source: "content-range",
canFinishEarly: true
};
}
if (contentLength > 0) {
return {
expectedTotal: responseStatus === 206 ? existingBytes + contentLength : contentLength,
source: "content-length",
canFinishEarly: true
};
}
if (knownTotal > 0) {
return {
expectedTotal: knownTotal,
source: "provider-metadata",
canFinishEarly: false
};
}
return {
expectedTotal: null,
source: "stream-end",
canFinishEarly: false
};
}
export function validateDownloadedFileCompletion(args: {
actualBytes: number;
plan: DownloadCompletionPlan;
toleranceBytes?: number;
}): {
ok: boolean;
totalBytes: number;
acceptedMetadataMismatch: boolean;
error?: string;
} {
const actualBytes = Math.max(0, Math.floor(Number(args.actualBytes) || 0));
const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN)
? Math.max(0, Math.floor(args.plan.expectedTotal || 0))
: 0;
const toleranceBytes = Math.max(0, Math.floor(Number(args.toleranceBytes ?? ALLOCATION_UNIT_SIZE) || 0));
if (
expectedTotal > 0 &&
(args.plan.source === "content-range" || args.plan.source === "content-length") &&
actualBytes + toleranceBytes < expectedTotal
) {
return {
ok: false,
totalBytes: expectedTotal,
acceptedMetadataMismatch: false,
error: `download_underflow:${actualBytes}/${expectedTotal}`
};
}
if (actualBytes <= 0 && expectedTotal > 0) {
return {
ok: false,
totalBytes: expectedTotal,
acceptedMetadataMismatch: false,
error: `download_underflow:${actualBytes}/${expectedTotal}`
};
}
if (args.plan.source === "provider-metadata") {
if (expectedTotal > 0 && actualBytes + toleranceBytes < expectedTotal) {
return {
ok: false,
totalBytes: expectedTotal,
acceptedMetadataMismatch: false,
error: `download_underflow:${actualBytes}/${expectedTotal}`
};
}
return {
ok: true,
totalBytes: actualBytes,
acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > toleranceBytes
};
}
if (args.plan.source === "stream-end") {
if (actualBytes <= 0) {
return {
ok: false,
totalBytes: 0,
acceptedMetadataMismatch: false,
error: "download_underflow:0/0"
};
}
return {
ok: true,
totalBytes: actualBytes,
acceptedMetadataMismatch: false
};
}
return {
ok: true,
totalBytes: Math.max(actualBytes, expectedTotal),
acceptedMetadataMismatch: false
};
}

File diff suppressed because it is too large Load Diff

View File

@ -1,45 +0,0 @@
export interface ErrorRingEntry {
ts: string;
level: string;
message: string;
}
export interface ErrorRing {
push: (entry: ErrorRingEntry) => void;
snapshot: () => ErrorRingEntry[];
clear: () => void;
size: () => number;
}
export function createErrorRing(capacity: number): ErrorRing {
const limit = Math.max(1, Math.floor(capacity));
const buffer: ErrorRingEntry[] = [];
return {
push(entry: ErrorRingEntry): void {
buffer.push(entry);
while (buffer.length > limit) {
buffer.shift();
}
},
snapshot(): ErrorRingEntry[] {
return buffer.slice();
},
clear(): void {
buffer.length = 0;
},
size(): number {
return buffer.length;
}
};
}
const RECENT_ERROR_CAPACITY = 200;
const recentErrors = createErrorRing(RECENT_ERROR_CAPACITY);
export function recordRecentError(level: string, message: string, ts: string): void {
recentErrors.push({ level, message, ts });
}
export function getRecentErrors(): ErrorRingEntry[] {
return recentErrors.snapshot();
}

File diff suppressed because it is too large Load Diff

View File

@ -1,56 +0,0 @@
// Maps low-level filesystem/OS error codes to a human-readable cause so that a
// generic "write failed" or "timeout" can be reported as the specific root cause
// (disk full, permission denied, ...). Pure + side-effect-free for testing.
const DISK_ERROR_REASONS: Record<string, string> = {
ENOSPC: "Festplatte voll (ENOSPC)",
EDQUOT: "Speicher-Kontingent erschöpft (EDQUOT)",
EROFS: "Laufwerk schreibgeschützt (EROFS)",
EACCES: "Zugriff verweigert (EACCES)",
EPERM: "Operation nicht erlaubt (EPERM)",
EMFILE: "Zu viele offene Dateien (EMFILE)",
ENFILE: "System-Limit offener Dateien erreicht (ENFILE)",
EBUSY: "Datei/Laufwerk belegt (EBUSY)",
ENODEV: "Gerät nicht vorhanden (ENODEV)",
ENXIO: "Gerät getrennt (ENXIO)",
EIO: "Ein-/Ausgabefehler des Datenträgers (EIO)"
};
export function classifyDiskError(err: unknown): string | null {
const code = extractErrorCode(err);
if (code && DISK_ERROR_REASONS[code]) {
return DISK_ERROR_REASONS[code];
}
// Some errors arrive as plain strings/messages without a `.code`; fall back to
// scanning the text for a known code token.
const text = errorText(err);
for (const knownCode of Object.keys(DISK_ERROR_REASONS)) {
if (text.includes(knownCode)) {
return DISK_ERROR_REASONS[knownCode];
}
}
return null;
}
function extractErrorCode(err: unknown): string {
if (err && typeof err === "object") {
const code = (err as { code?: unknown }).code;
if (typeof code === "string") {
return code.toUpperCase();
}
}
return "";
}
function errorText(err: unknown): string {
if (typeof err === "string") {
return err;
}
if (err && typeof err === "object") {
const message = (err as { message?: unknown }).message;
if (typeof message === "string") {
return message;
}
}
return String(err ?? "");
}

View File

@ -1,7 +1,5 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
import crypto from "node:crypto";
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
const ITEM_LOG_RETENTION_DAYS = 30;
@ -23,17 +21,7 @@ const initializedThisProcess = new Set<string>();
let flushTimer: NodeJS.Timeout | null = null;
function normalizeItemId(itemId: string): string {
const trimmed = String(itemId || "").trim();
if (!trimmed) {
return "";
}
const safePrefix = trimmed
.replace(/[^a-zA-Z0-9._-]/g, "_")
.replace(/_+/g, "_")
.slice(0, 64)
.replace(/^_+|_+$/g, "");
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
return `${safePrefix || "item"}_${hash}`;
return String(itemId || "").trim();
}
function sanitizeFieldValue(value: unknown): string {
@ -63,7 +51,8 @@ function formatFields(fields?: Record<string, unknown>): string {
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function getItemLogFilePathFromNormalized(normalized: string): string | null {
function getItemLogFilePath(itemId: string): string | null {
const normalized = normalizeItemId(itemId);
if (!normalized || !itemLogsDir) {
return null;
}
@ -76,16 +65,12 @@ function getItemLogFilePathFromNormalized(normalized: string): string | null {
return logPath;
}
function getItemLogFilePath(itemId: string): string | null {
return getItemLogFilePathFromNormalized(normalizeItemId(itemId));
}
function flushPending(): void {
for (const [itemId, lines] of pendingLinesByItem.entries()) {
if (lines.length === 0) {
continue;
}
const logPath = getItemLogFilePathFromNormalized(itemId);
const logPath = getItemLogFilePath(itemId);
if (!logPath) {
continue;
}
@ -94,6 +79,7 @@ function flushPending(): void {
try {
fs.appendFileSync(logPath, chunk, "utf8");
} catch {
// ignore write errors
}
}
}
@ -123,9 +109,11 @@ async function cleanupOldItemLogs(dir: string): Promise<void> {
await fs.promises.unlink(filePath);
}
} catch {
// ignore locked/missing files
}
}
} catch {
// ignore missing dir
}
}
@ -152,8 +140,8 @@ export function initItemLogs(baseDir: string): void {
}
export function ensureItemLog(meta: ItemLogMeta): string | null {
const normalizedItemId = normalizeItemId(meta.itemId);
const logPath = getItemLogFilePath(meta.itemId);
const itemId = normalizeItemId(meta.itemId);
const logPath = getItemLogFilePath(itemId);
if (!logPath) {
return null;
}
@ -162,17 +150,17 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
if (!fs.existsSync(logPath)) {
fs.writeFileSync(logPath, "", "utf8");
}
if (!initializedThisProcess.has(normalizedItemId)) {
initializedThisProcess.add(normalizedItemId);
const startedAt = logTimestamp();
if (!initializedThisProcess.has(itemId)) {
initializedThisProcess.add(itemId);
const startedAt = new Date().toISOString();
fs.appendFileSync(
logPath,
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
`=== Item-Log Start: ${startedAt} | itemId=${itemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
"utf8"
);
fs.appendFileSync(
logPath,
`${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
`${new Date().toISOString()} [INFO] Item-Kontext initialisiert${formatFields({
packageId: meta.packageId,
packageName: meta.packageName,
fileName: meta.fileName,
@ -197,7 +185,7 @@ export function logItemEvent(
if (!logPath) {
return;
}
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
const line = `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`;
appendLine(itemId, line);
}
@ -216,13 +204,14 @@ export function shutdownItemLogs(): void {
}
flushPending();
for (const itemId of knownLogPaths.keys()) {
const logPath = getItemLogFilePathFromNormalized(itemId);
const logPath = getItemLogFilePath(itemId);
if (!logPath) {
continue;
}
try {
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
fs.appendFileSync(logPath, `=== Item-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
// ignore
}
}
pendingLinesByItem.clear();

View File

@ -1,11 +0,0 @@
export function logTimestamp(date: Date = new Date()): string {
const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absOffset = Math.abs(offsetMinutes);
const offset = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offset}`
);
}

View File

@ -1,24 +1,6 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import { recordRecentError } from "./error-ring";
import path from "node:path";
export function isDebugFlagEnabled(value: string | undefined): boolean {
if (!value) {
return false;
}
return /^(1|true|yes|on)$/i.test(value.trim());
}
// Read once at startup. Enabling verbose DEBUG logging on the (unattended) server
// is a deliberate support action that requires a restart — the runtime-toggleable
// channel is the trace log, not this.
const DEBUG_ENABLED = isDebugFlagEnabled(process.env.RD_DEBUG);
export function isDebugLoggingEnabled(): boolean {
return DEBUG_ENABLED;
}
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
let fallbackLogFilePath: string | null = null;
const LOG_FLUSH_INTERVAL_MS = 120;
@ -87,6 +69,7 @@ function writeStderr(text: string): void {
try {
process.stderr.write(text);
} catch {
// ignore stderr failures
}
}
@ -152,9 +135,11 @@ function rotateIfNeeded(filePath: string): void {
try {
fs.rmSync(backup, { force: true });
} catch {
// ignore
}
fs.renameSync(filePath, backup);
} catch {
// ignore - file may not exist yet
}
}
@ -174,6 +159,7 @@ 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
}
}
@ -183,14 +169,7 @@ async function flushAsync(): Promise<void> {
}
flushInFlight = true;
// Move (not copy) the pending lines out and take ownership. A concurrent write()
// during the await below pushes new lines AND can trim the 1MB cap from the FRONT
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
// here means await-time writes queue independently and nothing desyncs.
const linesSnapshot = pendingLines;
pendingLines = [];
pendingChars = 0;
const linesSnapshot = pendingLines.slice();
const chunk = linesSnapshot.join("");
try {
@ -207,19 +186,9 @@ async function flushAsync(): Promise<void> {
} else if (!primary.ok) {
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
}
if (!wroteAny) {
// Write failed: requeue the unwritten lines AHEAD of anything that arrived
// during the await (preserve order), then re-apply the buffer cap so a
// persistent write failure cannot grow the buffer without bound.
pendingLines = linesSnapshot.concat(pendingLines);
pendingChars += chunk.length;
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
const removed = pendingLines.shift();
if (!removed) {
break;
}
pendingChars = Math.max(0, pendingChars - removed.length);
}
if (wroteAny) {
pendingLines = pendingLines.slice(linesSnapshot.length);
pendingChars = Math.max(0, pendingChars - chunk.length);
}
} finally {
flushInFlight = false;
@ -238,21 +207,14 @@ function ensureExitHook(): void {
process.once("exit", flushSyncPending);
}
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
ensureExitHook();
const ts = logTimestamp();
const line = `${ts} [${level}] ${message}\n`;
const line = `${new Date().toISOString()} [${level}] ${message}\n`;
pendingLines.push(line);
pendingChars += line.length;
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
// "what failed recently" is answerable even after the file rotates.
if (level === "ERROR" || level === "WARN") {
recordRecentError(level, message, ts);
}
for (const listener of logListeners) {
try { listener(line); } catch { }
try { listener(line); } catch { /* ignore */ }
}
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
@ -271,9 +233,6 @@ function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): voi
}
export const logger = {
// Gated to a no-op when RD_DEBUG is unset so verbose call sites cost nothing
// (no formatting, no allocation) in the normal/production path.
debug: DEBUG_ENABLED ? (msg: string): void => write("DEBUG", msg) : (_msg: string): void => {},
info: (msg: string): void => write("INFO", msg),
warn: (msg: string): void => write("WARN", msg),
error: (msg: string): void => write("ERROR", msg)

View File

@ -9,6 +9,7 @@ import { APP_NAME } from "./constants";
import { extractHttpLinksFromText } from "./utils";
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
/* ── IPC validation helpers ────────────────────────────────────── */
function validateString(value: unknown, name: string): string {
if (typeof value !== "string") {
throw new Error(`${name} muss ein String sein`);
@ -43,23 +44,19 @@ function validateStringArray(value: unknown, name: string): string[] {
return value as string[];
}
/* ── Single Instance Lock ───────────────────────────────────────── */
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.exit(0);
process.exit(0);
}
/* ── Unhandled error protection ─────────────────────────────────── */
process.on("uncaughtException", (error) => {
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
});
process.on("unhandledRejection", (reason) => {
const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason);
logger.error(`Unhandled Rejection: ${detail}`);
});
// Node-Warnungen (z.B. MaxListenersExceeded, DeprecationWarning) sind ein
// Frühindikator für Leaks/Fehlnutzung in einem langlaufenden Server-Prozess.
process.on("warning", (warning) => {
logger.warn(`Node-Warnung: ${warning.name}: ${warning.message}${warning.stack ? ` | ${warning.stack.replace(/\s*\n\s*/g, " ⏎ ")}` : ""}`);
logger.error(`Unhandled Rejection: ${String(reason)}`);
});
let mainWindow: BrowserWindow | null = null;
@ -116,23 +113,6 @@ function createWindow(): BrowserWindow {
return window;
}
let rendererReloadTimes: number[] = [];
const RENDERER_RELOAD_WINDOW_MS = 5 * 60 * 1000;
const RENDERER_RELOAD_MAX = 3;
// Circuit breaker: recover from a one-off renderer crash by reloading, but stop
// after a few crashes in a short window so a reproducible crash can't spin into a
// reload loop that pegs an unattended server.
function allowRendererReload(): boolean {
const now = Date.now();
rendererReloadTimes = rendererReloadTimes.filter((t) => now - t < RENDERER_RELOAD_WINDOW_MS);
if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
return false;
}
rendererReloadTimes.push(now);
return true;
}
function bindMainWindowLifecycle(window: BrowserWindow): void {
window.on("close", (event) => {
const settings = controller.getSettings();
@ -147,33 +127,6 @@ function bindMainWindowLifecycle(window: BrowserWindow): void {
mainWindow = null;
}
});
window.webContents.on("render-process-gone", (_event, details) => {
logger.error(`Renderer-Prozess beendet: reason=${details.reason} exitCode=${details.exitCode ?? "?"}`);
if (details.reason === "clean-exit" || window.isDestroyed()) {
return;
}
if (allowRendererReload()) {
logger.warn("Renderer wird automatisch neu geladen (Wiederherstellung nach Absturz)");
try {
window.webContents.reload();
} catch (error) {
logger.error(`Renderer-Reload fehlgeschlagen: ${String(error)}`);
}
} else {
logger.error(`Renderer-Absturz: Auto-Reload gestoppt (mehr als ${RENDERER_RELOAD_MAX} Abstürze in ${RENDERER_RELOAD_WINDOW_MS / 60000} Min) - manueller Neustart nötig`);
}
});
// Nur protokollieren, niemals killen/neu laden: "unresponsive" feuert auch
// während legitimer langer Sync-Arbeit (große JSON-Serialisierung) und erholt
// sich meist von selbst. Eingreifen würde einen Schluckauf zum Ausfall machen.
window.webContents.on("unresponsive", () => {
logger.warn("Renderer reagiert nicht (unresponsive) - evtl. langer Sync-Task, warte auf Erholung");
});
window.webContents.on("responsive", () => {
logger.info("Renderer wieder reaktionsfähig (responsive)");
});
}
function createTray(): void {
@ -183,8 +136,7 @@ function createTray(): void {
const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
try {
tray = new Tray(iconPath);
} catch (error) {
logger.warn(`Tray-Icon konnte nicht erstellt werden (Headless/RDP/Service?): ${String(error)} - Minimize-to-Tray steht nicht zur Verfuegung, Fenster bleibt sichtbar.`);
} catch {
return;
}
tray.setToolTip(APP_NAME);
@ -305,7 +257,7 @@ function registerIpcHandlers(): void {
if (result.started) {
updateQuitTimer = setTimeout(() => {
app.quit();
}, 5000);
}, 900);
}
return result;
});
@ -326,6 +278,7 @@ function registerIpcHandlers(): void {
const result = controller.updateSettings(validated as Partial<AppSettings>);
updateClipboardWatcher();
updateTray();
// Manage scheduled-start timer
if (scheduledStartTimer !== null) {
clearTimeout(scheduledStartTimer);
scheduledStartTimer = null;
@ -334,6 +287,7 @@ function registerIpcHandlers(): void {
if (schedMs > 0) {
const delay = schedMs - Date.now();
if (delay <= 0) {
// Time already passed — start immediately and clear setting
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
controller.updateSettings({ scheduledStartEpochMs: 0 });
} else {
@ -387,6 +341,7 @@ function registerIpcHandlers(): void {
});
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
ipcMain.handle(IPC_CHANNELS.START, () => {
// Cancel any pending scheduled start when the user starts manually
if (scheduledStartTimer !== null) {
clearTimeout(scheduledStartTimer);
scheduledStartTimer = null;
@ -570,15 +525,15 @@ function registerIpcHandlers(): void {
});
ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => {
const exported = controller.exportSupportBundle();
const options = {
defaultPath: controller.getSupportBundleDefaultFileName(),
defaultPath: exported.defaultFileName,
filters: [{ name: "Support Bundle", extensions: ["zip"] }]
};
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
if (result.canceled || !result.filePath) {
return { saved: false };
}
const exported = controller.exportSupportBundle();
await fs.promises.writeFile(result.filePath, exported.buffer);
return { saved: true, filePath: result.filePath };
});
@ -685,14 +640,6 @@ function registerIpcHandlers(): void {
return controller.getDebridLinkHostLimits();
});
ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => {
return controller.checkDebridAccounts();
});
ipcMain.handle(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, async (_event, login: string, password: string) => {
return controller.checkSingleMegaDebridAccount(String(login || ""), String(password || ""));
});
ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => {
const options = {
properties: ["openFile"] as Array<"openFile">,
@ -713,25 +660,7 @@ function registerIpcHandlers(): void {
return { restored: false, message: `Backup-Datei zu groß (max 50 MB, Datei hat ${(stat.size / 1024 / 1024).toFixed(1)} MB)` };
}
const data = await fs.promises.readFile(filePath);
const importResult = controller.importBackup(data);
// Only a full restore (queue swapped) needs the auto-relaunch. A settings-
// only import applied live — relaunching would be pointless and would drop
// the running queue.
if (importResult.restored && importResult.relaunch) {
setTimeout(() => {
app.relaunch();
app.quit();
}, 1500);
}
return importResult;
});
ipcMain.on(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => {
try {
logger.error(formatRendererErrorReport(rawReport));
} catch (error) {
logger.error(`[Renderer] Fehlerbericht konnte nicht verarbeitet werden: ${String(error)}`);
}
return controller.importBackup(data);
});
controller.onState = (snapshot) => {
@ -742,41 +671,6 @@ function registerIpcHandlers(): void {
};
}
function formatRendererErrorReport(rawReport: unknown): string {
const report = (rawReport && typeof rawReport === "object" ? rawReport : {}) as Record<string, unknown>;
const str = (value: unknown): string => (typeof value === "string" ? value : "");
const num = (value: unknown): string => (typeof value === "number" && Number.isFinite(value) ? String(value) : "");
const kind = str(report.kind) || "error";
const message = (str(report.message) || "(ohne Nachricht)").slice(0, 2000);
const source = str(report.source);
const line = num(report.line);
const column = num(report.column);
const stack = str(report.stack).slice(0, 4000);
const componentStack = str(report.componentStack).slice(0, 4000);
const parts: string[] = [`[Renderer:${kind}] ${message}`];
if (source) {
parts.push(`@ ${source}${line ? `:${line}${column ? `:${column}` : ""}` : ""}`);
}
if (stack) {
parts.push(`| stack: ${stack.replace(/\s*\n\s*/g, " ⏎ ")}`);
}
if (componentStack) {
parts.push(`| react: ${componentStack.replace(/\s*\n\s*/g, " ⏎ ")}`);
}
return parts.join(" ");
}
app.on("child-process-gone", (_event, details) => {
const killed = details.reason !== "clean-exit" && details.reason !== "killed";
const line = `Subprozess beendet: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? "?"}${details.name ? ` name=${details.name}` : ""}${details.serviceName ? ` service=${details.serviceName}` : ""}`;
if (killed) {
logger.error(line);
} else {
logger.warn(line);
}
});
app.on("second-instance", () => {
if (mainWindow) {
if (mainWindow.isMinimized()) {

View File

@ -1,129 +0,0 @@
import crypto from "node:crypto";
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
const MEGA_API_TIMEOUT_MS = 12_000;
export interface MegaFileInfo {
name: string;
size: number;
}
const NEW_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/file\/([A-Za-z0-9_-]+)#([A-Za-z0-9_-]+)/i;
const LEGACY_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/#!([A-Za-z0-9_-]+)!([A-Za-z0-9_-]+)/i;
export function isMegaFileUrl(url: string): boolean {
const s = String(url || "").trim();
return NEW_FORMAT_RE.test(s) || LEGACY_FORMAT_RE.test(s);
}
function base64UrlDecode(s: string): Buffer | null {
let b64 = String(s || "").trim().replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4 !== 0) b64 += "=";
try {
return Buffer.from(b64, "base64");
} catch {
return null;
}
}
export interface ParsedMegaLink {
id: string;
rawKey: Buffer;
}
export function parseMegaUrl(url: string): ParsedMegaLink | null {
const s = String(url || "").trim();
const m = NEW_FORMAT_RE.exec(s) || LEGACY_FORMAT_RE.exec(s);
if (!m) return null;
const id = m[1];
const rawKey = base64UrlDecode(m[2]);
if (!rawKey || rawKey.length !== 32) return null;
return { id, rawKey };
}
export function decryptMegaAttributes(encrypted: Buffer, aesKey: Buffer): Record<string, unknown> | null {
if (!Buffer.isBuffer(encrypted) || encrypted.length === 0 || encrypted.length % 16 !== 0) return null;
if (!Buffer.isBuffer(aesKey) || aesKey.length !== 16) return null;
let plain: Buffer;
try {
const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
decipher.setAutoPadding(false);
plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
} catch {
return null;
}
const text = plain.toString("utf8").replace(/\0+$/, "").trim();
if (!text.startsWith("MEGA{")) return null;
try {
return JSON.parse(text.slice(4));
} catch {
return null;
}
}
function withTimeoutSignal(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort("mega-api-timeout"), timeoutMs);
if (parent) {
if (parent.aborted) {
controller.abort(parent.reason);
} else {
parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true });
}
}
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
return controller.signal;
}
export async function resolveMegaFilename(
url: string,
signal?: AbortSignal
): Promise<MegaFileInfo | null> {
const parsed = parseMegaUrl(url);
if (!parsed) return null;
const aesKey = parsed.rawKey.subarray(0, 16);
const apiUrl = `${MEGA_API_BASE}?id=${Math.floor(Math.random() * 1e9)}`;
const body = JSON.stringify([{ a: "g", g: 1, p: parsed.id }]);
let response: Response;
try {
response = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: withTimeoutSignal(signal, MEGA_API_TIMEOUT_MS)
});
} catch {
return null;
}
if (!response.ok) return null;
let payload: unknown;
try {
payload = await response.json();
} catch {
return null;
}
if (typeof payload === "number") return null;
if (!Array.isArray(payload) || payload.length === 0) return null;
const first = payload[0];
if (typeof first === "number") return null;
if (!first || typeof first !== "object") return null;
const info = first as { s?: unknown; at?: unknown; e?: unknown };
if (typeof info.e === "number" && info.e !== 0) return null;
const size = typeof info.s === "number" && info.s > 0 ? info.s : 0;
if (typeof info.at !== "string" || !info.at.trim()) return null;
const encryptedAttrs = base64UrlDecode(info.at);
if (!encryptedAttrs) return null;
const attrs = decryptMegaAttributes(encryptedAttrs, aesKey);
if (!attrs || typeof attrs.n !== "string" || !attrs.n.trim()) return null;
return { name: attrs.n.trim(), size };
}

View File

@ -16,8 +16,6 @@ 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();
}
@ -221,38 +219,43 @@ export class MegaWebFallback {
private getCredentials: () => MegaCredentials;
private sessions = new Map<string, { cookie: string; setAt: number }>();
private cookie = "";
private cookieSetAt = 0;
public constructor(getCredentials: () => MegaCredentials) {
this.getCredentials = getCredentials;
}
public async unrestrict(
link: string,
signal?: AbortSignal,
account?: { login: string; password: string }
): Promise<UnrestrictedLink | null> {
public async unrestrict(link: string, signal?: AbortSignal): 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();
const creds = 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 (!this.cookie || Date.now() - this.cookieSetAt > 20 * 60 * 1000) {
await this.login(creds.login, creds.password, overallSignal);
}
const generated = await this.generate(link, 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) {
this.cookie = "";
await this.login(creds.login, creds.password, overallSignal);
const retry = await this.generate(link, overallSignal);
if (!retry) {
return null;
}
return {
directUrl: retry.directUrl,
fileName: retry.fileName || filenameFromUrl(link),
fileSize: null,
retriesUsed: 0
};
}
return {
directUrl: generated.directUrl,
fileName: generated.fileName || filenameFromUrl(link),
@ -262,18 +265,9 @@ export class MegaWebFallback {
}, 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();
this.cookie = "";
this.cookieSetAt = 0;
}
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
@ -292,7 +286,7 @@ export class MegaWebFallback {
return raceWithAbort(run, signal);
}
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
private async login(login: string, password: string, signal?: AbortSignal): Promise<void> {
throwIfAborted(signal);
const response = await fetch(LOGIN_URL, {
method: "POST",
@ -329,17 +323,18 @@ export class MegaWebFallback {
throw new Error("Mega-Web Login ungültig oder Session blockiert");
}
return cookie;
this.cookie = cookie;
this.cookieSetAt = Date.now();
}
private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
private async generate(link: 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,
Cookie: this.cookie,
Referer: DEBRID_REFERER
},
body: new URLSearchParams({
@ -352,17 +347,13 @@ export class MegaWebFallback {
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})`);
}
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;
@ -375,7 +366,7 @@ export class MegaWebFallback {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0",
Cookie: cookie,
Cookie: this.cookie,
Referer: DEBRID_REFERER
},
body: new URLSearchParams({
@ -404,10 +395,6 @@ export class MegaWebFallback {
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;
}
@ -428,7 +415,7 @@ export class MegaWebFallback {
}
public dispose(): void {
this.sessions.clear();
this.cookie = "";
}
}

View File

@ -1,7 +1,5 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
import crypto from "node:crypto";
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
const PACKAGE_LOG_RETENTION_DAYS = 30;
@ -22,17 +20,7 @@ const initializedThisProcess = new Set<string>();
let flushTimer: NodeJS.Timeout | null = null;
function normalizePackageId(packageId: string): string {
const trimmed = String(packageId || "").trim();
if (!trimmed) {
return "";
}
const safePrefix = trimmed
.replace(/[^a-zA-Z0-9._-]/g, "_")
.replace(/_+/g, "_")
.slice(0, 64)
.replace(/^_+|_+$/g, "");
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
return `${safePrefix || "pkg"}_${hash}`;
return String(packageId || "").trim();
}
function sanitizeFieldValue(value: unknown): string {
@ -62,7 +50,8 @@ function formatFields(fields?: Record<string, unknown>): string {
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function getPackageLogFilePathFromNormalized(normalized: string): string | null {
function getPackageLogFilePath(packageId: string): string | null {
const normalized = normalizePackageId(packageId);
if (!normalized || !packageLogsDir) {
return null;
}
@ -75,16 +64,12 @@ function getPackageLogFilePathFromNormalized(normalized: string): string | null
return logPath;
}
function getPackageLogFilePath(packageId: string): string | null {
return getPackageLogFilePathFromNormalized(normalizePackageId(packageId));
}
function flushPending(): void {
for (const [packageId, lines] of pendingLinesByPackage.entries()) {
if (lines.length === 0) {
continue;
}
const logPath = getPackageLogFilePathFromNormalized(packageId);
const logPath = getPackageLogFilePath(packageId);
if (!logPath) {
continue;
}
@ -93,6 +78,7 @@ function flushPending(): void {
try {
fs.appendFileSync(logPath, chunk, "utf8");
} catch {
// ignore write errors
}
}
}
@ -122,9 +108,11 @@ async function cleanupOldPackageLogs(dir: string): Promise<void> {
await fs.promises.unlink(filePath);
}
} catch {
// ignore locked/missing files
}
}
} catch {
// ignore missing dir
}
}
@ -151,8 +139,8 @@ export function initPackageLogs(baseDir: string): void {
}
export function ensurePackageLog(meta: PackageLogMeta): string | null {
const normalizedPackageId = normalizePackageId(meta.packageId);
const logPath = getPackageLogFilePath(meta.packageId);
const packageId = normalizePackageId(meta.packageId);
const logPath = getPackageLogFilePath(packageId);
if (!logPath) {
return null;
}
@ -161,17 +149,17 @@ export function ensurePackageLog(meta: PackageLogMeta): string | null {
if (!fs.existsSync(logPath)) {
fs.writeFileSync(logPath, "", "utf8");
}
if (!initializedThisProcess.has(normalizedPackageId)) {
initializedThisProcess.add(normalizedPackageId);
const startedAt = logTimestamp();
if (!initializedThisProcess.has(packageId)) {
initializedThisProcess.add(packageId);
const startedAt = new Date().toISOString();
fs.appendFileSync(
logPath,
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
`=== Paket-Log Start: ${startedAt} | packageId=${packageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
"utf8"
);
fs.appendFileSync(
logPath,
`${logTimestamp()} [INFO] Paket-Kontext initialisiert${formatFields({
`${new Date().toISOString()} [INFO] Paket-Kontext initialisiert${formatFields({
name: meta.name,
outputDir: meta.outputDir,
extractDir: meta.extractDir
@ -195,7 +183,7 @@ export function logPackageEvent(
if (!logPath) {
return;
}
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
const line = `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`;
appendLine(packageId, line);
}
@ -214,13 +202,14 @@ export function shutdownPackageLogs(): void {
}
flushPending();
for (const packageId of knownLogPaths.keys()) {
const logPath = getPackageLogFilePathFromNormalized(packageId);
const logPath = getPackageLogFilePath(packageId);
if (!logPath) {
continue;
}
try {
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
// ignore
}
}
pendingLinesByPackage.clear();

View File

@ -158,10 +158,12 @@ export class RealDebridWebFallback {
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
});
} catch {
// ignore
}
try {
await currentSession.clearCache();
} catch {
// ignore
}
}
}
@ -318,6 +320,7 @@ export class RealDebridWebFallback {
return this.rememberToken(token);
}
} catch {
// ignore window scraping errors and fall back to session fetch
}
return null;
@ -327,12 +330,14 @@ 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;
}
@ -394,6 +399,7 @@ 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" };

View File

@ -82,6 +82,8 @@ 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");
}

View File

@ -1,5 +1,4 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
type RenameLogLevel = "INFO" | "WARN" | "ERROR";
@ -46,9 +45,11 @@ function rotateIfNeeded(filePath: string): void {
try {
fs.rmSync(backup, { force: true });
} catch {
// ignore
}
fs.renameSync(filePath, backup);
} catch {
// ignore
}
}
@ -61,6 +62,7 @@ function cleanupOldBackup(filePath: string): void {
fs.rmSync(backup, { force: true });
}
} catch {
// ignore
}
}
@ -76,7 +78,7 @@ export function initRenameLog(baseDir: string): void {
if (!fs.existsSync(renameLogPath)) {
fs.writeFileSync(renameLogPath, "", "utf8");
}
fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${logTimestamp()} ===\n`, "utf8");
fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
renameLogPath = null;
}
@ -93,10 +95,11 @@ export function logRenameEvent(level: RenameLogLevel, message: string, fields?:
}
fs.appendFileSync(
renameLogPath,
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
`${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`,
"utf8"
);
} catch {
// ignore write errors
}
}
@ -112,8 +115,9 @@ export function shutdownRenameLog(): void {
return;
}
try {
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
// ignore
}
renameLogPath = null;
}

View File

@ -1,5 +1,4 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
import { setLogListener } from "./logger";
@ -30,6 +29,7 @@ function flushPending(): void {
try {
fs.appendFileSync(sessionLogPath, chunk, "utf8");
} catch {
// ignore write errors
}
}
@ -66,9 +66,11 @@ 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
}
}
@ -84,7 +86,7 @@ export function initSessionLog(baseDir: string): void {
const timestamp = formatTimestamp();
sessionLogPath = path.join(sessionLogsDir, `session_${timestamp}.txt`);
const isoTimestamp = logTimestamp();
const isoTimestamp = new Date().toISOString();
try {
fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8");
} catch {
@ -106,16 +108,19 @@ export function shutdownSessionLog(): void {
return;
}
// Flush any pending lines
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
flushPending();
const isoTimestamp = logTimestamp();
// Write closing line
const isoTimestamp = new Date().toISOString();
try {
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
} catch {
// ignore
}
setLogListener(null);

View File

@ -1,195 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { AppSettings } from "../shared/types";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { StoragePaths } from "./storage";
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
export interface HealthCheckFinding {
severity: HealthCheckSeverity;
code: string;
message: string;
hint?: string;
}
export interface HealthCheckReport {
findings: HealthCheckFinding[];
errorCount: number;
warnCount: number;
infoCount: number;
}
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024;
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024;
function safeExists(p: string): boolean {
try {
return fs.existsSync(p);
} catch {
return false;
}
}
function getFileSizeBytes(p: string): number {
try {
const stat = fs.statSync(p);
return stat.size;
} catch {
return 0;
}
}
function isWritable(dir: string): boolean {
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
try {
fs.writeFileSync(probe, "x", { encoding: "utf8" });
fs.rmSync(probe, { force: true });
return true;
} catch {
return false;
}
}
function getFreeDiskSpaceBytes(target: string): number | null {
try {
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
if (typeof statfs !== "function") {
return null;
}
const result = statfs(target);
const bavail = BigInt(result.bavail);
const bsize = BigInt(result.bsize);
const free = bavail * bsize;
if (free > BigInt(Number.MAX_SAFE_INTEGER)) {
return Number.MAX_SAFE_INTEGER;
}
return Number(free);
} catch {
return null;
}
}
function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } {
const providers: string[] = [];
if (settings.token?.trim() || settings.realDebridUseWebLogin) {
providers.push("Real-Debrid");
}
if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) {
providers.push("AllDebrid");
}
if (settings.bestToken?.trim() || settings.bestDebridUseWebLogin) {
providers.push("BestDebrid");
}
if (settings.oneFichierApiKey?.trim()) {
providers.push("1Fichier");
}
if (settings.ddownloadLogin?.trim() && settings.ddownloadPassword?.trim()) {
providers.push("DDownload");
}
if (settings.linkSnappyLogin?.trim() && settings.linkSnappyPassword?.trim()) {
providers.push("LinkSnappy");
}
const dlKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
if (dlKeys.length > 0) {
providers.push(`Debrid-Link (${dlKeys.length} Key${dlKeys.length === 1 ? "" : "s"})`);
}
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
const legacyMegaConfigured = Boolean(settings.megaLogin?.trim() && settings.megaPassword?.trim());
if (megaAccounts.length > 0) {
providers.push(`Mega-Debrid (${megaAccounts.length} Acc)`);
} else if (legacyMegaConfigured) {
providers.push("Mega-Debrid");
}
return { count: providers.length, providers };
}
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
const findings: HealthCheckFinding[] = [];
const outputDir = String(settings.outputDir || "").trim();
if (!outputDir) {
findings.push({
severity: "WARN",
code: "outputDir_missing",
message: "Kein Download-Ziel-Verzeichnis konfiguriert",
hint: "In den Einstellungen unter 'Downloads' einen Ziel-Ordner setzen, sonst koennen keine Downloads starten."
});
} else if (!safeExists(outputDir)) {
findings.push({
severity: "WARN",
code: "outputDir_not_found",
message: `Download-Ziel-Ordner existiert nicht: ${outputDir}`,
hint: "Der Ordner wird beim ersten Download automatisch erstellt, sofern der Elternordner existiert und beschreibbar ist."
});
} else if (!isWritable(outputDir)) {
findings.push({
severity: "ERROR",
code: "outputDir_not_writable",
message: `Download-Ziel-Ordner ist NICHT beschreibbar: ${outputDir}`,
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
});
} else {
const freeBytes = getFreeDiskSpaceBytes(outputDir);
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
const freeMb = Math.round(freeBytes / (1024 * 1024));
findings.push({
severity: "WARN",
code: "low_disk_space",
message: `Wenig freier Speicher im Download-Ordner: ~${freeMb} MB verfuegbar (Schwelle ${LOW_DISK_SPACE_BYTES / (1024 * 1024 * 1024)} GB)`,
hint: "Groessere Downloads koennen auf halbem Weg fehlschlagen. Vorher Platz schaffen oder anderen Ordner waehlen."
});
}
}
const { count, providers } = countConfiguredProviders(settings);
if (count === 0) {
findings.push({
severity: "WARN",
code: "no_provider_configured",
message: "Kein Debrid-Provider konfiguriert — Downloads werden nicht funktionieren",
hint: "In den Einstellungen mindestens einen Provider (Real-Debrid, Mega-Debrid, Debrid-Link, ...) einrichten."
});
} else {
findings.push({
severity: "INFO",
code: "providers_configured",
message: `Konfigurierte Provider: ${providers.join(", ")}`
});
}
if (safeExists(storagePaths.sessionFile)) {
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
const sizeMb = Math.round(sizeBytes / (1024 * 1024));
findings.push({
severity: "WARN",
code: "large_state_file",
message: `State-Datei ist sehr gross: ${sizeMb} MB (${path.basename(storagePaths.sessionFile)})`,
hint: "Alte abgeschlossene Pakete aus der Queue entfernen, damit Startup + Save schneller werden."
});
}
}
if (!safeExists(storagePaths.baseDir)) {
findings.push({
severity: "ERROR",
code: "baseDir_missing",
message: `Runtime-Verzeichnis existiert nicht: ${storagePaths.baseDir}`,
hint: "Ohne Runtime-Verzeichnis koennen weder Settings noch Session-State persistiert werden."
});
} else if (!isWritable(storagePaths.baseDir)) {
findings.push({
severity: "ERROR",
code: "baseDir_not_writable",
message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`,
hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)."
});
}
const errorCount = findings.filter((f) => f.severity === "ERROR").length;
const warnCount = findings.filter((f) => f.severity === "WARN").length;
const infoCount = findings.filter((f) => f.severity === "INFO").length;
return { findings, errorCount, warnCount, infoCount };
}

View File

@ -2,8 +2,7 @@ import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts";
import { AppSettings, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types";
import { AppSettings, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, PackageEntry, PackagePriority, SessionState } from "../shared/types";
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
import { defaultSettings } from "./constants";
import { logger } from "./logger";
@ -16,50 +15,17 @@ const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "pack
const VALID_SPEED_MODES = new Set(["global", "per_download"]);
const VALID_THEMES = new Set(["dark", "light"]);
const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]);
const VALID_HISTORY_RETENTION_MODES = new Set<HistoryRetentionMode>(["never", "session", "permanent"]);
const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
]);
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
function asText(value: unknown): string {
return String(value ?? "").trim();
}
function normalizeSessionId(value: unknown): string {
const text = asText(value);
if (!text || !SAFE_SESSION_ID_RE.test(text)) {
return "";
}
return text;
}
function isPathInsideDir(filePath: string, dirPath: string): boolean {
try {
const resolvedFile = path.resolve(filePath);
const resolvedDir = path.resolve(dirPath);
const normalizedFile = process.platform === "win32" ? resolvedFile.toLowerCase() : resolvedFile;
const normalizedDir = process.platform === "win32" ? resolvedDir.toLowerCase() : resolvedDir;
return normalizedFile === normalizedDir || normalizedFile.startsWith(`${normalizedDir}${path.sep}`);
} catch {
return false;
}
}
function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string {
const targetPath = asText(value);
if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) {
return "";
}
if (!isPathInsideDir(targetPath, packageOutputDir)) {
return "";
}
return path.resolve(targetPath);
}
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
const num = Number(value);
if (!Number.isFinite(num)) {
@ -120,6 +86,7 @@ function normalizeColumnOrder(raw: unknown): string[] {
result.push(col);
}
}
// "name" is mandatory — ensure it's always present
if (!seen.has("name")) {
result.unshift("name");
}
@ -229,39 +196,6 @@ function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Re
return result;
}
function normalizeDebridAccountStatuses(
value: unknown,
megaIds: string[],
debridLinkIds: string[]
): Record<string, DebridAccountStatus> {
const allowed = new Set([...megaIds, ...debridLinkIds]);
const result: Record<string, DebridAccountStatus> = {};
if (value && typeof value === "object" && !Array.isArray(value)) {
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
if (!allowed.has(key) || !raw || typeof raw !== "object") {
continue;
}
const entry = raw as Partial<DebridAccountStatus>;
if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") {
continue;
}
result[key] = {
accountId: entry.accountId,
provider: entry.provider === "debridlink" ? "debridlink" : "megadebrid",
label: String(entry.label || ""),
maskedLogin: String(entry.maskedLogin || ""),
valid: Boolean(entry.valid),
isPremium: Boolean(entry.isPremium),
premiumUntilMs: typeof entry.premiumUntilMs === "number" ? entry.premiumUntilMs : null,
email: typeof entry.email === "string" ? entry.email : undefined,
message: String(entry.message || ""),
checkedAt: entry.checkedAt
};
}
}
return result;
}
function normalizeStringList(raw: unknown, allowedKeys: readonly string[]): string[] {
if (!Array.isArray(raw)) {
return [];
@ -308,6 +242,7 @@ function normalizeProviderOrder(
if (Array.isArray(raw) && raw.length > 0) {
list = raw;
} else {
// Migrate from old primary/secondary/tertiary
const candidates = [legacyPrimary, legacySecondary, legacyTertiary].filter(
(v) => v && String(v).trim() && String(v).trim() !== "none"
);
@ -345,11 +280,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
const currentUsageDay = getProviderUsageDayKey();
const megaLogin = asText(settings.megaLogin);
const megaPassword = asText(settings.megaPassword);
let megaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim();
if (!megaCredentials && megaLogin && megaPassword) {
megaCredentials = `${megaLogin}:${megaPassword}`;
}
const megaDebridAccountIds = getMegaDebridAccountIds(megaCredentials);
const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true;
const hasMegaCreds = Boolean(megaLogin && megaPassword);
const megaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined
@ -391,7 +321,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
megaLogin,
megaPassword,
megaCredentials,
megaDebridApiEnabled,
megaDebridWebEnabled,
megaDebridPreferApi,
@ -421,8 +350,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
packageName: asText(settings.packageName),
autoExtract: Boolean(settings.autoExtract),
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
keepGermanAudioOnly: Boolean(settings.keepGermanAudioOnly),
germanAudioMode: settings.germanAudioMode === "first" ? "first" : "tag",
extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir),
collectMkvToLibrary: Boolean(settings.collectMkvToLibrary),
mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir),
@ -448,9 +375,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
clipboardWatch: Boolean(settings.clipboardWatch),
minimizeToTray: Boolean(settings.minimizeToTray),
collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages,
historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode)
? settings.historyRetentionMode
: defaults.historyRetentionMode,
accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys !== undefined
? Boolean(settings.accountListShowDetailedDebridLinkKeys)
: defaults.accountListShowDetailedDebridLinkKeys,
@ -458,10 +382,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted,
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
columnOrder: normalizeColumnOrder(settings.columnOrder),
@ -479,13 +401,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
debridLinkApiKeyDailyLimitBytes,
debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {},
debridLinkApiKeyTotalUsageBytes,
megaDebridDisabledAccountIds: normalizeStringList(settings.megaDebridDisabledAccountIds, megaDebridAccountIds),
megaDebridAccountDailyLimitBytes: normalizeNamedByteMap(settings.megaDebridAccountDailyLimitBytes, megaDebridAccountIds),
megaDebridAccountDailyUsageBytes: providerDailyUsageDay === currentUsageDay
? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds)
: {},
megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds),
debridAccountStatuses: normalizeDebridAccountStatuses(settings.debridAccountStatuses, megaDebridAccountIds, debridLinkApiKeyIds),
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
};
@ -534,7 +449,6 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings {
realDebridUseWebLogin: settings.realDebridUseWebLogin,
megaLogin: "",
megaPassword: "",
megaCredentials: "",
bestToken: "",
bestDebridUseWebLogin: settings.bestDebridUseWebLogin,
allDebridToken: "",
@ -564,22 +478,7 @@ export function createStoragePaths(baseDir: string): StoragePaths {
}
function ensureBaseDir(baseDir: string): void {
try {
fs.mkdirSync(baseDir, { recursive: true });
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code || "";
if (code === "EACCES" || code === "EPERM") {
logger.error(`AppData-Ordner kann nicht erstellt werden (${code}): ${baseDir} - pruefe Schreibrechte fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
}
throw error;
}
}
function safeJsonReplacer(_key: string, value: unknown): unknown {
if (typeof value === "number" && !Number.isFinite(value)) {
return null;
}
return value;
fs.mkdirSync(baseDir, { recursive: true });
}
function asRecord(value: unknown): Record<string, unknown> | null {
@ -597,14 +496,7 @@ function readSettingsFile(filePath: string): AppSettings | null {
...parsed
});
return sanitizeCredentialPersistence(merged);
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code || "";
if (code === "ENOENT") {
} else if (code === "EACCES" || code === "EPERM") {
logger.error(`Settings-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
} else {
logger.warn(`Settings-Datei nicht lesbar: ${filePath}: ${String(error)}`);
}
} catch {
return null;
}
}
@ -624,8 +516,8 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
if (!item) {
continue;
}
const id = normalizeSessionId(item.id) || normalizeSessionId(entryId);
const packageId = normalizeSessionId(item.packageId);
const id = asText(item.id) || entryId;
const packageId = asText(item.packageId);
const url = asText(item.url);
if (!id || !packageId || !url) {
continue;
@ -670,7 +562,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
if (!pkg) {
continue;
}
const id = normalizeSessionId(pkg.id) || normalizeSessionId(entryId);
const id = asText(pkg.id) || entryId;
if (!id) {
continue;
}
@ -684,44 +576,21 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
extractDir: asText(pkg.extractDir),
status,
itemIds: rawItemIds
.map((value) => normalizeSessionId(value))
.map((value) => asText(value))
.filter((value) => value.length > 0),
cancelled: Boolean(pkg.cancelled),
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal",
downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
};
}
let orphanedItemCount = 0;
for (const [itemId, item] of Object.entries(itemsById)) {
if (!packagesById[item.packageId]) {
orphanedItemCount += 1;
delete itemsById[itemId];
}
}
if (orphanedItemCount > 0) {
logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`);
}
let droppedUnsafeTargetPathCount = 0;
for (const item of Object.values(itemsById)) {
const pkg = packagesById[item.packageId];
if (!pkg) {
continue;
}
const safeTargetPath = normalizeSessionTargetPath(item.targetPath, pkg.outputDir);
if (!safeTargetPath && asText(item.targetPath)) {
droppedUnsafeTargetPathCount += 1;
}
item.targetPath = safeTargetPath;
}
if (droppedUnsafeTargetPathCount > 0) {
logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`);
}
for (const pkg of Object.values(packagesById)) {
pkg.itemIds = pkg.itemIds.filter((itemId) => {
@ -733,7 +602,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
const seenOrder = new Set<string>();
const packageOrder = rawOrder
.map((entry) => normalizeSessionId(entry))
.map((entry) => asText(entry))
.filter((id) => {
if (!(id in packagesById) || seenOrder.has(id)) {
return false;
@ -780,11 +649,12 @@ export function loadSettings(paths: StoragePaths): AppSettings {
if (backupLoaded) {
logger.warn("Konfiguration defekt, Backup-Datei wird verwendet");
try {
const payload = JSON.stringify(backupLoaded, safeJsonReplacer, 2);
const payload = JSON.stringify(backupLoaded, null, 2);
const tempPath = `${paths.configFile}.tmp`;
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.configFile);
} catch {
// ignore restore write failure
}
return backupLoaded;
}
@ -815,15 +685,18 @@ function sessionBackupPath(sessionFile: string): string {
}
export function normalizeLoadedSessionTransientFields(session: SessionState): SessionState {
// Reset transient fields that may be stale from a previous crash
const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
for (const item of Object.values(session.items)) {
if (ACTIVE_STATUSES.has(item.status)) {
item.status = "queued";
item.lastError = "";
}
// Always clear stale speed values
item.speedBps = 0;
}
// Reset package-level active statuses to queued (mirrors item reset above)
const ACTIVE_PKG_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
for (const pkg of Object.values(session.packages)) {
if (ACTIVE_PKG_STATUSES.has(pkg.status)) {
@ -832,6 +705,7 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
pkg.postProcessLabel = undefined;
}
// Clear stale session-level running/paused flags
session.running = false;
session.paused = false;
@ -840,39 +714,37 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
function readSessionFile(filePath: string): SessionState | null {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
const pkgCount = Object.keys(session.packages).length;
const itemCount = Object.keys(session.items).length;
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items, ${raw.length} Bytes)`);
return session;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code || "";
if (code === "EACCES" || code === "EPERM") {
logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
} else {
logger.error(`Session-Datei nicht lesbar: ${filePath}: ${String(error)}`);
}
logger.error(`Session-Datei nicht lesbar: ${filePath}: ${String(error)}`);
return null;
}
}
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
ensureBaseDir(paths.baseDir);
// Create a backup of the existing config before overwriting
if (fs.existsSync(paths.configFile)) {
try {
fs.copyFileSync(paths.configFile, `${paths.configFile}.bak`);
} catch {
// Best-effort backup; proceed even if it fails
}
}
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
const payload = JSON.stringify(persisted, null, 2);
const tempPath = `${paths.configFile}.tmp`;
try {
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.configFile);
} catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { }
try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ }
throw error;
}
}
@ -900,7 +772,7 @@ async function writeSettingsPayload(paths: StoragePaths, payload: string): Promi
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
const payload = JSON.stringify(persisted, null, 2);
if (asyncSettingsSaveRunning) {
asyncSettingsSaveQueued = { paths, settings };
return;
@ -939,21 +811,15 @@ export function emptySession(): SessionState {
export function loadSession(paths: StoragePaths): SessionState {
ensureBaseDir(paths.baseDir);
const backupFile = sessionBackupPath(paths.sessionFile);
const primaryExists = fs.existsSync(paths.sessionFile);
if (!primaryExists) {
const hasRecoverable = fs.existsSync(backupFile)
|| fs.existsSync(sessionTempPath(paths.sessionFile, "sync"))
|| fs.existsSync(sessionTempPath(paths.sessionFile, "async"));
if (!hasRecoverable) {
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
return emptySession();
}
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
if (!fs.existsSync(paths.sessionFile)) {
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
return emptySession();
}
const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
const primary = readSessionFile(paths.sessionFile);
const backupFile = sessionBackupPath(paths.sessionFile);
// If primary loaded but is empty, check if backup has packages (safety net)
if (primary) {
const primaryPkgCount = Object.keys(primary.packages).length;
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) {
@ -963,11 +829,12 @@ export function loadSession(paths: StoragePaths): SessionState {
if (backupPkgCount > 0) {
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() });
const tempPath = sessionTempPath(paths.sessionFile, "sync");
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch {
// ignore restore write failure
}
return backup;
}
@ -980,32 +847,17 @@ export function loadSession(paths: StoragePaths): SessionState {
if (backup) {
logger.warn("Session defekt, Backup-Datei wird verwendet");
try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() });
const tempPath = sessionTempPath(paths.sessionFile, "sync");
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch {
// ignore restore write failure
}
return backup;
}
for (const kind of ["sync", "async"] as const) {
const tmpPath = sessionTempPath(paths.sessionFile, kind);
if (fs.existsSync(tmpPath)) {
const tmpSession = readSessionFile(tmpPath);
if (tmpSession && Object.keys(tmpSession.packages).length > 0) {
logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.packages).length} Pakete)`);
try {
const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer);
fs.writeFileSync(paths.sessionFile, payload, "utf8");
} catch {
}
return tmpSession;
}
}
}
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen)");
logger.error("Session konnte nicht geladen werden (auch Backup fehlgeschlagen)");
return emptySession();
}
@ -1016,21 +868,22 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
try {
fs.copyFileSync(paths.sessionFile, sessionBackupPath(paths.sessionFile));
} catch {
// Best-effort backup; proceed even if it fails
}
}
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
const tempPath = sessionTempPath(paths.sessionFile, "sync");
try {
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { }
try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ }
throw error;
}
}
let asyncSaveRunning = false;
let asyncSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null;
let asyncSaveQueued: { paths: StoragePaths; payload: string } | null = null;
let syncSaveGeneration = 0;
async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
@ -1038,6 +891,7 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {});
const tempPath = sessionTempPath(paths.sessionFile, "async");
await fsp.writeFile(tempPath, payload, "utf8");
// If a synchronous save occurred after this async save started, discard the stale write
if (generation < syncSaveGeneration) {
await fsp.rm(tempPath, { force: true }).catch(() => {});
return;
@ -1059,14 +913,15 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
}
}
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string): Promise<void> {
if (asyncSaveRunning) {
asyncSaveQueued = { paths, payload, generation };
asyncSaveQueued = { paths, payload };
return;
}
asyncSaveRunning = true;
const gen = syncSaveGeneration;
try {
await writeSessionPayload(paths, payload, generation);
await writeSessionPayload(paths, payload, gen);
} catch (error) {
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
} finally {
@ -1074,7 +929,7 @@ async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, gen
if (asyncSaveQueued) {
const queued = asyncSaveQueued;
asyncSaveQueued = null;
void saveSessionPayloadAsync(queued.paths, queued.payload, queued.generation);
void saveSessionPayloadAsync(queued.paths, queued.payload);
}
}
}
@ -1086,9 +941,8 @@ export function cancelPendingAsyncSaves(): void {
}
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
const generation = syncSaveGeneration;
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
await saveSessionPayloadAsync(paths, payload, generation);
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
await saveSessionPayloadAsync(paths, payload);
}
const MAX_HISTORY_ENTRIES = 500;
@ -1096,11 +950,11 @@ const MAX_HISTORY_ENTRIES = 500;
export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry | null {
const entry = asRecord(raw);
if (!entry) return null;
const id = asText(entry.id) || `hist-${Date.now().toString(36)}-${index}`;
const name = asText(entry.name) || "Unbenannt";
const providerRaw = asText(entry.provider);
return {
id,
name,
@ -1121,11 +975,11 @@ export function loadHistory(paths: StoragePaths): HistoryEntry[] {
if (!fs.existsSync(paths.historyFile)) {
return [];
}
try {
const raw = JSON.parse(fs.readFileSync(paths.historyFile, "utf8")) as unknown;
if (!Array.isArray(raw)) return [];
const entries: HistoryEntry[] = [];
for (let i = 0; i < raw.length && entries.length < MAX_HISTORY_ENTRIES; i++) {
const normalized = normalizeHistoryEntry(raw[i], i);
@ -1140,13 +994,13 @@ export function loadHistory(paths: StoragePaths): HistoryEntry[] {
export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void {
ensureBaseDir(paths.baseDir);
const trimmed = entries.slice(0, MAX_HISTORY_ENTRIES);
const payload = JSON.stringify(trimmed, safeJsonReplacer, 2);
const payload = JSON.stringify(trimmed, null, 2);
const tempPath = `${paths.historyFile}.tmp`;
try {
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.historyFile);
} catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { }
try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ }
throw error;
}
}
@ -1158,24 +1012,6 @@ export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry): Histo
return updated;
}
export function loadHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): HistoryEntry[] {
return retentionMode === "never" ? [] : loadHistory(paths);
}
export function addHistoryEntryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode, entry: HistoryEntry): HistoryEntry[] {
if (retentionMode === "never") {
return [];
}
return addHistoryEntry(paths, entry);
}
export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): void {
if (retentionMode === "permanent") {
return;
}
clearHistory(paths);
}
export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] {
const existing = loadHistory(paths);
const updated = existing.filter(e => e.id !== entryId);
@ -1189,6 +1025,7 @@ export function clearHistory(paths: StoragePaths): void {
try {
fs.unlinkSync(paths.historyFile);
} catch {
// ignore
}
}
}

View File

@ -5,15 +5,13 @@ import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup";
import { getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring";
import { getPackageLogPath } from "./package-log";
import { getRenameLogPath } from "./rename-log";
import { getDesktopRenameLogPath } from "./desktop-rename-log";
import { getSessionLogPath } from "./session-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager";
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
@ -53,26 +51,6 @@ function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): vo
}
}
function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): number {
if (!fs.existsSync(dirPath)) {
return 0;
}
const cutoff = Date.now() - maxAgeMs;
let added = 0;
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
const fullPath = path.join(dirPath, entry.name);
try {
if (fs.statSync(fullPath).mtimeMs >= cutoff) {
zip.addLocalFile(fullPath, zipRoot, entry.name);
added += 1;
}
} catch { }
}
return added;
}
function formatTimestampForFileName(date: Date): string {
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, "0");
@ -87,47 +65,8 @@ export function getSupportBundleDefaultFileName(): string {
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
}
type HostDiagnosticsMode = "full" | "cached" | "none";
interface BuildSupportBundleOptions {
hostDiagnosticsMode?: HostDiagnosticsMode;
}
function createDeferredHostDiagnostics(reason: string): unknown {
return {
collectedAt: new Date().toISOString(),
supported: process.platform === "win32",
platform: process.platform,
crashControl: null,
recentKernelPower: [],
recentWerKernel: [],
recentKernelDump: [],
recentAppCrashes: [],
recentMinidumps: [],
assessmentHints: [
reason
],
errors: []
};
}
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
if (mode === "none") {
return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert.");
}
if (mode === "cached") {
const cached = getCachedWindowsHostDiagnostics();
if (cached) {
return cached;
}
return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen.");
}
return getWindowsHostDiagnostics();
}
export function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Buffer {
export function buildSupportBundle(manager: DownloadManager, baseDir: string): Buffer {
const zip = new AdmZip();
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
const storagePaths = createStoragePaths(baseDir);
const settings = loadSettings(storagePaths);
const history = loadHistory(storagePaths);
@ -150,8 +89,7 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
...buildStatsPayload(snapshot),
allTime: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime
}
});
addJson(zip, "overview/debug-setup.json", debugSetup);
@ -168,14 +106,15 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
count: itemIds.length,
items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean)
});
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
addJson(zip, "overview/host-diagnostics.json", getWindowsHostDiagnostics());
addJson(zip, "overview/trace-config.json", getTraceConfig());
const recentErrors = getRecentErrors();
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
addFileIfExists(zip, storagePaths.configFile, "runtime/rd_downloader_config.json");
addFileIfExists(zip, storagePaths.sessionFile, "runtime/rd_session_state.json");
addFileIfExists(zip, storagePaths.historyFile, "runtime/rd_history.json");
addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
@ -184,15 +123,13 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
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);
addDirectoryIfExists(zip, path.join(baseDir, "package-logs"), "logs/package-logs");
addDirectoryIfExists(zip, path.join(baseDir, "item-logs"), "logs/item-logs");
for (const packageId of packageIds) {
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);

View File

@ -134,7 +134,6 @@ export function buildRedactedSettingsPayload(settings: AppSettings): Record<stri
statistics: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs,
providerDailyLimitBytes: settings.providerDailyLimitBytes,
providerDailyUsageBytes: settings.providerDailyUsageBytes,
providerTotalUsageBytes: settings.providerTotalUsageBytes,

View File

@ -1,5 +1,4 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
import { addLogListener, removeLogListener } from "./logger";
import type { SupportTraceConfig } from "../shared/types";
@ -64,6 +63,7 @@ function flushPending(): void {
try {
fs.appendFileSync(traceLogPath, chunk, "utf8");
} catch {
// ignore
}
}
@ -77,9 +77,11 @@ function rotateIfNeeded(filePath: string): void {
try {
fs.rmSync(backup, { force: true });
} catch {
// ignore
}
fs.renameSync(filePath, backup);
} catch {
// ignore
}
}
@ -92,6 +94,7 @@ function cleanupOldBackup(filePath: string): void {
fs.rmSync(backup, { force: true });
}
} catch {
// ignore
}
}
@ -159,6 +162,7 @@ function persistTraceConfig(): void {
try {
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
} catch {
// ignore
}
}
@ -185,10 +189,10 @@ function disableTraceDueToExpiry(): void {
...traceConfig,
enabled: false,
autoDisableAt: null,
updatedAt: logTimestamp()
updatedAt: new Date().toISOString()
});
persistTraceConfig();
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
}
function scheduleAutoDisable(): void {
@ -226,7 +230,7 @@ export function initTraceLog(baseDir: string): void {
}
traceConfig = loadTraceConfig();
persistTraceConfig();
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${logTimestamp()} ===\n`, "utf8");
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
traceLogPath = null;
traceConfigPath = null;
@ -259,11 +263,11 @@ export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTr
traceConfig = normalizeTraceConfig({
...traceConfig,
...patch,
updatedAt: logTimestamp()
updatedAt: new Date().toISOString()
});
persistTraceConfig();
scheduleAutoDisable();
appendTraceLine(`${logTimestamp()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig)}\n`);
return getTraceConfig();
}
@ -272,7 +276,7 @@ export function setTraceEnabled(enabled: boolean, note = "", durationMs: number
? new Date(Date.now() + durationMs).toISOString()
: null;
const next = updateTraceConfig({ enabled, autoDisableAt });
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
return next;
}
@ -288,7 +292,7 @@ export function logTraceEvent(
if (category === "audit" && !traceConfig.includeAudit) {
return;
}
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
appendTraceLine(`${new Date().toISOString()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
}
export function shutdownTraceLog(): void {
@ -303,8 +307,9 @@ export function shutdownTraceLog(): void {
}
flushPending();
try {
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
// ignore
}
traceLogPath = null;
traceConfigPath = null;

File diff suppressed because it is too large Load Diff

View File

@ -271,26 +271,8 @@ export function nowMs(): number {
return Date.now();
}
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error(String(signal.reason || "aborted")));
return;
}
const timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = (): void => {
clearTimeout(timer);
cleanup();
reject(new Error(String(signal?.reason || "aborted")));
};
const cleanup = (): void => {
signal?.removeEventListener("abort", onAbort);
};
signal?.addEventListener("abort", onAbort, { once: true });
});
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function formatEta(seconds: number): string {

View File

@ -1,510 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
// Removes only-German audio handling for "Dual Language" (.DL.) scene releases.
// Mirrors the user's ffmpeg script but adds: language-tag detection (with safe
// fallbacks), disk-space pre-check, atomic temp->replace, mtime preservation,
// abort-into-child, and "never destroy the only usable audio" safety.
//
// The ffmpeg/ffprobe-specific logic lives here so it is mockable in isolation;
// the per-package iteration + filename/.DL. rename + logging stays in
// download-manager.ts (its existing domain).
export type GermanAudioMode = "tag" | "first";
export interface ProbedAudioStream {
language: string;
title: string;
}
export type AudioTrackDecision =
| { action: "remux"; audioRelIndex: number; reason: string }
| { action: "single"; audioRelIndex: 0; reason: string }
| { action: "skip"; reason: string };
export type VideoProcessAction =
| "remuxed"
| "kept-single"
| "skipped-no-german"
| "skipped-no-audio"
| "skipped-no-space"
| "skipped-no-tool"
| "error"
| "aborted";
export interface VideoProcessResult {
action: VideoProcessAction;
reason: string;
keptTrackIndex?: number;
totalAudioTracks?: number;
audioLanguages?: string[];
error?: string;
}
export interface ProcessVideoOptions {
mode: GermanAudioMode;
cpuPriority?: string;
signal?: AbortSignal;
}
// Injection seam so the irreversible file-mutating body (temp -> replace ->
// utimes -> rm-on-failure) can be exercised in tests with a fake ffmpeg/ffprobe
// runner, without spawning real processes. Production passes nothing.
export interface ProcessVideoDeps {
resolveTooling?: () => Promise<{ ffmpeg: string; ffprobe: string } | null>;
runProcess?: typeof runVideoProcess;
// Seam for the atomic-replace rename so its failure/recovery path is testable
// without provoking a real OS file lock. Production uses renameWithRetry.
rename?: (from: string, to: string) => Promise<void>;
}
const VIDEO_REMUX_EXTENSIONS = new Set([".mkv", ".mp4"]);
const PROBE_TIMEOUT_MS = 60_000;
const STDOUT_CAP = 2 * 1024 * 1024;
const STDERR_CAP = 64 * 1024;
// ---------------------------------------------------------------------------
// Pure helpers (no fs / no process) — unit-tested in isolation.
// ---------------------------------------------------------------------------
// "X.German.DL.720p.mkv" -> "X.German.720p.mkv"; "X.DL.mkv" -> "X.mkv".
export function stripDualLangMarker(fileName: string): string {
const ext = path.extname(fileName);
const base = ext ? fileName.slice(0, -ext.length) : fileName;
const stripped = base.replace(/\.DL\./gi, ".").replace(/\.DL$/i, "");
return stripped + ext;
}
export function hasDualLangMarker(fileName: string): boolean {
return stripDualLangMarker(fileName) !== fileName;
}
export function isRemuxableVideoFile(fileName: string): boolean {
return VIDEO_REMUX_EXTENSIONS.has(path.extname(fileName).toLowerCase());
}
// True when the release name explicitly marks it as a German release. Used in
// tag mode to fall back to the first audio track (German-first scene convention)
// when the audio language tags are wrong (a German dub mislabeled "eng"), instead
// of skipping. Deliberately requires an explicit german/deutsch token — the
// ".DL." marker alone (present on every processed file) is not enough, and a bare
// "dubbed" can mean an Italian/French dub, so it must NOT flag a German release.
export function looksLikeGermanRelease(fileName: string): boolean {
return /(^|[._\s-])(german|deutsch)([._\s-]|$)/i.test(fileName);
}
function isGermanStream(stream: ProbedAudioStream): boolean {
const lang = (stream.language || "").toLowerCase().trim();
if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) {
return true;
}
// Free-text title fallback (used when the language tag is missing). Full words
// only — the 2-3 letter codes ger/deu are too ambiguous in a title and would
// pick the wrong track to keep (which then deletes the real German one).
const title = (stream.title || "").toLowerCase();
return /\b(german|deutsch)\b/.test(title);
}
// Decide which audio track to keep. Safety invariant: only ever choose to remux
// (which destroys the original) when we are confident; otherwise skip untouched.
export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMode, germanRelease = false): AudioTrackDecision {
const total = streams.length;
if (total === 0) {
return { action: "skip", reason: "no-audio" };
}
if (mode === "first") {
return total === 1
? { action: "single", audioRelIndex: 0, reason: "single-audio" }
: { action: "remux", audioRelIndex: 0, reason: "first-audio" };
}
// tag mode
const germanPos = streams.findIndex(isGermanStream);
if (germanPos >= 0) {
return total === 1
? { action: "single", audioRelIndex: 0, reason: "single-german" }
: { action: "remux", audioRelIndex: germanPos, reason: "german-tag" };
}
const anyTagged = streams.some((s) => (s.language || "").trim().length > 0);
if (!anyTagged) {
// No language metadata at all -> fall back to the script's behavior.
return total === 1
? { action: "single", audioRelIndex: 0, reason: "single-untagged" }
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" };
}
if (germanRelease) {
// Tagged, no German track found, but the release name explicitly says German
// -> the dub is mislabeled (German audio tagged "eng"). Trust the German-first
// scene convention rather than skipping.
return total === 1
? { action: "single", audioRelIndex: 0, reason: "single-german-mislabeled" }
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" };
}
// Tagged, no German track, and nothing says German -> never guess-delete.
return { action: "skip", reason: "no-german-track" };
}
export function parseFfprobeAudioStreams(jsonText: string): ProbedAudioStream[] {
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch {
return [];
}
const streams = (parsed as { streams?: unknown }).streams;
if (!Array.isArray(streams)) {
return [];
}
return streams.map((raw) => {
const tags = (raw && typeof raw === "object" ? (raw as { tags?: unknown }).tags : undefined) as
| { language?: unknown; title?: unknown }
| undefined;
return {
language: typeof tags?.language === "string" ? tags.language : "",
title: typeof tags?.title === "string" ? tags.title : ""
};
});
}
export function buildFfprobeArgs(input: string): string[] {
return [
"-v", "error",
"-select_streams", "a",
"-show_entries", "stream=index:stream_tags=language,title",
"-of", "json",
input
];
}
export function buildFfmpegRemuxArgs(opts: { input: string; output: string; audioRelIndex: number; keepSubs?: boolean }): string[] {
const args = ["-i", opts.input, "-map", "0:v:0", "-map", `0:a:${opts.audioRelIndex}`];
if (opts.keepSubs) {
// Optional (not enabled by current settings): keep German subtitle tracks only.
args.push("-map", "0:s:m:language:ger?", "-map", "0:s:m:language:deu?");
}
// Stream-copy and keep metadata (so the kept track's language tag survives;
// unlike the original script's -map_metadata -1 which dropped it).
args.push("-c", "copy", "-disposition:a:0", "default", "-y", opts.output);
return args;
}
// Stream-copy remux is disk-bound; generous budget scaled by size, clamped.
export function computeRemuxTimeoutMs(bytes: number): number {
const perBytes = Math.ceil((Number(bytes) || 0) / (10 * 1024 * 1024)) * 1000;
return Math.max(120_000, Math.min(60 * 60 * 1000, 120_000 + perBytes));
}
// ---------------------------------------------------------------------------
// Tooling discovery (system PATH + RD_FFMPEG_BIN/RD_FFPROBE_BIN env override).
// Lazy probe + cache, mirroring the extractor's 7z/Java resolution convention.
// ---------------------------------------------------------------------------
interface VideoTooling {
ffmpeg: string;
ffprobe: string;
}
let cachedTooling: VideoTooling | null | undefined;
let cachedToolingNullSince = 0;
const TOOLING_NULL_TTL_MS = 5 * 60 * 1000;
function ffmpegCandidate(): string {
return String(process.env.RD_FFMPEG_BIN || "").trim() || "ffmpeg";
}
function ffprobeCandidate(): string {
return String(process.env.RD_FFPROBE_BIN || "").trim() || "ffprobe";
}
async function probeVersion(command: string): Promise<boolean> {
const result = await runVideoProcess(command, ["-version"], { timeoutMs: 10_000 });
return result.ok && !result.missing;
}
export async function resolveVideoTooling(): Promise<VideoTooling | null> {
if (cachedTooling) {
return cachedTooling;
}
if (cachedTooling === null && Date.now() - cachedToolingNullSince < TOOLING_NULL_TTL_MS) {
return null;
}
const ffmpeg = ffmpegCandidate();
const ffprobe = ffprobeCandidate();
const [ffmpegOk, ffprobeOk] = await Promise.all([probeVersion(ffmpeg), probeVersion(ffprobe)]);
if (ffmpegOk && ffprobeOk) {
cachedTooling = { ffmpeg, ffprobe };
return cachedTooling;
}
cachedTooling = null;
cachedToolingNullSince = Date.now();
return null;
}
export function resetVideoToolingCache(): void {
cachedTooling = undefined;
cachedToolingNullSince = 0;
}
// ---------------------------------------------------------------------------
// Process spawning (ffmpeg/ffprobe). ffmpeg/ffprobe exit conventions: 0 = ok,
// anything else = real failure (NOT 7-Zip's "exit 1 = warning" semantics).
// ---------------------------------------------------------------------------
export interface VideoSpawnResult {
ok: boolean;
aborted: boolean;
timedOut: boolean;
missing: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
}
function appendCapped(buffer: string, text: string, cap: number): string {
const next = buffer + text;
return next.length > cap ? next.slice(next.length - cap) : next;
}
function applyChildPriority(pid: number | undefined, cpuPriority?: string): void {
if (process.platform !== "win32") {
return;
}
const numeric = Number(pid || 0);
if (!Number.isFinite(numeric) || numeric <= 0) {
return;
}
try {
const level = cpuPriority === "high" ? os.constants.priority.PRIORITY_NORMAL : os.constants.priority.PRIORITY_BELOW_NORMAL;
os.setPriority(numeric, level);
} catch {
}
}
function killChildTree(child: { pid?: number; kill: () => void }): void {
const pid = Number(child.pid || 0);
if (process.platform === "win32" && Number.isFinite(pid) && pid > 0) {
try {
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
killer.on("error", () => { try { child.kill(); } catch {} });
return;
} catch {
}
}
try {
child.kill();
} catch {
}
}
export function runVideoProcess(
command: string,
args: string[],
opts: { signal?: AbortSignal; timeoutMs?: number; cpuPriority?: string } = {}
): Promise<VideoSpawnResult> {
const { signal, timeoutMs, cpuPriority } = opts;
if (signal?.aborted) {
return Promise.resolve({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: null, stdout: "", stderr: "" });
}
return new Promise((resolve) => {
let settled = false;
let stdout = "";
let stderr = "";
let timedOut = false;
let aborted = false;
let timeoutId: NodeJS.Timeout | null = null;
const child = spawn(command, args, { windowsHide: true });
applyChildPriority(child.pid, cpuPriority);
const onAbort = (): void => {
aborted = true;
killChildTree(child);
};
const finish = (result: VideoSpawnResult): void => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve(result);
};
if (timeoutMs && timeoutMs > 0) {
timeoutId = setTimeout(() => {
timedOut = true;
killChildTree(child);
finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: null, stdout, stderr });
}, timeoutMs);
}
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
child.stdout?.on("data", (chunk) => { stdout = appendCapped(stdout, String(chunk || ""), STDOUT_CAP); });
child.stderr?.on("data", (chunk) => { stderr = appendCapped(stderr, String(chunk || ""), STDERR_CAP); });
child.on("error", (error) => {
const text = String(error || "");
finish({ ok: false, aborted: false, timedOut: false, missing: text.toLowerCase().includes("enoent"), exitCode: null, stdout, stderr: stderr || text });
});
child.on("close", (code) => {
if (aborted) {
finish({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: code, stdout, stderr });
return;
}
if (timedOut) {
finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: code, stdout, stderr });
return;
}
finish({ ok: code === 0, aborted: false, timedOut: false, missing: false, exitCode: code, stdout, stderr });
});
});
}
// ---------------------------------------------------------------------------
// Per-file orchestration: probe -> decide -> (disk check) -> remux -> atomic
// replace -> preserve mtime. Operates IN PLACE (same filename); the .DL. rename
// + companion handling + logging is done by the caller (download-manager).
// ---------------------------------------------------------------------------
async function getFreeSpaceBytes(dir: string): Promise<number | null> {
try {
const stat = await fs.promises.statfs(dir);
return Number(stat.bavail) * Number(stat.bsize);
} catch {
return null;
}
}
const RENAME_RETRY_DELAYS_MS = [200, 500, 1000];
const RENAME_RETRYABLE_CODES = new Set(["EBUSY", "EACCES", "EPERM", "EEXIST"]);
function delayMs(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Windows file locks from antivirus, the search indexer, or a media scanner are
// transient: a rename that hits EBUSY/EACCES/EPERM/EEXIST often succeeds a moment
// later. Retry with backoff before giving up so a momentary lock doesn't abort
// the atomic replace and leave the file unprocessed.
export async function renameWithRetry(from: string, to: string): Promise<void> {
for (let attempt = 0; ; attempt += 1) {
try {
await fs.promises.rename(from, to);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (!code || !RENAME_RETRYABLE_CODES.has(code) || attempt >= RENAME_RETRY_DELAYS_MS.length) {
throw error;
}
await delayMs(RENAME_RETRY_DELAYS_MS[attempt]);
}
}
}
// Short, unique, same-directory sidecar name (never longer than the original file
// name) so concurrent packages / retries never collide on a fixed temp name and a
// long scene filename + suffix cannot push the path past Windows MAX_PATH.
function uniqueTempPath(filePath: string): string {
const ext = path.extname(filePath);
const token = `${process.pid.toString(36)}${crypto.randomBytes(3).toString("hex")}`;
return path.join(path.dirname(filePath), `~rd${token}${ext}`);
}
export async function processVideoFile(filePath: string, opts: ProcessVideoOptions, deps: ProcessVideoDeps = {}): Promise<VideoProcessResult> {
const resolveTool = deps.resolveTooling || resolveVideoTooling;
const run = deps.runProcess || runVideoProcess;
if (opts.signal?.aborted) {
return { action: "aborted", reason: "aborted" };
}
const tooling = await resolveTool();
if (!tooling) {
return { action: "skipped-no-tool", reason: "ffmpeg/ffprobe nicht gefunden (PATH oder RD_FFMPEG_BIN)" };
}
const probe = await run(tooling.ffprobe, buildFfprobeArgs(filePath), { signal: opts.signal, timeoutMs: PROBE_TIMEOUT_MS });
if (probe.aborted) {
return { action: "aborted", reason: "aborted" };
}
if (!probe.ok) {
return { action: "error", reason: "ffprobe fehlgeschlagen", error: probe.stderr || `exit ${String(probe.exitCode)}` };
}
const streams = parseFfprobeAudioStreams(probe.stdout);
const audioLanguages = streams.map((s) => (s.language || "").trim() || "und");
const decision = pickAudioTrack(streams, opts.mode, looksLikeGermanRelease(path.basename(filePath)));
if (decision.action === "skip") {
return {
action: decision.reason === "no-german-track" ? "skipped-no-german" : "skipped-no-audio",
reason: decision.reason,
totalAudioTracks: streams.length,
audioLanguages
};
}
if (decision.action === "single") {
return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: 0 };
}
// remux path
let originalStat: fs.Stats;
try {
originalStat = await fs.promises.stat(filePath);
} catch (error) {
return { action: "error", reason: "stat fehlgeschlagen", error: String(error), audioLanguages };
}
const free = await getFreeSpaceBytes(path.dirname(filePath));
if (free !== null && free < Math.ceil(originalStat.size * 1.05)) {
return { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux", totalAudioTracks: streams.length, audioLanguages };
}
const tempPath = uniqueTempPath(filePath);
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
const remux = await run(
tooling.ffmpeg,
buildFfmpegRemuxArgs({ input: filePath, output: tempPath, audioRelIndex: decision.audioRelIndex, keepSubs: false }),
{ signal: opts.signal, timeoutMs: computeRemuxTimeoutMs(originalStat.size), cpuPriority: opts.cpuPriority }
);
if (remux.aborted) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "aborted", reason: "aborted" };
}
if (!remux.ok) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "error", reason: "ffmpeg remux fehlgeschlagen", error: remux.stderr || `exit ${String(remux.exitCode)}`, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: decision.audioRelIndex };
}
const tempStat = await fs.promises.stat(tempPath).catch(() => null);
if (!tempStat || tempStat.size <= 0) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "error", reason: "Remux ergab leere Datei", totalAudioTracks: streams.length, audioLanguages };
}
const renameOp = deps.rename || renameWithRetry;
try {
// Atomic replace-over: libuv maps fs.rename to MoveFileEx(REPLACE_EXISTING) on
// Windows and rename(2) on POSIX, both atomic on the same volume, so filePath
// holds either the full original or the full remux at every instant. Retried
// for transient locks. We must NEVER rm the original first (the old fallback
// did): an rm-then-failed-rename left zero copies of the file on disk.
await renameOp(tempPath, filePath);
// Preserve original mtime so freshness gates (hybrid collect) don't skip it.
await fs.promises.utimes(filePath, originalStat.atime, originalStat.mtime).catch(() => {});
} catch (error) {
// Replace failed -> the original is untouched at filePath. Drop the temp only.
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "error", reason: "Ersetzen der Datei fehlgeschlagen", error: String(error), totalAudioTracks: streams.length, audioLanguages };
}
return { action: "remuxed", reason: decision.reason, keptTrackIndex: decision.audioRelIndex, totalAudioTracks: streams.length, audioLanguages };
}

View File

@ -302,10 +302,6 @@ export function getWindowsHostDiagnostics(forceRefresh = false): WindowsHostDiag
}
}
export function getCachedWindowsHostDiagnostics(): WindowsHostDiagnostics | null {
return cachedValue;
}
export function resetWindowsHostDiagnosticsCache(): void {
cachedAt = 0;
cachedValue = null;
@ -319,6 +315,7 @@ export function hasRecentWindowsMinidumps(): boolean {
return true;
}
} catch {
// ignore
}
}
return false;

View File

@ -3,13 +3,11 @@ import {
AddLinksPayload,
AllDebridHostInfo,
AppSettings,
DebridAccountStatus,
DebridLinkHostLimitInfo,
DebridProvider,
DuplicatePolicy,
HistoryEntry,
PackagePriority,
RendererErrorReport,
SessionStats,
StartConflictEntry,
StartConflictResolutionResult,
@ -59,7 +57,7 @@ const api: ElectronApi = {
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
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),
@ -77,8 +75,6 @@ const api: ElectronApi = {
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),
@ -89,7 +85,6 @@ const api: ElectronApi = {
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),
reportRendererError: (report: RendererErrorReport): void => ipcRenderer.send(IPC_CHANNELS.LOG_RENDERER_ERROR, report),
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);

File diff suppressed because it is too large Load Diff

View File

@ -1,94 +0,0 @@
import React from "react";
interface ErrorBoundaryProps {
children: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
message: string;
}
// Catches render-time errors in the component tree so a crash shows a minimal
// recovery surface instead of a silent white screen, and forwards the error to
// the main process log. Kept deliberately dead-simple and state-independent: an
// error inside the error path is how you get a second white screen or a loop.
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, message: "" };
}
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return { hasError: true, message: error instanceof Error ? error.message : String(error) };
}
componentDidCatch(error: unknown, info: React.ErrorInfo): void {
try {
window.rd?.reportRendererError({
kind: "react",
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
componentStack: info?.componentStack || undefined
});
} catch {
}
}
private handleReload = (): void => {
window.location.reload();
};
render(): React.ReactNode {
if (!this.state.hasError) {
return this.props.children;
}
const overlay: React.CSSProperties = {
position: "fixed",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 16,
padding: 32,
background: "#070b14",
color: "#e6edf6",
fontFamily: "Segoe UI, system-ui, sans-serif",
textAlign: "center"
};
const pre: React.CSSProperties = {
maxWidth: 640,
maxHeight: 200,
overflow: "auto",
padding: 12,
background: "#0d1422",
border: "1px solid #243049",
borderRadius: 6,
color: "#ff9a8c",
fontSize: 12,
whiteSpace: "pre-wrap",
textAlign: "left"
};
const button: React.CSSProperties = {
padding: "8px 20px",
background: "#2d5cff",
color: "#fff",
border: "none",
borderRadius: 6,
cursor: "pointer",
fontSize: 14
};
return (
<div style={overlay}>
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1>
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}>
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
</p>
<pre style={pre}>{this.state.message}</pre>
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button>
</div>
);
}
}

View File

@ -1,39 +1,8 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { ErrorBoundary } from "./error-boundary";
import "./styles.css";
// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise
// rejections) to the main process log. Without this, a renderer crash leaves no
// trace anywhere on an unattended server.
function reportRendererError(report: Parameters<typeof window.rd.reportRendererError>[0]): void {
try {
window.rd?.reportRendererError(report);
} catch {
}
}
window.addEventListener("error", (event) => {
reportRendererError({
kind: "error",
message: event.message || String(event.error || "Unbekannter Fehler"),
stack: event.error instanceof Error ? event.error.stack : undefined,
source: event.filename || undefined,
line: typeof event.lineno === "number" ? event.lineno : undefined,
column: typeof event.colno === "number" ? event.colno : undefined
});
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
reportRendererError({
kind: "unhandledrejection",
message: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack : undefined
});
});
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element fehlt");
@ -41,8 +10,6 @@ if (!rootElement) {
createRoot(rootElement).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
<App />
</React.StrictMode>
);

View File

@ -36,26 +36,38 @@ export function sortPackagesForDisplay(
return packages;
}
const active: PackageEntry[] = [];
const active: Array<{ pkg: PackageEntry; index: number; completedRatio: number; downloadedBytes: number }> = [];
const rest: PackageEntry[] = [];
// Float packages that have an active item to the top, but keep BOTH groups in
// their original (queue) order. Earlier this sorted the active group by live
// completedRatio/downloadedBytes — which change on every progress tick (every
// 150-700ms), so active packages visibly reshuffled the whole time. A package
// entering/leaving the active bucket is a real, discrete event (start/finish);
// ranking *within* the bucket by live bytes was pure jitter nobody needs.
for (const pkg of packages) {
const hasActive = pkg.itemIds.some((id) => {
const item = itemsById[id];
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
});
(hasActive ? active : rest).push(pkg);
}
packages.forEach((pkg, index) => {
const items = pkg.itemIds
.map((id) => itemsById[id])
.filter((item): item is DownloadItem => Boolean(item));
const hasActive = items.some((item) => ACTIVE_PACKAGE_STATUSES.has(item.status));
if (!hasActive) {
rest.push(pkg);
return;
}
const completedRatio = items.length > 0
? items.filter((item) => item.status === "completed").length / items.length
: 0;
const downloadedBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
active.push({ pkg, index, completedRatio, downloadedBytes });
});
if (active.length === 0 || active.length === packages.length) {
return packages;
}
return [...active, ...rest];
active.sort((a, b) => {
if (a.completedRatio !== b.completedRatio) {
return b.completedRatio - a.completedRatio;
}
if (a.downloadedBytes !== b.downloadedBytes) {
return b.downloadedBytes - a.downloadedBytes;
}
return a.index - b.index;
});
return [...active.map((entry) => entry.pkg), ...rest];
}

View File

@ -1,27 +0,0 @@
import type { SessionState } from "../shared/types";
/**
* Drop selected ids whose package OR item no longer exists in the session.
* The selection set mixes package and item ids; when entries vanish (delta
* removal, backup-driven session swap, completed-cleanup) a stale id would
* otherwise inflate the selection count and the "(N)" action labels and keep
* "multi" styling alive for ghosts.
*
* Returns the SAME set instance when nothing changed, so callers can use it
* directly as a React state updater without forcing a re-render.
*/
export function pruneSelection(
selected: ReadonlySet<string>,
session: Pick<SessionState, "packages" | "items">
): Set<string> {
if (selected.size === 0) {
return selected as Set<string>;
}
const next = new Set<string>();
for (const id of selected) {
if (session.packages[id] || session.items[id]) {
next.add(id);
}
}
return next.size === selected.size ? (selected as Set<string>) : next;
}

View File

@ -1,16 +1,15 @@
:root {
font-family: "Manrope", "Segoe UI Variable", "Segoe UI", sans-serif;
--bg: #050810;
--bg-glow: #1e1730;
--surface: #0c1322;
--card: #12192c;
--field: #090f1c;
--border: #283447;
--text: #ece6dd;
--muted: #a59c8e;
--accent: #f2942d;
--accent-2: #ff7a5c;
--danger: #f4564b;
--bg: #040912;
--bg-glow: #10203b;
--surface: #0b1424;
--card: #101d31;
--field: #081120;
--border: #233954;
--text: #e2e8f0;
--muted: #90a4bf;
--accent: #38bdf8;
--danger: #f43f5e;
--button-bg: #0d1a2c;
--button-bg-hover: #12243d;
--tab-bg: #0b1321;
@ -34,9 +33,8 @@
--field: #ffffff;
--border: #c7d5ea;
--text: #0f223d;
--muted: #6b6052;
--accent: #c2701a;
--accent-2: #d9542b;
--muted: #4e6482;
--accent: #1168d9;
--danger: #c0392b;
--button-bg: #f3f7ff;
--button-bg-hover: #e6efff;
@ -70,6 +68,8 @@ body,
gap: 8px;
}
/* ── Menu Bar ───────────────────────────────────────────────── */
.menu-bar {
display: flex;
align-items: center;
@ -276,6 +276,8 @@ body,
white-space: nowrap;
}
.control-strip {
display: flex;
justify-content: space-between;
@ -470,9 +472,9 @@ body,
}
.btn.accent {
background: linear-gradient(180deg, #f9a948, #e9881f);
color: #1c1206;
border-color: #f0982f;
background: linear-gradient(180deg, #56d6ff, #33b9f4);
color: #07111c;
border-color: #41c6f9;
}
.btn.danger {
@ -656,7 +658,7 @@ body,
.pkg-column-header {
display: grid;
/* grid-template-columns set via inline style from columnOrder */
gap: 8px;
padding: 4px 10px;
background: color-mix(in srgb, var(--card) 58%, transparent);
@ -695,7 +697,7 @@ body,
.pkg-columns {
display: grid;
/* grid-template-columns set via inline style from columnOrder */
gap: 8px;
align-items: center;
min-width: 0;
@ -751,7 +753,7 @@ body,
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, #f2942d, #ff7a5c);
background: linear-gradient(90deg, #3bc9ff, #22d3ee);
border-radius: 4px;
transition: width 0.15s ease;
}
@ -1487,6 +1489,7 @@ body,
margin: -2px 0 10px;
}
.key-stats-popup {
width: min(1360px, calc(100vw - 20px));
max-width: min(1360px, calc(100vw - 20px));
@ -1559,12 +1562,10 @@ body,
.account-subkey-table-head .col-usage,
.account-subkey-table-head .col-limit,
.account-subkey-table-head .col-status,
.account-subkey-table-head .col-traffic,
.account-subkey-table-head .col-links,
.account-subkey-table-row .col-usage,
.account-subkey-table-row .col-limit,
.account-subkey-table-row .col-status,
.account-subkey-table-row .col-traffic,
.account-subkey-table-row .col-links {
text-align: right;
@ -1614,10 +1615,6 @@ body,
color: var(--muted);
}
.account-subkey-table-row .col-status {
text-align: center;
}
.account-subkey-table-row .col-traffic,
.account-subkey-table-row .col-links {
text-align: center;
@ -1638,38 +1635,6 @@ body,
font-size: 10px;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 88px;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
}
.status-pill-ok {
color: #166534;
background: color-mix(in srgb, #22c55e 16%, transparent);
}
.status-pill-warn {
color: #92400e;
background: color-mix(in srgb, #f59e0b 18%, transparent);
}
.status-pill-bad {
color: #991b1b;
background: color-mix(in srgb, #ef4444 14%, transparent);
}
.status-pill-muted {
color: var(--muted);
background: color-mix(in srgb, var(--field) 80%, transparent);
}
.btn-sm {
padding: 3px 8px;
font-size: 11px;
@ -2180,6 +2145,7 @@ body,
color: #0a0f1a;
}
.pkg-toggle {
display: inline-flex;
align-items: center;
@ -2264,7 +2230,7 @@ body,
.progress-dl {
height: 100%;
background: linear-gradient(90deg, #f2942d, #ff7a5c);
background: linear-gradient(90deg, #3bc9ff, #22d3ee);
}
.progress-ex {
@ -2272,22 +2238,17 @@ body,
background: linear-gradient(90deg, #22c55e, #4ade80);
}
/* History Tab */
.history-view {
display: flex;
flex-direction: column;
gap: 6px;
height: 100%;
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.history-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
padding: 5px 12px;
background: var(--card);
border: 1px solid var(--border);
@ -2297,14 +2258,6 @@ body,
color: var(--muted);
}
.history-toolbar-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-left: auto;
}
.history-card {
cursor: default;
}
@ -2372,7 +2325,7 @@ td {
.item-row {
display: grid;
/* grid-template-columns set via inline style from columnOrder */
gap: 8px;
align-items: center;
margin: 0 -10px;
@ -2434,12 +2387,6 @@ td {
background: #f59e0b;
box-shadow: 0 0 4px #f59e0b80;
}
/* Reserve the dot's footprint even before a status exists, so the filename does
not shift ~14px right when the online/offline/checking dot first appears. */
.link-status-dot-empty {
background: transparent;
box-shadow: none;
}
.prio-high {
color: #f59e0b !important;
@ -2540,97 +2487,39 @@ td {
margin-top: 10px;
}
.stats-sections {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 14px;
}
.stats-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.stats-section-title {
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-top: 8px;
}
.stat-item {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
min-height: 96px;
min-width: 0;
padding: 12px 14px;
gap: 4px;
padding: 10px 12px;
background: var(--field);
border-radius: 10px;
border: 1px solid var(--border);
text-align: left;
}
.stat-button {
width: 100%;
font: inherit;
color: inherit;
appearance: none;
cursor: default;
}
.stat-top {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.stat-eyebrow {
color: var(--muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.stat-item.stat-item-clickable {
cursor: pointer;
transition: border-color 0.15s, transform 0.15s ease;
transition: border-color 0.15s;
}
.stat-item.stat-item-clickable:hover {
border-color: var(--danger, #e05555);
transform: translateY(-1px);
}
.stat-label {
color: var(--text);
font-size: 14px;
font-weight: 600;
line-height: 1.25;
color: var(--muted);
font-size: 12px;
}
.stat-value {
font-size: clamp(22px, 1.35vw, 30px);
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.1;
overflow-wrap: anywhere;
}
.stat-value.stat-value-compact {
font-size: clamp(18px, 1.05vw, 24px);
line-height: 1.2;
}
.stat-value.danger {
@ -2710,7 +2599,7 @@ td {
}
.bar-fill.completed {
background: linear-gradient(90deg, #f2942d, #ff7a5c);
background: linear-gradient(90deg, #3bc9ff, #22d3ee);
}
.provider-detail {
@ -2726,12 +2615,6 @@ td {
font-size: 13px;
}
@media (max-width: 1680px) {
.stats-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.statistics-view {
grid-template-columns: 1fr;
@ -2743,23 +2626,7 @@ td {
}
.stats-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat-item {
min-height: 88px;
}
}
@media (max-width: 520px) {
.stats-grid {
grid-template-columns: 1fr;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
}
@ -3137,40 +3004,3 @@ td {
grid-column: span 1;
}
}
.account-board-header-actions { display: flex; gap: 8px; align-items: center; }
.account-validity-badge {
display: inline-block;
margin-top: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
border: 1px solid transparent;
white-space: nowrap;
}
.account-validity-badge.ok { color: #1c1206; background: linear-gradient(90deg, #7bd88f, #4fb96a); border-color: #4fb96a; }
.account-validity-badge.free { color: #2a2113; background: #f2c14e; border-color: #d9a72f; }
.account-validity-badge.invalid { color: #fff; background: #d9534f; border-color: #c0392b; }
.account-validity-badge.unknown { color: var(--muted, #a59c8e); background: transparent; border-color: var(--line, #4a4032); }
.rotation-panel { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow-y: auto; }
.rotation-empty { color: var(--muted, #a59c8e); font-size: 12px; }
.rotation-event {
display: grid;
grid-template-columns: 72px 1fr;
gap: 8px;
align-items: baseline;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
border-left: 3px solid var(--line, #4a4032);
background: rgba(255,255,255,0.02);
}
.rotation-event.WARN { border-left-color: #f2c14e; }
.rotation-event.ERROR { border-left-color: #d9534f; }
.rotation-event.INFO { border-left-color: #4fb96a; }
.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; }
.rotation-event .rotation-body strong { font-weight: 600; }
.rotation-event .rotation-reason { color: var(--muted, #a59c8e); }

View File

@ -1,3 +1,5 @@
/// <reference types="vite/client" />
import type { ElectronApi } from "../shared/preload-api";
declare global {

View File

@ -55,8 +55,6 @@ export const IPC_CHANNELS = {
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",
@ -66,6 +64,5 @@ export const IPC_CHANNELS = {
SET_PACKAGE_PRIORITY: "queue:set-package-priority",
SKIP_ITEMS: "queue:skip-items",
RESET_ITEMS: "queue:reset-items",
START_ITEMS: "queue:start-items",
LOG_RENDERER_ERROR: "log:renderer-error"
START_ITEMS: "queue:start-items"
} as const;

View File

@ -1,90 +0,0 @@
export interface MegaDebridAccountEntry {
id: string;
login: string;
password: string;
index: number;
label: string;
maskedLogin: string;
}
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
const FNV64_PRIME = 0x100000001b3n;
const FNV64_MASK = 0xffffffffffffffffn;
function fnv1a64(text: string): string {
let hash = FNV64_OFFSET_BASIS;
for (const char of text) {
hash ^= BigInt(char.codePointAt(0) || 0);
hash = (hash * FNV64_PRIME) & FNV64_MASK;
}
return hash.toString(36);
}
export function getMegaDebridAccountId(login: string): string {
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
}
export function maskMegaDebridLogin(login: string): string {
const trimmed = login.trim();
if (!trimmed) {
return "Nicht hinterlegt";
}
if (trimmed.length <= 4) {
return `${trimmed[0]}${"*".repeat(trimmed.length - 1)}`;
}
return `${trimmed.slice(0, 2)}${"*".repeat(Math.max(3, trimmed.length - 4))}${trimmed.slice(-2)}`;
}
export function getMegaDebridAccountLabel(index: number): string {
return `Account ${index + 1}`;
}
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
const seen = new Set<string>();
const lines = String(raw || "")
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean);
const entries: MegaDebridAccountEntry[] = [];
for (const line of lines) {
const colonIdx = line.indexOf(":");
let login: string;
let password: string;
if (colonIdx >= 0) {
login = line.slice(0, colonIdx).trim();
password = line.slice(colonIdx + 1).trim();
} else {
login = line;
password = legacyPassword;
}
if (!login || !password) {
continue;
}
const key = login.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
entries.push({
id: getMegaDebridAccountId(login),
login,
password,
index: entries.length,
label: getMegaDebridAccountLabel(entries.length),
maskedLogin: maskMegaDebridLogin(login)
});
}
return entries;
}
export function serializeMegaDebridAccounts(accounts: { login: string; password: string }[]): string {
return accounts
.filter((a) => a.login.trim() && a.password.trim())
.map((a) => `${a.login.trim()}:${a.password.trim()}`)
.join("\n");
}
export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] {
return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id);
}

View File

@ -2,14 +2,12 @@ import type {
AddLinksPayload,
AllDebridHostInfo,
AppSettings,
DebridAccountStatus,
DebugSetupCheckResult,
DebridLinkHostLimitInfo,
DebridProvider,
DuplicatePolicy,
HistoryEntry,
PackagePriority,
RendererErrorReport,
SessionStats,
StartConflictEntry,
StartConflictResolutionResult,
@ -56,7 +54,7 @@ export interface ElectronApi {
restart: () => Promise<void>;
quit: () => Promise<void>;
exportBackup: () => Promise<{ saved: boolean }>;
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
importBackup: () => Promise<{ restored: boolean; message: string }>;
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
openLog: () => Promise<void>;
openAuditLog: () => Promise<void>;
@ -74,8 +72,6 @@ export interface ElectronApi {
importBestDebridCookies: () => Promise<number>;
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
checkMegaDebridAccount: (login: string, password: string) => Promise<DebridAccountStatus | null>;
retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>;
resetPackage: (packageId: string) => Promise<void>;
@ -86,7 +82,6 @@ export interface ElectronApi {
skipItems: (itemIds: string[]) => Promise<void>;
resetItems: (itemIds: string[]) => Promise<void>;
startItems: (itemIds: string[]) => Promise<void>;
reportRendererError: (report: RendererErrorReport) => void;
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;

View File

@ -5,13 +5,11 @@ export type DebridLinkKeyByteMap = Record<string, number>;
type ProviderDailySettings =
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>;
type ProviderUsageSettings =
ProviderDailySettings
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>;
function normalizePositiveBytes(value: unknown): number {
const numeric = Number(value);
@ -249,81 +247,3 @@ export function addDebridLinkApiKeyTotalUsageBytes(
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
};
}
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
}
export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number {
return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]);
}
export function getMegaDebridAccountDailyUsageBytes(
settings: ProviderDailySettings,
accountId: string,
epochMs = Date.now()
): number {
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
return 0;
}
return normalizePositiveBytes(settings.megaDebridAccountDailyUsageBytes?.[accountId]);
}
export function isMegaDebridAccountDailyLimitReached(
settings: ProviderDailySettings,
accountId: string,
epochMs = Date.now()
): boolean {
const limit = getMegaDebridAccountDailyLimitBytes(settings, accountId);
return limit > 0 && getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit;
}
export function getMegaDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number {
return normalizePositiveBytes(settings.megaDebridAccountTotalUsageBytes?.[accountId]);
}
export function addMegaDebridAccountDailyUsageBytes(
settings: ProviderDailySettings,
accountId: string,
byteDelta: number,
epochMs = Date.now()
): Pick<AppSettings, "providerDailyUsageDay" | "megaDebridAccountDailyUsageBytes"> {
const increment = normalizePositiveBytes(byteDelta);
const dayKey = getProviderUsageDayKey(epochMs);
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
? { ...(settings.megaDebridAccountDailyUsageBytes || {}) }
: {};
if (increment <= 0) {
return {
providerDailyUsageDay: dayKey,
megaDebridAccountDailyUsageBytes: currentUsageBytes
};
}
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
return {
providerDailyUsageDay: dayKey,
megaDebridAccountDailyUsageBytes: currentUsageBytes
};
}
export function addMegaDebridAccountTotalUsageBytes(
settings: ProviderUsageSettings,
accountId: string,
byteDelta: number
): Pick<AppSettings, "megaDebridAccountTotalUsageBytes"> {
const increment = normalizePositiveBytes(byteDelta);
const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) };
if (increment <= 0) {
return {
megaDebridAccountTotalUsageBytes: currentUsageBytes
};
}
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
return {
megaDebridAccountTotalUsageBytes: currentUsageBytes
};
}

View File

@ -29,7 +29,6 @@ export type DebridFallbackProvider = DebridProvider | "none";
export type AppTheme = "dark" | "light";
export type PackagePriority = "high" | "normal" | "low";
export type ExtractCpuPriority = "high" | "middle" | "low";
export type HistoryRetentionMode = "never" | "session" | "permanent";
export interface BandwidthScheduleEntry {
id: string;
@ -42,28 +41,10 @@ export interface BandwidthScheduleEntry {
export interface DownloadStats {
totalDownloaded: number;
totalDownloadedAllTime: number;
totalFiles?: number;
totalFilesSession: number;
totalFilesAllTime: number;
totalPackages: number;
sessionStartedAt: number;
appSessionStartedAt: number;
sessionRuntimeMs: number;
totalRuntimeMs: number;
runtimeMeasuredAt: number;
}
export interface DebridAccountStatus {
accountId: string;
provider: "megadebrid" | "debridlink";
label: string;
maskedLogin: string;
valid: boolean;
isPremium: boolean;
premiumUntilMs: number | null;
email?: string;
message: string;
checkedAt: number;
}
export interface AppSettings {
@ -71,7 +52,6 @@ export interface AppSettings {
realDebridUseWebLogin: boolean;
megaLogin: string;
megaPassword: string;
megaCredentials: string;
megaDebridApiEnabled: boolean;
megaDebridWebEnabled: boolean;
megaDebridPreferApi: boolean;
@ -88,7 +68,7 @@ export interface AppSettings {
linkSnappyPassword: string;
archivePasswordList: string;
rememberToken: boolean;
providerOrder: readonly DebridProvider[];
providerOrder: DebridProvider[];
providerPrimary: DebridProvider;
providerSecondary: DebridFallbackProvider;
providerTertiary: DebridFallbackProvider;
@ -97,8 +77,6 @@ export interface AppSettings {
packageName: string;
autoExtract: boolean;
autoRename4sf4sj: boolean;
keepGermanAudioOnly: boolean;
germanAudioMode: "tag" | "first";
extractDir: string;
collectMkvToLibrary: boolean;
mkvLibraryDir: string;
@ -125,16 +103,13 @@ export interface AppSettings {
minimizeToTray: boolean;
theme: AppTheme;
collapseNewPackages: boolean;
historyRetentionMode: HistoryRetentionMode;
accountListShowDetailedDebridLinkKeys: boolean;
autoSortPackagesByProgress: boolean;
autoSkipExtracted: boolean;
hideExtractedItems: boolean;
confirmDeleteSelection: boolean;
backupIncludeDownloads: boolean;
totalDownloadedAllTime: number;
totalCompletedFilesAllTime: number;
totalRuntimeAllTimeMs: number;
bandwidthSchedules: BandwidthScheduleEntry[];
columnOrder: string[];
extractCpuPriority: ExtractCpuPriority;
@ -147,11 +122,6 @@ export interface AppSettings {
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
debridLinkApiKeyTotalUsageBytes: Record<string, number>;
megaDebridDisabledAccountIds: string[];
megaDebridAccountDailyLimitBytes: Record<string, number>;
megaDebridAccountDailyUsageBytes: Record<string, number>;
megaDebridAccountTotalUsageBytes: Record<string, number>;
debridAccountStatuses: Record<string, DebridAccountStatus>;
providerDailyUsageDay: string;
scheduledStartEpochMs: number;
}
@ -190,10 +160,8 @@ export interface PackageEntry {
itemIds: string[];
cancelled: boolean;
enabled: boolean;
priority?: PackagePriority;
priority: PackagePriority;
postProcessLabel?: string;
downloadStartedAt?: number;
downloadCompletedAt?: number;
createdAt: number;
updatedAt: number;
}
@ -234,19 +202,6 @@ export interface ContainerImportResult {
source: "dlc";
}
export interface RotationEvent {
id: string;
at: number;
level: "INFO" | "WARN" | "ERROR";
provider: string;
accountLabel: string;
event: string;
reason?: string;
category?: string;
cooldownSec?: number;
next?: string;
}
export interface UiSnapshot {
settings: AppSettings;
session: SessionState;
@ -260,10 +215,6 @@ export interface UiSnapshot {
clipboardActive: boolean;
reconnectSeconds: number;
packageSpeedBps: Record<string, number>;
payloadKind?: "full" | "delta";
removedItemIds?: string[];
removedPackageIds?: string[];
rotationEvents?: RotationEvent[];
}
export interface AddLinksPayload {
@ -330,8 +281,6 @@ export interface UpdateInstallProgress {
export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown";
export type AllDebridHostInfoSource = "api" | "web";
export type DebridLinkHostState = "up" | "down" | "unknown";
export type DebridLinkKeyState = "ready" | "cooldown" | "invalid" | "quota" | "rate_limit" | "error" | "unknown";
export interface AllDebridHostInfo {
host: string;
@ -357,15 +306,6 @@ export interface DebridLinkHostLimitInfo {
linksCurrent: number | null;
linksMax: number | null;
note: string;
state: DebridLinkKeyState;
stateLabel: string;
stateDetail: string;
cooldownUntil: number | null;
cooldownRemainingMs: number;
lastCheckedAt: number | null;
hostState: DebridLinkHostState;
hostStateLabel: string;
hostNote: string;
}
export interface ParsedHashEntry {
@ -501,13 +441,3 @@ export interface HistoryState {
entries: HistoryEntry[];
maxEntries: number;
}
export interface RendererErrorReport {
kind: "error" | "unhandledrejection" | "react";
message: string;
stack?: string;
source?: string;
line?: number;
column?: number;
componentStack?: string;
}

View File

@ -1,335 +0,0 @@
# Lessons
## 2026-05-31 — Fix-Diagnose EMPIRISCH bestätigen, bevor man released (Timeout ≠ Account-Hänger)
**Muster:** "acc2/acc3 nie versucht" wurde als "acc1 hängt → Per-Account-Timeout +
Rotation" diagnostiziert und als v1.7.168 released. Falsch: Mega-Debrid-**Web** ist eine
180s-Polling-Schleife (`mega-web-fallback.ts`) — acc1 *pollte* legitim, der 60s-Global-
Timeout (nicht "Hängen") schnitt es ab. Mein 25s-Per-Account-Cap machte es SCHLIMMER
(endlose 25s-Rotation, Datei nie aufgelöst). Erst der User-Log + Lesen der Provider-
Impl deckte es auf. Revert v1.7.169.
**Regel:**
- Ein Timeout bei einem langsam-pollenden Provider ist KEIN Account-Fehler → darf keine
Rotation/kein Skippen auslösen. Vor "Account hängt"-Annahmen die Provider-Impl lesen
(Polling? internes Ceiling? wie lange dauert ein Erfolg legitim?).
- Bei zwei gegensätzlichen Diagnosen (hier: Timeout-zu-kurz vs. IP-Block — stand in der
EIGENEN Memory!) NICHT die bequeme wählen + releasen. Erst empirisch diskriminieren
(Env-Var auf Server, Beobachtung, oder gezielte User-Frage). Ein Symptom, das BEIDE
Hypothesen gleich gut erklärt ("Timeout nach Xs"), beweist keine.
- NICHT lokal "verifizieren" wenn das Problem umgebungsspezifisch ist (geblockte
Server-IP) — lokaler Erfolg ist falsch-positiv.
## 2026-05-30 — Abgestürzten/„aufgehängten" Chat fortsetzen: zuerst reflog lesen
**Muster:** User bat, einen anderen, aufgehängten Chat-Strang „zu Ende zu bringen".
Der Working Tree sah harmlos aus (nur untracked), aber der eigentliche Fortschritt lag
in einem per `reset --hard HEAD~1` weggesetzten Commit, der nur noch im **reflog**
(dangling) lebte.
**Regel:** Bei „mach weiter wo es hing":
1. `git reflog` + `git log --oneline -20` zuerst — Ground Truth, NICHT der
(evtl. stale) gitStatus-Snapshot oder Konversations-interne Annahmen.
2. Reset-weggesetzte/dangling Commits (`git fsck --lost-found`, reflog) inspizieren
(`git show <sha>`) — dort steckt oft die unfertige Arbeit.
3. **Verstehen WARUM weggesetzt**, bevor man blind cherry-picked: hier brach ein
bestehender Test (`.toBe(signal)`-Identitätscheck), den der Fix zwingend ändert.
Der Reset war die Reaktion darauf, nicht „Fix war falsch". Erst die Reset-Ursache
beheben (Test auf Verhalten umstellen), dann den Fix recovern.
4. Eigene Memory (`project_*`) lesen — sie dokumentierte Bug + intendierten Fix exakt.
## 2026-05-30 — Release verifizieren BEVOR "fertig" gesagt wird; curl -F mit Leerzeichen im Pfad
**Muster A (Edit ins Leere + trotzdem released):** Ein Edit schlug fehl ("String not
found"), ich habe es übersehen, committet und v1.7.165 released — die Datei enthielt
das Feature NICHT. Erst der nächste Blick zeigte es.
**Regel:** Nach jedem Feature-Edit VOR dem Release `git show HEAD:datei | grep <marker>`
— bestätigen dass der Code wirklich im Release-Commit ist, nicht nur dass `git commit`
durchlief.
**Muster B (Gitea UNIQUE constraint):** `npm run release:gitea` pusht erst den Tag,
dann erstellt es den Release. Gitea legt beim Tag-Push automatisch einen Tag-Release-
Eintrag an (name=null). `fetchExistingRelease` im Script matcht den nicht → POST create
`UNIQUE constraint failed: release.repo_id, release.tag_name`. Commit + Tag sind dann
schon gepusht, nur der Release+Assets fehlen.
**Recovery:** `GET /api/v1/repos/.../releases/tags/<tag>` → id holen → `PATCH releases/<id>`
mit name/body/draft:false → Assets per `POST releases/<id>/assets?name=<url-encoded>` hochladen.
**Muster C (curl -F Datei mit Leerzeichen):** `curl -F "attachment=@release/Datei mit
Leerzeichen.exe.blockmap"` lädt FALSCHEN Inhalt hoch (Server-Size != lokale Size).
**Regel:** Datei mit Leerzeichen im Namen erst nach `/tmp/leerzeichenfrei` kopieren,
DAS hochladen, Asset-Name über `?name=<url-encoded>` setzen. Danach Server-Size gegen
lokale Size prüfen.
## 2026-05-30 — Nicht in chaotische Parallel-Tool-Batches verfallen (User-Korrektur: "bist du in nem endless loop")
**Muster:** Bei einem großen Multi-File-Edit habe ich Dutzende Tool-Calls (Bash-Probes,
Reads, Edits, Python-Inline-Skripte, mehrfache tsc-Läufe) in EINEN Message-Block gepackt.
Resultat: Ein einzelner Fehler/Cancel hat die ganze parallele Kette abgebrochen, Edits
landeten halb, ich verlor den Überblick welche Änderung wirklich auf Disk war, und es
wirkte wie eine Endlosschleife. Dazu: wegwerf-`scripts/_*.py`/`_*.txt` als Workaround
gegen Output-Encoding statt der dedizierten Tools.
**Regel:**
- Edits über mehrere Dateien **sequenziell, einer nach dem anderen**, mit kurzer
Verifikation dazwischen — nicht 20 spekulative Calls auf einmal.
- Nach jedem Edit, der fehlschlagen kann (Anchor evtl. nicht eindeutig), das Ergebnis
lesen, bevor der nächste folgt. Edit/Write erroren laut — darauf vertrauen.
- KEINE Wegwerf-Python-Skripte ins Repo schreiben, um Shell-Output zu parsen. `Grep`/
`Read`/`Edit` nutzen. Wenn doch ein Temp nötig ist: nach `os.tmpdir()`, nie nach
`scripts/`, und sofort wieder löschen.
- Verifikation gebündelt am ENDE (1× tsc, 1× build, 1× vitest), nicht 10× zwischendrin.
## 2026-05-28 — Analyse-Befund gegen beobachtete Realität gaten (Advisor-Korrektur)
**Muster:** Meine Analyse sagte einen *häufigen* Bug voraus (jede letzte Datei im
Standard-Modus + jede Nested-Datei landet unbenannt), während der User nur "1-2 pro
Staffel" meldete. Ich habe die Diskrepanz bemerkt ("zu schwer um unbemerkt zu bleiben")
und sie mit weiterem Timing-Argument wegrationalisiert.
**Regel:** Wenn die eigene Analyse etwas vorhersagt, das der beobachteten Realität
widerspricht, NICHT die bequeme Lesart wählen — **mit einem Reproduktions-Test gaten**,
bevor man fixt. Failing Test gegen den Ist-Stand zuerst (TDD/systematic-debugging Phase 4):
- reproduziert → Bug bestätigt, mit Sicherheit fixen.
- reproduziert nicht → Analyse hat eine Mitigation übersehen, kein Fix für Nicht-Bug.
## 2026-05-28 — Crash-Debris im Working Tree: stashen, nicht verwerfen
**Muster:** Eine abgestürzte Session (API 400) hinterließ ein uncommittetes Working Tree,
das drei releaste Commits revertierte. Verlockung: `git checkout`/discard, um clean HEAD
zu bekommen.
**Regel:** Fremde/unverstandene uncommittete Änderungen **`git stash`** (non-destruktiv,
recoverable), nie blind verwerfen. Gibt clean HEAD, nichts geht verloren, kein Stall auf
User-Rückfrage. Danach dem User sagen WAS gestasht wurde und WARUM.
## Wiring-Lock vs. Mechanism-Test
Ein Test, der eine Hilfsfunktion mit dem richtigen Flag direkt aufruft, beweist nur, dass
das Flag funktioniert — NICHT, dass der Produktionspfad das Flag setzt. Für echte
Absicherung einen End-to-End-Test durch den realen Einstiegspunkt fahren und per
Negativ-Gate (Flag temporär entfernen → Test muss fallen) verifizieren.
## 2026-05-31 — Log-Symptom ≠ User-Wortlaut: greppen, bevor man auf eine Meldung triggert
**Muster:** User meldete Mega-Debrid-Tageslimit als „Kein Server für diesen Hoster". Ich
wollte den Fix an genau diese Meldung (`MEGA_DEBRID_NO_SERVER_RE`) hängen. Der Advisor
stoppte: der Screenshot zeigte als Cooldown-Grund **„Antwort leer"**, nicht „Kein Server".
**Beweis (Support-Bundle gegrept):** „Kein Server"/„Erreur"/„aucun serveur" = **0** Treffer
im ganzen Bundle, „Antwort leer" = **20.861** Treffer. Der limitierte Account liefert im
Web-Pfad NIE eine unterscheidbare Meldung — `generate()` findet ohne `processDebrid`-Code
keinen Code → `return null` → der Aufrufer macht daraus „Antwort leer". Ein Trigger auf
„Kein Server" wäre toter Code gewesen (= die v1.7.172-Falle, zum 2. Mal fast getreten).
**Regel:** Bevor man einen Fix an einen bestimmten Meldungstext hängt, in den ECHTEN Logs
greppen, ob dieser Text dort überhaupt vorkommt (`count`-Mode, alt-Text vs. Ist-Text). Sind
zwei Fälle auf Message-Ebene nicht unterscheidbar (Tageslimit vs. transienter Blip → beide
„Antwort leer"), nicht raten — über ein **Verhaltens-Signal** klassifizieren: hier eine
Streak (3× hintereinander leer → geparkt), nicht der einmalige Wortlaut.
**Wiring-Test nicht vergessen** (eigene Lesson): die Helfer-Unit-Tests beweisen nur den
Zähler. Ein E2E-Test muss eine ECHTE leere Antwort durch den realen Einstiegspunkt
(`unrestrictWithAccounts` → `classifyAccountFailure` → catch → Park) treiben, sonst bleibt
unbewiesen, dass der Produktionspfad das Signal überhaupt setzt.
## 2026-06-01 — Ein Verifizierer muss dieselbe Pfad-Normalisierung nutzen wie die verifizierte Operation
**Muster:** Neues Renaming-Logging sollte nach jedem Rename verifizieren, ob die Datei
wirklich unter dem Zielnamen liegt. `verifyRename` machte statSync/readdirSync auf den
ROHEN Pfaden — der echte Rename lief aber über `toWindowsLongPathIfNeeded` (\?\-Prefix
ab >=248 Zeichen). Bei langen Scene-Release-Pfaden (genau das, was die App routinemäßig
umbenennt) scheiterten die rohen fs-Calls → falsches „Ziel nicht gefunden" UND — schlimmer —
die Quell-Prüfung scheiterte ebenfalls → `sourceGone` fälschlich true → **falsches „OK"**,
das einen halb-fertigen Verschiebevorgang maskiert. Der Diagnose-Log hätte genau die
schwersten Fälle vergiftet. (Adversarialer Review-Workflow fand es, Confidence 0.8.)
**Regel:** Wenn Code eine Operation VERIFIZIERT, muss er exakt dieselbe Pfad-/Encoding-/
Normalisierung verwenden wie die Operation selbst (hier: \?\-Long-Path-Prefix). Sonst
mis-reportet der Verifizierer still — und am verlässlichsten bei den Edge-Cases, die man
eigentlich fangen wollte. Ein falsches OK in einem Diagnose-Log ist schlimmer als ein
falsches ERROR. Zusatz: readdir-Fehler darf nicht zu „Schreibweise ok" degradieren
(stilles False-OK) → eigenes WARN-Level „nicht verifizierbar".
**Meta:** Bei einem Feature, dessen ganzer Zweck Beobachtbarkeit/Verifikation ist, lohnt
ein adversarialer Review mit Fokus „würde die Verifikation auf der ECHTEN Last (lange
Pfade, case-insensitive FS, EXDEV) korrekt urteilen?" — nicht nur „kompiliert + Happy-Path-Test".
## 2026-06-03 — Renaming „nie 100%": entkoppelte Scans + Namens-Fabrikation aus token-losen Ordnern
**Symptom (aus dem Desktop-Rename-Log diagnostiziert):** 17 Dateien landeten ROH in der
Library ("tvarchiv...s07e12-720.mkv", "4sf-...s04e01.mkv"). KEINE [ERROR]-Zeile — alle [INFO],
weil die Verifikation nur „liegt die Datei am Zielnamen?" prüft, nicht „ist der Zielname
sinnvoll?". Das Logging hat den Bug sichtbar gemacht (genau sein Zweck).
**Root Cause 1 (entkoppelte Scans):** Auto-Rename (scannt nur extractDir, nur present-and-
stable Dateien, Freshness-Gate loggt nur via logger.info → keine Session-Spur) und
collectMkvFilesToLibrary (verschiebt JEDE .mkv, behielt den rohen Basename) sind getrennte
Scans. Eine von Auto-Rename verpasste Datei (verpasster Zyklus ODER lag in „Downloader
Unfertig" außerhalb extractDir) wurde von collect roh weggeschoben. **Fix:** collect leitet
den sauberen Namen SELBST ab — über dieselbe Funktion wie Auto-Rename (decideAutoRenameBaseName,
single source of truth) → Race wird egal, beide Pfade können nicht mehr divergieren.
**Root Cause 2 (latente Fabrikation, vom Advisor gefunden):** decideAutoRenameBaseName
fabrizierte „Mega-Direct-Pack.S01E01" für einen generischen Paketordner, weil
`hasSceneGroupSuffix("Mega-Direct-Pack")` auf „-Pack" falsch-positiv matcht und Guard B dann
die Quell-Episode an einen token-losen Ordner anhängt. Das hätte AUTO-RENAME genauso getroffen
(nur dormant, weil echte Releases saubere Ordner haben). **Fix an der Wurzel:** Rename nur,
wenn IRGENDEIN folderCandidate einen echten Season-/Episode-Token trägt — ein token-loser
Ordner kann keine Episode autoritativ benennen.
**Meta-Lektionen:**
1. Bei „X nie 100%": die Fehler aus dem ECHTEN Log ziehen (greppen), nicht raten. Hier:
„Kein Server" 0×, „Antwort leer" 20k×; und 17 vs vermutete 12 (5 begannen mit Ziffer „4").
2. Symptom-Fix vs Wurzel-Fix: ein collect-seitiger Guard (Quell-Auflösung+Codec) hätte das
Symptom kaschiert + eine Restlücke gelassen; der Wurzel-Fix in der gemeinsamen Funktion
schließt BEIDE Pfade + ermöglicht ehrliches 100%.
3. Wenn ein (Sub-)Agent eine empirische Behauptung aufstellt, die der beobachteten Realität
widerspricht (Review: „liefert no-target" vs Test: „benennt um"), NICHT raten — mit einem
Wegwerf-Diagnose-Test die echte Rückgabe sichtbar machen, DANN entscheiden.
4. „raw-keep ist der Boden" als Guard-Prinzip: ein Rename darf nie einen schlechteren Namen
erzeugen als der Originalname.
## 2026-06-03 (2) — Renaming „verschlimmbessert" guten Quellnamen (Scene-Gruppe mit Unterstrich)
**Symptom (neues Desktop-Log):** `castle.s08e02.german.dl.720p.web.h264-idtv_int.mkv` (bereits
SAUBER) im Ordner `Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT` (Paket `scn2-cstl7`) wurde zu
`scn2-cstl7.S08E02.mkv` — also GUTER Name → obfuskierter Paketname. Andere Klasse als die 17
(roh→nicht-angefasst); hier gut→schlechter.
**Ursache (reproduziert, kein Raten):** `hasSceneGroupSuffix("...H264-idTV_iNT")` = false, weil
`SCENE_GROUP_SUFFIX_RE`/`_FALLBACK_RE` Unterstriche im Gruppen-Suffix verbieten. → buildAutoRenameBaseName
verwarf den sauberen Episoden-Ordner (return null) → fiel auf den Paketordner `scn2-cstl7` zurück
→ Episode angehängt = `scn2-cstl7.S08E02`. Guard A (Quelle-besser) griff nicht, weil
`hasMeaningfulSeriesPrefix("scn2-cstl7.S08E02")=true` (Gruppe sieht aus wie Serien-Prefix).
**Fix:** `extractFlexibleSceneGroupSuffix` (existierte, war nicht verdrahtet) in hasSceneGroupSuffix
einbinden → Unterstrich-Gruppen erkannt → sauberer Ordner gewinnt → idealer Name.
**Meta-Lektionen:**
1. „100%" gilt nur fuer die DATEN, die man hatte. Mein lueckenloser Check des 2026-06-02-Logs war
korrekt — aber ein NEUER Download (Castle/idTV_iNT) brachte eine Gruppen-Form, die im alten Log
nicht vorkam. Bei „nie 100%" ehrlich sagen: „fuer die bekannten Faelle 100%, neue Muster brauchen
neue Logs". Das Desktop-Log liefert genau diese neuen Muster.
2. Reproduzieren statt raten: ein 3-Zeilen-Diagnose-Test (buildAutoRenameBaseName pro Ordner +
decideAutoRenameBaseName) zeigte sofort, WELCHER Ordner verworfen wird und warum — nicht spekulieren.
3. Offener Backstop-Gedanke fuer echte Robustheit: ein generelles Guard "ersetze nie einen bereits
VOLLSTAENDIGEN Quellnamen (Serie+Episode+Aufloesung+Codec) durch einen, der die Serien-Identitaet
verliert" wuerde KUENFTIGE unbekannte Gruppen-Formate abfangen — riskanter Eingriff in Guard A,
nur mit Tests + auf User-Wunsch.
## 2026-06-03 (3) — Renaming-Klasse „Junk-Quellname + sauberer Release-Ordner" (Folge-Nummer statt SxxExx)
**Symptom (Log 18-18):** „Kreuzfahrt ins Glück" — 25 Folgen `bet_kig_01_hdt.mkv` (obfuskiert, KEIN
SxxExx-Token) im sauberen Episoden-Ordner `Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.
German.720p.HDTV.x264-BET` (Episode als bloße „01"). Auto-Rename: „kein Zielname" → 25× roh in die
Library. Diesmal SICHTBAR als 25 [WARN] (vorher 0 WARN) — das Log zeigt die Klasse direkt.
**Ursache (reproduziert):** `buildAutoRenameBaseName` gibt null zurück, sobald die QUELLE keinen
SxxExx-Token hat (Z.1288) — egal wie sauber der Ordner ist. Das „Folge 01"-Nummernformat (kein
S01E01) wurde nie unterstuetzt. VORBESTEHEND, nicht meine v1.7.178/179.
**Fix:** Fallback in decideAutoRenameBaseName — wenn kein Zielname UND Quelle hat keinen
Episode-Token, den ersten folderCandidate nehmen, der ein VOLLSTAENDIGER Scene-Release-Ordner ist:
`hasSceneGroupSuffix(f) && (RESOLUTION_RE.test(f) || CODEC_RE.test(f)) && !SCENE_SEASON_ONLY_RE.test(f)`.
Greift NUR ohne Quell-Episode-Token → schliesst sich mit dem Fabrikations-Guard aus (Mega-Direct hat
Quell-Token → unerreicht). note:"folder-as-is".
**Advisor-Punkt (wichtig):** NICHT nur Aufloesung pruefen — alte deutsche TV-Serien gibt es als
DVDRip/XviD OHNE 720p-Token. `RESOLUTION_RE ODER CODEC_RE` → sonst die naechste Runde. Pin-Test:
DVDRip-Variante (kein 720p, nur x264).
**Edge (Advisor):** Bonus/Sample muss VOR diesem Fallback gefiltert werden (sonst kriegt ein
Featurette/Sample im Episoden-Ordner den Episodennamen). Bestaetigt: Auto-Rename-Loop (Sample-Size +
BONUS_FILENAME_RE) und Collect filtern beide vor der Namensherleitung → gedeckt.
**Meta:** 3. „anderes Format" in Folge — diese Klasse (Junk-Quelle + sauberer Ordner) ist die
groesste verbleibende. Scene-Naming hat aber einen langen Schwanz: ehrlich „diese Klasse ist
abgedeckt", nicht „jetzt 100%". Das Desktop-Log liefert jede neue Klasse sofort.
## 2026-06-04 — KEINE „Claude/AI"-Spuren in oeffentlichen Releases (GitHub)
**Korrektur:** „kein SCHAU MAL wie ich mit claude gearbeitet hab release … entfern alles was da drin
steckt." Beim einmaligen GitHub-Sync (Sucukdeluxe/real-debrid-downloader) waren oeffentlich: `CLAUDE.md`,
`design-mockups/`, `tasks/lessons.md`+`todo.md`, historisch `.claude/`, und **357 Commits mit
`Co-Authored-By: Claude`-Trailer**.
**Regel ab jetzt:** Fuer dieses Projekt KEINE `Co-Authored-By: Claude`-Trailer mehr an Commits
(ueberschreibt die Default-Git-Anweisung — User-Wunsch hat Vorrang). Keine KI-Artefakte (CLAUDE.md,
Mockups, lessons/todo, .claude/) in irgendetwas, das oeffentlich gepusht wird.
**Wie sauber gemacht (ohne Gitea/lokal anzufassen):** isolierter `git clone``git filter-repo`
(`--invert-paths --path …` + `--message-callback` der Trailer-Zeilen droppt) → Force-Push NUR main +
v1.7.180 zu GitHub. Alte Tags NICHT geloescht, sondern via `.git/filter-repo/commit-map` auf ihre
sauberen Commits **umgehaengt** (89 Tags, alle Releases bleiben erhalten) — besser als Loeschen.
**Ehrliche Grenze (Advisor):** Force-Push säubert nur ref-erreichbare Historie. Verwaiste alte Commits
bleiben per voller SHA erreichbar, bis GitHub GC'd ODER das Repo neu angelegt wird (nur der User kann
das — Token hat kein `delete_repo`). Lokaler Klon verifiziert ≠ GitHub-Zustand: immer per `gh api`
gegenpruefen (Datei 404 am Tag, Commit-Messages trailer-frei).
**Methodik:** vor Force-Push Voll-Range-Secret-Scan (push-protection killt sonst mitten im Push) +
Tree-Content-Grep auf `claude|anthropic` (filter-repo tilgt Pfad-NAMEN + Trailer, nicht Datei-INHALTE).
## 2026-06-04 — Folge bleibt bei „Downloader Fertig" haengen: Episodentitel == Bonus-Wort
**Symptom (User-Screenshot + rd-support-bundle):** `Revenge.2011.S04E19.Interview...mkv` extrahiert +
korrekt umbenannt, aber NIE in die Library verschoben — kein Fehler. „selten, 4-5 Folgen pro 1,5TB".
**Diagnose (Bundle):** Paket-Log zeigte 22/23 „MKV verschoben", E19 fehlte, KEIN WARN/ERROR. Im
HAUPT-Log (`rd_downloader.log`) dann 5× `MKV-Sammelordner: Bonus-Datei uebersprungen: ...S04E19.Interview`.
**Root Cause:** `BONUS_FILENAME_RE` enthaelt `interview` (+ outtakes/special/featurette/bloopers/...). Der
Episodentitel „Interview" (UND der Episoden-Ordnername — `isInsideBonusDir` macht `.includes()` Substring)
matchte → `collectMkvFilesToLibrary` stufte die echte Folge als Bonus/Extras ein und skippte sie. Trifft
auch ganze Serien deren NAME ein Bonus-Wort ist. Skip war nur `logger.info` → im Paket-Log UNSICHTBAR
(darum „silent orphan", nur via Forensik gefunden).
**Fix:** neue exportierte `isBonusContent(filePath, packageDir, nameWithoutExt)` — eine Datei MIT echtem
SxxExx-Token (`extractEpisodeToken`) ist eine nummerierte Episode, NIE Bonus (egal welches Titelwort).
Echte Extras (kein Token / Extras-Subordner) bleiben gefiltert. Beide Call-Sites umgestellt (Auto-Rename
~4312 + Collect ~5054). 2 Integrationstests (Interview wird gesammelt / Making.Of bleibt) + 5 Unit-Tests.
**Diagnose-Lektion (Advisor-Gate):** „4-5 Folgen" plural → NICHT beim 1. Fund stoppen. Bundle-weit
gegengeprueft: 0 Move-Fehler, nur 1 Bonus-Skip. 4 weitere „noch frisch"-Defers sahen wie Orphans aus,
waren aber FALSE POSITIVES — Moves loggen NICHT ins Haupt-Log (nur Paket-Log), und deren Paket-Logs fehlten
im Bundle. Per Code bewiesen: finaler Deferred-Collect laeuft fuer jedes fertige Paket (`success` =
completed-Items, Z.11904) mit `deferFreshFiles=false` → faengt Frische-Defers. Also Frische orphan't NICHT;
Bonus schon (Filter ignoriert deferFreshFiles, skippt in JEDEM Pass inkl. final). Lehre: bevor man „X ist
Orphan" behauptet, pruefen ob der GEGENBEWEIS (Move) im verfuegbaren Log ueberhaupt sichtbar WAERE.
## 2026-06-05 — Folge bleibt ROH: vollstaendiger Episoden-Ordner OHNE -GROUP-Suffix
**Symptom (rename-session 2026-06-04):** `safari-fm-s04e08a.avi` / `...b.avi` landeten ROH in der Library
(entpackt2). Log: `Auto-Rename übersprungen: kein Zielname`. Funktionierende S01E02 hatte Ordner
`...XviD-SAFARi` (Gruppe), die kaputten S04E08a/b hatten `...SATRiP.XviD` (KEIN -GROUP).
**Root Cause (Wegwerf-Diagnose, NICHT geraten):** Erste Hypothese „a/b-Token nicht erkannt" war FALSCH —
`extractEpisodeToken("...s04e08a")`="S04E08" (das Lookahead `(?!\d)` verbietet nur Ziffern, nicht Buchstaben).
Echte Ursache: das Gate in `buildAutoRenameBaseName` (`isLegacy4sf || isSceneGroupFolder`) lehnt einen
vollstaendigen Episoden-Ordner OHNE -GROUP ab (endet auf bare Codec `.XviD`). Die QUELLE hat aber einen
Token → der v1.7.180-Fallback (greift NUR ohne Quell-Token) feuert nicht → no-target → roh gemoved.
**Fix:** Gate um `isCompleteEpisodeFolder` erweitert = echter Episoden-Token IM Ordner UND Codec-/
Aufloesungs-Marker (neue Module-Consts `SCENE_RESOLUTION_MARKER_RE` / `SCENE_CODEC_MARKER_RE`, inkl.
xvid/divx). Part-Buchstabe a/b bleibt erhalten (Ordnername dient unveraendert als Zielname; nur der
RANGE-Zweig schreibt Token um, und a/b ist kein Range). Konservativ: bare „Show.S01E01" ohne Marker bleibt
abgelehnt (kein Over-Firing). v1.7.180-Fallback nutzt jetzt dieselben Module-Consts (DRY). Greift in
Auto-Rename UND Collect (beide via decideAutoRenameBaseName). 5 Unit- + 1 Collect-Integrationstest.
**Methodik-Lektion:** Die naheliegende Hypothese (a/b-Suffix) per Diagnose-Test widerlegt, BEVOR gefixt —
das Lookahead genau gelesen statt angenommen. Spart einen Fix am falschen Ort.
## 2026-06-05 — Collect zerstoert fertigen S01E01-Namen via Episoden-Titel-Ordner (Miniserie)
**Symptom (rename-session 2026-06-05):** Miniserie "Steven Spielbergs Taken" landete als
"...E01.Hinter.dem.Himmel...-GTVG.S01E01.mkv" (Episodentitel + hinten angehaengtes S01E01) statt sauber
"...S01E01...-GTVG.mkv". User: "keine Staffel, nur Episodentitel".
**Root Cause (diagnostisch bewiesen):** Auto-Rename benannte korrekt zu "...S01E01...-GTVG.mkv" (kombiniert
S01 aus dem Paket/Season-Ordner + E01 aus der Quelle). Der COLLECT (deriveCleanCollectFileName ->
decideAutoRenameBaseName) leitet die Datei NEU ab — Quelle ist nun der schon-saubere Name. Der per-Episode-
Ordner traegt aber nur einen Episode-only-Token + Titel ("...E01.Hinter.dem.Himmel...-GTVG", KEIN S01).
buildAutoRenameBaseName nimmt den Ordner (Gruppen-Suffix -GTVG vorhanden). In Guard B `if (!targetEpisodeToken)`
wird der Quell-Token an den Ordnernamen ANGEHAENGT (applyEpisodeTokenToFolderName) -> "...-GTVG.S01E01"
(Token HINTER der Gruppe = verkrueppelt). Der Root-Guard greift NICHT, weil der Season-Ordner einen S01-Token
liefert (anyFolderHasSeasonOrEpisode=true).
**Fix:** In Guard B, im `!targetEpisodeToken`-Zweig VOR dem Anhaengen: ist die QUELLE ein NICHT
obfuskierter Scene-Name (`!looksLikeObfuscatedSceneFileName(sourceName)`), dann
`return {kind:"skip", reason:"source-better"}` -> Collect behaelt den fertigen Namen. In diesem Zweig
traegt die Quelle den EINZIGEN SxxExx-Token (Ordner hat keinen) -> obfuskiert? -> Ordner gewinnt (Append),
sauber? -> Quelle gewinnt. Greift NUR im `!targetEpisodeToken`-Zweig (Ordner ohne SxxExx); safari
(Ordner MIT Token) unberuehrt. 4 Unit- + 1 Collect-Integrationstest. tsc 6 (Baseline), 700/700 gruen, Build gruen.
**Methodik:** Erst Diagnose (decideAutoRenameBaseName mit Collect-Inputs) -> exakt der mangled Name
reproduziert. Per User-Wunsch adversarial via Workflow gegengeprueft (ultracode, 3 Lenses + Synthese).
**Adversarialer Befund (Workflow fing's):** Mein erster Guard hatte einen ZWEITEN Konjunkt
`hasMeaningfulSeriesPrefix(sourceBaseName)` (>=3 Alpha vor S0x). Der ist sachfremd: KURZE Serien (ER, V,
24, Yu) fallen durch -> selber verkrueppelter Name. Gestrichen -> nur `!obfuskiert` gaten. Lehre: ein
zusaetzlicher "klingt-vernuenftig"-Konjunkt (Praefix-Laenge) kann eine ganze reale Klasse (Kurz-Titel)
stumm ausschliessen; adversariale Verifikation mit konkretem Gegenbeispiel (ER.S01E01) hat's gefunden.

View File

@ -1,104 +0,0 @@
# Plan: „Nur deutsche Tonspur behalten" (.DL.) als Tool-Funktion
Quelle der Idee: User-Script `Remove Non German Audio.py` (ffmpeg `-map 0:v:0 -map 0:a:0
-c copy -map_metadata -1`, + `.DL.`→`.` Rename). Soll als **toggle­barer Post-Extract-Schritt**
nach jedem Entpacken laufen, nur für **MKV/MP4 mit `.DL.` im Namen** (Dual-Language),
und nur die **deutsche** Spur behalten. Fundiert per 6-Agent-Analyse + Advisor.
## 1. Verhalten (Soll)
- Läuft automatisch nach dem Entpacken eines Pakets (wenn Toggle an), bevor MKV-Collect.
- Pro extrahierter Video-Datei mit `.DL.` im Namen (case-insensitive, nur .mkv/.mp4):
1. Audiospuren prüfen → deutsche/erste Spur bestimmen (Modus = User-Entscheidung, s.u.).
2. Wenn >1 Audiospur: remux (stream-copy, kein Re-Encode) → behält Video + 1 Audio
(+ optional dt. Untertitel) → Temp-Datei → atomar ersetzen.
3. `.DL.` aus dem Dateinamen strippen (`.DL.`→`.`, `.DL`→``), Companion-Dateien (Untertitel/.nfo) mitziehen.
4. Wenn nur 1 Audiospur: **kein** Remux (spart Neuschreiben großer Dateien), ABER `.DL.`-Strip trotzdem.
- Status pro Item sichtbar (z.B. „Tonspur wird bereinigt" / „Deutsche Spur behalten").
## 2. Architektur
- **NEUES Modul `src/main/video-processor.ts`** (spiegelt `extractor.ts`: exportierte async-Funktion
+ Options-Bag, KEINE DI-Klasse — es gibt keinen Constructor-Seam). Enthält:
- ffmpeg/ffprobe-Spawn nach dem `runExtractCommand`-Muster (extractor.ts:1296): `spawn(cmd,args,{windowsHide:true})`,
Promise-Wrapper, Timeout-Watchdog → `killProcessTree` (taskkill /T /F), **AbortSignal IN den Child** geben.
- **Pure exportierte Helfer** für Unit-Tests: `pickGermanAudioTrack(probeJson, mode)`, `stripDualLangMarker(name)`,
`buildFfmpegRemuxArgs(...)`, `computeRemuxTimeoutMs(bytes)`.
- ffmpeg-Exit-Codes ≠ 7-Zip (NICHT die „exit 1 = ok"-Logik kopieren — nur das Spawn/Await/Kill-Gerüst).
- ffprobe-JSON auf stdout NICHT durch den 48KB-Tail-Cap (`appendLimited`) — stdout separat voll puffern.
- **ffmpeg-Discovery (Option a, empfohlen):** System-PATH + `RD_FFMPEG_BIN` env + lazy `ffmpeg -version`-Probe
gecacht (spiegelt `RD_7Z_BIN`, extractor.ts:1030-1083). **Nicht bündeln** (~80-150MB → triggert den
eigenen 150MB-Large-Bundle-Selfcheck debug-setup.ts:22 + GPL-Lizenzpflicht). Wenn ffmpeg fehlt → Schritt
überspringen + WARN loggen + (optional) in Health-Check/Errors surfacen. NIE Downloads blockieren.
- **CPU-Priorität:** `lowerExtractProcessPriority(pid, priority)` + `extractOsPriority` wiederverwenden,
Priorität als **expliziten Param** (nicht das Modul-Global `currentExtractCpuPriority` — Cross-Talk-Gefahr).
Honoriert `settings.extractCpuPriority`.
## 3. Einhängepunkte (BEIDE Pfade — kritisch!)
Post-Processing ist **pro Paket**, zwei Pfade; Hybrid-Pakete durchlaufen NIE den Deferred-Pass:
- **Deferred** (download-manager.ts ~11614): nach `autoRenameExtractedVideoFiles`, VOR archive-cleanup/collect.
- **Hybrid** (download-manager.ts ~10944): zwischen Rename und Collect im detached Block.
- Beide: **innerhalb `chainPackageFileOp(pkg.id, ...)`** (serialisiert Datei-Ops pro Paket), nur auf
`pkg.extractDir` operieren — NIE im geteilten `mkvLibraryDir` (= der v1.7.107-revertierte Cross-Package-Crash;
autoRename bricht bei Overlap ab, 3905-3919).
- **Gate:** neuen Flag in den Post-Process-Aggregator OR-en (~7078-7084), sonst läuft der Schritt nie
standalone. Hängt inhärent an `autoExtract` (braucht entpackte Dateien).
- Datei-Enumeration: `collectVideoFiles(rootDir)` (rekursiv, SAMPLE_VIDEO_EXTENSIONS, constants.ts:28) — nur
.mkv/.mp4 verarbeiten; Sample/Bonus-Dateien per vorhandenem Skip-Prädikat auslassen.
## 4. Der .DL.-Knoten (LÖST den „Feature no-op"-Fehler)
- Selektion = „Datei hat `.DL.`"; der Schritt strippt `.DL.`. → KEIN früherer Schritt darf den Marker entfernen.
- **autoRename NICHT ändern** (behält `.DL.` verbatim) → Marker überlebt bis zum Video-Schritt.
- Video-Schritt läuft **nach** autoRename → sieht `.DL.` → remuxt + strippt `.DL.` atomar pro Datei.
- **NUR `collectMkvFilesToLibrary.deriveCleanCollectFileName`** bekommt den `.DL.`-Strip als Post-Transform
(läuft NACH dem Video-Schritt → kann den Selektor nicht brechen, verhindert nur Re-Einführung aus dem
Ordner-Token). Companion-Files via `renameCompanionFiles`/`moveCompanionFiles` mitziehen.
## 5. Sicherheitsmodell (Original NIE verlieren)
- Remux → Temp-Datei → Größe > 0 (idealerweise ~plausibel) prüfen → erst dann atomar ersetzen/umbenennen
(`renamePathWithExdevFallback` + `verifyRenameAsync`). ffmpeg-Fehler/Abbruch → Temp löschen, Original bleibt.
- **Disk-Space-Pre-Check**: vor Remux freien Platz ≥ Dateigröße (+Marge) prüfen, sonst skip+log
(Temp verdoppelt transient den Platz auf einer Platte, die grad entpackt hat / parallel lädt).
- **AbortSignal in den ffmpeg-Child** (Deferred-/Hybrid-Controller) → Stop/Cancel/Reset killt laufenden Remux.
- **mtime erhalten** (`fs.utimes` nach Remux) → sonst überspringt Hybrid-Collect (deferFreshFiles=true) die
frisch angefasste Datei.
- **Sicherheits-Invariante (BEIDE Modi):** Original nur ersetzen, wenn die behaltene Spur sicher die richtige
ist. Bei Unsicherheit (keine Tags / kein Deutsch gefunden) → Datei UNANGETASTET lassen + loggen, statt
versehentlich die einzige brauchbare Spur zu löschen.
- Dispositions-Flag der behaltenen Spur auf „default" setzen.
- Best-effort pro Datei: ein Fehler markiert NICHT das Paket als failed und blockiert nicht den Collect anderer Dateien.
## 6. ffmpeg/ffprobe-Aufrufe (Stream-Copy, schnell)
- Probe (nur im Tag-Modus): `ffprobe -v error -select_streams a -show_entries stream=index:stream_tags=language,title -of json INPUT`
- Remux erste Spur (Script-Parität): `ffmpeg -i INPUT -map 0:v:0 -map 0:a:0 [-map 0:s? je nach Untertitel-Option] -c copy -map_metadata -1 -disposition:a:0 default -y TEMP`
- Remux deutsche Spur (Tag-Modus): `-map 0:v:0 -map 0:a:<dt-Index> ...` (Index aus ffprobe).
## 7. Settings/UI-Wiring (5 Pflicht-Stellen, +1 optional)
1. `src/shared/types.ts` AppSettings: `keepGermanAudioOnly: boolean` (+ ggf. `germanAudioMode`, `keepGermanSubs`, `ffmpegPath`).
2. `src/main/constants.ts` defaultSettings: `keepGermanAudioOnly: false` etc.
3. `src/main/storage.ts` normalizeSettings: `Boolean(...)` (Pfad: `asText`, NICHT normalizeAbsoluteDir → leer = System-ffmpeg).
4. `src/renderer/App.tsx` Settings-Tab „entpacken" neben collectMkvToLibrary: Toggle + eingerückte Sub-Optionen (disabled wenn aus).
5. `src/renderer/App.tsx` **emptySnapshot()-Literal** (~840-859) — sonst tsc-Fehler (Feld non-optional).
6. (optional) `src/main/support-data.ts` ~95: Flag in Diagnose-Export spiegeln.
## 8. Tests + Verifikations-Gate
- ffmpeg in Tests **gemockt** (kein echter ffmpeg-Lauf): neues Modul via `vi.mock` in download-manager.test.ts
(assert: korrekt aufgerufen + Sequenz nach autoRename / vor collect, Deferred + Hybrid). KEIN blankes
`vi.mock("node:child_process")` in download-manager.test.ts (bricht echte Extractor-ZIP-Tests).
- Separate `video-processor.test.ts`: `node:child_process` mocken → ffmpeg/ffprobe-ARGS asserten (Track-Wahl, Untertitel-Option).
- Pure Helfer fs-frei testen (wie tests/auto-rename.test.ts): `pickGermanAudioTrack`, `stripDualLangMarker`.
- Negativ-Test: Toggle aus → keine Verarbeitung. Edge: 1-Audio-`.DL.` → nur Rename, kein Remux. Kein-Deutsch → unangetastet.
- **Gate:** tsc-Baseline = 6 vorbestehende Fehler (NICHT clean) → „keine NEUEN tsc-Fehler" + vitest 728→728+N grün + `npm run self-check` grün.
## 9. OFFENE ENTSCHEIDUNGEN (vor Bau — per AskUserQuestion)
- **A. Spurauswahl:** Script-Parität (immer erste Audiospur, kein ffprobe, validiertes Verhalten) vs.
Smart (deutsche Spur per Sprach-Tag, Fallback erste Spur, skip wenn kein Deutsch).
- **B. Untertitel:** weglassen (wie Script) vs. deutsche Untertitel behalten.
- **C. ffmpeg-Quelle:** nur System-PATH + `RD_FFMPEG_BIN` env vs. zusätzlich Settings-Pfad-Feld im UI.
## 10. Umsetzungsreihenfolge (nach Entscheidungen)
1. `video-processor.ts` + pure Helfer + deren Unit-Tests (TDD).
2. ffmpeg/ffprobe-Discovery (probe+cache).
3. Settings-Wiring (5 Stellen) + UI-Toggle.
4. Einhängen in Deferred + Hybrid (in chainPackageFileOp), Gate OR-en.
5. collect deriveCleanCollectFileName: `.DL.`-Strip-Safety-Net.
6. Logging (logRenameProcess, neuer Stage 'audio-strip').
7. Tests (download-manager mock + video-processor args + negativ/edge). Gate prüfen.

View File

@ -1,164 +0,0 @@
# Real-Debrid-Downloader — Tasks (Stand 2026-06-08)
**Status:** Alle zugesagten Features erledigt+released (Archiv unten). Aktuell läuft ein
**intensiver Bug-Audit** (User-Goal 2026-06-08, "schaue intensiv nach weiteren Bugs") —
Fortschritt direkt unten.
---
## 🔴 LAUFEND — Bug-Audit 2026-06-08 (Multi-Agent find→verify, 18 bestätigt)
Advisor-Triage: **A = einzige echte Daten-Verlust-Notlage** (zerstört echte Datei auf Platte)
→ zuerst, ALLEINE Release. **B verifiziert demoted:** applyRetroactiveCleanupPolicy/
removePackageFromSession löschen KEINE Platten-Dateien (nur Session/Queue-Einträge + ggf.
History-Eintrag) → Queue-Integrität, nicht Daten-Verlust → in v1.7.190-Batch.
Sequenz: Release 1 (v1.7.189) = **A allein**; Release 2 (v1.7.190) = B/I,C,D/E,F,G,H,J,L,M,N,O,P,Q.
Ein Commit pro Fix, jeder einzeln verifiziert. **K übersprungen** (auto-rename-Reorder,
schlechtestes Risiko/Nutzen, kann für diesen User gar nicht feuern).
### Release 1 — Daten-Verlust-Stopper (v1.7.189, A ALLEIN)
- [x] **A** `video-processor.ts` atomic-replace zerstörte bei Windows-Lock BEIDE Kopien
(rm(original) VOR bestätigtem Replace + outer-catch rm(temp) → 0 Kopien). **GEFIXT:**
atomic replace-over + `renameWithRetry` (EBUSY/EACCES/EPERM/EEXIST, Backoff 200/500/1000ms),
rm-first-Fallback entfernt, **unique** Temp-Name (`~rd<pid><rand>`, löst auch C-Kollision).
Advisor bestätigt Ansatz besser als bak-dance (kein Missing-File-Window). 3 neue Tests
(Recovery + Retry-Pfad), 41 video-processor-Tests grün, tsc=6 (Baseline). Commit 189af22.
### Release 2 — v1.7.190 (GEFIXT + verifiziert, ein Commit pro Fix)
- [x] **L+M** video-processor.ts zu weite Deutsch-Erkennung. isGermanStream Titel-Fallback nur
ganze Wörter (ger/deu raus → konnten falsche Spur picken + echte dt. löschen); looksLikeGerman
Release 'dubbed' raus (ital./franz. Dub triggerte German-first). 2 Negativtests. Commit 272a41a.
- [x] **H** logger.ts flushAsync slice-snapshot korrumpiert bei 1MB-Cap-Trim während await →
ungeschriebene Zeilen verloren. Move-snapshot (Buffer auf [] übernehmen) + Requeue bei
Schreibfehler. Commit 4432fa2.
- [x] **J+Q** download-manager. J: runPackagePostProcessing finally löschte Map-Eintrag ohne
Identity-Guard → Abort+Neustart-Race riss neuen Task raus (Waise + Doppel-Lauf); jetzt nur
löschen wenn Map noch auf DIESEN Task/Controller zeigt (handle-Objekt wegen TS2454). Q:
collectFilesByExtensions filtert `~rd`-Temp-Präfix (crash-verwaiste Teil-Remuxe nie ins
Library). Commit 3c33b98.
- [x] **P** extractor.ts nested-Resume-Keys (`nested:<name>`) bei jedem extractPackageArchives
gepurged → verschachtelte Archive beim Resume neu entpackt; `startsWith("nested:")` im Prune
übersprungen. Commit 61a8304.
- [x] **B/I** app-controller.ts importBackup settings-only purgte LIVE-Queue (Dateien blieben auf
Platte) + rollte Usage-Zähler zurück. Fix: setSettings({suppressRetroactiveCleanup}) +
overlayLiveUsageCounters (extrahiert+wiederverwendet, inkl. Key-Filter). Commit dc05b51.
### Verifiziert KEINE Bugs / bewusst NICHT angefasst (Advisor-Disziplin: erst belegen, dann ändern)
- **G** dropItemContribution "subtrahiert Session-Totals nicht" → **KEIN Bug**: Test "keeps
cumulative session totals when completed items are removed" kodifiziert die Absicht (Session-
Zähler kumulativ, divergieren bewusst von der Item-Map; Retry-Pfad zieht ab, weil neu geladen
wird). Fix-Versuch ließ den Test failen → revertiert, Klarstellungs-Kommentar gesetzt.
- **N** stripDualLangFromFileName "Kollision" → **bereits geguarded**: existsAsync-Skip verhindert
Überschreiben; Remux machte Inhalt eh deutsch-only; collect strippt `.DL.` downstream. Residual
= generischer Rename-TOCTOU (in JEDEM Rename-Pfad), kein spezifischer Bug hier.
- **D/E** abort-Klassifizierung über signal.reason statt Text → **deferred (Robustheit, kein
Live-Bug auf User-Pfad)**. BELEGT: mega-web-fallback normalisiert JEDEN Abort (Timeout UND
Cancel) zu `new Error("aborted:mega-web")` → aktueller Guard `/aborted/i && !/timeout/i` FEUERT
→ v1.7.187-Cooldown LÄUFT auf dem Web-Pfad (User-Pfad). Einzige Imperfektion: Cancel >8s wird
fälschlich gecooled (minor). Empirisch bestätigt: `AbortSignal.any([ac,timeout]).reason?.name===
'TimeoutError'` (timeout) vs string/AbortError (cancel) — falls je gebaut: signal.aborted-gaten,
reason.name nutzen, Text-Fallback behalten, reason-Test. Hoch-Risiko (kritischer Unrestrict-Pfad
JEDES Downloads) → nicht für Robustheit anfassen. API-Pfad-Abort-Text nicht erschöpfend geprüft.
- **E** "API 'cancel'-Pfad umgeht" → **nicht real**: kein `'cancel'`-throw im Code gefunden.
- **O** classifyAccountFailure abort-Branch tot → **stehen lassen**: tot NUR wegen aktueller
Text-Interception; ein signal.aborted-gated D/E würde ihn wiederbeleben. Kein Kosmetik-Churn.
- **F** Mega-Web empty-streak Concurrency → **N-shaped, deferred**: Streak wird bei Erfolg (1956)
+ Nicht-Limit-Fehler (2005) gecleart; "bis Neustart gesperrt" ist bewusste Tageslimit-Logik,
Restart-cleared; Mega-Web single-flight → Concurrency greift nicht. Keine fühlbare Schädigung
konstruierbar → keine Park-State-Maschinerie.
- **C** → in A subsumiert (unique Temp-Name). **K** übersprungen (auto-rename-Reorder, Risiko≫Nutzen).
---
## 🟢 OFFEN — Backlog (optional, nie begonnen)
### ✅ Mega-Web Account-Rotation überspringt Account 3 — GEFIXT 2026-06-08 (v1.7.187)
**Fix:** Ein Mega-Web-Account-Abbruch (geteiltes Timeout feuert während der Account lief)
setzt jetzt einen 2-min-Cooldown auf den Account (nur wenn er ≥8s lief, sonst = User-Cancel,
RD_MEGA_ABORT_MIN_RUN_MS env). Dadurch überspringt der download-manager-Retry diesen Account
und rotiert zum nächsten (debrid.ts, abort-Handling im Rotations-catch, vor classifyAccountFailure).
Log-Event `TIMEOUT_COOLDOWN` (gelb, "Timeout/Abbruch → nächster Account beim Retry") statt
rotem "fataler Fehler" (App.tsx:1141 Label). 2 Regressionstests (Cooldown gesetzt → Call 2
rotiert; Quick-Abbruch → kein Cooldown). EHRLICH: fixt Korrektheit, NICHT Latenz — Account 1
brennt weiter ~60s ins Timeout bevor der Retry auf Account 2 wechselt (instant-Failover bräuchte
per-Account-Timeout = größerer Eingriff, bewusst verschoben). Advisor-gegengeprüft.
**(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)**
**Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen
Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert.
**Verifizierter Mechanismus (Code):**
- Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s →
weiter zu Account 2. Account 2 → `aborted:debrid`.
- `classifyAccountFailure` (`debrid.ts:2036`) stuft JEDEN Abbruch als **fatal** ein →
`throw` (`debrid.ts:1991`) → Schleife bricht ab → **Account 3 nie erreicht.**
- Account 2 bekommt beim Fatal-Abbruch **keinen Cooldown** (cooldownMs:0). Beim
download-manager-Retry wird Account 1 (Cooldown) übersprungen, aber Account 2 (kein
Cooldown) ERNEUT vor Account 3 probiert → bricht wieder ab → ewiges 1↔2.
- Geteiltes 60s-Unrestrict-Timeout `download-manager.ts:8590` (`AbortSignal.any([taskAbort,
timeout(60s)])`) gilt für die GANZE Rotation, nicht pro Account. Mega-Web pollt intern bis
180s (`mega-web-fallback.ts:235` + Poll-Loop `:371`). Sobald das geteilte 60s feuert, bleibt
das kombinierte Signal aborted → KEIN späterer Account kriegt im selben Pass eine echte Chance.
**BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich"
(11:51:4511:52:26), dann zwei "abgebrochen (aborted:debrid)" um 11:53:30 UND 11:54:30 —
**exakt 60s auseinander** = das geteilte 60s-Unrestrict-Timeout feuert (kein User-Stop, der
wiederholt sich nicht periodisch). Hier rotiert GAR NICHTS: Account 1 bricht ab → fatal →
Rotation stoppt sofort bei idx=0 → Account 2 und 3 werden NIE probiert. Bug eindeutig
bestätigt, elapsedMs nicht mehr nötig. Account 1 selbst ist gesund (10x ok) — Mega-Web hängt
nur sporadisch (no-server-Poll) bis ins 60s-Timeout.
**Fix-Design (wenn bestätigt):** Pro-Account-Timeout-Budget, abgekoppelt vom geteilten Cap.
debrid.ts braucht das **cancel-only** Signal getrennt vom Timeout (kombiniertes Signal kann
beides nicht unterscheiden). Minimal-invasiv: optionaler `opts`-Param an `unrestrictLink`
({cancelSignal, perAttemptTimeoutMs}) — nur die Mega-Rotation liest ihn, andere Provider
unberührt (kombiniertes Signal bleibt). Pro Account: `AbortSignal.any([cancelSignal,
AbortSignal.timeout(perAttemptMs)])`. Abbruch-Logik: cancelSignal aborted → echter Stop;
eigenes Account-Timer gefeuert → non-fatal, Cooldown, weiter zum nächsten Account (inkl. 3).
**Regressionstest ZUERST** (3 Accounts, 1+2 failen/aborten → assert Account 3 kriegt TEST).
**Advisor-Gate** vor Eingriff (kritischer Unrestrict-Pfad, betrifft jeden Download).
Hinweis: Grundursache der leeren Antworten = Mega-Debrid Server/IP-Thema — Fix macht Rotation
nur FAIRER (alle Accounts drankommen), bringt aber keinen busy Server zum Antworten.
### Features / UX (nach ROI)
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor.
1. [ ] **Push-Benachrichtigungen** (Discord/Telegram/ntfy) — SM. Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.**
2. [ ] **Fernsteuerung über Debug-Server** (POST-Endpunkte) — SM. Server hat HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop`.
3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird nie geprüft → versehentliche Re-Downloads. Warnen: "3 Links bereits geladen".
4. [ ] **Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen".
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung für Downloads → Abbruch mitten drin bei voller Platte.
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen". (Daten dafür liegen jetzt teils in der Error-Ring aus v1.7.185.)
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nicht dargestellt. Welches Abo lohnt sich?
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — SM. Quota/Cooldown-Fails am nächsten Tag automatisch neu.
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Gleicher Hook wie #1.
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M.
### Design-Richtung (Entscheidung steht aus)
4 Mockups in `design-mockups/` (index.html = Vergleich): **Aurora** (verfeinert dark, geringstes Risiko) · **Command** (Terminal/Ops, dicht) · **Vellum** (light editorial) · **Nebula** (neon).
→ Richtung wählen. Siehe Memory: design-taste (Anti-KI-Look) + design-direction (Ember-Wärme, flach/ehrlich).
### Alte Audit-Items (2026-04-04, Status ggf. veraltet — VOR Fix gegen aktuellen Code verifizieren)
- [ ] Debrid-Link `maxDataHost` kühlt ganzen Key ab statt nur den Host
- [ ] Debrid-Link `fileNotAvailable` setzt Key auf "error" statt temporär
- [ ] AllDebrid: kein per-host-Cooldown für erschöpfte Quotas
- [ ] LinkSnappy: keine Auth-Dedup (parallele Requests rufen beide authenticate())
- [ ] Extractor password-cache race (parallele Worker mutieren `packageLearnedPasswords`)
- [ ] Hybrid race: 1 Datei/Staffel evtl. beim MKV-Move nicht umbenannt (NUR per-package fixen — Post-MKV-Move-Scan ist tabu, v1.7.107 revertiert)
---
## ✅ ERLEDIGT — Archiv (Details in git-History + Memory)
- **Erweitertes Logging** → released **v1.7.185** (Crash-Handler, Renderer-Fehler-IPC, RD_DEBUG-Level, Error-Ring + `/errors`, ENOSPC-Klassifizierung, Memory-Heartbeat). → Memory: extended-logging
- **Link-Prefetch** → untersucht (6-Agent) + **bewusst verworfen** (marginal bei maxParallel 8, Mega-Web single-flight). → Memory: link-prefetch-declined
- **Backup nur Settings** → v1.7.184 (`backupIncludeDownloads`-Toggle + 4 Selektions/Flicker-Fixes). → Memory: backup-settings-only
- **Account-Rotation-Overhaul** → v1.7.164168 (Validity/Premium-Badges, Live-Panel, "Alle prüfen"). → Memory: account-rotation
- **Mega-Debrid-Account deaktivieren (UI)** → erledigt (Toggle im Edit-Dialog, im Code verifiziert 2026-06-07)
- **Bugs/Robustheit (Deferred-Pipeline H1/H2/H3/M1/M2/N1)** → v1.7.158/159; M3 bewusst übersprungen (Generation-Guard schützt Integrität bereits)
- **Deferred-Pfad Rename-Gap** → gefixt v1.7.162+ (finaler Deferred-Pass benennt frische Dateien vor Collect um; Repro-Test grün)
- **Repo-Privacy-Audit** → GitHub gelöscht+neu (saubere History), Gitea unberührt. → Memory: repo-privacy-audit
### Bewusst NICHT angefasst (Crash-Debris / alte Experimente)
- Gestashtes Crash-Debris `stash@{0}` (Revert von 08372f9/18eada9/98dc366 + log.old) — bei Bedarf recoverbar, sonst verwerfbar
- Untracked `*-postprocess/` + `fix-library-renames.mjs` — alte Experimente (Apr/Mai)

View File

@ -1,161 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
import type { AppSettings } from "../src/shared/types";
function megaAccount(login = "user@example.com"): MegaDebridAccountEntry {
return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" };
}
function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry {
return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" };
}
function mockFetchOnce(status: number, body: unknown): void {
const text = typeof body === "string" ? body : JSON.stringify(body);
vi.stubGlobal("fetch", vi.fn(async () => ({
ok: status >= 200 && status < 300,
status,
text: async () => text
})) as unknown as typeof fetch);
}
const NOW = 1_700_000_000_000;
afterEach(() => {
vi.unstubAllGlobals();
});
describe("checkMegaDebridAccount", () => {
it("reports valid + premium from vip_end (future Unix ts)", async () => {
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60;
mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(true);
expect(st.premiumUntilMs).toBe(futureSec * 1000);
expect(st.email).toBe("a@b.de");
expect(st.message).toMatch(/Premium noch/);
});
it("reports valid but NOT premium when vip_end is in the past", async () => {
const pastSec = Math.floor(NOW / 1000) - 1000;
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: String(pastSec) });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
});
it("reports valid but no premium when vip_end is 0/missing", async () => {
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: "0" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
expect(st.premiumUntilMs).toBe(0);
expect(st.message).toMatch(/Kein Premium/);
});
it("reports invalid login when response_code != ok", async () => {
mockFetchOnce(200, { response_code: "error", response_text: "bad login" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.isPremium).toBe(false);
expect(st.message).toMatch(/Ungueltiger Login/);
});
it("reports invalid on HTTP error", async () => {
mockFetchOnce(500, "server error");
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
});
it("never throws on network error — returns a failed status", async () => {
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNRESET"); }) as unknown as typeof fetch);
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.message).toMatch(/Pruefung fehlgeschlagen/);
});
});
describe("checkDebridLinkKey", () => {
it("reports valid + premium from premiumLeft seconds", async () => {
const premiumLeft = 60 * 24 * 60 * 60;
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(true);
expect(st.premiumUntilMs).toBe(NOW + premiumLeft * 1000);
});
it("reports valid but free (premiumLeft 0, accountType 0)", async () => {
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 0, premiumLeft: 0 } });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
expect(st.message).toMatch(/Free/);
});
it("reports invalid key on HTTP 401", async () => {
mockFetchOnce(401, { success: false, error: "badToken" });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.message).toMatch(/Ungueltiger API-Key/);
});
it("reports invalid key when success=false", async () => {
mockFetchOnce(200, { success: false, error: "badToken" });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(false);
});
});
describe("checkAllDebridAccounts", () => {
it("returns empty array when nothing configured", async () => {
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toEqual([]);
});
it("checks every configured mega account + debrid-link key", async () => {
const futureSec = Math.floor(Date.now() / 1000) + 1000;
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
if (String(url).includes("mega-debrid")) {
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "t", vip_end: String(futureSec) }) };
}
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
}) as unknown as typeof fetch);
const settings = {
megaCredentials: "a@b.de:pw1\nc@d.de:pw2",
megaPassword: "",
debridLinkApiKeys: "key1\nkey2\nkey3"
} as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toHaveLength(5);
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
expect(result.every((r) => r.valid)).toBe(true);
});
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
let inFlight = 0;
let maxInFlight = 0;
vi.stubGlobal("fetch", vi.fn(async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
}) as unknown as typeof fetch);
const keys = Array.from({ length: 9 }, (_, i) => `key_${i}`).join("\n");
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: keys } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toHaveLength(9);
expect(maxInFlight).toBeLessThanOrEqual(4);
result.forEach((r, i) => expect(r.label).toBe(`Key ${i + 1}`));
});
});

View File

@ -1,57 +0,0 @@
import { describe, it, expect } from "vitest";
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
import type { RotationEvent } from "../src/shared/types";
describe("rotation item-sink (AsyncLocalStorage)", () => {
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
const captured: RotationEvent[] = [];
await runWithRotationItemSink((ev) => captured.push(ev), async () => {
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "TEST", { link: "x" });
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" });
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" });
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
await Promise.resolve();
});
const events = captured.map((e) => e.event);
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
const failed = captured.find((e) => e.event === "FAILED");
expect(failed?.reason).toBe("Timeout");
expect(failed?.next).toBe("Account 2/3 (cd**zw)");
});
it("does not leak events to the sink outside the run() scope", () => {
const captured: RotationEvent[] = [];
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
expect(captured).toHaveLength(0);
});
it("isolates two parallel item sinks (no cross-attribution)", async () => {
const a: RotationEvent[] = [];
const b: RotationEvent[] = [];
await Promise.all([
runWithRotationItemSink((ev) => a.push(ev), async () => {
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "TEST");
await new Promise((r) => setTimeout(r, 10));
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "OK");
}),
runWithRotationItemSink((ev) => b.push(ev), async () => {
logAccountRotation("INFO", "Debrid-Link", "Key 1 (b)", "TEST");
await new Promise((r) => setTimeout(r, 5));
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
})
]);
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
});
it("still feeds the global UI ring (outcomes only, TEST filtered)", () => {
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
const ring = getRecentRotationEvents(10);
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
});
});

View File

@ -6,223 +6,9 @@ import {
ensureRepackToken,
buildAutoRenameBaseName,
buildAutoRenameBaseNameFromFolders,
buildAutoRenameBaseNameFromFoldersWithOptions,
hasMeaningfulSeriesPrefix,
looksLikeObfuscatedSceneFileName,
decideAutoRenameBaseName,
isBonusContent
buildAutoRenameBaseNameFromFoldersWithOptions
} from "../src/main/download-manager";
describe("decideAutoRenameBaseName (shared naming decision — used by auto-rename AND mkv-collect)", () => {
it("derives the clean name for a Herzflimmern episode from the per-episode folder (S07E12 — the reported failure)", () => {
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
const folders = [
"Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV",
"Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"
];
const decision = decideAutoRenameBaseName(
folders,
source,
"tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720",
folders[0],
folders[1]
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe("Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV");
});
it("derives the clean name from a SEASON-only folder by injecting the source episode token (Herzflimmern S03E14)", () => {
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s03e14-720.mkv";
const seasonFolder = "Herzflimmern.die.Klinik.am.See.S03.German.720p.Webrip.x264-TVARCHiV";
const decision = decideAutoRenameBaseName(
[seasonFolder],
source,
"tvarchiv.herzflimmern.die.klinik.am.see.s03e14-720",
seasonFolder,
seasonFolder
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe("Herzflimmern.die.Klinik.am.See.S03E14.German.720p.Webrip.x264-TVARCHiV");
});
it("derives the clean name for the Fritzie S04 files that sat raw in Downloader Unfertig (4sf- scene group, season folder)", () => {
const source = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv";
const seasonFolder = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF";
const decision = decideAutoRenameBaseName(
[seasonFolder],
source,
"4sf-fritzie.himmel.muss.warten.web.7p-s04e01",
seasonFolder,
seasonFolder
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe("Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF");
});
it("is idempotent: an already-clean file in its clean folder derives to the same name (no worse-than-now)", () => {
const clean = "Herzflimmern.Die.Klinik.am.See.S07E02.German.720p.Webrip.x264-TVARCHiV";
const decision = decideAutoRenameBaseName(
[clean, "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"],
`${clean}.mkv`,
clean,
clean,
"Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe(clean);
});
it("GUARD: lets the parent folder token override an OBFUSCATED source filename (anti-piracy scramble)", () => {
const decision = decideAutoRenameBaseName(
["Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"],
"awa-diethundermans02e16hd.mkv",
"awa-diethundermans02e16hd",
"Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake",
"Die.Thundermans.S02.GERMAN.x264-aWake"
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toContain("S02E01");
});
it("GUARD: a CLEAN scene source is NEVER overridden by a mismatching folder token (folder is wrong, not the file)", () => {
const decision = decideAutoRenameBaseName(
["The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON"],
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv",
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f",
"The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON",
"The.Royals.2015.S01.German.DL.720p.BluRay.x264-iNTENTiON"
);
expect(decision.kind).toBe("skip");
expect(decision.kind === "skip" && decision.reason).toBe("token-mismatch");
});
it("skips (no-target) when no folder candidate yields a usable scene name", () => {
const decision = decideAutoRenameBaseName(
["random user folder", "another plain dir"],
"some.file.mkv",
"some.file",
"random user folder",
"another plain dir"
);
expect(decision.kind).toBe("skip");
});
it("uses the CLEAN per-episode folder (scene group WITH underscore, e.g. -idTV_iNT) — not the obfuscated package folder", () => {
const epFolder = "Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT";
const decision = decideAutoRenameBaseName(
[epFolder, "scn2-cstl7"],
"castle.s08e02.german.dl.720p.web.h264-idtv_int.mkv",
"castle.s08e02.german.dl.720p.web.h264-idtv_int",
epFolder,
"scn2-cstl7"
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe(epFolder);
});
it("uses the complete per-episode folder when the SOURCE has no SxxExx token (bare 'Folge 01' format)", () => {
const folders = [
"Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.720p.HDTV.x264-BET",
"kig.hdtv.7p-001",
"Kreuzfahrt ins Glück S01"
];
const decision = decideAutoRenameBaseName(folders, "bet_kig_01_hdt.mkv", "bet_kig_01_hdt", folders[0], folders[2]);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe(folders[0]);
});
it("complete-folder fallback fires on CODEC alone (no resolution token — DVDRip/XviD class)", () => {
const folders = [
"Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.DVDRip.x264-BET",
"Kreuzfahrt ins Glück S01"
];
const decision = decideAutoRenameBaseName(folders, "bet_kig_01.mkv", "bet_kig_01", folders[0], folders[1]);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe(folders[0]);
});
it("complete-folder fallback does NOT fire when the source HAS an episode token (generic pack stays no-target)", () => {
const decision = decideAutoRenameBaseName(
["Mega-Direct-Pack"],
"Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv",
"Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT",
"Mega-Direct-Pack",
"Mega-Direct-Pack"
);
expect(decision.kind).toBe("skip");
});
});
describe("hasMeaningfulSeriesPrefix", () => {
it("recognizes a real series name before the season token", () => {
expect(hasMeaningfulSeriesPrefix("Desperate.Housewives.S01.Synced.DL.720p.WEB-DL.AC3.h264")).toBe(true);
expect(hasMeaningfulSeriesPrefix("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake")).toBe(true);
expect(hasMeaningfulSeriesPrefix("Mistresses.2013.S02.GERMAN.DL.720p.WEB.x264-TSCC")).toBe(true);
expect(hasMeaningfulSeriesPrefix("show.name.s01e01.720p")).toBe(true);
});
it("rejects generic season-label folders without a series name", () => {
expect(hasMeaningfulSeriesPrefix("S01 Complete")).toBe(false);
expect(hasMeaningfulSeriesPrefix("S02")).toBe(false);
expect(hasMeaningfulSeriesPrefix("S01E01 Complete")).toBe(false);
expect(hasMeaningfulSeriesPrefix(".S01.bla")).toBe(false);
});
it("returns false when there is no season token at all", () => {
expect(hasMeaningfulSeriesPrefix("Some Random Folder")).toBe(false);
expect(hasMeaningfulSeriesPrefix("")).toBe(false);
});
});
describe("looksLikeObfuscatedSceneFileName", () => {
it("flags hoster-obfuscated names with no scene markers as obfuscated", () => {
expect(looksLikeObfuscatedSceneFileName("awa-diethundermans02e16hd.mkv")).toBe(true);
expect(looksLikeObfuscatedSceneFileName("scn-dthund7-S02E06.mkv")).toBe(true);
expect(looksLikeObfuscatedSceneFileName("4sj-blue-bloods-s08e21-720p.mkv")).toBe(true);
});
it("treats clean scene releases with multiple markers as NOT obfuscated", () => {
expect(looksLikeObfuscatedSceneFileName("the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv")).toBe(false);
expect(looksLikeObfuscatedSceneFileName("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake.mkv")).toBe(false);
expect(looksLikeObfuscatedSceneFileName("Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB-DL.AC3.h264.mkv")).toBe(false);
});
it("handles edge cases (empty, very short)", () => {
expect(looksLikeObfuscatedSceneFileName("")).toBe(true);
expect(looksLikeObfuscatedSceneFileName("a.mkv")).toBe(true);
});
it("treats long dotted names as scene-style even with few markers", () => {
expect(looksLikeObfuscatedSceneFileName("Some.Show.With.Many.Tokens.S01E01.mkv")).toBe(false);
});
});
describe("extractEpisodeToken (extended formats)", () => {
it("recognizes the older xX format (capped at 2 episode digits)", () => {
expect(extractEpisodeToken("show.1x01.720p.mkv")).toBe("S01E01");
expect(extractEpisodeToken("show-2x05-hdtv.mkv")).toBe("S02E05");
expect(extractEpisodeToken("Show.Name.10x99.mkv")).toBe("S10E99");
expect(extractEpisodeToken("Show.Name.10x100.mkv")).toBeNull();
expect(extractEpisodeToken("Show.Name.S10E100.mkv")).toBe("S10E100");
});
it("does not falsely match resolution tokens like 1080x720", () => {
expect(extractEpisodeToken("show.1080p.mkv")).toBeNull();
expect(extractEpisodeToken("show.S01E01.1080p.mkv")).toBe("S01E01");
});
it("does not falsely match codec tokens like x264 / x265 (caps episode digits)", () => {
expect(extractEpisodeToken("Movie.x264-GROUP.mkv")).toBeNull();
expect(extractEpisodeToken("Movie.5x265.x265.mkv")).toBeNull();
expect(extractEpisodeToken("Show.S01E01.x265-GROUP.mkv")).toBe("S01E01");
});
it("does not falsely match common aspect ratios like 1920x1080", () => {
expect(extractEpisodeToken("Movie.1920x1080.mkv")).toBeNull();
});
});
describe("extractEpisodeToken", () => {
it("extracts S01E01 from standard scene format", () => {
expect(extractEpisodeToken("show.name.s01e01.720p")).toBe("S01E01");
@ -467,6 +253,7 @@ describe("buildAutoRenameBaseName", () => {
expect(result).toBeNull();
});
// Edge cases
it("handles 2160p quality token", () => {
const result = buildAutoRenameBaseName("Show.S01.2160p-4sf", "show.s01e01.rp.2160p.mkv");
expect(result).toBe("Show.S01E01.REPACK.2160p-4sf");
@ -484,10 +271,12 @@ describe("buildAutoRenameBaseName", () => {
it("handles high season and episode numbers", () => {
const result = buildAutoRenameBaseName("Show.S99.720p-4sf", "show.s99e999.720p.mkv");
// SCENE_EPISODE_RE allows up to 3-digit episodes and 2-digit seasons
expect(result).not.toBeNull();
expect(result!).toContain("S99E999");
});
// Real-world scene release patterns
it("real-world: German series with dots", () => {
const result = buildAutoRenameBaseName(
"Der.Bergdoktor.S18.German.720p.WEB.x264-4SJ",
@ -552,13 +341,18 @@ describe("buildAutoRenameBaseName", () => {
expect(result).toBe("Cobra.Kai.S06E14.720p.NF.WEB-DL.DDP5.1.x264-4SF");
});
// Bug-hunting edge cases
it("source filename extension is not included in episode detection", () => {
// The sourceFileName passed to buildAutoRenameBaseName is the basename without extension
// so .mkv should not interfere, but let's verify with an actual extension
const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e01.mkv");
// "mkv" should not be treated as part of the filename match
expect(result).not.toBeNull();
expect(result!).toContain("S01E01");
});
it("does not match episode-like patterns in codec strings", () => {
// h.265 has digits but should not be confused with episode tokens
const token = extractEpisodeToken("show.s01e01.h.265");
expect(token).toBe("S01E01");
});
@ -576,19 +370,23 @@ describe("buildAutoRenameBaseName", () => {
"Show.S01E05.720p-4sf",
"show.s01e05.720p"
);
// Must NOT produce "Show.S01E05.720p.S01E05-4sf" (double episode bug)
expect(result).toBe("Show.S01E05.720p-4sf");
});
it("handles folder with only -4sf suffix (edge case)", () => {
const result = buildAutoRenameBaseName("-4sf", "show.s01e01.mkv");
// Extreme edge case - sanitizeFilename trims leading dots
expect(result).not.toBeNull();
expect(result!).toContain("S01E01");
expect(result!).toContain("-4sf");
expect(result!).not.toContain(".S01E01.S01E01");
expect(result!).not.toContain(".S01E01.S01E01"); // no duplication
});
it("sanitizes special characters from result", () => {
// sanitizeFilename should strip dangerous chars
const result = buildAutoRenameBaseName("Show:Name.S01-4sf", "show.s01e01.mkv");
// The colon should be sanitized away
expect(result).not.toBeNull();
expect(result!).not.toContain(":");
});
@ -852,6 +650,7 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
expect(result).toBe("Mammon.S01E05E06.German.1080P.Bluray.x264-SMAHD");
});
// Last-resort fallback: folder has season but no scene group suffix (user-renamed packages)
it("renames when folder has season but no scene group suffix (Mystery Road case)", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
["Mystery Road S02"],
@ -879,6 +678,7 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
"myst.road.de.dl.hdtv.7p-s02e05",
{ forceEpisodeForSeasonFolder: true }
);
// Should use the scene-group folder (hrs), not the custom one
expect(result).toBe("Mystery.Road.S02E05.GERMAN.DL.AC3.720p.HDTV.x264-hrs");
});
@ -962,128 +762,4 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
);
expect(result).toBe("9JKL.S01E14.GERMAN.720p.WEB.x264-WvF");
});
it("documents malformed package name (S01GERMAN) limitation", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
[
"3MH.web.7p-101",
"Drei.Meter.ueber.dem.Himmel.S01GERMAN.DL.720P.WEB.X264-WAYNE"
],
"Drei.Meter.ueber.dem.Himmel.S01E01.GERMAN.DL.720P.WEB.X264-WAYNE",
{ forceEpisodeForSeasonFolder: true }
);
if (result !== null) {
expect(typeof result).toBe("string");
}
});
});
describe("isBonusContent (numbered episodes are never bonus)", () => {
const pkgDir = "/pkg/Show.S04.GERMAN.DL.720p.WEB.x264-GRP";
it("does NOT treat a numbered episode as bonus even when its TITLE is a bonus word", () => {
const name = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
const fp = `${pkgDir}/${name}/${name}.mkv`;
expect(isBonusContent(fp, pkgDir, name)).toBe(false);
});
it("covers further bonus-word episode titles with a token", () => {
for (const title of ["Special", "Featurette", "Outtakes", "Bloopers", "Making.Of"]) {
const name = `Show.S04E07.${title}.GERMAN.720p.WEB.x264-GRP`;
expect(isBonusContent(`${pkgDir}/${name}.mkv`, pkgDir, name)).toBe(false);
}
});
it("STILL treats genuine extras WITHOUT an episode token as bonus", () => {
for (const name of [
"Show.Making.Of.GERMAN.720p.WEB.x264-GRP",
"Show.Behind.The.Scenes.GERMAN-GRP",
"Some.Interview.With.Cast"
]) {
expect(isBonusContent(`${pkgDir}/${name}.mkv`, pkgDir, name)).toBe(true);
}
});
it("a token-bearing file inside an Extras subfolder is still kept (numbered episode wins)", () => {
const name = "Show.S04E19.Interview.GROUP";
const fp = `${pkgDir}/Extras/${name}/${name}.mkv`;
expect(isBonusContent(fp, pkgDir, name)).toBe(false);
});
it("a token-less file inside an Extras subfolder is bonus", () => {
const fp = `${pkgDir}/Extras/Making.Of.mkv`;
expect(isBonusContent(fp, pkgDir, "Making.Of")).toBe(true);
});
});
describe("complete episode folder WITHOUT group suffix (codec/resolution only)", () => {
const hash = "c284d9d9072eaf3ac314d05f951dd115";
it("uses the clean folder name when it has an episode token + codec but no -GROUP (safari S04E08a)", () => {
const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
expect(decision).toEqual({ kind: "rename", baseName: folder });
});
it("keeps multi-part letters a/b distinct (Teil.1 vs Teil.2 do NOT collide)", () => {
const fa = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
const fb = "Fluss-Monster.S04E08b.Am.Essequibo.Teil.2.German.DOKU.SATRiP.XviD";
const da = decideAutoRenameBaseName([fa, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
const db = decideAutoRenameBaseName([fb, hash], "safari-fm-s04e08b.avi", "safari-fm-s04e08b", hash, hash);
expect(da).toEqual({ kind: "rename", baseName: fa });
expect(db).toEqual({ kind: "rename", baseName: fb });
expect((da as any).baseName).not.toBe((db as any).baseName);
});
it("the previously-working group-suffix folder still works (no regression)", () => {
const folder = "Fluss-Monster.S01E02.Auf.der.Suche.nach.dem.Killer-Wels.German.DOKU.SATRiP.XviD-SAFARi";
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s01e02.avi", "safari-fm-s01e02", hash, hash);
expect(decision).toEqual({ kind: "rename", baseName: folder });
});
it("does NOT use a bare episode folder WITHOUT any codec/resolution marker (stays conservative)", () => {
const decision = decideAutoRenameBaseName(["Show.S01E01", hash], "abc-s01e01.avi", "abc-s01e01", hash, hash);
expect(decision.kind).toBe("skip");
});
it("does NOT fabricate a name from a token-LESS folder (Mega-Direct guard intact)", () => {
const decision = decideAutoRenameBaseName(["Mega-Direct-Pack", hash], "Direct.Show.S01E01.DIRECT.mkv", "Direct.Show.S01E01.DIRECT", hash, hash);
expect(decision.kind).toBe("skip");
});
});
describe("collect must not mangle an already-clean SxxExx name via an episode-title folder", () => {
const hash = "c284d9d9072eaf3ac314d05f951dd115";
const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG";
const pkgFolder = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG";
const cleanSource = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG";
it("keeps the clean source (skip) instead of appending the token to the episode-title folder", () => {
const decision = decideAutoRenameBaseName([epFolder, pkgFolder], cleanSource + ".mkv", cleanSource, epFolder, pkgFolder);
expect(decision.kind).toBe("skip");
expect(JSON.stringify(decision)).not.toContain("GTVG.S01E01");
});
it("still cleans a JUNK/obfuscated source via an episode-title folder (append path intact, no skip)", () => {
const epFolder = "Show.E05.Die.Sache.German.720p.HDTV.x264-GRP";
const seasonFolder = "Show.S01.German.720p.HDTV.x264-GRP";
const decision = decideAutoRenameBaseName([epFolder, seasonFolder], "scn-show7-S01E05.mkv", "scn-show7-S01E05", epFolder, seasonFolder);
expect(decision.kind).toBe("rename");
expect(extractEpisodeToken((decision as any).baseName)).toBe("S01E05");
});
it("does NOT affect a folder that already carries an SxxExx token (safari S04E08a stays a rename)", () => {
const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
expect(decision).toEqual({ kind: "rename", baseName: folder });
});
it("keeps a clean SHORT-prefix series source (ER) instead of the crippled token append", () => {
const epFolder = "ER.E01.Tag.und.Nacht.German.720p.HDTV.x264-GROUP";
const seasonFolder = "ER.S01.German.720p.HDTV.x264-GROUP";
const cleanSource = "ER.S01E01.German.720p.HDTV.x264-GROUP";
const decision = decideAutoRenameBaseName([epFolder, seasonFolder], cleanSource + ".mkv", cleanSource, epFolder, seasonFolder);
expect(decision.kind).toBe("skip");
expect(JSON.stringify(decision)).not.toContain("GROUP.S01E01");
});
});

View File

@ -20,6 +20,7 @@ describe("backup-crypto", () => {
const plaintext = JSON.stringify({ settings: { token: secret } });
const encrypted = encryptBackup(plaintext);
// The encrypted buffer should NOT contain the secret in plaintext
expect(encrypted.toString("utf8")).not.toContain(secret);
expect(encrypted.toString("latin1")).not.toContain(secret);
});
@ -33,7 +34,9 @@ describe("backup-crypto", () => {
const plaintext = "same input data";
const a = encryptBackup(plaintext);
const b = encryptBackup(plaintext);
// IVs are different, so full buffers must differ
expect(a.equals(b)).toBe(false);
// But both decrypt to the same plaintext
expect(decryptBackup(a)).toBe(plaintext);
expect(decryptBackup(b)).toBe(plaintext);
});
@ -46,6 +49,7 @@ describe("backup-crypto", () => {
it("throws on corrupted ciphertext", () => {
const encrypted = encryptBackup("test data");
// Flip a byte in the ciphertext area
const corrupted = Buffer.from(encrypted);
corrupted[corrupted.length - 1] ^= 0xff;
expect(() => decryptBackup(corrupted)).toThrow();

View File

@ -1,81 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
}
const session: SessionState = {
version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never },
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
reconnectReason: "", paused: false, running: true, updatedAt: 0
};
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
describe("buildBackupPayload — default is settings-only", () => {
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
expect(p.kind).toBe("settings-only");
expect(p.session).toBeUndefined();
expect(p.history).toBeUndefined();
expect(p.settings).toBeDefined();
});
it("includes session + history when backupIncludeDownloads is true", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
expect(p.kind).toBe("full");
expect(p.session).toBe(session);
expect(p.history).toBe(history);
});
it("treats a missing flag as settings-only (safe default)", () => {
const p = buildBackupPayload({ ...baseInput, settings: {} as AppSettings });
expect(p.kind).toBe("settings-only");
expect(p.session).toBeUndefined();
});
it("ROUND-TRIP: toggle off -> exported payload carries the flag still false", () => {
// "Haken aus bleibt aus": the exported settings object preserves the flag,
// so importing it keeps the toggle off.
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
expect((p.settings as AppSettings).backupIncludeDownloads).toBe(false);
});
});
describe("planBackupImport — decision follows the file, not the local toggle", () => {
it("settings-only backup (no session) -> restore settings only, no relaunch", () => {
const plan = planBackupImport({ version: 2, kind: "settings-only", settings: { theme: "dark" } });
expect(plan.valid).toBe(true);
expect(plan.restoreDownloads).toBe(false);
expect(plan.message).toMatch(/Einstellungen/);
});
it("full backup (with session) -> restore downloads + relaunch", () => {
const plan = planBackupImport({ version: 2, kind: "full", settings: { theme: "dark" }, session });
expect(plan.valid).toBe(true);
expect(plan.restoreDownloads).toBe(true);
});
it("rejects payloads without settings", () => {
expect(planBackupImport({ session }).valid).toBe(false);
expect(planBackupImport(null).valid).toBe(false);
expect(planBackupImport("nope").valid).toBe(false);
expect(planBackupImport({}).valid).toBe(false);
});
it("a settings-only export then import does NOT pull in the download list", () => {
// Build with toggle off, then plan the import of exactly that payload.
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
expect(plan.restoreDownloads).toBe(false); // queue stays untouched
});
it("a full export then import DOES restore the download list", () => {
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
expect(plan.restoreDownloads).toBe(true);
});
});

View File

@ -73,6 +73,7 @@ describe("bestdebrid-web", () => {
try {
fs.rmSync(filePath, { force: true });
} catch {
// ignore temp cleanup failures
}
}
});

View File

@ -42,6 +42,7 @@ describe("cleanup", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir);
// Create nested directory structure with archive files
const sub1 = path.join(dir, "season1");
const sub2 = path.join(dir, "season1", "extras");
fs.mkdirSync(sub2, { recursive: true });
@ -50,15 +51,17 @@ describe("cleanup", () => {
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
// Non-archive files should be kept
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
const removed = cleanupCancelledPackageArtifacts(dir);
expect(removed).toBe(4);
expect(removed).toBe(4); // 2 rar parts + zip + 7z
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
// Non-archives kept
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
});
@ -67,17 +70,23 @@ describe("cleanup", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir);
// File with link-like name containing URLs should be removed
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
// File with link-like name but no URLs should be kept
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
// Regular text file that doesn't match the link pattern should be kept
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
// .url files should always be removed
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
// .dlc files should always be removed
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
const removed = await removeDownloadLinkArtifacts(dir);
expect(removed).toBeGreaterThanOrEqual(3);
expect(removed).toBeGreaterThanOrEqual(3); // download_links.txt + bookmark.url + container.dlc
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
// Non-matching files should be kept
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
});

View File

@ -22,7 +22,9 @@ describe("container", () => {
const oversizedFilePath = path.join(dir, "oversized.dlc");
fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1));
// Create a valid mockup DLC that would be skipped if an error was thrown
const validFilePath = path.join(dir, "valid.dlc");
// Just needs to be short enough to pass file limits but fail parsing, triggering dcrypt fallback
fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content..."));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
@ -36,6 +38,7 @@ describe("container", () => {
const result = await importDlcContainers([oversizedFilePath, validFilePath]);
// Expect the oversized to be silently skipped, and valid to be parsed into 1 package with DLC filename
expect(result).toHaveLength(1);
expect(result[0].name).toBe("valid");
expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]);
@ -57,14 +60,17 @@ describe("container", () => {
tempDirs.push(dir);
const filePath = path.join(dir, "fallback.dlc");
// A file large enough to trigger local decryption attempt (needs > 89 bytes to pass the slice check)
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("service.jdownloader.org")) {
// Mock local RC service failure (returning 404)
return new Response("", { status: 404 });
}
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
// Mock dcrypt fallback success
return new Response("http://fallback.com/1", { status: 200 });
}
return new Response("", { status: 404 });
@ -75,6 +81,7 @@ describe("container", () => {
expect(result).toHaveLength(1);
expect(result[0].name).toBe("fallback");
expect(result[0].links).toEqual(["http://fallback.com/1"]);
// Should have tried both!
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
@ -128,6 +135,7 @@ describe("container", () => {
expect(result).toHaveLength(1);
expect(result[0].name).toBe("big-dlc");
expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]);
// local RC + upload + paste = 3 calls
expect(fetchSpy).toHaveBeenCalledTimes(3);
});

View File

@ -1,17 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, normalizeResolvedFilename, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, normalizeResolvedFilename, resetDebridLinkRuntimeStateForTests } from "../src/main/debrid";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
resetDebridLinkRuntimeStateForTests();
resetMegaDebridRuntimeStateForTests();
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
vi.restoreAllMocks();
});
@ -22,7 +19,6 @@ describe("debrid service", () => {
token: "rd-token",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
bestToken: "",
providerOrder: [] as const,
providerPrimary: "realdebrid" as const,
@ -62,7 +58,6 @@ describe("debrid service", () => {
token: "rd-token",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
providerPrimary: "realdebrid" as const,
providerSecondary: "megadebrid" as const,
providerTertiary: "bestdebrid" as const,
@ -390,135 +385,6 @@ describe("debrid service", () => {
expect(result.directUrl).toBe("https://debrid-link.example/quota-ok.bin");
});
it("scopes Debrid-Link maxDataHost cooldown to the (key, host) pair so the key stays usable for other hosters", async () => {
const settings = {
...defaultSettings(),
debridLinkApiKeys: "dl-key-one\ndl-key-two",
providerOrder: ["debridlink"] as const,
providerPrimary: "debridlink" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: true
};
const unrestrictAuthHeaders: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const headers = init?.headers;
let authHeader = "";
if (headers instanceof Headers) {
authHeader = headers.get("Authorization") || "";
} else if (Array.isArray(headers)) {
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
} else {
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
}
if (url.includes("debrid-link.com/api/v2/downloader/limits")) {
return new Response(JSON.stringify({
success: true,
value: { nextResetSeconds: { value: 900 } }
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
if (url.includes("/downloader/add")) {
unrestrictAuthHeaders.push(authHeader);
const bodyText = init?.body ? String(init.body) : "";
const isRapidgator = /rapidgator/i.test(bodyText);
if (authHeader === "Bearer dl-key-one" && isRapidgator) {
return new Response(JSON.stringify({
success: false,
error: "maxDataHost",
error_description: "host quota reached"
}), { status: 403, headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({
success: true,
value: {
downloadUrl: `https://debrid-link.example/${authHeader.slice(-3)}-${isRapidgator ? "rg" : "ot"}.bin`,
name: "ok.bin",
size: 1024
}
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
const r1 = await service.unrestrictLink("https://rapidgator.net/file/first");
expect(r1.providerLabel).toContain("Key 2");
unrestrictAuthHeaders.length = 0;
const r2 = await service.unrestrictLink("https://rapidgator.net/file/second");
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-two"]);
expect(r2.providerLabel).toContain("Key 2");
unrestrictAuthHeaders.length = 0;
const r3 = await service.unrestrictLink("https://uploaded.net/file/third");
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-one"]);
expect(r3.providerLabel).toContain("Key 1");
});
it("does not mark Debrid-Link key as errored when the API returns fileNotAvailable (link-level, not key-level)", async () => {
const settings = {
...defaultSettings(),
debridLinkApiKeys: "dl-key-one\ndl-key-two",
providerOrder: ["debridlink"] as const,
providerPrimary: "debridlink" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: true
};
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const headers = init?.headers;
let authHeader = "";
if (headers instanceof Headers) {
authHeader = headers.get("Authorization") || "";
} else if (Array.isArray(headers)) {
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
} else {
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
}
if (!url.includes("/downloader/add")) {
return new Response("not-found", { status: 404 });
}
if (authHeader === "Bearer dl-key-one") {
return new Response(JSON.stringify({
success: false,
error: "fileNotAvailable",
error_description: "link is currently not available"
}), { status: 403, headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({
success: true,
value: {
downloadUrl: "https://debrid-link.example/ok.bin",
name: "ok.bin",
size: 1024
}
}), { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof fetch;
const key1Id = parseDebridLinkApiKeys("dl-key-one")[0].id;
const key2Id = parseDebridLinkApiKeys("dl-key-two")[0].id;
const service = new DebridService(settings);
const result = await service.unrestrictLink("https://rapidgator.net/file/example");
expect(result.providerLabel).toContain("Key 2");
expect(getDebridLinkKeyRuntimeStateForTests(key1Id)).not.toBe("error");
expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready");
});
it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => {
const settings = {
...defaultSettings(),
@ -593,70 +459,7 @@ describe("debrid service", () => {
expect(addCalls).toBe(2);
});
it("returns an invalid-all marker when all Debrid-Link keys are invalid", async () => {
const settings = {
...defaultSettings(),
debridLinkApiKeys: "dl-key-one\ndl-key-two",
providerOrder: ["debridlink"] as const,
providerPrimary: "debridlink" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: true
};
const authHeaders: string[] = [];
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const headers = init?.headers;
let authHeader = "";
if (headers instanceof Headers) {
authHeader = headers.get("Authorization") || "";
} else if (Array.isArray(headers)) {
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
} else {
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
}
authHeaders.push(authHeader);
return new Response(JSON.stringify({
success: false,
error: "badToken",
error_description: "token expired"
}), {
status: 401,
headers: { "Content-Type": "application/json" }
});
}) as typeof fetch;
const service = new DebridService(settings);
await expect(service.unrestrictLink("https://hoster.example/all-invalid.bin")).rejects.toThrow(/debrid_link_invalid_all:/i);
expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]);
});
it("returns a clear error when all Debrid-Link keys are locally exhausted", async () => {
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
const settings = {
...defaultSettings(),
debridLinkApiKeys: "dl-key-one\ndl-key-two",
providerOrder: ["debridlink"] as const,
providerPrimary: "debridlink" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
debridLinkApiKeyDailyLimitBytes: {
[keys[0].id]: 100,
[keys[1].id]: 100
},
debridLinkApiKeyDailyUsageBytes: {
[keys[0].id]: 100,
[keys[1].id]: 100
},
providerDailyUsageDay: getProviderUsageDayKey()
};
const service = new DebridService(settings);
await expect(service.unrestrictLink("https://hoster.example/no-key-left.bin")).rejects.toThrow(/debrid-link nicht verfuegbar|kein aktiver api-key/i);
});
it("stops rotation immediately on Debrid-Link notDebrid (provider-wide) — does NOT burn remaining keys", async () => {
it("fails fast on provider-wide Debrid-Link notDebrid errors without rotating through all keys", async () => {
const settings = {
...defaultSettings(),
debridLinkApiKeys: "dl-key-one\ndl-key-two",
@ -682,7 +485,7 @@ describe("debrid service", () => {
}) as typeof fetch;
const service = new DebridService(settings);
await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow(/debrid_link_cooldown.*notDebrid/);
await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow("Link kann aktuell nicht generiert werden (notDebrid: notDebrid)");
expect(authHeaders).toEqual(["Bearer dl-key-one"]);
});
@ -1022,90 +825,6 @@ describe("debrid service", () => {
expect(calledUrls.some((url) => url.includes("/limits"))).toBe(true);
});
it("includes Debrid-Link host and key state diagnostics in host limits", async () => {
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("debrid-link.com/api/v2/downloader/hosts")) {
return new Response(JSON.stringify({
success: true,
value: [
{
name: "rapidgator",
status: 1,
domains: ["rapidgator.net", "rg.to"]
}
]
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) {
return new Response(JSON.stringify({
success: true,
value: {
hosters: [
{
name: "rapidgator",
daySize: { current: 1024, value: 2048 },
dayCount: { current: 2, value: 5 }
}
]
}
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const info = await fetchDebridLinkHostLimits("key-a", "rapidgator");
expect(info[0].state).toBe("ready");
expect(info[0].stateLabel).toBe("Bereit");
expect(info[0].hostState).toBe("up");
expect(info[0].hostStateLabel).toBe("Online");
});
it("returns invalid Debrid-Link key diagnostics instead of failing the whole popup request", async () => {
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("debrid-link.com/api/v2/downloader/hosts")) {
return new Response(JSON.stringify({
success: true,
value: [
{
name: "rapidgator",
status: 0,
domains: ["rapidgator.net"]
}
]
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) {
return new Response(JSON.stringify({
success: false,
error: "badToken",
error_description: "token expired"
}), {
status: 401,
headers: { "Content-Type": "application/json" }
});
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const info = await fetchDebridLinkHostLimits("key-a", "rapidgator");
expect(info).toHaveLength(1);
expect(info[0].state).toBe("invalid");
expect(info[0].cooldownRemainingMs).toBeGreaterThan(0);
expect(info[0].hostState).toBe("down");
expect(info[0].hostStateLabel).toBe("Offline");
});
it("uses AllDebrid web path when enabled", async () => {
const settings = {
...defaultSettings(),
@ -1247,7 +966,6 @@ describe("debrid service", () => {
allDebridToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "megadebrid" as const,
@ -1255,6 +973,7 @@ describe("debrid service", () => {
autoProviderFallback: true
};
// API returns 404 for connectUser → API fails, falls back to web
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 }));
globalThis.fetch = fetchSpy as unknown as typeof fetch;
@ -1280,7 +999,6 @@ describe("debrid service", () => {
allDebridToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: true,
providerPrimary: "megadebrid-api" as const,
@ -1311,7 +1029,6 @@ describe("debrid service", () => {
allDebridToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: true,
providerOrder: [] as const,
@ -1345,7 +1062,6 @@ describe("debrid service", () => {
allDebridToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
@ -1353,6 +1069,7 @@ describe("debrid service", () => {
autoProviderFallback: false
};
// API connect fails fast → falls through to web fallback
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<never> => new Promise((_, reject) => {
@ -1379,340 +1096,6 @@ describe("debrid service", () => {
}
});
it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
let webCalls = 0;
const megaWeb = vi.fn(async (_link: string, _signal?: AbortSignal) => {
webCalls += 1;
if (webCalls <= 3) {
throw new Error("Mega-Web: daily limit reached (Tageslimit erreicht)");
}
return {
fileName: "rotated-to-acc2.rar",
directUrl: "https://mega-web.example/rotated-to-acc2.rar",
fileSize: null,
retriesUsed: 0
};
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/limit-rotation-test");
expect(result.directUrl).toBe("https://mega-web.example/rotated-to-acc2.rar");
expect(webCalls).toBeGreaterThanOrEqual(4);
}, 30000);
it("skips a manually disabled Mega-Debrid account and uses the next one", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridDisabledAccountIds: [getMegaDebridAccountId("user1")],
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn(async () => ({
fileName: "from-acc2.rar",
directUrl: "https://mega-web.example/from-acc2.rar",
fileSize: null,
retriesUsed: 0
}));
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/disabled-acc-test");
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
expect(result.directUrl).toBe("https://mega-web.example/from-acc2.rar");
expect(megaWeb).toHaveBeenCalledTimes(1);
}, 20000);
it("fails fast on Mega-Debrid hoster quota ('Kein Server') and rotates to the next account", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
let calls = 0;
const megaWeb = vi.fn(async () => {
calls += 1;
if (calls === 1) {
throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.");
}
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/quota-rotate-test");
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
expect(result.directUrl).toBe("https://mega-web.example/acc2.rar");
expect(calls).toBe(2);
}, 20000);
it("passes each account's OWN credentials to the Mega web unrestrict during rotation", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const accountsSeen: Array<string | undefined> = [];
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
accountsSeen.push(account?.login);
if (account?.login === "user1") {
throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.");
}
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/per-account-creds");
expect(accountsSeen).toContain("user1");
expect(accountsSeen).toContain("user2");
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
expect(result.directUrl).toBe("https://mega-web.example/ok.rar");
}, 20000);
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
const key = `${getMegaDebridAccountId("user1")}:web`;
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
clearMegaDebridEmptyResponseStreak(key);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
});
it("keeps an 'until restart' park active forever (never expires until process restart)", () => {
const key = `${getMegaDebridAccountId("user1")}:api`;
primeMegaDebridUntilRestartForTests(key);
const now = getMegaDebridAccountCooldownState(key);
expect(now?.untilRestart).toBe(true);
const farFuture = Date.now() + 100 * 24 * 60 * 60 * 1000;
expect(getMegaDebridAccountCooldownState(key, farFuture)?.untilRestart).toBe(true);
});
it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const user1 = getMegaDebridAccountId("user1");
primeMegaDebridUntilRestartForTests(`${user1}:api`);
primeMegaDebridUntilRestartForTests(`${user1}:web`);
const loginsSeen: Array<string | undefined> = [];
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
loginsSeen.push(account?.login);
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/parked-skip-test");
expect(loginsSeen).not.toContain("user1");
expect(loginsSeen).toContain("user2");
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
}, 20000);
it("fails terminally (no retry timer) when ALL Mega-Debrid accounts are parked until restart", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
for (const login of ["user1", "user2"]) {
const id = getMegaDebridAccountId(login);
primeMegaDebridUntilRestartForTests(`${id}:api`);
primeMegaDebridUntilRestartForTests(`${id}:web`);
}
const megaWeb = vi.fn(async () => ({ fileName: "x.rar", directUrl: "https://mega-web.example/x.rar", fileSize: null, retriesUsed: 0 }));
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await expect(service.unrestrictLink("https://rapidgator.net/file/all-parked-test")).rejects.toThrow(/bis Neustart gesperrt/i);
expect(megaWeb).not.toHaveBeenCalled();
}, 20000);
it("drives a real empty response through the full rotation into an until-restart park (wiring test)", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const key = `${getMegaDebridAccountId("user1")}:web`;
recordMegaDebridEmptyResponseStreak(key);
recordMegaDebridEmptyResponseStreak(key);
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
const megaWeb = vi.fn(async () => null);
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await service.unrestrictLink("https://rapidgator.net/file/wiring").catch(() => undefined);
expect(megaWeb).toHaveBeenCalled();
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
}, 20000);
it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const loginsSeen: Array<string | undefined> = [];
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
loginsSeen.push(account?.login);
if (account?.login === "user1") {
throw new Error("aborted:debrid");
}
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
// Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down.
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow();
expect(loginsSeen).toContain("user1");
expect(loginsSeen).not.toContain("user2");
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
// Call 2 (the retry, same state): account 1 is on cooldown -> skipped -> account 2 served.
loginsSeen.length = 0;
const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-2");
expect(loginsSeen).not.toContain("user1");
expect(loginsSeen).toContain("user2");
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
}, 20000);
it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn(async () => { throw new Error("aborted:debrid"); });
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
await expect(service.unrestrictLink("https://rapidgator.net/file/quick-cancel")).rejects.toThrow();
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
}, 20000);
it("respects provider selection and does not append hidden providers", async () => {
const settings = {
...defaultSettings(),
@ -1721,7 +1104,6 @@ describe("debrid service", () => {
allDebridToken: "ad-token",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
providerPrimary: "megadebrid" as const,
providerSecondary: "megadebrid" as const,
providerTertiary: "megadebrid" as const,
@ -1753,7 +1135,6 @@ describe("debrid service", () => {
token: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
providerPrimary: "realdebrid" as const,
providerSecondary: "megadebrid" as const,
providerTertiary: "none" as const,
@ -1778,7 +1159,6 @@ describe("debrid service", () => {
token: "rd-token",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
providerPrimary: "realdebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
@ -2083,9 +1463,11 @@ describe("normalizeResolvedFilename", () => {
});
it("strips HTML tags and collapses whitespace", () => {
// Tags are replaced by spaces, then multiple spaces collapsed
const result = normalizeResolvedFilename("<b>Show.S01E01</b>.part01.rar");
expect(result).toBe("Show.S01E01 .part01.rar");
// Entity decoding happens before tag removal, so &lt;...&gt; becomes <...> then gets stripped
const entityTagResult = normalizeResolvedFilename("File&lt;Tag&gt;.part1.rar");
expect(entityTagResult).toBe("File .part1.rar");
});
@ -2108,6 +1490,7 @@ describe("normalizeResolvedFilename", () => {
});
it("handles combined transforms", () => {
// "Download file" prefix stripped, &amp; decoded to &, "- Rapidgator" suffix stripped
expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar - Rapidgator"))
.toBe("Show.S01E01.part01.rar");
});

View File

@ -78,6 +78,7 @@ async function waitForReady(url: string): Promise<void> {
return;
}
} catch {
// retry
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
@ -172,11 +173,7 @@ function buildSnapshot(baseDir: string): UiSnapshot {
totalFilesSession: 0,
totalFilesAllTime: 0,
totalPackages: 1,
sessionStartedAt: Date.now() - 30_000,
appSessionStartedAt: Date.now() - 60_000,
sessionRuntimeMs: 60_000,
totalRuntimeMs: 3 * 60_000,
runtimeMeasuredAt: Date.now()
sessionStartedAt: Date.now() - 30_000
},
speedText: "8.0 MB/s",
etaText: "ETA: 00:25",
@ -212,8 +209,7 @@ async function createFixture() {
debridLinkApiKeys,
debridLinkDisabledKeyIds: debridLinkKeyIds[1] ? [debridLinkKeyIds[1]] : [],
totalDownloadedAllTime: 128 * 1024 * 1024,
totalCompletedFilesAllTime: 12,
totalRuntimeAllTimeMs: 5 * 60_000
totalCompletedFilesAllTime: 12
});
saveHistory(storagePaths, [
{
@ -313,6 +309,7 @@ afterEach(() => {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore cleanup failures
}
}
});
@ -403,22 +400,6 @@ describe("debug-server", () => {
expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0);
});
it("writes the client IP into the debug trace log", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, {
headers: {
"X-Forwarded-For": "159.195.63.46"
}
});
expect(response.ok).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 200));
const traceLogPath = getTraceLogPath();
expect(traceLogPath).toBeTruthy();
const traceText = fs.readFileSync(traceLogPath!, "utf8");
expect(traceText).toContain("clientIp=159.195.63.46");
});
it("serves package details and package log by package query", async () => {
const fixture = await createFixture();

View File

@ -1,124 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
getDesktopRenameLogPath,
initDesktopRenameLog,
logDesktopRename,
shutdownDesktopRenameLog,
verifyRename
} from "../src/main/desktop-rename-log";
const createdTmpDirs: string[] = [];
function tmpDesktop(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-log-"));
createdTmpDirs.push(dir);
return dir;
}
afterEach(() => {
shutdownDesktopRenameLog();
for (const dir of createdTmpDirs) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
createdTmpDirs.length = 0;
});
describe("desktop-rename-log", () => {
it("creates the Downloader-Log folder + session file on init and appends formatted lines", () => {
const desktop = tmpDesktop();
initDesktopRenameLog(desktop);
const logPath = getDesktopRenameLogPath();
expect(logPath).toBeTruthy();
expect(path.dirname(logPath as string).endsWith("Downloader-Log")).toBe(true);
expect(fs.existsSync(logPath as string)).toBe(true);
logDesktopRename("INFO", "Test-Rename", { source: "a.mkv", requested: "b.mkv" });
const content = fs.readFileSync(logPath as string, "utf8");
expect(content).toContain("Rename-Session gestartet");
expect(content).toContain("Test-Rename");
expect(content).toContain("source=a.mkv");
expect(content).toContain("requested=b.mkv");
expect(content).toMatch(/\[INFO\]/);
});
it("self-heals: recreates the whole Downloader-Log FOLDER and file if it is deleted mid-session", () => {
const desktop = tmpDesktop();
initDesktopRenameLog(desktop);
const logPath = getDesktopRenameLogPath() as string;
logDesktopRename("INFO", "ZeileA");
fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true });
expect(fs.existsSync(logPath)).toBe(false);
logDesktopRename("INFO", "ZeileB");
expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true);
expect(fs.existsSync(logPath)).toBe(true);
const content = fs.readFileSync(logPath, "utf8");
expect(content).toContain("Rename-Session gestartet");
expect(content).toContain("ZeileB");
});
it("is a silent no-op when initialized without a desktop path (never throws)", () => {
initDesktopRenameLog("");
expect(getDesktopRenameLogPath()).toBeNull();
expect(() => logDesktopRename("INFO", "egal")).not.toThrow();
});
it("verifyRename: ok when the target exists under the exact name and the source is gone", () => {
const dir = tmpDesktop();
const source = path.join(dir, "scn-xyz.part1.rar");
const target = path.join(dir, "Movie.2024.German.1080p.part1.rar");
fs.writeFileSync(target, "data");
const v = verifyRename(source, target);
expect(v.ok).toBe(true);
expect(v.level).toBe("INFO");
expect(v.targetExists).toBe(true);
expect(v.onDiskName).toBe("Movie.2024.German.1080p.part1.rar");
expect(v.nameMatches).toBe(true);
expect(v.sourceGone).toBe(true);
expect(v.targetSize).toBe(4);
});
it("verifyRename: FAILS when the target is missing although rename reported success", () => {
const dir = tmpDesktop();
const v = verifyRename(path.join(dir, "src.rar"), path.join(dir, "never-created.rar"));
expect(v.ok).toBe(false);
expect(v.level).toBe("ERROR");
expect(v.targetExists).toBe(false);
expect(v.reason).toMatch(/nicht gefunden/i);
});
it("verifyRename: FAILS (half-done move) when the source still exists next to the target", () => {
const dir = tmpDesktop();
const source = path.join(dir, "src.rar");
const target = path.join(dir, "dst.rar");
fs.writeFileSync(source, "x");
fs.writeFileSync(target, "x");
const v = verifyRename(source, target);
expect(v.ok).toBe(false);
expect(v.level).toBe("ERROR");
expect(v.sourceGone).toBe(false);
expect(v.reason).toMatch(/Quelldatei existiert noch/i);
});
it("verifyRename: an in-place rename (same path) is ok and does not flag a lingering source", () => {
const dir = tmpDesktop();
const p = path.join(dir, "file.mkv");
fs.writeFileSync(p, "x");
const v = verifyRename(p, p);
expect(v.ok).toBe(true);
expect(v.targetExists).toBe(true);
expect(v.nameMatches).toBe(true);
});
});

View File

@ -1,61 +0,0 @@
import { describe, expect, it } from "vitest";
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
describe("download-completion", () => {
describe("planDownloadCompletion", () => {
it("uses content-length when present", () => {
const plan = planDownloadCompletion({
existingBytes: 0, responseStatus: 200, contentLength: 1000,
totalFromRange: null, knownTotal: null, correctedTotal: null
});
expect(plan.source).toBe("content-length");
expect(plan.expectedTotal).toBe(1000);
});
it("falls back to stream-end when no size info is available", () => {
const plan = planDownloadCompletion({
existingBytes: 0, responseStatus: 200, contentLength: 0,
totalFromRange: null, knownTotal: null, correctedTotal: null
});
expect(plan.source).toBe("stream-end");
expect(plan.expectedTotal).toBeNull();
});
});
describe("validateDownloadedFileCompletion", () => {
const streamEnd = { expectedTotal: null, source: "stream-end" as const, canFinishEarly: false };
const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true });
const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false });
it("rejects a 0-byte stream-end download (H3)", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd });
expect(result.ok).toBe(false);
expect(result.error).toContain("download_underflow");
});
it("accepts a non-empty stream-end download", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 5_000_000, plan: streamEnd });
expect(result.ok).toBe(true);
expect(result.totalBytes).toBe(5_000_000);
});
it("rejects an underflowing content-length download", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 400, plan: contentLength(1000), toleranceBytes: 0 });
expect(result.ok).toBe(false);
});
it("accepts a complete content-length download", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 1000, plan: contentLength(1000) });
expect(result.ok).toBe(true);
});
it("rejects a 0-byte download even with known provider size", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: providerMeta(2000) });
expect(result.ok).toBe(false);
});
it("accepts provider-metadata download and flags size mismatch", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 });
expect(result.ok).toBe(false);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import { createErrorRing } from "../src/main/error-ring";
describe("createErrorRing", () => {
it("keeps entries in insertion order", () => {
const ring = createErrorRing(10);
ring.push({ ts: "t1", level: "ERROR", message: "a" });
ring.push({ ts: "t2", level: "WARN", message: "b" });
expect(ring.snapshot().map((e) => e.message)).toEqual(["a", "b"]);
expect(ring.size()).toBe(2);
});
it("caps at capacity by dropping the oldest", () => {
const ring = createErrorRing(3);
for (const m of ["a", "b", "c", "d", "e"]) {
ring.push({ ts: m, level: "ERROR", message: m });
}
expect(ring.snapshot().map((e) => e.message)).toEqual(["c", "d", "e"]);
expect(ring.size()).toBe(3);
});
it("snapshot returns a copy, not the live buffer", () => {
const ring = createErrorRing(5);
ring.push({ ts: "t", level: "WARN", message: "x" });
const snap = ring.snapshot();
snap.push({ ts: "t2", level: "ERROR", message: "injected" });
expect(ring.snapshot().map((e) => e.message)).toEqual(["x"]);
});
it("clear empties the ring", () => {
const ring = createErrorRing(5);
ring.push({ ts: "t", level: "ERROR", message: "x" });
ring.clear();
expect(ring.snapshot()).toEqual([]);
expect(ring.size()).toBe(0);
});
it("coerces a non-positive capacity to at least 1", () => {
const ring = createErrorRing(0);
ring.push({ ts: "t1", level: "ERROR", message: "a" });
ring.push({ ts: "t2", level: "ERROR", message: "b" });
expect(ring.snapshot().map((e) => e.message)).toEqual(["b"]);
});
});

View File

@ -74,6 +74,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
// Create a ZIP with some content to trigger progress
const zipPath = path.join(packageDir, "progress-test.zip");
const zip = new AdmZip();
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
@ -107,16 +108,20 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(result.extracted).toBe(1);
expect(result.failed).toBe(0);
// Should have at least preparing, extracting, and done phases
const phases = new Set(progressUpdates.map((u) => u.phase));
expect(phases.has("preparing")).toBe(true);
expect(phases.has("extracting")).toBe(true);
// Extracting phase should include the archive name
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
expect(extracting.length).toBeGreaterThan(0);
// Should end at 100%
const lastExtracting = extracting[extracting.length - 1];
expect(lastExtracting.archivePercent).toBe(100);
// Files should exist
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
});
@ -130,6 +135,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
// Create two separate ZIP archives
const zip1 = new AdmZip();
zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
zip1.writeZip(path.join(packageDir, "archive1.zip"));
@ -156,8 +162,10 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(result.extracted).toBe(2);
expect(result.failed).toBe(0);
// Both archive names should have appeared in progress
expect(archiveNames.has("archive1.zip")).toBe(true);
expect(archiveNames.has("archive2.zip")).toBe(true);
// Both files extracted
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
});

View File

@ -12,7 +12,6 @@ import {
archiveFilenamePasswords,
detectArchiveSignature,
classifyExtractionError,
shouldSerialRetryParallelFailures,
findArchiveCandidates,
orderExtractorCandidatesForArchive,
resolveExtractorBackendModeForArchive,
@ -865,6 +864,7 @@ describe("extractor", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-"));
tempDirs.push(root);
const filePath = path.join(root, "test.rar");
// RAR5 signature: 52 61 72 21 1A 07
fs.writeFileSync(filePath, Buffer.from("526172211a0700", "hex"));
const sig = await detectArchiveSignature(filePath);
expect(sig).toBe("rar");
@ -941,6 +941,7 @@ describe("extractor", () => {
const candidates = await findArchiveCandidates(packageDir);
const names = candidates.map((c) => path.basename(c));
expect(names).toContain("movie.001");
// .002 should NOT be in candidates (only .001 is the entry point)
expect(names).not.toContain("movie.002");
});
@ -955,6 +956,7 @@ describe("extractor", () => {
const candidates = await findArchiveCandidates(packageDir);
const names = candidates.map((c) => path.basename(c));
// .zip.001 should appear once from zipSplit detection, not duplicated by genericSplit
expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1);
});
@ -1046,19 +1048,6 @@ describe("extractor", () => {
});
});
describe("shouldSerialRetryParallelFailures", () => {
it("keeps serial recovery enabled after mixed parallel results", () => {
expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(true);
expect(shouldSerialRetryParallelFailures(2, ["missing_parts"])).toBe(true);
});
it("only retries a total parallel wipe-out for contention-like failures", () => {
expect(shouldSerialRetryParallelFailures(0, ["crc_error", "wrong_password", "unknown"])).toBe(true);
expect(shouldSerialRetryParallelFailures(0, ["missing_parts"])).toBe(false);
expect(shouldSerialRetryParallelFailures(0, ["unsupported_format", "crc_error"])).toBe(false);
});
});
describe("password discovery", () => {
it("reports per-archive failures through onArchiveFailure", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-"));
@ -1097,6 +1086,7 @@ describe("extractor", () => {
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
// Create 3 zip archives
for (const name of ["ep01.zip", "ep02.zip", "ep03.zip"]) {
const zip = new AdmZip();
zip.addFile(`${name}.txt`, Buffer.from(name));
@ -1123,6 +1113,7 @@ describe("extractor", () => {
expect(result.extracted).toBe(3);
expect(result.failed).toBe(0);
// First archive should be ep01 (natural order, extracted serially for discovery)
expect(seenOrder[0]).toBe("ep01.zip");
});
@ -1139,6 +1130,7 @@ describe("extractor", () => {
zip.writeZip(path.join(packageDir, name));
}
// No passwordList → only empty string → length=1 → no discovery phase
const result = await extractPackageArchives({
packageDir,
targetDir,

View File

@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import { classifyDiskError } from "../src/main/fs-error";
import { isDebugFlagEnabled } from "../src/main/logger";
describe("classifyDiskError", () => {
it("maps ENOSPC from an error code to a disk-full reason", () => {
const err = Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" });
expect(classifyDiskError(err)).toMatch(/Festplatte voll/);
});
it("maps EACCES from a code to a permission reason", () => {
const err = Object.assign(new Error("nope"), { code: "EACCES" });
expect(classifyDiskError(err)).toMatch(/Zugriff verweigert/);
});
it("lower-case codes are normalized", () => {
const err = Object.assign(new Error("x"), { code: "enospc" });
expect(classifyDiskError(err)).toMatch(/ENOSPC/);
});
it("falls back to scanning the message text when no code is present", () => {
expect(classifyDiskError(new Error("operation failed: ENOSPC on volume"))).toMatch(/Festplatte voll/);
});
it("handles a plain string error", () => {
expect(classifyDiskError("EROFS: read-only file system")).toMatch(/schreibgeschützt/);
});
it("returns null for an unrelated error", () => {
expect(classifyDiskError(new Error("write_drain_timeout"))).toBeNull();
expect(classifyDiskError(new Error("premature close"))).toBeNull();
expect(classifyDiskError(null)).toBeNull();
expect(classifyDiskError(undefined)).toBeNull();
});
});
describe("isDebugFlagEnabled", () => {
it("is true for affirmative values", () => {
for (const v of ["1", "true", "TRUE", "yes", "on", " on "]) {
expect(isDebugFlagEnabled(v)).toBe(true);
}
});
it("is false for empty/negative/garbage values", () => {
for (const v of [undefined, "", "0", "false", "off", "no", "maybe"]) {
expect(isDebugFlagEnabled(v)).toBe(false);
}
});
});

View File

@ -1,150 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock only processVideoFile (the ffmpeg boundary); keep the real pure helpers
// (stripDualLangMarker / hasDualLangMarker / isRemuxableVideoFile) so the
// download-manager's selection + .DL.-rename wiring is exercised for real.
vi.mock("../src/main/video-processor", async (importActual) => {
const actual = await importActual<typeof import("../src/main/video-processor")>();
return { ...actual, processVideoFile: vi.fn(), resolveVideoTooling: vi.fn() };
});
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log";
import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor";
const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>;
const mockedTooling = resolveVideoTooling as unknown as ReturnType<typeof vi.fn>;
const tempDirs: string[] = [];
afterEach(() => {
mockedProcess.mockReset();
mockedTooling.mockReset();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
function setup(keepGermanAudioOnly: boolean): { extractDir: string; manager: DownloadManager; pkg: any } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ga-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
const stateDir = path.join(root, "state");
fs.mkdirSync(extractDir, { recursive: true });
fs.mkdirSync(stateDir, { recursive: true });
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
keepGermanAudioOnly,
germanAudioMode: "tag",
autoRename4sf4sj: false,
outputDir: path.join(root, "out"),
extractDir,
mkvLibraryDir: path.join(stateDir, "_mkv")
},
emptySession(),
createStoragePaths(stateDir)
);
const pkg: any = {
id: "ga-pkg-1",
name: "Test.Show.S01.GERMAN.DL.720p",
outputDir: path.join(root, "out", "Test.Show"),
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 0,
updatedAt: 0
};
// Default: ffmpeg/ffprobe "available" so the step proceeds to the (mocked)
// processVideoFile. Tests that need the no-tool path override this.
mockedTooling.mockResolvedValue({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
return { extractDir, manager, pkg };
}
const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv";
const PLAIN_MKV = "Show.S01E02.German.1080p.x264.mkv";
const SAMPLE_DL = "Show.sample.DL.mkv";
const DL_AVI = "Show.S01E03.German.DL.avi";
function stage(extractDir: string): void {
for (const f of [DL_MKV, PLAIN_MKV, SAMPLE_DL, DL_AVI]) {
fs.writeFileSync(path.join(extractDir, f), "x");
}
}
describe("keepGermanAudioOnly integration", () => {
it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "remuxed", reason: "german-tag", totalAudioTracks: 2, keptTrackIndex: 0 } as VideoProcessResult);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(mockedProcess).toHaveBeenCalledTimes(1);
expect(mockedProcess.mock.calls[0][0]).toBe(path.join(extractDir, DL_MKV));
expect(n).toBe(1);
const files = fs.readdirSync(extractDir);
expect(files).toContain("Show.S01E01.German.720p.x264.mkv"); // .DL. stripped
expect(files).not.toContain(DL_MKV);
expect(files).toContain(PLAIN_MKV); // non-.DL. untouched
expect(files).toContain(SAMPLE_DL); // sample skipped
expect(files).toContain(DL_AVI); // avi not remuxable, skipped
});
it("does nothing when the setting is off", async () => {
const { extractDir, manager, pkg } = setup(false);
stage(extractDir);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled();
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
});
it("leaves the file fully untouched (name included) when no German track is found", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2 } as VideoProcessResult);
await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(mockedProcess).toHaveBeenCalledTimes(1);
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // NOT renamed -> stays visible as unprocessed
});
it("still strips .DL. for a single-audio file (no remux needed)", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "kept-single", reason: "single-german", totalAudioTracks: 1, keptTrackIndex: 0 } as VideoProcessResult);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0); // not counted as a remux
expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv");
});
it("skips up front (no processVideoFile calls) and leaves files untouched when ffmpeg is missing", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
});
});

View File

@ -34,20 +34,25 @@ describe("integrity", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
tempDirs.push(dir);
// Create a .md5 manifest that exceeds the 5MB limit
const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000);
const manifestPath = path.join(dir, "hashes.md5");
fs.writeFileSync(manifestPath, largeContent, "utf8");
// Verify the file is actually > 5MB
const stat = fs.statSync(manifestPath);
expect(stat.size).toBeGreaterThan(5 * 1024 * 1024);
// readHashManifest should skip the oversized file
const manifest = readHashManifest(dir);
expect(manifest.size).toBe(0);
});
it("does not parse SHA256 (64-char hex) as valid hash", () => {
// SHA256 is 64 chars - parseHashLine only supports 32 (MD5) and 40 (SHA1)
const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin";
const result = parseHashLine(sha256Line);
// 64-char hex should not match the MD5 (32) or SHA1 (40) pattern
expect(result).toBeNull();
});

View File

@ -63,23 +63,4 @@ describe("item-log", () => {
expect(content).toContain("archive=episode.part2.rar");
expect(content).toContain("code=missing_parts");
});
it("keeps traversal-like item ids inside the item log directory", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
tempDirs.push(baseDir);
initItemLogs(baseDir);
const logPath = ensureItemLog({
itemId: "..\\..\\outside",
packageId: "pkg-traversal",
packageName: "Traversal Paket",
fileName: "episode.part2.rar",
targetPath: "C:\\downloads\\Traversal Paket\\episode.part2.rar"
});
expect(logPath).not.toBeNull();
const logsDir = path.resolve(path.join(baseDir, "item-logs"));
const resolvedLogPath = path.resolve(logPath!);
expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true);
});
});

View File

@ -115,11 +115,7 @@ function buildSnapshot(): UiSnapshot {
totalFilesSession: 0,
totalFilesAllTime: 0,
totalPackages: 2,
sessionStartedAt: 0,
appSessionStartedAt: 0,
sessionRuntimeMs: 0,
totalRuntimeMs: 0,
runtimeMeasuredAt: 0
sessionStartedAt: 0
},
speedText: "",
etaText: "",

View File

@ -8,16 +8,16 @@ describe("link-parser", () => {
{ name: "Package A", links: ["http://link1", "http://link2"] },
{ name: "Package B", links: ["http://link3"] },
{ name: "Package A", links: ["http://link4", "http://link1"] },
{ name: "", links: ["http://link5"] }
{ name: "", links: ["http://link5"] } // empty name will be inferred
];
const result = mergePackageInputs(input);
expect(result).toHaveLength(3);
expect(result).toHaveLength(3); // Package A, Package B, and inferred 'Paket'
const pkgA = result.find(p => p.name === "Package A");
expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]);
expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]); // link1 deduplicated
const pkgB = result.find(p => p.name === "Package B");
expect(pkgB?.links).toEqual(["http://link3"]);
});
@ -29,7 +29,8 @@ describe("link-parser", () => {
];
const result = mergePackageInputs(input);
// "Valid?Name*" becomes "Valid Name " -> trimmed to "Valid Name"
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]);
});
@ -58,23 +59,24 @@ describe("link-parser", () => {
Here are some links:
http://example.com/part1.rar
http://example.com/part2.rar
# package: Custom_Name
http://other.com/file1
http://other.com/file2
`;
const result = parseCollectorInput(rawText, "DefaultFallback");
// Should have 2 packages: "DefaultFallback" and "Custom_Name"
expect(result).toHaveLength(2);
const defaultPkg = result.find(p => p.name === "DefaultFallback");
expect(defaultPkg?.links).toEqual([
"http://example.com/part1.rar",
"http://example.com/part2.rar"
]);
const customPkg = result.find(p => p.name === "Custom_Name");
const customPkg = result.find(p => p.name === "Custom_Name"); // sanitized!
expect(customPkg?.links).toEqual([
"http://other.com/file1",
"http://other.com/file2"

View File

@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { logTimestamp } from "../src/main/log-timestamp";
describe("logTimestamp", () => {
it("formats local time with an explicit UTC offset (ISO 8601), not a UTC 'Z' string", () => {
const instant = new Date("2026-05-31T17:29:43.605Z");
const formatted = logTimestamp(instant);
expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/);
expect(formatted.endsWith("Z")).toBe(false);
});
it("is parseable back to the exact same instant (offset keeps it unambiguous)", () => {
const instant = new Date("2026-05-31T17:29:43.605Z");
expect(new Date(logTimestamp(instant)).getTime()).toBe(instant.getTime());
});
it("shows the LOCAL wall-clock hour (machine-timezone-independent assertion)", () => {
const instant = new Date("2026-05-31T17:29:43.605Z");
const formatted = logTimestamp(instant);
expect(formatted.slice(11, 13)).toBe(String(instant.getHours()).padStart(2, "0"));
});
});

View File

@ -1,178 +0,0 @@
import crypto from "node:crypto";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
decryptMegaAttributes,
isMegaFileUrl,
parseMegaUrl,
resolveMegaFilename
} from "../src/main/mega-public-api";
function base64Url(buf: Buffer): string {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function makeRandomFileKey(): Buffer {
return crypto.randomBytes(32);
}
function encryptAttributes(jsonAttrs: Record<string, unknown>, aesKey: Buffer): string {
const plain = "MEGA" + JSON.stringify(jsonAttrs);
const padded = Buffer.from(plain, "utf8");
const padLen = (16 - (padded.length % 16)) % 16;
const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]);
const cipher = crypto.createCipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
cipher.setAutoPadding(false);
const enc = Buffer.concat([cipher.update(buf), cipher.final()]);
return base64Url(enc);
}
describe("mega-public-api", () => {
describe("isMegaFileUrl", () => {
it("recognizes new format", () => {
expect(isMegaFileUrl("https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo")).toBe(true);
});
it("recognizes legacy format", () => {
expect(isMegaFileUrl("https://mega.nz/#!abc123!def456")).toBe(true);
});
it("recognizes mega.co.nz", () => {
expect(isMegaFileUrl("https://mega.co.nz/file/abc#xyz")).toBe(true);
});
it("rejects folder URLs", () => {
expect(isMegaFileUrl("https://mega.nz/folder/abc#xyz")).toBe(false);
});
it("rejects non-mega URLs", () => {
expect(isMegaFileUrl("https://example.com/file/abc#xyz")).toBe(false);
});
it("rejects garbage", () => {
expect(isMegaFileUrl("")).toBe(false);
expect(isMegaFileUrl("foo")).toBe(false);
});
});
describe("parseMegaUrl", () => {
it("parses new-format URL into id + 32-byte key", () => {
const url = "https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo";
const parsed = parseMegaUrl(url);
expect(parsed).not.toBeNull();
expect(parsed?.id).toBe("pZl1wBRQ");
expect(parsed?.rawKey.length).toBe(32);
});
it("parses legacy-format URL", () => {
const id = "abcDEF12";
const key = makeRandomFileKey();
const url = `https://mega.nz/#!${id}!${base64Url(key)}`;
const parsed = parseMegaUrl(url);
expect(parsed?.id).toBe(id);
expect(parsed?.rawKey.equals(key)).toBe(true);
});
it("rejects URL with folder key (16 bytes)", () => {
const url = `https://mega.nz/file/abc#${base64Url(crypto.randomBytes(16))}`;
expect(parseMegaUrl(url)).toBeNull();
});
it("rejects malformed URLs", () => {
expect(parseMegaUrl("not-a-url")).toBeNull();
expect(parseMegaUrl("https://mega.nz/file/abc")).toBeNull();
});
});
describe("decryptMegaAttributes", () => {
it("round-trips encrypted Mega attributes", () => {
const aesKey = crypto.randomBytes(16);
const original = { n: "Test.S01E01.German.1080p.WEB.x264-DEMO.mkv", c: "ignored" };
const enc = encryptAttributes(original, aesKey);
const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64");
const decrypted = decryptMegaAttributes(decoded, aesKey);
expect(decrypted).not.toBeNull();
expect(decrypted?.n).toBe(original.n);
});
it("returns null for wrong key", () => {
const aesKey = crypto.randomBytes(16);
const wrongKey = crypto.randomBytes(16);
const enc = encryptAttributes({ n: "x" }, aesKey);
const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64");
expect(decryptMegaAttributes(decoded, wrongKey)).toBeNull();
});
it("returns null for non-multiple-of-16 input", () => {
const aesKey = crypto.randomBytes(16);
expect(decryptMegaAttributes(Buffer.alloc(15), aesKey)).toBeNull();
});
it("returns null for wrong key length", () => {
expect(decryptMegaAttributes(Buffer.alloc(16), Buffer.alloc(8))).toBeNull();
});
});
describe("resolveMegaFilename (mocked fetch)", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("returns filename + size for a valid Mega response", async () => {
const fileKey = makeRandomFileKey();
const aesKey = fileKey.subarray(0, 16);
const url = `https://mega.nz/file/testId12#${base64Url(fileKey)}`;
const encrypted = encryptAttributes(
{ n: "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv" },
aesKey
);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
async json() {
return [{ s: 1234567890, at: encrypted, msd: 1 }];
}
} as unknown as Response);
const result = await resolveMegaFilename(url);
expect(result).not.toBeNull();
expect(result?.name).toBe("Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv");
expect(result?.size).toBe(1234567890);
});
it("returns null when Mega returns numeric error", async () => {
const fileKey = makeRandomFileKey();
const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
async json() {
return -9;
}
} as unknown as Response);
expect(await resolveMegaFilename(url)).toBeNull();
});
it("returns null when response is array with error code", async () => {
const fileKey = makeRandomFileKey();
const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
async json() {
return [-16];
}
} as unknown as Response);
expect(await resolveMegaFilename(url)).toBeNull();
});
it("returns null when fetch throws", async () => {
const fileKey = makeRandomFileKey();
const url = `https://mega.nz/file/networkFail#${base64Url(fileKey)}`;
global.fetch = vi.fn().mockRejectedValue(new Error("network down"));
expect(await resolveMegaFilename(url)).toBeNull();
});
it("returns null for non-mega URL without making any fetch call", async () => {
const fetchSpy = vi.fn();
global.fetch = fetchSpy as unknown as typeof fetch;
expect(await resolveMegaFilename("https://example.com/file/abc#xyz")).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
});

View File

@ -21,18 +21,19 @@ describe("mega-web-fallback", () => {
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
fetchCallCount += 1;
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
// The POST to generate the code
return new Response(`
<div class="acp-box">
<h3>Link: https://mega.debrid/link1</h3>
@ -40,119 +41,37 @@ describe("mega-web-fallback", () => {
</div>
`, { status: 200 });
}
if (urlStr.includes("ajax=debrid")) {
// Polling endpoint
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
const result = await fallback.unrestrict("https://mega.debrid/link1");
expect(result).not.toBeNull();
expect(result?.directUrl).toBe("https://mega.direct/123");
expect(result?.fileName).toBe("link1");
// Calls: 1. Login POST, 2. Verify GET, 3. Generate POST, 4. Polling POST
expect(fetchCallCount).toBe(4);
});
it("fails fast on 'Kein Server für diesen Hoster' (account hoster quota) instead of re-login + re-poll", async () => {
let ajaxCalls = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l1</h3><a href="javascript:processDebrid(1,'code1',0)">d</a></div>`, { status: 200 });
}
if (urlStr.includes("ajax=debrid")) {
ajaxCalls += 1;
return new Response(JSON.stringify({ link: "", text: "Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal." }), { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
expect(ajaxCalls).toBe(1);
});
it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => {
let ajaxCalls = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
return new Response('<div class="error">Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.</div>', { status: 200 });
}
if (urlStr.includes("ajax=debrid")) {
ajaxCalls += 1;
return new Response(JSON.stringify({ link: "https://should.not/happen" }), { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
expect(ajaxCalls).toBe(0);
});
it("logs in with the per-account credentials passed to unrestrict, not the default", async () => {
const loginsUsed: string[] = [];
globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: { body?: unknown }) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const params = new URLSearchParams(String(opts?.body ?? ""));
loginsUsed.push(params.get("login") || "");
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l1</h3><a href="javascript:processDebrid(1,'code1',0)">d</a></div>`, { status: 200 });
}
if (urlStr.includes("ajax=debrid")) {
return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" }));
const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" });
expect(result?.directUrl).toBe("https://mega.direct/ok");
expect(loginsUsed).toContain("account2");
expect(loginsUsed).not.toContain("defaultacc");
});
it("throws if login fails to set cookie", async () => {
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers();
const headers = new Headers(); // No cookie
return new Response("", { headers, status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "bad", password: "bad" }));
await expect(fallback.unrestrict("http://mega.debrid/file"))
.rejects.toThrow("Mega-Web Login liefert kein Session-Cookie");
});
@ -166,17 +85,18 @@ describe("mega-web-fallback", () => {
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
// Missing form!
return new Response('<html><body>Nothing here</body></html>', { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
await expect(fallback.unrestrict("http://mega.debrid/file"))
.rejects.toThrow("Mega-Web Login ungültig oder Session blockiert");
});
it("returns null if generation fails to find a code", async () => {
let callCount = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
@ -191,6 +111,7 @@ describe("mega-web-fallback", () => {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
// The generate POST returns HTML without any codes
return new Response(`<div>No links here</div>`, { status: 200 });
}
return new Response("Not found", { status: 404 });
@ -198,7 +119,8 @@ describe("mega-web-fallback", () => {
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
const result = await fallback.unrestrict("http://mega.debrid/file");
// Generation fails -> resets cookie -> tries again -> fails again -> returns null
expect(result).toBeNull();
});

View File

@ -61,22 +61,4 @@ describe("package-log", () => {
expect(content).toContain("archive=episode.part1.rar");
expect(content).toContain("password=\"secret\"");
});
it("keeps traversal-like package ids inside the package log directory", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
tempDirs.push(baseDir);
initPackageLogs(baseDir);
const logPath = ensurePackageLog({
packageId: "..\\..\\outside",
name: "Traversal Paket",
outputDir: "C:\\downloads\\Traversal Paket",
extractDir: "C:\\extract\\Traversal Paket"
});
expect(logPath).not.toBeNull();
const logsDir = path.resolve(path.join(baseDir, "package-logs"));
const resolvedLogPath = path.resolve(logPath!);
expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true);
});
});

View File

@ -44,50 +44,23 @@ function createItem(id: string, packageId: string, status: DownloadItem["status"
}
describe("sortPackagesForDisplay", () => {
it("floats active packages to the top, keeping queue order within each group", () => {
// pkg-a and pkg-b both have an active (downloading) item -> both float up in
// their original queue order; pkg-c (queued only) sinks below.
it("moves active packages with more progress to the top when auto sort is enabled", () => {
const packages = [
createPackage("pkg-a", ["a1", "a2"]),
createPackage("pkg-c", ["c1"]),
createPackage("pkg-b", ["b1", "b2"])
createPackage("pkg-b", ["b1", "b2"]),
createPackage("pkg-c", ["c1"])
];
const items: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 250),
a2: createItem("a2", "pkg-a", "completed", 500),
c1: createItem("c1", "pkg-c", "queued", 0),
b1: createItem("b1", "pkg-b", "downloading", 800),
b2: createItem("b2", "pkg-b", "completed", 900)
b2: createItem("b2", "pkg-b", "completed", 900),
c1: createItem("c1", "pkg-c", "queued", 0)
};
const sorted = sortPackagesForDisplay(packages, items, true, true);
// active group [pkg-a, pkg-b] in queue order, then rest [pkg-c]
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
});
it("does NOT reshuffle active packages when only their progress changes (anti-flicker)", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"])
];
// Both active. pkg-b initially has more bytes than pkg-a.
const before: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 100),
b1: createItem("b1", "pkg-b", "downloading", 900)
};
const orderBefore = sortPackagesForDisplay(packages, before, true, true).map((p) => p.id);
// A progress tick: pkg-a overtakes pkg-b in bytes. Order must NOT change —
// both are still active, so they keep queue order. (Old code swapped them.)
const after: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 5000),
b1: createItem("b1", "pkg-b", "downloading", 950)
};
const orderAfter = sortPackagesForDisplay(packages, after, true, true).map((p) => p.id);
expect(orderBefore).toEqual(["pkg-a", "pkg-b"]);
expect(orderAfter).toEqual(orderBefore);
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-a", "pkg-c"]);
});
it("keeps package order untouched when auto sort is disabled", () => {

View File

@ -17,6 +17,7 @@ function makeItems(names: string[]): MinimalItem[] {
}
describe("resolveArchiveItemsFromList", () => {
// ── Multipart RAR (.partN.rar) ──
it("matches multipart .part1.rar archives", () => {
const items = makeItems([
@ -45,6 +46,8 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(3);
});
// ── Old-style RAR (.rar + .r00, .r01, etc.) ──
it("matches old-style .rar + .rNN volumes", () => {
const items = makeItems([
"Archive.rar",
@ -57,6 +60,8 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(4);
});
// ── Single RAR ──
it("matches a single .rar file", () => {
const items = makeItems(["SingleFile.rar", "Other.mkv"]);
const result = resolveArchiveItemsFromList("SingleFile.rar", items as any);
@ -64,6 +69,8 @@ describe("resolveArchiveItemsFromList", () => {
expect((result[0] as any).fileName).toBe("SingleFile.rar");
});
// ── Split ZIP ──
it("matches split .zip.NNN files", () => {
const items = makeItems([
"Data.zip",
@ -75,6 +82,8 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(4);
});
// ── Split 7z ──
it("matches split .7z.NNN files", () => {
const items = makeItems([
"Backup.7z.001",
@ -84,6 +93,8 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(2);
});
// ── Generic .NNN splits ──
it("matches generic .NNN split files", () => {
const items = makeItems([
"video.001",
@ -94,6 +105,8 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(3);
});
// ── Exact filename match ──
it("matches a single .zip by exact name", () => {
const items = makeItems(["myarchive.zip", "other.rar"]);
const result = resolveArchiveItemsFromList("myarchive.zip", items as any);
@ -101,6 +114,8 @@ describe("resolveArchiveItemsFromList", () => {
expect((result[0] as any).fileName).toBe("myarchive.zip");
});
// ── Case insensitivity ──
it("matches case-insensitively", () => {
const items = makeItems([
"MOVIE.PART1.RAR",
@ -110,26 +125,40 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(2);
});
// ── Stem-based fallback ──
it("uses stem-based fallback when exact patterns fail", () => {
// Simulate a debrid service that renames "Movie.part1.rar" to "Movie.part1_dl.rar"
// but the disk file is "Movie.part1.rar"
const items = makeItems([
"Movie.rar",
]);
// The archive on disk is "Movie.part1.rar" but there's no item matching the
// .partN pattern. The stem "movie" should match "Movie.rar" via fallback.
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
// stem fallback: "movie" starts with "movie" and ends with .rar
expect(result).toHaveLength(1);
});
// ── Single item fallback ──
it("returns single archive item when no pattern matches", () => {
const items = makeItems(["totally-different-name.rar"]);
const result = resolveArchiveItemsFromList("Original.rar", items as any);
// Single item in list with archive extension → return it
expect(result).toHaveLength(1);
});
// ── Empty when no match ──
it("returns empty when items have no archive extensions", () => {
const items = makeItems(["video.mkv", "subtitle.srt"]);
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
expect(result).toHaveLength(0);
});
// ── Items without targetPath ──
it("falls back to fileName when targetPath is missing", () => {
const items = [
{ fileName: "Movie.part1.rar", id: "1", status: "completed" },
@ -139,6 +168,8 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(2);
});
// ── Multiple archives, should not cross-match ──
it("does not cross-match different archive groups", () => {
const items = makeItems([
"Episode.S01E01.part1.rar",

View File

@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import { pruneSelection } from "../src/renderer/selection";
import type { SessionState } from "../src/shared/types";
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
const packages: Record<string, never> = {};
const items: Record<string, never> = {};
for (const id of packageIds) packages[id] = {} as never;
for (const id of itemIds) items[id] = {} as never;
return { packages, items };
}
describe("pruneSelection", () => {
it("drops ids whose package/item no longer exists", () => {
const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]);
const next = pruneSelection(sel, session(["p1"], ["i1"]));
expect([...next].sort()).toEqual(["i1", "p1"]);
});
it("returns the SAME set instance when nothing changed (no needless re-render)", () => {
const sel = new Set(["p1", "i1"]);
const next = pruneSelection(sel, session(["p1"], ["i1"]));
expect(next).toBe(sel);
});
it("returns the same instance for an empty selection", () => {
const sel = new Set<string>();
expect(pruneSelection(sel, session(["p1"], ["i1"]))).toBe(sel);
});
it("prunes everything when the whole session was swapped out", () => {
const sel = new Set(["p1", "i1"]);
const next = pruneSelection(sel, session([], []));
expect(next.size).toBe(0);
expect(next).not.toBe(sel);
});
it("keeps a mixed package+item selection when both survive", () => {
const sel = new Set(["p1", "p2", "i1"]);
const next = pruneSelection(sel, session(["p1", "p2"], ["i1", "i2"]));
expect([...next].sort()).toEqual(["i1", "p1", "p2"]);
expect(next).toBe(sel); // unchanged → same instance
});
});

View File

@ -8,7 +8,9 @@ import { setLogListener } from "../src/main/logger";
const tempDirs: string[] = [];
afterEach(() => {
// Ensure session log is shut down between tests
shutdownSessionLog();
// Ensure listener is cleared between tests
setLogListener(null);
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
@ -40,9 +42,11 @@ describe("session-log", () => {
initSessionLog(baseDir);
const logPath = getSessionLogPath()!;
// Simulate a log line via the listener
const { logger } = await import("../src/main/logger");
logger.info("Test-Nachricht für Session-Log");
// Wait for flush (200ms interval + margin)
await new Promise((resolve) => setTimeout(resolve, 500));
const content = fs.readFileSync(logPath, "utf8");
@ -73,6 +77,7 @@ describe("session-log", () => {
shutdownSessionLog();
// Log after shutdown - should NOT appear in session log
const { logger } = await import("../src/main/logger");
logger.info("Nach-Shutdown-Nachricht");
@ -89,16 +94,21 @@ describe("session-log", () => {
const logsDir = path.join(baseDir, "session-logs");
fs.mkdirSync(logsDir, { recursive: true });
// Create a fake old session log
const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt");
fs.writeFileSync(oldFile, "old session");
// Set mtime to 30 days ago
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
fs.utimesSync(oldFile, oldTime, oldTime);
// Create a recent file
const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt");
fs.writeFileSync(newFile, "new session");
// initSessionLog triggers cleanup
initSessionLog(baseDir);
// Wait for async cleanup
await new Promise((resolve) => setTimeout(resolve, 300));
expect(fs.existsSync(oldFile)).toBe(false);
@ -114,6 +124,7 @@ describe("session-log", () => {
const logsDir = path.join(baseDir, "session-logs");
fs.mkdirSync(logsDir, { recursive: true });
// Create a file from 2 days ago (should be kept)
const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt");
fs.writeFileSync(recentFile, "recent session");
const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
@ -136,6 +147,7 @@ describe("session-log", () => {
const path1 = getSessionLogPath();
shutdownSessionLog();
// Small delay to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 1100));
initSessionLog(baseDir);

View File

@ -1,132 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DownloadItem, PackageEntry, SessionState } from "../src/shared/types";
import {
cancelPendingAsyncSaves,
createStoragePaths,
emptySession,
loadSession,
saveSession,
saveSessionAsync
} from "../src/main/storage";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function makePackage(id: string, itemId: string): PackageEntry {
return {
id,
name: `Package ${id}`,
outputDir: "C:/tmp/out",
extractDir: "C:/tmp/extract",
status: "queued",
itemIds: [itemId],
cancelled: false,
enabled: true,
downloadStartedAt: 0,
downloadCompletedAt: 0,
createdAt: 1,
updatedAt: 1
};
}
function makeItem(id: string, packageId: string): DownloadItem {
return {
id,
packageId,
url: `https://example.com/${id}`,
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: `${id}.rar`,
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: 1,
updatedAt: 1
};
}
function sessionWith(ids: string[]): SessionState {
const s = emptySession();
for (const id of ids) {
const itemId = `${id}-item`;
s.packageOrder.push(id);
s.packages[id] = makePackage(id, itemId);
s.items[itemId] = makeItem(itemId, id);
}
return s;
}
const settle = (ms = 250): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
describe("session restart loss", () => {
it("does not let a queued stale async save clobber a newer synchronous save", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
cancelPendingAsyncSaves();
await settle(50);
saveSession(paths, sessionWith(["A", "B"]));
const inflight = saveSessionAsync(paths, sessionWith(["A", "B"]));
const queued = saveSessionAsync(paths, sessionWith(["A", "B"]));
saveSession(paths, sessionWith(["A", "B", "C"]));
await inflight;
await queued;
await settle();
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B", "C"]);
});
it("recovers packages from the backup when the primary session file is absent", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
expect(fs.existsSync(paths.sessionFile)).toBe(false);
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
});
it("still treats a truly fresh install (no primary, no backup, no temp) as empty", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages)).toEqual([]);
expect(Object.keys(loaded.items)).toEqual([]);
});
it("recovers from the backup when the primary exists but is empty", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.sessionFile, JSON.stringify(emptySession()), "utf8");
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
});
});

View File

@ -1,134 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths } from "../src/main/storage";
import { runStartupHealthCheck } from "../src/main/startup-health-check";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
function makeTempBase(): { baseDir: string; outputDir: string; paths: ReturnType<typeof createStoragePaths> } {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-"));
tempDirs.push(baseDir);
const outputDir = path.join(baseDir, "downloads");
fs.mkdirSync(outputDir, { recursive: true });
return {
baseDir: path.join(baseDir, "runtime"),
outputDir,
paths: createStoragePaths(path.join(baseDir, "runtime"))
};
}
describe("runStartupHealthCheck", () => {
it("flags missing download directory", () => {
const { outputDir, paths } = makeTempBase();
fs.mkdirSync(paths.baseDir, { recursive: true });
const settings = {
...defaultSettings(),
token: "rd-token",
outputDir: path.join(outputDir, "does-not-exist-subdir")
};
const report = runStartupHealthCheck(settings, paths);
const codes = report.findings.map((f) => f.code);
expect(codes).toContain("outputDir_not_found");
});
it("flags no-provider-configured when all credentials are empty", () => {
const { outputDir, paths } = makeTempBase();
fs.mkdirSync(paths.baseDir, { recursive: true });
const settings = {
...defaultSettings(),
token: "",
megaLogin: "",
megaPassword: "",
megaCredentials: "",
allDebridToken: "",
bestToken: "",
oneFichierApiKey: "",
debridLinkApiKeys: "",
outputDir
};
const report = runStartupHealthCheck(settings, paths);
const codes = report.findings.map((f) => f.code);
expect(codes).toContain("no_provider_configured");
expect(report.warnCount).toBeGreaterThanOrEqual(1);
});
it("reports configured providers when at least one credential is set", () => {
const { outputDir, paths } = makeTempBase();
fs.mkdirSync(paths.baseDir, { recursive: true });
const settings = {
...defaultSettings(),
token: "rd-token-here",
debridLinkApiKeys: "dl-key-a\ndl-key-b",
outputDir
};
const report = runStartupHealthCheck(settings, paths);
const providersFinding = report.findings.find((f) => f.code === "providers_configured");
expect(providersFinding).toBeDefined();
expect(providersFinding?.message).toContain("Real-Debrid");
expect(providersFinding?.message).toContain("Debrid-Link");
expect(providersFinding?.message).toContain("2 Keys");
});
it("flags large state files", () => {
const { outputDir, paths } = makeTempBase();
fs.mkdirSync(paths.baseDir, { recursive: true });
fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0));
const settings = {
...defaultSettings(),
token: "rd-token",
outputDir
};
const report = runStartupHealthCheck(settings, paths);
const codes = report.findings.map((f) => f.code);
expect(codes).toContain("large_state_file");
});
it("flags missing base dir as ERROR", () => {
const { outputDir, paths } = makeTempBase();
const settings = {
...defaultSettings(),
token: "rd-token",
outputDir
};
const report = runStartupHealthCheck(settings, paths);
const codes = report.findings.map((f) => f.code);
expect(codes).toContain("baseDir_missing");
expect(report.errorCount).toBeGreaterThanOrEqual(1);
});
it("passes cleanly when everything is healthy", () => {
const { outputDir, paths } = makeTempBase();
fs.mkdirSync(paths.baseDir, { recursive: true });
const settings = {
...defaultSettings(),
token: "rd-token-here",
outputDir
};
const report = runStartupHealthCheck(settings, paths);
expect(report.errorCount).toBe(0);
const codes = report.findings.map((f) => f.code);
expect(codes).not.toContain("outputDir_not_found");
expect(codes).not.toContain("outputDir_not_writable");
expect(codes).not.toContain("no_provider_configured");
expect(codes).not.toContain("baseDir_missing");
expect(codes).not.toContain("baseDir_not_writable");
});
});

View File

@ -6,7 +6,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { AppSettings } from "../src/shared/types";
import { defaultSettings } from "../src/main/constants";
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
import { createStoragePaths, emptySession, loadSession, loadSettings, normalizeSettings, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
const tempDirs: string[] = [];
@ -273,65 +273,6 @@ describe("settings storage", () => {
expect(normalizedDisabled.allDebridUseWebLogin).toBe(false);
});
it("defaults history retention to permanent and normalizes invalid values", () => {
expect(defaultSettings().historyRetentionMode).toBe("permanent");
const normalized = normalizeSettings({
...defaultSettings(),
historyRetentionMode: "broken" as unknown as AppSettings["historyRetentionMode"]
});
expect(normalized.historyRetentionMode).toBe("permanent");
});
it("skips adding persisted history entries when history retention is never", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const result = addHistoryEntryForRetention(paths, "never", {
id: "hist-1",
name: "ignored",
totalBytes: 1024,
downloadedBytes: 1024,
fileCount: 1,
provider: "realdebrid",
completedAt: Date.now(),
durationSeconds: 12,
status: "completed",
outputDir: path.join(dir, "out"),
urls: ["https://example.com/file.rar"]
});
expect(result).toEqual([]);
expect(loadHistory(paths)).toEqual([]);
expect(loadHistoryForRetention(paths, "never")).toEqual([]);
});
it("clears persisted history for session retention mode", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveHistory(paths, [{
id: "hist-2",
name: "kept",
totalBytes: 2048,
downloadedBytes: 2048,
fileCount: 1,
provider: "realdebrid",
completedAt: Date.now(),
durationSeconds: 20,
status: "completed",
outputDir: path.join(dir, "out"),
urls: ["https://example.com/file2.rar"]
}]);
resetHistoryForRetention(paths, "session");
expect(loadHistory(paths)).toEqual([]);
});
it("assigns and preserves bandwidth schedule ids", () => {
const normalized = normalizeSettings({
...defaultSettings(),
@ -364,8 +305,6 @@ describe("settings storage", () => {
itemIds: ["item1", "item2", "item3", "item4"],
cancelled: false,
enabled: true,
downloadStartedAt: 0,
downloadCompletedAt: 0,
createdAt: Date.now(),
updatedAt: Date.now()
};
@ -453,13 +392,19 @@ describe("settings storage", () => {
saveSession(paths, session);
const loaded = loadSession(paths);
// Active statuses (downloading, paused) should be reset to "queued"
expect(loaded.items["item1"].status).toBe("queued");
expect(loaded.items["item2"].status).toBe("queued");
// Speed should be cleared
expect(loaded.items["item1"].speedBps).toBe(0);
// lastError should be cleared for reset items
expect(loaded.items["item1"].lastError).toBe("");
// Completed and queued statuses should be preserved
expect(loaded.items["item3"].status).toBe("completed");
expect(loaded.items["item4"].status).toBe("queued");
// Downloaded bytes should be preserved
expect(loaded.items["item1"].downloadedBytes).toBe(5000);
// Package data should be preserved
expect(loaded.packages["pkg1"].name).toBe("Test Package");
});
@ -493,8 +438,6 @@ describe("settings storage", () => {
itemIds: ["item-backup"],
cancelled: false,
enabled: true,
downloadStartedAt: 0,
downloadCompletedAt: 0,
createdAt: Date.now(),
updatedAt: Date.now()
};
@ -536,6 +479,7 @@ describe("settings storage", () => {
tempDirs.push(dir);
const paths = createStoragePaths(dir);
// Write invalid JSON to the config file
fs.writeFileSync(paths.configFile, "{{{{not valid json!!!}", "utf8");
const loaded = loadSettings(paths);
@ -605,75 +549,6 @@ describe("settings storage", () => {
expect(loaded.packageOrder).toEqual(["pkg-valid"]);
});
it("drops unsafe session ids and target paths outside the package output directory", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const outputDir = path.join(dir, "downloads", "safe");
const safeTargetPath = path.join(outputDir, "safe.bin");
const outsideTargetPath = path.join(dir, "outside.bin");
fs.writeFileSync(paths.sessionFile, JSON.stringify({
version: 2,
packageOrder: ["pkg-safe", "../pkg-evil"],
packages: {
"pkg-safe": {
id: "pkg-safe",
name: "Safe Package",
outputDir,
extractDir: path.join(dir, "extract", "safe"),
status: "queued",
itemIds: ["item-safe", "item-outside", "../item-evil"],
cancelled: false,
enabled: true
},
"../pkg-evil": {
id: "../pkg-evil",
name: "Unsafe Package",
outputDir,
extractDir: path.join(dir, "extract", "unsafe"),
status: "queued",
itemIds: ["item-evil"],
cancelled: false,
enabled: true
}
},
items: {
"item-safe": {
id: "item-safe",
packageId: "pkg-safe",
url: "https://example.com/safe",
status: "queued",
fileName: "safe.bin",
targetPath: safeTargetPath
},
"item-outside": {
id: "item-outside",
packageId: "pkg-safe",
url: "https://example.com/outside",
status: "queued",
fileName: "outside.bin",
targetPath: outsideTargetPath
},
"../item-evil": {
id: "../item-evil",
packageId: "pkg-safe",
url: "https://example.com/evil",
status: "queued",
fileName: "evil.bin",
targetPath: safeTargetPath
}
}
}), "utf8");
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages)).toEqual(["pkg-safe"]);
expect(Object.keys(loaded.items).sort()).toEqual(["item-outside", "item-safe"]);
expect(loaded.packageOrder).toEqual(["pkg-safe"]);
expect(path.resolve(loaded.items["item-safe"]?.targetPath || "")).toBe(path.resolve(safeTargetPath));
expect(loaded.items["item-outside"]?.targetPath).toBe("");
});
it("captures async session save payload before later mutations", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
@ -721,6 +596,7 @@ describe("settings storage", () => {
tempDirs.push(dir);
const paths = createStoragePaths(dir);
// Write a minimal config that simulates an old version missing newer fields
fs.writeFileSync(
paths.configFile,
JSON.stringify({
@ -734,9 +610,11 @@ describe("settings storage", () => {
const loaded = loadSettings(paths);
const defaults = defaultSettings();
// Old fields should be preserved
expect(loaded.token).toBe("my-token");
expect(loaded.outputDir).toBe(path.resolve("/custom/output"));
// Missing new fields should get default values
expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback);
expect(loaded.hybridExtract).toBe(defaults.hybridExtract);
expect(loaded.completedCleanupPolicy).toBe(defaults.completedCleanupPolicy);

View File

@ -1,172 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import http from "node:http";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession, loadSession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
const tempDirs: string[] = [];
const originalFetch = globalThis.fetch;
afterEach(async () => {
globalThis.fetch = originalFetch;
shutdownItemLogs();
shutdownPackageLogs();
for (const dir of tempDirs.splice(0)) {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
fs.rmSync(dir, { recursive: true, force: true });
break;
} catch {
await new Promise((resolve) => setTimeout(resolve, 80));
}
}
}
});
async function waitFor(predicate: () => boolean, timeoutMs = 20000): Promise<void> {
const started = Date.now();
while (!predicate()) {
if (Date.now() - started > timeoutMs) {
throw new Error("waitFor timeout");
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function startTricklingServer(): Promise<{ directUrl: string; stop: () => Promise<void> }> {
const openTimers = new Set<NodeJS.Timeout>();
const openResponses = new Set<http.ServerResponse>();
const server = http.createServer((req, res) => {
if ((req.url || "") !== "/direct") {
res.statusCode = 404;
res.end("not-found");
return;
}
res.statusCode = 200;
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Length", String(64 * 1024 * 1024));
openResponses.add(res);
res.write(Buffer.alloc(64 * 1024, 7));
const timer = setInterval(() => {
try {
res.write(Buffer.alloc(16 * 1024, 9));
} catch {
}
}, 100);
openTimers.add(timer);
res.on("close", () => {
clearInterval(timer);
openTimers.delete(timer);
openResponses.delete(res);
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("server address unavailable");
}
const directUrl = `http://127.0.0.1:${address.port}/direct`;
const stop = async (): Promise<void> => {
for (const timer of openTimers) {
clearInterval(timer);
}
openTimers.clear();
for (const res of openResponses) {
try {
res.destroy();
} catch {
}
}
openResponses.clear();
server.close();
await once(server, "close");
};
return { directUrl, stop };
}
function mockUnrestrict(directUrl: string): void {
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/unrestrict/link")) {
return new Response(
JSON.stringify({ download: directUrl, filename: "episode.mkv", filesize: 64 * 1024 * 1024 }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
return originalFetch(input, init);
};
}
async function driveActiveDownload(root: string): Promise<{ manager: DownloadManager; paths: ReturnType<typeof createStoragePaths>; serverStop: () => Promise<void> }> {
const { directUrl, stop: serverStop } = await startTricklingServer();
mockUnrestrict(directUrl);
const paths = createStoragePaths(path.join(root, "state"));
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false,
autoReconnect: false,
retryLimit: 0
},
emptySession(),
paths
);
manager.addPackages([{ name: "park", links: ["https://dummy/park"] }]);
await manager.start();
await waitFor(() => {
const item = Object.values(manager.getSnapshot().session.items)[0];
return item?.status === "downloading" && (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size > 0;
});
return { manager, paths, serverStop };
}
describe("update restart resume", () => {
it("characterization: a plain stop() leaves an in-flight item cancelled across a restart", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-"));
tempDirs.push(root);
const { manager, paths, serverStop } = await driveActiveDownload(root);
try {
manager.stop();
manager.persistNowSync();
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
manager.prepareForShutdown();
const reloaded = loadSession(paths);
const item = Object.values(reloaded.items)[0];
expect(item).toBeTruthy();
expect(item.status).toBe("cancelled");
} finally {
await serverStop();
}
});
it("parks an in-flight item as queued for an update restart so it auto-resumes", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-"));
tempDirs.push(root);
const { manager, paths, serverStop } = await driveActiveDownload(root);
try {
manager.stop({ parkForRestart: true });
manager.persistNowSync();
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
manager.prepareForShutdown();
const reloaded = loadSession(paths);
const item = Object.values(reloaded.items)[0];
expect(item).toBeTruthy();
expect(Object.keys(reloaded.packages).length).toBe(1);
expect(item.status).toBe("queued");
} finally {
await serverStop();
}
});
});

View File

@ -319,35 +319,6 @@ describe("update", () => {
expect(result.message).toMatch(/integrit|sha256|mismatch/i);
});
it("blocks installer start when no digest can be resolved", async () => {
const executablePayload = fs.readFileSync(process.execPath);
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("unsigned-setup.exe")) {
return new Response(executablePayload, {
status: 200,
headers: { "Content-Type": "application/octet-stream" }
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
const prechecked: UpdateCheckResult = {
updateAvailable: true,
currentVersion: APP_VERSION,
latestVersion: "9.9.9",
latestTag: "",
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/unsigned-setup.exe",
setupAssetName: "setup.exe",
setupAssetDigest: ""
};
const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(false);
expect(result.message).toMatch(/digest|integrit|sha/i);
});
it("uses latest.yml SHA512 digest when API asset digest is missing", async () => {
const executablePayload = fs.readFileSync(process.execPath);
const digestSha512Hex = sha512Hex(executablePayload);
@ -614,6 +585,7 @@ describe("parseVersionParts", () => {
});
it("handles version with pre-release suffix", () => {
// Non-numeric suffixes are stripped per part
expect(parseVersionParts("1.2.3-beta")).toEqual([1, 2, 3]);
expect(parseVersionParts("1.2.3rc1")).toEqual([1, 2, 3]);
});

View File

@ -92,6 +92,7 @@ describe("utils", () => {
const result = sanitizeFilename(longName);
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
// The function should return a non-empty string and not crash
expect(result).toBe(longName);
});
@ -99,6 +100,7 @@ describe("utils", () => {
const result = formatEta(999999);
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
// 999999 seconds = 277h 46m 39s
expect(result).toBe("277:46:39");
});
@ -111,22 +113,28 @@ describe("utils", () => {
it("extracts filenames from URLs with encoded characters", () => {
expect(filenameFromUrl("https://example.com/file%20with%20spaces.rar")).toBe("file with spaces.rar");
// %C3%A9 decodes to e-acute (UTF-8), which is preserved
expect(filenameFromUrl("https://example.com/t%C3%A9st%20file.zip")).toBe("t\u00e9st file.zip");
expect(filenameFromUrl("https://example.com/dl?filename=Movie%20Name%20S01E01.mkv")).toBe("Movie Name S01E01.mkv");
// Malformed percent-encoding should not crash
const result = filenameFromUrl("https://example.com/%ZZ%invalid");
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
it("handles looksLikeOpaqueFilename edge cases", () => {
// Empty string -> sanitizeFilename returns "Paket" which is not opaque
expect(looksLikeOpaqueFilename("")).toBe(false);
expect(looksLikeOpaqueFilename("a")).toBe(false);
expect(looksLikeOpaqueFilename("ab")).toBe(false);
expect(looksLikeOpaqueFilename("abc")).toBe(false);
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
// 24-char hex string is opaque (matches /^[a-f0-9]{24,}$/)
expect(looksLikeOpaqueFilename("abcdef123456789012345678")).toBe(true);
expect(looksLikeOpaqueFilename("abcdef1234567890abcdef12")).toBe(true);
// Short hex strings (< 24 chars) are NOT considered opaque
expect(looksLikeOpaqueFilename("abcdef12345")).toBe(false);
// Real filename with extension
expect(looksLikeOpaqueFilename("Show.S01E01.720p.mkv")).toBe(false);
});
});

View File

@ -1,348 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
stripDualLangMarker,
hasDualLangMarker,
isRemuxableVideoFile,
looksLikeGermanRelease,
pickAudioTrack,
parseFfprobeAudioStreams,
buildFfprobeArgs,
buildFfmpegRemuxArgs,
computeRemuxTimeoutMs,
processVideoFile,
renameWithRetry,
type VideoSpawnResult
} from "../src/main/video-processor";
describe("stripDualLangMarker", () => {
it("strips a mid-name .DL. token", () => {
expect(stripDualLangMarker("Show.S01E01.German.DL.720p.WEB.x264.mkv")).toBe("Show.S01E01.German.720p.WEB.x264.mkv");
});
it("strips a .DL. directly before the extension", () => {
expect(stripDualLangMarker("Movie.DL.mkv")).toBe("Movie.mkv");
});
it("strips a trailing .DL token before extension", () => {
expect(stripDualLangMarker("Movie.German.DL.mp4")).toBe("Movie.German.mp4");
});
it("is case-insensitive", () => {
expect(stripDualLangMarker("Show.dl.1080p.mkv")).toBe("Show.1080p.mkv");
});
it("leaves files without the marker unchanged", () => {
expect(stripDualLangMarker("Show.S01E01.German.1080p.mkv")).toBe("Show.S01E01.German.1080p.mkv");
});
it("does not strip unrelated tokens containing DL", () => {
expect(stripDualLangMarker("Show.HANDLES.1080p.mkv")).toBe("Show.HANDLES.1080p.mkv");
});
});
describe("hasDualLangMarker", () => {
it("detects the marker", () => {
expect(hasDualLangMarker("X.German.DL.720p.mkv")).toBe(true);
expect(hasDualLangMarker("X.DL.mkv")).toBe(true);
});
it("returns false without the marker", () => {
expect(hasDualLangMarker("X.German.720p.mkv")).toBe(false);
});
});
describe("isRemuxableVideoFile", () => {
it("accepts mkv/mp4 only", () => {
expect(isRemuxableVideoFile("a.mkv")).toBe(true);
expect(isRemuxableVideoFile("a.MP4")).toBe(true);
expect(isRemuxableVideoFile("a.avi")).toBe(false);
expect(isRemuxableVideoFile("a.srt")).toBe(false);
});
});
describe("pickAudioTrack", () => {
const ger = { language: "ger", title: "" };
const eng = { language: "eng", title: "" };
const untagged = { language: "", title: "" };
it("no audio -> skip", () => {
expect(pickAudioTrack([], "tag").action).toBe("skip");
});
it("first mode keeps first of many", () => {
const d = pickAudioTrack([eng, ger], "first");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0 });
});
it("first mode with single audio -> single (no remux)", () => {
expect(pickAudioTrack([eng], "first")).toMatchObject({ action: "single" });
});
it("tag mode picks the German track even if not first", () => {
const d = pickAudioTrack([eng, ger], "tag");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
});
it("tag mode picks German via title when language untagged", () => {
const d = pickAudioTrack([{ language: "", title: "Englisch" }, { language: "", title: "Deutsch" }], "tag");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1 });
});
it("tag mode does NOT treat an ambiguous 3-letter title code as German (no false-positive pick)", () => {
// Two untagged tracks whose titles are only "Ger"/"Deu" must not be mistaken
// for a German track; with no real German signal this falls back to first.
const d = pickAudioTrack([{ language: "", title: "Ger" }, { language: "", title: "Deu" }], "tag");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
});
it("tag mode with single German -> single (no remux)", () => {
expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" });
});
it("tag mode, fully untagged multi -> fallback to first", () => {
const d = pickAudioTrack([untagged, untagged], "tag");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
});
it("tag mode, tagged but no German -> SKIP (never delete the only usable audio)", () => {
expect(pickAudioTrack([eng, { language: "fre", title: "" }], "tag")).toMatchObject({ action: "skip", reason: "no-german-track" });
});
it("tag mode, no German tag but GERMAN release -> fall back to first track (mislabeled dub)", () => {
expect(pickAudioTrack([eng, eng], "tag", true)).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" });
});
it("tag mode, single mislabeled track on a German release -> keep it (no remux)", () => {
expect(pickAudioTrack([eng], "tag", true)).toMatchObject({ action: "single", reason: "single-german-mislabeled" });
});
it("tag mode, no German tag and NOT flagged German -> still SKIP (safety preserved)", () => {
expect(pickAudioTrack([eng, eng], "tag", false)).toMatchObject({ action: "skip", reason: "no-german-track" });
});
it("correctly tagged German still wins even on a German release (fallback not needed)", () => {
expect(pickAudioTrack([eng, ger], "tag", true)).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
});
});
describe("looksLikeGermanRelease", () => {
it("detects German/Dubbed release names", () => {
expect(looksLikeGermanRelease("Desperate.Housewives.S02E01.German.DD51.Dubbed.DL.720p.WEB-DL.x264.mkv")).toBe(true);
expect(looksLikeGermanRelease("1899.S01E01.German.DL.720p.WEB-x264-WvF.mkv")).toBe(true);
expect(looksLikeGermanRelease("Show.S01E01.Deutsch.1080p.mkv")).toBe(true);
});
it("does not flag a bare .DL. name without an explicit German token", () => {
expect(looksLikeGermanRelease("Show.S01E01.DL.720p.x264.mkv")).toBe(false);
expect(looksLikeGermanRelease("Show.S01E01.MULTi.1080p.mkv")).toBe(false);
});
it("does not flag a non-German dub as a German release (bare 'Dubbed' is ambiguous)", () => {
expect(looksLikeGermanRelease("Movie.2020.ITALIAN.Dubbed.DL.1080p.mkv")).toBe(false);
expect(looksLikeGermanRelease("Movie.2020.FRENCH.DUBBED.DL.720p.mkv")).toBe(false);
});
});
describe("parseFfprobeAudioStreams", () => {
it("parses language/title tags", () => {
const json = JSON.stringify({ streams: [{ index: 1, tags: { language: "ger", title: "Deutsch" } }, { index: 2, tags: { language: "eng" } }] });
expect(parseFfprobeAudioStreams(json)).toEqual([{ language: "ger", title: "Deutsch" }, { language: "eng", title: "" }]);
});
it("returns [] on invalid json", () => {
expect(parseFfprobeAudioStreams("not json")).toEqual([]);
});
it("returns [] when streams missing", () => {
expect(parseFfprobeAudioStreams("{}")).toEqual([]);
});
});
describe("buildFfprobeArgs", () => {
it("requests audio streams as json", () => {
const args = buildFfprobeArgs("in.mkv");
expect(args).toContain("-select_streams");
expect(args).toContain("a");
expect(args[args.length - 1]).toBe("in.mkv");
expect(args).toContain("json");
});
});
describe("buildFfmpegRemuxArgs", () => {
it("maps video + chosen audio, stream-copy, keeps metadata (language tag), no subs by default", () => {
const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 1 });
expect(args).toEqual([
"-i", "in.mkv", "-map", "0:v:0", "-map", "0:a:1",
"-c", "copy", "-disposition:a:0", "default", "-y", "out.mkv"
]);
expect(args).not.toContain("-map_metadata"); // language tag of kept track must survive
});
it("adds optional German subtitle maps when keepSubs", () => {
const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 0, keepSubs: true });
expect(args.join(" ")).toContain("0:s:m:language:ger?");
});
});
describe("computeRemuxTimeoutMs", () => {
it("has a floor", () => {
expect(computeRemuxTimeoutMs(0)).toBe(120_000);
});
it("scales with size and caps at 60 min", () => {
expect(computeRemuxTimeoutMs(50 * 1024 * 1024 * 1024)).toBe(60 * 60 * 1000);
});
});
// Exercises the REAL file-mutating body (temp -> replace -> utimes -> rm) with a
// fake ffmpeg/ffprobe runner. This is the irreversible-overwrite path that the
// download-manager integration test (which mocks processVideoFile wholesale)
// cannot cover.
describe("processVideoFile (real fs body, fake runner)", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const d of tempDirs.splice(0)) {
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
function makeFile(content: string, name = "Show.S01E01.German.DL.720p.mkv"): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-vp-"));
tempDirs.push(dir);
const file = path.join(dir, name);
fs.writeFileSync(file, content);
return file;
}
function fakeRunner(opts: { probeJson: string; ffmpegOk?: boolean }): typeof import("../src/main/video-processor").runVideoProcess {
return async (_command: string, args: string[]): Promise<VideoSpawnResult> => {
const base = { aborted: false, timedOut: false, missing: false } as const;
if (args.includes("-show_entries")) {
return { ...base, ok: true, exitCode: 0, stdout: opts.probeJson, stderr: "" };
}
const output = args[args.length - 1];
if (opts.ffmpegOk !== false) {
fs.writeFileSync(output, "REMUXED-GERMAN-ONLY");
return { ...base, ok: true, exitCode: 0, stdout: "", stderr: "" };
}
return { ...base, ok: false, exitCode: 1, stdout: "", stderr: "ffmpeg boom" };
};
}
// Any sidecar the replace machinery may leave behind (unique "~rd…" temp names).
function leftoverTemps(file: string): string[] {
return fs.readdirSync(path.dirname(file)).filter((n) => n.startsWith("~rd"));
}
const tooling = async (): Promise<{ ffmpeg: string; ffprobe: string }> => ({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
const twoTracksGerSecond = JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "ger" } }] });
it("replaces the original in place and preserves mtime on success", async () => {
const file = makeFile("ORIGINAL");
const oldTime = new Date(Date.now() - 5 * 60 * 1000);
fs.utimesSync(file, oldTime, oldTime);
const beforeMtime = fs.statSync(file).mtimeMs;
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: twoTracksGerSecond })
});
expect(result.action).toBe("remuxed");
expect(result.keptTrackIndex).toBe(1); // German was second
expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY"); // original overwritten
expect(Math.abs(fs.statSync(file).mtimeMs - beforeMtime)).toBeLessThan(1500); // mtime preserved
expect(leftoverTemps(file)).toEqual([]); // unique temp cleaned up
});
it("leaves the original intact and removes temp when ffmpeg fails", async () => {
const file = makeFile("ORIGINAL");
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: twoTracksGerSecond, ffmpegOk: false })
});
expect(result.action).toBe("error");
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // never lost
expect(leftoverTemps(file)).toEqual([]);
});
it("keeps the original intact and cleans the temp when the atomic replace rename fails (no zero-copy window)", async () => {
// Simulate a Windows file lock that defeats the replace even after retries.
// The original must survive: the old rm-then-rename fallback could leave the
// file with NEITHER the original nor the remux on disk.
const file = makeFile("ORIGINAL");
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: twoTracksGerSecond }),
rename: async () => { throw Object.assign(new Error("locked"), { code: "EBUSY" }); }
});
expect(result.action).toBe("error");
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // original never destroyed
expect(leftoverTemps(file)).toEqual([]); // remux temp removed
});
it("does not touch a single-audio file (no remux)", async () => {
const file = makeFile("ORIGINAL");
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "ger" } }] }) })
});
expect(result.action).toBe("kept-single");
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
});
it("remuxes a German-named release with MISLABELED audio tags (fallback to first track)", async () => {
// Name says German, but both audio tracks are tagged eng/fre (the dub is
// mislabeled). The fallback keeps the first track instead of skipping.
const file = makeFile("ORIGINAL"); // name contains "German"
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })
});
expect(result.action).toBe("remuxed");
expect(result.keptTrackIndex).toBe(0);
expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY");
});
it("leaves a NON-German-named file untouched when tagged but no German track (safety preserved)", async () => {
const file = makeFile("ORIGINAL", "Show.S01E01.MULTi.DL.720p.mkv");
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })
});
expect(result.action).toBe("skipped-no-german");
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
});
it("returns skipped-no-tool when ffmpeg/ffprobe are absent", async () => {
const file = makeFile("ORIGINAL");
const result = await processVideoFile(file, { mode: "tag" }, { resolveTooling: async () => null });
expect(result.action).toBe("skipped-no-tool");
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
});
});
describe("renameWithRetry", () => {
afterEach(() => { vi.restoreAllMocks(); });
const busy = (): NodeJS.ErrnoException => Object.assign(new Error("locked"), { code: "EBUSY" });
it("retries a transient EBUSY and then succeeds", async () => {
let calls = 0;
vi.spyOn(fs.promises, "rename").mockImplementation(async () => {
calls += 1;
if (calls <= 2) { throw busy(); }
});
await expect(renameWithRetry("a", "b")).resolves.toBeUndefined();
expect(calls).toBe(3); // failed twice, succeeded on the third attempt
});
it("gives up after exhausting retries on a persistent lock", async () => {
let calls = 0;
vi.spyOn(fs.promises, "rename").mockImplementation(async () => { calls += 1; throw busy(); });
await expect(renameWithRetry("a", "b")).rejects.toThrow("locked");
expect(calls).toBe(4); // initial attempt + 3 backoff retries
});
it("does not retry a non-retryable error (e.g. EXDEV) — fails fast", async () => {
let calls = 0;
vi.spyOn(fs.promises, "rename").mockImplementation(async () => {
calls += 1;
throw Object.assign(new Error("cross-device"), { code: "EXDEV" });
});
await expect(renameWithRetry("a", "b")).rejects.toThrow("cross-device");
expect(calls).toBe(1);
});
});