Release v1.4.23 with critical bug audit fixes
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-02-28 12:16:08 +01:00
parent f70237f13d
commit 9598fca34e
17 changed files with 723 additions and 78 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.19", "version": "1.4.23",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.19", "version": "1.4.23",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.22", "version": "1.4.23",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -141,15 +141,42 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
let removedFiles = 0; let removedFiles = 0;
let removedDirs = 0; let removedDirs = 0;
const allDirs: string[] = []; const sampleDirs: string[] = [];
const stack = [extractDir]; const stack = [extractDir];
const countFilesRecursive = (rootDir: string): number => {
let count = 0;
const dirs = [rootDir];
while (dirs.length > 0) {
const current = dirs.pop() as string;
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
dirs.push(full);
} else if (entry.isFile()) {
count += 1;
}
}
}
return count;
};
while (stack.length > 0) { while (stack.length > 0) {
const current = stack.pop() as string; const current = stack.pop() as string;
allDirs.push(current);
for (const entry of fs.readdirSync(current, { withFileTypes: true })) { for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name); const full = path.join(current, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
const base = entry.name.toLowerCase();
if (SAMPLE_DIR_NAMES.has(base)) {
sampleDirs.push(full);
continue;
}
stack.push(full); stack.push(full);
continue; continue;
} }
@ -157,13 +184,11 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
continue; continue;
} }
const parent = path.basename(path.dirname(full)).toLowerCase();
const stem = path.parse(entry.name).name.toLowerCase(); const stem = path.parse(entry.name).name.toLowerCase();
const ext = path.extname(entry.name).toLowerCase(); const ext = path.extname(entry.name).toLowerCase();
const inSampleDir = SAMPLE_DIR_NAMES.has(parent);
const isSampleVideo = SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem); const isSampleVideo = SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem);
if (inSampleDir || isSampleVideo) { if (isSampleVideo) {
try { try {
fs.rmSync(full, { force: true }); fs.rmSync(full, { force: true });
removedFiles += 1; removedFiles += 1;
@ -174,17 +199,12 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
} }
} }
allDirs.sort((a, b) => b.length - a.length); sampleDirs.sort((a, b) => b.length - a.length);
for (const dir of allDirs) { for (const dir of sampleDirs) {
if (dir === extractDir) {
continue;
}
const base = path.basename(dir).toLowerCase();
if (!SAMPLE_DIR_NAMES.has(base)) {
continue;
}
try { try {
const filesInDir = countFilesRecursive(dir);
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
removedFiles += filesInDir;
removedDirs += 1; removedDirs += 1;
} catch { } catch {
// ignore // ignore

View File

@ -320,6 +320,9 @@ class MegaDebridClient {
web.retriesUsed = attempt - 1; web.retriesUsed = attempt - 1;
return web; return web;
} }
if (!lastError) {
lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer";
}
if (attempt < REQUEST_RETRIES) { if (attempt < REQUEST_RETRIES) {
await sleep(retryDelay(attempt)); await sleep(retryDelay(attempt));
} }
@ -358,7 +361,7 @@ class BestDebridClient {
"User-Agent": "RD-Node-Downloader/1.1.12" "User-Agent": "RD-Node-Downloader/1.1.12"
}; };
if (request.useAuthHeader) { if (request.useAuthHeader) {
headers.Authorization = this.token; headers.Authorization = `Bearer ${this.token}`;
} }
const response = await fetch(request.url, { const response = await fetch(request.url, {
@ -478,7 +481,7 @@ class AllDebridClient {
const responseLink = pickString(info, ["link"]); const responseLink = pickString(info, ["link"]);
const byResponse = canonicalToInput.get(canonicalLink(responseLink)); const byResponse = canonicalToInput.get(canonicalLink(responseLink));
const byIndex = chunk[i] || ""; const byIndex = chunk.length === 1 ? chunk[0] : "";
const original = byResponse || byIndex; const original = byResponse || byIndex;
if (!original) { if (!original) {
continue; continue;
@ -609,7 +612,7 @@ export class DebridService {
reportResolved(link, fromPage); reportResolved(link, fromPage);
}); });
const stillUnresolved = unresolved.filter((link) => !clean.has(link)); const stillUnresolved = unresolved.filter((link) => !clean.has(link) && !isRapidgatorLink(link));
await runWithConcurrency(stillUnresolved, 4, async (link) => { await runWithConcurrency(stillUnresolved, 4, async (link) => {
try { try {
const unrestricted = await this.unrestrictLink(link); const unrestricted = await this.unrestrictLink(link);
@ -629,6 +632,31 @@ export class DebridService {
this.settings.providerTertiary this.settings.providerTertiary
); );
const primary = order[0];
if (!this.settings.autoProviderFallback) {
if (!this.isProviderConfigured(primary)) {
throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`);
}
try {
const result = await this.unrestrictViaProvider(primary, link);
let fileName = result.fileName;
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
const fromPage = await resolveRapidgatorFilename(link);
if (fromPage) {
fileName = fromPage;
}
}
return {
...result,
fileName,
provider: primary,
providerLabel: PROVIDER_LABELS[primary]
};
} catch (error) {
throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[primary]}: ${compactErrorText(error)}`);
}
}
let configuredFound = false; let configuredFound = false;
const attempts: string[] = []; const attempts: string[] = [];
@ -637,9 +665,6 @@ export class DebridService {
continue; continue;
} }
configuredFound = true; configuredFound = true;
if (!this.settings.autoProviderFallback && attempts.length > 0) {
break;
}
try { try {
const result = await this.unrestrictViaProvider(provider, link); const result = await this.unrestrictViaProvider(provider, link);

View File

@ -42,6 +42,8 @@ const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000;
const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 90000; const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 90000;
const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
function getDownloadStallTimeoutMs(): number { function getDownloadStallTimeoutMs(): number {
const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN); const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) { if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) {
@ -71,6 +73,14 @@ function getGlobalStallWatchdogTimeoutMs(): number {
return DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS; return DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS;
} }
function getPostExtractTimeoutMs(): number {
const fromEnv = Number(process.env.RD_POST_EXTRACT_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 24 * 60 * 60 * 1000) {
return Math.floor(fromEnv);
}
return DEFAULT_POST_EXTRACT_TIMEOUT_MS;
}
type DownloadManagerOptions = { type DownloadManagerOptions = {
megaWebUnrestrict?: MegaWebUnrestrictor; megaWebUnrestrict?: MegaWebUnrestrictor;
}; };
@ -562,17 +572,26 @@ export class DownloadManager extends EventEmitter {
if (pkg.status === "paused") { if (pkg.status === "paused") {
pkg.status = "queued"; pkg.status = "queued";
} }
let hasReactivatedRunItems = false;
for (const itemId of pkg.itemIds) { for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId]; const item = this.session.items[itemId];
if (!item) { if (!item) {
continue; continue;
} }
if (this.session.running && !isFinishedStatus(item.status)) {
this.runOutcomes.delete(itemId);
this.runItemIds.add(itemId);
hasReactivatedRunItems = true;
}
if (item.status === "queued" && item.fullStatus === "Paket gestoppt") { if (item.status === "queued" && item.fullStatus === "Paket gestoppt") {
item.fullStatus = "Wartet"; item.fullStatus = "Wartet";
item.updatedAt = nowMs(); item.updatedAt = nowMs();
} }
} }
if (this.session.running) { if (this.session.running) {
if (hasReactivatedRunItems) {
this.runPackageIds.add(packageId);
}
void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (togglePackage): ${compactErrorText(err)}`)); void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (togglePackage): ${compactErrorText(err)}`));
} }
} }
@ -1297,6 +1316,8 @@ export class DownloadManager extends EventEmitter {
this.speedBytesLastWindow = 0; this.speedBytesLastWindow = 0;
this.lastGlobalProgressBytes = 0; this.lastGlobalProgressBytes = 0;
this.lastGlobalProgressAt = nowMs(); this.lastGlobalProgressAt = nowMs();
this.globalSpeedLimitQueue = Promise.resolve();
this.globalSpeedLimitNextAt = 0;
this.summary = null; this.summary = null;
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
@ -2103,6 +2124,9 @@ export class DownloadManager extends EventEmitter {
while (true) { while (true) {
try { try {
const unrestricted = await this.debridService.unrestrictLink(item.url); const unrestricted = await this.debridService.unrestrictLink(item.url);
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
item.provider = unrestricted.provider; item.provider = unrestricted.provider;
item.retries += unrestricted.retriesUsed; item.retries += unrestricted.retriesUsed;
item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url)); item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url));
@ -2206,24 +2230,29 @@ export class DownloadManager extends EventEmitter {
return; return;
} catch (error) { } catch (error) {
const reason = active.abortReason; const reason = active.abortReason;
const claimedTargetPath = this.claimedTargetPathByItem.get(item.id) || "";
if (reason === "cancel") { if (reason === "cancel") {
item.status = "cancelled"; item.status = "cancelled";
item.fullStatus = "Entfernt"; item.fullStatus = "Entfernt";
this.recordRunOutcome(item.id, "cancelled"); this.recordRunOutcome(item.id, "cancelled");
if (claimedTargetPath) {
try { try {
fs.rmSync(item.targetPath, { force: true }); fs.rmSync(claimedTargetPath, { force: true });
} catch { } catch {
// ignore // ignore
} }
}
} else if (reason === "stop") { } else if (reason === "stop") {
item.status = "cancelled"; item.status = "cancelled";
item.fullStatus = "Gestoppt"; item.fullStatus = "Gestoppt";
this.recordRunOutcome(item.id, "cancelled"); this.recordRunOutcome(item.id, "cancelled");
if (claimedTargetPath) {
try { try {
fs.rmSync(item.targetPath, { force: true }); fs.rmSync(claimedTargetPath, { force: true });
} catch { } catch {
// ignore // ignore
} }
}
} else if (reason === "shutdown") { } else if (reason === "shutdown") {
item.status = "queued"; item.status = "queued";
item.speedBps = 0; item.speedBps = 0;
@ -2916,6 +2945,10 @@ export class DownloadManager extends EventEmitter {
private cachedSpeedLimitAt = 0; private cachedSpeedLimitAt = 0;
private globalSpeedLimitQueue: Promise<void> = Promise.resolve();
private globalSpeedLimitNextAt = 0;
private getEffectiveSpeedLimitKbps(): number { private getEffectiveSpeedLimitKbps(): number {
const now = nowMs(); const now = nowMs();
if (now - this.cachedSpeedLimitAt < 2000) { if (now - this.cachedSpeedLimitAt < 2000) {
@ -2947,6 +2980,25 @@ export class DownloadManager extends EventEmitter {
return 0; return 0;
} }
private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number): Promise<void> {
const task = this.globalSpeedLimitQueue
.catch(() => undefined)
.then(async () => {
const now = nowMs();
const waitMs = Math.max(0, this.globalSpeedLimitNextAt - now);
if (waitMs > 0) {
await sleep(waitMs);
}
const startAt = Math.max(nowMs(), this.globalSpeedLimitNextAt);
const durationMs = Math.max(1, Math.ceil((chunkBytes / bytesPerSecond) * 1000));
this.globalSpeedLimitNextAt = startAt + durationMs;
});
this.globalSpeedLimitQueue = task;
await task;
}
private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise<void> { private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise<void> {
const limitKbps = this.getEffectiveSpeedLimitKbps(); const limitKbps = this.getEffectiveSpeedLimitKbps();
if (limitKbps <= 0) { if (limitKbps <= 0) {
@ -2967,12 +3019,7 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
this.pruneSpeedEvents(now); await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond);
const globalBytes = this.speedBytesLastWindow + chunkBytes;
const globalAllowed = bytesPerSecond * 3;
if (globalBytes > globalAllowed) {
await sleep(Math.min(250, Math.ceil(((globalBytes - globalAllowed) / bytesPerSecond) * 1000)));
}
} }
private findReadyArchiveSets(pkg: PackageEntry): Set<string> { private findReadyArchiveSets(pkg: PackageEntry): Set<string> {
@ -3194,9 +3241,28 @@ export class DownloadManager extends EventEmitter {
updateExtractingStatus("Entpacken 0%"); updateExtractingStatus("Entpacken 0%");
this.emitState(); this.emitState();
const extractTimeoutMs = 4 * 60 * 60 * 1000; const extractTimeoutMs = getPostExtractTimeoutMs();
const extractAbortController = new AbortController();
let timedOut = false;
const onParentAbort = (): void => {
if (extractAbortController.signal.aborted) {
return;
}
extractAbortController.abort("aborted:extract");
};
if (signal) {
if (signal.aborted) {
onParentAbort();
} else {
signal.addEventListener("abort", onParentAbort, { once: true });
}
}
const extractDeadline = setTimeout(() => { const extractDeadline = setTimeout(() => {
logger.error(`Post-Processing Extraction Timeout nach 4h: pkg=${pkg.name}`); timedOut = true;
logger.error(`Post-Processing Extraction Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s: pkg=${pkg.name}`);
if (!extractAbortController.signal.aborted) {
extractAbortController.abort("extract_timeout");
}
}, extractTimeoutMs); }, extractTimeoutMs);
try { try {
const result = await extractPackageArchives({ const result = await extractPackageArchives({
@ -3207,7 +3273,7 @@ export class DownloadManager extends EventEmitter {
removeLinks: this.settings.removeLinkFilesAfterExtract, removeLinks: this.settings.removeLinkFilesAfterExtract,
removeSamples: this.settings.removeSamplesAfterExtract, removeSamples: this.settings.removeSamplesAfterExtract,
passwordList: this.settings.archivePasswordList, passwordList: this.settings.archivePasswordList,
signal, signal: extractAbortController.signal,
onProgress: (progress) => { onProgress: (progress) => {
const label = progress.phase === "done" const label = progress.phase === "done"
? "Entpacken 100%" ? "Entpacken 100%"
@ -3224,7 +3290,6 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
} }
}); });
clearTimeout(extractDeadline);
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
if (result.failed > 0) { if (result.failed > 0) {
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen"); const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
@ -3257,9 +3322,18 @@ export class DownloadManager extends EventEmitter {
pkg.status = "completed"; pkg.status = "completed";
} }
} catch (error) { } catch (error) {
clearTimeout(extractDeadline);
const reasonRaw = String(error || ""); const reasonRaw = String(error || "");
if (reasonRaw.includes("aborted:extract")) { if (reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout")) {
if (timedOut) {
const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`;
logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`);
for (const entry of completedItems) {
entry.fullStatus = `Entpack-Fehler: ${timeoutReason}`;
entry.updatedAt = nowMs();
}
pkg.status = "failed";
pkg.updatedAt = nowMs();
} else {
for (const entry of completedItems) { for (const entry of completedItems) {
if (/^Entpacken/i.test(entry.fullStatus || "")) { if (/^Entpacken/i.test(entry.fullStatus || "")) {
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
@ -3271,6 +3345,7 @@ export class DownloadManager extends EventEmitter {
logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`); logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`);
return; return;
} }
}
const reason = compactErrorText(error); const reason = compactErrorText(error);
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
for (const entry of completedItems) { for (const entry of completedItems) {
@ -3278,6 +3353,11 @@ export class DownloadManager extends EventEmitter {
entry.updatedAt = nowMs(); entry.updatedAt = nowMs();
} }
pkg.status = "failed"; pkg.status = "failed";
} finally {
clearTimeout(extractDeadline);
if (signal) {
signal.removeEventListener("abort", onParentAbort);
}
} }
} else if (failed > 0) { } else if (failed > 0) {
pkg.status = "failed"; pkg.status = "failed";
@ -3380,6 +3460,8 @@ export class DownloadManager extends EventEmitter {
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();
this.globalSpeedLimitQueue = Promise.resolve();
this.globalSpeedLimitNextAt = 0;
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs(); this.lastGlobalProgressAt = nowMs();
this.persistNow(); this.persistNow();

View File

@ -12,8 +12,12 @@ const NO_EXTRACTOR_MESSAGE = "WinRAR/UnRAR nicht gefunden. Bitte WinRAR installi
let resolvedExtractorCommand: string | null = null; let resolvedExtractorCommand: string | null = null;
let resolveFailureReason = ""; let resolveFailureReason = "";
let resolveFailureAt = 0;
let externalExtractorSupportsPerfFlags = true; let externalExtractorSupportsPerfFlags = true;
const EXTRACTOR_RETRY_AFTER_MS = 30_000;
const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256;
export interface ExtractOptions { export interface ExtractOptions {
packageDir: string; packageDir: string;
targetDir: string; targetDir: string;
@ -45,6 +49,14 @@ const EXTRACT_PER_GIB_TIMEOUT_MS = 4 * 60 * 1000;
const EXTRACT_MAX_TIMEOUT_MS = 120 * 60 * 1000; const EXTRACT_MAX_TIMEOUT_MS = 120 * 60 * 1000;
const ARCHIVE_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); const ARCHIVE_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
function zipEntryMemoryLimitBytes(): number {
const fromEnvMb = Number(process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB ?? NaN);
if (Number.isFinite(fromEnvMb) && fromEnvMb >= 8 && fromEnvMb <= 4096) {
return Math.floor(fromEnvMb * 1024 * 1024);
}
return DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB * 1024 * 1024;
}
export function pathSetKey(filePath: string): string { export function pathSetKey(filePath: string): string {
return process.platform === "win32" ? filePath.toLowerCase() : filePath; return process.platform === "win32" ? filePath.toLowerCase() : filePath;
} }
@ -490,8 +502,13 @@ async function resolveExtractorCommand(): Promise<string> {
return resolvedExtractorCommand; return resolvedExtractorCommand;
} }
if (resolveFailureReason) { if (resolveFailureReason) {
const age = Date.now() - resolveFailureAt;
if (age < EXTRACTOR_RETRY_AFTER_MS) {
throw new Error(resolveFailureReason); throw new Error(resolveFailureReason);
} }
resolveFailureReason = "";
resolveFailureAt = 0;
}
const candidates = winRarCandidates(); const candidates = winRarCandidates();
for (const command of candidates) { for (const command of candidates) {
@ -503,12 +520,14 @@ async function resolveExtractorCommand(): Promise<string> {
if (!probe.missingCommand) { if (!probe.missingCommand) {
resolvedExtractorCommand = command; resolvedExtractorCommand = command;
resolveFailureReason = ""; resolveFailureReason = "";
resolveFailureAt = 0;
logger.info(`Entpacker erkannt: ${command}`); logger.info(`Entpacker erkannt: ${command}`);
return command; return command;
} }
} }
resolveFailureReason = NO_EXTRACTOR_MESSAGE; resolveFailureReason = NO_EXTRACTOR_MESSAGE;
resolveFailureAt = Date.now();
throw new Error(resolveFailureReason); throw new Error(resolveFailureReason);
} }
@ -581,6 +600,7 @@ async function runExternalExtract(
if (result.missingCommand) { if (result.missingCommand) {
resolvedExtractorCommand = null; resolvedExtractorCommand = null;
resolveFailureReason = NO_EXTRACTOR_MESSAGE; resolveFailureReason = NO_EXTRACTOR_MESSAGE;
resolveFailureAt = Date.now();
throw new Error(NO_EXTRACTOR_MESSAGE); throw new Error(NO_EXTRACTOR_MESSAGE);
} }
@ -592,6 +612,7 @@ async function runExternalExtract(
function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode): void { function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode): void {
const mode = effectiveConflictMode(conflictMode); const mode = effectiveConflictMode(conflictMode);
const memoryLimitBytes = zipEntryMemoryLimitBytes();
const zip = new AdmZip(archivePath); const zip = new AdmZip(archivePath);
const entries = zip.getEntries(); const entries = zip.getEntries();
const resolvedTarget = path.resolve(targetDir); const resolvedTarget = path.resolve(targetDir);
@ -605,6 +626,14 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
fs.mkdirSync(outputPath, { recursive: true }); fs.mkdirSync(outputPath, { recursive: true });
continue; continue;
} }
const uncompressedSize = Number((entry as unknown as { header?: { size?: number } }).header?.size ?? NaN);
if (Number.isFinite(uncompressedSize) && uncompressedSize > memoryLimitBytes) {
const entryMb = Math.ceil(uncompressedSize / (1024 * 1024));
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.mkdirSync(path.dirname(outputPath), { recursive: true });
// TOCTOU note: There is a small race between existsSync and writeFileSync below. // TOCTOU note: There is a small race between existsSync and writeFileSync below.
// This is acceptable here because zip extraction is single-threaded and we need // This is acceptable here because zip extraction is single-threaded and we need

View File

@ -25,18 +25,25 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number)
return Math.max(min, Math.min(max, Math.floor(num))); return Math.max(min, Math.min(max, Math.floor(num)));
} }
function createScheduleId(index: number): string {
return `sched-${Date.now().toString(36)}-${index.toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] { function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] {
if (!Array.isArray(raw)) { if (!Array.isArray(raw)) {
return []; return [];
} }
const normalized: BandwidthScheduleEntry[] = []; const normalized: BandwidthScheduleEntry[] = [];
for (const entry of raw) { for (let index = 0; index < raw.length; index += 1) {
const entry = raw[index];
if (!entry || typeof entry !== "object") { if (!entry || typeof entry !== "object") {
continue; continue;
} }
const value = entry as Partial<BandwidthScheduleEntry>; const value = entry as Partial<BandwidthScheduleEntry>;
const rawId = typeof value.id === "string" ? value.id.trim() : "";
normalized.push({ normalized.push({
id: rawId || createScheduleId(index),
startHour: clampNumber(value.startHour, 0, 0, 23), startHour: clampNumber(value.startHour, 0, 0, 23),
endHour: clampNumber(value.endHour, 8, 0, 23), endHour: clampNumber(value.endHour, 8, 0, 23),
speedLimitKbps: clampNumber(value.speedLimitKbps, 0, 0, 500000), speedLimitKbps: clampNumber(value.speedLimitKbps, 0, 0, 500000),

View File

@ -12,6 +12,7 @@ import { logger } from "./logger";
const RELEASE_FETCH_TIMEOUT_MS = 12000; const RELEASE_FETCH_TIMEOUT_MS = 12000;
const CONNECT_TIMEOUT_MS = 30000; const CONNECT_TIMEOUT_MS = 30000;
const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45000;
const RETRIES_PER_CANDIDATE = 3; const RETRIES_PER_CANDIDATE = 3;
const RETRY_DELAY_MS = 1500; const RETRY_DELAY_MS = 1500;
const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
@ -69,6 +70,14 @@ function timeoutController(ms: number): { signal: AbortSignal; clear: () => void
}; };
} }
function getDownloadBodyIdleTimeoutMs(): number {
const fromEnv = Number(process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 1000 && fromEnv <= 30 * 60 * 1000) {
return Math.floor(fromEnv);
}
return DOWNLOAD_BODY_IDLE_TIMEOUT_MS;
}
export function parseVersionParts(version: string): number[] { export function parseVersionParts(version: string): number[] {
const cleaned = version.replace(/^v/i, "").trim(); const cleaned = version.replace(/^v/i, "").trim();
return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0")); return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0"));
@ -200,9 +209,9 @@ function readHttpStatusFromError(error: unknown): number {
return match ? Number(match[1]) : 0; return match ? Number(match[1]) : 0;
} }
function isRecoverableDownloadError(error: unknown): boolean { function isRetryableDownloadError(error: unknown): boolean {
const status = readHttpStatusFromError(error); const status = readHttpStatusFromError(error);
if (status === 404 || status === 403 || status === 429 || status >= 500) { if (status === 429 || status >= 500) {
return true; return true;
} }
@ -215,6 +224,14 @@ function isRecoverableDownloadError(error: unknown): boolean {
|| text.includes("aborted"); || text.includes("aborted");
} }
function shouldTryNextDownloadCandidate(error: unknown): boolean {
const status = readHttpStatusFromError(error);
if (status >= 400 && status <= 599) {
return true;
}
return isRetryableDownloadError(error);
}
function deriveUpdateFileName(check: UpdateCheckResult, url: string): string { function deriveUpdateFileName(check: UpdateCheckResult, url: string): string {
const fromName = String(check.setupAssetName || "").trim(); const fromName = String(check.setupAssetName || "").trim();
if (fromName) { if (fromName) {
@ -298,7 +315,55 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream<Uint8Array>); const source = Readable.fromWeb(response.body as unknown as NodeReadableStream<Uint8Array>);
const target = fs.createWriteStream(targetPath); const target = fs.createWriteStream(targetPath);
const idleTimeoutMs = getDownloadBodyIdleTimeoutMs();
let idleTimer: NodeJS.Timeout | null = null;
const clearIdleTimer = (): void => {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
};
const onIdleTimeout = (): void => {
const timeoutError = new Error(`Update Download Body Timeout nach ${Math.ceil(idleTimeoutMs / 1000)}s`);
source.destroy(timeoutError);
target.destroy(timeoutError);
};
const resetIdleTimer = (): void => {
if (idleTimeoutMs <= 0) {
return;
}
clearIdleTimer();
idleTimer = setTimeout(onIdleTimeout, idleTimeoutMs);
};
const onSourceData = (): void => {
resetIdleTimer();
};
const onSourceDone = (): void => {
clearIdleTimer();
};
if (idleTimeoutMs > 0) {
source.on("data", onSourceData);
source.on("end", onSourceDone);
source.on("close", onSourceDone);
source.on("error", onSourceDone);
target.on("close", onSourceDone);
target.on("error", onSourceDone);
resetIdleTimer();
}
try {
await pipeline(source, target); await pipeline(source, target);
} finally {
clearIdleTimer();
source.off("data", onSourceData);
source.off("end", onSourceDone);
source.off("close", onSourceDone);
source.off("error", onSourceDone);
target.off("close", onSourceDone);
target.off("error", onSourceDone);
}
logger.info(`Update-Download abgeschlossen: ${targetPath}`); logger.info(`Update-Download abgeschlossen: ${targetPath}`);
} }
@ -319,7 +384,7 @@ async function downloadWithRetries(url: string, targetPath: string): Promise<voi
} catch { } catch {
// ignore // ignore
} }
if (attempt < RETRIES_PER_CANDIDATE && isRecoverableDownloadError(error)) { if (attempt < RETRIES_PER_CANDIDATE && isRetryableDownloadError(error)) {
logger.warn(`Update-Download Retry ${attempt}/${RETRIES_PER_CANDIDATE} für ${url}: ${compactErrorText(error)}`); logger.warn(`Update-Download Retry ${attempt}/${RETRIES_PER_CANDIDATE} für ${url}: ${compactErrorText(error)}`);
await sleep(RETRY_DELAY_MS * attempt); await sleep(RETRY_DELAY_MS * attempt);
continue; continue;
@ -342,7 +407,7 @@ async function downloadFromCandidates(candidates: string[], targetPath: string):
} catch (error) { } catch (error) {
lastError = error; lastError = error;
logger.warn(`Update-Download Kandidat ${index + 1}/${candidates.length} endgültig fehlgeschlagen: ${compactErrorText(error)}`); logger.warn(`Update-Download Kandidat ${index + 1}/${candidates.length} endgültig fehlgeschlagen: ${compactErrorText(error)}`);
if (index < candidates.length - 1 && isRecoverableDownloadError(error)) { if (index < candidates.length - 1 && shouldTryNextDownloadCandidate(error)) {
continue; continue;
} }
break; break;

View File

@ -9,6 +9,12 @@ function safeDecodeURIComponent(value: string): string {
} }
} }
const WINDOWS_RESERVED_BASENAMES = new Set([
"con", "prn", "aux", "nul",
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"
]);
export function compactErrorText(message: unknown, maxLen = 220): string { export function compactErrorText(message: unknown, maxLen = 220): string {
const raw = String(message ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); const raw = String(message ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
if (!raw) { if (!raw) {
@ -21,8 +27,27 @@ export function compactErrorText(message: unknown, maxLen = 220): string {
} }
export function sanitizeFilename(name: string): string { export function sanitizeFilename(name: string): string {
const cleaned = String(name || "").trim().replace(/\0/g, "").replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim(); const cleaned = String(name || "")
return cleaned || "Paket"; .replace(/\0/g, "")
.replace(/[\\/:*?"<>|]/g, " ")
.replace(/\s+/g, " ")
.trim();
let normalized = cleaned
.replace(/^[.\s]+/g, "")
.replace(/[.\s]+$/g, "")
.trim();
if (!normalized || normalized === "." || normalized === ".." || /^\.+$/.test(normalized)) {
return "Paket";
}
const parsed = path.parse(normalized);
if (WINDOWS_RESERVED_BASENAMES.has(parsed.name.toLowerCase())) {
normalized = `${parsed.name}_${parsed.ext}`;
}
return normalized || "Paket";
} }
export function isHttpLink(value: string): boolean { export function isHttpLink(value: string): boolean {

View File

@ -81,6 +81,23 @@ function humanSize(bytes: number): string {
let nextCollectorId = 1; let nextCollectorId = 1;
function createScheduleId(): string {
return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
const fromIndex = order.indexOf(draggedPackageId);
const toIndex = order.indexOf(targetPackageId);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
return order;
}
const next = [...order];
const [dragged] = next.splice(fromIndex, 1);
const insertIndex = Math.max(0, Math.min(next.length, toIndex));
next.splice(insertIndex, 0, dragged);
return next;
}
export function App(): ReactElement { export function App(): ReactElement {
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot); const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
const [tab, setTab] = useState<Tab>("collector"); const [tab, setTab] = useState<Tab>("collector");
@ -122,10 +139,6 @@ export function App(): ReactElement {
activeTabRef.current = tab; activeTabRef.current = tab;
}, [tab]); }, [tab]);
useEffect(() => {
settingsDirtyRef.current = settingsDirty;
}, [settingsDirty]);
const showToast = (message: string, timeoutMs = 2200): void => { const showToast = (message: string, timeoutMs = 2200): void => {
setStatusToast(message); setStatusToast(message);
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
@ -141,6 +154,7 @@ export function App(): ReactElement {
void window.rd.getSnapshot().then((state) => { void window.rd.getSnapshot().then((state) => {
setSnapshot(state); setSnapshot(state);
setSettingsDraft(state.settings); setSettingsDraft(state.settings);
settingsDirtyRef.current = false;
setSettingsDirty(false); setSettingsDirty(false);
applyTheme(state.settings.theme); applyTheme(state.settings.theme);
if (state.settings.autoUpdateCheck) { if (state.settings.autoUpdateCheck) {
@ -406,6 +420,7 @@ export function App(): ReactElement {
const persistDraftSettings = async (): Promise<AppSettings> => { const persistDraftSettings = async (): Promise<AppSettings> => {
const result = await window.rd.updateSettings(normalizedSettingsDraft); const result = await window.rd.updateSettings(normalizedSettingsDraft);
setSettingsDraft(result); setSettingsDraft(result);
settingsDirtyRef.current = false;
setSettingsDirty(false); setSettingsDirty(false);
return result; return result;
}; };
@ -485,8 +500,8 @@ export function App(): ReactElement {
const onAddLinks = async (): Promise<void> => { const onAddLinks = async (): Promise<void> => {
await performQuickAction(async () => { await performQuickAction(async () => {
await persistDraftSettings(); const persisted = await persistDraftSettings();
const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName }); const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: persisted.packageName });
if (result.addedLinks > 0) { if (result.addedLinks > 0) {
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: "" } : t)); setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: "" } : t));
@ -575,19 +590,23 @@ export function App(): ReactElement {
}; };
const setBool = (key: keyof AppSettings, value: boolean): void => { const setBool = (key: keyof AppSettings, value: boolean): void => {
settingsDirtyRef.current = true;
setSettingsDirty(true); setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, [key]: value })); setSettingsDraft((prev) => ({ ...prev, [key]: value }));
}; };
const setText = (key: keyof AppSettings, value: string): void => { const setText = (key: keyof AppSettings, value: string): void => {
settingsDirtyRef.current = true;
setSettingsDirty(true); setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, [key]: value })); setSettingsDraft((prev) => ({ ...prev, [key]: value }));
}; };
const setNum = (key: keyof AppSettings, value: number): void => { const setNum = (key: keyof AppSettings, value: number): void => {
settingsDirtyRef.current = true;
setSettingsDirty(true); setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, [key]: value })); setSettingsDraft((prev) => ({ ...prev, [key]: value }));
}; };
const setSpeedLimitMbps = (value: number): void => { const setSpeedLimitMbps = (value: number): void => {
const mbps = Number.isFinite(value) ? Math.max(0, value) : 0; const mbps = Number.isFinite(value) ? Math.max(0, value) : 0;
settingsDirtyRef.current = true;
setSettingsDirty(true); setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) })); setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
}; };
@ -628,16 +647,13 @@ export function App(): ReactElement {
}, [snapshot.session.packageOrder]); }, [snapshot.session.packageOrder]);
const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => { const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
const order = [...snapshot.session.packageOrder]; const nextOrder = reorderPackageOrderByDrop(snapshot.session.packageOrder, draggedPackageId, targetPackageId);
const fromIndex = order.indexOf(draggedPackageId); const unchanged = nextOrder.length === snapshot.session.packageOrder.length
const toIndex = order.indexOf(targetPackageId); && nextOrder.every((id, index) => id === snapshot.session.packageOrder[index]);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { if (unchanged) {
return; return;
} }
const [dragged] = order.splice(fromIndex, 1); void window.rd.reorderPackages(nextOrder);
const insertIndex = fromIndex < toIndex ? toIndex - 1 : toIndex;
order.splice(insertIndex, 0, dragged);
void window.rd.reorderPackages(order);
}, [snapshot.session.packageOrder]); }, [snapshot.session.packageOrder]);
const addCollectorTab = (): void => { const addCollectorTab = (): void => {
@ -684,18 +700,24 @@ export function App(): ReactElement {
const schedules = settingsDraft.bandwidthSchedules ?? []; const schedules = settingsDraft.bandwidthSchedules ?? [];
const addSchedule = (): void => { const addSchedule = (): void => {
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ setSettingsDraft((prev) => ({
...prev, ...prev,
bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }] bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { id: createScheduleId(), startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }]
})); }));
}; };
const removeSchedule = (idx: number): void => { const removeSchedule = (idx: number): void => {
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ setSettingsDraft((prev) => ({
...prev, ...prev,
bandwidthSchedules: (prev.bandwidthSchedules ?? []).filter((_, i) => i !== idx) bandwidthSchedules: (prev.bandwidthSchedules ?? []).filter((_, i) => i !== idx)
})); }));
}; };
const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => { const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => {
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ setSettingsDraft((prev) => ({
...prev, ...prev,
bandwidthSchedules: (prev.bandwidthSchedules ?? []).map((s, i) => i === idx ? { ...s, [field]: value } : s) bandwidthSchedules: (prev.bandwidthSchedules ?? []).map((s, i) => i === idx ? { ...s, [field]: value } : s)
@ -909,6 +931,7 @@ export function App(): ReactElement {
<button className="btn" disabled={actionBusy} onClick={onCheckUpdates}>Updates prüfen</button> <button className="btn" disabled={actionBusy} onClick={onCheckUpdates}>Updates prüfen</button>
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => { <button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
const next = settingsDraft.theme === "dark" ? "light" : "dark"; const next = settingsDraft.theme === "dark" ? "light" : "dark";
settingsDirtyRef.current = true;
setSettingsDirty(true); setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme })); setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
applyTheme(next as AppTheme); applyTheme(next as AppTheme);
@ -1019,7 +1042,7 @@ export function App(): ReactElement {
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
<h4>Bandbreitenplanung</h4> <h4>Bandbreitenplanung</h4>
{schedules.map((s, i) => ( {schedules.map((s, i) => (
<div key={i} className="schedule-row"> <div key={s.id || `schedule-${i}`} className="schedule-row">
<input type="number" min={0} max={23} value={s.startHour} onChange={(e) => updateSchedule(i, "startHour", Number(e.target.value))} title="Von (Stunde)" /> <input type="number" min={0} max={23} value={s.startHour} onChange={(e) => updateSchedule(i, "startHour", Number(e.target.value))} title="Von (Stunde)" />
<span>-</span> <span>-</span>
<input type="number" min={0} max={23} value={s.endHour} onChange={(e) => updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" /> <input type="number" min={0} max={23} value={s.endHour} onChange={(e) => updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" />

View File

@ -19,6 +19,7 @@ export type DebridFallbackProvider = DebridProvider | "none";
export type AppTheme = "dark" | "light"; export type AppTheme = "dark" | "light";
export interface BandwidthScheduleEntry { export interface BandwidthScheduleEntry {
id?: string;
startHour: number; startHour: number;
endHour: number; endHour: number;
speedLimitKbps: number; speedLimitKbps: number;

21
tests/app-order.test.ts Normal file
View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { reorderPackageOrderByDrop } from "../src/renderer/App";
describe("reorderPackageOrderByDrop", () => {
it("moves adjacent package down by one on drop", () => {
const next = reorderPackageOrderByDrop(["a", "b", "c"], "b", "c");
expect(next).toEqual(["a", "c", "b"]);
});
it("moves package after lower drop target", () => {
const next = reorderPackageOrderByDrop(["a", "b", "c", "d"], "a", "c");
expect(next).toEqual(["b", "c", "a", "d"]);
});
it("returns original order when ids are invalid", () => {
const order = ["a", "b", "c"];
expect(reorderPackageOrderByDrop(order, "x", "b")).toEqual(order);
expect(reorderPackageOrderByDrop(order, "a", "x")).toEqual(order);
expect(reorderPackageOrderByDrop(order, "a", "a")).toEqual(order);
});
});

View File

@ -114,6 +114,44 @@ describe("debrid service", () => {
expect(result.fileSize).toBe(2048); expect(result.fileSize).toBe(2048);
}); });
it("sends Bearer auth header to BestDebrid", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "best-token",
providerPrimary: "bestdebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: true
};
let authHeader = "";
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("/api/v1/generateLink?link=")) {
const headers = init?.headers;
if (headers instanceof Headers) {
authHeader = headers.get("Authorization") || "";
} else if (Array.isArray(headers)) {
const tuple = headers.find(([key]) => key.toLowerCase() === "authorization");
authHeader = tuple?.[1] || "";
} else {
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
}
return new Response(JSON.stringify({ download: "https://best.example/file.bin", filename: "file.bin", filesize: 42 }), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
const result = await service.unrestrictLink("https://hoster.example/file/abc");
expect(result.provider).toBe("bestdebrid");
expect(authHeader).toBe("Bearer best-token");
});
it("supports AllDebrid unlock", async () => { it("supports AllDebrid unlock", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
@ -216,6 +254,30 @@ describe("debrid service", () => {
expect(allDebridCalls).toBe(0); expect(allDebridCalls).toBe(0);
}); });
it("does not use secondary provider when fallback is disabled and primary is missing", async () => {
const settings = {
...defaultSettings(),
token: "",
megaLogin: "user",
megaPassword: "pass",
providerPrimary: "realdebrid" as const,
providerSecondary: "megadebrid" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
const megaWeb = vi.fn(async () => ({
fileName: "should-not-run.bin",
directUrl: "https://unused",
fileSize: null,
retriesUsed: 0
}));
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html")).rejects.toThrow(/nicht konfiguriert/i);
expect(megaWeb).toHaveBeenCalledTimes(0);
});
it("allows disabling secondary and tertiary providers", async () => { it("allows disabling secondary and tertiary providers", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
@ -367,6 +429,82 @@ describe("debrid service", () => {
{ link: linkFromProvider, fileName: "from-provider.part2.rar" } { link: linkFromProvider, fileName: "from-provider.part2.rar" }
])); ]));
}); });
it("does not unrestrict rapidgator links during filename scan after page lookup miss", async () => {
const settings = {
...defaultSettings(),
token: "rd-token",
providerPrimary: "realdebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
allDebridToken: ""
};
const link = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let unrestrictCalls = 0;
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
unrestrictCalls += 1;
return new Response(JSON.stringify({ error: "should-not-be-called" }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
if (url === link) {
return new Response("not found", { status: 404 });
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
const resolved = await service.resolveFilenames([link]);
expect(resolved.size).toBe(0);
expect(unrestrictCalls).toBe(0);
});
it("does not map AllDebrid filename infos by index when response link is missing", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "ad-token",
providerPrimary: "realdebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: true
};
const linkA = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const linkB = "https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.alldebrid.com/v4/link/infos")) {
return new Response(JSON.stringify({
status: "success",
data: {
infos: [
{ filename: "wrong-a.mkv" },
{ filename: "wrong-b.mkv" }
]
}
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
if (url === linkA || url === linkB) {
return new Response("no title", { status: 404 });
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
const resolved = await service.resolveFilenames([linkA, linkB]);
expect(resolved.size).toBe(0);
});
}); });
describe("normalizeResolvedFilename", () => { describe("normalizeResolvedFilename", () => {

View File

@ -3636,6 +3636,150 @@ describe("download manager", () => {
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt (Quelle fehlt)"); expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt (Quelle fehlt)");
}); });
it("does not delete stale target file when stopping during unrestrict phase", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false,
maxParallel: 1
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "stop-unrestrict", links: ["https://dummy/slow-unrestrict"] }]);
const initialSnapshot = manager.getSnapshot();
const pkgId = initialSnapshot.session.packageOrder[0];
const itemId = initialSnapshot.session.packages[pkgId]?.itemIds[0] || "";
if (!itemId) {
throw new Error("item missing");
}
const item = manager.getSnapshot().session.items[itemId];
const staleTargetPath = path.join(path.dirname(item.targetPath), "existing-before-start.mkv");
fs.mkdirSync(path.dirname(staleTargetPath), { recursive: true });
fs.writeFileSync(staleTargetPath, "keep", "utf8");
const mutableSession = manager.getSnapshot().session;
if (mutableSession.items[itemId]) {
mutableSession.items[itemId].targetPath = staleTargetPath;
mutableSession.items[itemId].fileName = path.basename(staleTargetPath);
mutableSession.items[itemId].downloadedBytes = 0;
mutableSession.items[itemId].progressPercent = 0;
}
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/unrestrict/link")) {
await new Promise((resolve) => setTimeout(resolve, 260));
return new Response(
JSON.stringify({
download: "https://cdn.example/unused.bin",
filename: "new-file.mkv",
filesize: 1024
}),
{
status: 200,
headers: { "Content-Type": "application/json" }
}
);
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
manager.start();
await new Promise((resolve) => setTimeout(resolve, 40));
manager.stop();
await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "cancelled", 12000);
expect(fs.existsSync(staleTargetPath)).toBe(true);
});
it("counts re-enabled package items in run summary totals", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const payload = Buffer.alloc(96 * 1024, 5);
const server = http.createServer((req, res) => {
if ((req.url || "") !== "/slow") {
res.statusCode = 404;
res.end("not-found");
return;
}
setTimeout(() => {
res.statusCode = 200;
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Length", String(payload.length));
res.end(payload);
}, 180);
});
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}/slow`;
globalThis.fetch = (async (input: RequestInfo | URL): 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: payload.length
}),
{
status: 200,
headers: { "Content-Type": "application/json" }
}
);
}
return originalFetch(input);
}) as typeof fetch;
try {
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false,
maxParallel: 1
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([
{ name: "pkg-a", links: ["https://dummy/a"] },
{ name: "pkg-b", links: ["https://dummy/b"] }
]);
const packageIds = manager.getSnapshot().session.packageOrder;
const packageToToggle = packageIds[0];
manager.start();
await new Promise((resolve) => setTimeout(resolve, 40));
manager.togglePackage(packageToToggle);
manager.togglePackage(packageToToggle);
await waitFor(() => !manager.getSnapshot().session.running, 25000);
const summary = manager.getSnapshot().summary;
expect(summary?.total).toBe(2);
} finally {
server.close();
await once(server, "close");
}
});
it("auto-renames extracted 4SF scene files to folder format", async () => { it("auto-renames extracted 4SF scene files to folder format", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);

View File

@ -149,6 +149,23 @@ describe("settings storage", () => {
expect(normalized.archivePasswordList).toBe("one\ntwo\nthree"); expect(normalized.archivePasswordList).toBe("one\ntwo\nthree");
}); });
it("assigns and preserves bandwidth schedule ids", () => {
const normalized = normalizeSettings({
...defaultSettings(),
bandwidthSchedules: [{ startHour: 1, endHour: 6, speedLimitKbps: 1024, enabled: true }]
});
const generatedId = normalized.bandwidthSchedules[0]?.id;
expect(typeof generatedId).toBe("string");
expect(generatedId?.length).toBeGreaterThan(0);
const normalizedAgain = normalizeSettings({
...defaultSettings(),
bandwidthSchedules: normalized.bandwidthSchedules
});
expect(normalizedAgain.bandwidthSchedules[0]?.id).toBe(generatedId);
});
it("resets stale active statuses to queued on session load", () => { it("resets stale active statuses to queued on session load", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir); tempDirs.push(dir);

View File

@ -106,7 +106,51 @@ describe("update", () => {
const result = await installLatestUpdate("owner/repo", prechecked); const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(true); expect(result.started).toBe(true);
expect(requestedUrls.some((url) => url.includes("/releases/latest/download/"))).toBe(true); expect(requestedUrls.some((url) => url.includes("/releases/latest/download/"))).toBe(true);
expect(requestedUrls.filter((url) => url.includes("stale-setup.exe"))).toHaveLength(1);
}); });
it("aborts hanging update body downloads on idle timeout", async () => {
const previousTimeout = process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS;
process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = "1000";
try {
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("hang-setup.exe")) {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
}
});
return new Response(body, {
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: "v9.9.9",
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/hang-setup.exe",
setupAssetName: ""
};
const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(false);
expect(result.message).toMatch(/timeout/i);
} finally {
if (previousTimeout === undefined) {
delete process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS;
} else {
process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = previousTimeout;
}
}
}, 20000);
}); });
describe("normalizeUpdateRepo extended", () => { describe("normalizeUpdateRepo extended", () => {

View File

@ -14,6 +14,10 @@ describe("utils", () => {
expect(sanitizeFilename(" ")).toBe("Paket"); expect(sanitizeFilename(" ")).toBe("Paket");
expect(sanitizeFilename("test\0file.txt")).toBe("testfile.txt"); expect(sanitizeFilename("test\0file.txt")).toBe("testfile.txt");
expect(sanitizeFilename("\0\0\0")).toBe("Paket"); expect(sanitizeFilename("\0\0\0")).toBe("Paket");
expect(sanitizeFilename("..")).toBe("Paket");
expect(sanitizeFilename(".")).toBe("Paket");
expect(sanitizeFilename("release... ")).toBe("release");
expect(sanitizeFilename(" con ")).toBe("con_");
}); });
it("parses package markers", () => { it("parses package markers", () => {