Harden resume flows and ship v1.1.23 stability fixes
Build and Release / build (push) Has been cancelled

This commit is contained in:
Sucukdeluxe
2026-02-27 11:04:52 +01:00
parent 583d74fcc9
commit 6d777e2a56
15 changed files with 1790 additions and 236 deletions
+3 -3
View File
@@ -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>): AppSettings {
this.settings = {
this.settings = normalizeSettings({
...defaultSettings(),
...this.settings,
...partial
};
});
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
return this.settings;
+1 -1
View File
@@ -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";
+345 -125
View File
@@ -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<string, string>();
private claimedTargetPathByItem = new Map<string, string>();
private runItemIds = new Set<string>();
private runPackageIds = new Set<string>();
private runOutcomes = new Map<string, "completed" | "failed" | "cancelled">();
private runCompletedPackages = new Set<string>();
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<void> => 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<void>((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<void> => 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<void>((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();
}
+31 -8
View File
@@ -29,7 +29,29 @@ function findArchiveCandidates(packageDir: string): string[] {
return Array.from(new Set(ordered));
}
function runExternalExtract(archivePath: string, targetDir: string): Promise<void> {
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<void> {
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<voi
return;
}
const cmd = candidates[idx];
const args = cmd.toLowerCase().includes("unrar")
? ["x", "-o+", archivePath, `${targetDir}${path.sep}`]
: ["x", "-y", archivePath, `-o${targetDir}`];
const args = buildExternalExtractArgs(cmd, archivePath, targetDir, conflictMode);
const child = spawn(cmd, args, { windowsHide: true });
child.on("error", () => tryExec(idx + 1));
child.on("close", (code) => {
@@ -56,6 +76,7 @@ function runExternalExtract(archivePath: string, targetDir: string): Promise<voi
}
function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode): void {
const mode = effectiveConflictMode(conflictMode);
const zip = new AdmZip(archivePath);
const entries = zip.getEntries();
for (const entry of entries) {
@@ -66,10 +87,10 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
if (fs.existsSync(outputPath)) {
if (conflictMode === "skip") {
if (mode === "skip") {
continue;
}
if (conflictMode === "rename") {
if (mode === "rename") {
const parsed = path.parse(outputPath);
let n = 1;
let candidate = outputPath;
@@ -108,15 +129,17 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
let extracted = 0;
let failed = 0;
const extractedArchives: string[] = [];
for (const archivePath of candidates) {
try {
const ext = path.extname(archivePath).toLowerCase();
if (ext === ".zip") {
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
} else {
await runExternalExtract(archivePath, options.targetDir);
await runExternalExtract(archivePath, options.targetDir, options.conflictMode);
}
extracted += 1;
extractedArchives.push(archivePath);
} catch (error) {
failed += 1;
logger.error(`Entpack-Fehler ${path.basename(archivePath)}: ${String(error)}`);
@@ -124,7 +147,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
}
if (extracted > 0) {
cleanupArchives(candidates, options.cleanupMode);
cleanupArchives(extractedArchives, options.cleanupMode);
if (options.removeLinks) {
removeDownloadLinkArtifacts(options.targetDir);
}
+92 -18
View File
@@ -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<AppSettings>;
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);
+89 -36
View File
@@ -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<UpdateCheckResult> {
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<UpdateCheckResult
};
try {
const response = await fetch(`https://api.github.com/repos/${safeRepo}/releases/latest`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "RD-Node-Downloader/1.1.14"
}
});
const timeout = timeoutController(15000);
let response: Response;
try {
response = await fetch(`https://api.github.com/repos/${safeRepo}/releases/latest`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "RD-Node-Downloader/1.1.14"
},
signal: timeout.signal
});
} finally {
timeout.clear();
}
const payload = await response.json().catch(() => null) as Record<string, unknown> | 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<UpdateCheckResult
const latestVersion = latestTag.replace(/^v/i, "") || APP_VERSION;
const releaseUrl = String(payload.html_url || fallback.releaseUrl);
const assets = Array.isArray(payload.assets) ? payload.assets as Array<Record<string, unknown>> : [];
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<UpdateCheckResult
}
async function downloadFile(url: string, targetPath: string): Promise<void> {
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<void>((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<Uint8Array>);
const target = fs.createWriteStream(targetPath);
await pipeline(source, target);
}
export async function installLatestUpdate(repo: string): Promise<UpdateInstallResult> {
@@ -127,7 +175,7 @@ export async function installLatestUpdate(repo: string): Promise<UpdateInstallRe
}
const fileName = path.basename(new URL(downloadUrl).pathname || "update.exe") || "update.exe";
const targetPath = path.join(os.tmpdir(), "rd-update", fileName);
const targetPath = path.join(os.tmpdir(), "rd-update", `${Date.now()}-${fileName}`);
try {
await downloadFile(downloadUrl, targetPath);
const child = spawn(targetPath, [], {
@@ -137,6 +185,11 @@ export async function installLatestUpdate(repo: string): Promise<UpdateInstallRe
child.unref();
return { started: true, message: "Update-Installer gestartet" };
} catch (error) {
try {
await fs.promises.rm(targetPath, { force: true });
} catch {
// ignore
}
return { started: false, message: compactErrorText(error) };
}
}
+9 -1
View File
@@ -1,6 +1,14 @@
import path from "node:path";
import { ParsedPackageInput } from "../shared/types";
function safeDecodeURIComponent(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function compactErrorText(message: unknown, maxLen = 220): string {
const raw = String(message ?? "").replace(/<[^>]+>/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");
+58 -41
View File
@@ -81,6 +81,18 @@ export function App(): ReactElement {
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
const latestStateRef = useRef<UiSnapshot | null>(null);
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | 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<void> => {
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<void> => {
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<void> => {
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<void> => {
@@ -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 {
<input value={settingsDraft.outputDir} onChange={(event) => setText("outputDir", event.target.value)} />
<button
className="btn"
onClick={async () => {
const selected = await window.rd.pickFolder();
if (selected) {
setText("outputDir", selected);
}
onClick={() => {
void performQuickAction(async () => {
const selected = await window.rd.pickFolder();
if (selected) {
setText("outputDir", selected);
}
});
}}
>Wählen</button>
</div>
@@ -443,11 +458,13 @@ export function App(): ReactElement {
<input value={settingsDraft.extractDir} onChange={(event) => setText("extractDir", event.target.value)} />
<button
className="btn"
onClick={async () => {
const selected = await window.rd.pickFolder();
if (selected) {
setText("extractDir", selected);
}
onClick={() => {
void performQuickAction(async () => {
const selected = await window.rd.pickFolder();
if (selected) {
setText("extractDir", selected);
}
});
}}
>Wählen</button>
</div>