Compare commits

...

33 Commits

Author SHA1 Message Date
Sucukdeluxe
72642351d0 Release v1.6.55 2026-03-05 06:25:20 +01:00
Sucukdeluxe
51a01ea03f Use bulk IInArchive.extract() for ~8x faster extraction, fix archive item resolution
- Replace extractSlow() per-item extraction with IInArchive.extract() bulk API
  in 7-Zip-JBinding. Solid RAR archives no longer re-decode from the beginning
  for each item, bringing extraction speed close to native WinRAR/7z.exe (~375 MB/s
  instead of ~43 MB/s).

- Add BulkExtractCallback implementing both IArchiveExtractCallback and
  ICryptoGetTextPassword for proper password handling during bulk extraction.

- Fix resolveArchiveItemsFromList with multi-level fallback matching:
  1. Pattern match (multipart RAR, split ZIP/7z, generic splits)
  2. Exact filename match (case-insensitive)
  3. Stem-based fuzzy match (handles debrid service filename modifications)
  4. Single-item archive fallback

- Simplify caching from Set+Array workaround back to simple Map<string, T>
  (the original "caching failure" was caused by resolveArchiveItemsFromList
  returning empty arrays, not by Map/Set/Object data structure bugs).

- Add comprehensive tests for archive item resolution (14 test cases)
  and JVM extraction progress callbacks (2 test cases).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:24:12 +01:00
Sucukdeluxe
d9a78ea837 Release v1.6.54 2026-03-05 05:59:50 +01:00
Sucukdeluxe
5b221d5bd5 Add persistent JVM daemon for extraction, fix caching with Set+Array
- JVM extractor now supports --daemon mode: starts once, processes
  multiple archives via stdin JSON protocol, eliminating ~5s JVM boot
  per archive
- TypeScript side: daemon manager starts JVM once, sends requests via
  stdin, falls back to spawning new process if daemon is busy
- Fix extraction progress caching: replaced Object.create(null) + in
  operator with Set<string> + linear Array scan — both Map.has() and
  the in operator mysteriously failed to find keys that were just set
- Daemon auto-shutdown on app quit via shutdownDaemon() in before-quit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:59:13 +01:00
Sucukdeluxe
c36549ca69 Release v1.6.53 2026-03-05 05:48:41 +01:00
Sucukdeluxe
7e79bef8da Increase JVM extractor heap to 8GB max / 512MB initial
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:48:02 +01:00
Sucukdeluxe
e3b4a4ba19 Release v1.6.52 2026-03-05 05:42:55 +01:00
Sucukdeluxe
30d216c7ca Fix extraction progress caching and JVM tuning
- Replace Map-based archive item cache with plain Object.create(null)
  to work around mysterious Map.has() returning false despite set()
  being called with the same key — this caused resolveArchiveItems
  to run on every 1.1s pulse instead of being cached, preventing
  extraction progress (Entpacken X%) from ever showing in the UI
- Apply same fix to both hybrid and full extraction paths
- Increase JVM heap from 512MB to 1GB for better extraction throughput
- Use SerialGC for faster JVM startup on short-lived extract processes
- Add download lifecycle logging (package add + item download start)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:42:23 +01:00
Sucukdeluxe
d80483adc2 Add download lifecycle logging for better diagnostics
- Log when packages are added (count + names)
- Log when individual item downloads start (filename, size, provider)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:32:44 +01:00
Sucukdeluxe
1cda391dfe Fix extraction speed and UI label updates
- Change OS priority from IDLE/BELOW_NORMAL to NORMAL/BELOW_NORMAL so
  extraction runs at full speed (matching manual 7-Zip/WinRAR performance)
- Use "high" priority in both hybrid and full extraction paths
- Increase hybrid extraction threads from hardcoded 2 to dynamic
  calculation (half CPU count, min 2, max 8)
- Fix emitState forced emit being silently dropped when a non-forced
  timer was already pending — forced emits now always replace pending
  timers to ensure immediate UI feedback during extraction transitions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:28:42 +01:00
Sucukdeluxe
375ec36781 Release v1.6.50 2026-03-05 05:09:24 +01:00
Sucukdeluxe
4ad1c05444 Fix extraction UI labels and speed for final extraction pass
- Force immediate emitState when first resolving archive items so UI
  transitions from 'Ausstehend' to 'Entpacken X%' instantly
- Use BELOW_NORMAL priority (instead of IDLE) for final extraction
  when all downloads are complete — matches manual extraction speed
- Add diagnostic logging for resolveArchiveItems matching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:08:09 +01:00
Sucukdeluxe
c88eeb0b12 Release v1.6.49 2026-03-05 04:45:53 +01:00
Sucukdeluxe
c6261aba6a Log when each item download completes
Add "Download fertig: filename (size), pkg=name" log line when an item
finishes downloading, enabling precise timing analysis of when archive
parts become available for extraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 04:45:17 +01:00
Sucukdeluxe
a010b967b9 Release v1.6.48 2026-03-05 04:42:09 +01:00
Sucukdeluxe
af6547f254 Add MKV collection after hybrid extraction + detailed timing logs
- Run collectMkvFilesToLibrary in background after each hybrid extraction
  round so MKVs are moved to the library as episodes are extracted, not
  only after the entire package finishes
- Add timing logs to identify bottlenecks:
  - Post-process slot wait time
  - Per-round duration with requeue status
  - Recovery loop duration
  - Setup time in handlePackagePostProcessing
  - findReadyArchiveSets duration when > 200ms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 04:41:01 +01:00
Sucukdeluxe
ba235b0b93 Release v1.6.47 2026-03-05 04:37:42 +01:00
Sucukdeluxe
1bfde96e46 Self-requeue hybrid extraction to avoid missed archive sets
After a hybrid extraction round completes, set the requeue flag so the
do-while loop immediately checks for more ready archive sets. Previously,
if all items completed before the task started processing, the single
requeue flag was consumed and no new completions triggered re-extraction,
causing 25+ second gaps until the next download completion.

Also change runHybridExtraction return type from void to number
(extracted count) to enable conditional self-requeue only when archives
were actually extracted, preventing infinite requeue loops.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 04:37:03 +01:00
Sucukdeluxe
e1f9b4b6d3 Release v1.6.46 2026-03-05 04:24:22 +01:00
Sucukdeluxe
95cf4fbed8 Eliminate 10-15s pause between package extractions
Release post-process slot immediately after main extraction completes.
All slow post-extraction work (nested extraction, auto-rename, archive
cleanup, link/sample removal, empty directory cleanup, MKV collection)
now runs in background via runDeferredPostExtraction so the next package
can start unpacking without delay.

- Export hasAnyFilesRecursive, removeEmptyDirectoryTree, cleanupArchives
  from extractor.ts for use in deferred handler
- Import removeDownloadLinkArtifacts, removeSampleArtifacts from cleanup
- Expand runDeferredPostExtraction with full post-cleanup pipeline:
  nested extraction, rename, archive cleanup, link/sample removal,
  empty dir tree removal, resume state clearing, MKV collection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 04:22:20 +01:00
Sucukdeluxe
9ddc7d31bb Make update changelog collapsible in confirm dialog
Long changelogs made the update dialog unscrollable, preventing users
from reaching the install button. Changelog is now in a collapsed
<details> element. Dialog also has max-height with overflow scroll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 04:12:47 +01:00
Sucukdeluxe
83626017b9 Fix btn-danger CSS class mismatch in history tab
CSS defines .btn.danger (two classes) but code used "btn btn-danger"
(one hyphenated class). History danger buttons now get correct red styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 04:07:09 +01:00
Sucukdeluxe
b9372f0ef0 Add ddownload to VALID_PRIMARY_PROVIDERS and VALID_FALLBACK_PROVIDERS
DDownload was missing from provider validation sets, preventing users
from configuring it as primary or fallback provider in settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 04:04:31 +01:00
Sucukdeluxe
db97a7df14 Fix setPackagePriority type safety and add missing .catch() to IPC calls
- Use PackagePriority type instead of string/any in preload and app-controller
- Add .catch() to start(), extractNow(), setPackagePriority(), updateSettings(columnOrder), openLog(), openSessionLog()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:59:10 +01:00
Sucukdeluxe
575fca3806 Release v1.6.45 2026-03-05 03:54:54 +01:00
Sucukdeluxe
a1c8f42435 Comprehensive bugfix release v1.6.45
Fix ~70 issues across the entire codebase including security fixes,
error handling improvements, test stabilization, and code quality.

- Fix TLS race condition with reference-counted acquire/release
- Bind debug server to 127.0.0.1 instead of 0.0.0.0
- Add overall timeout to MegaWebFallback
- Stream update installer to disk instead of RAM buffering
- Add path traversal protection in JVM extractor
- Cache DdownloadClient with credential-based invalidation
- Add .catch() to all fire-and-forget IPC calls
- Wrap app startup, clipboard, session-log in try/catch
- Add timeouts to container.ts fetch calls
- Fix variable shadowing, tsconfig path, line endings
- Stabilize tests with proper cleanup and timing tolerance
- Fix installer privileges, scripts, and afterPack null checks
- Delete obsolete _upload_release.mjs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:53:28 +01:00
Sucukdeluxe
a3c2680fec Show transitional label between archive extractions
After an archive finishes at 100%, show "Naechstes Archiv..." label
while the next archive initializes, eliminating the "dead" gap where
no activity was visible between consecutive extractions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:13:08 +01:00
Sucukdeluxe
12dade0240 Show compact changelog in update dialog, strip sub-items and long descriptions
Only top-level list items are shown in the updater changelog.
Indented sub-items, headings, and long descriptions are removed
for a clean, compact display. Detailed notes remain on the
Gitea release page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:02:41 +01:00
Sucukdeluxe
2a528a126c Add detailed preparation labels and dash separator for post-process status
- Show "Entpacken vorbereiten..." while scanning archives and checking disk space
- Show "Archive scannen..." and "Speicherplatz prüfen..." phases from extractor
- Use dash separator in UI: "[10/10 - Done] - Entpacken 45% (3/6)"
- Handle new "preparing" phase in both hybrid and full extraction progress handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:57:23 +01:00
Sucukdeluxe
8839080069 Show live extraction progress in package postProcessLabel
Update postProcessLabel during extraction with detailed progress:
- Overall percentage and archive count (e.g. "Entpacken 45% (3/6)")
- Password cracking progress when testing passwords
- Works for both hybrid and full extraction modes

Previously the label was static "Entpacken..." with no detail about
what was happening during potentially long extraction phases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:46:10 +01:00
Sucukdeluxe
8f66d75eb3 Show DDownload provider label instead of generic Debrid in status
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:42:19 +01:00
Sucukdeluxe
56ee681aec Strip markdown formatting from changelog in update dialog
The confirm dialog is plain text and cannot render markdown. Strip
headings, bold, italic, code backticks, and normalize list bullets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:37:18 +01:00
Sucukdeluxe
6db03f05a9 Fix DDownload downloads failing due to SSL certificate verification
DDownload's storage servers (dstorage.org) use certificates that fail
Node.js TLS verification. Add skipTlsVerify flag to UnrestrictedLink
and temporarily disable TLS verification for the download fetch when
the flag is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:32:59 +01:00
44 changed files with 1801 additions and 506 deletions

View File

@ -1,75 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const credResult = spawnSync("git", ["credential", "fill"], {
input: "protocol=https\nhost=codeberg.org\n\n",
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"]
});
const creds = new Map();
for (const line of credResult.stdout.split(/\r?\n/)) {
if (line.includes("=")) {
const [k, v] = line.split("=", 2);
creds.set(k, v);
}
}
const auth = "Basic " + Buffer.from(creds.get("username") + ":" + creds.get("password")).toString("base64");
const owner = "Sucukdeluxe";
const repo = "real-debrid-downloader";
const tag = "v1.5.35";
const baseApi = `https://codeberg.org/api/v1/repos/${owner}/${repo}`;
async function main() {
await fetch(baseApi, {
method: "PATCH",
headers: { Authorization: auth, "Content-Type": "application/json" },
body: JSON.stringify({ has_releases: true })
});
const createRes = await fetch(`${baseApi}/releases`, {
method: "POST",
headers: { Authorization: auth, "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
tag_name: tag,
target_commitish: "main",
name: tag,
body: "- Fix: Fortschritt zeigt jetzt kombinierten Wert (Download + Entpacken)\n- Fix: Pausieren zeigt nicht mehr 'Warte auf Daten'\n- Pixel-perfekte Dual-Layer Progress-Bar Texte (clip-path)",
draft: false,
prerelease: false
})
});
const release = await createRes.json();
if (!createRes.ok) {
console.error("Create failed:", JSON.stringify(release));
process.exit(1);
}
console.log("Release created:", release.id);
const files = [
"Real-Debrid-Downloader Setup 1.5.35.exe",
"Real-Debrid-Downloader 1.5.35.exe",
"latest.yml",
"Real-Debrid-Downloader Setup 1.5.35.exe.blockmap"
];
for (const f of files) {
const filePath = path.join("release", f);
const data = fs.readFileSync(filePath);
const uploadUrl = `${baseApi}/releases/${release.id}/assets?name=${encodeURIComponent(f)}`;
const res = await fetch(uploadUrl, {
method: "POST",
headers: { Authorization: auth, "Content-Type": "application/octet-stream" },
body: data
});
if (res.ok) {
console.log("Uploaded:", f);
} else if (res.status === 409 || res.status === 422) {
console.log("Skipped existing:", f);
} else {
console.error("Upload failed for", f, ":", res.status);
}
}
console.log(`Done! https://codeberg.org/${owner}/${repo}/releases/tag/${tag}`);
}
main().catch(e => { console.error(e.message); process.exit(1); });

View File

