diff --git a/package-lock.json b/package-lock.json index e5d87c6..92fe059 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.1.22", + "version": "1.1.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.1.22", + "version": "1.1.23", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 9940c47..c58d0f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.1.22", + "version": "1.1.23", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index c2dfcbd..b7cb185 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -7,7 +7,7 @@ import { DownloadManager } from "./download-manager"; import { parseCollectorInput } from "./link-parser"; import { configureLogger, logger } from "./logger"; import { MegaWebFallback } from "./mega-web-fallback"; -import { createStoragePaths, emptySession, loadSession, loadSettings, saveSettings } from "./storage"; +import { createStoragePaths, loadSession, loadSettings, normalizeSettings, saveSettings } from "./storage"; import { checkGitHubUpdate, installLatestUpdate } from "./update"; export class AppController { @@ -69,11 +69,11 @@ export class AppController { } public updateSettings(partial: Partial): AppSettings { - this.settings = { + this.settings = normalizeSettings({ ...defaultSettings(), ...this.settings, ...partial - }; + }); saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); return this.settings; diff --git a/src/main/constants.ts b/src/main/constants.ts index c9b490f..eeb8dab 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -3,7 +3,7 @@ import os from "node:os"; import { AppSettings } from "../shared/types"; export const APP_NAME = "Debrid Download Manager"; -export const APP_VERSION = "1.1.22"; +export const APP_VERSION = "1.1.23"; export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0"; export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload"; diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 75e34be..62be353 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -67,19 +67,19 @@ function providerLabel(provider: DownloadItem["provider"]): string { return "Debrid"; } -function nextAvailablePath(targetPath: string): string { - if (!fs.existsSync(targetPath)) { - return targetPath; - } - const parsed = path.parse(targetPath); - let i = 1; - while (true) { - const candidate = path.join(parsed.dir, `${parsed.name} (${i})${parsed.ext}`); - if (!fs.existsSync(candidate)) { - return candidate; - } - i += 1; +function pathKey(filePath: string): string { + const resolved = path.resolve(filePath); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function isPathInsideDir(filePath: string, dirPath: string): boolean { + const file = pathKey(filePath); + const dir = pathKey(dirPath); + if (file === dir) { + return true; } + const withSep = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`; + return file.startsWith(withSep); } export class DownloadManager extends EventEmitter { @@ -107,6 +107,18 @@ export class DownloadManager extends EventEmitter { private speedBytesLastWindow = 0; + private reservedTargetPaths = new Map(); + + private claimedTargetPathByItem = new Map(); + + private runItemIds = new Set(); + + private runPackageIds = new Set(); + + private runOutcomes = new Map(); + + private runCompletedPackages = new Set(); + public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) { super(); this.settings = settings; @@ -140,8 +152,22 @@ export class DownloadManager extends EventEmitter { this.pruneSpeedEvents(now); const speedBps = this.speedBytesLastWindow / 3; - const totalItems = Object.keys(this.session.items).length; - const doneItems = Object.values(this.session.items).filter((item) => isFinishedStatus(item.status)).length; + let totalItems = Object.keys(this.session.items).length; + let doneItems = Object.values(this.session.items).filter((item) => isFinishedStatus(item.status)).length; + if (this.session.running && this.runItemIds.size > 0) { + totalItems = this.runItemIds.size; + doneItems = 0; + for (const itemId of this.runItemIds) { + if (this.runOutcomes.has(itemId)) { + doneItems += 1; + continue; + } + const item = this.session.items[itemId]; + if (item && isFinishedStatus(item.status)) { + doneItems += 1; + } + } + } const elapsed = this.session.runStartedAt > 0 ? (now - this.session.runStartedAt) / 1000 : 0; const rate = doneItems > 0 && elapsed > 0 ? doneItems / elapsed : 0; const remaining = totalItems - doneItems; @@ -165,6 +191,12 @@ export class DownloadManager extends EventEmitter { this.session.packages = {}; this.session.items = {}; this.session.summaryText = ""; + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.reservedTargetPaths.clear(); + this.claimedTargetPathByItem.clear(); this.summary = null; this.persistNow(); this.emitState(true); @@ -220,6 +252,10 @@ export class DownloadManager extends EventEmitter { }; packageEntry.itemIds.push(itemId); this.session.items[itemId] = item; + if (this.session.running) { + this.runItemIds.add(itemId); + this.runPackageIds.add(packageId); + } if (looksLikeOpaqueFilename(fileName)) { const existing = unresolvedByLink.get(link) ?? []; existing.push(itemId); @@ -267,6 +303,9 @@ export class DownloadManager extends EventEmitter { if (!looksLikeOpaqueFilename(item.fileName)) { continue; } + if (item.status !== "queued" && item.status !== "reconnect_wait") { + continue; + } item.fileName = normalized; item.targetPath = path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, normalized); item.updatedAt = nowMs(); @@ -295,6 +334,7 @@ export class DownloadManager extends EventEmitter { if (!item) { continue; } + this.recordRunOutcome(itemId, "cancelled"); const active = this.activeTasks.get(itemId); if (active) { active.abortReason = "cancel"; @@ -313,9 +353,43 @@ export class DownloadManager extends EventEmitter { if (this.session.running) { return; } + const runItems = Object.values(this.session.items) + .filter((item) => item.status === "queued" || item.status === "reconnect_wait"); + if (runItems.length === 0) { + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.reservedTargetPaths.clear(); + this.claimedTargetPathByItem.clear(); + this.session.running = false; + this.session.paused = false; + this.session.runStartedAt = 0; + this.session.totalDownloadedBytes = 0; + this.session.summaryText = ""; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.summary = null; + this.persistSoon(); + this.emitState(true); + return; + } + this.runItemIds = new Set(runItems.map((item) => item.id)); + this.runPackageIds = new Set(runItems.map((item) => item.packageId)); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.session.running = true; this.session.paused = false; - this.session.runStartedAt = this.session.runStartedAt || nowMs(); + this.session.runStartedAt = nowMs(); + this.session.totalDownloadedBytes = 0; + this.session.summaryText = ""; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + this.speedEvents = []; + this.speedBytesLastWindow = 0; this.summary = null; this.persistSoon(); this.emitState(true); @@ -325,6 +399,8 @@ export class DownloadManager extends EventEmitter { public stop(): void { this.session.running = false; this.session.paused = false; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; for (const active of this.activeTasks.values()) { active.abortReason = "stop"; active.abortController.abort("stop"); @@ -344,17 +420,32 @@ export class DownloadManager extends EventEmitter { } private normalizeSessionStatuses(): void { + this.session.running = false; + this.session.paused = false; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + for (const item of Object.values(this.session.items)) { if (item.provider !== "realdebrid" && item.provider !== "megadebrid" && item.provider !== "bestdebrid" && item.provider !== "alldebrid") { item.provider = null; } - if (item.status === "downloading" || item.status === "validating" || item.status === "extracting" || item.status === "integrity_check") { + if (item.status === "downloading" + || item.status === "validating" + || item.status === "extracting" + || item.status === "integrity_check" + || item.status === "paused" + || item.status === "reconnect_wait") { item.status = "queued"; item.speedBps = 0; } } for (const pkg of Object.values(this.session.packages)) { - if (pkg.status === "downloading" || pkg.status === "validating" || pkg.status === "extracting" || pkg.status === "integrity_check") { + if (pkg.status === "downloading" + || pkg.status === "validating" + || pkg.status === "extracting" + || pkg.status === "integrity_check" + || pkg.status === "paused" + || pkg.status === "reconnect_wait") { pkg.status = "queued"; } } @@ -436,6 +527,55 @@ export class DownloadManager extends EventEmitter { this.pruneSpeedEvents(now); } + private recordRunOutcome(itemId: string, status: "completed" | "failed" | "cancelled"): void { + if (!this.runItemIds.has(itemId)) { + return; + } + this.runOutcomes.set(itemId, status); + } + + private claimTargetPath(itemId: string, preferredPath: string, allowExistingFile = false): string { + const existingClaim = this.claimedTargetPathByItem.get(itemId); + if (existingClaim) { + const owner = this.reservedTargetPaths.get(pathKey(existingClaim)); + if (owner === itemId) { + return existingClaim; + } + this.claimedTargetPathByItem.delete(itemId); + } + + const parsed = path.parse(preferredPath); + let index = 0; + while (true) { + const candidate = index === 0 + ? preferredPath + : path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`); + const key = pathKey(candidate); + const owner = this.reservedTargetPaths.get(key); + const existsOnDisk = fs.existsSync(candidate); + const allowExistingCandidate = allowExistingFile && index === 0; + if ((!owner || owner === itemId) && (owner === itemId || !existsOnDisk || allowExistingCandidate)) { + this.reservedTargetPaths.set(key, itemId); + this.claimedTargetPathByItem.set(itemId, candidate); + return candidate; + } + index += 1; + } + } + + private releaseTargetPath(itemId: string): void { + const claimedPath = this.claimedTargetPathByItem.get(itemId); + if (!claimedPath) { + return; + } + const key = pathKey(claimedPath); + const owner = this.reservedTargetPaths.get(key); + if (owner === itemId) { + this.reservedTargetPaths.delete(key); + } + this.claimedTargetPathByItem.delete(itemId); + } + private removePackageFromSession(packageId: string, itemIds: string[]): void { for (const itemId of itemIds) { delete this.session.items[itemId]; @@ -566,6 +706,7 @@ export class DownloadManager extends EventEmitter { this.emitState(); void this.processItem(active).finally(() => { + this.releaseTargetPath(item.id); if (active.nonResumableCounted) { this.nonResumableActive = Math.max(0, this.nonResumableActive - 1); } @@ -588,7 +729,14 @@ export class DownloadManager extends EventEmitter { item.retries = unrestricted.retriesUsed; item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url)); fs.mkdirSync(pkg.outputDir, { recursive: true }); - item.targetPath = nextAvailablePath(path.join(pkg.outputDir, item.fileName)); + const existingTargetPath = String(item.targetPath || "").trim(); + const canReuseExistingTarget = existingTargetPath + && isPathInsideDir(existingTargetPath, pkg.outputDir) + && (item.downloadedBytes > 0 || fs.existsSync(existingTargetPath)); + const preferredTargetPath = canReuseExistingTarget + ? existingTargetPath + : path.join(pkg.outputDir, item.fileName); + item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget)); item.totalBytes = unrestricted.fileSize; item.status = "downloading"; item.fullStatus = `Download läuft (${unrestricted.providerLabel})`; @@ -646,6 +794,7 @@ export class DownloadManager extends EventEmitter { item.speedBps = 0; item.updatedAt = nowMs(); pkg.updatedAt = nowMs(); + this.recordRunOutcome(item.id, "completed"); await this.handlePackagePostProcessing(pkg.id); this.applyCompletedCleanupPolicy(pkg.id, item.id); @@ -656,14 +805,27 @@ export class DownloadManager extends EventEmitter { if (reason === "cancel") { item.status = "cancelled"; item.fullStatus = "Entfernt"; + this.recordRunOutcome(item.id, "cancelled"); + try { + fs.rmSync(item.targetPath, { force: true }); + } catch { + // ignore + } } else if (reason === "stop") { item.status = "cancelled"; item.fullStatus = "Gestoppt"; + this.recordRunOutcome(item.id, "cancelled"); + try { + fs.rmSync(item.targetPath, { force: true }); + } catch { + // ignore + } } else if (reason === "reconnect") { item.status = "queued"; item.fullStatus = "Wartet auf Reconnect"; } else { item.status = "failed"; + this.recordRunOutcome(item.id, "failed"); item.lastError = compactErrorText(error); item.fullStatus = `Fehler: ${item.lastError}`; } @@ -693,9 +855,11 @@ export class DownloadManager extends EventEmitter { headers.Range = `bytes=${existingBytes}-`; } - if (this.reconnectActive()) { + while (this.reconnectActive()) { + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } await sleep(250); - continue; } let response: Response; @@ -706,6 +870,9 @@ export class DownloadManager extends EventEmitter { signal: active.abortController.signal }); } catch (error) { + if (active.abortController.signal.aborted || String(error).includes("aborted:")) { + throw error; + } lastError = compactErrorText(error); if (attempt < REQUEST_RETRIES) { item.fullStatus = `Verbindungsfehler, retry ${attempt + 1}/${REQUEST_RETRIES}`; @@ -717,6 +884,18 @@ export class DownloadManager extends EventEmitter { } if (!response.ok) { + if (response.status === 416 && existingBytes > 0) { + const rangeTotal = parseContentRangeTotal(response.headers.get("content-range")); + const expectedTotal = knownTotal && knownTotal > 0 ? knownTotal : rangeTotal; + if (expectedTotal && existingBytes >= expectedTotal) { + item.totalBytes = expectedTotal; + item.downloadedBytes = existingBytes; + item.progressPercent = 100; + item.speedBps = 0; + item.updatedAt = nowMs(); + return { retriesUsed: attempt - 1, resumable: true }; + } + } const text = await response.text(); lastError = compactErrorText(text || `HTTP ${response.status}`); if (this.settings.autoReconnect && [429, 503].includes(response.status)) { @@ -732,115 +911,143 @@ export class DownloadManager extends EventEmitter { } const acceptRanges = (response.headers.get("accept-ranges") || "").toLowerCase().includes("bytes"); - const resumable = response.status === 206 || acceptRanges; - active.resumable = resumable; - - const contentLength = Number(response.headers.get("content-length") || 0); - const totalFromRange = parseContentRangeTotal(response.headers.get("content-range")); - if (knownTotal && knownTotal > 0) { - item.totalBytes = knownTotal; - } else if (totalFromRange) { - item.totalBytes = totalFromRange; - } else if (contentLength > 0) { - item.totalBytes = existingBytes + contentLength; - } - - const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w"; - if (writeMode === "w" && existingBytes > 0) { - fs.rmSync(targetPath, { force: true }); - } - - const stream = fs.createWriteStream(targetPath, { flags: writeMode }); - let written = writeMode === "a" ? existingBytes : 0; - let windowBytes = 0; - let windowStarted = nowMs(); - - const waitDrain = (): Promise => new Promise((resolve, reject) => { - const onDrain = (): void => { - stream.off("error", onError); - resolve(); - }; - const onError = (error: Error): void => { - stream.off("drain", onDrain); - reject(error); - }; - stream.once("drain", onDrain); - stream.once("error", onError); - }); - try { - const body = response.body; - if (!body) { - throw new Error("Leerer Response-Body"); + const resumable = response.status === 206 || acceptRanges; + active.resumable = resumable; + + const contentLength = Number(response.headers.get("content-length") || 0); + const totalFromRange = parseContentRangeTotal(response.headers.get("content-range")); + if (knownTotal && knownTotal > 0) { + item.totalBytes = knownTotal; + } else if (totalFromRange) { + item.totalBytes = totalFromRange; + } else if (contentLength > 0) { + item.totalBytes = existingBytes + contentLength; } - const reader = body.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - const chunk = value; - if (active.abortController.signal.aborted) { - throw new Error(`aborted:${active.abortReason}`); - } - while (this.session.paused && this.session.running && !active.abortController.signal.aborted) { - item.status = "paused"; - item.fullStatus = "Pausiert"; - this.emitState(); - await sleep(120); - } - if (this.reconnectActive() && active.resumable) { - active.abortReason = "reconnect"; - active.abortController.abort("reconnect"); - throw new Error("aborted:reconnect"); - } - const buffer = Buffer.from(chunk); - await this.applySpeedLimit(buffer.length, windowBytes, windowStarted); - if (!stream.write(buffer)) { - await waitDrain(); - } - written += buffer.length; - windowBytes += buffer.length; - this.session.totalDownloadedBytes += buffer.length; - this.recordSpeed(buffer.length); - - const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.1); - const speed = windowBytes / elapsed; - if (elapsed >= 1.2) { - windowStarted = nowMs(); - windowBytes = 0; - } - - item.status = "downloading"; - item.speedBps = Math.max(0, Math.floor(speed)); - item.downloadedBytes = written; - item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0; - item.fullStatus = `Download läuft (${providerLabel(item.provider)})`; - item.updatedAt = nowMs(); - this.emitState(); + const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w"; + if (writeMode === "w" && existingBytes > 0) { + fs.rmSync(targetPath, { force: true }); } - } finally { - await new Promise((resolve, reject) => { - const onFinish = (): void => { + + const stream = fs.createWriteStream(targetPath, { flags: writeMode }); + let written = writeMode === "a" ? existingBytes : 0; + let windowBytes = 0; + let windowStarted = nowMs(); + + const waitDrain = (): Promise => new Promise((resolve, reject) => { + const onDrain = (): void => { stream.off("error", onError); resolve(); }; - const onError = (error: Error): void => { - stream.off("finish", onFinish); - reject(error); + const onError = (streamError: Error): void => { + stream.off("drain", onDrain); + reject(streamError); }; - stream.once("finish", onFinish); + stream.once("drain", onDrain); stream.once("error", onError); - stream.end(); }); - } - item.downloadedBytes = written; - item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100; - item.speedBps = 0; - item.updatedAt = nowMs(); - return { retriesUsed: attempt - 1, resumable }; + try { + const body = response.body; + if (!body) { + throw new Error("Leerer Response-Body"); + } + const reader = body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + const chunk = value; + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + while (this.session.paused && this.session.running && !active.abortController.signal.aborted) { + item.status = "paused"; + item.fullStatus = "Pausiert"; + this.emitState(); + await sleep(120); + } + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + if (this.reconnectActive() && active.resumable) { + active.abortReason = "reconnect"; + active.abortController.abort("reconnect"); + throw new Error("aborted:reconnect"); + } + + const buffer = Buffer.from(chunk); + await this.applySpeedLimit(buffer.length, windowBytes, windowStarted); + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + if (!stream.write(buffer)) { + await waitDrain(); + } + written += buffer.length; + windowBytes += buffer.length; + this.session.totalDownloadedBytes += buffer.length; + this.recordSpeed(buffer.length); + + const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.1); + const speed = windowBytes / elapsed; + if (elapsed >= 1.2) { + windowStarted = nowMs(); + windowBytes = 0; + } + + item.status = "downloading"; + item.speedBps = Math.max(0, Math.floor(speed)); + item.downloadedBytes = written; + item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0; + item.fullStatus = `Download läuft (${providerLabel(item.provider)})`; + item.updatedAt = nowMs(); + this.emitState(); + } + } finally { + await new Promise((resolve, reject) => { + if (stream.closed || stream.destroyed) { + resolve(); + return; + } + const onDone = (): void => { + stream.off("error", onError); + stream.off("finish", onDone); + stream.off("close", onDone); + resolve(); + }; + const onError = (streamError: Error): void => { + stream.off("finish", onDone); + stream.off("close", onDone); + reject(streamError); + }; + stream.once("finish", onDone); + stream.once("close", onDone); + stream.once("error", onError); + stream.end(); + }); + } + + item.downloadedBytes = written; + item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100; + item.speedBps = 0; + item.updatedAt = nowMs(); + return { retriesUsed: attempt - 1, resumable }; + } catch (error) { + if (active.abortController.signal.aborted || String(error).includes("aborted:")) { + throw error; + } + lastError = compactErrorText(error); + if (attempt < REQUEST_RETRIES) { + item.fullStatus = `Downloadfehler, retry ${attempt + 1}/${REQUEST_RETRIES}`; + this.emitState(); + await sleep(350 * attempt); + continue; + } + throw new Error(lastError || "Download fehlgeschlagen"); + } } throw new Error(lastError || "Download fehlgeschlagen"); @@ -911,6 +1118,13 @@ export class DownloadManager extends EventEmitter { } else { pkg.status = "completed"; } + if (this.runPackageIds.has(packageId)) { + if (pkg.status === "completed") { + this.runCompletedPackages.add(packageId); + } else { + this.runCompletedPackages.delete(packageId); + } + } pkg.updatedAt = nowMs(); } @@ -956,12 +1170,12 @@ export class DownloadManager extends EventEmitter { private finishRun(): void { this.session.running = false; this.session.paused = false; - const items = Object.values(this.session.items); - const total = items.length; - const success = items.filter((item) => item.status === "completed").length; - const failed = items.filter((item) => item.status === "failed").length; - const cancelled = items.filter((item) => item.status === "cancelled").length; - const extracted = Object.values(this.session.packages).filter((pkg) => pkg.status === "completed").length; + const total = this.runItemIds.size; + const outcomes = Array.from(this.runOutcomes.values()); + const success = outcomes.filter((status) => status === "completed").length; + const failed = outcomes.filter((status) => status === "failed").length; + const cancelled = outcomes.filter((status) => status === "cancelled").length; + const extracted = this.runCompletedPackages.size; const duration = this.session.runStartedAt > 0 ? Math.max(1, Math.floor((nowMs() - this.session.runStartedAt) / 1000)) : 1; const avgSpeed = Math.floor(this.session.totalDownloadedBytes / duration); this.summary = { @@ -973,7 +1187,13 @@ export class DownloadManager extends EventEmitter { durationSeconds: duration, averageSpeedBps: avgSpeed }; - this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${Math.max(total, 1)}`; + this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`; + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.reservedTargetPaths.clear(); + this.claimedTargetPathByItem.clear(); this.persistNow(); this.emitState(); } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 6c962cd..aa1c7a4 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -29,7 +29,29 @@ function findArchiveCandidates(packageDir: string): string[] { return Array.from(new Set(ordered)); } -function runExternalExtract(archivePath: string, targetDir: string): Promise { +function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip" | "rename" { + if (conflictMode === "rename") { + return "rename"; + } + if (conflictMode === "overwrite") { + return "overwrite"; + } + return "skip"; +} + +export function buildExternalExtractArgs(command: string, archivePath: string, targetDir: string, conflictMode: ConflictMode): string[] { + const mode = effectiveConflictMode(conflictMode); + const lower = command.toLowerCase(); + if (lower.includes("unrar")) { + const overwrite = mode === "overwrite" ? "-o+" : mode === "rename" ? "-or" : "-o-"; + return ["x", overwrite, archivePath, `${targetDir}${path.sep}`]; + } + + const overwrite = mode === "overwrite" ? "-aoa" : mode === "rename" ? "-aou" : "-aos"; + return ["x", "-y", overwrite, archivePath, `-o${targetDir}`]; +} + +function runExternalExtract(archivePath: string, targetDir: string, conflictMode: ConflictMode): Promise { const candidates = ["7z", "C:\\Program Files\\7-Zip\\7z.exe", "C:\\Program Files (x86)\\7-Zip\\7z.exe", "unrar"]; return new Promise((resolve, reject) => { const tryExec = (idx: number): void => { @@ -38,9 +60,7 @@ function runExternalExtract(archivePath: string, targetDir: string): Promise tryExec(idx + 1)); child.on("close", (code) => { @@ -56,6 +76,7 @@ function runExternalExtract(archivePath: string, targetDir: string): Promise 0) { - cleanupArchives(candidates, options.cleanupMode); + cleanupArchives(extractedArchives, options.cleanupMode); if (options.removeLinks) { removeDownloadLinkArtifacts(options.targetDir); } diff --git a/src/main/storage.ts b/src/main/storage.ts index fbddcb8..50945af 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -5,6 +5,92 @@ import { defaultSettings } from "./constants"; import { logger } from "./logger"; const VALID_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid"]); +const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]); +const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]); +const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]); +const VALID_SPEED_MODES = new Set(["global", "per_download"]); + +function asText(value: unknown): string { + return String(value ?? "").trim(); +} + +function clampNumber(value: unknown, fallback: number, min: number, max: number): number { + const num = Number(value); + if (!Number.isFinite(num)) { + return fallback; + } + return Math.max(min, Math.min(max, Math.floor(num))); +} + +export function normalizeSettings(settings: AppSettings): AppSettings { + const defaults = defaultSettings(); + const normalized: AppSettings = { + ...defaults, + ...settings, + token: asText(settings.token), + megaLogin: asText(settings.megaLogin), + megaPassword: asText(settings.megaPassword), + bestToken: asText(settings.bestToken), + allDebridToken: asText(settings.allDebridToken), + rememberToken: Boolean(settings.rememberToken), + autoProviderFallback: Boolean(settings.autoProviderFallback), + outputDir: asText(settings.outputDir) || defaults.outputDir, + packageName: asText(settings.packageName), + autoExtract: Boolean(settings.autoExtract), + extractDir: asText(settings.extractDir) || defaults.extractDir, + createExtractSubfolder: Boolean(settings.createExtractSubfolder), + hybridExtract: Boolean(settings.hybridExtract), + removeLinkFilesAfterExtract: Boolean(settings.removeLinkFilesAfterExtract), + removeSamplesAfterExtract: Boolean(settings.removeSamplesAfterExtract), + enableIntegrityCheck: Boolean(settings.enableIntegrityCheck), + autoResumeOnStart: Boolean(settings.autoResumeOnStart), + autoReconnect: Boolean(settings.autoReconnect), + maxParallel: clampNumber(settings.maxParallel, defaults.maxParallel, 1, 50), + speedLimitEnabled: Boolean(settings.speedLimitEnabled), + speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000), + reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600), + autoUpdateCheck: Boolean(settings.autoUpdateCheck), + updateRepo: asText(settings.updateRepo) || defaults.updateRepo + }; + + if (!VALID_PROVIDERS.has(normalized.providerPrimary)) { + normalized.providerPrimary = defaults.providerPrimary; + } + if (!VALID_PROVIDERS.has(normalized.providerSecondary)) { + normalized.providerSecondary = defaults.providerSecondary; + } + if (!VALID_PROVIDERS.has(normalized.providerTertiary)) { + normalized.providerTertiary = defaults.providerTertiary; + } + if (!VALID_CLEANUP_MODES.has(normalized.cleanupMode)) { + normalized.cleanupMode = defaults.cleanupMode; + } + if (!VALID_CONFLICT_MODES.has(normalized.extractConflictMode)) { + normalized.extractConflictMode = defaults.extractConflictMode; + } + if (!VALID_FINISHED_POLICIES.has(normalized.completedCleanupPolicy)) { + normalized.completedCleanupPolicy = defaults.completedCleanupPolicy; + } + if (!VALID_SPEED_MODES.has(normalized.speedLimitMode)) { + normalized.speedLimitMode = defaults.speedLimitMode; + } + + return normalized; +} + +function sanitizeCredentialPersistence(settings: AppSettings): AppSettings { + if (settings.rememberToken) { + return settings; + } + return { + ...settings, + token: "", + megaLogin: "", + megaPassword: "", + bestToken: "", + allDebridToken: "" + }; +} export interface StoragePaths { baseDir: string; @@ -30,25 +116,12 @@ export function loadSettings(paths: StoragePaths): AppSettings { return defaultSettings(); } try { - const parsed = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as Partial; - const merged: AppSettings = { + const parsed = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as AppSettings; + const merged = normalizeSettings({ ...defaultSettings(), ...parsed - }; - if (!VALID_PROVIDERS.has(merged.providerPrimary)) { - merged.providerPrimary = "realdebrid"; - } - if (!VALID_PROVIDERS.has(merged.providerSecondary)) { - merged.providerSecondary = "megadebrid"; - } - if (!VALID_PROVIDERS.has(merged.providerTertiary)) { - merged.providerTertiary = "bestdebrid"; - } - merged.autoProviderFallback = Boolean(merged.autoProviderFallback); - merged.maxParallel = Math.max(1, Math.min(50, Number(merged.maxParallel) || 4)); - merged.speedLimitKbps = Math.max(0, Math.min(500000, Number(merged.speedLimitKbps) || 0)); - merged.reconnectWaitSeconds = Math.max(10, Math.min(600, Number(merged.reconnectWaitSeconds) || 45)); - return merged; + }); + return sanitizeCredentialPersistence(merged); } catch (error) { logger.error(`Konfiguration konnte nicht geladen werden: ${String(error)}`); return defaultSettings(); @@ -57,7 +130,8 @@ export function loadSettings(paths: StoragePaths): AppSettings { export function saveSettings(paths: StoragePaths, settings: AppSettings): void { ensureBaseDir(paths.baseDir); - const payload = JSON.stringify(settings, null, 2); + const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); + const payload = JSON.stringify(persisted, null, 2); const tempPath = `${paths.configFile}.tmp`; fs.writeFileSync(tempPath, payload, "utf8"); fs.renameSync(tempPath, paths.configFile); diff --git a/src/main/update.ts b/src/main/update.ts index 6933523..afb4087 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -2,11 +2,60 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawn } from "node:child_process"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { ReadableStream as NodeReadableStream } from "node:stream/web"; import { APP_VERSION, DEFAULT_UPDATE_REPO } from "./constants"; import { UpdateCheckResult, UpdateInstallResult } from "../shared/types"; import { compactErrorText } from "./utils"; -type ReleaseAsset = { name: string; browser_download_url: string }; +export function normalizeUpdateRepo(repo: string): string { + const raw = String(repo || "").trim(); + if (!raw) { + return DEFAULT_UPDATE_REPO; + } + + const normalizeParts = (input: string): string => { + const cleaned = input + .replace(/^https?:\/\/(?:www\.)?github\.com\//i, "") + .replace(/^(?:www\.)?github\.com\//i, "") + .replace(/^git@github\.com:/i, "") + .replace(/\.git$/i, "") + .replace(/^\/+|\/+$/g, ""); + const parts = cleaned.split("/").filter(Boolean); + if (parts.length >= 2) { + return `${parts[0]}/${parts[1]}`; + } + return ""; + }; + + try { + const url = new URL(raw); + const host = url.hostname.toLowerCase(); + if (host === "github.com" || host === "www.github.com") { + const normalized = normalizeParts(url.pathname); + if (normalized) { + return normalized; + } + } + } catch { + // plain owner/repo value + } + + const normalized = normalizeParts(raw); + return normalized || DEFAULT_UPDATE_REPO; +} + +function timeoutController(ms: number): { signal: AbortSignal; clear: () => void } { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(new Error(`timeout:${ms}`)); + }, ms); + return { + signal: controller.signal, + clear: () => clearTimeout(timer) + }; +} function parseVersionParts(version: string): number[] { const cleaned = version.replace(/^v/i, "").trim(); @@ -31,7 +80,7 @@ function isRemoteNewer(currentVersion: string, latestVersion: string): boolean { } export async function checkGitHubUpdate(repo: string): Promise { - const safeRepo = (repo || DEFAULT_UPDATE_REPO).trim() || DEFAULT_UPDATE_REPO; + const safeRepo = normalizeUpdateRepo(repo); const fallback: UpdateCheckResult = { updateAvailable: false, currentVersion: APP_VERSION, @@ -41,12 +90,19 @@ export async function checkGitHubUpdate(repo: string): Promise null) as Record | null; if (!response.ok || !payload) { const reason = String((payload?.message as string) || `HTTP ${response.status}`); @@ -57,12 +113,14 @@ export async function checkGitHubUpdate(repo: string): Promise> : []; - const setup = assets + const exeAssets = assets .map((asset) => ({ name: String(asset.name || ""), browser_download_url: String(asset.browser_download_url || "") })) - .find((asset) => /\.setup\..*\.exe$/i.test(asset.name)); + .filter((asset) => asset.browser_download_url && /\.exe$/i.test(asset.name)); + const setup = exeAssets.find((asset) => /setup/i.test(asset.name)) + || exeAssets.find((asset) => !/portable/i.test(asset.name)); return { updateAvailable: isRemoteNewer(APP_VERSION, latestVersion), @@ -81,36 +139,26 @@ export async function checkGitHubUpdate(repo: string): Promise { - const response = await fetch(url, { - headers: { - "User-Agent": "RD-Node-Downloader/1.1.18" - } - }); + const timeout = timeoutController(10 * 60 * 1000); + let response: Response; + try { + response = await fetch(url, { + headers: { + "User-Agent": "RD-Node-Downloader/1.1.18" + }, + signal: timeout.signal + }); + } finally { + timeout.clear(); + } if (!response.ok || !response.body) { throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`); } await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); - const stream = fs.createWriteStream(targetPath); - await new Promise((resolve, reject) => { - const reader = response.body!.getReader(); - const pump = (): void => { - void reader.read().then(({ done, value }) => { - if (done) { - stream.end(() => resolve()); - return; - } - if (value) { - stream.write(Buffer.from(value)); - } - pump(); - }).catch((error) => { - stream.destroy(); - reject(error); - }); - }; - pump(); - }); + const source = Readable.fromWeb(response.body as unknown as NodeReadableStream); + const target = fs.createWriteStream(targetPath); + await pipeline(source, target); } export async function installLatestUpdate(repo: string): Promise { @@ -127,7 +175,7 @@ export async function installLatestUpdate(repo: string): Promise]+>/g, " ").replace(/\s+/g, " ").trim(); if (!raw) { @@ -58,7 +66,7 @@ export function filenameFromUrl(url: string): string { || parsed.searchParams.get("title") || ""; const rawName = queryName || path.basename(parsed.pathname || ""); - const decoded = decodeURIComponent(rawName || "").trim(); + const decoded = safeDecodeURIComponent(rawName || "").trim(); const normalized = decoded .replace(/\.(rar|zip|7z|tar|gz|bz2|xz|iso|part\d+\.rar|r\d{2})\.html$/i, ".$1") .replace(/\.(mp4|mkv|avi|mp3|flac|srt)\.html$/i, ".$1"); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1adc015..5bd5759 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -81,6 +81,18 @@ export function App(): ReactElement { const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings); const latestStateRef = useRef(null); const stateFlushTimerRef = useRef | null>(null); + const toastTimerRef = useRef | null>(null); + + const showToast = (message: string, timeoutMs = 2200): void => { + setStatusToast(message); + if (toastTimerRef.current) { + clearTimeout(toastTimerRef.current); + } + toastTimerRef.current = setTimeout(() => { + setStatusToast(""); + toastTimerRef.current = null; + }, timeoutMs); + }; useEffect(() => { let unsubscribe: (() => void) | null = null; @@ -90,8 +102,10 @@ export function App(): ReactElement { if (state.settings.autoUpdateCheck) { void window.rd.checkUpdates().then((result) => { void handleUpdateResult(result, "startup"); - }); + }).catch(() => undefined); } + }).catch((error) => { + showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800); }); unsubscribe = window.rd.onStateUpdate((state) => { latestStateRef.current = state; @@ -111,6 +125,10 @@ export function App(): ReactElement { clearTimeout(stateFlushTimerRef.current); stateFlushTimerRef.current = null; } + if (toastTimerRef.current) { + clearTimeout(toastTimerRef.current); + toastTimerRef.current = null; + } if (unsubscribe) { unsubscribe(); } @@ -124,16 +142,14 @@ export function App(): ReactElement { const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise => { if (result.error) { if (source === "manual") { - setStatusToast(`Update-Check fehlgeschlagen: ${result.error}`); - setTimeout(() => setStatusToast(""), 2800); + showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); } return; } if (!result.updateAvailable) { if (source === "manual") { - setStatusToast(`Kein Update verfügbar (v${result.currentVersion})`); - setTimeout(() => setStatusToast(""), 2000); + showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); } return; } @@ -142,31 +158,35 @@ export function App(): ReactElement { `Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?` ); if (!approved) { - setStatusToast(`Update verfügbar: ${result.latestTag}`); - setTimeout(() => setStatusToast(""), 2600); + showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; } const install = await window.rd.installUpdate(); if (install.started) { - setStatusToast("Updater gestartet - App wird geschlossen"); - setTimeout(() => setStatusToast(""), 2600); + showToast("Updater gestartet - App wird geschlossen", 2600); return; } - setStatusToast(`Auto-Update fehlgeschlagen: ${install.message}`); - setTimeout(() => setStatusToast(""), 3200); + showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200); }; const onSaveSettings = async (): Promise => { - await window.rd.updateSettings(settingsDraft); - setStatusToast("Settings gespeichert"); - setTimeout(() => setStatusToast(""), 1800); + try { + await window.rd.updateSettings(settingsDraft); + showToast("Settings gespeichert", 1800); + } catch (error) { + showToast(`Settings konnten nicht gespeichert werden: ${String(error)}`, 2800); + } }; const onCheckUpdates = async (): Promise => { - const result = await window.rd.checkUpdates(); - await handleUpdateResult(result, "manual"); + try { + const result = await window.rd.checkUpdates(); + await handleUpdateResult(result, "manual"); + } catch (error) { + showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800); + } }; const onAddLinks = async (): Promise => { @@ -174,15 +194,13 @@ export function App(): ReactElement { await window.rd.updateSettings(settingsDraft); const result = await window.rd.addLinks({ rawText: linksRaw, packageName: settingsDraft.packageName }); if (result.addedLinks > 0) { - setStatusToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); + showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); setLinksRaw(""); } else { - setStatusToast("Keine gültigen Links gefunden"); + showToast("Keine gültigen Links gefunden"); } - setTimeout(() => setStatusToast(""), 2200); } catch (error) { - setStatusToast(`Fehler beim Hinzufügen: ${String(error)}`); - setTimeout(() => setStatusToast(""), 2600); + showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600); } }; @@ -194,11 +212,9 @@ export function App(): ReactElement { } await window.rd.updateSettings(settingsDraft); const result = await window.rd.addContainers(files); - setStatusToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); - setTimeout(() => setStatusToast(""), 2200); + showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); } catch (error) { - setStatusToast(`Fehler beim DLC-Import: ${String(error)}`); - setTimeout(() => setStatusToast(""), 2600); + showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); } }; @@ -215,11 +231,9 @@ export function App(): ReactElement { try { await window.rd.updateSettings(settingsDraft); const result = await window.rd.addContainers(dlc); - setStatusToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); - setTimeout(() => setStatusToast(""), 2200); + showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); } catch (error) { - setStatusToast(`Fehler bei Drag-and-Drop: ${String(error)}`); - setTimeout(() => setStatusToast(""), 2600); + showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); } }; @@ -239,8 +253,7 @@ export function App(): ReactElement { try { await action(); } catch (error) { - setStatusToast(`Fehler: ${String(error)}`); - setTimeout(() => setStatusToast(""), 2600); + showToast(`Fehler: ${String(error)}`, 2600); } }; @@ -428,11 +441,13 @@ export function App(): ReactElement { setText("outputDir", event.target.value)} /> @@ -443,11 +458,13 @@ export function App(): ReactElement { setText("extractDir", event.target.value)} /> diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts new file mode 100644 index 0000000..804b0df --- /dev/null +++ b/tests/download-manager.test.ts @@ -0,0 +1,859 @@ +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 } from "../src/main/storage"; + +const tempDirs: string[] = []; +const originalFetch = globalThis.fetch; + +async function waitFor(predicate: () => boolean, timeoutMs = 15000): Promise { + const started = Date.now(); + while (!predicate()) { + if (Date.now() - started > timeoutMs) { + throw new Error("waitFor timeout"); + } + await new Promise((resolve) => setTimeout(resolve, 60)); + } +} + +afterEach(() => { + globalThis.fetch = originalFetch; + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("download manager", () => { + it("retries interrupted streams and resumes download", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(512 * 1024, 11); + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/direct") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + directCalls += 1; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + + if (directCalls === 1 && start === 0) { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, Math.floor(binary.length / 2))); + res.socket?.destroy(); + return; + } + + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + 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`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + 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: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "retry", links: ["https://dummy/retry"] }]); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.retries).toBeGreaterThan(0); + expect(directCalls).toBeGreaterThan(1); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("assigns unique target paths for same filenames in parallel", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(64 * 1024, 9); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/same") { + res.statusCode = 404; + res.end("not-found"); + return; + } + setTimeout(() => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }, 260); + }); + + 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}/same`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + 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: "same-release.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 2 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "same-name", links: ["https://dummy/first", "https://dummy/second"] }]); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const items = Object.values(manager.getSnapshot().session.items); + expect(items).toHaveLength(2); + expect(items.every((item) => item.status === "completed")).toBe(true); + const targetPaths = items.map((item) => item.targetPath); + expect(new Set(targetPaths).size).toBe(2); + for (const targetPath of targetPaths) { + expect(fs.existsSync(targetPath)).toBe(true); + } + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("reuses stored partial target path when queued item resumes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 7); + const partialSize = 64 * 1024; + const pkgDir = path.join(root, "downloads", "resume"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "resume.mkv"); + fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize)); + let seenRangeStart = -1; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/resume") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + seenRangeStart = start; + const chunk = binary.subarray(start); + + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + 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}/resume`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + 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: "resume.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "resume-pkg"; + const itemId = "resume-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "resume", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "resume"), + status: "queued", + itemIds: [itemId], + cancelled: false, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/resume", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: partialSize, + totalBytes: binary.length, + progressPercent: Math.floor((partialSize / binary.length) * 100), + fileName: "resume.mkv", + targetPath: existingTargetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.targetPath).toBe(existingTargetPath); + expect(seenRangeStart).toBe(partialSize); + expect(fs.statSync(existingTargetPath).size).toBe(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("treats HTTP 416 on full range as completed resume", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(128 * 1024, 2); + const pkgDir = path.join(root, "downloads", "range-complete"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "complete.mkv"); + fs.writeFileSync(existingTargetPath, binary); + let saw416 = false; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/complete") { + res.statusCode = 404; + res.end("not-found"); + return; + } + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + if (start >= binary.length) { + saw416 = true; + res.statusCode = 416; + res.setHeader("Content-Range", `bytes */${binary.length}`); + res.end(""); + return; + } + + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + 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}/complete`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + 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: "complete.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "complete-pkg"; + const itemId = "complete-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "range-complete", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "range-complete"), + status: "queued", + itemIds: [itemId], + cancelled: false, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/complete", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: binary.length, + totalBytes: binary.length, + progressPercent: 100, + fileName: "complete.mkv", + targetPath: existingTargetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(saw416).toBe(true); + expect(item?.status).toBe("completed"); + expect(item?.targetPath).toBe(existingTargetPath); + expect(item?.downloadedBytes).toBe(binary.length); + expect(fs.statSync(existingTargetPath).size).toBe(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("normalizes stale running state on startup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + session.running = true; + session.paused = true; + session.reconnectUntil = Date.now() + 30_000; + session.reconnectReason = "HTTP 429"; + const packageId = "stale-pkg"; + const itemId = "stale-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "stale", + outputDir: path.join(root, "downloads", "stale"), + extractDir: path.join(root, "extract", "stale"), + status: "reconnect_wait", + itemIds: [itemId], + cancelled: false, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/stale", + provider: "realdebrid", + status: "paused", + retries: 0, + speedBps: 100, + downloadedBytes: 123, + totalBytes: 456, + progressPercent: 26, + fileName: "stale.mkv", + targetPath: path.join(root, "downloads", "stale", "stale.mkv"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Pausiert", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const snapshot = manager.getSnapshot(); + expect(snapshot.session.running).toBe(false); + expect(snapshot.session.paused).toBe(false); + expect(snapshot.session.reconnectUntil).toBe(0); + expect(snapshot.session.reconnectReason).toBe(""); + expect(snapshot.session.items[itemId]?.status).toBe("queued"); + expect(snapshot.session.items[itemId]?.speedBps).toBe(0); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + expect(snapshot.canStart).toBe(true); + }); + + it("resets run counters and reconnect state on start", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + session.runStartedAt = Date.now() - 3600 * 1000; + session.totalDownloadedBytes = 9_999_999; + session.reconnectUntil = Date.now() + 120000; + session.reconnectReason = "HTTP 503"; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 5000); + + const snapshot = manager.getSnapshot(); + const summary = manager.getSummary(); + expect(snapshot.session.totalDownloadedBytes).toBe(0); + expect(snapshot.session.reconnectUntil).toBe(0); + expect(snapshot.session.reconnectReason).toBe(""); + expect(summary).toBeNull(); + }); + + it("does not start a run when queue is empty", 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 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 80)); + + const snapshot = manager.getSnapshot(); + expect(snapshot.session.running).toBe(false); + expect(manager.getSummary()).toBeNull(); + }); + + it("calculates ETA from current run items only", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(128 * 1024, 4); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/slow-eta") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + const half = Math.floor(binary.length / 2); + res.write(binary.subarray(0, half)); + setTimeout(() => { + res.end(binary.subarray(half)); + }, 700); + }); + + 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-eta`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + 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: "new-episode.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const oldPkgId = "old-pkg"; + const oldItemId = "old-item"; + const oldNow = Date.now() - 5000; + session.packageOrder = [oldPkgId]; + session.packages[oldPkgId] = { + id: oldPkgId, + name: "old", + outputDir: path.join(root, "downloads", "old"), + extractDir: path.join(root, "extract", "old"), + status: "completed", + itemIds: [oldItemId], + cancelled: false, + createdAt: oldNow, + updatedAt: oldNow + }; + session.items[oldItemId] = { + id: oldItemId, + packageId: oldPkgId, + url: "https://dummy/old", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 100, + totalBytes: 100, + progressPercent: 100, + fileName: "old.bin", + targetPath: path.join(root, "downloads", "old", "old.bin"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "done", + createdAt: oldNow, + updatedAt: oldNow + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "new", links: ["https://dummy/new"] }]); + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 120)); + + const runningSnapshot = manager.getSnapshot(); + expect(runningSnapshot.session.running).toBe(true); + expect(runningSnapshot.etaText).toBe("ETA: --"); + + await waitFor(() => !manager.getSnapshot().session.running, 25000); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("keeps accurate summary when completed items are cleaned immediately", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(128 * 1024, 3); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/file") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + 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}/file`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + 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: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + completedCleanupPolicy: "immediate" + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "cleanup", links: ["https://dummy/cleanup"] }]); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const snapshot = manager.getSnapshot(); + const summary = manager.getSummary(); + expect(Object.keys(snapshot.session.items)).toHaveLength(0); + expect(summary).not.toBeNull(); + expect(summary?.total).toBe(1); + expect(summary?.success).toBe(1); + expect(summary?.failed).toBe(0); + expect(summary?.cancelled).toBe(0); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("counts queued package cancellations in run summary", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 5); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/slow") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + const mid = Math.floor(binary.length / 2); + res.write(binary.subarray(0, mid)); + setTimeout(() => { + res.end(binary.subarray(mid)); + }, 600); + }); + + 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, init?: RequestInit): Promise => { + 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: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + 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: "cancel-run", links: ["https://dummy/one", "https://dummy/two"] }]); + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 120)); + const pkgId = manager.getSnapshot().session.packageOrder[0]; + manager.cancelPackage(pkgId); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const summary = manager.getSummary(); + expect(summary).not.toBeNull(); + expect(summary?.total).toBe(2); + expect(summary?.cancelled).toBe(2); + expect(summary?.success).toBe(0); + expect(summary?.failed).toBe(0); + } finally { + server.close(); + await once(server, "close"); + } + }); +}); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts new file mode 100644 index 0000000..9b537c3 --- /dev/null +++ b/tests/extractor.test.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import AdmZip from "adm-zip"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildExternalExtractArgs, extractPackageArchives } from "../src/main/extractor"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("extractor", () => { + it("maps external extractor args by conflict mode", () => { + expect(buildExternalExtractArgs("7z", "archive.rar", "C:\\target", "overwrite")).toEqual([ + "x", + "-y", + "-aoa", + "archive.rar", + "-oC:\\target" + ]); + expect(buildExternalExtractArgs("7z", "archive.rar", "C:\\target", "ask")).toEqual([ + "x", + "-y", + "-aos", + "archive.rar", + "-oC:\\target" + ]); + + const unrarRename = buildExternalExtractArgs("unrar", "archive.rar", "C:\\target", "rename"); + expect(unrarRename[0]).toBe("x"); + expect(unrarRename[1]).toBe("-or"); + expect(unrarRename[2]).toBe("archive.rar"); + }); + + it("deletes only successfully extracted archives", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const validZipPath = path.join(packageDir, "ok.zip"); + const invalidZipPath = path.join(packageDir, "bad.zip"); + + const zip = new AdmZip(); + zip.addFile("release.txt", Buffer.from("ok")); + zip.writeZip(validZipPath); + fs.writeFileSync(invalidZipPath, "not-a-zip", "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "delete", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(1); + expect(fs.existsSync(validZipPath)).toBe(false); + expect(fs.existsSync(invalidZipPath)).toBe(true); + expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true); + }); + + it("treats ask conflict mode as skip in zip extraction", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const zipPath = path.join(packageDir, "conflict.zip"); + const zip = new AdmZip(); + zip.addFile("same.txt", Buffer.from("new")); + zip.writeZip(zipPath); + + const existingPath = path.join(targetDir, "same.txt"); + fs.writeFileSync(existingPath, "old", "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "ask", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + expect(fs.readFileSync(existingPath, "utf8")).toBe("old"); + }); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts new file mode 100644 index 0000000..a97ec42 --- /dev/null +++ b/tests/storage.test.ts @@ -0,0 +1,127 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { AppSettings } from "../src/shared/types"; +import { defaultSettings } from "../src/main/constants"; +import { createStoragePaths, loadSettings, normalizeSettings, saveSettings } from "../src/main/storage"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("settings storage", () => { + it("does not persist provider credentials when rememberToken is disabled", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + saveSettings(paths, { + ...defaultSettings(), + rememberToken: false, + token: "rd-token", + megaLogin: "mega-user", + megaPassword: "mega-pass", + bestToken: "best-token", + allDebridToken: "all-token" + }); + + const raw = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as Record; + expect(raw.token).toBe(""); + expect(raw.megaLogin).toBe(""); + expect(raw.megaPassword).toBe(""); + expect(raw.bestToken).toBe(""); + expect(raw.allDebridToken).toBe(""); + + const loaded = loadSettings(paths); + expect(loaded.rememberToken).toBe(false); + expect(loaded.token).toBe(""); + expect(loaded.megaLogin).toBe(""); + expect(loaded.megaPassword).toBe(""); + expect(loaded.bestToken).toBe(""); + expect(loaded.allDebridToken).toBe(""); + }); + + it("persists provider credentials when rememberToken is enabled", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + saveSettings(paths, { + ...defaultSettings(), + rememberToken: true, + token: "rd-token", + megaLogin: "mega-user", + megaPassword: "mega-pass", + bestToken: "best-token", + allDebridToken: "all-token" + }); + + const loaded = loadSettings(paths); + expect(loaded.token).toBe("rd-token"); + expect(loaded.megaLogin).toBe("mega-user"); + expect(loaded.megaPassword).toBe("mega-pass"); + expect(loaded.bestToken).toBe("best-token"); + expect(loaded.allDebridToken).toBe("all-token"); + }); + + it("normalizes invalid enum and numeric values", () => { + const normalized = normalizeSettings({ + ...defaultSettings(), + providerPrimary: "invalid-provider" as unknown as AppSettings["providerPrimary"], + cleanupMode: "broken" as unknown as AppSettings["cleanupMode"], + extractConflictMode: "broken" as unknown as AppSettings["extractConflictMode"], + completedCleanupPolicy: "broken" as unknown as AppSettings["completedCleanupPolicy"], + speedLimitMode: "broken" as unknown as AppSettings["speedLimitMode"], + maxParallel: 0, + reconnectWaitSeconds: 9999, + speedLimitKbps: -1, + outputDir: " ", + extractDir: " ", + updateRepo: " " + }); + + expect(normalized.providerPrimary).toBe("realdebrid"); + expect(normalized.cleanupMode).toBe("none"); + expect(normalized.extractConflictMode).toBe("overwrite"); + expect(normalized.completedCleanupPolicy).toBe("never"); + expect(normalized.speedLimitMode).toBe("global"); + expect(normalized.maxParallel).toBe(1); + expect(normalized.reconnectWaitSeconds).toBe(600); + expect(normalized.speedLimitKbps).toBe(0); + expect(normalized.outputDir).toBe(defaultSettings().outputDir); + expect(normalized.extractDir).toBe(defaultSettings().extractDir); + expect(normalized.updateRepo).toBe(defaultSettings().updateRepo); + }); + + it("normalizes malformed persisted config on load", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + + fs.writeFileSync( + paths.configFile, + JSON.stringify({ + providerPrimary: "not-valid", + completedCleanupPolicy: "not-valid", + maxParallel: "999", + reconnectWaitSeconds: "1", + speedLimitMode: "not-valid", + updateRepo: "" + }), + "utf8" + ); + + const loaded = loadSettings(paths); + expect(loaded.providerPrimary).toBe("realdebrid"); + expect(loaded.completedCleanupPolicy).toBe("never"); + expect(loaded.maxParallel).toBe(50); + expect(loaded.reconnectWaitSeconds).toBe(10); + expect(loaded.speedLimitMode).toBe("global"); + expect(loaded.updateRepo).toBe(defaultSettings().updateRepo); + }); +}); diff --git a/tests/update.test.ts b/tests/update.test.ts new file mode 100644 index 0000000..4b524da --- /dev/null +++ b/tests/update.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { checkGitHubUpdate, normalizeUpdateRepo } from "../src/main/update"; +import { APP_VERSION } from "../src/main/constants"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe("update", () => { + it("normalizes update repo input", () => { + expect(normalizeUpdateRepo("")).toBe("Sucukdeluxe/real-debrid-downloader"); + expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo"); + expect(normalizeUpdateRepo("https://github.com/owner/repo")).toBe("owner/repo"); + expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo"); + expect(normalizeUpdateRepo("https://github.com/owner/repo/releases/tag/v1.2.3")).toBe("owner/repo"); + expect(normalizeUpdateRepo("github.com/owner/repo.git")).toBe("owner/repo"); + expect(normalizeUpdateRepo("git@github.com:owner/repo.git")).toBe("owner/repo"); + }); + + it("uses normalized repo slug for GitHub API requests", async () => { + let requestedUrl = ""; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + requestedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + return new Response( + JSON.stringify({ + tag_name: `v${APP_VERSION}`, + html_url: "https://github.com/owner/repo/releases/tag/v1.0.0", + assets: [] + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + }) as typeof fetch; + + const result = await checkGitHubUpdate("https://github.com/owner/repo/releases"); + expect(requestedUrl).toBe("https://api.github.com/repos/owner/repo/releases/latest"); + expect(result.currentVersion).toBe(APP_VERSION); + expect(result.latestVersion).toBe(APP_VERSION); + expect(result.updateAvailable).toBe(false); + }); + + it("picks setup executable asset from release list", async () => { + globalThis.fetch = (async (): Promise => new Response( + JSON.stringify({ + tag_name: "v9.9.9", + html_url: "https://github.com/owner/repo/releases/tag/v9.9.9", + assets: [ + { + name: "Real-Debrid-Downloader 9.9.9.exe", + browser_download_url: "https://example.invalid/portable.exe" + }, + { + name: "Real-Debrid-Downloader Setup 9.9.9.exe", + browser_download_url: "https://example.invalid/setup.exe" + } + ] + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + )) as typeof fetch; + + const result = await checkGitHubUpdate("owner/repo"); + expect(result.updateAvailable).toBe(true); + expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe"); + }); +}); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 01a0793..cc163fc 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -34,6 +34,7 @@ describe("utils", () => { it("normalizes filenames from links", () => { expect(filenameFromUrl("https://rapidgator.net/file/id/show.part1.rar.html")).toBe("show.part1.rar"); expect(filenameFromUrl("https://debrid.example/dl/abc?filename=Movie.S01E01.mkv")).toBe("Movie.S01E01.mkv"); + expect(filenameFromUrl("https://debrid.example/dl/%E0%A4%A")).toBe("%E0%A4%A"); expect(filenameFromUrl("https://debrid.example/dl/e51f6809bb6ca615601f5ac5db433737")).toBe("e51f6809bb6ca615601f5ac5db433737"); expect(looksLikeOpaqueFilename("download.bin")).toBe(true); expect(looksLikeOpaqueFilename("e51f6809bb6ca615601f5ac5db433737")).toBe(true);