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",
"version": "1.4.27",
"version": "1.4.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "real-debrid-downloader",
"version": "1.4.27",
"version": "1.4.28",
"license": "MIT",
"dependencies": {
"adm-zip": "^0.5.16",

View File

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

View File

@ -12,7 +12,7 @@ import {
UpdateInstallResult
} from "../shared/types";
import { importDlcContainers } from "./container";
import { APP_VERSION, defaultSettings } from "./constants";
import { APP_VERSION } from "./constants";
import { DownloadManager } from "./download-manager";
import { parseCollectorInput } from "./link-parser";
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 { 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 {
private settings: AppSettings;
@ -33,6 +42,8 @@ export class AppController {
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
public constructor() {
configureLogger(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths);
@ -45,7 +56,7 @@ export class AppController {
megaWebUnrestrict: (link: string) => this.megaWebFallback.unrestrict(link)
});
this.manager.on("state", (snapshot: UiSnapshot) => {
this.onState?.(snapshot);
this.onStateHandler?.(snapshot);
});
logger.info(`App gestartet v${APP_VERSION}`);
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 {
return this.manager.getSnapshot();
@ -87,13 +107,13 @@ export class AppController {
}
public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial);
const nextSettings = normalizeSettings({
...defaultSettings(),
...this.settings,
...partial
...sanitizedPatch
});
if (JSON.stringify(nextSettings) === JSON.stringify(this.settings)) {
if (settingsFingerprint(nextSettings) === settingsFingerprint(this.settings)) {
return this.settings;
}

View File

@ -9,15 +9,15 @@ async function yieldToLoop(): Promise<void> {
}
export function isArchiveOrTempFile(filePath: string): boolean {
const lower = filePath.toLowerCase();
const ext = path.extname(lower);
const lowerName = path.basename(filePath).toLowerCase();
const ext = path.extname(lowerName);
if (ARCHIVE_TEMP_EXTENSIONS.has(ext)) {
return true;
}
if (lower.includes(".part") && lower.endsWith(".rar")) {
if (lowerName.includes(".part") && lowerName.endsWith(".rar")) {
return true;
}
return RAR_SPLIT_RE.test(lower);
return RAR_SPLIT_RE.test(lowerName);
}
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 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_LINK_ARTIFACT_BYTES = 256 * 1024;

View File

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

View File

@ -5,6 +5,7 @@ import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
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 ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4";
@ -35,8 +36,7 @@ type BestDebridRequest = {
function canonicalLink(link: string): string {
try {
const parsed = new URL(link);
const query = parsed.searchParams.toString();
return `${parsed.hostname}${parsed.pathname}${query ? `?${query}` : ""}`.toLowerCase();
return `${parsed.host.toLowerCase()}${parsed.pathname}${parsed.search}`;
} catch {
return link.trim().toLowerCase();
}
@ -71,6 +71,34 @@ function isRetryableErrorText(text: string): boolean {
|| 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 {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
@ -105,7 +133,7 @@ function pickNumber(payload: Record<string, unknown> | null, keys: string[]): nu
}
for (const key of keys) {
const value = Number(payload[key] ?? NaN);
if (Number.isFinite(value) && value > 0) {
if (Number.isFinite(value) && value >= 0) {
return Math.floor(value);
}
}
@ -124,6 +152,15 @@ function parseError(status: number, responseText: string, payload: Record<string
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[] {
const seen = new Set<DebridProvider>();
const result: DebridProvider[] = [];
@ -219,8 +256,7 @@ export function extractRapidgatorFilenameFromHtml(html: string): string {
/<title>([^<]+)<\/title>/i,
/(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]+)\s*</i,
/(?:Dateiname|File\s*name)\s*[:\-]\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
/download\s+file\s+([^<\r\n]+)/i
];
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));
let index = 0;
let firstError: unknown = null;
const next = (): T | undefined => {
if (index >= items.length) {
return undefined;
@ -254,14 +291,30 @@ async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (i
const runners = Array.from({ length: size }, async () => {
let current = next();
while (current !== undefined) {
await worker(current);
try {
await worker(current);
} catch (error) {
if (!firstError) {
firstError = error;
}
}
current = next();
}
});
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)) {
return "";
}
@ -270,6 +323,10 @@ async function resolveRapidgatorFilename(link: string): Promise<string> {
return fromUrl;
}
if (signal?.aborted) {
throw new Error("aborted:debrid");
}
for (let attempt = 1; attempt <= REQUEST_RETRIES + 2; attempt += 1) {
try {
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-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 (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
await sleep(retryDelay(attempt));
await sleepWithSignal(retryDelay(attempt), signal);
continue;
}
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 fromHtml = extractRapidgatorFilenameFromHtml(html);
if (fromHtml) {
return fromHtml;
}
} catch {
// retry below
return "";
} 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) {
await sleep(retryDelay(attempt));
await sleepWithSignal(retryDelay(attempt), signal);
}
}
@ -338,6 +413,9 @@ class MegaDebridClient {
web.retriesUsed = attempt - 1;
return web;
}
if (web && !web.directUrl) {
throw new Error("Mega-Web Antwort ohne Download-Link");
}
if (!lastError) {
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) {
try {
const headers: Record<string, string> = {
"User-Agent": "RD-Node-Downloader/1.1.12"
"User-Agent": DEBRID_USER_AGENT
};
if (request.useAuthHeader) {
headers.Authorization = `Bearer ${this.token}`;
@ -402,6 +480,14 @@ class BestDebridClient {
const directUrl = pickString(payload, ["download", "debridLink", "link"]);
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 fileSize = pickNumber(payload, ["filesize", "size", "bytes"]);
return {
@ -426,7 +512,7 @@ class BestDebridClient {
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: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "RD-Node-Downloader/1.1.15"
"User-Agent": DEBRID_USER_AGENT
},
body,
signal: AbortSignal.timeout(API_TIMEOUT_MS)
@ -501,8 +587,7 @@ class AllDebridClient {
const status = pickString(payload, ["status"]);
if (status && status.toLowerCase() === "error") {
const errorObj = asRecord(payload?.error);
throw new Error(pickString(errorObj, ["message", "code"]) || "AllDebrid API error");
throw new Error(parseAllDebridError(payload));
}
chunkResolved = true;
@ -534,7 +619,9 @@ class AllDebridClient {
const responseLink = pickString(info, ["link"]);
const byResponse = canonicalToInput.get(canonicalLink(responseLink));
const byIndex = chunk.length === 1 ? chunk[0] : "";
const byIndex = chunk.length === 1
? chunk[0]
: "";
const original = byResponse || byIndex;
if (!original) {
continue;
@ -555,7 +642,7 @@ class AllDebridClient {
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "RD-Node-Downloader/1.1.12"
"User-Agent": DEBRID_USER_AGENT
},
body: new URLSearchParams({ link }),
signal: AbortSignal.timeout(API_TIMEOUT_MS)
@ -577,11 +664,13 @@ class AllDebridClient {
if (looksHtml) {
throw new Error("AllDebrid lieferte HTML statt JSON");
}
if (!payload) {
throw new Error("AllDebrid Antwort ist kein JSON-Objekt");
}
const status = pickString(payload, ["status"]);
if (status && status.toLowerCase() === "error") {
const errorObj = asRecord(payload?.error);
throw new Error(pickString(errorObj, ["message", "code"]) || "AllDebrid API error");
throw new Error(parseAllDebridError(payload));
}
const data = asRecord(payload?.data);
@ -612,29 +701,24 @@ class AllDebridClient {
export class DebridService {
private settings: AppSettings;
private realDebridClient: RealDebridClient;
private allDebridClient: AllDebridClient;
private options: DebridServiceOptions;
public constructor(settings: AppSettings, options: DebridServiceOptions = {}) {
this.settings = settings;
this.options = options;
this.realDebridClient = new RealDebridClient(settings.token);
this.allDebridClient = new AllDebridClient(settings.allDebridToken);
}
public setSettings(next: AppSettings): void {
this.settings = next;
this.realDebridClient = new RealDebridClient(next.token);
this.allDebridClient = new AllDebridClient(next.allDebridToken);
}
public async resolveFilenames(
links: string[],
onResolved?: (link: string, fileName: string) => void
onResolved?: (link: string, fileName: string) => void,
signal?: AbortSignal
): Promise<Map<string, string>> {
const settings = { ...this.settings };
const allDebridClient = new AllDebridClient(settings.allDebridToken);
const unresolved = links.filter((link) => looksLikeOpaqueFilename(filenameFromUrl(link)));
if (unresolved.length === 0) {
return new Map<string, string>();
@ -653,10 +737,10 @@ export class DebridService {
onResolved?.(link, normalized);
};
const token = this.settings.allDebridToken.trim();
const token = settings.allDebridToken.trim();
if (token) {
try {
const infos = await this.allDebridClient.getLinkInfos(unresolved);
const infos = await allDebridClient.getLinkInfos(unresolved);
for (const [link, fileName] of infos.entries()) {
reportResolved(link, fileName);
}
@ -667,14 +751,14 @@ export class DebridService {
const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link));
await runWithConcurrency(remaining, 6, async (link) => {
const fromPage = await resolveRapidgatorFilename(link);
const fromPage = await resolveRapidgatorFilename(link, signal);
reportResolved(link, fromPage);
});
const stillUnresolved = unresolved.filter((link) => !clean.has(link) && !isRapidgatorLink(link));
await runWithConcurrency(stillUnresolved, 4, async (link) => {
try {
const unrestricted = await this.unrestrictLink(link);
const unrestricted = await this.unrestrictLink(link, signal, settings);
reportResolved(link, unrestricted.fileName || "");
} catch {
// ignore final fallback errors
@ -684,23 +768,24 @@ export class DebridService {
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(
this.settings.providerPrimary,
this.settings.providerSecondary,
this.settings.providerTertiary
settings.providerPrimary,
settings.providerSecondary,
settings.providerTertiary
);
const primary = order[0];
if (!this.settings.autoProviderFallback) {
if (!this.isProviderConfigured(primary)) {
if (!settings.autoProviderFallback) {
if (!this.isProviderConfiguredFor(settings, primary)) {
throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`);
}
try {
const result = await this.unrestrictViaProvider(primary, link);
const result = await this.unrestrictViaProvider(settings, primary, link, signal);
let fileName = result.fileName;
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
const fromPage = await resolveRapidgatorFilename(link);
const fromPage = await resolveRapidgatorFilename(link, signal);
if (fromPage) {
fileName = fromPage;
}
@ -720,16 +805,16 @@ export class DebridService {
const attempts: string[] = [];
for (const provider of order) {
if (!this.isProviderConfigured(provider)) {
if (!this.isProviderConfiguredFor(settings, provider)) {
continue;
}
configuredFound = true;
try {
const result = await this.unrestrictViaProvider(provider, link);
const result = await this.unrestrictViaProvider(settings, provider, link, signal);
let fileName = result.fileName;
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
const fromPage = await resolveRapidgatorFilename(link);
const fromPage = await resolveRapidgatorFilename(link, signal);
if (fromPage) {
fileName = fromPage;
}
@ -752,29 +837,29 @@ export class DebridService {
throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`);
}
private isProviderConfigured(provider: DebridProvider): boolean {
private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean {
if (provider === "realdebrid") {
return Boolean(this.settings.token.trim());
return Boolean(settings.token.trim());
}
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") {
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") {
return this.realDebridClient.unrestrictLink(link);
return new RealDebridClient(settings.token).unrestrictLink(link, signal);
}
if (provider === "megadebrid") {
return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link);
}
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 {
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 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 {
if (!contentRange) {
return null;
@ -123,7 +130,7 @@ function parseContentDispositionFilename(contentDisposition: string | null): str
const encodedMatch = contentDisposition.match(/filename\*\s*=\s*([^;]+)/i);
if (encodedMatch?.[1]) {
let value = encodedMatch[1].trim();
value = value.replace(/^UTF-8''/i, "");
value = value.replace(/^[A-Za-z0-9._-]+(?:'[^']*)?'/, "");
value = value.replace(/^['"]+|['"]+$/g, "");
try {
const decoded = decodeURIComponent(value).trim();
@ -144,13 +151,9 @@ function parseContentDispositionFilename(contentDisposition: string | null): str
return plainMatch[1].trim().replace(/^['"]+|['"]+$/g, "");
}
function canRetryStatus(status: number): boolean {
return status === 429 || status >= 500;
}
function isArchiveLikePath(filePath: string): boolean {
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 {
@ -259,7 +262,7 @@ export function ensureRepackToken(baseName: string): string {
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) {
return withQualityToken;
}
@ -357,6 +360,8 @@ export class DownloadManager extends EventEmitter {
private lastGlobalProgressAt = 0;
private retryAfterByItem = new Map<string, number>();
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) {
super();
this.settings = settings;
@ -428,10 +433,16 @@ export class DownloadManager extends EventEmitter {
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 {
settings: this.settings,
session: this.session,
summary: this.summary,
settings: snapshotSettings,
session: snapshotSession,
summary: snapshotSummary,
stats: this.getStats(now),
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
etaText: paused ? "ETA: --" : `ETA: ${formatEta(eta)}`,
@ -494,7 +505,14 @@ export class DownloadManager extends EventEmitter {
}
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));
this.session.packageOrder = [...valid, ...remaining];
this.persistSoon();
@ -508,6 +526,7 @@ export class DownloadManager extends EventEmitter {
}
this.recordRunOutcome(itemId, "cancelled");
const active = this.activeTasks.get(itemId);
const hasActiveTask = Boolean(active);
if (active) {
active.abortReason = "cancel";
active.abortController.abort("cancel");
@ -523,7 +542,10 @@ export class DownloadManager extends EventEmitter {
}
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
this.releaseTargetPath(itemId);
this.retryAfterByItem.delete(itemId);
if (!hasActiveTask) {
this.releaseTargetPath(itemId);
}
this.persistSoon();
this.emitState(true);
}
@ -631,12 +653,21 @@ export class DownloadManager extends EventEmitter {
return { addedPackages: 0, addedLinks: 0 };
}
const inputs: ParsedPackageInput[] = data.packages
.filter((pkg) => pkg.name && Array.isArray(pkg.links) && pkg.links.length > 0)
.map((pkg) => ({ name: pkg.name, links: pkg.links }));
.map((pkg) => {
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);
}
public clearAll(): void {
this.clearPersistTimer();
this.stop();
this.abortPostProcessing("clear_all");
if (this.stateEmitTimer) {
@ -652,6 +683,7 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear();
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear();
@ -663,6 +695,8 @@ export class DownloadManager extends EventEmitter {
this.hybridExtractRequeue.clear();
this.packagePostProcessQueue = Promise.resolve();
this.summary = null;
this.nonResumableActive = 0;
this.retryAfterByItem.clear();
this.persistNow();
this.emitState(true);
}
@ -804,9 +838,16 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.delete(itemId);
this.runOutcomes.delete(itemId);
this.itemContributedBytes.delete(itemId);
this.retryAfterByItem.delete(itemId);
delete this.session.items[itemId];
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];
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
this.runPackageIds.delete(packageId);
@ -818,6 +859,12 @@ export class DownloadManager extends EventEmitter {
}
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);
if (canDeleteExtractDir) {
try {
@ -857,10 +904,12 @@ export class DownloadManager extends EventEmitter {
item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url)));
this.runOutcomes.delete(itemId);
this.itemContributedBytes.delete(itemId);
this.retryAfterByItem.delete(itemId);
if (this.session.running) {
this.runItemIds.add(itemId);
}
}
this.runCompletedPackages.delete(packageId);
pkg.status = "queued";
pkg.updatedAt = nowMs();
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.persistSoon();
this.emitState(true);
@ -1297,6 +1351,7 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear();
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear();
this.session.running = false;
@ -1312,6 +1367,7 @@ export class DownloadManager extends EventEmitter {
this.lastGlobalProgressBytes = 0;
this.lastGlobalProgressAt = nowMs();
this.summary = null;
this.nonResumableActive = 0;
this.persistSoon();
this.emitState(true);
return;
@ -1320,6 +1376,7 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.session.running = true;
this.session.paused = false;
@ -1340,6 +1397,7 @@ export class DownloadManager extends EventEmitter {
this.globalSpeedLimitQueue = Promise.resolve();
this.globalSpeedLimitNextAt = 0;
this.summary = null;
this.nonResumableActive = 0;
this.persistSoon();
this.emitState(true);
void this.ensureScheduler().catch((error) => {
@ -1356,6 +1414,7 @@ export class DownloadManager extends EventEmitter {
this.session.paused = false;
this.session.reconnectUntil = 0;
this.session.reconnectReason = "";
this.retryAfterByItem.clear();
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs();
this.abortPostProcessing("stop");
@ -1369,6 +1428,7 @@ export class DownloadManager extends EventEmitter {
public prepareForShutdown(): void {
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.paused = false;
this.session.reconnectUntil = 0;
@ -1423,6 +1483,8 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear();
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.nonResumableActive = 0;
this.session.summaryText = "";
this.persistNow();
this.emitState(true);
@ -1506,8 +1568,8 @@ export class DownloadManager extends EventEmitter {
if (failed > 0) {
pkg.status = "failed";
} else if (cancelled > 0 && success === 0) {
pkg.status = "cancelled";
} else if (cancelled > 0) {
pkg.status = success > 0 ? "failed" : "cancelled";
} else if (success > 0) {
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 {
if (this.persistTimer) {
return;
@ -1658,10 +1728,6 @@ export class DownloadManager extends EventEmitter {
const parsed = path.parse(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;
for (let index = 0; index <= maxIndex; index += 1) {
const candidate = index === 0
@ -1669,7 +1735,7 @@ export class DownloadManager extends EventEmitter {
: path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`);
const key = index === 0
? preferredKey
: `${baseDirKey}${sep}${baseNameKey} (${index})${baseExtKey}`;
: pathKey(candidate);
const owner = this.reservedTargetPaths.get(key);
const existsOnDisk = fs.existsSync(candidate);
const allowExistingCandidate = allowExistingFile && index === 0;
@ -1809,7 +1875,11 @@ export class DownloadManager extends EventEmitter {
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) {
pkg.status = targetStatus;
pkg.updatedAt = nowMs();
@ -1865,7 +1935,14 @@ export class DownloadManager extends EventEmitter {
}
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) {
this.retryAfterByItem.delete(itemId);
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
}
@ -1918,7 +1995,7 @@ export class DownloadManager extends EventEmitter {
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();
break;
}
@ -2031,7 +2108,8 @@ export class DownloadManager extends EventEmitter {
private markQueuedAsReconnectWait(): boolean {
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);
for (const itemId of itemIds) {
const item = this.session.items[itemId];
@ -2056,6 +2134,7 @@ export class DownloadManager extends EventEmitter {
}
private findNextQueuedItem(): { packageId: string; itemId: string } | null {
const now = nowMs();
for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) {
@ -2066,6 +2145,13 @@ export class DownloadManager extends EventEmitter {
if (!item) {
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") {
return { packageId, itemId };
}
@ -2078,6 +2164,28 @@ export class DownloadManager extends EventEmitter {
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 {
let count = 0;
for (const packageId of this.session.packageOrder) {
@ -2098,6 +2206,18 @@ export class DownloadManager extends EventEmitter {
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 {
const item = this.session.items[itemId];
const pkg = this.session.packages[packageId];
@ -2108,6 +2228,8 @@ export class DownloadManager extends EventEmitter {
return;
}
this.retryAfterByItem.delete(itemId);
item.status = "validating";
item.fullStatus = "Link wird umgewandelt";
item.updatedAt = nowMs();
@ -2154,7 +2276,7 @@ export class DownloadManager extends EventEmitter {
const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES);
while (true) {
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) {
throw new Error(`aborted:${active.abortReason}`);
}
@ -2191,6 +2313,10 @@ export class DownloadManager extends EventEmitter {
this.nonResumableActive += 1;
}
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
if (this.settings.enableIntegrityCheck) {
item.status = "integrity_check";
item.fullStatus = "CRC-Check läuft";
@ -2198,6 +2324,9 @@ export class DownloadManager extends EventEmitter {
this.emitState();
const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir);
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
if (!validation.ok) {
item.lastError = validation.message;
item.fullStatus = `${validation.message}, Neuversuch`;
@ -2207,7 +2336,7 @@ export class DownloadManager extends EventEmitter {
// ignore
}
if (item.attempts < maxAttempts) {
item.status = "queued";
item.status = "integrity_check";
item.progressPercent = 0;
item.downloadedBytes = 0;
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 fileSizeOnDisk = finalTargetPath && fs.existsSync(finalTargetPath)
? fs.statSync(finalTargetPath).size
@ -2241,6 +2374,11 @@ export class DownloadManager extends EventEmitter {
done = true;
}
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
item.status = "completed";
item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`;
item.progressPercent = 100;
@ -2249,13 +2387,15 @@ export class DownloadManager extends EventEmitter {
pkg.updatedAt = nowMs();
this.recordRunOutcome(item.id, "completed");
void this.runPackagePostProcessing(pkg.id).catch((err) => {
logger.warn(`runPackagePostProcessing Fehler (processItem): ${compactErrorText(err)}`);
}).finally(() => {
this.applyCompletedCleanupPolicy(pkg.id, item.id);
this.persistSoon();
this.emitState();
});
if (this.session.running && !active.abortController.signal.aborted) {
void this.runPackagePostProcessing(pkg.id).catch((err) => {
logger.warn(`runPackagePostProcessing Fehler (processItem): ${compactErrorText(err)}`);
}).finally(() => {
this.applyCompletedCleanupPolicy(pkg.id, item.id);
this.persistSoon();
this.emitState();
});
}
this.persistSoon();
this.emitState();
return;
@ -2280,16 +2420,11 @@ export class DownloadManager extends EventEmitter {
item.status = "cancelled";
item.fullStatus = "Gestoppt";
this.recordRunOutcome(item.id, "cancelled");
if (claimedTargetPath) {
try {
fs.rmSync(claimedTargetPath, { force: true });
} catch {
// ignore
}
if (!active.resumable && claimedTargetPath && !fs.existsSync(claimedTargetPath)) {
item.downloadedBytes = 0;
item.progressPercent = 0;
item.totalBytes = null;
}
item.downloadedBytes = 0;
item.progressPercent = 0;
item.totalBytes = null;
} else if (reason === "shutdown") {
item.status = "queued";
item.speedBps = 0;
@ -2297,6 +2432,7 @@ export class DownloadManager extends EventEmitter {
item.fullStatus = activePkg && !activePkg.enabled ? "Paket gestoppt" : "Wartet";
} else if (reason === "reconnect") {
item.status = "queued";
item.speedBps = 0;
item.fullStatus = "Wartet auf Reconnect";
} else if (reason === "package_toggle") {
item.status = "queued";
@ -2306,18 +2442,11 @@ export class DownloadManager extends EventEmitter {
stallRetries += 1;
if (stallRetries <= 2) {
item.retries += 1;
item.status = "queued";
item.speedBps = 0;
item.fullStatus = `Keine Daten empfangen, Retry ${stallRetries}/2`;
this.queueRetry(item, active, 350 * stallRetries, `Keine Daten empfangen, Retry ${stallRetries}/2`);
item.lastError = "";
item.attempts = 0;
item.updatedAt = nowMs();
active.abortController = new AbortController();
active.abortReason = "none";
this.persistSoon();
this.emitState();
await sleep(350 * stallRetries);
continue;
return;
}
item.status = "failed";
item.lastError = "Download hing wiederholt";
@ -2337,6 +2466,15 @@ export class DownloadManager extends EventEmitter {
item.downloadedBytes = 0;
item.totalBytes = null;
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) {
freshRetryUsed = true;
@ -2347,53 +2485,34 @@ export class DownloadManager extends EventEmitter {
// ignore
}
this.releaseTargetPath(item.id);
item.status = "queued";
item.fullStatus = "Netzwerkfehler erkannt, frischer Retry";
this.queueRetry(item, active, 450, "Netzwerkfehler erkannt, frischer Retry");
item.lastError = "";
item.attempts = 0;
item.downloadedBytes = 0;
item.totalBytes = null;
item.progressPercent = 0;
item.speedBps = 0;
item.updatedAt = nowMs();
this.persistSoon();
this.emitState();
await sleep(450);
continue;
return;
}
if (isUnrestrictFailure(errorText) && unrestrictRetries < maxUnrestrictRetries) {
unrestrictRetries += 1;
item.retries += 1;
item.status = "queued";
item.fullStatus = `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`;
this.queueRetry(item, active, Math.min(8000, 2000 * unrestrictRetries), `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`);
item.lastError = errorText;
item.attempts = 0;
item.speedBps = 0;
item.updatedAt = nowMs();
active.abortController = new AbortController();
active.abortReason = "none";
this.persistSoon();
this.emitState();
await sleep(Math.min(8000, 2000 * unrestrictRetries));
continue;
return;
}
if (genericErrorRetries < maxGenericErrorRetries) {
genericErrorRetries += 1;
item.retries += 1;
item.status = "queued";
item.fullStatus = `Fehler erkannt, Auto-Retry ${genericErrorRetries}/${maxGenericErrorRetries}`;
this.queueRetry(item, active, Math.min(1200, 300 * genericErrorRetries), `Fehler erkannt, Auto-Retry ${genericErrorRetries}/${maxGenericErrorRetries}`);
item.lastError = errorText;
item.attempts = 0;
item.speedBps = 0;
item.updatedAt = nowMs();
active.abortController = new AbortController();
active.abortReason = "none";
this.persistSoon();
this.emitState();
await sleep(Math.min(1200, 300 * genericErrorRetries));
continue;
return;
}
item.status = "failed";
@ -2482,6 +2601,7 @@ export class DownloadManager extends EventEmitter {
if (!response.ok) {
if (response.status === 416 && existingBytes > 0) {
await response.arrayBuffer().catch(() => undefined);
const rangeTotal = parseContentRangeTotal(response.headers.get("content-range"));
const expectedTotal = knownTotal && knownTotal > 0 ? knownTotal : rangeTotal;
if (expectedTotal && existingBytes === expectedTotal) {
@ -2654,6 +2774,7 @@ export class DownloadManager extends EventEmitter {
active.abortController.signal.addEventListener("abort", onAbort, { once: true });
});
let bodyError: unknown = null;
try {
const body = response.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);
await this.applySpeedLimit(buffer.length, windowBytes, windowStarted);
await this.applySpeedLimit(buffer.length, windowBytes, windowStarted, active.abortController.signal);
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
@ -2778,29 +2899,44 @@ export class DownloadManager extends EventEmitter {
}
} finally {
clearInterval(idleTimer);
}
} finally {
await new Promise<void>((resolve, reject) => {
if (stream.closed || stream.destroyed) {
resolve();
return;
try {
reader.releaseLock();
} catch {
// ignore
}
const onDone = (): void => {
stream.off("error", onError);
stream.off("finish", onDone);
stream.off("close", onDone);
resolve();
};
const onError = (streamError: Error): void => {
stream.off("finish", onDone);
stream.off("close", onDone);
reject(streamError);
};
stream.once("finish", onDone);
stream.once("close", onDone);
stream.once("error", onError);
stream.end();
});
}
} catch (error) {
bodyError = error;
throw error;
} finally {
try {
await new Promise<void>((resolve, reject) => {
if (stream.closed || stream.destroyed) {
resolve();
return;
}
const onDone = (): void => {
stream.off("error", onError);
stream.off("finish", onDone);
stream.off("close", onDone);
resolve();
};
const onError = (streamError: Error): void => {
stream.off("finish", onDone);
stream.off("close", onDone);
reject(streamError);
};
stream.once("finish", onDone);
stream.once("close", onDone);
stream.once("error", onError);
stream.end();
});
} catch (streamCloseError) {
if (!bodyError) {
throw streamCloseError;
}
logger.warn(`Stream-Abschlussfehler unterdrückt: ${compactErrorText(streamCloseError)}`);
}
}
item.downloadedBytes = written;
@ -2970,8 +3106,8 @@ export class DownloadManager extends EventEmitter {
if (failed > 0) {
pkg.status = "failed";
} else if (cancelled > 0 && success === 0) {
pkg.status = "cancelled";
} else if (cancelled > 0) {
pkg.status = success > 0 ? "failed" : "cancelled";
} else if (success > 0) {
pkg.status = "completed";
}
@ -2999,6 +3135,10 @@ export class DownloadManager extends EventEmitter {
if (!entry.enabled) {
continue;
}
if (entry.startHour === entry.endHour) {
this.cachedSpeedLimitKbps = entry.speedLimitKbps;
return this.cachedSpeedLimitKbps;
}
const wraps = entry.startHour > entry.endHour;
const inRange = wraps
? hour >= entry.startHour || hour < entry.endHour
@ -3017,14 +3157,46 @@ export class DownloadManager extends EventEmitter {
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
.catch(() => undefined)
.then(async () => {
if (signal?.aborted) {
throw new Error("aborted:speed_limit");
}
const now = nowMs();
const waitMs = Math.max(0, this.globalSpeedLimitNextAt - now);
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);
@ -3036,7 +3208,7 @@ export class DownloadManager extends EventEmitter {
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();
if (limitKbps <= 0) {
return;
@ -3050,13 +3222,38 @@ export class DownloadManager extends EventEmitter {
if (projected > allowed) {
const sleepMs = Math.ceil(((projected - allowed) / bytesPerSecond) * 1000);
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;
}
await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond);
await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond, signal);
}
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)) {
const stem = entryPointName.replace(/\.rar$/i, "").toLowerCase();
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)) {
const stem = entryPointName.replace(/\.zip\.001$/i, "").toLowerCase();
@ -3323,6 +3520,9 @@ export class DownloadManager extends EventEmitter {
}
}
const extractDeadline = setTimeout(() => {
if (signal?.aborted || extractAbortController.signal.aborted) {
return;
}
timedOut = true;
logger.error(`Post-Processing Extraction Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s: pkg=${pkg.name}`);
if (!extractAbortController.signal.aborted) {
@ -3432,8 +3632,8 @@ export class DownloadManager extends EventEmitter {
}
} else if (failed > 0) {
pkg.status = "failed";
} else if (cancelled > 0 && success === 0) {
pkg.status = "cancelled";
} else if (cancelled > 0) {
pkg.status = success > 0 ? "failed" : "cancelled";
} else {
pkg.status = "completed";
}
@ -3483,9 +3683,17 @@ export class DownloadManager extends EventEmitter {
}
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);
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
this.retryAfterByItem.delete(itemId);
if (pkg.itemIds.length === 0) {
this.removePackageFromSession(packageId, []);
}
@ -3536,6 +3744,7 @@ export class DownloadManager extends EventEmitter {
this.speedBytesLastWindow = 0;
this.globalSpeedLimitQueue = Promise.resolve();
this.globalSpeedLimitNextAt = 0;
this.nonResumableActive = 0;
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs();
this.persistNow();

View File

@ -319,19 +319,17 @@ function winRarCandidates(): string[] {
const installed = [
path.join(programFiles, "WinRAR", "UnRAR.exe"),
path.join(programFiles, "WinRAR", "WinRAR.exe"),
path.join(programFilesX86, "WinRAR", "UnRAR.exe"),
path.join(programFilesX86, "WinRAR", "WinRAR.exe")
path.join(programFilesX86, "WinRAR", "UnRAR.exe")
];
if (localAppData) {
installed.push(path.join(localAppData, "Programs", "WinRAR", "UnRAR.exe"));
installed.push(path.join(localAppData, "Programs", "WinRAR", "WinRAR.exe"));
}
const ordered = resolvedExtractorCommand
? [resolvedExtractorCommand, ...installed, "UnRAR.exe", "WinRAR.exe", "unrar", "winrar"]
: [...installed, "UnRAR.exe", "WinRAR.exe", "unrar", "winrar"];
? [resolvedExtractorCommand, ...installed, "UnRAR.exe", "unrar"]
: [...installed, "UnRAR.exe", "unrar"];
return Array.from(new Set(ordered.filter(Boolean)));
}
@ -378,6 +376,47 @@ type ExtractSpawnResult = {
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(
command: string,
args: string[],
@ -394,6 +433,8 @@ function runExtractCommand(
let output = "";
const child = spawn(command, args, { windowsHide: true });
let timeoutId: NodeJS.Timeout | null = null;
let timedOutByWatchdog = false;
let abortedBySignal = false;
const finish = (result: ExtractSpawnResult): void => {
if (settled) {
@ -412,11 +453,8 @@ function runExtractCommand(
if (timeoutMs && timeoutMs > 0) {
timeoutId = setTimeout(() => {
try {
child.kill();
} catch {
// ignore
}
timedOutByWatchdog = true;
killProcessTree(child);
finish({
ok: false,
missingCommand: false,
@ -429,11 +467,8 @@ function runExtractCommand(
const onAbort = signal
? (): void => {
try {
child.kill();
} catch {
// ignore
}
abortedBySignal = true;
killProcessTree(child);
finish({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" });
}
: null;
@ -464,6 +499,20 @@ function runExtractCommand(
});
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) {
finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" });
return;
@ -543,7 +592,7 @@ async function resolveExtractorCommandInternal(): Promise<string> {
}
const probeArgs = command.toLowerCase().includes("winrar") ? ["-?"] : ["?"];
const probe = await runExtractCommand(command, probeArgs, undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS);
if (!probe.missingCommand) {
if (probe.ok) {
resolvedExtractorCommand = command;
resolveFailureReason = "";
resolveFailureAt = 0;
@ -680,17 +729,20 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
const zip = new AdmZip(archivePath);
const entries = zip.getEntries();
const resolvedTarget = path.resolve(targetDir);
const usedOutputs = new Set<string>();
const renameCounters = new Map<string, number>();
for (const entry of entries) {
if (signal?.aborted) {
throw new Error("aborted:extract");
}
const outputPath = path.resolve(targetDir, entry.entryName);
if (!outputPath.startsWith(resolvedTarget + path.sep) && outputPath !== resolvedTarget) {
const baseOutputPath = path.resolve(targetDir, entry.entryName);
if (!baseOutputPath.startsWith(resolvedTarget + path.sep) && baseOutputPath !== resolvedTarget) {
logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`);
continue;
}
if (entry.isDirectory) {
fs.mkdirSync(outputPath, { recursive: true });
fs.mkdirSync(baseOutputPath, { recursive: true });
continue;
}
@ -708,52 +760,76 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
}).header;
const uncompressedSize = Number(header?.size ?? header?.dataHeader?.size ?? 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 limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
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 limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
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)
&& compressedSize > 0
&& crc !== 0) {
throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker");
}
let outputPath = baseOutputPath;
let outputKey = pathSetKey(outputPath);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
// 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
// the exists check to implement skip/rename conflict resolution semantics.
if (fs.existsSync(outputPath)) {
if (usedOutputs.has(outputKey) || fs.existsSync(outputPath)) {
if (mode === "skip") {
continue;
}
if (mode === "rename") {
const parsed = path.parse(outputPath);
let n = 1;
let candidate = outputPath;
while (fs.existsSync(candidate)) {
const parsed = path.parse(baseOutputPath);
const counterKey = pathSetKey(baseOutputPath);
let n = renameCounters.get(counterKey) || 1;
let candidate = baseOutputPath;
let candidateKey = outputKey;
while (n <= 10000) {
candidate = path.join(parsed.dir, `${parsed.name} (${n})${parsed.ext}`);
candidateKey = pathSetKey(candidate);
if (!usedOutputs.has(candidateKey) && !fs.existsSync(candidate)) {
break;
}
n += 1;
}
if (n > 10000) {
throw new Error(`ZIP-Rename-Limit erreicht für ${entry.entryName}`);
}
renameCounters.set(counterKey, n + 1);
if (signal?.aborted) {
throw new Error("aborted:extract");
}
fs.writeFileSync(candidate, entry.getData());
continue;
outputPath = candidate;
outputKey = candidateKey;
}
}
if (signal?.aborted) {
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)) {
const stem = escapeRegex(fileName.replace(/\.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);
}
@ -859,11 +935,39 @@ function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode): numbe
}
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) {
try {
if (!fs.existsSync(filePath)) {
continue;
}
if (cleanupMode === "trash") {
if (moveToTrashLike(filePath)) {
removed += 1;
}
continue;
}
fs.rmSync(filePath, { force: true });
removed += 1;
} catch {
@ -877,13 +981,13 @@ function hasAnyFilesRecursive(rootDir: string): boolean {
if (!fs.existsSync(rootDir)) {
return false;
}
const deadline = Date.now() + 70;
const deadline = Date.now() + 220;
let inspectedDirs = 0;
const stack = [rootDir];
while (stack.length > 0) {
inspectedDirs += 1;
if (inspectedDirs > 8000 || Date.now() > deadline) {
return true;
return hasAnyEntries(rootDir);
}
const current = stack.pop() as string;
let entries: fs.Dirent[] = [];
@ -1086,7 +1190,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (!shouldFallbackToExternalZip(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);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, options.signal);
@ -1140,7 +1244,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} else {
if (!options.skipPostCleanup) {
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") {
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}`);

View File

@ -57,8 +57,10 @@ function flushSyncPending(): void {
pendingLines = [];
pendingChars = 0;
rotateIfNeeded(logFilePath);
const primary = appendLine(logFilePath, chunk);
if (fallbackLogFilePath) {
rotateIfNeeded(fallbackLogFilePath);
const fallback = appendLine(fallbackLogFilePath, chunk);
if (!primary.ok && !fallback.ok) {
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;
}
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[] {
if (!Array.isArray(value) || !value.every(v => typeof v === "string")) {
throw new Error(`${name} muss ein String-Array sein`);
@ -121,7 +131,21 @@ function extractLinksFromText(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 {
@ -193,13 +217,21 @@ function registerIpcHandlers(): void {
}
});
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();
updateTray();
return result;
});
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {
validatePlainObject(payload ?? {}, "payload");
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);
});
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) => {
validateString(packageId, "packageId");
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);
});
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.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => {
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);
});
ipcMain.handle(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => {

View File

@ -1,6 +1,8 @@
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
import { compactErrorText, sleep } from "./utils";
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.28";
export interface UnrestrictedLink {
fileName: string;
directUrl: string;
@ -16,6 +18,33 @@ function retryDelay(attempt: number): number {
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 {
const type = String(contentType || "").toLowerCase();
if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
@ -39,7 +68,7 @@ export class RealDebridClient {
this.token = token;
}
public async unrestrictLink(link: string): Promise<UnrestrictedLink> {
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
try {
@ -49,10 +78,10 @@ export class RealDebridClient {
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "RD-Node-Downloader/1.1.12"
"User-Agent": DEBRID_USER_AGENT
},
body,
signal: AbortSignal.timeout(30000)
signal: withTimeoutSignal(signal, 30000)
});
const text = await response.text();
@ -91,7 +120,10 @@ export class RealDebridClient {
};
} catch (error) {
lastError = compactErrorText(error);
if (attempt >= REQUEST_RETRIES) {
if (signal?.aborted || /aborted/i.test(lastError)) {
break;
}
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
break;
}
await sleep(retryDelay(attempt));

View File

@ -57,11 +57,20 @@ function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] {
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 {
const defaults = defaultSettings();
const normalized: AppSettings = {
...defaults,
...settings,
token: asText(settings.token),
megaLogin: asText(settings.megaLogin),
megaPassword: asText(settings.megaPassword),
@ -69,23 +78,30 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
allDebridToken: asText(settings.allDebridToken),
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n/g, "\n"),
rememberToken: Boolean(settings.rememberToken),
providerPrimary: settings.providerPrimary,
providerSecondary: settings.providerSecondary,
providerTertiary: settings.providerTertiary,
autoProviderFallback: Boolean(settings.autoProviderFallback),
outputDir: asText(settings.outputDir) || defaults.outputDir,
outputDir: normalizeAbsoluteDir(settings.outputDir, defaults.outputDir),
packageName: asText(settings.packageName),
autoExtract: Boolean(settings.autoExtract),
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
extractDir: asText(settings.extractDir) || defaults.extractDir,
extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir),
createExtractSubfolder: Boolean(settings.createExtractSubfolder),
hybridExtract: Boolean(settings.hybridExtract),
cleanupMode: settings.cleanupMode,
extractConflictMode: settings.extractConflictMode,
removeLinkFilesAfterExtract: Boolean(settings.removeLinkFilesAfterExtract),
removeSamplesAfterExtract: Boolean(settings.removeSamplesAfterExtract),
enableIntegrityCheck: Boolean(settings.enableIntegrityCheck),
autoResumeOnStart: Boolean(settings.autoResumeOnStart),
autoReconnect: Boolean(settings.autoReconnect),
maxParallel: clampNumber(settings.maxParallel, defaults.maxParallel, 1, 50),
reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600),
completedCleanupPolicy: settings.completedCleanupPolicy,
speedLimitEnabled: Boolean(settings.speedLimitEnabled),
speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000),
reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600),
speedLimitMode: settings.speedLimitMode,
autoUpdateCheck: Boolean(settings.autoUpdateCheck),
updateRepo: asText(settings.updateRepo) || defaults.updateRepo,
clipboardWatch: Boolean(settings.clipboardWatch),
@ -103,6 +119,12 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
if (!VALID_FALLBACK_PROVIDERS.has(normalized.providerTertiary)) {
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)) {
normalized.cleanupMode = defaults.cleanupMode;
}
@ -264,9 +286,16 @@ function normalizeLoadedSession(raw: unknown): SessionState {
}
const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
const seenOrder = new Set<string>();
const packageOrder = rawOrder
.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)) {
if (!packageOrder.includes(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 {
ensureBaseDir(paths.baseDir);
// 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 {
ensureBaseDir(paths.baseDir);
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
const tempPath = `${paths.sessionFile}.tmp`;
const tempPath = sessionTempPath(paths.sessionFile, "sync");
fs.writeFileSync(tempPath, payload, "utf8");
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> {
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");
try {
await fsp.rename(tempPath, paths.sessionFile);

View File

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

View File

@ -43,8 +43,9 @@ export function sanitizeFilename(name: string): string {
}
const parsed = path.parse(normalized);
if (WINDOWS_RESERVED_BASENAMES.has(parsed.name.toLowerCase())) {
normalized = `${parsed.name}_${parsed.ext}`;
const reservedBase = (parsed.name.split(".")[0] || parsed.name).toLowerCase();
if (WINDOWS_RESERVED_BASENAMES.has(reservedBase)) {
normalized = `${parsed.name.replace(/^([^.]*)/, "$1_")}${parsed.ext}`;
}
return normalized || "Paket";
@ -70,14 +71,25 @@ export function extractHttpLinksFromText(text: string): string[] {
for (const match of matches) {
let candidate = String(match || "").trim();
while (candidate.length > 0 && /[)\],.!?;:]+$/.test(candidate)) {
if (candidate.endsWith(")")) {
while (candidate.length > 0) {
const lastChar = candidate[candidate.length - 1];
if (![")", "]", ",", ".", "!", "?", ";", ":"].includes(lastChar)) {
break;
}
if (lastChar === ")") {
const openCount = (candidate.match(/\(/g) || []).length;
const closeCount = (candidate.match(/\)/g) || []).length;
if (closeCount <= openCount) {
break;
}
}
if (lastChar === "]") {
const openCount = (candidate.match(/\[/g) || []).length;
const closeCount = (candidate.match(/\]/g) || []).length;
if (closeCount <= openCount) {
break;
}
}
candidate = candidate.slice(0, -1);
}
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 decoded = safeDecodeURIComponent(rawName || "").trim();
const normalized = decoded
.replace(/\.(rar|zip|7z|tar|gz|bz2|xz|iso|part\d+\.rar|r\d{2})\.html$/i, ".$1")
.replace(/\.(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");
return sanitizeFilename(normalized || "download.bin");
} catch {
@ -206,6 +218,9 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName:
}
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));
}

View File

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