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