This commit is contained in:
+34
-14
@@ -141,15 +141,42 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
|
||||
|
||||
let removedFiles = 0;
|
||||
let removedDirs = 0;
|
||||
const allDirs: string[] = [];
|
||||
const sampleDirs: string[] = [];
|
||||
const stack = [extractDir];
|
||||
|
||||
const countFilesRecursive = (rootDir: string): number => {
|
||||
let count = 0;
|
||||
const dirs = [rootDir];
|
||||
while (dirs.length > 0) {
|
||||
const current = dirs.pop() as string;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
dirs.push(full);
|
||||
} else if (entry.isFile()) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop() as string;
|
||||
allDirs.push(current);
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const base = entry.name.toLowerCase();
|
||||
if (SAMPLE_DIR_NAMES.has(base)) {
|
||||
sampleDirs.push(full);
|
||||
continue;
|
||||
}
|
||||
stack.push(full);
|
||||
continue;
|
||||
}
|
||||
@@ -157,13 +184,11 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
|
||||
continue;
|
||||
}
|
||||
|
||||
const parent = path.basename(path.dirname(full)).toLowerCase();
|
||||
const stem = path.parse(entry.name).name.toLowerCase();
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const inSampleDir = SAMPLE_DIR_NAMES.has(parent);
|
||||
const isSampleVideo = SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem);
|
||||
|
||||
if (inSampleDir || isSampleVideo) {
|
||||
if (isSampleVideo) {
|
||||
try {
|
||||
fs.rmSync(full, { force: true });
|
||||
removedFiles += 1;
|
||||
@@ -174,17 +199,12 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
|
||||
}
|
||||
}
|
||||
|
||||
allDirs.sort((a, b) => b.length - a.length);
|
||||
for (const dir of allDirs) {
|
||||
if (dir === extractDir) {
|
||||
continue;
|
||||
}
|
||||
const base = path.basename(dir).toLowerCase();
|
||||
if (!SAMPLE_DIR_NAMES.has(base)) {
|
||||
continue;
|
||||
}
|
||||
sampleDirs.sort((a, b) => b.length - a.length);
|
||||
for (const dir of sampleDirs) {
|
||||
try {
|
||||
const filesInDir = countFilesRecursive(dir);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
removedFiles += filesInDir;
|
||||
removedDirs += 1;
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
+31
-6
@@ -320,6 +320,9 @@ class MegaDebridClient {
|
||||
web.retriesUsed = attempt - 1;
|
||||
return web;
|
||||
}
|
||||
if (!lastError) {
|
||||
lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer";
|
||||
}
|
||||
if (attempt < REQUEST_RETRIES) {
|
||||
await sleep(retryDelay(attempt));
|
||||
}
|
||||
@@ -358,7 +361,7 @@ class BestDebridClient {
|
||||
"User-Agent": "RD-Node-Downloader/1.1.12"
|
||||
};
|
||||
if (request.useAuthHeader) {
|
||||
headers.Authorization = this.token;
|
||||
headers.Authorization = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(request.url, {
|
||||
@@ -478,7 +481,7 @@ class AllDebridClient {
|
||||
|
||||
const responseLink = pickString(info, ["link"]);
|
||||
const byResponse = canonicalToInput.get(canonicalLink(responseLink));
|
||||
const byIndex = chunk[i] || "";
|
||||
const byIndex = chunk.length === 1 ? chunk[0] : "";
|
||||
const original = byResponse || byIndex;
|
||||
if (!original) {
|
||||
continue;
|
||||
@@ -609,7 +612,7 @@ export class DebridService {
|
||||
reportResolved(link, fromPage);
|
||||
});
|
||||
|
||||
const stillUnresolved = unresolved.filter((link) => !clean.has(link));
|
||||
const stillUnresolved = unresolved.filter((link) => !clean.has(link) && !isRapidgatorLink(link));
|
||||
await runWithConcurrency(stillUnresolved, 4, async (link) => {
|
||||
try {
|
||||
const unrestricted = await this.unrestrictLink(link);
|
||||
@@ -629,6 +632,31 @@ export class DebridService {
|
||||
this.settings.providerTertiary
|
||||
);
|
||||
|
||||
const primary = order[0];
|
||||
if (!this.settings.autoProviderFallback) {
|
||||
if (!this.isProviderConfigured(primary)) {
|
||||
throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`);
|
||||
}
|
||||
try {
|
||||
const result = await this.unrestrictViaProvider(primary, link);
|
||||
let fileName = result.fileName;
|
||||
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
||||
const fromPage = await resolveRapidgatorFilename(link);
|
||||
if (fromPage) {
|
||||
fileName = fromPage;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
fileName,
|
||||
provider: primary,
|
||||
providerLabel: PROVIDER_LABELS[primary]
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[primary]}: ${compactErrorText(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
let configuredFound = false;
|
||||
const attempts: string[] = [];
|
||||
|
||||
@@ -637,9 +665,6 @@ export class DebridService {
|
||||
continue;
|
||||
}
|
||||
configuredFound = true;
|
||||
if (!this.settings.autoProviderFallback && attempts.length > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.unrestrictViaProvider(provider, link);
|
||||
|
||||
+112
-30
@@ -42,6 +42,8 @@ const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000;
|
||||
|
||||
const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 90000;
|
||||
|
||||
const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
|
||||
|
||||
function getDownloadStallTimeoutMs(): number {
|
||||
const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN);
|
||||
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) {
|
||||
@@ -71,6 +73,14 @@ function getGlobalStallWatchdogTimeoutMs(): number {
|
||||
return DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function getPostExtractTimeoutMs(): number {
|
||||
const fromEnv = Number(process.env.RD_POST_EXTRACT_TIMEOUT_MS ?? NaN);
|
||||
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 24 * 60 * 60 * 1000) {
|
||||
return Math.floor(fromEnv);
|
||||
}
|
||||
return DEFAULT_POST_EXTRACT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
type DownloadManagerOptions = {
|
||||
megaWebUnrestrict?: MegaWebUnrestrictor;
|
||||
};
|
||||
@@ -562,17 +572,26 @@ export class DownloadManager extends EventEmitter {
|
||||
if (pkg.status === "paused") {
|
||||
pkg.status = "queued";
|
||||
}
|
||||
let hasReactivatedRunItems = false;
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (this.session.running && !isFinishedStatus(item.status)) {
|
||||
this.runOutcomes.delete(itemId);
|
||||
this.runItemIds.add(itemId);
|
||||
hasReactivatedRunItems = true;
|
||||
}
|
||||
if (item.status === "queued" && item.fullStatus === "Paket gestoppt") {
|
||||
item.fullStatus = "Wartet";
|
||||
item.updatedAt = nowMs();
|
||||
}
|
||||
}
|
||||
if (this.session.running) {
|
||||
if (hasReactivatedRunItems) {
|
||||
this.runPackageIds.add(packageId);
|
||||
}
|
||||
void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (togglePackage): ${compactErrorText(err)}`));
|
||||
}
|
||||
}
|
||||
@@ -1297,6 +1316,8 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.lastGlobalProgressBytes = 0;
|
||||
this.lastGlobalProgressAt = nowMs();
|
||||
this.globalSpeedLimitQueue = Promise.resolve();
|
||||
this.globalSpeedLimitNextAt = 0;
|
||||
this.summary = null;
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
@@ -2103,6 +2124,9 @@ export class DownloadManager extends EventEmitter {
|
||||
while (true) {
|
||||
try {
|
||||
const unrestricted = await this.debridService.unrestrictLink(item.url);
|
||||
if (active.abortController.signal.aborted) {
|
||||
throw new Error(`aborted:${active.abortReason}`);
|
||||
}
|
||||
item.provider = unrestricted.provider;
|
||||
item.retries += unrestricted.retriesUsed;
|
||||
item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url));
|
||||
@@ -2206,23 +2230,28 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
} catch (error) {
|
||||
const reason = active.abortReason;
|
||||
const claimedTargetPath = this.claimedTargetPathByItem.get(item.id) || "";
|
||||
if (reason === "cancel") {
|
||||
item.status = "cancelled";
|
||||
item.fullStatus = "Entfernt";
|
||||
this.recordRunOutcome(item.id, "cancelled");
|
||||
try {
|
||||
fs.rmSync(item.targetPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
if (claimedTargetPath) {
|
||||
try {
|
||||
fs.rmSync(claimedTargetPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else if (reason === "stop") {
|
||||
item.status = "cancelled";
|
||||
item.fullStatus = "Gestoppt";
|
||||
this.recordRunOutcome(item.id, "cancelled");
|
||||
try {
|
||||
fs.rmSync(item.targetPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
if (claimedTargetPath) {
|
||||
try {
|
||||
fs.rmSync(claimedTargetPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else if (reason === "shutdown") {
|
||||
item.status = "queued";
|
||||
@@ -2916,6 +2945,10 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private cachedSpeedLimitAt = 0;
|
||||
|
||||
private globalSpeedLimitQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
private globalSpeedLimitNextAt = 0;
|
||||
|
||||
private getEffectiveSpeedLimitKbps(): number {
|
||||
const now = nowMs();
|
||||
if (now - this.cachedSpeedLimitAt < 2000) {
|
||||
@@ -2947,6 +2980,25 @@ export class DownloadManager extends EventEmitter {
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number): Promise<void> {
|
||||
const task = this.globalSpeedLimitQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const now = nowMs();
|
||||
const waitMs = Math.max(0, this.globalSpeedLimitNextAt - now);
|
||||
if (waitMs > 0) {
|
||||
await sleep(waitMs);
|
||||
}
|
||||
|
||||
const startAt = Math.max(nowMs(), this.globalSpeedLimitNextAt);
|
||||
const durationMs = Math.max(1, Math.ceil((chunkBytes / bytesPerSecond) * 1000));
|
||||
this.globalSpeedLimitNextAt = startAt + durationMs;
|
||||
});
|
||||
|
||||
this.globalSpeedLimitQueue = task;
|
||||
await task;
|
||||
}
|
||||
|
||||
private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise<void> {
|
||||
const limitKbps = this.getEffectiveSpeedLimitKbps();
|
||||
if (limitKbps <= 0) {
|
||||
@@ -2963,16 +3015,11 @@ export class DownloadManager extends EventEmitter {
|
||||
if (sleepMs > 0) {
|
||||
await sleep(Math.min(300, sleepMs));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.pruneSpeedEvents(now);
|
||||
const globalBytes = this.speedBytesLastWindow + chunkBytes;
|
||||
const globalAllowed = bytesPerSecond * 3;
|
||||
if (globalBytes > globalAllowed) {
|
||||
await sleep(Math.min(250, Math.ceil(((globalBytes - globalAllowed) / bytesPerSecond) * 1000)));
|
||||
}
|
||||
await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond);
|
||||
}
|
||||
|
||||
private findReadyArchiveSets(pkg: PackageEntry): Set<string> {
|
||||
@@ -3194,9 +3241,28 @@ export class DownloadManager extends EventEmitter {
|
||||
updateExtractingStatus("Entpacken 0%");
|
||||
this.emitState();
|
||||
|
||||
const extractTimeoutMs = 4 * 60 * 60 * 1000;
|
||||
const extractTimeoutMs = getPostExtractTimeoutMs();
|
||||
const extractAbortController = new AbortController();
|
||||
let timedOut = false;
|
||||
const onParentAbort = (): void => {
|
||||
if (extractAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
extractAbortController.abort("aborted:extract");
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onParentAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onParentAbort, { once: true });
|
||||
}
|
||||
}
|
||||
const extractDeadline = setTimeout(() => {
|
||||
logger.error(`Post-Processing Extraction Timeout nach 4h: pkg=${pkg.name}`);
|
||||
timedOut = true;
|
||||
logger.error(`Post-Processing Extraction Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s: pkg=${pkg.name}`);
|
||||
if (!extractAbortController.signal.aborted) {
|
||||
extractAbortController.abort("extract_timeout");
|
||||
}
|
||||
}, extractTimeoutMs);
|
||||
try {
|
||||
const result = await extractPackageArchives({
|
||||
@@ -3207,7 +3273,7 @@ export class DownloadManager extends EventEmitter {
|
||||
removeLinks: this.settings.removeLinkFilesAfterExtract,
|
||||
removeSamples: this.settings.removeSamplesAfterExtract,
|
||||
passwordList: this.settings.archivePasswordList,
|
||||
signal,
|
||||
signal: extractAbortController.signal,
|
||||
onProgress: (progress) => {
|
||||
const label = progress.phase === "done"
|
||||
? "Entpacken 100%"
|
||||
@@ -3224,7 +3290,6 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState();
|
||||
}
|
||||
});
|
||||
clearTimeout(extractDeadline);
|
||||
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
|
||||
if (result.failed > 0) {
|
||||
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
|
||||
@@ -3257,19 +3322,29 @@ export class DownloadManager extends EventEmitter {
|
||||
pkg.status = "completed";
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(extractDeadline);
|
||||
const reasonRaw = String(error || "");
|
||||
if (reasonRaw.includes("aborted:extract")) {
|
||||
for (const entry of completedItems) {
|
||||
if (/^Entpacken/i.test(entry.fullStatus || "")) {
|
||||
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
||||
if (reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout")) {
|
||||
if (timedOut) {
|
||||
const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`;
|
||||
logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`);
|
||||
for (const entry of completedItems) {
|
||||
entry.fullStatus = `Entpack-Fehler: ${timeoutReason}`;
|
||||
entry.updatedAt = nowMs();
|
||||
}
|
||||
entry.updatedAt = nowMs();
|
||||
pkg.status = "failed";
|
||||
pkg.updatedAt = nowMs();
|
||||
} else {
|
||||
for (const entry of completedItems) {
|
||||
if (/^Entpacken/i.test(entry.fullStatus || "")) {
|
||||
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
||||
}
|
||||
entry.updatedAt = nowMs();
|
||||
}
|
||||
pkg.status = pkg.enabled ? "queued" : "paused";
|
||||
pkg.updatedAt = nowMs();
|
||||
logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`);
|
||||
return;
|
||||
}
|
||||
pkg.status = pkg.enabled ? "queued" : "paused";
|
||||
pkg.updatedAt = nowMs();
|
||||
logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`);
|
||||
return;
|
||||
}
|
||||
const reason = compactErrorText(error);
|
||||
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
|
||||
@@ -3278,6 +3353,11 @@ export class DownloadManager extends EventEmitter {
|
||||
entry.updatedAt = nowMs();
|
||||
}
|
||||
pkg.status = "failed";
|
||||
} finally {
|
||||
clearTimeout(extractDeadline);
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onParentAbort);
|
||||
}
|
||||
}
|
||||
} else if (failed > 0) {
|
||||
pkg.status = "failed";
|
||||
@@ -3380,6 +3460,8 @@ export class DownloadManager extends EventEmitter {
|
||||
this.reservedTargetPaths.clear();
|
||||
this.claimedTargetPathByItem.clear();
|
||||
this.itemContributedBytes.clear();
|
||||
this.globalSpeedLimitQueue = Promise.resolve();
|
||||
this.globalSpeedLimitNextAt = 0;
|
||||
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
||||
this.lastGlobalProgressAt = nowMs();
|
||||
this.persistNow();
|
||||
|
||||
+30
-1
@@ -12,8 +12,12 @@ const NO_EXTRACTOR_MESSAGE = "WinRAR/UnRAR nicht gefunden. Bitte WinRAR installi
|
||||
|
||||
let resolvedExtractorCommand: string | null = null;
|
||||
let resolveFailureReason = "";
|
||||
let resolveFailureAt = 0;
|
||||
let externalExtractorSupportsPerfFlags = true;
|
||||
|
||||
const EXTRACTOR_RETRY_AFTER_MS = 30_000;
|
||||
const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256;
|
||||
|
||||
export interface ExtractOptions {
|
||||
packageDir: string;
|
||||
targetDir: string;
|
||||
@@ -45,6 +49,14 @@ const EXTRACT_PER_GIB_TIMEOUT_MS = 4 * 60 * 1000;
|
||||
const EXTRACT_MAX_TIMEOUT_MS = 120 * 60 * 1000;
|
||||
const ARCHIVE_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function zipEntryMemoryLimitBytes(): number {
|
||||
const fromEnvMb = Number(process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB ?? NaN);
|
||||
if (Number.isFinite(fromEnvMb) && fromEnvMb >= 8 && fromEnvMb <= 4096) {
|
||||
return Math.floor(fromEnvMb * 1024 * 1024);
|
||||
}
|
||||
return DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB * 1024 * 1024;
|
||||
}
|
||||
|
||||
export function pathSetKey(filePath: string): string {
|
||||
return process.platform === "win32" ? filePath.toLowerCase() : filePath;
|
||||
}
|
||||
@@ -490,7 +502,12 @@ async function resolveExtractorCommand(): Promise<string> {
|
||||
return resolvedExtractorCommand;
|
||||
}
|
||||
if (resolveFailureReason) {
|
||||
throw new Error(resolveFailureReason);
|
||||
const age = Date.now() - resolveFailureAt;
|
||||
if (age < EXTRACTOR_RETRY_AFTER_MS) {
|
||||
throw new Error(resolveFailureReason);
|
||||
}
|
||||
resolveFailureReason = "";
|
||||
resolveFailureAt = 0;
|
||||
}
|
||||
|
||||
const candidates = winRarCandidates();
|
||||
@@ -503,12 +520,14 @@ async function resolveExtractorCommand(): Promise<string> {
|
||||
if (!probe.missingCommand) {
|
||||
resolvedExtractorCommand = command;
|
||||
resolveFailureReason = "";
|
||||
resolveFailureAt = 0;
|
||||
logger.info(`Entpacker erkannt: ${command}`);
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
resolveFailureReason = NO_EXTRACTOR_MESSAGE;
|
||||
resolveFailureAt = Date.now();
|
||||
throw new Error(resolveFailureReason);
|
||||
}
|
||||
|
||||
@@ -581,6 +600,7 @@ async function runExternalExtract(
|
||||
if (result.missingCommand) {
|
||||
resolvedExtractorCommand = null;
|
||||
resolveFailureReason = NO_EXTRACTOR_MESSAGE;
|
||||
resolveFailureAt = Date.now();
|
||||
throw new Error(NO_EXTRACTOR_MESSAGE);
|
||||
}
|
||||
|
||||
@@ -592,6 +612,7 @@ async function runExternalExtract(
|
||||
|
||||
function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode): void {
|
||||
const mode = effectiveConflictMode(conflictMode);
|
||||
const memoryLimitBytes = zipEntryMemoryLimitBytes();
|
||||
const zip = new AdmZip(archivePath);
|
||||
const entries = zip.getEntries();
|
||||
const resolvedTarget = path.resolve(targetDir);
|
||||
@@ -605,6 +626,14 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
|
||||
fs.mkdirSync(outputPath, { recursive: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
const uncompressedSize = Number((entry as unknown as { header?: { size?: number } }).header?.size ?? NaN);
|
||||
if (Number.isFinite(uncompressedSize) && 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)`);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+8
-1
@@ -25,18 +25,25 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number)
|
||||
return Math.max(min, Math.min(max, Math.floor(num)));
|
||||
}
|
||||
|
||||
function createScheduleId(index: number): string {
|
||||
return `sched-${Date.now().toString(36)}-${index.toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized: BandwidthScheduleEntry[] = [];
|
||||
for (const entry of raw) {
|
||||
for (let index = 0; index < raw.length; index += 1) {
|
||||
const entry = raw[index];
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const value = entry as Partial<BandwidthScheduleEntry>;
|
||||
const rawId = typeof value.id === "string" ? value.id.trim() : "";
|
||||
normalized.push({
|
||||
id: rawId || createScheduleId(index),
|
||||
startHour: clampNumber(value.startHour, 0, 0, 23),
|
||||
endHour: clampNumber(value.endHour, 8, 0, 23),
|
||||
speedLimitKbps: clampNumber(value.speedLimitKbps, 0, 0, 500000),
|
||||
|
||||
+70
-5
@@ -12,6 +12,7 @@ import { logger } from "./logger";
|
||||
|
||||
const RELEASE_FETCH_TIMEOUT_MS = 12000;
|
||||
const CONNECT_TIMEOUT_MS = 30000;
|
||||
const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45000;
|
||||
const RETRIES_PER_CANDIDATE = 3;
|
||||
const RETRY_DELAY_MS = 1500;
|
||||
const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||||
@@ -69,6 +70,14 @@ function timeoutController(ms: number): { signal: AbortSignal; clear: () => void
|
||||
};
|
||||
}
|
||||
|
||||
function getDownloadBodyIdleTimeoutMs(): number {
|
||||
const fromEnv = Number(process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS ?? NaN);
|
||||
if (Number.isFinite(fromEnv) && fromEnv >= 1000 && fromEnv <= 30 * 60 * 1000) {
|
||||
return Math.floor(fromEnv);
|
||||
}
|
||||
return DOWNLOAD_BODY_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
export function parseVersionParts(version: string): number[] {
|
||||
const cleaned = version.replace(/^v/i, "").trim();
|
||||
return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0"));
|
||||
@@ -200,9 +209,9 @@ function readHttpStatusFromError(error: unknown): number {
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function isRecoverableDownloadError(error: unknown): boolean {
|
||||
function isRetryableDownloadError(error: unknown): boolean {
|
||||
const status = readHttpStatusFromError(error);
|
||||
if (status === 404 || status === 403 || status === 429 || status >= 500) {
|
||||
if (status === 429 || status >= 500) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -215,6 +224,14 @@ function isRecoverableDownloadError(error: unknown): boolean {
|
||||
|| text.includes("aborted");
|
||||
}
|
||||
|
||||
function shouldTryNextDownloadCandidate(error: unknown): boolean {
|
||||
const status = readHttpStatusFromError(error);
|
||||
if (status >= 400 && status <= 599) {
|
||||
return true;
|
||||
}
|
||||
return isRetryableDownloadError(error);
|
||||
}
|
||||
|
||||
function deriveUpdateFileName(check: UpdateCheckResult, url: string): string {
|
||||
const fromName = String(check.setupAssetName || "").trim();
|
||||
if (fromName) {
|
||||
@@ -298,7 +315,55 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream<Uint8Array>);
|
||||
const target = fs.createWriteStream(targetPath);
|
||||
await pipeline(source, target);
|
||||
const idleTimeoutMs = getDownloadBodyIdleTimeoutMs();
|
||||
let idleTimer: NodeJS.Timeout | null = null;
|
||||
const clearIdleTimer = (): void => {
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
}
|
||||
};
|
||||
const onIdleTimeout = (): void => {
|
||||
const timeoutError = new Error(`Update Download Body Timeout nach ${Math.ceil(idleTimeoutMs / 1000)}s`);
|
||||
source.destroy(timeoutError);
|
||||
target.destroy(timeoutError);
|
||||
};
|
||||
const resetIdleTimer = (): void => {
|
||||
if (idleTimeoutMs <= 0) {
|
||||
return;
|
||||
}
|
||||
clearIdleTimer();
|
||||
idleTimer = setTimeout(onIdleTimeout, idleTimeoutMs);
|
||||
};
|
||||
|
||||
const onSourceData = (): void => {
|
||||
resetIdleTimer();
|
||||
};
|
||||
const onSourceDone = (): void => {
|
||||
clearIdleTimer();
|
||||
};
|
||||
|
||||
if (idleTimeoutMs > 0) {
|
||||
source.on("data", onSourceData);
|
||||
source.on("end", onSourceDone);
|
||||
source.on("close", onSourceDone);
|
||||
source.on("error", onSourceDone);
|
||||
target.on("close", onSourceDone);
|
||||
target.on("error", onSourceDone);
|
||||
resetIdleTimer();
|
||||
}
|
||||
|
||||
try {
|
||||
await pipeline(source, target);
|
||||
} finally {
|
||||
clearIdleTimer();
|
||||
source.off("data", onSourceData);
|
||||
source.off("end", onSourceDone);
|
||||
source.off("close", onSourceDone);
|
||||
source.off("error", onSourceDone);
|
||||
target.off("close", onSourceDone);
|
||||
target.off("error", onSourceDone);
|
||||
}
|
||||
logger.info(`Update-Download abgeschlossen: ${targetPath}`);
|
||||
}
|
||||
|
||||
@@ -319,7 +384,7 @@ async function downloadWithRetries(url: string, targetPath: string): Promise<voi
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (attempt < RETRIES_PER_CANDIDATE && isRecoverableDownloadError(error)) {
|
||||
if (attempt < RETRIES_PER_CANDIDATE && isRetryableDownloadError(error)) {
|
||||
logger.warn(`Update-Download Retry ${attempt}/${RETRIES_PER_CANDIDATE} für ${url}: ${compactErrorText(error)}`);
|
||||
await sleep(RETRY_DELAY_MS * attempt);
|
||||
continue;
|
||||
@@ -342,7 +407,7 @@ async function downloadFromCandidates(candidates: string[], targetPath: string):
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
logger.warn(`Update-Download Kandidat ${index + 1}/${candidates.length} endgültig fehlgeschlagen: ${compactErrorText(error)}`);
|
||||
if (index < candidates.length - 1 && isRecoverableDownloadError(error)) {
|
||||
if (index < candidates.length - 1 && shouldTryNextDownloadCandidate(error)) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
|
||||
+27
-2
@@ -9,6 +9,12 @@ function safeDecodeURIComponent(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const WINDOWS_RESERVED_BASENAMES = new Set([
|
||||
"con", "prn", "aux", "nul",
|
||||
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
|
||||
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"
|
||||
]);
|
||||
|
||||
export function compactErrorText(message: unknown, maxLen = 220): string {
|
||||
const raw = String(message ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!raw) {
|
||||
@@ -21,8 +27,27 @@ export function compactErrorText(message: unknown, maxLen = 220): string {
|
||||
}
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
const cleaned = String(name || "").trim().replace(/\0/g, "").replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim();
|
||||
return cleaned || "Paket";
|
||||
const cleaned = String(name || "")
|
||||
.replace(/\0/g, "")
|
||||
.replace(/[\\/:*?"<>|]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
let normalized = cleaned
|
||||
.replace(/^[.\s]+/g, "")
|
||||
.replace(/[.\s]+$/g, "")
|
||||
.trim();
|
||||
|
||||
if (!normalized || normalized === "." || normalized === ".." || /^\.+$/.test(normalized)) {
|
||||
return "Paket";
|
||||
}
|
||||
|
||||
const parsed = path.parse(normalized);
|
||||
if (WINDOWS_RESERVED_BASENAMES.has(parsed.name.toLowerCase())) {
|
||||
normalized = `${parsed.name}_${parsed.ext}`;
|
||||
}
|
||||
|
||||
return normalized || "Paket";
|
||||
}
|
||||
|
||||
export function isHttpLink(value: string): boolean {
|
||||
|
||||
+39
-16
@@ -81,6 +81,23 @@ function humanSize(bytes: number): string {
|
||||
|
||||
let nextCollectorId = 1;
|
||||
|
||||
function createScheduleId(): string {
|
||||
return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
||||
const fromIndex = order.indexOf(draggedPackageId);
|
||||
const toIndex = order.indexOf(targetPackageId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||||
return order;
|
||||
}
|
||||
const next = [...order];
|
||||
const [dragged] = next.splice(fromIndex, 1);
|
||||
const insertIndex = Math.max(0, Math.min(next.length, toIndex));
|
||||
next.splice(insertIndex, 0, dragged);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function App(): ReactElement {
|
||||
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
||||
const [tab, setTab] = useState<Tab>("collector");
|
||||
@@ -122,10 +139,6 @@ export function App(): ReactElement {
|
||||
activeTabRef.current = tab;
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
settingsDirtyRef.current = settingsDirty;
|
||||
}, [settingsDirty]);
|
||||
|
||||
const showToast = (message: string, timeoutMs = 2200): void => {
|
||||
setStatusToast(message);
|
||||
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
||||
@@ -141,6 +154,7 @@ export function App(): ReactElement {
|
||||
void window.rd.getSnapshot().then((state) => {
|
||||
setSnapshot(state);
|
||||
setSettingsDraft(state.settings);
|
||||
settingsDirtyRef.current = false;
|
||||
setSettingsDirty(false);
|
||||
applyTheme(state.settings.theme);
|
||||
if (state.settings.autoUpdateCheck) {
|
||||
@@ -406,6 +420,7 @@ export function App(): ReactElement {
|
||||
const persistDraftSettings = async (): Promise<AppSettings> => {
|
||||
const result = await window.rd.updateSettings(normalizedSettingsDraft);
|
||||
setSettingsDraft(result);
|
||||
settingsDirtyRef.current = false;
|
||||
setSettingsDirty(false);
|
||||
return result;
|
||||
};
|
||||
@@ -485,8 +500,8 @@ export function App(): ReactElement {
|
||||
|
||||
const onAddLinks = async (): Promise<void> => {
|
||||
await performQuickAction(async () => {
|
||||
await persistDraftSettings();
|
||||
const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName });
|
||||
const persisted = await persistDraftSettings();
|
||||
const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, 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));
|
||||
@@ -575,19 +590,23 @@ export function App(): ReactElement {
|
||||
};
|
||||
|
||||
const setBool = (key: keyof AppSettings, value: boolean): void => {
|
||||
settingsDirtyRef.current = true;
|
||||
setSettingsDirty(true);
|
||||
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
const setText = (key: keyof AppSettings, value: string): void => {
|
||||
settingsDirtyRef.current = true;
|
||||
setSettingsDirty(true);
|
||||
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
const setNum = (key: keyof AppSettings, value: number): void => {
|
||||
settingsDirtyRef.current = true;
|
||||
setSettingsDirty(true);
|
||||
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
const setSpeedLimitMbps = (value: number): void => {
|
||||
const mbps = Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
settingsDirtyRef.current = true;
|
||||
setSettingsDirty(true);
|
||||
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
|
||||
};
|
||||
@@ -628,16 +647,13 @@ export function App(): ReactElement {
|
||||
}, [snapshot.session.packageOrder]);
|
||||
|
||||
const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
|
||||
const order = [...snapshot.session.packageOrder];
|
||||
const fromIndex = order.indexOf(draggedPackageId);
|
||||
const toIndex = order.indexOf(targetPackageId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||||
const nextOrder = reorderPackageOrderByDrop(snapshot.session.packageOrder, draggedPackageId, targetPackageId);
|
||||
const unchanged = nextOrder.length === snapshot.session.packageOrder.length
|
||||
&& nextOrder.every((id, index) => id === snapshot.session.packageOrder[index]);
|
||||
if (unchanged) {
|
||||
return;
|
||||
}
|
||||
const [dragged] = order.splice(fromIndex, 1);
|
||||
const insertIndex = fromIndex < toIndex ? toIndex - 1 : toIndex;
|
||||
order.splice(insertIndex, 0, dragged);
|
||||
void window.rd.reorderPackages(order);
|
||||
void window.rd.reorderPackages(nextOrder);
|
||||
}, [snapshot.session.packageOrder]);
|
||||
|
||||
const addCollectorTab = (): void => {
|
||||
@@ -684,18 +700,24 @@ export function App(): ReactElement {
|
||||
|
||||
const schedules = settingsDraft.bandwidthSchedules ?? [];
|
||||
const addSchedule = (): void => {
|
||||
settingsDirtyRef.current = true;
|
||||
setSettingsDirty(true);
|
||||
setSettingsDraft((prev) => ({
|
||||
...prev,
|
||||
bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }]
|
||||
bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { id: createScheduleId(), startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }]
|
||||
}));
|
||||
};
|
||||
const removeSchedule = (idx: number): void => {
|
||||
settingsDirtyRef.current = true;
|
||||
setSettingsDirty(true);
|
||||
setSettingsDraft((prev) => ({
|
||||
...prev,
|
||||
bandwidthSchedules: (prev.bandwidthSchedules ?? []).filter((_, i) => i !== idx)
|
||||
}));
|
||||
};
|
||||
const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => {
|
||||
settingsDirtyRef.current = true;
|
||||
setSettingsDirty(true);
|
||||
setSettingsDraft((prev) => ({
|
||||
...prev,
|
||||
bandwidthSchedules: (prev.bandwidthSchedules ?? []).map((s, i) => i === idx ? { ...s, [field]: value } : s)
|
||||
@@ -909,6 +931,7 @@ export function App(): ReactElement {
|
||||
<button className="btn" disabled={actionBusy} onClick={onCheckUpdates}>Updates prüfen</button>
|
||||
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
|
||||
const next = settingsDraft.theme === "dark" ? "light" : "dark";
|
||||
settingsDirtyRef.current = true;
|
||||
setSettingsDirty(true);
|
||||
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
|
||||
applyTheme(next as AppTheme);
|
||||
@@ -1019,7 +1042,7 @@ export function App(): ReactElement {
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
|
||||
<h4>Bandbreitenplanung</h4>
|
||||
{schedules.map((s, i) => (
|
||||
<div key={i} className="schedule-row">
|
||||
<div key={s.id || `schedule-${i}`} className="schedule-row">
|
||||
<input type="number" min={0} max={23} value={s.startHour} onChange={(e) => updateSchedule(i, "startHour", Number(e.target.value))} title="Von (Stunde)" />
|
||||
<span>-</span>
|
||||
<input type="number" min={0} max={23} value={s.endHour} onChange={(e) => updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" />
|
||||
|
||||
@@ -19,6 +19,7 @@ export type DebridFallbackProvider = DebridProvider | "none";
|
||||
export type AppTheme = "dark" | "light";
|
||||
|
||||
export interface BandwidthScheduleEntry {
|
||||
id?: string;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
speedLimitKbps: number;
|
||||
|
||||
Reference in New Issue
Block a user