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>
This commit is contained in:
parent
a3c2680fec
commit
a1c8f42435
@ -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); });
|
||||
@ -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"
|
||||
|
||||
@ -26,6 +26,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,6 +43,8 @@ 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() {
|
||||
@ -152,9 +155,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 +182,8 @@ public final class JBindExtractorMain {
|
||||
out.close();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
in.close();
|
||||
} catch (Throwable ignored) {
|
||||
@ -188,11 +196,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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -221,6 +237,9 @@ public final class JBindExtractorMain {
|
||||
IInArchive archive = context.archive;
|
||||
ISimpleInArchive simple = archive.getSimpleInterface();
|
||||
ISimpleInArchiveItem[] items = simple.getArchiveItems();
|
||||
if (items == null) {
|
||||
throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath());
|
||||
}
|
||||
|
||||
long totalUnits = 0;
|
||||
boolean encrypted = false;
|
||||
@ -260,8 +279,10 @@ public final class JBindExtractorMain {
|
||||
}
|
||||
|
||||
ensureDirectory(output.getParentFile());
|
||||
rejectSymlink(output);
|
||||
final FileOutputStream out = new FileOutputStream(output);
|
||||
final long[] remaining = new long[] { itemUnits };
|
||||
boolean extractionSuccess = false;
|
||||
try {
|
||||
ExtractOperationResult result = item.extractSlow(new ISequentialOutStream() {
|
||||
@Override
|
||||
@ -291,6 +312,7 @@ public final class JBindExtractorMain {
|
||||
}
|
||||
throw new IOException("7z-Fehler: " + result.name());
|
||||
}
|
||||
extractionSuccess = true;
|
||||
} catch (SevenZipException error) {
|
||||
if (looksLikeWrongPassword(error, encrypted)) {
|
||||
throw new WrongPasswordException(error);
|
||||
@ -301,6 +323,12 @@ public final class JBindExtractorMain {
|
||||
out.close();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (!extractionSuccess && output.exists()) {
|
||||
try {
|
||||
output.delete();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@ -328,14 +356,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 +441,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 +525,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 +539,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 +558,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;
|
||||
@ -828,12 +910,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 +924,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
|
||||
|
||||
@ -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)}`);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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); });
|
||||
|
||||
@ -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); });
|
||||
|
||||
@ -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); });
|
||||
|
||||
@ -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"]);
|
||||
|
||||
@ -285,7 +285,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 +307,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("***")) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1154,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;
|
||||
@ -1163,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,
|
||||
@ -1338,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);
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -20,6 +20,23 @@ 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";
|
||||
|
||||
// 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 } from "./cleanup";
|
||||
import { DebridService, MegaWebUnrestrictor, checkRapidgatorOnline } from "./debrid";
|
||||
import { clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates } from "./extractor";
|
||||
@ -3212,11 +3229,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];
|
||||
@ -3305,7 +3322,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") {
|
||||
@ -5152,16 +5169,13 @@ export class DownloadManager extends EventEmitter {
|
||||
const connectTimeoutMs = getDownloadConnectTimeoutMs();
|
||||
let connectTimer: NodeJS.Timeout | null = null;
|
||||
const connectAbortController = new AbortController();
|
||||
const prevTlsReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
||||
if (skipTlsVerify) acquireTlsSkip();
|
||||
try {
|
||||
if (connectTimeoutMs > 0) {
|
||||
connectTimer = setTimeout(() => {
|
||||
connectAbortController.abort("connect_timeout");
|
||||
}, connectTimeoutMs);
|
||||
}
|
||||
if (skipTlsVerify) {
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||||
}
|
||||
response = await fetch(directUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
@ -5181,10 +5195,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (skipTlsVerify) {
|
||||
if (prevTlsReject === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
||||
else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTlsReject;
|
||||
}
|
||||
if (skipTlsVerify) releaseTlsSkip();
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 } 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", () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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`);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -61,7 +61,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 +115,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" },
|
||||
@ -1878,10 +1869,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());
|
||||
}, []);
|
||||
|
||||
@ -1924,28 +1917,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 => {
|
||||
@ -2279,7 +2272,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();
|
||||
}
|
||||
@ -2293,7 +2286,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>
|
||||
@ -2520,7 +2513,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 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>}
|
||||
@ -2607,7 +2600,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>
|
||||
)}
|
||||
@ -3052,8 +3045,8 @@ 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>
|
||||
)}
|
||||
@ -3063,7 +3056,7 @@ export function App(): ReactElement {
|
||||
<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")}
|
||||
@ -3088,7 +3081,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>
|
||||
)}
|
||||
@ -3097,7 +3090,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>
|
||||
)}
|
||||
@ -3129,7 +3122,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={() => {
|
||||
@ -3214,7 +3207,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>
|
||||
@ -3228,8 +3221,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>
|
||||
@ -3237,15 +3230,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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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-"));
|
||||
@ -70,10 +66,6 @@ describe("extractor jvm backend", () => {
|
||||
});
|
||||
|
||||
it("respects ask/skip conflict mode in jvm backend", async () => {
|
||||
if (!hasJavaRuntime() || !hasJvmExtractorRuntime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-"));
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -12,5 +12,5 @@
|
||||
"isolatedModules": true,
|
||||
"types": ["node", "vite/client"]
|
||||
},
|
||||
"include": ["src", "tests", "vite.config.ts"]
|
||||
"include": ["src", "tests", "vite.config.mts"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user