Release v1.4.28 with expanded bug audit fixes
Some checks are pending
Build and Release / build (push) Waiting to run
Some checks are pending
Build and Release / build (push) Waiting to run
This commit is contained in:
parent
05a0c4fd55
commit
84d8f37ba6
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}`);
|
||||
|
||||
|
||||
@ -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`);
|
||||
|
||||
@ -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, () => {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user