@ -25,11 +25,11 @@ AppPublisher=Sucukdeluxe
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
OutputDir={#MyOutputDir}
OutputBaseFilename=Real-Debrid-Downloader-Setup-{#MyAppVersion}
OutputBaseFilename=Real-Debrid-Downloader Setup {#MyAppVersion}
Compression=lzma
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
PrivilegesRequired=lowest
ArchitecturesInstallIn64BitMode=x64compatible
UninstallDisplayIcon={app}\{#MyAppExeName}
SetupIconFile={#MyIconFile}
@ -39,8 +39,8 @@ Name: "german"; MessagesFile: "compiler:Languages\German.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Files]
Source: "{#MySourceDir}\\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#MyIconFile}"; DestDir: "{app}"; DestName: "app_icon.ico"; Flags: ignoreversion
Source: "{#MySourceDir}\\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs
Source: "{#MyIconFile}"; DestDir: "{app}"; DestName: "app_icon.ico"
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app_icon.ico"

View File

@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "1.6.37",
"version": "1.6.55",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",

View File

@ -3,7 +3,9 @@ package com.sucukdeluxe.extractor;
import net.lingala.zip4j.ZipFile;
import net.lingala.zip4j.exception.ZipException;
import net.lingala.zip4j.model.FileHeader;
import net.sf.sevenzipjbinding.ExtractAskMode;
import net.sf.sevenzipjbinding.ExtractOperationResult;
import net.sf.sevenzipjbinding.IArchiveExtractCallback;
import net.sf.sevenzipjbinding.IArchiveOpenCallback;
import net.sf.sevenzipjbinding.IArchiveOpenVolumeCallback;
import net.sf.sevenzipjbinding.IInArchive;
@ -26,6 +28,7 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
@ -42,12 +45,18 @@ public final class JBindExtractorMain {
private static final Pattern NUMBERED_ZIP_SPLIT_RE = Pattern.compile("(?i).*\\.zip\\.\\d{3}$");
private static final Pattern OLD_ZIP_SPLIT_RE = Pattern.compile("(?i).*\\.z\\d{2,3}$");
private static final Pattern SEVEN_ZIP_SPLIT_RE = Pattern.compile("(?i).*\\.7z\\.001$");
private static final Pattern DIGIT_SUFFIX_RE = Pattern.compile("\\d{2,3}");
private static final Pattern WINDOWS_SPECIAL_CHARS_RE = Pattern.compile("[:<>*?\"\\|]");
private static volatile boolean sevenZipInitialized = false;
private JBindExtractorMain() {
}
public static void main(String[] args) {
if (args.length == 1 && "--daemon".equals(args[0])) {
runDaemon();
return;
}
int exit = 1;
try {
ExtractionRequest request = parseArgs(args);
@ -62,6 +71,127 @@ public final class JBindExtractorMain {
System.exit(exit);
}
private static void runDaemon() {
System.out.println("RD_DAEMON_READY");
System.out.flush();
java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(System.in, StandardCharsets.UTF_8));
try {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) {
continue;
}
int exitCode = 1;
try {
ExtractionRequest request = parseDaemonRequest(line);
exitCode = runExtraction(request);
} catch (IllegalArgumentException error) {
emitError("Argumentfehler: " + safeMessage(error));
exitCode = 2;
} catch (Throwable error) {
emitError(safeMessage(error));
exitCode = 1;
}
System.out.println("RD_REQUEST_DONE " + exitCode);
System.out.flush();
}
} catch (IOException ignored) {
// stdin closed parent process exited
}
}
private static ExtractionRequest parseDaemonRequest(String jsonLine) {
// Minimal JSON parsing without external dependencies.
// Expected format: {"archive":"...","target":"...","conflict":"...","backend":"...","passwords":["...","..."]}
ExtractionRequest request = new ExtractionRequest();
request.archiveFile = new File(extractJsonString(jsonLine, "archive"));
request.targetDir = new File(extractJsonString(jsonLine, "target"));
String conflict = extractJsonString(jsonLine, "conflict");
if (conflict.length() > 0) {
request.conflictMode = ConflictMode.fromValue(conflict);
}
String backend = extractJsonString(jsonLine, "backend");
if (backend.length() > 0) {
request.backend = Backend.fromValue(backend);
}
// Parse passwords array
int pwStart = jsonLine.indexOf("\"passwords\"");
if (pwStart >= 0) {
int arrStart = jsonLine.indexOf('[', pwStart);
int arrEnd = jsonLine.indexOf(']', arrStart);
if (arrStart >= 0 && arrEnd > arrStart) {
String arrContent = jsonLine.substring(arrStart + 1, arrEnd);
int idx = 0;
while (idx < arrContent.length()) {
int qStart = arrContent.indexOf('"', idx);
if (qStart < 0) break;
int qEnd = findClosingQuote(arrContent, qStart + 1);
if (qEnd < 0) break;
request.passwords.add(unescapeJsonString(arrContent.substring(qStart + 1, qEnd)));
idx = qEnd + 1;
}
}
}
if (request.archiveFile == null || !request.archiveFile.exists() || !request.archiveFile.isFile()) {
throw new IllegalArgumentException("Archiv nicht gefunden: " +
(request.archiveFile == null ? "null" : request.archiveFile.getAbsolutePath()));
}
if (request.targetDir == null) {
throw new IllegalArgumentException("--target fehlt");
}
return request;
}
private static String extractJsonString(String json, String key) {
String search = "\"" + key + "\"";
int keyIdx = json.indexOf(search);
if (keyIdx < 0) return "";
int colonIdx = json.indexOf(':', keyIdx + search.length());
if (colonIdx < 0) return "";
int qStart = json.indexOf('"', colonIdx + 1);
if (qStart < 0) return "";
int qEnd = findClosingQuote(json, qStart + 1);
if (qEnd < 0) return "";
return unescapeJsonString(json.substring(qStart + 1, qEnd));
}
private static int findClosingQuote(String s, int from) {
for (int i = from; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\\') {
i++; // skip escaped character
continue;
}
if (c == '"') return i;
}
return -1;
}
private static String unescapeJsonString(String s) {
if (s.indexOf('\\') < 0) return s;
StringBuilder sb = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\\' && i + 1 < s.length()) {
char next = s.charAt(i + 1);
switch (next) {
case '"': sb.append('"'); i++; break;
case '\\': sb.append('\\'); i++; break;
case '/': sb.append('/'); i++; break;
case 'n': sb.append('\n'); i++; break;
case 'r': sb.append('\r'); i++; break;
case 't': sb.append('\t'); i++; break;
default: sb.append(c); break;
}
} else {
sb.append(c);
}
}
return sb.toString();
}
private static int runExtraction(ExtractionRequest request) throws Exception {
List<String> passwords = normalizePasswords(request.passwords);
Exception lastError = null;
@ -152,9 +282,12 @@ public final class JBindExtractorMain {
}
ensureDirectory(output.getParentFile());
rejectSymlink(output);
long[] remaining = new long[] { itemUnits };
boolean extractionSuccess = false;
try {
InputStream in = zipFile.getInputStream(header);
try {
OutputStream out = new FileOutputStream(output);
try {
byte[] buffer = new byte[BUFFER_SIZE];
@ -176,6 +309,8 @@ public final class JBindExtractorMain {
out.close();
} catch (Throwable ignored) {
}
}
} finally {
try {
in.close();
} catch (Throwable ignored) {
@ -188,11 +323,19 @@ public final class JBindExtractorMain {
if (modified > 0) {
output.setLastModified(modified);
}
extractionSuccess = true;
} catch (ZipException error) {
if (isWrongPassword(error, encrypted)) {
throw new WrongPasswordException(error);
}
throw error;
} finally {
if (!extractionSuccess && output.exists()) {
try {
output.delete();
} catch (Throwable ignored) {
}
}
}
}
@ -219,98 +362,99 @@ public final class JBindExtractorMain {
try {
context = openSevenZipArchive(request.archiveFile, password);
IInArchive archive = context.archive;
ISimpleInArchive simple = archive.getSimpleInterface();
ISimpleInArchiveItem[] items = simple.getArchiveItems();
int itemCount = archive.getNumberOfItems();
if (itemCount <= 0) {
throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath());
}
// Pre-scan: collect file indices, sizes, output paths, and detect encryption
long totalUnits = 0;
boolean encrypted = false;
for (ISimpleInArchiveItem item : items) {
if (item == null || item.isFolder()) {
continue;
}
try {
encrypted = encrypted || item.isEncrypted();
} catch (Throwable ignored) {
// ignore encrypted flag read issues
}
totalUnits += safeSize(item.getSize());
}
ProgressTracker progress = new ProgressTracker(totalUnits);
progress.emitStart();
List<Integer> fileIndices = new ArrayList<Integer>();
List<File> outputFiles = new ArrayList<File>();
List<Long> fileSizes = new ArrayList<Long>();
Set<String> reserved = new HashSet<String>();
for (ISimpleInArchiveItem item : items) {
if (item == null) {
continue;
}
String entryName = normalizeEntryName(item.getPath(), "item-" + item.getItemIndex());
if (item.isFolder()) {
for (int i = 0; i < itemCount; i++) {
Boolean isFolder = (Boolean) archive.getProperty(i, PropID.IS_FOLDER);
String entryPath = (String) archive.getProperty(i, PropID.PATH);
String entryName = normalizeEntryName(entryPath, "item-" + i);
if (Boolean.TRUE.equals(isFolder)) {
File dir = resolveDirectory(request.targetDir, entryName);
ensureDirectory(dir);
reserved.add(pathKey(dir));
continue;
}
long itemUnits = safeSize(item.getSize());
try {
Boolean isEncrypted = (Boolean) archive.getProperty(i, PropID.ENCRYPTED);
encrypted = encrypted || Boolean.TRUE.equals(isEncrypted);
} catch (Throwable ignored) {
// ignore encrypted flag read issues
}
Long rawSize = (Long) archive.getProperty(i, PropID.SIZE);
long itemSize = safeSize(rawSize);
totalUnits += itemSize;
File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
if (output == null) {
progress.advance(itemUnits);
continue;
fileIndices.add(i);
outputFiles.add(output); // null if skipped
fileSizes.add(itemSize);
}
ensureDirectory(output.getParentFile());
final FileOutputStream out = new FileOutputStream(output);
final long[] remaining = new long[] { itemUnits };
if (fileIndices.isEmpty()) {
// All items are folders or skipped
ProgressTracker progress = new ProgressTracker(1);
progress.emitStart();
progress.emitDone();
return;
}
ProgressTracker progress = new ProgressTracker(totalUnits);
progress.emitStart();
// Build index array for bulk extract
int[] indices = new int[fileIndices.size()];
for (int i = 0; i < fileIndices.size(); i++) {
indices[i] = fileIndices.get(i);
}
// Map from archive index to our position in fileIndices/outputFiles
Map<Integer, Integer> indexToPos = new HashMap<Integer, Integer>();
for (int i = 0; i < fileIndices.size(); i++) {
indexToPos.put(fileIndices.get(i), i);
}
// Bulk extraction state
final boolean encryptedFinal = encrypted;
final String effectivePassword = password == null ? "" : password;
final File[] currentOutput = new File[1];
final FileOutputStream[] currentStream = new FileOutputStream[1];
final boolean[] currentSuccess = new boolean[1];
final long[] currentRemaining = new long[1];
final Throwable[] firstError = new Throwable[1];
final int[] currentPos = new int[] { -1 };
try {
ExtractOperationResult result = item.extractSlow(new ISequentialOutStream() {
@Override
public int write(byte[] data) throws SevenZipException {
if (data == null || data.length == 0) {
return 0;
}
try {
out.write(data);
} catch (IOException error) {
throw new SevenZipException("Fehler beim Schreiben: " + error.getMessage(), error);
}
long accounted = Math.min(remaining[0], (long) data.length);
remaining[0] -= accounted;
progress.advance(accounted);
return data.length;
}
}, password == null ? "" : password);
if (remaining[0] > 0) {
progress.advance(remaining[0]);
}
if (result != ExtractOperationResult.OK) {
if (isPasswordFailure(result, encrypted)) {
throw new WrongPasswordException(new IOException("Falsches Passwort"));
}
throw new IOException("7z-Fehler: " + result.name());
}
archive.extract(indices, false, new BulkExtractCallback(
archive, indexToPos, fileIndices, outputFiles, fileSizes,
progress, encryptedFinal, effectivePassword, currentOutput,
currentStream, currentSuccess, currentRemaining, currentPos, firstError
));
} catch (SevenZipException error) {
if (looksLikeWrongPassword(error, encrypted)) {
if (looksLikeWrongPassword(error, encryptedFinal)) {
throw new WrongPasswordException(error);
}
throw error;
} finally {
try {
out.close();
} catch (Throwable ignored) {
}
}
try {
java.util.Date modified = item.getLastWriteTime();
if (modified != null) {
output.setLastModified(modified.getTime());
}
} catch (Throwable ignored) {
// best effort
if (firstError[0] != null) {
if (firstError[0] instanceof WrongPasswordException) {
throw (WrongPasswordException) firstError[0];
}
throw (Exception) firstError[0];
}
progress.emitDone();
@ -328,14 +472,31 @@ public final class JBindExtractorMain {
if (SEVEN_ZIP_SPLIT_RE.matcher(nameLower).matches()) {
VolumedArchiveInStream volumed = new VolumedArchiveInStream(archiveFile.getName(), callback);
try {
IInArchive archive = SevenZip.openInArchive(null, volumed, callback);
return new SevenZipArchiveContext(archive, null, volumed, callback);
} catch (Exception error) {
callback.close();
throw error;
}
}
RandomAccessFile raf = new RandomAccessFile(archiveFile, "r");
RandomAccessFileInStream stream = new RandomAccessFileInStream(raf);
try {
IInArchive archive = SevenZip.openInArchive(null, stream, callback);
return new SevenZipArchiveContext(archive, stream, null, callback);
} catch (Exception error) {
try {
stream.close();
} catch (Throwable ignored) {
}
try {
raf.close();
} catch (Throwable ignored) {
}
throw error;
}
}
private static boolean isWrongPassword(ZipException error, boolean encrypted) {
@ -396,7 +557,7 @@ public final class JBindExtractorMain {
}
if (siblingName.startsWith(prefix) && siblingName.length() >= prefix.length() + 2) {
String suffix = siblingName.substring(prefix.length());
if (suffix.matches("\\d{2,3}")) {
if (DIGIT_SUFFIX_RE.matcher(suffix).matches()) {
return true;
}
}
@ -480,6 +641,12 @@ public final class JBindExtractorMain {
}
if (normalized.matches("^[a-zA-Z]:.*")) {
normalized = normalized.substring(2);
while (normalized.startsWith("/")) {
normalized = normalized.substring(1);
}
while (normalized.startsWith("\\")) {
normalized = normalized.substring(1);
}
}
File targetCanonical = targetDir.getCanonicalFile();
File output = new File(targetCanonical, normalized);
@ -488,7 +655,8 @@ public final class JBindExtractorMain {
String outputPath = outputCanonical.getPath();
String targetPathNorm = isWindows() ? targetPath.toLowerCase(Locale.ROOT) : targetPath;
String outputPathNorm = isWindows() ? outputPath.toLowerCase(Locale.ROOT) : outputPath;
if (!outputPathNorm.equals(targetPathNorm) && !outputPathNorm.startsWith(targetPathNorm + File.separator)) {
String targetPrefix = targetPathNorm.endsWith(File.separator) ? targetPathNorm : targetPathNorm + File.separator;
if (!outputPathNorm.equals(targetPathNorm) && !outputPathNorm.startsWith(targetPrefix)) {
throw new IOException("Path Traversal blockiert: " + entryName);
}
return outputCanonical;
@ -506,20 +674,50 @@ public final class JBindExtractorMain {
if (entry.length() == 0) {
return fallback;
}
// Sanitize Windows special characters from each path segment
String[] segments = entry.split("/", -1);
StringBuilder sanitized = new StringBuilder();
for (int i = 0; i < segments.length; i++) {
if (i > 0) {
sanitized.append('/');
}
sanitized.append(WINDOWS_SPECIAL_CHARS_RE.matcher(segments[i]).replaceAll("_"));
}
entry = sanitized.toString();
if (entry.length() == 0) {
return fallback;
}
return entry;
}
private static long safeSize(Long value) {
if (value == null) {
return 1;
return 0;
}
long size = value.longValue();
if (size <= 0) {
return 1;
return 0;
}
return size;
}
private static void rejectSymlink(File file) throws IOException {
if (file == null) {
return;
}
if (Files.isSymbolicLink(file.toPath())) {
throw new IOException("Zieldatei ist ein Symlink, Schreiben verweigert: " + file.getAbsolutePath());
}
// Also check parent directories for symlinks
File parent = file.getParentFile();
while (parent != null) {
if (Files.isSymbolicLink(parent.toPath())) {
throw new IOException("Elternverzeichnis ist ein Symlink, Schreiben verweigert: " + parent.getAbsolutePath());
}
parent = parent.getParentFile();
}
}
private static void ensureDirectory(File dir) throws IOException {
if (dir == null) {
return;
@ -681,6 +879,176 @@ public final class JBindExtractorMain {
private final List<String> passwords = new ArrayList<String>();
}
/**
* Bulk extraction callback that implements both IArchiveExtractCallback and
* ICryptoGetTextPassword. Using the bulk IInArchive.extract() API instead of
* per-item extractSlow() is critical for performance solid RAR archives
* otherwise re-decode from the beginning for every single item.
*/
private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword {
private final IInArchive archive;
private final Map<Integer, Integer> indexToPos;
private final List<Integer> fileIndices;
private final List<File> outputFiles;
private final List<Long> fileSizes;
private final ProgressTracker progress;
private final boolean encrypted;
private final String password;
private final File[] currentOutput;
private final FileOutputStream[] currentStream;
private final boolean[] currentSuccess;
private final long[] currentRemaining;
private final int[] currentPos;
private final Throwable[] firstError;
BulkExtractCallback(IInArchive archive, Map<Integer, Integer> indexToPos,
List<Integer> fileIndices, List<File> outputFiles, List<Long> fileSizes,
ProgressTracker progress, boolean encrypted, String password,
File[] currentOutput, FileOutputStream[] currentStream,
boolean[] currentSuccess, long[] currentRemaining, int[] currentPos,
Throwable[] firstError) {
this.archive = archive;
this.indexToPos = indexToPos;
this.fileIndices = fileIndices;
this.outputFiles = outputFiles;
this.fileSizes = fileSizes;
this.progress = progress;
this.encrypted = encrypted;
this.password = password;
this.currentOutput = currentOutput;
this.currentStream = currentStream;
this.currentSuccess = currentSuccess;
this.currentRemaining = currentRemaining;
this.currentPos = currentPos;
this.firstError = firstError;
}
@Override
public String cryptoGetTextPassword() {
return password;
}
@Override
public void setTotal(long total) {
// 7z reports total compressed bytes; we track uncompressed via ProgressTracker
}
@Override
public void setCompleted(long complete) {
// Not used we track per-write progress
}
@Override
public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException {
closeCurrentStream();
Integer pos = indexToPos.get(index);
if (pos == null) {
return null;
}
currentPos[0] = pos;
currentOutput[0] = outputFiles.get(pos);
currentSuccess[0] = false;
currentRemaining[0] = fileSizes.get(pos);
if (extractAskMode != ExtractAskMode.EXTRACT) {
currentOutput[0] = null;
return null;
}
if (currentOutput[0] == null) {
progress.advance(currentRemaining[0]);
return null;
}
try {
ensureDirectory(currentOutput[0].getParentFile());
rejectSymlink(currentOutput[0]);
currentStream[0] = new FileOutputStream(currentOutput[0]);
} catch (IOException error) {
throw new SevenZipException("Fehler beim Erstellen: " + error.getMessage(), error);
}
return new ISequentialOutStream() {
@Override
public int write(byte[] data) throws SevenZipException {
if (data == null || data.length == 0) {
return 0;
}
try {
currentStream[0].write(data);
} catch (IOException error) {
throw new SevenZipException("Fehler beim Schreiben: " + error.getMessage(), error);
}
long accounted = Math.min(currentRemaining[0], (long) data.length);
currentRemaining[0] -= accounted;
progress.advance(accounted);
return data.length;
}
};
}
@Override
public void prepareOperation(ExtractAskMode extractAskMode) {
// no-op
}
@Override
public void setOperationResult(ExtractOperationResult result) throws SevenZipException {
if (currentRemaining[0] > 0) {
progress.advance(currentRemaining[0]);
currentRemaining[0] = 0;
}
if (result == ExtractOperationResult.OK) {
currentSuccess[0] = true;
closeCurrentStream();
if (currentPos[0] >= 0 && currentOutput[0] != null) {
try {
int archiveIndex = fileIndices.get(currentPos[0]);
java.util.Date modified = (java.util.Date) archive.getProperty(archiveIndex, PropID.LAST_MODIFICATION_TIME);
if (modified != null) {
currentOutput[0].setLastModified(modified.getTime());
}
} catch (Throwable ignored) {
// best effort
}
}
} else {
closeCurrentStream();
if (currentOutput[0] != null && currentOutput[0].exists()) {
try {
currentOutput[0].delete();
} catch (Throwable ignored) {
}
}
if (firstError[0] == null) {
if (isPasswordFailure(result, encrypted)) {
firstError[0] = new WrongPasswordException(new IOException("Falsches Passwort"));
} else {
firstError[0] = new IOException("7z-Fehler: " + result.name());
}
}
}
}
private void closeCurrentStream() {
if (currentStream[0] != null) {
try {
currentStream[0].close();
} catch (Throwable ignored) {
}
currentStream[0] = null;
}
if (!currentSuccess[0] && currentOutput[0] != null && currentOutput[0].exists()) {
try {
currentOutput[0].delete();
} catch (Throwable ignored) {
}
}
}
}
private static final class WrongPasswordException extends Exception {
private static final long serialVersionUID = 1L;
@ -828,12 +1196,11 @@ public final class JBindExtractorMain {
if (filename == null || filename.trim().length() == 0) {
return null;
}
File direct = new File(filename);
if (direct.isAbsolute() && direct.exists()) {
return direct;
}
// Always resolve relative to the archive's parent directory.
// Never accept absolute paths to prevent path traversal.
String baseName = new File(filename).getName();
if (archiveDir != null) {
File relative = new File(archiveDir, filename);
File relative = new File(archiveDir, baseName);
if (relative.exists()) {
return relative;
}
@ -843,13 +1210,13 @@ public final class JBindExtractorMain {
if (!sibling.isFile()) {
continue;
}
if (sibling.getName().equalsIgnoreCase(filename)) {
if (sibling.getName().equalsIgnoreCase(baseName)) {
return sibling;
}
}
}
}
return direct.exists() ? direct : null;
return null;
}
@Override

View File

@ -2,8 +2,17 @@ const path = require("path");
const { rcedit } = require("rcedit");
module.exports = async function afterPack(context) {
const exePath = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.exe`);
const productFilename = context.packager?.appInfo?.productFilename;
if (!productFilename) {
console.warn(" • rcedit: skipped — productFilename not available");
return;
}
const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
console.log(` • rcedit: patching icon → ${exePath}`);
try {
await rcedit(exePath, { icon: iconPath });
} catch (error) {
console.warn(` • rcedit: failed — ${String(error)}`);
}
};

View File

@ -31,6 +31,7 @@ async function main(): Promise<void> {
login: settings.megaLogin,
password: settings.megaPassword
}));
try {
const service = new DebridService(settings, {
megaWebUnrestrict: (link) => megaWeb.unrestrict(link)
});
@ -42,7 +43,9 @@ async function main(): Promise<void> {
console.log(`[FAIL] ${String(error)}`);
}
}
} finally {
megaWeb.dispose();
}
}
void main();
main().catch(e => { console.error(e); process.exit(1); });

View File

@ -16,8 +16,8 @@ function sleep(ms) {
}
function cookieFrom(headers) {
const raw = headers.get("set-cookie") || "";
return raw.split(",").map((x) => x.split(";")[0].trim()).filter(Boolean).join("; ");
const cookies = headers.getSetCookie();
return cookies.map((x) => x.split(";")[0].trim()).filter(Boolean).join("; ");
}
function parseDebridCodes(html) {
@ -47,6 +47,9 @@ async function resolveCode(cookie, code) {
});
const text = (await res.text()).trim();
if (text === "reload") {
if (attempt % 5 === 0) {
console.log(` [retry] code=${code} attempt=${attempt}/50 (waiting for server)`);
}
await sleep(800);
continue;
}
@ -98,7 +101,13 @@ async function main() {
redirect: "manual"
});
if (loginRes.status >= 400) {
throw new Error(`Login failed with HTTP ${loginRes.status}`);
}
const cookie = cookieFrom(loginRes.headers);
if (!cookie) {
throw new Error("Login returned no session cookie");
}
console.log("login", loginRes.status, loginRes.headers.get("location") || "");
const debridRes = await fetch("https://www.mega-debrid.eu/index.php?form=debrid", {
@ -136,4 +145,4 @@ async function main() {
}
}
await main();
await main().catch((e) => { console.error(e); process.exit(1); });

View File

@ -66,6 +66,8 @@ async function callRealDebrid(link) {
};
}
// megaCookie is intentionally cached at module scope so that multiple
// callMegaDebrid() invocations reuse the same session cookie.
async function callMegaDebrid(link) {
if (!megaCookie) {
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {
@ -77,13 +79,15 @@ async function callMegaDebrid(link) {
body: new URLSearchParams({ login: megaLogin, password: megaPassword, remember: "on" }),
redirect: "manual"
});
megaCookie = (loginRes.headers.get("set-cookie") || "")
.split(",")
if (loginRes.status >= 400) {
return { ok: false, error: `Mega-Web login failed with HTTP ${loginRes.status}` };
}
megaCookie = loginRes.headers.getSetCookie()
.map((chunk) => chunk.split(";")[0].trim())
.filter(Boolean)
.join("; ");
if (!megaCookie) {
return { ok: false, error: "Mega-Web login failed" };
return { ok: false, error: "Mega-Web login returned no session cookie" };
}
}
@ -290,4 +294,4 @@ async function main() {
}
}
await main();
await main().catch((e) => { console.error(e); process.exit(1); });

View File

@ -37,7 +37,8 @@ function runWithInput(command, args, input) {
cwd: process.cwd(),
encoding: "utf8",
input,
stdio: ["pipe", "pipe", "pipe"]
stdio: ["pipe", "pipe", "pipe"],
timeout: 10000
});
if (result.status !== 0) {
const stderr = String(result.stderr || "").trim();
@ -95,15 +96,17 @@ function getGiteaRepo() {
const preferredBase = normalizeBaseUrl(process.env.GITEA_BASE_URL || process.env.FORGEJO_BASE_URL || "https://git.24-music.de");
const preferredProtocol = preferredBase ? new URL(preferredBase).protocol : "https:";
for (const remote of remotes) {
try {
const remoteUrl = runCapture("git", ["remote", "get-url", remote]);
const parsed = parseRemoteUrl(remoteUrl);
const remoteBase = `https://${parsed.host}`.toLowerCase();
if (preferredBase && remoteBase !== preferredBase.toLowerCase()) {
if (preferredBase && remoteBase !== preferredBase.toLowerCase().replace(/^http:/, "https:")) {
continue;
}
return { remote, ...parsed, baseUrl: `https://${parsed.host}` };
return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` };
} catch {
// try next remote
}
@ -179,7 +182,8 @@ function updatePackageVersion(rootDir, version) {
const packagePath = path.join(rootDir, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
if (String(packageJson.version || "") === version) {
throw new Error(`package.json is already at version ${version}`);
process.stdout.write(`package.json is already at version ${version}, skipping update.\n`);
return;
}
packageJson.version = version;
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
@ -257,9 +261,31 @@ async function createOrGetRelease(baseApi, tag, authHeader, notes) {
async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, files) {
for (const fileName of files) {
const filePath = path.join(releaseDir, fileName);
const fileData = fs.readFileSync(filePath);
const fileSize = fs.statSync(filePath).size;
const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`;
const response = await apiRequest("POST", uploadUrl, authHeader, fileData, "application/octet-stream");
// Stream large files instead of loading them entirely into memory
const fileStream = fs.createReadStream(filePath);
const response = await fetch(uploadUrl, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: authHeader,
"Content-Type": "application/octet-stream",
"Content-Length": String(fileSize)
},
body: fileStream,
duplex: "half"
});
const text = await response.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (response.ok) {
process.stdout.write(`Uploaded: ${fileName}\n`);
continue;
@ -268,7 +294,7 @@ async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, f
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
continue;
}
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(response.body)}`);
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(parsed)}`);
}
}
@ -290,17 +316,18 @@ async function main() {
ensureNoTrackedChanges();
ensureTagMissing(tag);
if (args.dryRun) {
process.stdout.write(`Dry run: would release ${tag}. No changes made.\n`);
return;
}
updatePackageVersion(rootDir, version);
process.stdout.write(`Building release artifacts for ${tag}...\n`);
run(NPM_EXECUTABLE, ["run", "release:win"]);
const assets = ensureAssetsExist(rootDir, version);
if (args.dryRun) {
process.stdout.write(`Dry run complete. Assets exist for ${tag}.\n`);
return;
}
run("git", ["add", "package.json"]);
run("git", ["commit", "-m", `Release ${tag}`]);
run("git", ["push", repo.remote, "main"]);

View File

@ -5,6 +5,7 @@ import {
AppSettings,
DuplicatePolicy,
HistoryEntry,
PackagePriority,
ParsedPackageInput,
SessionStats,
StartConflictEntry,
@ -285,7 +286,7 @@ export class AppController {
public exportBackup(): string {
const settings = { ...this.settings };
const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaPassword", "bestToken", "allDebridToken", "ddownloadPassword"];
const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "ddownloadLogin", "ddownloadPassword"];
for (const key of SENSITIVE_KEYS) {
const val = settings[key];
if (typeof val === "string" && val.length > 0) {
@ -307,7 +308,7 @@ export class AppController {
return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" };
}
const importedSettings = parsed.settings as AppSettings;
const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaPassword", "bestToken", "allDebridToken", "ddownloadPassword"];
const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "ddownloadLogin", "ddownloadPassword"];
for (const key of SENSITIVE_KEYS) {
const val = (importedSettings as Record<string, unknown>)[key];
if (typeof val === "string" && val.startsWith("***")) {
@ -360,8 +361,8 @@ export class AppController {
clearHistory(this.storagePaths);
}
public setPackagePriority(packageId: string, priority: string): void {
this.manager.setPackagePriority(packageId, priority as any);
public setPackagePriority(packageId: string, priority: PackagePriority): void {
this.manager.setPackagePriority(packageId, priority);
}
public skipItems(itemIds: string[]): void {

View File

@ -164,7 +164,7 @@ async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]>
const dlcData = content.slice(0, -88);
const rcUrl = DLC_SERVICE_URL.replace("{KEY}", encodeURIComponent(dlcKey));
const rcResponse = await fetch(rcUrl, { method: "GET" });
const rcResponse = await fetch(rcUrl, { method: "GET", signal: AbortSignal.timeout(30000) });
if (!rcResponse.ok) {
return [];
}
@ -217,7 +217,8 @@ async function tryDcryptUpload(fileContent: Buffer, fileName: string): Promise<s
const response = await fetch(DCRYPT_UPLOAD_URL, {
method: "POST",
body: form
body: form,
signal: AbortSignal.timeout(30000)
});
if (response.status === 413) {
return null;
@ -235,7 +236,8 @@ async function tryDcryptPaste(fileContent: Buffer): Promise<string[] | null> {
const response = await fetch(DCRYPT_PASTE_URL, {
method: "POST",
body: form
body: form,
signal: AbortSignal.timeout(30000)
});
if (response.status === 413) {
return null;

View File

@ -1053,7 +1053,8 @@ class DdownloadClient {
fileName: filenameFromUrl(directUrl) || filenameFromUrl(link),
directUrl,
fileSize: null,
retriesUsed: attempt - 1
retriesUsed: attempt - 1,
skipTlsVerify: true
};
}
}
@ -1102,7 +1103,8 @@ class DdownloadClient {
fileName: fileName || filenameFromUrl(directUrl),
directUrl,
fileSize: null,
retriesUsed: attempt - 1
retriesUsed: attempt - 1,
skipTlsVerify: true
};
}
}
@ -1115,7 +1117,8 @@ class DdownloadClient {
fileName,
directUrl: directMatch[0],
fileSize: null,
retriesUsed: attempt - 1
retriesUsed: attempt - 1,
skipTlsVerify: true
};
}
@ -1151,6 +1154,9 @@ export class DebridService {
private options: DebridServiceOptions;
private cachedDdownloadClient: DdownloadClient | null = null;
private cachedDdownloadKey = "";
public constructor(settings: AppSettings, options: DebridServiceOptions = {}) {
this.settings = cloneSettings(settings);
this.options = options;
@ -1160,6 +1166,16 @@ export class DebridService {
this.settings = cloneSettings(next);
}
private getDdownloadClient(login: string, password: string): DdownloadClient {
const key = `${login}\0${password}`;
if (this.cachedDdownloadClient && this.cachedDdownloadKey === key) {
return this.cachedDdownloadClient;
}
this.cachedDdownloadClient = new DdownloadClient(login, password);
this.cachedDdownloadKey = key;
return this.cachedDdownloadClient;
}
public async resolveFilenames(
links: string[],
onResolved?: (link: string, fileName: string) => void,
@ -1335,7 +1351,7 @@ export class DebridService {
return new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal);
}
if (provider === "ddownload") {
return new DdownloadClient(settings.ddownloadLogin, settings.ddownloadPassword).unrestrictLink(link, signal);
return this.getDdownloadClient(settings.ddownloadLogin, settings.ddownloadPassword).unrestrictLink(link, signal);
}
return new BestDebridClient(settings.bestToken).unrestrictLink(link, signal);
}

View File

@ -261,7 +261,7 @@ export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
const port = getPort(baseDir);
server = http.createServer(handleRequest);
server.listen(port, "0.0.0.0", () => {
server.listen(port, "127.0.0.1", () => {
logger.info(`Debug-Server gestartet auf Port ${port}`);
});
server.on("error", (err) => {

View File

@ -20,9 +20,26 @@ import {
UiSnapshot
} from "../shared/types";
import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS } from "./constants";
import { cleanupCancelledPackageArtifactsAsync } from "./cleanup";
// Reference counter for NODE_TLS_REJECT_UNAUTHORIZED to avoid race conditions
// when multiple parallel downloads need TLS verification disabled (e.g. DDownload).
let tlsSkipRefCount = 0;
function acquireTlsSkip(): void {
tlsSkipRefCount += 1;
if (tlsSkipRefCount === 1) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
}
}
function releaseTlsSkip(): void {
tlsSkipRefCount -= 1;
if (tlsSkipRefCount <= 0) {
tlsSkipRefCount = 0;
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
}
}
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { DebridService, MegaWebUnrestrictor, checkRapidgatorOnline } from "./debrid";
import { clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates } from "./extractor";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree } from "./extractor";
import { validateFileAgainstManifest } from "./integrity";
import { logger } from "./logger";
import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage";
@ -291,6 +308,9 @@ function providerLabel(provider: DownloadItem["provider"]): string {
if (provider === "alldebrid") {
return "AllDebrid";
}
if (provider === "ddownload") {
return "DDownload";
}
return "Debrid";
}
@ -731,60 +751,86 @@ export function buildAutoRenameBaseNameFromFoldersWithOptions(
return null;
}
function resolveArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] {
export function resolveArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] {
const entryLower = archiveName.toLowerCase();
// Helper: get item basename (try targetPath first, then fileName)
const itemBaseName = (item: DownloadItem): string =>
path.basename(item.targetPath || item.fileName || "");
// Try pattern-based matching first (for multipart archives)
let pattern: RegExp | null = null;
const multipartMatch = entryLower.match(/^(.*)\.part0*1\.rar$/);
if (multipartMatch) {
const prefix = multipartMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i");
return items.filter((item) => {
const name = path.basename(item.targetPath || item.fileName || "");
return pattern.test(name);
});
pattern = new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i");
}
if (!pattern) {
const rarMatch = entryLower.match(/^(.*)\.rar$/);
if (rarMatch) {
const stem = rarMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`^${stem}\\.r(ar|\\d{2,3})$`, "i");
return items.filter((item) => {
const name = path.basename(item.targetPath || item.fileName || "");
return pattern.test(name);
});
pattern = new RegExp(`^${stem}\\.r(ar|\\d{2,3})$`, "i");
}
// Split ZIP (e.g., movie.zip.001, movie.zip.002)
}
if (!pattern) {
const zipSplitMatch = entryLower.match(/^(.*)\.zip\.001$/);
if (zipSplitMatch) {
const stem = zipSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`^${stem}\\.zip(\\.\\d+)?$`, "i");
return items.filter((item) => {
const name = path.basename(item.targetPath || item.fileName || "");
return pattern.test(name);
});
pattern = new RegExp(`^${stem}\\.zip(\\.\\d+)?$`, "i");
}
// Split 7z (e.g., movie.7z.001, movie.7z.002)
}
if (!pattern) {
const sevenSplitMatch = entryLower.match(/^(.*)\.7z\.001$/);
if (sevenSplitMatch) {
const stem = sevenSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`^${stem}\\.7z(\\.\\d+)?$`, "i");
return items.filter((item) => {
const name = path.basename(item.targetPath || item.fileName || "");
return pattern.test(name);
});
pattern = new RegExp(`^${stem}\\.7z(\\.\\d+)?$`, "i");
}
// Generic .NNN splits (e.g., movie.001, movie.002)
}
if (!pattern && /^(.*)\.001$/.test(entryLower) && !/\.(zip|7z)\.001$/.test(entryLower)) {
const genericSplitMatch = entryLower.match(/^(.*)\.001$/);
if (genericSplitMatch && !/\.(zip|7z)\.001$/.test(entryLower)) {
if (genericSplitMatch) {
const stem = genericSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`^${stem}\\.\\d{3}$`, "i");
return items.filter((item) => {
const name = path.basename(item.targetPath || item.fileName || "");
return pattern.test(name);
});
pattern = new RegExp(`^${stem}\\.\\d{3}$`, "i");
}
return items.filter((item) => {
const name = path.basename(item.targetPath || item.fileName || "").toLowerCase();
return name === entryLower;
}
// Attempt 1: Pattern match (handles multipart archives)
if (pattern) {
const matched = items.filter((item) => pattern!.test(itemBaseName(item)));
if (matched.length > 0) return matched;
}
// Attempt 2: Exact filename match (case-insensitive)
const exactMatch = items.filter((item) => itemBaseName(item).toLowerCase() === entryLower);
if (exactMatch.length > 0) return exactMatch;
// Attempt 3: Stem-based fuzzy match — strip archive extensions and compare stems.
// Handles cases where debrid services modify filenames slightly.
const archiveStem = entryLower
.replace(/\.part\d+\.rar$/i, "")
.replace(/\.r\d{2,3}$/i, "")
.replace(/\.rar$/i, "")
.replace(/\.(zip|7z)\.\d{3}$/i, "")
.replace(/\.\d{3}$/i, "")
.replace(/\.(zip|7z)$/i, "");
if (archiveStem.length > 3) {
const stemMatch = items.filter((item) => {
const name = itemBaseName(item).toLowerCase();
return name.startsWith(archiveStem) && /\.(rar|r\d{2,3}|zip|7z|\d{3})$/i.test(name);
});
if (stemMatch.length > 0) return stemMatch;
}
// Attempt 4: If only one item in the list and one archive — return it as a best-effort match.
// This handles single-file packages where the filename may have been modified.
if (items.length === 1) {
const singleName = itemBaseName(items[0]).toLowerCase();
if (/\.(rar|zip|7z|\d{3})$/i.test(singleName)) {
return items;
}
}
return [];
}
function retryDelayWithJitter(attempt: number, baseMs: number): number {
@ -1364,6 +1410,10 @@ export class DownloadManager extends EventEmitter {
addedPackages += 1;
}
if (addedPackages > 0 || addedLinks > 0) {
const pkgNames = packages.filter((p) => p.links.length > 0).map((p) => p.name).join(", ");
logger.info(`Pakete hinzugefügt: ${addedPackages} Paket(e), ${addedLinks} Link(s) [${pkgNames}]`);
}
this.persistSoon();
this.emitState();
if (unresolvedByLink.size > 0) {
@ -3209,11 +3259,11 @@ export class DownloadManager extends EventEmitter {
for (const item of Object.values(this.session.items)) {
if (item.status !== "completed") continue;
const fs = item.fullStatus || "";
const fullSt = item.fullStatus || "";
// Only relabel items with active extraction status (e.g. "Entpacken 45%", "Passwort prüfen")
// Skip items that were merely waiting ("Entpacken - Ausstehend", "Entpacken - Warten auf Parts")
// as they were never actively extracting and "abgebrochen" would be misleading.
if (/^Entpacken\b/i.test(fs) && !/Ausstehend/i.test(fs) && !/Warten/i.test(fs) && !isExtractedLabel(fs)) {
if (/^Entpacken\b/i.test(fullSt) && !/Ausstehend/i.test(fullSt) && !/Warten/i.test(fullSt) && !isExtractedLabel(fullSt)) {
item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
item.updatedAt = nowMs();
const pkg = this.session.packages[item.packageId];
@ -3302,7 +3352,7 @@ export class DownloadManager extends EventEmitter {
this.session.reconnectReason = "";
for (const item of Object.values(this.session.items)) {
if (item.provider !== "realdebrid" && item.provider !== "megadebrid" && item.provider !== "bestdebrid" && item.provider !== "alldebrid") {
if (item.provider !== "realdebrid" && item.provider !== "megadebrid" && item.provider !== "bestdebrid" && item.provider !== "alldebrid" && item.provider !== "ddownload") {
item.provider = null;
}
if (item.status === "cancelled" && item.fullStatus === "Gestoppt") {
@ -3550,14 +3600,16 @@ export class DownloadManager extends EventEmitter {
this.emit("state", this.getSnapshot());
return;
}
// Too soon — schedule deferred forced emit
if (!this.stateEmitTimer) {
// Too soon — replace any pending timer with a shorter forced-emit timer
if (this.stateEmitTimer) {
clearTimeout(this.stateEmitTimer);
this.stateEmitTimer = null;
}
this.stateEmitTimer = setTimeout(() => {
this.stateEmitTimer = null;
this.lastStateEmitAt = nowMs();
this.emit("state", this.getSnapshot());
}, MIN_FORCE_GAP_MS - sinceLastEmit);
}
return;
}
if (this.stateEmitTimer) {
@ -3795,18 +3847,26 @@ export class DownloadManager extends EventEmitter {
this.packagePostProcessAbortControllers.set(packageId, abortController);
const task = (async () => {
const slotWaitStart = nowMs();
await this.acquirePostProcessSlot(packageId);
const slotWaitMs = nowMs() - slotWaitStart;
if (slotWaitMs > 100) {
logger.info(`Post-Process Slot erhalten nach ${(slotWaitMs / 1000).toFixed(1)}s Wartezeit: pkg=${packageId.slice(0, 8)}`);
}
try {
// Loop while requeue requests arrive — keep the slot so the same
// package can immediately re-run hybrid extraction without waiting
// behind other packages that may be queued for the slot.
let round = 0;
do {
round += 1;
const hadRequeue = this.hybridExtractRequeue.has(packageId);
this.hybridExtractRequeue.delete(packageId);
const roundStart = nowMs();
try {
await this.handlePackagePostProcessing(packageId, abortController.signal);
} catch (error) {
logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`);
}
const roundMs = nowMs() - roundStart;
logger.info(`Post-Process Runde ${round} fertig in ${(roundMs / 1000).toFixed(1)}s (requeue=${hadRequeue}, nextRequeue=${this.hybridExtractRequeue.has(packageId)}): pkg=${packageId.slice(0, 8)}`);
this.persistSoon();
this.emitState();
} while (this.hybridExtractRequeue.has(packageId));
@ -4706,6 +4766,7 @@ export class DownloadManager extends EventEmitter {
item.fullStatus = `Starte... (${unrestricted.providerLabel})`;
item.updatedAt = nowMs();
this.emitState();
logger.info(`Download Start: ${item.fileName} (${humanSize(unrestricted.fileSize || 0)}) via ${unrestricted.providerLabel}, pkg=${pkg.name}`);
const maxAttempts = maxItemAttempts;
let done = false;
@ -4717,7 +4778,7 @@ export class DownloadManager extends EventEmitter {
item.updatedAt = nowMs();
this.emitState();
}
const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes);
const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes, unrestricted.skipTlsVerify);
active.resumable = result.resumable;
if (!active.resumable && !active.nonResumableCounted) {
active.nonResumableCounted = true;
@ -4814,6 +4875,7 @@ export class DownloadManager extends EventEmitter {
item.updatedAt = nowMs();
pkg.updatedAt = nowMs();
this.recordRunOutcome(item.id, "completed");
logger.info(`Download fertig: ${item.fileName} (${humanSize(item.downloadedBytes)}), pkg=${pkg.name}`);
if (this.session.running && !active.abortController.signal.aborted) {
void this.runPackagePostProcessing(pkg.id).catch((err) => {
@ -5102,7 +5164,8 @@ export class DownloadManager extends EventEmitter {
active: ActiveTask,
directUrl: string,
targetPath: string,
knownTotal: number | null
knownTotal: number | null,
skipTlsVerify?: boolean
): Promise<{ resumable: boolean }> {
const item = this.session.items[active.itemId];
if (!item) {
@ -5148,6 +5211,7 @@ export class DownloadManager extends EventEmitter {
const connectTimeoutMs = getDownloadConnectTimeoutMs();
let connectTimer: NodeJS.Timeout | null = null;
const connectAbortController = new AbortController();
if (skipTlsVerify) acquireTlsSkip();
try {
if (connectTimeoutMs > 0) {
connectTimer = setTimeout(() => {
@ -5173,6 +5237,7 @@ export class DownloadManager extends EventEmitter {
}
throw error;
} finally {
if (skipTlsVerify) releaseTlsSkip();
if (connectTimer) {
clearTimeout(connectTimer);
}
@ -6233,8 +6298,13 @@ export class DownloadManager extends EventEmitter {
return false;
}
private async runHybridExtraction(packageId: string, pkg: PackageEntry, items: DownloadItem[], signal?: AbortSignal): Promise<void> {
private async runHybridExtraction(packageId: string, pkg: PackageEntry, items: DownloadItem[], signal?: AbortSignal): Promise<number> {
const findReadyStart = nowMs();
const readyArchives = await this.findReadyArchiveSets(pkg);
const findReadyMs = nowMs() - findReadyStart;
if (findReadyMs > 200) {
logger.info(`findReadyArchiveSets dauerte ${(findReadyMs / 1000).toFixed(1)}s: pkg=${pkg.name}, found=${readyArchives.size}`);
}
if (readyArchives.size === 0) {
logger.info(`Hybrid-Extract: pkg=${pkg.name}, keine fertigen Archive-Sets`);
// Relabel completed items that are part of incomplete multi-part archives
@ -6250,7 +6320,7 @@ export class DownloadManager extends EventEmitter {
}
this.emitState();
}
return;
return 0;
}
logger.info(`Hybrid-Extract Start: pkg=${pkg.name}, readyArchives=${readyArchives.size}`);
@ -6290,7 +6360,7 @@ export class DownloadManager extends EventEmitter {
// a previous hybrid round, there is nothing new to extract.
if (hybridItems.length > 0 && hybridItems.every((item) => isExtractedLabel(item.fullStatus))) {
logger.info(`Hybrid-Extract: pkg=${pkg.name}, alle ${hybridItems.length} Items bereits entpackt, überspringe`);
return;
return 0;
}
// Filter out archives whose items are ALL already extracted so we don't
@ -6313,7 +6383,7 @@ export class DownloadManager extends EventEmitter {
}
if (readyArchives.size === 0) {
logger.info(`Hybrid-Extract: pkg=${pkg.name}, alle fertigen Archive bereits entpackt`);
return;
return 0;
}
// Resolve archive items dynamically from ALL package items (not just
@ -6322,9 +6392,9 @@ export class DownloadManager extends EventEmitter {
const resolveArchiveItems = (archiveName: string): DownloadItem[] =>
resolveArchiveItemsFromList(archiveName, items);
// Track multiple active archives for parallel hybrid extraction
const activeHybridArchiveMap = new Map<string, DownloadItem[]>();
const hybridArchiveStartTimes = new Map<string, number>();
// Track archives for parallel hybrid extraction progress
const hybridResolvedItems = new Map<string, DownloadItem[]>();
const hybridStartTimes = new Map<string, number>();
let hybridLastEmitAt = 0;
// Mark items based on whether their archive is actually ready for extraction.
@ -6363,29 +6433,47 @@ export class DownloadManager extends EventEmitter {
packageId,
hybridMode: true,
maxParallel: this.settings.maxParallelExtract || 2,
extractCpuPriority: this.settings.extractCpuPriority,
extractCpuPriority: "high",
onProgress: (progress) => {
if (progress.phase === "preparing") {
pkg.postProcessLabel = progress.archiveName || "Vorbereiten...";
this.emitState();
return;
}
if (progress.phase === "done") {
// Do NOT mark remaining archives as "Done" here — some may have
// failed. The post-extraction code (result.failed check) will
// assign the correct label. Only clear the tracking maps.
activeHybridArchiveMap.clear();
hybridArchiveStartTimes.clear();
hybridResolvedItems.clear();
hybridStartTimes.clear();
return;
}
if (progress.archiveName) {
// Resolve items for this archive if not yet tracked
if (!activeHybridArchiveMap.has(progress.archiveName)) {
activeHybridArchiveMap.set(progress.archiveName, resolveArchiveItems(progress.archiveName));
hybridArchiveStartTimes.set(progress.archiveName, nowMs());
if (!hybridResolvedItems.has(progress.archiveName)) {
const resolved = resolveArchiveItems(progress.archiveName);
hybridResolvedItems.set(progress.archiveName, resolved);
hybridStartTimes.set(progress.archiveName, nowMs());
if (resolved.length === 0) {
logger.warn(`resolveArchiveItems (hybrid): KEINE Items gefunden für archiveName="${progress.archiveName}", items.length=${items.length}, itemNames=[${items.map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`);
} else {
logger.info(`resolveArchiveItems (hybrid): ${resolved.length} Items für archiveName="${progress.archiveName}"`);
const initLabel = `Entpacken 0% · ${progress.archiveName}`;
const initAt = nowMs();
for (const entry of resolved) {
if (!isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = initLabel;
entry.updatedAt = initAt;
}
const archItems = activeHybridArchiveMap.get(progress.archiveName)!;
}
hybridLastEmitAt = initAt;
this.emitState(true);
}
}
const archItems = hybridResolvedItems.get(progress.archiveName) || [];
// If archive is at 100%, mark its items as done and remove from active
if (Number(progress.archivePercent ?? 0) >= 100) {
const doneAt = nowMs();
const startedAt = hybridArchiveStartTimes.get(progress.archiveName) || doneAt;
const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt;
const doneLabel = formatExtractDone(doneAt - startedAt);
for (const entry of archItems) {
if (!isExtractedLabel(entry.fullStatus)) {
@ -6393,8 +6481,14 @@ export class DownloadManager extends EventEmitter {
entry.updatedAt = doneAt;
}
}
activeHybridArchiveMap.delete(progress.archiveName);
hybridArchiveStartTimes.delete(progress.archiveName);
hybridResolvedItems.delete(progress.archiveName);
hybridStartTimes.delete(progress.archiveName);
// Show transitional label while next archive initializes
const done = progress.current + 1;
if (done < progress.total) {
pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Naechstes Archiv...`;
this.emitState();
}
} else {
// Update this archive's items with per-archive progress
const archiveLabel = ` · ${progress.archiveName}`;
@ -6421,6 +6515,18 @@ export class DownloadManager extends EventEmitter {
}
}
// Update package-level label with overall extraction progress
const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive));
if (progress.passwordFound) {
pkg.postProcessLabel = `Passwort gefunden · ${progress.archiveName || ""}`;
} else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) {
const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100);
pkg.postProcessLabel = `Passwort knacken: ${pwPct}%`;
} else {
pkg.postProcessLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})`;
}
// Throttled emit — also promote "Warten auf Parts" items that
// completed downloading in the meantime to "Ausstehend".
const now = nowMs();
@ -6439,9 +6545,20 @@ export class DownloadManager extends EventEmitter {
logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`);
if (result.extracted > 0) {
pkg.postProcessLabel = "Renaming...";
this.emitState();
// Fire-and-forget: rename then collect MKVs in background so the
// slot is not blocked and the next archive set can start immediately.
void (async () => {
try {
await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg);
} catch (err) {
logger.warn(`Hybrid Auto-Rename Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`);
}
try {
await this.collectMkvFilesToLibrary(packageId, pkg);
} catch (err) {
logger.warn(`Hybrid MKV-Collection Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`);
}
})();
}
if (result.failed > 0) {
logger.warn(`Hybrid-Extract: ${result.failed} Archive fehlgeschlagen, wird beim finalen Durchlauf erneut versucht`);
@ -6468,6 +6585,7 @@ export class DownloadManager extends EventEmitter {
entry.updatedAt = updatedAt;
}
}
return result.extracted;
} catch (error) {
const errorText = String(error || "");
if (errorText.includes("aborted:extract")) {
@ -6480,7 +6598,7 @@ export class DownloadManager extends EventEmitter {
entry.updatedAt = abortAt;
}
}
return;
return 0;
}
logger.warn(`Hybrid-Extract Fehler: pkg=${pkg.name}, reason=${compactErrorText(error)}`);
const errorAt = nowMs();
@ -6492,9 +6610,11 @@ export class DownloadManager extends EventEmitter {
}
}
}
return 0;
}
private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise<void> {
const handleStart = nowMs();
const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled) {
return;
@ -6506,6 +6626,7 @@ export class DownloadManager extends EventEmitter {
// Recover items whose file exists on disk but status was never set to "completed".
// Only recover items in idle states (queued/paused), never active ones (downloading/validating).
const recoveryStart = nowMs();
for (const item of items) {
if (isFinishedStatus(item.status)) {
continue;
@ -6545,16 +6666,19 @@ export class DownloadManager extends EventEmitter {
}
}
const recoveryMs = nowMs() - recoveryStart;
const success = items.filter((item) => item.status === "completed").length;
const failed = items.filter((item) => item.status === "failed").length;
const cancelled = items.filter((item) => item.status === "cancelled").length;
logger.info(`Post-Processing Start: pkg=${pkg.name}, success=${success}, failed=${failed}, cancelled=${cancelled}, autoExtract=${this.settings.autoExtract}`);
const setupMs = nowMs() - handleStart;
logger.info(`Post-Processing Start: pkg=${pkg.name}, success=${success}, failed=${failed}, cancelled=${cancelled}, autoExtract=${this.settings.autoExtract}, setupMs=${setupMs}, recoveryMs=${recoveryMs}`);
const allDone = success + failed + cancelled >= items.length;
if (!allDone && this.settings.hybridExtract && this.settings.autoExtract && failed === 0 && success > 0) {
pkg.postProcessLabel = "Entpacken...";
await this.runHybridExtraction(packageId, pkg, items, signal);
pkg.postProcessLabel = "Entpacken vorbereiten...";
this.emitState();
const hybridExtracted = await this.runHybridExtraction(packageId, pkg, items, signal);
if (signal?.aborted) {
pkg.postProcessLabel = undefined;
pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "queued" : "paused";
@ -6570,6 +6694,12 @@ export class DownloadManager extends EventEmitter {
if (!this.session.packages[packageId]) {
return; // Package was fully cleaned up
}
// Self-requeue if we extracted something — more archive sets may have
// become ready while we were extracting (items that completed before
// this task started set the requeue flag once, which was already consumed).
if (hybridExtracted > 0) {
this.hybridExtractRequeue.add(packageId);
}
pkg.postProcessLabel = undefined;
pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued";
pkg.updatedAt = nowMs();
@ -6586,9 +6716,10 @@ export class DownloadManager extends EventEmitter {
const completedItems = items.filter((item) => item.status === "completed");
const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus));
let extractedCount = 0;
if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) {
pkg.postProcessLabel = "Entpacken...";
pkg.postProcessLabel = "Entpacken vorbereiten...";
pkg.status = "extracting";
this.emitState();
const extractionStartMs = nowMs();
@ -6597,12 +6728,13 @@ export class DownloadManager extends EventEmitter {
resolveArchiveItemsFromList(archiveName, completedItems);
let lastExtractEmitAt = 0;
const emitExtractStatus = (_text: string, force = false): void => {
const emitExtractStatus = (text: string, force = false): void => {
const now = nowMs();
if (!force && now - lastExtractEmitAt < EXTRACT_PROGRESS_EMIT_INTERVAL_MS) {
return;
}
lastExtractEmitAt = now;
pkg.postProcessLabel = text || "Entpacken...";
this.emitState();
};
@ -6642,9 +6774,9 @@ export class DownloadManager extends EventEmitter {
}
}, extractTimeoutMs);
try {
// Track multiple active archives for parallel extraction
const activeArchiveItemsMap = new Map<string, DownloadItem[]>();
const archiveStartTimes = new Map<string, number>();
// Track archives for parallel extraction progress
const fullResolvedItems = new Map<string, DownloadItem[]>();
const fullStartTimes = new Map<string, number>();
const result = await extractPackageArchives({
packageDir: pkg.outputDir,
@ -6656,31 +6788,51 @@ export class DownloadManager extends EventEmitter {
passwordList: this.settings.archivePasswordList,
signal: extractAbortController.signal,
packageId,
skipPostCleanup: true,
maxParallel: this.settings.maxParallelExtract || 2,
extractCpuPriority: this.settings.extractCpuPriority,
// All downloads finished — use NORMAL OS priority so extraction runs at
// full speed (matching manual 7-Zip/WinRAR speed).
extractCpuPriority: "high",
onProgress: (progress) => {
if (progress.phase === "preparing") {
pkg.postProcessLabel = progress.archiveName || "Vorbereiten...";
this.emitState();
return;
}
if (progress.phase === "done") {
// Do NOT mark remaining archives as "Done" here — some may have
// failed. The post-extraction code (result.failed check) will
// assign the correct label. Only clear the tracking maps.
activeArchiveItemsMap.clear();
archiveStartTimes.clear();
fullResolvedItems.clear();
fullStartTimes.clear();
emitExtractStatus("Entpacken 100%", true);
return;
}
if (progress.archiveName) {
// Resolve items for this archive if not yet tracked
if (!activeArchiveItemsMap.has(progress.archiveName)) {
activeArchiveItemsMap.set(progress.archiveName, resolveArchiveItems(progress.archiveName));
archiveStartTimes.set(progress.archiveName, nowMs());
if (!fullResolvedItems.has(progress.archiveName)) {
const resolved = resolveArchiveItems(progress.archiveName);
fullResolvedItems.set(progress.archiveName, resolved);
fullStartTimes.set(progress.archiveName, nowMs());
if (resolved.length === 0) {
logger.warn(`resolveArchiveItems (full): KEINE Items für archiveName="${progress.archiveName}", completedItems=${completedItems.length}, names=[${completedItems.map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`);
} else {
logger.info(`resolveArchiveItems (full): ${resolved.length} Items für archiveName="${progress.archiveName}"`);
const initLabel = `Entpacken 0% · ${progress.archiveName}`;
const initAt = nowMs();
for (const entry of resolved) {
if (!isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = initLabel;
entry.updatedAt = initAt;
}
const archiveItems = activeArchiveItemsMap.get(progress.archiveName)!;
}
emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true);
}
}
const archiveItems = fullResolvedItems.get(progress.archiveName) || [];
// If archive is at 100%, mark its items as done and remove from active
if (Number(progress.archivePercent ?? 0) >= 100) {
const doneAt = nowMs();
const startedAt = archiveStartTimes.get(progress.archiveName) || doneAt;
const startedAt = fullStartTimes.get(progress.archiveName) || doneAt;
const doneLabel = formatExtractDone(doneAt - startedAt);
for (const entry of archiveItems) {
if (!isExtractedLabel(entry.fullStatus)) {
@ -6688,8 +6840,13 @@ export class DownloadManager extends EventEmitter {
entry.updatedAt = doneAt;
}
}
activeArchiveItemsMap.delete(progress.archiveName);
archiveStartTimes.delete(progress.archiveName);
fullResolvedItems.delete(progress.archiveName);
fullStartTimes.delete(progress.archiveName);
// Show transitional label while next archive initializes
const done = progress.current + 1;
if (done < progress.total) {
emitExtractStatus(`Entpacken (${done}/${progress.total}) - Naechstes Archiv...`, true);
}
} else {
// Update this archive's items with per-archive progress
const archiveTag = progress.archiveName ? ` · ${progress.archiveName}` : "";
@ -6736,13 +6893,10 @@ export class DownloadManager extends EventEmitter {
}
});
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
extractedCount = result.extracted;
// Auto-rename even when some archives failed — successfully extracted files still need renaming
if (result.extracted > 0) {
pkg.postProcessLabel = "Renaming...";
this.emitState();
await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg);
}
// Auto-rename wird in runDeferredPostExtraction ausgeführt (im Hintergrund),
// damit der Slot sofort freigegeben wird.
if (result.failed > 0) {
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
@ -6843,20 +6997,6 @@ export class DownloadManager extends EventEmitter {
this.recordPackageHistory(packageId, pkg, items);
}
if (this.settings.autoExtract && alreadyMarkedExtracted && failed === 0 && success > 0 && this.settings.cleanupMode !== "none") {
pkg.postProcessLabel = "Aufräumen...";
this.emitState();
const removedArchives = await this.cleanupRemainingArchiveArtifacts(pkg.outputDir);
if (removedArchives > 0) {
logger.info(`Hybrid-Post-Cleanup entfernte Archive: pkg=${pkg.name}, entfernt=${removedArchives}`);
}
}
if (success > 0 && (pkg.status === "completed" || pkg.status === "failed")) {
pkg.postProcessLabel = "Verschiebe MKVs...";
this.emitState();
await this.collectMkvFilesToLibrary(packageId, pkg);
}
if (this.runPackageIds.has(packageId)) {
if (pkg.status === "completed" || pkg.status === "failed") {
this.runCompletedPackages.add(packageId);
@ -6866,9 +7006,137 @@ export class DownloadManager extends EventEmitter {
}
pkg.postProcessLabel = undefined;
pkg.updatedAt = nowMs();
logger.info(`Post-Processing Ende: pkg=${pkg.name}, status=${pkg.status}`);
logger.info(`Post-Processing Ende: pkg=${pkg.name}, status=${pkg.status} (deferred work wird im Hintergrund ausgeführt)`);
// Deferred post-extraction: Rename, MKV-Sammlung, Cleanup laufen im Hintergrund,
// damit der Post-Process-Slot sofort freigegeben wird und das nächste Pack
// ohne 1015 Sekunden Pause entpacken kann.
void this.runDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount);
}
/**
* Runs slow post-extraction work (rename, MKV collection, cleanup) in the background
* so the post-process slot is released immediately and the next pack can start unpacking.
*/
private async runDeferredPostExtraction(
packageId: string,
pkg: PackageEntry,
success: number,
failed: number,
alreadyMarkedExtracted: boolean,
extractedCount: number
): Promise<void> {
try {
// ── Nested extraction: extract archives found inside the extracted output ──
if (extractedCount > 0 && failed === 0 && this.settings.autoExtract) {
const nestedBlacklist = /\.(iso|img|bin|dmg|vhd|vhdx|vmdk|wim)$/i;
const nestedCandidates = (await findArchiveCandidates(pkg.extractDir))
.filter((p) => !nestedBlacklist.test(p));
if (nestedCandidates.length > 0) {
pkg.postProcessLabel = "Nested Entpacken...";
this.emitState();
logger.info(`Deferred Nested-Extraction: ${nestedCandidates.length} Archive in ${pkg.extractDir}`);
const nestedResult = await extractPackageArchives({
packageDir: pkg.extractDir,
targetDir: pkg.extractDir,
cleanupMode: this.settings.cleanupMode,
conflictMode: this.settings.extractConflictMode,
removeLinks: false,
removeSamples: false,
passwordList: this.settings.archivePasswordList,
packageId,
onlyArchives: new Set(nestedCandidates.map((p) => process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p))),
maxParallel: this.settings.maxParallelExtract || 2,
extractCpuPriority: this.settings.extractCpuPriority,
});
extractedCount += nestedResult.extracted;
logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`);
}
}
// ── Auto-Rename ──
if (extractedCount > 0) {
pkg.postProcessLabel = "Renaming...";
this.emitState();
await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg);
}
// ── Archive cleanup (source archives in outputDir) ──
if (extractedCount > 0 && failed === 0 && this.settings.cleanupMode !== "none") {
pkg.postProcessLabel = "Aufräumen...";
this.emitState();
const sourceAndTargetEqual = path.resolve(pkg.outputDir).toLowerCase() === path.resolve(pkg.extractDir).toLowerCase();
if (!sourceAndTargetEqual) {
const candidates = await findArchiveCandidates(pkg.outputDir);
if (candidates.length > 0) {
const removed = await cleanupArchives(candidates, this.settings.cleanupMode);
if (removed > 0) {
logger.info(`Deferred Archive-Cleanup: pkg=${pkg.name}, entfernt=${removed}`);
}
}
}
}
// ── Hybrid archive cleanup (wenn bereits als extracted markiert) ──
if (this.settings.autoExtract && alreadyMarkedExtracted && failed === 0 && success > 0 && this.settings.cleanupMode !== "none") {
const removedArchives = await this.cleanupRemainingArchiveArtifacts(pkg.outputDir);
if (removedArchives > 0) {
logger.info(`Hybrid-Post-Cleanup entfernte Archive: pkg=${pkg.name}, entfernt=${removedArchives}`);
}
}
// ── Link/Sample artifact removal ──
if (extractedCount > 0 && failed === 0) {
if (this.settings.removeLinkFilesAfterExtract) {
const removedLinks = await removeDownloadLinkArtifacts(pkg.extractDir);
if (removedLinks > 0) {
logger.info(`Deferred Link-Cleanup: pkg=${pkg.name}, entfernt=${removedLinks}`);
}
}
if (this.settings.removeSamplesAfterExtract) {
const removedSamples = await removeSampleArtifacts(pkg.extractDir);
if (removedSamples.files > 0 || removedSamples.dirs > 0) {
logger.info(`Deferred Sample-Cleanup: pkg=${pkg.name}, files=${removedSamples.files}, dirs=${removedSamples.dirs}`);
}
}
}
// ── Empty directory tree removal ──
if (extractedCount > 0 && failed === 0 && this.settings.cleanupMode === "delete") {
if (!(await hasAnyFilesRecursive(pkg.outputDir))) {
const removedDirs = await removeEmptyDirectoryTree(pkg.outputDir);
if (removedDirs > 0) {
logger.info(`Deferred leere Download-Ordner entfernt: pkg=${pkg.name}, dirs=${removedDirs}`);
}
}
}
// ── Resume state cleanup ──
if (extractedCount > 0 && failed === 0) {
await clearExtractResumeState(pkg.outputDir, packageId);
}
// ── MKV collection ──
if (success > 0 && (pkg.status === "completed" || pkg.status === "failed")) {
pkg.postProcessLabel = "Verschiebe MKVs...";
this.emitState();
await this.collectMkvFilesToLibrary(packageId, pkg);
}
pkg.postProcessLabel = undefined;
pkg.updatedAt = nowMs();
this.persistSoon();
this.emitState();
this.applyPackageDoneCleanup(packageId);
} catch (error) {
logger.warn(`Deferred Post-Extraction Fehler: pkg=${pkg.name}, reason=${compactErrorText(error)}`);
} finally {
pkg.postProcessLabel = undefined;
pkg.updatedAt = nowMs();
this.persistSoon();
this.emitState();
}
}
private applyPackageDoneCleanup(packageId: string): void {

View File

@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawn, spawnSync } from "node:child_process";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import AdmZip from "adm-zip";
import { CleanupMode, ConflictMode } from "../shared/types";
import { logger } from "./logger";
@ -62,6 +62,26 @@ function removeSubstMapping(mapping: SubstMapping): void {
logger.info(`subst ${mapping.drive}: entfernt`);
}
export function cleanupStaleSubstDrives(): void {
if (process.platform !== "win32") return;
try {
const result = spawnSync("subst", [], { stdio: "pipe", timeout: 5000 });
const output = String(result.stdout || "");
for (const line of output.split("\n")) {
const match = line.match(/^([A-Z]):\\: => (.+)/i);
if (!match) continue;
const drive = match[1].toUpperCase();
const target = match[2].trim();
if (/\\rd-extract-|\\Real-Debrid-Downloader/i.test(target)) {
spawnSync("subst", [`${drive}:`, "/d"], { stdio: "pipe", timeout: 5000 });
logger.info(`Stale subst ${drive}: entfernt (${target})`);
}
}
} catch {
// ignore — subst cleanup is best-effort
}
}
let resolvedExtractorCommand: string | null = null;
let resolveFailureReason = "";
let resolveFailureAt = 0;
@ -99,7 +119,7 @@ export interface ExtractProgressUpdate {
archiveName: string;
archivePercent?: number;
elapsedMs?: number;
phase: "extracting" | "done";
phase: "extracting" | "done" | "preparing";
passwordAttempt?: number;
passwordTotal?: number;
passwordFound?: boolean;
@ -580,8 +600,8 @@ function extractCpuBudgetFromPriority(priority?: string): number {
function extractOsPriority(priority?: string): number {
switch (priority) {
case "high": return os.constants.priority.PRIORITY_BELOW_NORMAL;
default: return os.constants.priority.PRIORITY_LOW;
case "high": return os.constants.priority.PRIORITY_NORMAL;
default: return os.constants.priority.PRIORITY_BELOW_NORMAL;
}
}
@ -595,10 +615,15 @@ function extractCpuBudgetPercent(priority?: string): number {
function extractorThreadSwitch(hybridMode = false, priority?: string): string {
if (hybridMode) {
// 2 threads during hybrid extraction (download + extract simultaneously).
// JDownloader 2 uses in-process 7-Zip-JBinding which naturally limits throughput
// to ~16 MB/s write. 2 UnRAR threads produce similar controlled disk load.
return "-mt2";
// Use half the CPU budget during hybrid extraction to leave headroom for
// concurrent downloads. Falls back to at least 2 threads.
const envValue = Number(process.env.RD_EXTRACT_THREADS ?? NaN);
if (Number.isFinite(envValue) && envValue >= 1 && envValue <= 32) {
return `-mt${Math.floor(envValue)}`;
}
const cpuCount = Math.max(1, os.cpus().length || 1);
const hybridThreads = Math.max(2, Math.min(8, Math.floor(cpuCount / 2)));
return `-mt${hybridThreads}`;
}
const envValue = Number(process.env.RD_EXTRACT_THREADS ?? NaN);
if (Number.isFinite(envValue) && envValue >= 1 && envValue <= 32) {
@ -620,8 +645,8 @@ function lowerExtractProcessPriority(childPid: number | undefined, cpuPriority?:
return;
}
try {
// Lowers CPU scheduling priority so extraction doesn't starve other processes.
// high → BELOW_NORMAL, middle/low → IDLE. I/O priority stays Normal (like JDownloader 2).
// Sets CPU scheduling priority for the extraction process.
// high → NORMAL (full speed), default → BELOW_NORMAL. I/O priority stays Normal.
os.setPriority(pid, extractOsPriority(cpuPriority));
} catch {
// ignore: priority lowering is best-effort
@ -963,6 +988,274 @@ function parseJvmLine(
}
}
// ── Persistent JVM Daemon ──
// Keeps a single JVM process alive across multiple extraction requests,
// eliminating the ~5s JVM boot overhead per archive.
interface DaemonRequest {
resolve: (result: JvmExtractResult) => void;
onArchiveProgress?: (percent: number) => void;
signal?: AbortSignal;
timeoutMs?: number;
parseState: { bestPercent: number; usedPassword: string; backend: string; reportedError: string };
}
let daemonProcess: ChildProcess | null = null;
let daemonReady = false;
let daemonBusy = false;
let daemonCurrentRequest: DaemonRequest | null = null;
let daemonStdoutBuffer = "";
let daemonStderrBuffer = "";
let daemonOutput = "";
let daemonTimeoutId: NodeJS.Timeout | null = null;
let daemonAbortHandler: (() => void) | null = null;
let daemonLayout: JvmExtractorLayout | null = null;
export function shutdownDaemon(): void {
if (daemonProcess) {
try { daemonProcess.stdin?.end(); } catch { /* ignore */ }
try { killProcessTree(daemonProcess); } catch { /* ignore */ }
daemonProcess = null;
}
daemonReady = false;
daemonBusy = false;
daemonCurrentRequest = null;
daemonStdoutBuffer = "";
daemonStderrBuffer = "";
daemonOutput = "";
if (daemonTimeoutId) { clearTimeout(daemonTimeoutId); daemonTimeoutId = null; }
if (daemonAbortHandler) { daemonAbortHandler = null; }
daemonLayout = null;
}
function finishDaemonRequest(result: JvmExtractResult): void {
const req = daemonCurrentRequest;
if (!req) return;
daemonCurrentRequest = null;
daemonBusy = false;
daemonStdoutBuffer = "";
daemonStderrBuffer = "";
daemonOutput = "";
if (daemonTimeoutId) { clearTimeout(daemonTimeoutId); daemonTimeoutId = null; }
if (req.signal && daemonAbortHandler) {
req.signal.removeEventListener("abort", daemonAbortHandler);
daemonAbortHandler = null;
}
req.resolve(result);
}
function handleDaemonLine(line: string): void {
const trimmed = String(line || "").trim();
if (!trimmed) return;
// Check for daemon ready signal
if (trimmed === "RD_DAEMON_READY") {
daemonReady = true;
logger.info("JVM Daemon bereit (persistent)");
return;
}
// Check for request completion
if (trimmed.startsWith("RD_REQUEST_DONE ")) {
const code = parseInt(trimmed.slice("RD_REQUEST_DONE ".length).trim(), 10);
const req = daemonCurrentRequest;
if (!req) return;
if (code === 0) {
req.onArchiveProgress?.(100);
finishDaemonRequest({
ok: true, missingCommand: false, missingRuntime: false,
aborted: false, timedOut: false, errorText: "",
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
});
} else {
const message = cleanErrorText(req.parseState.reportedError || daemonOutput) || `Exit Code ${code}`;
finishDaemonRequest({
ok: false, missingCommand: false, missingRuntime: isJvmRuntimeMissingError(message),
aborted: false, timedOut: false, errorText: message,
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
});
}
return;
}
// Regular progress/status lines — delegate to parseJvmLine
if (daemonCurrentRequest) {
parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState);
}
}
function startDaemon(layout: JvmExtractorLayout): boolean {
if (daemonProcess && daemonReady) return true;
shutdownDaemon();
const jvmTmpDir = path.join(os.tmpdir(), `rd-extract-daemon-${crypto.randomUUID()}`);
fs.mkdirSync(jvmTmpDir, { recursive: true });
const args = [
"-Dfile.encoding=UTF-8",
`-Djava.io.tmpdir=${jvmTmpDir}`,
"-Xms512m",
"-Xmx8g",
"-XX:+UseSerialGC",
"-cp",
layout.classPath,
JVM_EXTRACTOR_MAIN_CLASS,
"--daemon"
];
try {
const child = spawn(layout.javaCommand, args, {
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"]
});
lowerExtractProcessPriority(child.pid, currentExtractCpuPriority);
daemonProcess = child;
daemonLayout = layout;
child.stdout!.on("data", (chunk) => {
const raw = String(chunk || "");
daemonOutput = appendLimited(daemonOutput, raw);
daemonStdoutBuffer += raw;
const lines = daemonStdoutBuffer.split(/\r?\n/);
daemonStdoutBuffer = lines.pop() || "";
for (const line of lines) {
handleDaemonLine(line);
}
});
child.stderr!.on("data", (chunk) => {
const raw = String(chunk || "");
daemonOutput = appendLimited(daemonOutput, raw);
daemonStderrBuffer += raw;
const lines = daemonStderrBuffer.split(/\r?\n/);
daemonStderrBuffer = lines.pop() || "";
for (const line of lines) {
if (daemonCurrentRequest) {
parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState);
}
}
});
child.on("error", () => {
if (daemonCurrentRequest) {
finishDaemonRequest({
ok: false, missingCommand: true, missingRuntime: true,
aborted: false, timedOut: false, errorText: "Daemon process error",
usedPassword: "", backend: ""
});
}
shutdownDaemon();
});
child.on("close", () => {
if (daemonCurrentRequest) {
const req = daemonCurrentRequest;
finishDaemonRequest({
ok: false, missingCommand: false, missingRuntime: false,
aborted: false, timedOut: false,
errorText: cleanErrorText(req.parseState.reportedError || daemonOutput) || "Daemon process exited unexpectedly",
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
});
}
// Clean up tmp dir
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
daemonProcess = null;
daemonReady = false;
daemonBusy = false;
daemonLayout = null;
});
logger.info(`JVM Daemon gestartet (PID ${child.pid})`);
return true;
} catch (error) {
logger.warn(`JVM Daemon Start fehlgeschlagen: ${String(error)}`);
return false;
}
}
function isDaemonAvailable(layout: JvmExtractorLayout): boolean {
// Start daemon if not running yet
if (!daemonProcess || !daemonReady) {
startDaemon(layout);
}
return Boolean(daemonProcess && daemonReady && !daemonBusy);
}
function sendDaemonRequest(
archivePath: string,
targetDir: string,
conflictMode: ConflictMode,
passwordCandidates: string[],
onArchiveProgress?: (percent: number) => void,
signal?: AbortSignal,
timeoutMs?: number
): Promise<JvmExtractResult> {
return new Promise((resolve) => {
const mode = effectiveConflictMode(conflictMode);
const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" };
daemonBusy = true;
daemonOutput = "";
daemonCurrentRequest = { resolve, onArchiveProgress, signal, timeoutMs, parseState };
// Set up timeout
if (timeoutMs && timeoutMs > 0) {
daemonTimeoutId = setTimeout(() => {
// Timeout — kill the daemon and restart fresh for next request
const req = daemonCurrentRequest;
if (req) {
finishDaemonRequest({
ok: false, missingCommand: false, missingRuntime: false,
aborted: false, timedOut: true,
errorText: `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`,
usedPassword: parseState.usedPassword, backend: parseState.backend
});
}
shutdownDaemon();
}, timeoutMs);
}
// Set up abort handler
if (signal) {
daemonAbortHandler = () => {
const req = daemonCurrentRequest;
if (req) {
finishDaemonRequest({
ok: false, missingCommand: false, missingRuntime: false,
aborted: true, timedOut: false, errorText: "aborted:extract",
usedPassword: parseState.usedPassword, backend: parseState.backend
});
}
// Kill daemon on abort — cleaner than trying to interrupt mid-extraction
shutdownDaemon();
};
signal.addEventListener("abort", daemonAbortHandler, { once: true });
}
// Build and send JSON request
const jsonRequest = JSON.stringify({
archive: archivePath,
target: targetDir,
conflict: mode,
backend: "auto",
passwords: passwordCandidates
});
try {
daemonProcess!.stdin!.write(jsonRequest + "\n");
} catch (error) {
finishDaemonRequest({
ok: false, missingCommand: false, missingRuntime: false,
aborted: false, timedOut: false,
errorText: `Daemon stdin write failed: ${String(error)}`,
usedPassword: "", backend: ""
});
shutdownDaemon();
}
});
}
function runJvmExtractCommand(
layout: JvmExtractorLayout,
archivePath: string,
@ -986,6 +1279,15 @@ function runJvmExtractCommand(
});
}
// Try persistent daemon first — saves ~5s JVM boot per archive
if (isDaemonAvailable(layout)) {
logger.info(`JVM Daemon: Sende Request für ${path.basename(archivePath)}`);
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
}
// Fallback: spawn a new JVM process (daemon busy or not available)
logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}${daemonBusy ? " (Daemon busy)" : ""}`);
const mode = effectiveConflictMode(conflictMode);
// Each JVM process needs its own temp dir so parallel SevenZipJBinding
// instances don't fight over the same native DLL file lock.
@ -994,8 +1296,9 @@ function runJvmExtractCommand(
const args = [
"-Dfile.encoding=UTF-8",
`-Djava.io.tmpdir=${jvmTmpDir}`,
"-Xms32m",
"-Xmx512m",
"-Xms512m",
"-Xmx8g",
"-XX:+UseSerialGC",
"-cp",
layout.classPath,
JVM_EXTRACTOR_MAIN_CLASS,
@ -1698,7 +2001,7 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
return Array.from(targets);
}
async function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode): Promise<number> {
export async function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode): Promise<number> {
if (cleanupMode === "none") {
return 0;
}
@ -1769,7 +2072,7 @@ async function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode):
return removed;
}
async function hasAnyFilesRecursive(rootDir: string): Promise<boolean> {
export async function hasAnyFilesRecursive(rootDir: string): Promise<boolean> {
const rootExists = await fs.promises.access(rootDir).then(() => true, () => false);
if (!rootExists) {
return false;
@ -1817,7 +2120,7 @@ async function hasAnyEntries(rootDir: string): Promise<boolean> {
}
}
async function removeEmptyDirectoryTree(rootDir: string): Promise<number> {
export async function removeEmptyDirectoryTree(rootDir: string): Promise<number> {
const rootExists = await fs.promises.access(rootDir).then(() => true, () => false);
if (!rootExists) {
return 0;
@ -1862,6 +2165,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (options.signal?.aborted) {
throw new Error("aborted:extract");
}
options.onProgress?.({ current: 0, total: 0, percent: 0, archiveName: "Archive scannen...", phase: "preparing" });
const allCandidates = await findArchiveCandidates(options.packageDir);
const candidates = options.onlyArchives
? allCandidates.filter((archivePath) => {
@ -1873,6 +2177,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
// Disk space pre-check
if (candidates.length > 0) {
options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" });
try {
await fs.promises.mkdir(options.targetDir, { recursive: true });
} catch { /* ignore */ }

View File

@ -7,6 +7,7 @@ import { IPC_CHANNELS } from "../shared/ipc";
import { getLogFilePath, logger } from "./logger";
import { APP_NAME } from "./constants";
import { extractHttpLinksFromText } from "./utils";
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
/* ── IPC validation helpers ────────────────────────────────────── */
function validateString(value: unknown, name: string): string {
@ -81,7 +82,7 @@ function createWindow(): BrowserWindow {
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu"
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu https://git.24-music.de https://ddownload.com https://ddl.to"
]
}
});
@ -188,7 +189,12 @@ function startClipboardWatcher(): void {
}
lastClipboardText = normalizeClipboardText(clipboard.readText());
clipboardTimer = setInterval(() => {
const text = normalizeClipboardText(clipboard.readText());
let text: string;
try {
text = normalizeClipboardText(clipboard.readText());
} catch {
return;
}
if (text === lastClipboardText || !text.trim()) {
return;
}
@ -481,6 +487,7 @@ app.on("second-instance", () => {
});
app.whenReady().then(() => {
cleanupStaleSubstDrives();
registerIpcHandlers();
mainWindow = createWindow();
bindMainWindowLifecycle(mainWindow);
@ -493,6 +500,9 @@ app.whenReady().then(() => {
bindMainWindowLifecycle(mainWindow);
}
});
}).catch((error) => {
console.error("App startup failed:", error);
app.quit();
});
app.on("window-all-closed", () => {
@ -505,6 +515,7 @@ app.on("before-quit", () => {
if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; }
stopClipboardWatcher();
destroyTray();
shutdownDaemon();
try {
controller.shutdown();
} catch (error) {

View File

@ -228,22 +228,23 @@ export class MegaWebFallback {
}
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
const overallSignal = withTimeoutSignal(signal, 180000);
return this.runExclusive(async () => {
throwIfAborted(signal);
throwIfAborted(overallSignal);
const creds = this.getCredentials();
if (!creds.login.trim() || !creds.password.trim()) {
return null;
}
if (!this.cookie || Date.now() - this.cookieSetAt > 20 * 60 * 1000) {
await this.login(creds.login, creds.password, signal);
await this.login(creds.login, creds.password, overallSignal);
}
const generated = await this.generate(link, signal);
const generated = await this.generate(link, overallSignal);
if (!generated) {
this.cookie = "";
await this.login(creds.login, creds.password, signal);
const retry = await this.generate(link, signal);
await this.login(creds.login, creds.password, overallSignal);
const retry = await this.generate(link, overallSignal);
if (!retry) {
return null;
}
@ -261,7 +262,7 @@ export class MegaWebFallback {
fileSize: null,
retriesUsed: 0
};
}, signal);
}, overallSignal);
}
public invalidateSession(): void {

View File

@ -8,6 +8,7 @@ export interface UnrestrictedLink {
directUrl: string;
fileSize: number | null;
retriesUsed: number;
skipTlsVerify?: boolean;
}
function shouldRetryStatus(status: number): boolean {

View File

@ -76,7 +76,12 @@ async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise<v
export function initSessionLog(baseDir: string): void {
sessionLogsDir = path.join(baseDir, "session-logs");
try {
fs.mkdirSync(sessionLogsDir, { recursive: true });
} catch {
sessionLogsDir = null;
return;
}
const timestamp = formatTimestamp();
sessionLogPath = path.join(sessionLogsDir, `session_${timestamp}.txt`);

View File

@ -5,8 +5,8 @@ import { AppSettings, BandwidthScheduleEntry, DebridProvider, DownloadItem, Down
import { defaultSettings } from "./constants";
import { logger } from "./logger";
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid"]);
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid", "bestdebrid", "alldebrid"]);
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload"]);
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload"]);
const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]);
const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]);
@ -113,7 +113,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
allDebridToken: asText(settings.allDebridToken),
ddownloadLogin: asText(settings.ddownloadLogin),
ddownloadPassword: asText(settings.ddownloadPassword),
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n/g, "\n"),
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"),
rememberToken: Boolean(settings.rememberToken),
providerPrimary: settings.providerPrimary,
providerSecondary: settings.providerSecondary,

View File

@ -794,7 +794,8 @@ async function downloadFile(url: string, targetPath: string, onProgress?: Update
};
const reader = response.body.getReader();
const chunks: Buffer[] = [];
const tempPath = targetPath + ".tmp";
const writeStream = fs.createWriteStream(tempPath);
try {
resetIdleTimer();
@ -808,27 +809,39 @@ async function downloadFile(url: string, targetPath: string, onProgress?: Update
break;
}
const buf = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
chunks.push(buf);
if (!writeStream.write(buf)) {
await new Promise<void>((resolve) => writeStream.once("drain", resolve));
}
downloadedBytes += buf.byteLength;
resetIdleTimer();
emitDownloadProgress(false);
}
} catch (error) {
writeStream.destroy();
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
throw error;
} finally {
clearIdleTimer();
}
await new Promise<void>((resolve, reject) => {
writeStream.end(() => resolve());
writeStream.on("error", reject);
});
if (idleTimedOut) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleTimeoutMs / 1000)}s`);
}
const fileBuffer = Buffer.concat(chunks);
if (totalBytes && fileBuffer.byteLength !== totalBytes) {
throw new Error(`Update Download unvollständig (${fileBuffer.byteLength} / ${totalBytes} Bytes)`);
if (totalBytes && downloadedBytes !== totalBytes) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
throw new Error(`Update Download unvollständig (${downloadedBytes} / ${totalBytes} Bytes)`);
}
await fs.promises.writeFile(targetPath, fileBuffer);
await fs.promises.rename(tempPath, targetPath);
emitDownloadProgress(true);
logger.info(`Update-Download abgeschlossen: ${targetPath} (${fileBuffer.byteLength} Bytes)`);
logger.info(`Update-Download abgeschlossen: ${targetPath} (${downloadedBytes} Bytes)`);
return { expectedBytes: totalBytes };
}

View File

@ -4,6 +4,7 @@ import {
AppSettings,
DuplicatePolicy,
HistoryEntry,
PackagePriority,
SessionStats,
StartConflictEntry,
StartConflictResolutionResult,
@ -56,7 +57,7 @@ const api: ElectronApi = {
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
setPackagePriority: (packageId: string, priority: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority),
setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority),
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),

View File

@ -36,6 +36,7 @@ interface ConfirmPromptState {
message: string;
confirmLabel: string;
danger?: boolean;
details?: string;
}
interface ContextMenuState {
@ -61,7 +62,7 @@ const emptyStats = (): DownloadStats => ({
const emptySnapshot = (): UiSnapshot => ({
settings: {
token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "",
token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "", ddownloadLogin: "", ddownloadPassword: "",
archivePasswordList: "",
rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid",
providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "",
@ -115,15 +116,6 @@ function extractHoster(url: string): string {
} catch { return ""; }
}
function formatHoster(item: DownloadItem): string {
const hoster = extractHoster(item.url);
const label = hoster || "-";
if (item.provider) {
return `${label} via ${providerLabels[item.provider]}`;
}
return label;
}
const settingsSubTabs: { key: SettingsSubTab; label: string }[] = [
{ key: "allgemein", label: "Allgemein" },
{ key: "accounts", label: "Accounts" },
@ -999,15 +991,36 @@ export function App(): ReactElement {
if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); }
return;
}
let changelogBlock = "";
let changelogText = "";
if (result.releaseNotes) {
const notes = result.releaseNotes.length > 500 ? `${result.releaseNotes.slice(0, 500)}` : result.releaseNotes;
changelogBlock = `\n\n--- Changelog ---\n${notes}`;
const lines = result.releaseNotes.split("\n");
const compactLines: string[] = [];
for (const line of lines) {
if (/^\s{2,}[-*]/.test(line)) continue;
if (/^#{1,6}\s/.test(line)) continue;
if (!line.trim()) continue;
let clean = line
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/^\s*[-*]\s+/, "- ")
.trim();
const colonIdx = clean.indexOf(":");
if (colonIdx > 0 && colonIdx < clean.length - 1) {
const afterColon = clean.slice(colonIdx + 1).trim();
if (afterColon.length > 60) {
clean = clean.slice(0, colonIdx + 1).trim();
}
}
if (clean) compactLines.push(clean);
}
changelogText = compactLines.join("\n");
}
const approved = await askConfirmPrompt({
title: "Update verfügbar",
message: `${result.latestTag} (aktuell v${result.currentVersion})${changelogBlock}\n\nJetzt automatisch herunterladen und installieren?`,
confirmLabel: "Jetzt installieren"
message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`,
confirmLabel: "Jetzt installieren",
details: changelogText || undefined
});
if (!mountedRef.current) {
return;
@ -1848,10 +1861,12 @@ export function App(): ReactElement {
const executeDeleteSelection = useCallback((ids: Set<string>): void => {
const current = snapshotRef.current;
const promises: Promise<void>[] = [];
for (const id of ids) {
if (current.session.items[id]) void window.rd.removeItem(id);
else if (current.session.packages[id]) void window.rd.cancelPackage(id);
if (current.session.items[id]) promises.push(window.rd.removeItem(id));
else if (current.session.packages[id]) promises.push(window.rd.cancelPackage(id));
}
void Promise.all(promises).catch(() => {});
setSelectedIds(new Set());
}, []);
@ -1894,28 +1909,28 @@ export function App(): ReactElement {
const onExportBackup = async (): Promise<void> => {
closeMenus();
try {
await performQuickAction(async () => {
const result = await window.rd.exportBackup();
if (result.saved) {
showToast("Sicherung exportiert");
}
} catch (error) {
}, (error) => {
showToast(`Sicherung fehlgeschlagen: ${String(error)}`, 2600);
}
});
};
const onImportBackup = async (): Promise<void> => {
closeMenus();
try {
await performQuickAction(async () => {
const result = await window.rd.importBackup();
if (result.restored) {
showToast(result.message, 4000);
} else if (result.message !== "Abgebrochen") {
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
}
} catch (error) {
}, (error) => {
showToast(`Sicherung laden fehlgeschlagen: ${String(error)}`, 2600);
}
});
};
const onMenuRestart = (): void => {
@ -2226,10 +2241,10 @@ export function App(): ReactElement {
</button>
{openMenu === "hilfe" && (
<div className="menu-dropdown">
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog(); }}>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}>
<span>Log öffnen</span>
</button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog(); }}>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}>
<span>Session-Log öffnen</span>
</button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}>
@ -2249,7 +2264,7 @@ export function App(): ReactElement {
onClick={() => {
if (snapshot.session.paused) {
setSnapshot((prev) => ({ ...prev, session: { ...prev.session, paused: false } }));
void window.rd.togglePause();
void window.rd.togglePause().catch(() => {});
} else {
void onStartDownloads();
}
@ -2263,7 +2278,7 @@ export function App(): ReactElement {
disabled={!snapshot.canPause || snapshot.session.paused}
onClick={() => {
setSnapshot((prev) => ({ ...prev, session: { ...prev.session, paused: true } }));
void window.rd.togglePause();
void window.rd.togglePause().catch(() => {});
}}
>
<svg viewBox="0 0 24 24" width="18" height="18"><rect x="5" y="3" width="4.5" height="18" rx="1" fill="currentColor" /><rect x="14.5" y="3" width="4.5" height="18" rx="1" fill="currentColor" /></svg>
@ -2385,7 +2400,7 @@ export function App(): ReactElement {
newOrder.splice(toIdx, 0, dragColId);
setColumnOrder(newOrder);
setDragColId(null);
void window.rd.updateSettings({ columnOrder: newOrder });
void window.rd.updateSettings({ columnOrder: newOrder }).catch(() => {});
}}
onDragEnd={() => { setDragColId(null); setDropTargetCol(null); }}
onClick={sortCol ? () => {
@ -2479,7 +2494,7 @@ export function App(): ReactElement {
: `${historyEntries.length} Paket${historyEntries.length !== 1 ? "e" : ""} im Verlauf`}
</span>
{selectedHistoryIds.size > 0 && (
<button className="btn btn-danger" onClick={() => {
<button className="btn danger" onClick={() => {
const idSet = new Set(selectedHistoryIds);
void Promise.all([...idSet].map(id => window.rd.removeHistoryEntry(id))).then(() => {
setHistoryEntries((prev) => prev.filter((e) => !idSet.has(e.id)));
@ -2490,7 +2505,7 @@ export function App(): ReactElement {
}}>Ausgewählte entfernen ({selectedHistoryIds.size})</button>
)}
{historyEntries.length > 0 && (
<button className="btn btn-danger" onClick={() => { void window.rd.clearHistory().then(() => { setHistoryEntries([]); setSelectedHistoryIds(new Set()); }); }}>Verlauf leeren</button>
<button className="btn danger" onClick={() => { void window.rd.clearHistory().then(() => { setHistoryEntries([]); setSelectedHistoryIds(new Set()); }).catch(() => {}); }}>Verlauf leeren</button>
)}
</div>
{historyEntries.length === 0 && <div className="empty">Noch keine abgeschlossenen Pakete im Verlauf.</div>}
@ -2577,7 +2592,7 @@ export function App(): ReactElement {
<span>{entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"}</span>
</div>
<div className="history-actions">
<button className="btn" onClick={() => { void window.rd.removeHistoryEntry(entry.id).then(() => { setHistoryEntries((prev) => prev.filter((e) => e.id !== entry.id)); setSelectedHistoryIds((prev) => { const n = new Set(prev); n.delete(entry.id); return n; }); }); }}>Eintrag entfernen</button>
<button className="btn" onClick={() => { void window.rd.removeHistoryEntry(entry.id).then(() => { setHistoryEntries((prev) => prev.filter((e) => e.id !== entry.id)); setSelectedHistoryIds((prev) => { const n = new Set(prev); n.delete(entry.id); return n; }); }).catch(() => {}); }}>Eintrag entfernen</button>
</div>
</div>
)}
@ -2866,6 +2881,12 @@ export function App(): ReactElement {
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<h3>{confirmPrompt.title}</h3>
<p style={{ whiteSpace: "pre-line" }}>{confirmPrompt.message}</p>
{confirmPrompt.details && (
<details className="modal-details">
<summary>Changelog anzeigen</summary>
<pre>{confirmPrompt.details}</pre>
</details>
)}
<div className="modal-actions">
<button className="btn" onClick={() => closeConfirmPrompt(false)}>Abbrechen</button>
<button
@ -3022,18 +3043,18 @@ export function App(): ReactElement {
<button className="ctx-menu-item" onClick={() => {
const pkgIds = [...selectedIds].filter((id) => snapshot.session.packages[id]);
const itemIds = [...selectedIds].filter((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); });
if (pkgIds.length > 0) void window.rd.startPackages(pkgIds);
if (itemIds.length > 0) void window.rd.startItems(itemIds);
if (pkgIds.length > 0) void window.rd.startPackages(pkgIds).catch(() => {});
if (itemIds.length > 0) void window.rd.startItems(itemIds).catch(() => {});
setContextMenu(null);
}}>Ausgewählte Downloads starten{multi ? ` (${selectedIds.size})` : ""}</button>
)}
<button className="ctx-menu-item" onClick={() => { void window.rd.start(); setContextMenu(null); }}>Alle Downloads starten</button>
<button className="ctx-menu-item" onClick={() => { void window.rd.start().catch(() => {}); setContextMenu(null); }}>Alle Downloads starten</button>
<div className="ctx-menu-sep" />
<button className="ctx-menu-item" onClick={() => showLinksPopup(contextMenu.packageId, contextMenu.itemId)}>Linkadressen anzeigen</button>
<div className="ctx-menu-sep" />
{hasPackages && !contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => {
for (const id of selectedIds) { if (snapshot.session.packages[id]) void window.rd.togglePackage(id); }
for (const id of selectedIds) { if (snapshot.session.packages[id]) void window.rd.togglePackage(id).catch(() => {}); }
setContextMenu(null);
}}>
{multi ? `Alle ${selectedIds.size} umschalten` : (snapshot.session.packages[contextMenu.packageId]?.enabled ? "Deaktivieren" : "Aktivieren")}
@ -3058,7 +3079,7 @@ export function App(): ReactElement {
{hasPackages && !contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => {
const pkgIds = [...selectedIds].filter((id) => snapshot.session.packages[id]);
for (const id of pkgIds) void window.rd.resetPackage(id);
for (const id of pkgIds) void window.rd.resetPackage(id).catch(() => {});
setContextMenu(null);
}}>Zurücksetzen{multi ? ` (${[...selectedIds].filter((id) => snapshot.session.packages[id]).length})` : ""}</button>
)}
@ -3067,7 +3088,7 @@ export function App(): ReactElement {
const itemIds = multi
? [...selectedIds].filter((id) => snapshot.session.items[id])
: [contextMenu.itemId!];
void window.rd.resetItems(itemIds);
void window.rd.resetItems(itemIds).catch(() => {});
setContextMenu(null);
}}>Zurücksetzen{multi ? ` (${[...selectedIds].filter((id) => snapshot.session.items[id]).length})` : ""}</button>
)}
@ -3077,7 +3098,7 @@ export function App(): ReactElement {
const someCompleted = items.some((item) => item && item.status === "completed");
return (<>
{someCompleted && (
<button className="ctx-menu-item" onClick={() => { void window.rd.extractNow(contextMenu.packageId); setContextMenu(null); }}>Jetzt entpacken</button>
<button className="ctx-menu-item" onClick={() => { void window.rd.extractNow(contextMenu.packageId).catch(() => {}); setContextMenu(null); }}>Jetzt entpacken</button>
)}
</>);
})()}
@ -3090,7 +3111,7 @@ export function App(): ReactElement {
const label = p === "high" ? "Hoch" : p === "low" ? "Niedrig" : "Standard";
const pkgIds = [...selectedIds].filter((id) => snapshot.session.packages[id]);
const allMatch = pkgIds.every((id) => (snapshot.session.packages[id]?.priority || "normal") === p);
return <button key={p} className={`ctx-menu-item${allMatch ? " ctx-menu-active" : ""}`} onClick={() => { for (const id of pkgIds) void window.rd.setPackagePriority(id, p); setContextMenu(null); }}>{allMatch ? `${label}` : label}</button>;
return <button key={p} className={`ctx-menu-item${allMatch ? " ctx-menu-active" : ""}`} onClick={() => { for (const id of pkgIds) void window.rd.setPackagePriority(id, p).catch(() => {}); setContextMenu(null); }}>{allMatch ? `${label}` : label}</button>;
})}
</div>
</div>
@ -3099,7 +3120,7 @@ export function App(): ReactElement {
const itemIds = [...selectedIds].filter((id) => snapshot.session.items[id]);
const skippable = itemIds.filter((id) => { const it = snapshot.session.items[id]; return it && (it.status === "queued" || it.status === "reconnect_wait"); });
if (skippable.length === 0) return null;
return <button className="ctx-menu-item" onClick={() => { void window.rd.skipItems(skippable); setContextMenu(null); }}>Überspringen{skippable.length > 1 ? ` (${skippable.length})` : ""}</button>;
return <button className="ctx-menu-item" onClick={() => { void window.rd.skipItems(skippable).catch(() => {}); setContextMenu(null); }}>Überspringen{skippable.length > 1 ? ` (${skippable.length})` : ""}</button>;
})()}
{hasPackages && (
<button className="ctx-menu-item ctx-danger" onClick={() => {
@ -3143,7 +3164,7 @@ export function App(): ReactElement {
newOrder.splice(insertAt, 0, col);
}
setColumnOrder(newOrder);
void window.rd.updateSettings({ columnOrder: newOrder });
void window.rd.updateSettings({ columnOrder: newOrder }).catch(() => {});
}}
>
{isVisible ? "\u2713 " : "\u2003 "}{def.label}
@ -3184,7 +3205,7 @@ export function App(): ReactElement {
)}
<div className="ctx-menu-sep" />
<button className="ctx-menu-item ctx-danger" onClick={() => {
void window.rd.clearHistory().then(() => { setHistoryEntries([]); setSelectedHistoryIds(new Set()); });
void window.rd.clearHistory().then(() => { setHistoryEntries([]); setSelectedHistoryIds(new Set()); }).catch(() => {});
setHistoryCtxMenu(null);
}}>Verlauf leeren</button>
</div>
@ -3198,8 +3219,8 @@ export function App(): ReactElement {
<div className="link-popup-list">
{linkPopup.links.map((link, i) => (
<div key={i} className="link-popup-row">
<span className="link-popup-name link-popup-click" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.name); showToast("Name kopiert"); }}>{link.name}</span>
<span className="link-popup-url link-popup-click" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.url); showToast("Link kopiert"); }}>{link.url}</span>
<span className="link-popup-name link-popup-click" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</span>
<span className="link-popup-url link-popup-click" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</span>
</div>
))}
</div>
@ -3207,15 +3228,13 @@ export function App(): ReactElement {
{linkPopup.isPackage && (
<button className="btn" onClick={() => {
const text = linkPopup.links.map((l) => l.name).join("\n");
void navigator.clipboard.writeText(text);
showToast("Alle Namen kopiert");
void navigator.clipboard.writeText(text).then(() => showToast("Alle Namen kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
}}>Alle Namen kopieren</button>
)}
{linkPopup.isPackage && (
<button className="btn" onClick={() => {
const text = linkPopup.links.map((l) => l.url).join("\n");
void navigator.clipboard.writeText(text);
showToast("Alle Links kopiert");
void navigator.clipboard.writeText(text).then(() => showToast("Alle Links kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
}}>Alle Links kopieren</button>
)}
<button className="btn" onClick={() => setLinkPopup(null)}>Schließen</button>
@ -3362,7 +3381,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
<span key={col} className={`pkg-col pkg-col-prio${pkg.priority === "high" ? " prio-high" : pkg.priority === "low" ? " prio-low" : ""}`}>{pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : ""}</span>
);
case "status": return (
<span key={col} className="pkg-col pkg-col-status">[{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` · ${failed} Fehler` : ""}{cancelled > 0 ? ` · ${cancelled} abgebr.` : ""}]{pkg.postProcessLabel ? ` ${pkg.postProcessLabel}` : ""}</span>
<span key={col} className="pkg-col pkg-col-status">[{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` · ${failed} Fehler` : ""}{cancelled > 0 ? ` · ${cancelled} abgebr.` : ""}]{pkg.postProcessLabel ? ` - ${pkg.postProcessLabel}` : ""}</span>
);
case "speed": return (
<span key={col} className="pkg-col pkg-col-speed">{packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : ""}</span>

View File

@ -1639,6 +1639,7 @@ td {
border-radius: 12px;
padding: 10px 14px;
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.35);
z-index: 50;
}
.ctx-menu {
@ -1763,6 +1764,8 @@ td {
.modal-card {
width: min(560px, 100%);
max-height: calc(100vh - 40px);
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 14px;
background: linear-gradient(180deg, color-mix(in srgb, var(--card) 98%, transparent), color-mix(in srgb, var(--surface) 98%, transparent));
@ -1781,6 +1784,34 @@ td {
color: var(--muted);
}
.modal-details {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0;
}
.modal-details summary {
padding: 6px 10px;
cursor: pointer;
font-size: 13px;
color: var(--muted);
user-select: none;
}
.modal-details summary:hover {
color: var(--text);
}
.modal-details pre {
margin: 0;
padding: 8px 10px;
border-top: 1px solid var(--border);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 260px;
overflow-y: auto;
color: var(--muted);
}
.modal-path {
font-size: 12px;
word-break: break-all;

View File

@ -269,6 +269,7 @@ describe("buildAutoRenameBaseName", () => {
const result = buildAutoRenameBaseName("Show.S99.720p-4sf", "show.s99e999.720p.mkv");
// SCENE_EPISODE_RE allows up to 3-digit episodes and 2-digit seasons
expect(result).not.toBeNull();
expect(result!).toContain("S99E999");
});
// Real-world scene release patterns
@ -343,6 +344,7 @@ describe("buildAutoRenameBaseName", () => {
const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e01.mkv");
// "mkv" should not be treated as part of the filename match
expect(result).not.toBeNull();
expect(result!).toContain("S01E01");
});
it("does not match episode-like patterns in codec strings", () => {
@ -373,6 +375,7 @@ describe("buildAutoRenameBaseName", () => {
// Extreme edge case - sanitizeFilename trims leading dots
expect(result).not.toBeNull();
expect(result!).toContain("S01E01");
expect(result!).toContain("-4sf");
expect(result!).not.toContain(".S01E01.S01E01"); // no duplication
});

View File

@ -317,7 +317,7 @@ describe("debrid service", () => {
const controller = new AbortController();
const abortTimer = setTimeout(() => {
controller.abort("test");
}, 25);
}, 200);
try {
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i);

View File

@ -36,12 +36,8 @@ afterEach(() => {
}
});
describe("extractor jvm backend", () => {
describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => {
it("extracts zip archives through SevenZipJBinding backend", async () => {
if (!hasJavaRuntime() || !hasJvmExtractorRuntime()) {
return;
}
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-"));
@ -69,11 +65,112 @@ describe("extractor jvm backend", () => {
expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true);
});
it("respects ask/skip conflict mode in jvm backend", async () => {
if (!hasJavaRuntime() || !hasJvmExtractorRuntime()) {
return;
}
it("emits progress callbacks with archiveName and percent", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-progress-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
// Create a ZIP with some content to trigger progress
const zipPath = path.join(packageDir, "progress-test.zip");
const zip = new AdmZip();
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
zip.addFile("file2.txt", Buffer.from("Another file ".repeat(100)));
zip.writeZip(zipPath);
const progressUpdates: Array<{
archiveName: string;
percent: number;
phase: string;
archivePercent?: number;
}> = [];
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onProgress: (update) => {
progressUpdates.push({
archiveName: update.archiveName,
percent: update.percent,
phase: update.phase,
archivePercent: update.archivePercent,
});
},
});
expect(result.extracted).toBe(1);
expect(result.failed).toBe(0);
// Should have at least preparing, extracting, and done phases
const phases = new Set(progressUpdates.map((u) => u.phase));
expect(phases.has("preparing")).toBe(true);
expect(phases.has("extracting")).toBe(true);
// Extracting phase should include the archive name
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
expect(extracting.length).toBeGreaterThan(0);
// Should end at 100%
const lastExtracting = extracting[extracting.length - 1];
expect(lastExtracting.archivePercent).toBe(100);
// Files should exist
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
});
it("extracts multiple archives sequentially with progress for each", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-multi-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
// Create two separate ZIP archives
const zip1 = new AdmZip();
zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
zip1.writeZip(path.join(packageDir, "archive1.zip"));
const zip2 = new AdmZip();
zip2.addFile("episode02.txt", Buffer.from("ep2 content"));
zip2.writeZip(path.join(packageDir, "archive2.zip"));
const archiveNames = new Set<string>();
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onProgress: (update) => {
if (update.phase === "extracting" && update.archiveName) {
archiveNames.add(update.archiveName);
}
},
});
expect(result.extracted).toBe(2);
expect(result.failed).toBe(0);
// Both archive names should have appeared in progress
expect(archiveNames.has("archive1.zip")).toBe(true);
expect(archiveNames.has("archive2.zip")).toBe(true);
// Both files extracted
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
});
it("respects ask/skip conflict mode in jvm backend", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-"));

View File

@ -15,6 +15,8 @@ import {
const tempDirs: string[] = [];
const originalExtractBackend = process.env.RD_EXTRACT_BACKEND;
const originalStatfs = fs.promises.statfs;
const originalZipEntryMemoryLimit = process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
beforeEach(() => {
process.env.RD_EXTRACT_BACKEND = "legacy";
@ -29,6 +31,12 @@ afterEach(() => {
} else {
process.env.RD_EXTRACT_BACKEND = originalExtractBackend;
}
(fs.promises as any).statfs = originalStatfs;
if (originalZipEntryMemoryLimit === undefined) {
delete process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
} else {
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = originalZipEntryMemoryLimit;
}
});
describe("extractor", () => {
@ -574,7 +582,6 @@ describe("extractor", () => {
});
it("keeps original ZIP size guard error when external fallback is unavailable", async () => {
const previousLimit = process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = "8";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
@ -588,7 +595,6 @@ describe("extractor", () => {
zip.addFile("large.bin", Buffer.alloc(9 * 1024 * 1024, 7));
zip.writeZip(zipPath);
try {
const result = await extractPackageArchives({
packageDir,
targetDir,
@ -600,20 +606,9 @@ describe("extractor", () => {
expect(result.extracted).toBe(0);
expect(result.failed).toBe(1);
expect(String(result.lastError)).toMatch(/ZIP-Eintrag.*groß/i);
} finally {
if (previousLimit === undefined) {
delete process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
} else {
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = previousLimit;
}
}
});
it("matches resume-state archive names case-insensitively on Windows", async () => {
if (process.platform !== "win32") {
return;
}
it.skipIf(process.platform !== "win32")("matches resume-state archive names case-insensitively on Windows", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
@ -650,10 +645,8 @@ describe("extractor", () => {
zip.addFile("test.txt", Buffer.alloc(1024, 0x41));
zip.writeZip(path.join(packageDir, "test.zip"));
const originalStatfs = fs.promises.statfs;
(fs.promises as any).statfs = async () => ({ bfree: 1, bsize: 1 });
try {
await expect(
extractPackageArchives({
packageDir,
@ -664,9 +657,6 @@ describe("extractor", () => {
removeSamples: false,
})
).rejects.toThrow(/Nicht genug Speicherplatz/);
} finally {
(fs.promises as any).statfs = originalStatfs;
}
});
it("proceeds when disk space is sufficient", async () => {

View File

@ -166,7 +166,7 @@ describe("mega-web-fallback", () => {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort("test");
}, 30);
}, 200);
try {
await expect(fallback.unrestrict("https://mega.debrid/link2", controller.signal)).rejects.toThrow(/aborted/i);

View File

@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import { resolveArchiveItemsFromList } from "../src/main/download-manager";
type MinimalItem = {
targetPath?: string;
fileName?: string;
[key: string]: unknown;
};
function makeItems(names: string[]): MinimalItem[] {
return names.map((name) => ({
targetPath: `C:\\Downloads\\Package\\${name}`,
fileName: name,
id: name,
status: "completed",
}));
}
describe("resolveArchiveItemsFromList", () => {
// ── Multipart RAR (.partN.rar) ──
it("matches multipart .part1.rar archives", () => {
const items = makeItems([
"Movie.part1.rar",
"Movie.part2.rar",
"Movie.part3.rar",
"Other.rar",
]);
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
expect(result).toHaveLength(3);
expect(result.map((i: any) => i.fileName)).toEqual([
"Movie.part1.rar",
"Movie.part2.rar",
"Movie.part3.rar",
]);
});
it("matches multipart .part01.rar archives (zero-padded)", () => {
const items = makeItems([
"Film.part01.rar",
"Film.part02.rar",
"Film.part10.rar",
"Unrelated.zip",
]);
const result = resolveArchiveItemsFromList("Film.part01.rar", items as any);
expect(result).toHaveLength(3);
});
// ── Old-style RAR (.rar + .r00, .r01, etc.) ──
it("matches old-style .rar + .rNN volumes", () => {
const items = makeItems([
"Archive.rar",
"Archive.r00",
"Archive.r01",
"Archive.r02",
"Other.zip",
]);
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
expect(result).toHaveLength(4);
});
// ── Single RAR ──
it("matches a single .rar file", () => {
const items = makeItems(["SingleFile.rar", "Other.mkv"]);
const result = resolveArchiveItemsFromList("SingleFile.rar", items as any);
expect(result).toHaveLength(1);
expect((result[0] as any).fileName).toBe("SingleFile.rar");
});
// ── Split ZIP ──
it("matches split .zip.NNN files", () => {
const items = makeItems([
"Data.zip",
"Data.zip.001",
"Data.zip.002",
"Data.zip.003",
]);
const result = resolveArchiveItemsFromList("Data.zip.001", items as any);
expect(result).toHaveLength(4);
});
// ── Split 7z ──
it("matches split .7z.NNN files", () => {
const items = makeItems([
"Backup.7z.001",
"Backup.7z.002",
]);
const result = resolveArchiveItemsFromList("Backup.7z.001", items as any);
expect(result).toHaveLength(2);
});
// ── Generic .NNN splits ──
it("matches generic .NNN split files", () => {
const items = makeItems([
"video.001",
"video.002",
"video.003",
]);
const result = resolveArchiveItemsFromList("video.001", items as any);
expect(result).toHaveLength(3);
});
// ── Exact filename match ──
it("matches a single .zip by exact name", () => {
const items = makeItems(["myarchive.zip", "other.rar"]);
const result = resolveArchiveItemsFromList("myarchive.zip", items as any);
expect(result).toHaveLength(1);
expect((result[0] as any).fileName).toBe("myarchive.zip");
});
// ── Case insensitivity ──
it("matches case-insensitively", () => {
const items = makeItems([
"MOVIE.PART1.RAR",
"MOVIE.PART2.RAR",
]);
const result = resolveArchiveItemsFromList("movie.part1.rar", items as any);
expect(result).toHaveLength(2);
});
// ── Stem-based fallback ──
it("uses stem-based fallback when exact patterns fail", () => {
// Simulate a debrid service that renames "Movie.part1.rar" to "Movie.part1_dl.rar"
// but the disk file is "Movie.part1.rar"
const items = makeItems([
"Movie.rar",
]);
// The archive on disk is "Movie.part1.rar" but there's no item matching the
// .partN pattern. The stem "movie" should match "Movie.rar" via fallback.
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
// stem fallback: "movie" starts with "movie" and ends with .rar
expect(result).toHaveLength(1);
});
// ── Single item fallback ──
it("returns single archive item when no pattern matches", () => {
const items = makeItems(["totally-different-name.rar"]);
const result = resolveArchiveItemsFromList("Original.rar", items as any);
// Single item in list with archive extension → return it
expect(result).toHaveLength(1);
});
// ── Empty when no match ──
it("returns empty when items have no archive extensions", () => {
const items = makeItems(["video.mkv", "subtitle.srt"]);
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
expect(result).toHaveLength(0);
});
// ── Items without targetPath ──
it("falls back to fileName when targetPath is missing", () => {
const items = [
{ fileName: "Movie.part1.rar", id: "1", status: "completed" },
{ fileName: "Movie.part2.rar", id: "2", status: "completed" },
];
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
expect(result).toHaveLength(2);
});
// ── Multiple archives, should not cross-match ──
it("does not cross-match different archive groups", () => {
const items = makeItems([
"Episode.S01E01.part1.rar",
"Episode.S01E01.part2.rar",
"Episode.S01E02.part1.rar",
"Episode.S01E02.part2.rar",
]);
const result1 = resolveArchiveItemsFromList("Episode.S01E01.part1.rar", items as any);
expect(result1).toHaveLength(2);
expect(result1.every((i: any) => i.fileName.includes("S01E01"))).toBe(true);
const result2 = resolveArchiveItemsFromList("Episode.S01E02.part1.rar", items as any);
expect(result2).toHaveLength(2);
expect(result2.every((i: any) => i.fileName.includes("S01E02"))).toBe(true);
});
});

View File

@ -153,7 +153,7 @@ async function main(): Promise<void> {
createStoragePaths(path.join(tempRoot, "state-pause"))
);
manager2.addPackages([{ name: "pause", links: ["https://dummy/slow"] }]);
manager2.start();
await manager2.start();
await new Promise((resolve) => setTimeout(resolve, 120));
const paused = manager2.togglePause();
assert(paused, "Pause konnte nicht aktiviert werden");

View File

@ -8,6 +8,8 @@ import { setLogListener } from "../src/main/logger";
const tempDirs: string[] = [];
afterEach(() => {
// Ensure session log is shut down between tests
shutdownSessionLog();
// Ensure listener is cleared between tests
setLogListener(null);
for (const dir of tempDirs.splice(0)) {
@ -45,7 +47,7 @@ describe("session-log", () => {
logger.info("Test-Nachricht für Session-Log");
// Wait for flush (200ms interval + margin)
await new Promise((resolve) => setTimeout(resolve, 350));
await new Promise((resolve) => setTimeout(resolve, 500));
const content = fs.readFileSync(logPath, "utf8");
expect(content).toContain("Test-Nachricht für Session-Log");
@ -79,7 +81,7 @@ describe("session-log", () => {
const { logger } = await import("../src/main/logger");
logger.info("Nach-Shutdown-Nachricht");
await new Promise((resolve) => setTimeout(resolve, 350));
await new Promise((resolve) => setTimeout(resolve, 500));
const content = fs.readFileSync(logPath, "utf8");
expect(content).not.toContain("Nach-Shutdown-Nachricht");
@ -137,7 +139,7 @@ describe("session-log", () => {
shutdownSessionLog();
});
it("multiple sessions create different files", () => {
it("multiple sessions create different files", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
tempDirs.push(baseDir);
@ -146,10 +148,7 @@ describe("session-log", () => {
shutdownSessionLog();
// Small delay to ensure different timestamp
const start = Date.now();
while (Date.now() - start < 1100) {
// busy-wait for 1.1 seconds to get different second in filename
}
await new Promise((resolve) => setTimeout(resolve, 1100));
initSessionLog(baseDir);
const path2 = getSessionLogPath();

View File

@ -12,5 +12,5 @@
"isolatedModules": true,
"types": ["node", "vite/client"]
},
"include": ["src", "tests", "vite.config.ts"]
"include": ["src", "tests", "vite.config.mts"]
}