Harden resume flows and ship v1.1.23 stability fixes
Build and Release / build (push) Has been cancelled
Build and Release / build (push) Has been cancelled
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user