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

This commit is contained in:
Sucukdeluxe 2026-02-28 19:47:46 +01:00
parent 05a0c4fd55
commit 84d8f37ba6
16 changed files with 966 additions and 282 deletions

4
package-lock.json generated
View File

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

View File

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

View File

@ -12,7 +12,7 @@ import {
UpdateInstallResult UpdateInstallResult
} from "../shared/types"; } from "../shared/types";
import { importDlcContainers } from "./container"; import { importDlcContainers } from "./container";
import { APP_VERSION, defaultSettings } from "./constants"; import { APP_VERSION } from "./constants";
import { DownloadManager } from "./download-manager"; import { DownloadManager } from "./download-manager";
import { parseCollectorInput } from "./link-parser"; import { parseCollectorInput } from "./link-parser";
import { configureLogger, getLogFilePath, logger } from "./logger"; import { configureLogger, getLogFilePath, logger } from "./logger";
@ -20,6 +20,15 @@ import { MegaWebFallback } from "./mega-web-fallback";
import { createStoragePaths, loadSession, loadSettings, normalizeSettings, saveSettings } from "./storage"; import { createStoragePaths, loadSession, loadSettings, normalizeSettings, saveSettings } from "./storage";
import { checkGitHubUpdate, installLatestUpdate } from "./update"; import { checkGitHubUpdate, installLatestUpdate } from "./update";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
return Object.fromEntries(entries) as Partial<AppSettings>;
}
function settingsFingerprint(settings: AppSettings): string {
return JSON.stringify(normalizeSettings(settings));
}
export class AppController { export class AppController {
private settings: AppSettings; private settings: AppSettings;
@ -33,6 +42,8 @@ export class AppController {
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
public constructor() { public constructor() {
configureLogger(this.storagePaths.baseDir); configureLogger(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths); this.settings = loadSettings(this.storagePaths);
@ -45,7 +56,7 @@ export class AppController {
megaWebUnrestrict: (link: string) => this.megaWebFallback.unrestrict(link) megaWebUnrestrict: (link: string) => this.megaWebFallback.unrestrict(link)
}); });
this.manager.on("state", (snapshot: UiSnapshot) => { this.manager.on("state", (snapshot: UiSnapshot) => {
this.onState?.(snapshot); this.onStateHandler?.(snapshot);
}); });
logger.info(`App gestartet v${APP_VERSION}`); logger.info(`App gestartet v${APP_VERSION}`);
logger.info(`Log-Datei: ${getLogFilePath()}`); logger.info(`Log-Datei: ${getLogFilePath()}`);
@ -72,7 +83,16 @@ export class AppController {
); );
} }
public onState: ((snapshot: UiSnapshot) => void) | null = null; public get onState(): ((snapshot: UiSnapshot) => void) | null {
return this.onStateHandler;
}
public set onState(handler: ((snapshot: UiSnapshot) => void) | null) {
this.onStateHandler = handler;
if (handler) {
handler(this.manager.getSnapshot());
}
}
public getSnapshot(): UiSnapshot { public getSnapshot(): UiSnapshot {
return this.manager.getSnapshot(); return this.manager.getSnapshot();
@ -87,13 +107,13 @@ export class AppController {
} }
public updateSettings(partial: Partial<AppSettings>): AppSettings { public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial);
const nextSettings = normalizeSettings({ const nextSettings = normalizeSettings({
...defaultSettings(),
...this.settings, ...this.settings,
...partial ...sanitizedPatch
}); });
if (JSON.stringify(nextSettings) === JSON.stringify(this.settings)) { if (settingsFingerprint(nextSettings) === settingsFingerprint(this.settings)) {
return this.settings; return this.settings;
} }

View File

@ -9,15 +9,15 @@ async function yieldToLoop(): Promise<void> {
} }
export function isArchiveOrTempFile(filePath: string): boolean { export function isArchiveOrTempFile(filePath: string): boolean {
const lower = filePath.toLowerCase(); const lowerName = path.basename(filePath).toLowerCase();
const ext = path.extname(lower); const ext = path.extname(lowerName);
if (ARCHIVE_TEMP_EXTENSIONS.has(ext)) { if (ARCHIVE_TEMP_EXTENSIONS.has(ext)) {
return true; return true;
} }
if (lower.includes(".part") && lower.endsWith(".rar")) { if (lowerName.includes(".part") && lowerName.endsWith(".rar")) {
return true; return true;
} }
return RAR_SPLIT_RE.test(lower); return RAR_SPLIT_RE.test(lowerName);
} }
export function cleanupCancelledPackageArtifacts(packageDir: string): number { export function cleanupCancelledPackageArtifacts(packageDir: string): number {

View File

@ -21,7 +21,7 @@ export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rs
export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i; export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i;
export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz"]); export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz"]);
export const RAR_SPLIT_RE = /\.r\d{2}$/i; export const RAR_SPLIT_RE = /\.r\d{2,3}$/i;
export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024; export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024; export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;

View File

@ -194,11 +194,21 @@ export async function importDlcContainers(filePaths: string[]): Promise<ParsedPa
let packages: ParsedPackageInput[] = []; let packages: ParsedPackageInput[] = [];
try { try {
packages = await decryptDlcLocal(filePath); packages = await decryptDlcLocal(filePath);
} catch { } catch (error) {
if (/zu groß|ungültig/i.test(compactErrorText(error))) {
throw error;
}
packages = []; packages = [];
} }
if (packages.length === 0) { if (packages.length === 0) {
try {
packages = await decryptDlcViaDcrypt(filePath); packages = await decryptDlcViaDcrypt(filePath);
} catch (error) {
if (/zu groß|ungültig/i.test(compactErrorText(error))) {
throw error;
}
packages = [];
}
} }
out.push(...packages); out.push(...packages);
} }

View File

