Release v1.4.23 with critical 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
f70237f13d
commit
9598fca34e
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.19",
|
||||
"version": "1.4.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.19",
|
||||
"version": "1.4.23",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.22",
|
||||
"version": "1.4.23",
|
||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
21
tests/app-order.test.ts
Normal file
21
tests/app-order.test.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reorderPackageOrderByDrop } from "../src/renderer/App";
|
||||
|
||||
describe("reorderPackageOrderByDrop", () => {
|
||||
it("moves adjacent package down by one on drop", () => {
|
||||
const next = reorderPackageOrderByDrop(["a", "b", "c"], "b", "c");
|
||||
expect(next).toEqual(["a", "c", "b"]);
|
||||
});
|
||||
|
||||
it("moves package after lower drop target", () => {
|
||||
const next = reorderPackageOrderByDrop(["a", "b", "c", "d"], "a", "c");
|
||||
expect(next).toEqual(["b", "c", "a", "d"]);
|
||||
});
|
||||
|
||||
it("returns original order when ids are invalid", () => {
|
||||
const order = ["a", "b", "c"];
|
||||
expect(reorderPackageOrderByDrop(order, "x", "b")).toEqual(order);
|
||||
expect(reorderPackageOrderByDrop(order, "a", "x")).toEqual(order);
|
||||
expect(reorderPackageOrderByDrop(order, "a", "a")).toEqual(order);
|
||||
});
|
||||
});
|
||||
@ -114,6 +114,44 @@ describe("debrid service", () => {
|
||||
expect(result.fileSize).toBe(2048);
|
||||
});
|
||||
|
||||
it("sends Bearer auth header to BestDebrid", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "best-token",
|
||||
providerPrimary: "bestdebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: true
|
||||
};
|
||||
|
||||
let authHeader = "";
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/api/v1/generateLink?link=")) {
|
||||
const headers = init?.headers;
|
||||
if (headers instanceof Headers) {
|
||||
authHeader = headers.get("Authorization") || "";
|
||||
} else if (Array.isArray(headers)) {
|
||||
const tuple = headers.find(([key]) => key.toLowerCase() === "authorization");
|
||||
authHeader = tuple?.[1] || "";
|
||||
} else {
|
||||
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
||||
}
|
||||
return new Response(JSON.stringify({ download: "https://best.example/file.bin", filename: "file.bin", filesize: 42 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
const result = await service.unrestrictLink("https://hoster.example/file/abc");
|
||||
expect(result.provider).toBe("bestdebrid");
|
||||
expect(authHeader).toBe("Bearer best-token");
|
||||
});
|
||||
|
||||
it("supports AllDebrid unlock", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
@ -216,6 +254,30 @@ describe("debrid service", () => {
|
||||
expect(allDebridCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("does not use secondary provider when fallback is disabled and primary is missing", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
megaLogin: "user",
|
||||
megaPassword: "pass",
|
||||
providerPrimary: "realdebrid" as const,
|
||||
providerSecondary: "megadebrid" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
|
||||
const megaWeb = vi.fn(async () => ({
|
||||
fileName: "should-not-run.bin",
|
||||
directUrl: "https://unused",
|
||||
fileSize: null,
|
||||
retriesUsed: 0
|
||||
}));
|
||||
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html")).rejects.toThrow(/nicht konfiguriert/i);
|
||||
expect(megaWeb).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("allows disabling secondary and tertiary providers", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
@ -367,6 +429,82 @@ describe("debrid service", () => {
|
||||
{ link: linkFromProvider, fileName: "from-provider.part2.rar" }
|
||||
]));
|
||||
});
|
||||
|
||||
it("does not unrestrict rapidgator links during filename scan after page lookup miss", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
providerPrimary: "realdebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
allDebridToken: ""
|
||||
};
|
||||
|
||||
const link = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
let unrestrictCalls = 0;
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
||||
unrestrictCalls += 1;
|
||||
return new Response(JSON.stringify({ error: "should-not-be-called" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
if (url === link) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
const resolved = await service.resolveFilenames([link]);
|
||||
expect(resolved.size).toBe(0);
|
||||
expect(unrestrictCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("does not map AllDebrid filename infos by index when response link is missing", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "ad-token",
|
||||
providerPrimary: "realdebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: true
|
||||
};
|
||||
|
||||
const linkA = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const linkB = "https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.alldebrid.com/v4/link/infos")) {
|
||||
return new Response(JSON.stringify({
|
||||
status: "success",
|
||||
data: {
|
||||
infos: [
|
||||
{ filename: "wrong-a.mkv" },
|
||||
{ filename: "wrong-b.mkv" }
|
||||
]
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
if (url === linkA || url === linkB) {
|
||||
return new Response("no title", { status: 404 });
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
const resolved = await service.resolveFilenames([linkA, linkB]);
|
||||
expect(resolved.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeResolvedFilename", () => {
|
||||
|
||||
@ -3636,6 +3636,150 @@ describe("download manager", () => {
|
||||
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt (Quelle fehlt)");
|
||||
});
|
||||
|
||||
it("does not delete stale target file when stopping during unrestrict phase", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false,
|
||||
maxParallel: 1
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
manager.addPackages([{ name: "stop-unrestrict", links: ["https://dummy/slow-unrestrict"] }]);
|
||||
const initialSnapshot = manager.getSnapshot();
|
||||
const pkgId = initialSnapshot.session.packageOrder[0];
|
||||
const itemId = initialSnapshot.session.packages[pkgId]?.itemIds[0] || "";
|
||||
if (!itemId) {
|
||||
throw new Error("item missing");
|
||||
}
|
||||
|
||||
const item = manager.getSnapshot().session.items[itemId];
|
||||
const staleTargetPath = path.join(path.dirname(item.targetPath), "existing-before-start.mkv");
|
||||
fs.mkdirSync(path.dirname(staleTargetPath), { recursive: true });
|
||||
fs.writeFileSync(staleTargetPath, "keep", "utf8");
|
||||
|
||||
const mutableSession = manager.getSnapshot().session;
|
||||
if (mutableSession.items[itemId]) {
|
||||
mutableSession.items[itemId].targetPath = staleTargetPath;
|
||||
mutableSession.items[itemId].fileName = path.basename(staleTargetPath);
|
||||
mutableSession.items[itemId].downloadedBytes = 0;
|
||||
mutableSession.items[itemId].progressPercent = 0;
|
||||
}
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/unrestrict/link")) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 260));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
download: "https://cdn.example/unused.bin",
|
||||
filename: "new-file.mkv",
|
||||
filesize: 1024
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
);
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
manager.stop();
|
||||
await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "cancelled", 12000);
|
||||
expect(fs.existsSync(staleTargetPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("counts re-enabled package items in run summary totals", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const payload = Buffer.alloc(96 * 1024, 5);
|
||||
const server = http.createServer((req, res) => {
|
||||
if ((req.url || "") !== "/slow") {
|
||||
res.statusCode = 404;
|
||||
res.end("not-found");
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
res.setHeader("Content-Length", String(payload.length));
|
||||
res.end(payload);
|
||||
}, 180);
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("server address unavailable");
|
||||
}
|
||||
const directUrl = `http://127.0.0.1:${address.port}/slow`;
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/unrestrict/link")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
download: directUrl,
|
||||
filename: "episode.mkv",
|
||||
filesize: payload.length
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
);
|
||||
}
|
||||
return originalFetch(input);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false,
|
||||
maxParallel: 1
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
manager.addPackages([
|
||||
{ name: "pkg-a", links: ["https://dummy/a"] },
|
||||
{ name: "pkg-b", links: ["https://dummy/b"] }
|
||||
]);
|
||||
|
||||
const packageIds = manager.getSnapshot().session.packageOrder;
|
||||
const packageToToggle = packageIds[0];
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
manager.togglePackage(packageToToggle);
|
||||
manager.togglePackage(packageToToggle);
|
||||
|
||||
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||
const summary = manager.getSnapshot().summary;
|
||||
expect(summary?.total).toBe(2);
|
||||
} finally {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-renames extracted 4SF scene files to folder format", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@ -149,6 +149,23 @@ describe("settings storage", () => {
|
||||
expect(normalized.archivePasswordList).toBe("one\ntwo\nthree");
|
||||
});
|
||||
|
||||
it("assigns and preserves bandwidth schedule ids", () => {
|
||||
const normalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
bandwidthSchedules: [{ startHour: 1, endHour: 6, speedLimitKbps: 1024, enabled: true }]
|
||||
});
|
||||
|
||||
const generatedId = normalized.bandwidthSchedules[0]?.id;
|
||||
expect(typeof generatedId).toBe("string");
|
||||
expect(generatedId?.length).toBeGreaterThan(0);
|
||||
|
||||
const normalizedAgain = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
bandwidthSchedules: normalized.bandwidthSchedules
|
||||
});
|
||||
expect(normalizedAgain.bandwidthSchedules[0]?.id).toBe(generatedId);
|
||||
});
|
||||
|
||||
it("resets stale active statuses to queued on session load", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
@ -106,7 +106,51 @@ describe("update", () => {
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
expect(result.started).toBe(true);
|
||||
expect(requestedUrls.some((url) => url.includes("/releases/latest/download/"))).toBe(true);
|
||||
expect(requestedUrls.filter((url) => url.includes("stale-setup.exe"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("aborts hanging update body downloads on idle timeout", async () => {
|
||||
const previousTimeout = process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS;
|
||||
process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = "1000";
|
||||
|
||||
try {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("hang-setup.exe")) {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
}
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/octet-stream" }
|
||||
});
|
||||
}
|
||||
return new Response("missing", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const prechecked: UpdateCheckResult = {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/hang-setup.exe",
|
||||
setupAssetName: ""
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
expect(result.started).toBe(false);
|
||||
expect(result.message).toMatch(/timeout/i);
|
||||
} finally {
|
||||
if (previousTimeout === undefined) {
|
||||
delete process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS;
|
||||
} else {
|
||||
process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = previousTimeout;
|
||||
}
|
||||
}
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
describe("normalizeUpdateRepo extended", () => {
|
||||
|
||||
@ -14,6 +14,10 @@ describe("utils", () => {
|
||||
expect(sanitizeFilename(" ")).toBe("Paket");
|
||||
expect(sanitizeFilename("test\0file.txt")).toBe("testfile.txt");
|
||||
expect(sanitizeFilename("\0\0\0")).toBe("Paket");
|
||||
expect(sanitizeFilename("..")).toBe("Paket");
|
||||
expect(sanitizeFilename(".")).toBe("Paket");
|
||||
expect(sanitizeFilename("release... ")).toBe("release");
|
||||
expect(sanitizeFilename(" con ")).toBe("con_");
|
||||
});
|
||||
|
||||
it("parses package markers", () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user