@ -5,6 +5,7 @@ import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
const API_TIMEOUT_MS = 30000; const API_TIMEOUT_MS = 30000;
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.28";
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1"; const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4"; const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4";
@ -35,8 +36,7 @@ type BestDebridRequest = {
function canonicalLink(link: string): string { function canonicalLink(link: string): string {
try { try {
const parsed = new URL(link); const parsed = new URL(link);
const query = parsed.searchParams.toString(); return `${parsed.host.toLowerCase()}${parsed.pathname}${parsed.search}`;
return `${parsed.hostname}${parsed.pathname}${query ? `?${query}` : ""}`.toLowerCase();
} catch { } catch {
return link.trim().toLowerCase(); return link.trim().toLowerCase();
} }
@ -71,6 +71,34 @@ function isRetryableErrorText(text: string): boolean {
|| lower.includes("html statt json"); || lower.includes("html statt json");
} }
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await sleep(ms);
return;
}
if (signal.aborted) {
throw new Error("aborted:debrid");
}
await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null;
signal.removeEventListener("abort", onAbort);
resolve();
}, Math.max(0, ms));
const onAbort = (): void => {
if (timer) {
clearTimeout(timer);
timer = null;
}
signal.removeEventListener("abort", onAbort);
reject(new Error("aborted:debrid"));
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
function asRecord(value: unknown): Record<string, unknown> | null { function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) { if (!value || typeof value !== "object" || Array.isArray(value)) {
return null; return null;
@ -105,7 +133,7 @@ function pickNumber(payload: Record<string, unknown> | null, keys: string[]): nu
} }
for (const key of keys) { for (const key of keys) {
const value = Number(payload[key] ?? NaN); const value = Number(payload[key] ?? NaN);
if (Number.isFinite(value) && value > 0) { if (Number.isFinite(value) && value >= 0) {
return Math.floor(value); return Math.floor(value);
} }
} }
@ -124,6 +152,15 @@ function parseError(status: number, responseText: string, payload: Record<string
return `HTTP ${status}`; return `HTTP ${status}`;
} }
function parseAllDebridError(payload: Record<string, unknown> | null): string {
const errorValue = payload?.error;
if (typeof errorValue === "string" && errorValue.trim()) {
return errorValue.trim();
}
const errorObj = asRecord(errorValue);
return pickString(errorObj, ["message", "code"]) || "AllDebrid API error";
}
function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] { function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] {
const seen = new Set<DebridProvider>(); const seen = new Set<DebridProvider>();
const result: DebridProvider[] = []; const result: DebridProvider[] = [];
@ -219,8 +256,7 @@ export function extractRapidgatorFilenameFromHtml(html: string): string {
/<title>([^<]+)<\/title>/i, /<title>([^<]+)<\/title>/i,
/(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]+)\s*</i, /(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]+)\s*</i,
/(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]+)/i, /(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]+)/i,
/download\s+file\s+([^<\r\n]+)/i, /download\s+file\s+([^<\r\n]+)/i
/([A-Za-z0-9][A-Za-z0-9._\-()[\] ]{2,220}\.(?:part\d+\.rar|r\d{2}|rar|zip|7z|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|m2ts|ts|webm|mp3|flac|aac|srt|ass|sub))/i
]; ];
for (const pattern of patterns) { for (const pattern of patterns) {
@ -243,6 +279,7 @@ async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (i
} }
const size = Math.max(1, Math.min(concurrency, items.length)); const size = Math.max(1, Math.min(concurrency, items.length));
let index = 0; let index = 0;
let firstError: unknown = null;
const next = (): T | undefined => { const next = (): T | undefined => {
if (index >= items.length) { if (index >= items.length) {
return undefined; return undefined;
@ -254,14 +291,30 @@ async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (i
const runners = Array.from({ length: size }, async () => { const runners = Array.from({ length: size }, async () => {
let current = next(); let current = next();
while (current !== undefined) { while (current !== undefined) {
try {
await worker(current); await worker(current);
} catch (error) {
if (!firstError) {
firstError = error;
}
}
current = next(); current = next();
} }
}); });
await Promise.all(runners); await Promise.all(runners);
if (firstError) {
throw firstError;
}
} }
async function resolveRapidgatorFilename(link: string): Promise<string> { function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
if (!signal) {
return AbortSignal.timeout(timeoutMs);
}
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
}
async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Promise<string> {
if (!isRapidgatorLink(link)) { if (!isRapidgatorLink(link)) {
return ""; return "";
} }
@ -270,6 +323,10 @@ async function resolveRapidgatorFilename(link: string): Promise<string> {
return fromUrl; return fromUrl;
} }
if (signal?.aborted) {
throw new Error("aborted:debrid");
}
for (let attempt = 1; attempt <= REQUEST_RETRIES + 2; attempt += 1) { for (let attempt = 1; attempt <= REQUEST_RETRIES + 2; attempt += 1) {
try { try {
const response = await fetch(link, { const response = await fetch(link, {
@ -279,26 +336,44 @@ async function resolveRapidgatorFilename(link: string): Promise<string> {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,de;q=0.8" "Accept-Language": "en-US,en;q=0.9,de;q=0.8"
}, },
signal: AbortSignal.timeout(API_TIMEOUT_MS) signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
}); });
if (!response.ok) { if (!response.ok) {
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) { if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
await sleep(retryDelay(attempt)); await sleepWithSignal(retryDelay(attempt), signal);
continue; continue;
} }
return ""; return "";
} }
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
if (contentType
&& !contentType.includes("text/html")
&& !contentType.includes("application/xhtml")
&& !contentType.includes("text/plain")
&& !contentType.includes("text/xml")
&& !contentType.includes("application/xml")) {
return "";
}
const html = await response.text(); const html = await response.text();
const fromHtml = extractRapidgatorFilenameFromHtml(html); const fromHtml = extractRapidgatorFilenameFromHtml(html);
if (fromHtml) { if (fromHtml) {
return fromHtml; return fromHtml;
} }
} catch { return "";
// retry below } catch (error) {
const errorText = compactErrorText(error);
if (/aborted/i.test(errorText)) {
throw error;
}
if (attempt >= REQUEST_RETRIES + 2 || !isRetryableErrorText(errorText)) {
return "";
}
} }
if (attempt < REQUEST_RETRIES + 2) { if (attempt < REQUEST_RETRIES + 2) {
await sleep(retryDelay(attempt)); await sleepWithSignal(retryDelay(attempt), signal);
} }
} }
@ -338,6 +413,9 @@ class MegaDebridClient {
web.retriesUsed = attempt - 1; web.retriesUsed = attempt - 1;
return web; return web;
} }
if (web && !web.directUrl) {
throw new Error("Mega-Web Antwort ohne Download-Link");
}
if (!lastError) { if (!lastError) {
lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer"; lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer";
} }
@ -376,7 +454,7 @@ class BestDebridClient {
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
try { try {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"User-Agent": "RD-Node-Downloader/1.1.12" "User-Agent": DEBRID_USER_AGENT
}; };
if (request.useAuthHeader) { if (request.useAuthHeader) {
headers.Authorization = `Bearer ${this.token}`; headers.Authorization = `Bearer ${this.token}`;
@ -402,6 +480,14 @@ class BestDebridClient {
const directUrl = pickString(payload, ["download", "debridLink", "link"]); const directUrl = pickString(payload, ["download", "debridLink", "link"]);
if (directUrl) { if (directUrl) {
try {
const parsedDirect = new URL(directUrl);
if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") {
throw new Error("invalid_protocol");
}
} catch {
throw new Error("BestDebrid Antwort enthält ungültige Download-URL");
}
const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(originalLink); const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(originalLink);
const fileSize = pickNumber(payload, ["filesize", "size", "bytes"]); const fileSize = pickNumber(payload, ["filesize", "size", "bytes"]);
return { return {
@ -426,7 +512,7 @@ class BestDebridClient {
await sleep(retryDelay(attempt)); await sleep(retryDelay(attempt));
} }
} }
throw new Error(lastError || "BestDebrid Request fehlgeschlagen"); throw new Error(String(lastError || "BestDebrid Request fehlgeschlagen").replace(/^Error:\s*/i, ""));
} }
} }
@ -473,7 +559,7 @@ class AllDebridClient {
headers: { headers: {
Authorization: `Bearer ${this.token}`, Authorization: `Bearer ${this.token}`,
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "RD-Node-Downloader/1.1.15" "User-Agent": DEBRID_USER_AGENT
}, },
body, body,
signal: AbortSignal.timeout(API_TIMEOUT_MS) signal: AbortSignal.timeout(API_TIMEOUT_MS)
@ -501,8 +587,7 @@ class AllDebridClient {
const status = pickString(payload, ["status"]); const status = pickString(payload, ["status"]);
if (status && status.toLowerCase() === "error") { if (status && status.toLowerCase() === "error") {
const errorObj = asRecord(payload?.error); throw new Error(parseAllDebridError(payload));
throw new Error(pickString(errorObj, ["message", "code"]) || "AllDebrid API error");
} }
chunkResolved = true; chunkResolved = true;
@ -534,7 +619,9 @@ class AllDebridClient {
const responseLink = pickString(info, ["link"]); const responseLink = pickString(info, ["link"]);
const byResponse = canonicalToInput.get(canonicalLink(responseLink)); const byResponse = canonicalToInput.get(canonicalLink(responseLink));
const byIndex = chunk.length === 1 ? chunk[0] : ""; const byIndex = chunk.length === 1
? chunk[0]
: "";
const original = byResponse || byIndex; const original = byResponse || byIndex;
if (!original) { if (!original) {
continue; continue;
@ -555,7 +642,7 @@ class AllDebridClient {
headers: { headers: {
Authorization: `Bearer ${this.token}`, Authorization: `Bearer ${this.token}`,
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "RD-Node-Downloader/1.1.12" "User-Agent": DEBRID_USER_AGENT
}, },
body: new URLSearchParams({ link }), body: new URLSearchParams({ link }),
signal: AbortSignal.timeout(API_TIMEOUT_MS) signal: AbortSignal.timeout(API_TIMEOUT_MS)
@ -577,11 +664,13 @@ class AllDebridClient {
if (looksHtml) { if (looksHtml) {
throw new Error("AllDebrid lieferte HTML statt JSON"); throw new Error("AllDebrid lieferte HTML statt JSON");
} }
if (!payload) {
throw new Error("AllDebrid Antwort ist kein JSON-Objekt");
}
const status = pickString(payload, ["status"]); const status = pickString(payload, ["status"]);
if (status && status.toLowerCase() === "error") { if (status && status.toLowerCase() === "error") {
const errorObj = asRecord(payload?.error); throw new Error(parseAllDebridError(payload));
throw new Error(pickString(errorObj, ["message", "code"]) || "AllDebrid API error");
} }
const data = asRecord(payload?.data); const data = asRecord(payload?.data);
@ -612,29 +701,24 @@ class AllDebridClient {
export class DebridService { export class DebridService {
private settings: AppSettings; private settings: AppSettings;
private realDebridClient: RealDebridClient;
private allDebridClient: AllDebridClient;
private options: DebridServiceOptions; private options: DebridServiceOptions;
public constructor(settings: AppSettings, options: DebridServiceOptions = {}) { public constructor(settings: AppSettings, options: DebridServiceOptions = {}) {
this.settings = settings; this.settings = settings;
this.options = options; this.options = options;
this.realDebridClient = new RealDebridClient(settings.token);
this.allDebridClient = new AllDebridClient(settings.allDebridToken);
} }
public setSettings(next: AppSettings): void { public setSettings(next: AppSettings): void {
this.settings = next; this.settings = next;
this.realDebridClient = new RealDebridClient(next.token);
this.allDebridClient = new AllDebridClient(next.allDebridToken);
} }
public async resolveFilenames( public async resolveFilenames(
links: string[], links: string[],
onResolved?: (link: string, fileName: string) => void onResolved?: (link: string, fileName: string) => void,
signal?: AbortSignal
): Promise<Map<string, string>> { ): Promise<Map<string, string>> {
const settings = { ...this.settings };
const allDebridClient = new AllDebridClient(settings.allDebridToken);
const unresolved = links.filter((link) => looksLikeOpaqueFilename(filenameFromUrl(link))); const unresolved = links.filter((link) => looksLikeOpaqueFilename(filenameFromUrl(link)));
if (unresolved.length === 0) { if (unresolved.length === 0) {
return new Map<string, string>(); return new Map<string, string>();
@ -653,10 +737,10 @@ export class DebridService {
onResolved?.(link, normalized); onResolved?.(link, normalized);
}; };
const token = this.settings.allDebridToken.trim(); const token = settings.allDebridToken.trim();
if (token) { if (token) {
try { try {
const infos = await this.allDebridClient.getLinkInfos(unresolved); const infos = await allDebridClient.getLinkInfos(unresolved);
for (const [link, fileName] of infos.entries()) { for (const [link, fileName] of infos.entries()) {
reportResolved(link, fileName); reportResolved(link, fileName);
} }
@ -667,14 +751,14 @@ export class DebridService {
const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link)); const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link));
await runWithConcurrency(remaining, 6, async (link) => { await runWithConcurrency(remaining, 6, async (link) => {
const fromPage = await resolveRapidgatorFilename(link); const fromPage = await resolveRapidgatorFilename(link, signal);
reportResolved(link, fromPage); reportResolved(link, fromPage);
}); });
const stillUnresolved = unresolved.filter((link) => !clean.has(link) && !isRapidgatorLink(link)); const stillUnresolved = unresolved.filter((link) => !clean.has(link) && !isRapidgatorLink(link));
await runWithConcurrency(stillUnresolved, 4, async (link) => { await runWithConcurrency(stillUnresolved, 4, async (link) => {
try { try {
const unrestricted = await this.unrestrictLink(link); const unrestricted = await this.unrestrictLink(link, signal, settings);
reportResolved(link, unrestricted.fileName || ""); reportResolved(link, unrestricted.fileName || "");
} catch { } catch {
// ignore final fallback errors // ignore final fallback errors
@ -684,23 +768,24 @@ export class DebridService {
return clean; return clean;
} }
public async unrestrictLink(link: string): Promise<ProviderUnrestrictedLink> { public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
const settings = settingsSnapshot ? { ...settingsSnapshot } : { ...this.settings };
const order = toProviderOrder( const order = toProviderOrder(
this.settings.providerPrimary, settings.providerPrimary,
this.settings.providerSecondary, settings.providerSecondary,
this.settings.providerTertiary settings.providerTertiary
); );
const primary = order[0]; const primary = order[0];
if (!this.settings.autoProviderFallback) { if (!settings.autoProviderFallback) {
if (!this.isProviderConfigured(primary)) { if (!this.isProviderConfiguredFor(settings, primary)) {
throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`); throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`);
} }
try { try {
const result = await this.unrestrictViaProvider(primary, link); const result = await this.unrestrictViaProvider(settings, primary, link, signal);
let fileName = result.fileName; let fileName = result.fileName;
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
const fromPage = await resolveRapidgatorFilename(link); const fromPage = await resolveRapidgatorFilename(link, signal);
if (fromPage) { if (fromPage) {
fileName = fromPage; fileName = fromPage;
} }
@ -720,16 +805,16 @@ export class DebridService {
const attempts: string[] = []; const attempts: string[] = [];
for (const provider of order) { for (const provider of order) {
if (!this.isProviderConfigured(provider)) { if (!this.isProviderConfiguredFor(settings, provider)) {
continue; continue;
} }
configuredFound = true; configuredFound = true;
try { try {
const result = await this.unrestrictViaProvider(provider, link); const result = await this.unrestrictViaProvider(settings, provider, link, signal);
let fileName = result.fileName; let fileName = result.fileName;
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
const fromPage = await resolveRapidgatorFilename(link); const fromPage = await resolveRapidgatorFilename(link, signal);
if (fromPage) { if (fromPage) {
fileName = fromPage; fileName = fromPage;
} }
@ -752,29 +837,29 @@ export class DebridService {
throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`); throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`);
} }
private isProviderConfigured(provider: DebridProvider): boolean { private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean {
if (provider === "realdebrid") { if (provider === "realdebrid") {
return Boolean(this.settings.token.trim()); return Boolean(settings.token.trim());
} }
if (provider === "megadebrid") { if (provider === "megadebrid") {
return Boolean(this.settings.megaLogin.trim() && this.settings.megaPassword.trim() && this.options.megaWebUnrestrict); return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim() && this.options.megaWebUnrestrict);
} }
if (provider === "alldebrid") { if (provider === "alldebrid") {
return Boolean(this.settings.allDebridToken.trim()); return Boolean(settings.allDebridToken.trim());
} }
return Boolean(this.settings.bestToken.trim()); return Boolean(settings.bestToken.trim());
} }
private async unrestrictViaProvider(provider: DebridProvider, link: string): Promise<UnrestrictedLink> { private async unrestrictViaProvider(settings: AppSettings, provider: DebridProvider, link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
if (provider === "realdebrid") { if (provider === "realdebrid") {
return this.realDebridClient.unrestrictLink(link); return new RealDebridClient(settings.token).unrestrictLink(link, signal);
} }
if (provider === "megadebrid") { if (provider === "megadebrid") {
return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link); return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link);
} }
if (provider === "alldebrid") { if (provider === "alldebrid") {
return this.allDebridClient.unrestrictLink(link); return new AllDebridClient(settings.allDebridToken).unrestrictLink(link);
} }
return new BestDebridClient(this.settings.bestToken).unrestrictLink(link); return new BestDebridClient(settings.bestToken).unrestrictLink(link);
} }
} }

View File

@ -54,7 +54,7 @@ function getDownloadStallTimeoutMs(): number {
function getDownloadConnectTimeoutMs(): number { function getDownloadConnectTimeoutMs(): number {
const fromEnv = Number(process.env.RD_CONNECT_TIMEOUT_MS ?? NaN); const fromEnv = Number(process.env.RD_CONNECT_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 180000) { if (Number.isFinite(fromEnv) && fromEnv >= 250 && fromEnv <= 180000) {
return Math.floor(fromEnv); return Math.floor(fromEnv);
} }
return DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS; return DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS;
@ -103,6 +103,13 @@ function cloneSession(session: SessionState): SessionState {
}; };
} }
function cloneSettings(settings: AppSettings): AppSettings {
return {
...settings,
bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry }))
};
}
function parseContentRangeTotal(contentRange: string | null): number | null { function parseContentRangeTotal(contentRange: string | null): number | null {
if (!contentRange) { if (!contentRange) {
return null; return null;
@ -123,7 +130,7 @@ function parseContentDispositionFilename(contentDisposition: string | null): str
const encodedMatch = contentDisposition.match(/filename\*\s*=\s*([^;]+)/i); const encodedMatch = contentDisposition.match(/filename\*\s*=\s*([^;]+)/i);
if (encodedMatch?.[1]) { if (encodedMatch?.[1]) {
let value = encodedMatch[1].trim(); let value = encodedMatch[1].trim();
value = value.replace(/^UTF-8''/i, ""); value = value.replace(/^[A-Za-z0-9._-]+(?:'[^']*)?'/, "");
value = value.replace(/^['"]+|['"]+$/g, ""); value = value.replace(/^['"]+|['"]+$/g, "");
try { try {
const decoded = decodeURIComponent(value).trim(); const decoded = decodeURIComponent(value).trim();
@ -144,13 +151,9 @@ function parseContentDispositionFilename(contentDisposition: string | null): str
return plainMatch[1].trim().replace(/^['"]+|['"]+$/g, ""); return plainMatch[1].trim().replace(/^['"]+|['"]+$/g, "");
} }
function canRetryStatus(status: number): boolean {
return status === 429 || status >= 500;
}
function isArchiveLikePath(filePath: string): boolean { function isArchiveLikePath(filePath: string): boolean {
const lower = path.basename(filePath).toLowerCase(); const lower = path.basename(filePath).toLowerCase();
return /\.(?:part\d+\.rar|rar|r\d{2}|zip|z\d{2}|7z|7z\.\d{3})$/i.test(lower); return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip|z\d{2}|7z|7z\.\d{3})$/i.test(lower);
} }
function isFetchFailure(errorText: string): boolean { function isFetchFailure(errorText: string): boolean {
@ -259,7 +262,7 @@ export function ensureRepackToken(baseName: string): string {
return baseName; return baseName;
} }
const withQualityToken = baseName.replace(SCENE_QUALITY_TOKEN_RE, ".REPACK.$2"); const withQualityToken = baseName.replace(SCENE_QUALITY_TOKEN_RE, "$1REPACK.$2");
if (withQualityToken !== baseName) { if (withQualityToken !== baseName) {
return withQualityToken; return withQualityToken;
} }
@ -357,6 +360,8 @@ export class DownloadManager extends EventEmitter {
private lastGlobalProgressAt = 0; private lastGlobalProgressAt = 0;
private retryAfterByItem = new Map<string, number>();
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;
@ -428,10 +433,16 @@ export class DownloadManager extends EventEmitter {
const reconnectMs = Math.max(0, this.session.reconnectUntil - now); const reconnectMs = Math.max(0, this.session.reconnectUntil - now);
const snapshotSession = cloneSession(this.session);
const snapshotSettings = cloneSettings(this.settings);
const snapshotSummary = this.summary
? { ...this.summary }
: null;
return { return {
settings: this.settings, settings: snapshotSettings,
session: this.session, session: snapshotSession,
summary: this.summary, summary: snapshotSummary,
stats: this.getStats(now), stats: this.getStats(now),
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
etaText: paused ? "ETA: --" : `ETA: ${formatEta(eta)}`, etaText: paused ? "ETA: --" : `ETA: ${formatEta(eta)}`,
@ -494,7 +505,14 @@ export class DownloadManager extends EventEmitter {
} }
public reorderPackages(packageIds: string[]): void { public reorderPackages(packageIds: string[]): void {
const valid = packageIds.filter((id) => this.session.packages[id]); const seen = new Set<string>();
const valid = packageIds.filter((id) => {
if (!this.session.packages[id] || seen.has(id)) {
return false;
}
seen.add(id);
return true;
});
const remaining = this.session.packageOrder.filter((id) => !valid.includes(id)); const remaining = this.session.packageOrder.filter((id) => !valid.includes(id));
this.session.packageOrder = [...valid, ...remaining]; this.session.packageOrder = [...valid, ...remaining];
this.persistSoon(); this.persistSoon();
@ -508,6 +526,7 @@ export class DownloadManager extends EventEmitter {
} }
this.recordRunOutcome(itemId, "cancelled"); this.recordRunOutcome(itemId, "cancelled");
const active = this.activeTasks.get(itemId); const active = this.activeTasks.get(itemId);
const hasActiveTask = Boolean(active);
if (active) { if (active) {
active.abortReason = "cancel"; active.abortReason = "cancel";
active.abortController.abort("cancel"); active.abortController.abort("cancel");
@ -523,7 +542,10 @@ export class DownloadManager extends EventEmitter {
} }
delete this.session.items[itemId]; delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1); this.itemCount = Math.max(0, this.itemCount - 1);
this.retryAfterByItem.delete(itemId);
if (!hasActiveTask) {
this.releaseTargetPath(itemId); this.releaseTargetPath(itemId);
}
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
} }
@ -631,12 +653,21 @@ export class DownloadManager extends EventEmitter {
return { addedPackages: 0, addedLinks: 0 }; return { addedPackages: 0, addedLinks: 0 };
} }
const inputs: ParsedPackageInput[] = data.packages const inputs: ParsedPackageInput[] = data.packages
.filter((pkg) => pkg.name && Array.isArray(pkg.links) && pkg.links.length > 0) .map((pkg) => {
.map((pkg) => ({ name: pkg.name, links: pkg.links })); const name = typeof pkg?.name === "string" ? pkg.name : "";
const linksRaw = Array.isArray(pkg?.links) ? pkg.links : [];
const links = linksRaw
.filter((link) => typeof link === "string")
.map((link) => link.trim())
.filter(Boolean);
return { name, links };
})
.filter((pkg) => pkg.name.trim().length > 0 && pkg.links.length > 0);
return this.addPackages(inputs); return this.addPackages(inputs);
} }
public clearAll(): void { public clearAll(): void {
this.clearPersistTimer();
this.stop(); this.stop();
this.abortPostProcessing("clear_all"); this.abortPostProcessing("clear_all");
if (this.stateEmitTimer) { if (this.stateEmitTimer) {
@ -652,6 +683,7 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear(); this.runPackageIds.clear();
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();
@ -663,6 +695,8 @@ export class DownloadManager extends EventEmitter {
this.hybridExtractRequeue.clear(); this.hybridExtractRequeue.clear();
this.packagePostProcessQueue = Promise.resolve(); this.packagePostProcessQueue = Promise.resolve();
this.summary = null; this.summary = null;
this.nonResumableActive = 0;
this.retryAfterByItem.clear();
this.persistNow(); this.persistNow();
this.emitState(true); this.emitState(true);
} }
@ -804,9 +838,16 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.delete(itemId); this.runItemIds.delete(itemId);
this.runOutcomes.delete(itemId); this.runOutcomes.delete(itemId);
this.itemContributedBytes.delete(itemId); this.itemContributedBytes.delete(itemId);
this.retryAfterByItem.delete(itemId);
delete this.session.items[itemId]; delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1); this.itemCount = Math.max(0, this.itemCount - 1);
} }
const postProcessController = this.packagePostProcessAbortControllers.get(packageId);
if (postProcessController && !postProcessController.signal.aborted) {
postProcessController.abort("cancel");
}
this.packagePostProcessAbortControllers.delete(packageId);
this.packagePostProcessTasks.delete(packageId);
delete this.session.packages[packageId]; delete this.session.packages[packageId];
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
this.runPackageIds.delete(packageId); this.runPackageIds.delete(packageId);
@ -818,6 +859,12 @@ export class DownloadManager extends EventEmitter {
} }
if (policy === "overwrite") { if (policy === "overwrite") {
const postProcessController = this.packagePostProcessAbortControllers.get(packageId);
if (postProcessController && !postProcessController.signal.aborted) {
postProcessController.abort("overwrite");
}
this.packagePostProcessAbortControllers.delete(packageId);
this.packagePostProcessTasks.delete(packageId);
const canDeleteExtractDir = this.isPackageSpecificExtractDir(pkg) && !this.isExtractDirSharedWithOtherPackages(pkg.id, pkg.extractDir); const canDeleteExtractDir = this.isPackageSpecificExtractDir(pkg) && !this.isExtractDirSharedWithOtherPackages(pkg.id, pkg.extractDir);
if (canDeleteExtractDir) { if (canDeleteExtractDir) {
try { try {
@ -857,10 +904,12 @@ export class DownloadManager extends EventEmitter {
item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url))); item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url)));
this.runOutcomes.delete(itemId); this.runOutcomes.delete(itemId);
this.itemContributedBytes.delete(itemId); this.itemContributedBytes.delete(itemId);
this.retryAfterByItem.delete(itemId);
if (this.session.running) { if (this.session.running) {
this.runItemIds.add(itemId); this.runItemIds.add(itemId);
} }
} }
this.runCompletedPackages.delete(packageId);
pkg.status = "queued"; pkg.status = "queued";
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
this.persistSoon(); this.persistSoon();
@ -1257,6 +1306,11 @@ export class DownloadManager extends EventEmitter {
} }
} }
const postProcessController = this.packagePostProcessAbortControllers.get(packageId);
if (postProcessController && !postProcessController.signal.aborted) {
postProcessController.abort("cancel");
}
this.removePackageFromSession(packageId, itemIds); this.removePackageFromSession(packageId, itemIds);
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
@ -1297,6 +1351,7 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear(); this.runPackageIds.clear();
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
this.session.running = false; this.session.running = false;
@ -1312,6 +1367,7 @@ export class DownloadManager extends EventEmitter {
this.lastGlobalProgressBytes = 0; this.lastGlobalProgressBytes = 0;
this.lastGlobalProgressAt = nowMs(); this.lastGlobalProgressAt = nowMs();
this.summary = null; this.summary = null;
this.nonResumableActive = 0;
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
return; return;
@ -1320,6 +1376,7 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds = new Set(runItems.map((item) => item.packageId)); this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.session.running = true; this.session.running = true;
this.session.paused = false; this.session.paused = false;
@ -1340,6 +1397,7 @@ export class DownloadManager extends EventEmitter {
this.globalSpeedLimitQueue = Promise.resolve(); this.globalSpeedLimitQueue = Promise.resolve();
this.globalSpeedLimitNextAt = 0; this.globalSpeedLimitNextAt = 0;
this.summary = null; this.summary = null;
this.nonResumableActive = 0;
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
void this.ensureScheduler().catch((error) => { void this.ensureScheduler().catch((error) => {
@ -1356,6 +1414,7 @@ export class DownloadManager extends EventEmitter {
this.session.paused = false; this.session.paused = false;
this.session.reconnectUntil = 0; this.session.reconnectUntil = 0;
this.session.reconnectReason = ""; this.session.reconnectReason = "";
this.retryAfterByItem.clear();
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs(); this.lastGlobalProgressAt = nowMs();
this.abortPostProcessing("stop"); this.abortPostProcessing("stop");
@ -1369,6 +1428,7 @@ export class DownloadManager extends EventEmitter {
public prepareForShutdown(): void { public prepareForShutdown(): void {
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`); logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
this.clearPersistTimer();
this.session.running = false; this.session.running = false;
this.session.paused = false; this.session.paused = false;
this.session.reconnectUntil = 0; this.session.reconnectUntil = 0;
@ -1423,6 +1483,8 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear(); this.runPackageIds.clear();
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.nonResumableActive = 0;
this.session.summaryText = ""; this.session.summaryText = "";
this.persistNow(); this.persistNow();
this.emitState(true); this.emitState(true);
@ -1506,8 +1568,8 @@ export class DownloadManager extends EventEmitter {
if (failed > 0) { if (failed > 0) {
pkg.status = "failed"; pkg.status = "failed";
} else if (cancelled > 0 && success === 0) { } else if (cancelled > 0) {
pkg.status = "cancelled"; pkg.status = success > 0 ? "failed" : "cancelled";
} else if (success > 0) { } else if (success > 0) {
pkg.status = "completed"; pkg.status = "completed";
} }
@ -1543,6 +1605,14 @@ export class DownloadManager extends EventEmitter {
} }
} }
private clearPersistTimer(): void {
if (!this.persistTimer) {
return;
}
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
private persistSoon(): void { private persistSoon(): void {
if (this.persistTimer) { if (this.persistTimer) {
return; return;
@ -1658,10 +1728,6 @@ export class DownloadManager extends EventEmitter {
const parsed = path.parse(preferredPath); const parsed = path.parse(preferredPath);
const preferredKey = pathKey(preferredPath); const preferredKey = pathKey(preferredPath);
const baseDirKey = process.platform === "win32" ? parsed.dir.toLowerCase() : parsed.dir;
const baseNameKey = process.platform === "win32" ? parsed.name.toLowerCase() : parsed.name;
const baseExtKey = process.platform === "win32" ? parsed.ext.toLowerCase() : parsed.ext;
const sep = path.sep;
const maxIndex = 10000; const maxIndex = 10000;
for (let index = 0; index <= maxIndex; index += 1) { for (let index = 0; index <= maxIndex; index += 1) {
const candidate = index === 0 const candidate = index === 0
@ -1669,7 +1735,7 @@ export class DownloadManager extends EventEmitter {
: path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`); : path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`);
const key = index === 0 const key = index === 0
? preferredKey ? preferredKey
: `${baseDirKey}${sep}${baseNameKey} (${index})${baseExtKey}`; : pathKey(candidate);
const owner = this.reservedTargetPaths.get(key); const owner = this.reservedTargetPaths.get(key);
const existsOnDisk = fs.existsSync(candidate); const existsOnDisk = fs.existsSync(candidate);
const allowExistingCandidate = allowExistingFile && index === 0; const allowExistingCandidate = allowExistingFile && index === 0;
@ -1809,7 +1875,11 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
const targetStatus = failed > 0 ? "failed" : cancelled > 0 && success === 0 ? "cancelled" : "completed"; const targetStatus = failed > 0
? "failed"
: cancelled > 0
? (success > 0 ? "failed" : "cancelled")
: "completed";
if (pkg.status !== targetStatus) { if (pkg.status !== targetStatus) {
pkg.status = targetStatus; pkg.status = targetStatus;
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
@ -1865,7 +1935,14 @@ export class DownloadManager extends EventEmitter {
} }
private removePackageFromSession(packageId: string, itemIds: string[]): void { private removePackageFromSession(packageId: string, itemIds: string[]): void {
const postProcessController = this.packagePostProcessAbortControllers.get(packageId);
if (postProcessController && !postProcessController.signal.aborted) {
postProcessController.abort("package_removed");
}
this.packagePostProcessAbortControllers.delete(packageId);
this.packagePostProcessTasks.delete(packageId);
for (const itemId of itemIds) { for (const itemId of itemIds) {
this.retryAfterByItem.delete(itemId);
delete this.session.items[itemId]; delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1); this.itemCount = Math.max(0, this.itemCount - 1);
} }
@ -1918,7 +1995,7 @@ export class DownloadManager extends EventEmitter {
this.runGlobalStallWatchdog(now); this.runGlobalStallWatchdog(now);
if (this.activeTasks.size === 0 && !this.hasQueuedItems() && this.packagePostProcessTasks.size === 0) { if (this.activeTasks.size === 0 && !this.hasQueuedItems() && !this.hasDelayedQueuedItems() && this.packagePostProcessTasks.size === 0) {
this.finishRun(); this.finishRun();
break; break;
} }
@ -2031,7 +2108,8 @@ export class DownloadManager extends EventEmitter {
private markQueuedAsReconnectWait(): boolean { private markQueuedAsReconnectWait(): boolean {
let changed = false; let changed = false;
const waitText = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`; const waitSeconds = Math.max(0, Math.ceil((this.session.reconnectUntil - nowMs()) / 1000));
const waitText = `Reconnect-Wait (${waitSeconds}s)`;
const itemIds = this.runItemIds.size > 0 ? this.runItemIds : Object.keys(this.session.items); const itemIds = this.runItemIds.size > 0 ? this.runItemIds : Object.keys(this.session.items);
for (const itemId of itemIds) { for (const itemId of itemIds) {
const item = this.session.items[itemId]; const item = this.session.items[itemId];
@ -2056,6 +2134,7 @@ export class DownloadManager extends EventEmitter {
} }
private findNextQueuedItem(): { packageId: string; itemId: string } | null { private findNextQueuedItem(): { packageId: string; itemId: string } | null {
const now = nowMs();
for (const packageId of this.session.packageOrder) { for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) { if (!pkg || pkg.cancelled || !pkg.enabled) {
@ -2066,6 +2145,13 @@ export class DownloadManager extends EventEmitter {
if (!item) { if (!item) {
continue; continue;
} }
const retryAfter = this.retryAfterByItem.get(itemId) || 0;
if (retryAfter > now) {
continue;
}
if (retryAfter > 0) {
this.retryAfterByItem.delete(itemId);
}
if (item.status === "queued" || item.status === "reconnect_wait") { if (item.status === "queued" || item.status === "reconnect_wait") {
return { packageId, itemId }; return { packageId, itemId };
} }
@ -2078,6 +2164,28 @@ export class DownloadManager extends EventEmitter {
return this.findNextQueuedItem() !== null; return this.findNextQueuedItem() !== null;
} }
private hasDelayedQueuedItems(): boolean {
const now = nowMs();
for (const [itemId, readyAt] of this.retryAfterByItem.entries()) {
if (readyAt <= now) {
continue;
}
const item = this.session.items[itemId];
if (!item) {
continue;
}
if (item.status !== "queued" && item.status !== "reconnect_wait") {
continue;
}
const pkg = this.session.packages[item.packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) {
continue;
}
return true;
}
return false;
}
private countQueuedItems(): number { private countQueuedItems(): number {
let count = 0; let count = 0;
for (const packageId of this.session.packageOrder) { for (const packageId of this.session.packageOrder) {
@ -2098,6 +2206,18 @@ export class DownloadManager extends EventEmitter {
return count; return count;
} }
private queueRetry(item: DownloadItem, active: ActiveTask, delayMs: number, statusText: string): void {
const waitMs = Math.max(0, Math.floor(delayMs));
item.status = "queued";
item.speedBps = 0;
item.fullStatus = statusText;
item.updatedAt = nowMs();
item.attempts = 0;
active.abortController = new AbortController();
active.abortReason = "none";
this.retryAfterByItem.set(item.id, nowMs() + waitMs);
}
private startItem(packageId: string, itemId: string): void { private startItem(packageId: string, itemId: string): void {
const item = this.session.items[itemId]; const item = this.session.items[itemId];
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
@ -2108,6 +2228,8 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
this.retryAfterByItem.delete(itemId);
item.status = "validating"; item.status = "validating";
item.fullStatus = "Link wird umgewandelt"; item.fullStatus = "Link wird umgewandelt";
item.updatedAt = nowMs(); item.updatedAt = nowMs();
@ -2154,7 +2276,7 @@ export class DownloadManager extends EventEmitter {
const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES); const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES);
while (true) { while (true) {
try { try {
const unrestricted = await this.debridService.unrestrictLink(item.url); const unrestricted = await this.debridService.unrestrictLink(item.url, active.abortController.signal);
if (active.abortController.signal.aborted) { if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`); throw new Error(`aborted:${active.abortReason}`);
} }
@ -2191,6 +2313,10 @@ export class DownloadManager extends EventEmitter {
this.nonResumableActive += 1; this.nonResumableActive += 1;
} }
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
if (this.settings.enableIntegrityCheck) { if (this.settings.enableIntegrityCheck) {
item.status = "integrity_check"; item.status = "integrity_check";
item.fullStatus = "CRC-Check läuft"; item.fullStatus = "CRC-Check läuft";
@ -2198,6 +2324,9 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir); const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir);
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
if (!validation.ok) { if (!validation.ok) {
item.lastError = validation.message; item.lastError = validation.message;
item.fullStatus = `${validation.message}, Neuversuch`; item.fullStatus = `${validation.message}, Neuversuch`;
@ -2207,7 +2336,7 @@ export class DownloadManager extends EventEmitter {
// ignore // ignore
} }
if (item.attempts < maxAttempts) { if (item.attempts < maxAttempts) {
item.status = "queued"; item.status = "integrity_check";
item.progressPercent = 0; item.progressPercent = 0;
item.downloadedBytes = 0; item.downloadedBytes = 0;
item.totalBytes = unrestricted.fileSize; item.totalBytes = unrestricted.fileSize;
@ -2219,6 +2348,10 @@ export class DownloadManager extends EventEmitter {
} }
} }
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
const finalTargetPath = String(item.targetPath || "").trim(); const finalTargetPath = String(item.targetPath || "").trim();
const fileSizeOnDisk = finalTargetPath && fs.existsSync(finalTargetPath) const fileSizeOnDisk = finalTargetPath && fs.existsSync(finalTargetPath)
? fs.statSync(finalTargetPath).size ? fs.statSync(finalTargetPath).size
@ -2241,6 +2374,11 @@ export class DownloadManager extends EventEmitter {
done = true; done = true;
} }
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
item.status = "completed"; item.status = "completed";
item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`; item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`;
item.progressPercent = 100; item.progressPercent = 100;
@ -2249,6 +2387,7 @@ export class DownloadManager extends EventEmitter {
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
this.recordRunOutcome(item.id, "completed"); this.recordRunOutcome(item.id, "completed");
if (this.session.running && !active.abortController.signal.aborted) {
void this.runPackagePostProcessing(pkg.id).catch((err) => { void this.runPackagePostProcessing(pkg.id).catch((err) => {
logger.warn(`runPackagePostProcessing Fehler (processItem): ${compactErrorText(err)}`); logger.warn(`runPackagePostProcessing Fehler (processItem): ${compactErrorText(err)}`);
}).finally(() => { }).finally(() => {
@ -2256,6 +2395,7 @@ export class DownloadManager extends EventEmitter {
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
}); });
}
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
return; return;
@ -2280,16 +2420,11 @@ export class DownloadManager extends EventEmitter {
item.status = "cancelled"; item.status = "cancelled";
item.fullStatus = "Gestoppt"; item.fullStatus = "Gestoppt";
this.recordRunOutcome(item.id, "cancelled"); this.recordRunOutcome(item.id, "cancelled");
if (claimedTargetPath) { if (!active.resumable && claimedTargetPath && !fs.existsSync(claimedTargetPath)) {
try {
fs.rmSync(claimedTargetPath, { force: true });
} catch {
// ignore
}
}
item.downloadedBytes = 0; item.downloadedBytes = 0;
item.progressPercent = 0; item.progressPercent = 0;
item.totalBytes = null; item.totalBytes = null;
}
} else if (reason === "shutdown") { } else if (reason === "shutdown") {
item.status = "queued"; item.status = "queued";
item.speedBps = 0; item.speedBps = 0;
@ -2297,6 +2432,7 @@ export class DownloadManager extends EventEmitter {
item.fullStatus = activePkg && !activePkg.enabled ? "Paket gestoppt" : "Wartet"; item.fullStatus = activePkg && !activePkg.enabled ? "Paket gestoppt" : "Wartet";
} else if (reason === "reconnect") { } else if (reason === "reconnect") {
item.status = "queued"; item.status = "queued";
item.speedBps = 0;
item.fullStatus = "Wartet auf Reconnect"; item.fullStatus = "Wartet auf Reconnect";
} else if (reason === "package_toggle") { } else if (reason === "package_toggle") {
item.status = "queued"; item.status = "queued";
@ -2306,18 +2442,11 @@ export class DownloadManager extends EventEmitter {
stallRetries += 1; stallRetries += 1;
if (stallRetries <= 2) { if (stallRetries <= 2) {
item.retries += 1; item.retries += 1;
item.status = "queued"; this.queueRetry(item, active, 350 * stallRetries, `Keine Daten empfangen, Retry ${stallRetries}/2`);
item.speedBps = 0;
item.fullStatus = `Keine Daten empfangen, Retry ${stallRetries}/2`;
item.lastError = ""; item.lastError = "";
item.attempts = 0;
item.updatedAt = nowMs();
active.abortController = new AbortController();
active.abortReason = "none";
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
await sleep(350 * stallRetries); return;
continue;
} }
item.status = "failed"; item.status = "failed";
item.lastError = "Download hing wiederholt"; item.lastError = "Download hing wiederholt";
@ -2337,6 +2466,15 @@ export class DownloadManager extends EventEmitter {
item.downloadedBytes = 0; item.downloadedBytes = 0;
item.totalBytes = null; item.totalBytes = null;
item.progressPercent = 0; item.progressPercent = 0;
item.status = "failed";
this.recordRunOutcome(item.id, "failed");
item.lastError = errorText;
item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0;
item.updatedAt = nowMs();
this.persistSoon();
this.emitState();
return;
} }
if (shouldFreshRetry) { if (shouldFreshRetry) {
freshRetryUsed = true; freshRetryUsed = true;
@ -2347,53 +2485,34 @@ export class DownloadManager extends EventEmitter {
// ignore // ignore
} }
this.releaseTargetPath(item.id); this.releaseTargetPath(item.id);
item.status = "queued"; this.queueRetry(item, active, 450, "Netzwerkfehler erkannt, frischer Retry");
item.fullStatus = "Netzwerkfehler erkannt, frischer Retry";
item.lastError = ""; item.lastError = "";
item.attempts = 0;
item.downloadedBytes = 0; item.downloadedBytes = 0;
item.totalBytes = null; item.totalBytes = null;
item.progressPercent = 0; item.progressPercent = 0;
item.speedBps = 0;
item.updatedAt = nowMs();
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
await sleep(450); return;
continue;
} }
if (isUnrestrictFailure(errorText) && unrestrictRetries < maxUnrestrictRetries) { if (isUnrestrictFailure(errorText) && unrestrictRetries < maxUnrestrictRetries) {
unrestrictRetries += 1; unrestrictRetries += 1;
item.retries += 1; item.retries += 1;
item.status = "queued"; this.queueRetry(item, active, Math.min(8000, 2000 * unrestrictRetries), `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`);
item.fullStatus = `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`;
item.lastError = errorText; item.lastError = errorText;
item.attempts = 0;
item.speedBps = 0;
item.updatedAt = nowMs();
active.abortController = new AbortController();
active.abortReason = "none";
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
await sleep(Math.min(8000, 2000 * unrestrictRetries)); return;
continue;
} }
if (genericErrorRetries < maxGenericErrorRetries) { if (genericErrorRetries < maxGenericErrorRetries) {
genericErrorRetries += 1; genericErrorRetries += 1;
item.retries += 1; item.retries += 1;
item.status = "queued"; this.queueRetry(item, active, Math.min(1200, 300 * genericErrorRetries), `Fehler erkannt, Auto-Retry ${genericErrorRetries}/${maxGenericErrorRetries}`);
item.fullStatus = `Fehler erkannt, Auto-Retry ${genericErrorRetries}/${maxGenericErrorRetries}`;
item.lastError = errorText; item.lastError = errorText;
item.attempts = 0;
item.speedBps = 0;
item.updatedAt = nowMs();
active.abortController = new AbortController();
active.abortReason = "none";
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
await sleep(Math.min(1200, 300 * genericErrorRetries)); return;
continue;
} }
item.status = "failed"; item.status = "failed";
@ -2482,6 +2601,7 @@ export class DownloadManager extends EventEmitter {
if (!response.ok) { if (!response.ok) {
if (response.status === 416 && existingBytes > 0) { if (response.status === 416 && existingBytes > 0) {
await response.arrayBuffer().catch(() => undefined);
const rangeTotal = parseContentRangeTotal(response.headers.get("content-range")); const rangeTotal = parseContentRangeTotal(response.headers.get("content-range"));
const expectedTotal = knownTotal && knownTotal > 0 ? knownTotal : rangeTotal; const expectedTotal = knownTotal && knownTotal > 0 ? knownTotal : rangeTotal;
if (expectedTotal && existingBytes === expectedTotal) { if (expectedTotal && existingBytes === expectedTotal) {
@ -2654,6 +2774,7 @@ export class DownloadManager extends EventEmitter {
active.abortController.signal.addEventListener("abort", onAbort, { once: true }); active.abortController.signal.addEventListener("abort", onAbort, { once: true });
}); });
let bodyError: unknown = null;
try { try {
const body = response.body; const body = response.body;
if (!body) { if (!body) {
@ -2744,7 +2865,7 @@ export class DownloadManager extends EventEmitter {
} }
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
await this.applySpeedLimit(buffer.length, windowBytes, windowStarted); await this.applySpeedLimit(buffer.length, windowBytes, windowStarted, active.abortController.signal);
if (active.abortController.signal.aborted) { if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`); throw new Error(`aborted:${active.abortReason}`);
} }
@ -2778,8 +2899,17 @@ export class DownloadManager extends EventEmitter {
} }
} finally { } finally {
clearInterval(idleTimer); clearInterval(idleTimer);
try {
reader.releaseLock();
} catch {
// ignore
} }
}
} catch (error) {
bodyError = error;
throw error;
} finally { } finally {
try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
if (stream.closed || stream.destroyed) { if (stream.closed || stream.destroyed) {
resolve(); resolve();
@ -2801,6 +2931,12 @@ export class DownloadManager extends EventEmitter {
stream.once("error", onError); stream.once("error", onError);
stream.end(); stream.end();
}); });
} catch (streamCloseError) {
if (!bodyError) {
throw streamCloseError;
}
logger.warn(`Stream-Abschlussfehler unterdrückt: ${compactErrorText(streamCloseError)}`);
}
} }
item.downloadedBytes = written; item.downloadedBytes = written;
@ -2970,8 +3106,8 @@ export class DownloadManager extends EventEmitter {
if (failed > 0) { if (failed > 0) {
pkg.status = "failed"; pkg.status = "failed";
} else if (cancelled > 0 && success === 0) { } else if (cancelled > 0) {
pkg.status = "cancelled"; pkg.status = success > 0 ? "failed" : "cancelled";
} else if (success > 0) { } else if (success > 0) {
pkg.status = "completed"; pkg.status = "completed";
} }
@ -2999,6 +3135,10 @@ export class DownloadManager extends EventEmitter {
if (!entry.enabled) { if (!entry.enabled) {
continue; continue;
} }
if (entry.startHour === entry.endHour) {
this.cachedSpeedLimitKbps = entry.speedLimitKbps;
return this.cachedSpeedLimitKbps;
}
const wraps = entry.startHour > entry.endHour; const wraps = entry.startHour > entry.endHour;
const inRange = wraps const inRange = wraps
? hour >= entry.startHour || hour < entry.endHour ? hour >= entry.startHour || hour < entry.endHour
@ -3017,14 +3157,46 @@ export class DownloadManager extends EventEmitter {
return 0; return 0;
} }
private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number): Promise<void> { private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number, signal?: AbortSignal): Promise<void> {
const task = this.globalSpeedLimitQueue const task = this.globalSpeedLimitQueue
.catch(() => undefined) .catch(() => undefined)
.then(async () => { .then(async () => {
if (signal?.aborted) {
throw new Error("aborted:speed_limit");
}
const now = nowMs(); const now = nowMs();
const waitMs = Math.max(0, this.globalSpeedLimitNextAt - now); const waitMs = Math.max(0, this.globalSpeedLimitNextAt - now);
if (waitMs > 0) { if (waitMs > 0) {
await sleep(waitMs); await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null;
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve();
}, waitMs);
const onAbort = (): void => {
if (timer) {
clearTimeout(timer);
timer = null;
}
signal?.removeEventListener("abort", onAbort);
reject(new Error("aborted:speed_limit"));
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
}
});
}
if (signal?.aborted) {
throw new Error("aborted:speed_limit");
} }
const startAt = Math.max(nowMs(), this.globalSpeedLimitNextAt); const startAt = Math.max(nowMs(), this.globalSpeedLimitNextAt);
@ -3036,7 +3208,7 @@ export class DownloadManager extends EventEmitter {
await task; await task;
} }
private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise<void> { private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number, signal?: AbortSignal): Promise<void> {
const limitKbps = this.getEffectiveSpeedLimitKbps(); const limitKbps = this.getEffectiveSpeedLimitKbps();
if (limitKbps <= 0) { if (limitKbps <= 0) {
return; return;
@ -3050,13 +3222,38 @@ export class DownloadManager extends EventEmitter {
if (projected > allowed) { if (projected > allowed) {
const sleepMs = Math.ceil(((projected - allowed) / bytesPerSecond) * 1000); const sleepMs = Math.ceil(((projected - allowed) / bytesPerSecond) * 1000);
if (sleepMs > 0) { if (sleepMs > 0) {
await sleep(Math.min(300, sleepMs)); await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null;
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve();
}, Math.min(300, sleepMs));
const onAbort = (): void => {
if (timer) {
clearTimeout(timer);
timer = null;
}
signal?.removeEventListener("abort", onAbort);
reject(new Error("aborted:speed_limit"));
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
}
});
} }
} }
return; return;
} }
await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond); await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond, signal);
} }
private findReadyArchiveSets(pkg: PackageEntry): Set<string> { private findReadyArchiveSets(pkg: PackageEntry): Set<string> {
@ -3125,7 +3322,7 @@ export class DownloadManager extends EventEmitter {
if (/\.rar$/i.test(entryPointName) && !/\.part\d+\.rar$/i.test(entryPointName)) { if (/\.rar$/i.test(entryPointName) && !/\.part\d+\.rar$/i.test(entryPointName)) {
const stem = entryPointName.replace(/\.rar$/i, "").toLowerCase(); const stem = entryPointName.replace(/\.rar$/i, "").toLowerCase();
const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^${escaped}\\.r(ar|\\d{2})$`, "i").test(fileName); return new RegExp(`^${escaped}\\.r(ar|\\d{2,3})$`, "i").test(fileName);
} }
if (/\.zip\.001$/i.test(entryPointName)) { if (/\.zip\.001$/i.test(entryPointName)) {
const stem = entryPointName.replace(/\.zip\.001$/i, "").toLowerCase(); const stem = entryPointName.replace(/\.zip\.001$/i, "").toLowerCase();
@ -3323,6 +3520,9 @@ export class DownloadManager extends EventEmitter {
} }
} }
const extractDeadline = setTimeout(() => { const extractDeadline = setTimeout(() => {
if (signal?.aborted || extractAbortController.signal.aborted) {
return;
}
timedOut = true; timedOut = true;
logger.error(`Post-Processing Extraction Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s: pkg=${pkg.name}`); logger.error(`Post-Processing Extraction Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s: pkg=${pkg.name}`);
if (!extractAbortController.signal.aborted) { if (!extractAbortController.signal.aborted) {
@ -3432,8 +3632,8 @@ export class DownloadManager extends EventEmitter {
} }
} else if (failed > 0) { } else if (failed > 0) {
pkg.status = "failed"; pkg.status = "failed";
} else if (cancelled > 0 && success === 0) { } else if (cancelled > 0) {
pkg.status = "cancelled"; pkg.status = success > 0 ? "failed" : "cancelled";
} else { } else {
pkg.status = "completed"; pkg.status = "completed";
} }
@ -3483,9 +3683,17 @@ export class DownloadManager extends EventEmitter {
} }
if (policy === "immediate") { if (policy === "immediate") {
if (this.settings.autoExtract) {
const item = this.session.items[itemId];
const extracted = item ? isExtractedLabel(item.fullStatus || "") : false;
if (!extracted) {
return;
}
}
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
delete this.session.items[itemId]; delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1); this.itemCount = Math.max(0, this.itemCount - 1);
this.retryAfterByItem.delete(itemId);
if (pkg.itemIds.length === 0) { if (pkg.itemIds.length === 0) {
this.removePackageFromSession(packageId, []); this.removePackageFromSession(packageId, []);
} }
@ -3536,6 +3744,7 @@ export class DownloadManager extends EventEmitter {
this.speedBytesLastWindow = 0; this.speedBytesLastWindow = 0;
this.globalSpeedLimitQueue = Promise.resolve(); this.globalSpeedLimitQueue = Promise.resolve();
this.globalSpeedLimitNextAt = 0; this.globalSpeedLimitNextAt = 0;
this.nonResumableActive = 0;
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs(); this.lastGlobalProgressAt = nowMs();
this.persistNow(); this.persistNow();

View File

@ -319,19 +319,17 @@ function winRarCandidates(): string[] {
const installed = [ const installed = [
path.join(programFiles, "WinRAR", "UnRAR.exe"), path.join(programFiles, "WinRAR", "UnRAR.exe"),
path.join(programFiles, "WinRAR", "WinRAR.exe"),
path.join(programFilesX86, "WinRAR", "UnRAR.exe"), path.join(programFilesX86, "WinRAR", "UnRAR.exe"),
path.join(programFilesX86, "WinRAR", "WinRAR.exe") path.join(programFilesX86, "WinRAR", "UnRAR.exe")
]; ];
if (localAppData) { if (localAppData) {
installed.push(path.join(localAppData, "Programs", "WinRAR", "UnRAR.exe")); installed.push(path.join(localAppData, "Programs", "WinRAR", "UnRAR.exe"));
installed.push(path.join(localAppData, "Programs", "WinRAR", "WinRAR.exe"));
} }
const ordered = resolvedExtractorCommand const ordered = resolvedExtractorCommand
? [resolvedExtractorCommand, ...installed, "UnRAR.exe", "WinRAR.exe", "unrar", "winrar"] ? [resolvedExtractorCommand, ...installed, "UnRAR.exe", "unrar"]
: [...installed, "UnRAR.exe", "WinRAR.exe", "unrar", "winrar"]; : [...installed, "UnRAR.exe", "unrar"];
return Array.from(new Set(ordered.filter(Boolean))); return Array.from(new Set(ordered.filter(Boolean)));
} }
@ -378,6 +376,47 @@ type ExtractSpawnResult = {
errorText: string; errorText: string;
}; };
function killProcessTree(child: { pid?: number; kill: () => void }): void {
const pid = Number(child.pid || 0);
if (!Number.isFinite(pid) || pid <= 0) {
try {
child.kill();
} catch {
// ignore
}
return;
}
if (process.platform === "win32") {
try {
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
windowsHide: true,
stdio: "ignore"
});
killer.on("error", () => {
try {
child.kill();
} catch {
// ignore
}
});
} catch {
try {
child.kill();
} catch {
// ignore
}
}
return;
}
try {
child.kill();
} catch {
// ignore
}
}
function runExtractCommand( function runExtractCommand(
command: string, command: string,
args: string[], args: string[],
@ -394,6 +433,8 @@ function runExtractCommand(
let output = ""; let output = "";
const child = spawn(command, args, { windowsHide: true }); const child = spawn(command, args, { windowsHide: true });
let timeoutId: NodeJS.Timeout | null = null; let timeoutId: NodeJS.Timeout | null = null;
let timedOutByWatchdog = false;
let abortedBySignal = false;
const finish = (result: ExtractSpawnResult): void => { const finish = (result: ExtractSpawnResult): void => {
if (settled) { if (settled) {
@ -412,11 +453,8 @@ function runExtractCommand(
if (timeoutMs && timeoutMs > 0) { if (timeoutMs && timeoutMs > 0) {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
try { timedOutByWatchdog = true;
child.kill(); killProcessTree(child);
} catch {
// ignore
}
finish({ finish({
ok: false, ok: false,
missingCommand: false, missingCommand: false,
@ -429,11 +467,8 @@ function runExtractCommand(
const onAbort = signal const onAbort = signal
? (): void => { ? (): void => {
try { abortedBySignal = true;
child.kill(); killProcessTree(child);
} catch {
// ignore
}
finish({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" }); finish({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" });
} }
: null; : null;
@ -464,6 +499,20 @@ function runExtractCommand(
}); });
child.on("close", (code) => { child.on("close", (code) => {
if (abortedBySignal) {
finish({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" });
return;
}
if (timedOutByWatchdog) {
finish({
ok: false,
missingCommand: false,
aborted: false,
timedOut: true,
errorText: `Entpacken Timeout nach ${Math.ceil((timeoutMs || 0) / 1000)}s`
});
return;
}
if (code === 0) { if (code === 0) {
finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" }); finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" });
return; return;
@ -543,7 +592,7 @@ async function resolveExtractorCommandInternal(): Promise<string> {
} }
const probeArgs = command.toLowerCase().includes("winrar") ? ["-?"] : ["?"]; const probeArgs = command.toLowerCase().includes("winrar") ? ["-?"] : ["?"];
const probe = await runExtractCommand(command, probeArgs, undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS); const probe = await runExtractCommand(command, probeArgs, undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS);
if (!probe.missingCommand) { if (probe.ok) {
resolvedExtractorCommand = command; resolvedExtractorCommand = command;
resolveFailureReason = ""; resolveFailureReason = "";
resolveFailureAt = 0; resolveFailureAt = 0;
@ -680,17 +729,20 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
const zip = new AdmZip(archivePath); const zip = new AdmZip(archivePath);
const entries = zip.getEntries(); const entries = zip.getEntries();
const resolvedTarget = path.resolve(targetDir); const resolvedTarget = path.resolve(targetDir);
const usedOutputs = new Set<string>();
const renameCounters = new Map<string, number>();
for (const entry of entries) { for (const entry of entries) {
if (signal?.aborted) { if (signal?.aborted) {
throw new Error("aborted:extract"); throw new Error("aborted:extract");
} }
const outputPath = path.resolve(targetDir, entry.entryName); const baseOutputPath = path.resolve(targetDir, entry.entryName);
if (!outputPath.startsWith(resolvedTarget + path.sep) && outputPath !== resolvedTarget) { if (!baseOutputPath.startsWith(resolvedTarget + path.sep) && baseOutputPath !== resolvedTarget) {
logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`); logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`);
continue; continue;
} }
if (entry.isDirectory) { if (entry.isDirectory) {
fs.mkdirSync(outputPath, { recursive: true }); fs.mkdirSync(baseOutputPath, { recursive: true });
continue; continue;
} }
@ -708,52 +760,76 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
}).header; }).header;
const uncompressedSize = Number(header?.size ?? header?.dataHeader?.size ?? NaN); const uncompressedSize = Number(header?.size ?? header?.dataHeader?.size ?? NaN);
const compressedSize = Number(header?.compressedSize ?? header?.dataHeader?.compressedSize ?? NaN); const compressedSize = Number(header?.compressedSize ?? header?.dataHeader?.compressedSize ?? NaN);
const crc = Number(header?.crc ?? header?.dataHeader?.crc ?? 0);
if (Number.isFinite(uncompressedSize) && uncompressedSize > memoryLimitBytes) { if (!Number.isFinite(uncompressedSize) || uncompressedSize < 0) {
throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker");
}
if (!Number.isFinite(compressedSize) || compressedSize < 0) {
throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker");
}
if (uncompressedSize > memoryLimitBytes) {
const entryMb = Math.ceil(uncompressedSize / (1024 * 1024)); const entryMb = Math.ceil(uncompressedSize / (1024 * 1024));
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
} }
if (Number.isFinite(compressedSize) && compressedSize > memoryLimitBytes) { if (compressedSize > memoryLimitBytes) {
const entryMb = Math.ceil(compressedSize / (1024 * 1024)); const entryMb = Math.ceil(compressedSize / (1024 * 1024));
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
throw new Error(`ZIP-Eintrag komprimiert zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); throw new Error(`ZIP-Eintrag komprimiert zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
} }
if ((!Number.isFinite(uncompressedSize) || uncompressedSize <= 0)
&& Number.isFinite(compressedSize) let outputPath = baseOutputPath;
&& compressedSize > 0 let outputKey = pathSetKey(outputPath);
&& crc !== 0) {
throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker");
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.mkdirSync(path.dirname(outputPath), { recursive: true });
// TOCTOU note: There is a small race between existsSync and writeFileSync below. // TOCTOU note: There is a small race between existsSync and writeFileSync below.
// This is acceptable here because zip extraction is single-threaded and we need // This is acceptable here because zip extraction is single-threaded and we need
// the exists check to implement skip/rename conflict resolution semantics. // the exists check to implement skip/rename conflict resolution semantics.
if (fs.existsSync(outputPath)) { if (usedOutputs.has(outputKey) || fs.existsSync(outputPath)) {
if (mode === "skip") { if (mode === "skip") {
continue; continue;
} }
if (mode === "rename") { if (mode === "rename") {
const parsed = path.parse(outputPath); const parsed = path.parse(baseOutputPath);
let n = 1; const counterKey = pathSetKey(baseOutputPath);
let candidate = outputPath; let n = renameCounters.get(counterKey) || 1;
while (fs.existsSync(candidate)) { let candidate = baseOutputPath;
let candidateKey = outputKey;
while (n <= 10000) {
candidate = path.join(parsed.dir, `${parsed.name} (${n})${parsed.ext}`); candidate = path.join(parsed.dir, `${parsed.name} (${n})${parsed.ext}`);
candidateKey = pathSetKey(candidate);
if (!usedOutputs.has(candidateKey) && !fs.existsSync(candidate)) {
break;
}
n += 1; n += 1;
} }
if (n > 10000) {
throw new Error(`ZIP-Rename-Limit erreicht für ${entry.entryName}`);
}
renameCounters.set(counterKey, n + 1);
if (signal?.aborted) { if (signal?.aborted) {
throw new Error("aborted:extract"); throw new Error("aborted:extract");
} }
fs.writeFileSync(candidate, entry.getData()); outputPath = candidate;
continue; outputKey = candidateKey;
} }
} }
if (signal?.aborted) { if (signal?.aborted) {
throw new Error("aborted:extract"); throw new Error("aborted:extract");
} }
fs.writeFileSync(outputPath, entry.getData()); const data = entry.getData();
if (data.length > memoryLimitBytes) {
const entryMb = Math.ceil(data.length / (1024 * 1024));
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
}
if (data.length > Math.max(uncompressedSize, compressedSize) * 20) {
throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${entry.entryName})`);
}
fs.writeFileSync(outputPath, data);
usedOutputs.add(outputKey);
} }
} }
@ -795,7 +871,7 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
if (/\.rar$/i.test(fileName)) { if (/\.rar$/i.test(fileName)) {
const stem = escapeRegex(fileName.replace(/\.rar$/i, "")); const stem = escapeRegex(fileName.replace(/\.rar$/i, ""));
addMatching(new RegExp(`^${stem}\\.rar$`, "i")); addMatching(new RegExp(`^${stem}\\.rar$`, "i"));
addMatching(new RegExp(`^${stem}\\.r\\d{2}$`, "i")); addMatching(new RegExp(`^${stem}\\.r\\d{2,3}$`, "i"));
return Array.from(targets); return Array.from(targets);
} }
@ -859,11 +935,39 @@ function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode): numbe
} }
let removed = 0; let removed = 0;
const moveToTrashLike = (filePath: string): boolean => {
try {
const parsed = path.parse(filePath);
const trashDir = path.join(parsed.dir, ".rd-trash");
fs.mkdirSync(trashDir, { recursive: true });
let index = 0;
while (index <= 10000) {
const suffix = index === 0 ? "" : `-${index}`;
const candidate = path.join(trashDir, `${parsed.base}.${Date.now()}${suffix}`);
if (!fs.existsSync(candidate)) {
fs.renameSync(filePath, candidate);
return true;
}
index += 1;
}
} catch {
// ignore
}
return false;
};
for (const filePath of targets) { for (const filePath of targets) {
try { try {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
continue; continue;
} }
if (cleanupMode === "trash") {
if (moveToTrashLike(filePath)) {
removed += 1;
}
continue;
}
fs.rmSync(filePath, { force: true }); fs.rmSync(filePath, { force: true });
removed += 1; removed += 1;
} catch { } catch {
@ -877,13 +981,13 @@ function hasAnyFilesRecursive(rootDir: string): boolean {
if (!fs.existsSync(rootDir)) { if (!fs.existsSync(rootDir)) {
return false; return false;
} }
const deadline = Date.now() + 70; const deadline = Date.now() + 220;
let inspectedDirs = 0; let inspectedDirs = 0;
const stack = [rootDir]; const stack = [rootDir];
while (stack.length > 0) { while (stack.length > 0) {
inspectedDirs += 1; inspectedDirs += 1;
if (inspectedDirs > 8000 || Date.now() > deadline) { if (inspectedDirs > 8000 || Date.now() > deadline) {
return true; return hasAnyEntries(rootDir);
} }
const current = stack.pop() as string; const current = stack.pop() as string;
let entries: fs.Dirent[] = []; let entries: fs.Dirent[] = [];
@ -1086,7 +1190,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (!shouldFallbackToExternalZip(error)) { if (!shouldFallbackToExternalZip(error)) {
throw error; throw error;
} }
const usedPassword = await runExternalExtract(archivePath, options.targetDir, "overwrite", passwordCandidates, (value) => { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => {
archivePercent = Math.max(archivePercent, value); archivePercent = Math.max(archivePercent, value);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, options.signal); }, options.signal);
@ -1140,7 +1244,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} else { } else {
if (!options.skipPostCleanup) { if (!options.skipPostCleanup) {
const cleanupSources = failed === 0 ? candidates : Array.from(extractedArchives.values()); const cleanupSources = failed === 0 ? candidates : Array.from(extractedArchives.values());
const removedArchives = cleanupArchives(cleanupSources, options.cleanupMode); const sourceAndTargetEqual = pathSetKey(path.resolve(options.packageDir)) === pathSetKey(path.resolve(options.targetDir));
const removedArchives = sourceAndTargetEqual
? 0
: cleanupArchives(cleanupSources, options.cleanupMode);
if (sourceAndTargetEqual && options.cleanupMode !== "none") {
logger.warn(`Archive-Cleanup übersprungen (Quelle=Ziel): ${options.packageDir}`);
}
if (options.cleanupMode !== "none") { if (options.cleanupMode !== "none") {
logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`); logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`);
} }
@ -1183,7 +1293,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} }
} }
emitProgress(candidates.length, "", "done"); emitProgress(extracted, "", "done");
logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`); logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`);

View File

@ -57,8 +57,10 @@ function flushSyncPending(): void {
pendingLines = []; pendingLines = [];
pendingChars = 0; pendingChars = 0;
rotateIfNeeded(logFilePath);
const primary = appendLine(logFilePath, chunk); const primary = appendLine(logFilePath, chunk);
if (fallbackLogFilePath) { if (fallbackLogFilePath) {
rotateIfNeeded(fallbackLogFilePath);
const fallback = appendLine(fallbackLogFilePath, chunk); const fallback = appendLine(fallbackLogFilePath, chunk);
if (!primary.ok && !fallback.ok) { if (!primary.ok && !fallback.ok) {
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);

View File

@ -14,6 +14,16 @@ function validateString(value: unknown, name: string): string {
} }
return value; return value;
} }
function validatePlainObject(value: unknown, name: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${name} muss ein Objekt sein`);
}
return value as Record<string, unknown>;
}
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
const RENAME_PACKAGE_MAX_CHARS = 240;
function validateStringArray(value: unknown, name: string): string[] { function validateStringArray(value: unknown, name: string): string[] {
if (!Array.isArray(value) || !value.every(v => typeof v === "string")) { if (!Array.isArray(value) || !value.every(v => typeof v === "string")) {
throw new Error(`${name} muss ein String-Array sein`); throw new Error(`${name} muss ein String-Array sein`);
@ -121,7 +131,21 @@ function extractLinksFromText(text: string): string[] {
} }
function normalizeClipboardText(text: string): string { function normalizeClipboardText(text: string): string {
return String(text || "").slice(0, CLIPBOARD_MAX_TEXT_CHARS); const normalized = String(text || "");
if (normalized.length <= CLIPBOARD_MAX_TEXT_CHARS) {
return normalized;
}
const truncated = normalized.slice(0, CLIPBOARD_MAX_TEXT_CHARS);
const lastBreak = Math.max(
truncated.lastIndexOf("\n"),
truncated.lastIndexOf("\r"),
truncated.lastIndexOf("\t"),
truncated.lastIndexOf(" ")
);
if (lastBreak >= Math.floor(CLIPBOARD_MAX_TEXT_CHARS * 0.7)) {
return truncated.slice(0, lastBreak);
}
return truncated;
} }
function startClipboardWatcher(): void { function startClipboardWatcher(): void {
@ -193,13 +217,21 @@ function registerIpcHandlers(): void {
} }
}); });
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => { ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
const result = controller.updateSettings(partial ?? {}); const validated = validatePlainObject(partial ?? {}, "partial");
const result = controller.updateSettings(validated as Partial<AppSettings>);
updateClipboardWatcher(); updateClipboardWatcher();
updateTray(); updateTray();
return result; return result;
}); });
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {
validatePlainObject(payload ?? {}, "payload");
validateString(payload?.rawText, "rawText"); validateString(payload?.rawText, "rawText");
if (payload.packageName !== undefined) {
validateString(payload.packageName, "packageName");
}
if (payload.duplicatePolicy !== undefined && payload.duplicatePolicy !== "keep" && payload.duplicatePolicy !== "skip" && payload.duplicatePolicy !== "overwrite") {
throw new Error("duplicatePolicy muss 'keep', 'skip' oder 'overwrite' sein");
}
return controller.addLinks(payload); return controller.addLinks(payload);
}); });
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => { ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => {
@ -227,6 +259,9 @@ function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => { ipcMain.handle(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
validateString(newName, "newName"); validateString(newName, "newName");
if (newName.length > RENAME_PACKAGE_MAX_CHARS) {
throw new Error(`newName zu lang (max ${RENAME_PACKAGE_MAX_CHARS} Zeichen)`);
}
return controller.renamePackage(packageId, newName); return controller.renamePackage(packageId, newName);
}); });
ipcMain.handle(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { ipcMain.handle(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => {
@ -244,6 +279,10 @@ function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, () => controller.exportQueue()); ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, () => controller.exportQueue());
ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => { ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => {
validateString(json, "json"); validateString(json, "json");
const bytes = Buffer.byteLength(json, "utf8");
if (bytes > IMPORT_QUEUE_MAX_BYTES) {
throw new Error(`Queue-Import zu groß (max ${IMPORT_QUEUE_MAX_BYTES} Bytes)`);
}
return controller.importQueue(json); return controller.importQueue(json);
}); });
ipcMain.handle(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => { ipcMain.handle(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => {

View File

@ -1,6 +1,8 @@
import { API_BASE_URL, REQUEST_RETRIES } from "./constants"; import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
import { compactErrorText, sleep } from "./utils"; import { compactErrorText, sleep } from "./utils";
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.28";
export interface UnrestrictedLink { export interface UnrestrictedLink {
fileName: string; fileName: string;
directUrl: string; directUrl: string;
@ -16,6 +18,33 @@ function retryDelay(attempt: number): number {
return Math.min(5000, 400 * 2 ** attempt); return Math.min(5000, 400 * 2 ** attempt);
} }
function readHttpStatusFromErrorText(text: string): number {
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
return match ? Number(match[1]) : 0;
}
function isRetryableErrorText(text: string): boolean {
const status = readHttpStatusFromErrorText(text);
if (status === 429 || status >= 500) {
return true;
}
const lower = String(text || "").toLowerCase();
return lower.includes("timeout")
|| lower.includes("network")
|| lower.includes("fetch failed")
|| lower.includes("aborted")
|| lower.includes("econnreset")
|| lower.includes("enotfound")
|| lower.includes("etimedout");
}
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
if (!signal) {
return AbortSignal.timeout(timeoutMs);
}
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
}
function looksLikeHtmlResponse(contentType: string, body: string): boolean { function looksLikeHtmlResponse(contentType: string, body: string): boolean {
const type = String(contentType || "").toLowerCase(); const type = String(contentType || "").toLowerCase();
if (type.includes("text/html") || type.includes("application/xhtml+xml")) { if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
@ -39,7 +68,7 @@ export class RealDebridClient {
this.token = token; this.token = token;
} }
public async unrestrictLink(link: string): Promise<UnrestrictedLink> { public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
let lastError = ""; let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
try { try {
@ -49,10 +78,10 @@ export class RealDebridClient {
headers: { headers: {
Authorization: `Bearer ${this.token}`, Authorization: `Bearer ${this.token}`,
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "RD-Node-Downloader/1.1.12" "User-Agent": DEBRID_USER_AGENT
}, },
body, body,
signal: AbortSignal.timeout(30000) signal: withTimeoutSignal(signal, 30000)
}); });
const text = await response.text(); const text = await response.text();
@ -91,7 +120,10 @@ export class RealDebridClient {
}; };
} catch (error) { } catch (error) {
lastError = compactErrorText(error); lastError = compactErrorText(error);
if (attempt >= REQUEST_RETRIES) { if (signal?.aborted || /aborted/i.test(lastError)) {
break;
}
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
break; break;
} }
await sleep(retryDelay(attempt)); await sleep(retryDelay(attempt));

View File

@ -57,11 +57,20 @@ function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] {
return normalized; return normalized;
} }
function normalizeAbsoluteDir(value: unknown, fallback: string): string {
const text = asText(value);
if (/^\/[\s\S]+/.test(text)) {
return text.replace(/\\/g, "/");
}
if (!text || !path.isAbsolute(text)) {
return path.resolve(fallback);
}
return path.resolve(text);
}
export function normalizeSettings(settings: AppSettings): AppSettings { export function normalizeSettings(settings: AppSettings): AppSettings {
const defaults = defaultSettings(); const defaults = defaultSettings();
const normalized: AppSettings = { const normalized: AppSettings = {
...defaults,
...settings,
token: asText(settings.token), token: asText(settings.token),
megaLogin: asText(settings.megaLogin), megaLogin: asText(settings.megaLogin),
megaPassword: asText(settings.megaPassword), megaPassword: asText(settings.megaPassword),
@ -69,23 +78,30 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
allDebridToken: asText(settings.allDebridToken), allDebridToken: asText(settings.allDebridToken),
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n/g, "\n"), archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n/g, "\n"),
rememberToken: Boolean(settings.rememberToken), rememberToken: Boolean(settings.rememberToken),
providerPrimary: settings.providerPrimary,
providerSecondary: settings.providerSecondary,
providerTertiary: settings.providerTertiary,
autoProviderFallback: Boolean(settings.autoProviderFallback), autoProviderFallback: Boolean(settings.autoProviderFallback),
outputDir: asText(settings.outputDir) || defaults.outputDir, outputDir: normalizeAbsoluteDir(settings.outputDir, defaults.outputDir),
packageName: asText(settings.packageName), packageName: asText(settings.packageName),
autoExtract: Boolean(settings.autoExtract), autoExtract: Boolean(settings.autoExtract),
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj), autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
extractDir: asText(settings.extractDir) || defaults.extractDir, extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir),
createExtractSubfolder: Boolean(settings.createExtractSubfolder), createExtractSubfolder: Boolean(settings.createExtractSubfolder),
hybridExtract: Boolean(settings.hybridExtract), hybridExtract: Boolean(settings.hybridExtract),
cleanupMode: settings.cleanupMode,
extractConflictMode: settings.extractConflictMode,
removeLinkFilesAfterExtract: Boolean(settings.removeLinkFilesAfterExtract), removeLinkFilesAfterExtract: Boolean(settings.removeLinkFilesAfterExtract),
removeSamplesAfterExtract: Boolean(settings.removeSamplesAfterExtract), removeSamplesAfterExtract: Boolean(settings.removeSamplesAfterExtract),
enableIntegrityCheck: Boolean(settings.enableIntegrityCheck), enableIntegrityCheck: Boolean(settings.enableIntegrityCheck),
autoResumeOnStart: Boolean(settings.autoResumeOnStart), autoResumeOnStart: Boolean(settings.autoResumeOnStart),
autoReconnect: Boolean(settings.autoReconnect), autoReconnect: Boolean(settings.autoReconnect),
maxParallel: clampNumber(settings.maxParallel, defaults.maxParallel, 1, 50), maxParallel: clampNumber(settings.maxParallel, defaults.maxParallel, 1, 50),
reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600),
completedCleanupPolicy: settings.completedCleanupPolicy,
speedLimitEnabled: Boolean(settings.speedLimitEnabled), speedLimitEnabled: Boolean(settings.speedLimitEnabled),
speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000), speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000),
reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600), speedLimitMode: settings.speedLimitMode,
autoUpdateCheck: Boolean(settings.autoUpdateCheck), autoUpdateCheck: Boolean(settings.autoUpdateCheck),
updateRepo: asText(settings.updateRepo) || defaults.updateRepo, updateRepo: asText(settings.updateRepo) || defaults.updateRepo,
clipboardWatch: Boolean(settings.clipboardWatch), clipboardWatch: Boolean(settings.clipboardWatch),
@ -103,6 +119,12 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
if (!VALID_FALLBACK_PROVIDERS.has(normalized.providerTertiary)) { if (!VALID_FALLBACK_PROVIDERS.has(normalized.providerTertiary)) {
normalized.providerTertiary = "none"; normalized.providerTertiary = "none";
} }
if (normalized.providerSecondary === normalized.providerPrimary) {
normalized.providerSecondary = "none";
}
if (normalized.providerTertiary === normalized.providerPrimary || normalized.providerTertiary === normalized.providerSecondary) {
normalized.providerTertiary = "none";
}
if (!VALID_CLEANUP_MODES.has(normalized.cleanupMode)) { if (!VALID_CLEANUP_MODES.has(normalized.cleanupMode)) {
normalized.cleanupMode = defaults.cleanupMode; normalized.cleanupMode = defaults.cleanupMode;
} }
@ -264,9 +286,16 @@ function normalizeLoadedSession(raw: unknown): SessionState {
} }
const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : []; const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
const seenOrder = new Set<string>();
const packageOrder = rawOrder const packageOrder = rawOrder
.map((entry) => asText(entry)) .map((entry) => asText(entry))
.filter((id) => id in packagesById); .filter((id) => {
if (!(id in packagesById) || seenOrder.has(id)) {
return false;
}
seenOrder.add(id);
return true;
});
for (const packageId of Object.keys(packagesById)) { for (const packageId of Object.keys(packagesById)) {
if (!packageOrder.includes(packageId)) { if (!packageOrder.includes(packageId)) {
packageOrder.push(packageId); packageOrder.push(packageId);
@ -332,6 +361,10 @@ function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void
} }
} }
function sessionTempPath(sessionFile: string, kind: "sync" | "async"): string {
return `${sessionFile}.${kind}.tmp`;
}
export function saveSettings(paths: StoragePaths, settings: AppSettings): void { export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
// Create a backup of the existing config before overwriting // Create a backup of the existing config before overwriting
@ -396,7 +429,7 @@ export function loadSession(paths: StoragePaths): SessionState {
export function saveSession(paths: StoragePaths, session: SessionState): void { export function saveSession(paths: StoragePaths, session: SessionState): void {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
const tempPath = `${paths.sessionFile}.tmp`; const tempPath = sessionTempPath(paths.sessionFile, "sync");
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile); syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} }
@ -406,7 +439,7 @@ let asyncSaveQueued: { paths: StoragePaths; payload: string } | null = null;
async function writeSessionPayload(paths: StoragePaths, payload: string): Promise<void> { async function writeSessionPayload(paths: StoragePaths, payload: string): Promise<void> {
await fs.promises.mkdir(paths.baseDir, { recursive: true }); await fs.promises.mkdir(paths.baseDir, { recursive: true });
const tempPath = `${paths.sessionFile}.tmp`; const tempPath = sessionTempPath(paths.sessionFile, "async");
await fsp.writeFile(tempPath, payload, "utf8"); await fsp.writeFile(tempPath, payload, "utf8");
try { try {
await fsp.rename(tempPath, paths.sessionFile); await fsp.rename(tempPath, paths.sessionFile);

View File

@ -208,16 +208,15 @@ async function fetchReleasePayload(safeRepo: string, endpoint: string): Promise<
}, },
signal: timeout.signal signal: timeout.signal
}); });
} finally {
timeout.clear();
}
const payload = await readJsonWithTimeout(response, RELEASE_FETCH_TIMEOUT_MS); const payload = await readJsonWithTimeout(response, RELEASE_FETCH_TIMEOUT_MS);
return { return {
ok: response.ok, ok: response.ok,
status: response.status, status: response.status,
payload payload
}; };
} finally {
timeout.clear();
}
} }
function uniqueStrings(values: string[]): string[] { function uniqueStrings(values: string[]): string[] {
@ -440,6 +439,18 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
try { try {
await pipeline(source, target); await pipeline(source, target);
} catch (error) {
try {
source.destroy();
} catch {
// ignore
}
try {
target.destroy();
} catch {
// ignore
}
throw error;
} finally { } finally {
clearIdleTimer(); clearIdleTimer();
source.off("data", onSourceData); source.off("data", onSourceData);

View File

@ -43,8 +43,9 @@ export function sanitizeFilename(name: string): string {
} }
const parsed = path.parse(normalized); const parsed = path.parse(normalized);
if (WINDOWS_RESERVED_BASENAMES.has(parsed.name.toLowerCase())) { const reservedBase = (parsed.name.split(".")[0] || parsed.name).toLowerCase();
normalized = `${parsed.name}_${parsed.ext}`; if (WINDOWS_RESERVED_BASENAMES.has(reservedBase)) {
normalized = `${parsed.name.replace(/^([^.]*)/, "$1_")}${parsed.ext}`;
} }
return normalized || "Paket"; return normalized || "Paket";
@ -70,14 +71,25 @@ export function extractHttpLinksFromText(text: string): string[] {
for (const match of matches) { for (const match of matches) {
let candidate = String(match || "").trim(); let candidate = String(match || "").trim();
while (candidate.length > 0 && /[)\],.!?;:]+$/.test(candidate)) { while (candidate.length > 0) {
if (candidate.endsWith(")")) { const lastChar = candidate[candidate.length - 1];
if (![")", "]", ",", ".", "!", "?", ";", ":"].includes(lastChar)) {
break;
}
if (lastChar === ")") {
const openCount = (candidate.match(/\(/g) || []).length; const openCount = (candidate.match(/\(/g) || []).length;
const closeCount = (candidate.match(/\)/g) || []).length; const closeCount = (candidate.match(/\)/g) || []).length;
if (closeCount <= openCount) { if (closeCount <= openCount) {
break; break;
} }
} }
if (lastChar === "]") {
const openCount = (candidate.match(/\[/g) || []).length;
const closeCount = (candidate.match(/\]/g) || []).length;
if (closeCount <= openCount) {
break;
}
}
candidate = candidate.slice(0, -1); candidate = candidate.slice(0, -1);
} }
if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) { if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) {
@ -123,7 +135,7 @@ export function filenameFromUrl(url: string): string {
const rawName = queryName || path.basename(parsed.pathname || ""); const rawName = queryName || path.basename(parsed.pathname || "");
const decoded = safeDecodeURIComponent(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,3})\.html$/i, ".$1")
.replace(/\.(mp4|mkv|avi|mp3|flac|srt)\.html$/i, ".$1"); .replace(/\.(mp4|mkv|avi|mp3|flac|srt)\.html$/i, ".$1");
return sanitizeFilename(normalized || "download.bin"); return sanitizeFilename(normalized || "download.bin");
} catch { } catch {
@ -206,6 +218,9 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName:
} }
export function ensureDirPath(baseDir: string, packageName: string): string { export function ensureDirPath(baseDir: string, packageName: string): string {
if (!path.isAbsolute(baseDir)) {
throw new Error("baseDir muss ein absoluter Pfad sein");
}
return path.join(baseDir, sanitizeFilename(packageName)); return path.join(baseDir, sanitizeFilename(packageName));
} }

View File

@ -27,6 +27,13 @@ interface StartConflictPromptState {
applyToAll: boolean; applyToAll: boolean;
} }
interface ConfirmPromptState {
title: string;
message: string;
confirmLabel: string;
danger?: boolean;
}
const emptyStats = (): DownloadStats => ({ const emptyStats = (): DownloadStats => ({
totalDownloaded: 0, totalDownloaded: 0,
totalFiles: 0, totalFiles: 0,
@ -126,6 +133,7 @@ export function App(): ReactElement {
{ id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" } { id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" }
]); ]);
const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs);
const activeCollectorTabRef = useRef(activeCollectorTab); const activeCollectorTabRef = useRef(activeCollectorTab);
const activeTabRef = useRef<Tab>(tab); const activeTabRef = useRef<Tab>(tab);
const packageOrderRef = useRef<string[]>([]); const packageOrderRef = useRef<string[]>([]);
@ -136,10 +144,14 @@ export function App(): ReactElement {
const [showAllPackages, setShowAllPackages] = useState(false); const [showAllPackages, setShowAllPackages] = useState(false);
const [actionBusy, setActionBusy] = useState(false); const [actionBusy, setActionBusy] = useState(false);
const actionBusyRef = useRef(false); const actionBusyRef = useRef(false);
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const dragOverRef = useRef(false); const dragOverRef = useRef(false);
const dragDepthRef = useRef(0); const dragDepthRef = useRef(0);
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null); const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null); const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
@ -147,6 +159,10 @@ export function App(): ReactElement {
activeCollectorTabRef.current = activeCollectorTab; activeCollectorTabRef.current = activeCollectorTab;
}, [activeCollectorTab]); }, [activeCollectorTab]);
useEffect(() => {
collectorTabsRef.current = collectorTabs;
}, [collectorTabs]);
useEffect(() => { useEffect(() => {
activeTabRef.current = tab; activeTabRef.current = tab;
}, [tab]); }, [tab]);
@ -155,14 +171,14 @@ export function App(): ReactElement {
packageOrderRef.current = snapshot.session.packageOrder; packageOrderRef.current = snapshot.session.packageOrder;
}, [snapshot.session.packageOrder]); }, [snapshot.session.packageOrder]);
const showToast = (message: string, timeoutMs = 2200): void => { const showToast = useCallback((message: string, timeoutMs = 2200): void => {
setStatusToast(message); setStatusToast(message);
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
toastTimerRef.current = setTimeout(() => { toastTimerRef.current = setTimeout(() => {
setStatusToast(""); setStatusToast("");
toastTimerRef.current = null; toastTimerRef.current = null;
}, timeoutMs); }, timeoutMs);
}; }, []);
useEffect(() => { useEffect(() => {
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
@ -222,13 +238,20 @@ export function App(): ReactElement {
}); });
}); });
return () => { return () => {
mountedRef.current = false;
if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); } if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); }
if (startConflictResolverRef.current) { if (startConflictResolverRef.current) {
const resolver = startConflictResolverRef.current; const resolver = startConflictResolverRef.current;
startConflictResolverRef.current = null; startConflictResolverRef.current = null;
resolver(null); resolver(null);
} }
if (confirmResolverRef.current) {
const resolver = confirmResolverRef.current;
confirmResolverRef.current = null;
resolver(false);
}
if (unsubscribe) { unsubscribe(); } if (unsubscribe) { unsubscribe(); }
if (unsubClipboard) { unsubClipboard(); } if (unsubClipboard) { unsubClipboard(); }
}; };
@ -290,7 +313,7 @@ export function App(): ReactElement {
map.set(id, index); map.set(id, index);
}); });
return map; return map;
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder]); }, [downloadsTabActive, snapshot.session.packageOrder]);
const itemsByPackage = useMemo(() => { const itemsByPackage = useMemo(() => {
if (!downloadsTabActive) { if (!downloadsTabActive) {
@ -311,14 +334,19 @@ export function App(): ReactElement {
return; return;
} }
setCollapsedPackages((prev) => { setCollapsedPackages((prev) => {
const next: Record<string, boolean> = {}; const next: Record<string, boolean> = { ...prev };
const defaultCollapsed = totalPackageCount >= 24; const defaultCollapsed = totalPackageCount >= 24;
for (const packageId of packageIdsForView) { for (const packageId of snapshot.session.packageOrder) {
next[packageId] = prev[packageId] ?? defaultCollapsed; next[packageId] = prev[packageId] ?? defaultCollapsed;
} }
for (const packageId of Object.keys(next)) {
if (!snapshot.session.packages[packageId]) {
delete next[packageId];
}
}
return next; return next;
}); });
}, [downloadsTabActive, packageOrderKey, totalPackageCount, packageIdsForView]); }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
const hiddenPackageCount = shouldLimitPackageRendering const hiddenPackageCount = shouldLimitPackageRendering
? Math.max(0, totalPackageCount - packages.length) ? Math.max(0, totalPackageCount - packages.length)
@ -399,6 +427,9 @@ 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 (!mountedRef.current) {
return;
}
if (result.error) { if (result.error) {
if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); } if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); }
return; return;
@ -407,9 +438,16 @@ export function App(): ReactElement {
if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); } if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); }
return; return;
} }
const approved = window.confirm(`Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`); const approved = await askConfirmPrompt({
title: "Update verfügbar",
message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`,
confirmLabel: "Jetzt installieren"
});
if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; } if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; }
const install = await window.rd.installUpdate(); const install = await window.rd.installUpdate();
if (!mountedRef.current) {
return;
}
if (install.started) { showToast("Updater gestartet - App wird geschlossen", 2600); return; } if (install.started) { showToast("Updater gestartet - App wird geschlossen", 2600); return; }
showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200); showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200);
}; };
@ -460,6 +498,22 @@ export function App(): ReactElement {
}); });
}; };
const closeConfirmPrompt = (confirmed: boolean): void => {
const resolver = confirmResolverRef.current;
confirmResolverRef.current = null;
setConfirmPrompt(null);
if (resolver) {
resolver(confirmed);
}
};
const askConfirmPrompt = (prompt: ConfirmPromptState): Promise<boolean> => {
return new Promise((resolve) => {
confirmResolverRef.current = resolve;
setConfirmPrompt(prompt);
});
};
const onStartDownloads = async (): Promise<void> => { const onStartDownloads = async (): Promise<void> => {
await performQuickAction(async () => { await performQuickAction(async () => {
if (configuredProviders.length === 0) { if (configuredProviders.length === 0) {
@ -515,11 +569,14 @@ export function App(): ReactElement {
const onAddLinks = async (): Promise<void> => { const onAddLinks = async (): Promise<void> => {
await performQuickAction(async () => { await performQuickAction(async () => {
const activeId = activeCollectorTabRef.current;
const active = collectorTabsRef.current.find((t) => t.id === activeId) ?? collectorTabsRef.current[0];
const rawText = active?.text ?? "";
const persisted = await persistDraftSettings(); const persisted = await persistDraftSettings();
const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: persisted.packageName }); const result = await window.rd.addLinks({ rawText, packageName: persisted.packageName });
if (result.addedLinks > 0) { if (result.addedLinks > 0) {
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: "" } : t)); setCollectorTabs((prev) => prev.map((t) => t.id === activeId ? { ...t, text: "" } : t));
} else { } else {
showToast("Keine gültigen Links gefunden"); showToast("Keine gültigen Links gefunden");
} }
@ -572,7 +629,10 @@ export function App(): ReactElement {
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = "rd-queue-export.json"; a.download = "rd-queue-export.json";
a.style.display = "none";
document.body.appendChild(a);
a.click(); a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000); setTimeout(() => URL.revokeObjectURL(url), 60_000);
showToast("Queue exportiert"); showToast("Queue exportiert");
}, (error) => { }, (error) => {
@ -585,14 +645,30 @@ export function App(): ReactElement {
return; return;
} }
setActionBusy(true);
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = ".json"; input.accept = ".json";
const releasePickerBusy = (): void => {
setActionBusy(actionBusyRef.current);
};
const onWindowFocus = (): void => {
window.removeEventListener("focus", onWindowFocus);
if (!input.files || input.files.length === 0) {
releasePickerBusy();
}
};
input.onchange = async () => { input.onchange = async () => {
const file = input.files?.[0]; const file = input.files?.[0];
if (!file) { if (!file) {
releasePickerBusy();
return; return;
} }
releasePickerBusy();
await performQuickAction(async () => { await performQuickAction(async () => {
const text = await file.text(); const text = await file.text();
const result = await window.rd.importQueue(text); const result = await window.rd.importQueue(text);
@ -601,6 +677,8 @@ export function App(): ReactElement {
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
}); });
}; };
window.addEventListener("focus", onWindowFocus, { once: true });
input.click(); input.click();
}; };
@ -644,9 +722,17 @@ export function App(): ReactElement {
showToast(`Fehler: ${String(error)}`, 2600); showToast(`Fehler: ${String(error)}`, 2600);
} }
} finally { } finally {
setTimeout(() => { if (actionUnlockTimerRef.current) {
clearTimeout(actionUnlockTimerRef.current);
}
actionUnlockTimerRef.current = setTimeout(() => {
if (!mountedRef.current) {
actionUnlockTimerRef.current = null;
return;
}
actionBusyRef.current = false; actionBusyRef.current = false;
setActionBusy(false); setActionBusy(false);
actionUnlockTimerRef.current = null;
}, 80); }, 80);
} }
}; };
@ -659,6 +745,7 @@ export function App(): ReactElement {
const target = direction === "up" ? idx - 1 : idx + 1; const target = direction === "up" ? idx - 1 : idx + 1;
if (target < 0 || target >= order.length) { return; } if (target < 0 || target >= order.length) { return; }
[order[idx], order[target]] = [order[target], order[idx]]; [order[idx], order[target]] = [order[target], order[idx]];
setDownloadsSortDescending(false);
packageOrderRef.current = order; packageOrderRef.current = order;
void window.rd.reorderPackages(order); void window.rd.reorderPackages(order);
}, []); }, []);
@ -671,18 +758,22 @@ export function App(): ReactElement {
if (unchanged) { if (unchanged) {
return; return;
} }
setDownloadsSortDescending(false);
packageOrderRef.current = nextOrder; packageOrderRef.current = nextOrder;
void window.rd.reorderPackages(nextOrder); void window.rd.reorderPackages(nextOrder);
}, []); }, []);
const addCollectorTab = (): void => { const addCollectorTab = (): void => {
const id = `tab-${nextCollectorId++}`; const id = `tab-${nextCollectorId++}`;
const name = `Tab ${collectorTabs.length + 1}`; setCollectorTabs((prev) => {
setCollectorTabs((prev) => [...prev, { id, name, text: "" }]); const name = `Tab ${prev.length + 1}`;
return [...prev, { id, name, text: "" }];
});
setActiveCollectorTab(id); setActiveCollectorTab(id);
}; };
const removeCollectorTab = (id: string): void => { const removeCollectorTab = (id: string): void => {
let fallbackId = "";
setCollectorTabs((prev) => { setCollectorTabs((prev) => {
if (prev.length <= 1) { if (prev.length <= 1) {
return prev; return prev;
@ -693,11 +784,13 @@ export function App(): ReactElement {
} }
const next = prev.filter((tabEntry) => tabEntry.id !== id); const next = prev.filter((tabEntry) => tabEntry.id !== id);
if (activeCollectorTabRef.current === id) { if (activeCollectorTabRef.current === id) {
const fallback = next[Math.max(0, index - 1)]?.id ?? next[0]?.id ?? ""; fallbackId = next[Math.max(0, index - 1)]?.id ?? next[0]?.id ?? "";
setActiveCollectorTab(fallback);
} }
return next; return next;
}); });
if (fallbackId) {
setActiveCollectorTab(fallbackId);
}
}; };
const onPackageDragStart = useCallback((packageId: string) => { const onPackageDragStart = useCallback((packageId: string) => {
@ -812,11 +905,18 @@ export function App(): ReactElement {
className="btn" className="btn"
disabled={actionBusy} disabled={actionBusy}
onClick={() => { onClick={() => {
const confirmed = window.confirm("Wirklich alle Einträge aus der Queue löschen?"); void performQuickAction(async () => {
const confirmed = await askConfirmPrompt({
title: "Queue löschen",
message: "Wirklich alle Einträge aus der Queue löschen?",
confirmLabel: "Alles löschen",
danger: true
});
if (!confirmed) { if (!confirmed) {
return; return;
} }
void performQuickAction(() => window.rd.clearAll()); await window.rd.clearAll();
});
}} }}
> >
Alles leeren Alles leeren
@ -893,7 +993,7 @@ export function App(): ReactElement {
</button> </button>
<button <button
className={`btn${downloadsSortDescending ? " btn-active" : ""}`} className={`btn${downloadsSortDescending ? " btn-active" : ""}`}
disabled={packages.length < 2} disabled={totalPackageCount < 2}
onClick={() => { onClick={() => {
const nextDescending = !downloadsSortDescending; const nextDescending = !downloadsSortDescending;
setDownloadsSortDescending(nextDescending); setDownloadsSortDescending(nextDescending);
@ -939,7 +1039,13 @@ export function App(): ReactElement {
editingName={editingName} editingName={editingName}
collapsed={collapsedPackages[pkg.id] ?? false} collapsed={collapsedPackages[pkg.id] ?? false}
onStartEdit={() => { setEditingPackageId(pkg.id); setEditingName(pkg.name); }} onStartEdit={() => { setEditingPackageId(pkg.id); setEditingName(pkg.name); }}
onFinishEdit={(name) => { setEditingPackageId(null); if (name.trim()) { void window.rd.renamePackage(pkg.id, name); } }} onFinishEdit={(name) => {
setEditingPackageId(null);
const nextName = name.trim();
if (nextName && nextName !== pkg.name.trim()) {
void window.rd.renamePackage(pkg.id, nextName);
}
}}
onEditChange={setEditingName} onEditChange={setEditingName}
onToggleCollapse={() => { onToggleCollapse={() => {
setCollapsedPackages((prev) => ({ ...prev, [pkg.id]: !(prev[pkg.id] ?? false) })); setCollapsedPackages((prev) => ({ ...prev, [pkg.id]: !(prev[pkg.id] ?? false) }));
@ -1132,6 +1238,24 @@ export function App(): ReactElement {
)} )}
</main> </main>
{confirmPrompt && (
<div className="modal-backdrop" onClick={() => closeConfirmPrompt(false)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<h3>{confirmPrompt.title}</h3>
<p>{confirmPrompt.message}</p>
<div className="modal-actions">
<button className="btn" onClick={() => closeConfirmPrompt(false)}>Abbrechen</button>
<button
className={confirmPrompt.danger ? "btn danger" : "btn"}
onClick={() => closeConfirmPrompt(true)}
>
{confirmPrompt.confirmLabel}
</button>
</div>
</div>
</div>
)}
{startConflictPrompt && ( {startConflictPrompt && (
<div className="modal-backdrop" onClick={() => closeStartConflictPrompt(null)}> <div className="modal-backdrop" onClick={() => closeStartConflictPrompt(null)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}> <div className="modal-card" onClick={(event) => event.stopPropagation()}>
@ -1289,21 +1413,15 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) { if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) {
return false; return false;
} }
if (prev.items.length !== next.items.length) { if (prev.pkg.itemIds.length !== next.pkg.itemIds.length) {
return false; return false;
} }
if (prev.onCancel !== next.onCancel for (let index = 0; index < prev.pkg.itemIds.length; index += 1) {
|| prev.onMoveUp !== next.onMoveUp if (prev.pkg.itemIds[index] !== next.pkg.itemIds[index]) {
|| prev.onMoveDown !== next.onMoveDown return false;
|| prev.onToggle !== next.onToggle }
|| prev.onRemoveItem !== next.onRemoveItem }
|| prev.onStartEdit !== next.onStartEdit if (prev.items.length !== next.items.length) {
|| prev.onFinishEdit !== next.onFinishEdit
|| prev.onEditChange !== next.onEditChange
|| prev.onToggleCollapse !== next.onToggleCollapse
|| prev.onDragStart !== next.onDragStart
|| prev.onDrop !== next.onDrop
|| prev.onDragEnd !== next.onDragEnd) {
return false; return false;
} }
for (let index = 0; index < prev.items.length; index += 1) { for (let index = 0; index < prev.items.length; index += 1) {