release: publish Multi-Debrid Downloader v2.0.14
Add live English and German localization, queue availability and metadata resolution, responsive package controls, polished navigation and drag interactions, clearer history and account states, and a rebuilt public README. Harden Windows packaging with verified icons and version metadata, archive inspection, and expanded release tests.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function resolveAppIconPath(isPackaged: boolean, appPath: string, resourcesPath: string): string {
|
||||
const basePath = isPackaged ? resourcesPath : appPath;
|
||||
return path.join(basePath, "assets", "app_icon.ico");
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export const ONLINE_BACKUP_API_URL = "https://downloader.24-music.de/backup-api"
|
||||
export function defaultSettings(): AppSettings {
|
||||
const baseDir = path.join(os.homedir(), "Desktop", "Multi-Debrid-Downloader");
|
||||
return {
|
||||
language: "en",
|
||||
token: "",
|
||||
realDebridUseWebLogin: false,
|
||||
megaLogin: "",
|
||||
@@ -120,7 +121,8 @@ export function defaultSettings(): AppSettings {
|
||||
totalCompletedFilesAllTime: 0,
|
||||
totalRuntimeAllTimeMs: 0,
|
||||
bandwidthSchedules: [],
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"],
|
||||
columnOrderVersion: 3,
|
||||
extractCpuPriority: "high",
|
||||
autoExtractWhenStopped: true,
|
||||
disabledProviders: [],
|
||||
|
||||
+17
-47
@@ -1697,13 +1697,23 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
|
||||
export interface RapidgatorCheckResult {
|
||||
online: boolean;
|
||||
fileName: string;
|
||||
fileSize: string | null;
|
||||
fileSizeBytes: number | null;
|
||||
}
|
||||
|
||||
const RG_FILE_ID_RE = /\/file\/([a-z0-9]{32}|\d+)/i;
|
||||
const RG_FILE_NOT_FOUND_RE = />\s*404\s*File not found/i;
|
||||
const RG_FILESIZE_RE = /File\s*size:\s*<strong>([^<>"]+)<\/strong>/i;
|
||||
|
||||
export function parseRapidgatorFileSize(value: string | null | undefined): number | null {
|
||||
const match = String(value ?? "").trim().match(/^([\d.,]+)\s*(B|KB|KIB|MB|MIB|GB|GIB|TB|TIB)$/i);
|
||||
if (!match) return null;
|
||||
const amount = Number(match[1].replace(/,/g, "."));
|
||||
if (!Number.isFinite(amount) || amount < 0) return null;
|
||||
const powers: Record<string, number> = { B: 0, KB: 1, KIB: 1, MB: 2, MIB: 2, GB: 3, GIB: 3, TB: 4, TIB: 4 };
|
||||
const bytes = Math.round(amount * (1024 ** powers[match[2].toUpperCase()]));
|
||||
return Number.isSafeInteger(bytes) ? bytes : null;
|
||||
}
|
||||
|
||||
export async function checkRapidgatorOnline(
|
||||
link: string,
|
||||
signal?: AbortSignal
|
||||
@@ -1723,47 +1733,7 @@ export async function checkRapidgatorOnline(
|
||||
"Accept-Language": "en-US,en;q=0.9,de;q=0.8"
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
||||
try {
|
||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||
|
||||
const response = await fetch(link, {
|
||||
method: "HEAD",
|
||||
redirect: "follow",
|
||||
headers,
|
||||
signal: withTimeoutSignal(signal, 15000)
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const finalUrl = response.url || link;
|
||||
if (!finalUrl.includes(fileId)) {
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
}
|
||||
const fileName = filenameFromRapidgatorUrlPath(link);
|
||||
return { online: true, fileName, fileSize: null };
|
||||
}
|
||||
|
||||
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
|
||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
} catch (error) {
|
||||
const errorText = compactErrorText(error);
|
||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
|
||||
if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
|
||||
break;
|
||||
}
|
||||
await sleepWithSignal(retryDelay(attempt), signal);
|
||||
}
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
||||
try {
|
||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||
|
||||
@@ -1776,7 +1746,7 @@ export async function checkRapidgatorOnline(
|
||||
|
||||
if (response.status === 404) {
|
||||
try { await response.body?.cancel(); } catch { }
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
return { online: false, fileName: "", fileSizeBytes: null };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1791,20 +1761,20 @@ export async function checkRapidgatorOnline(
|
||||
const finalUrl = response.url || link;
|
||||
if (!finalUrl.includes(fileId)) {
|
||||
try { await response.body?.cancel(); } catch { }
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
return { online: false, fileName: "", fileSizeBytes: null };
|
||||
}
|
||||
|
||||
const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal);
|
||||
|
||||
if (RG_FILE_NOT_FOUND_RE.test(html)) {
|
||||
return { online: false, fileName: "", fileSize: null };
|
||||
return { online: false, fileName: "", fileSizeBytes: null };
|
||||
}
|
||||
|
||||
const fileName = extractRapidgatorFilenameFromHtml(html) || filenameFromRapidgatorUrlPath(link);
|
||||
const sizeMatch = html.match(RG_FILESIZE_RE);
|
||||
const fileSize = sizeMatch ? sizeMatch[1].trim() : null;
|
||||
const fileSizeBytes = parseRapidgatorFileSize(sizeMatch?.[1]);
|
||||
|
||||
return { online: true, fileName, fileSize };
|
||||
return { online: true, fileName, fileSizeBytes };
|
||||
} catch (error) {
|
||||
const errorText = compactErrorText(error);
|
||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const DEV_SERVER_PORT = process.env.DEV_SERVER_PORT || "5180";
|
||||
export const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
||||
@@ -605,7 +605,7 @@ export function getAuthoritativeRealDebridTotal(
|
||||
if (!resumeHardResetUsed || source !== "content-length") {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1696,14 +1696,27 @@ function formatExtractFailureLabel(reason: string, archiveName = ""): string {
|
||||
: `Entpack-Fehler: ${summary}`;
|
||||
}
|
||||
|
||||
function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
||||
function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
||||
const exponential = baseMs * Math.pow(1.5, Math.min(attempt - 1, 14));
|
||||
const capped = Math.min(exponential, 120000);
|
||||
const jitter = capped * (0.5 + Math.random() * 0.5);
|
||||
return Math.floor(jitter);
|
||||
}
|
||||
|
||||
export class DownloadManager extends EventEmitter {
|
||||
return Math.floor(jitter);
|
||||
}
|
||||
|
||||
export async function runWithLimitedConcurrency<T>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
|
||||
const workerCount = Math.min(items.length, Math.max(1, Math.floor(concurrency)));
|
||||
let nextIndex = 0;
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const item = items[nextIndex];
|
||||
nextIndex += 1;
|
||||
await worker(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
export class DownloadManager extends EventEmitter {
|
||||
private settings: AppSettings;
|
||||
|
||||
private session: SessionState;
|
||||
@@ -3278,31 +3291,28 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
const checkedUrls = new Map<string, Awaited<ReturnType<typeof checkRapidgatorOnline>>>();
|
||||
|
||||
for (const { itemId, url } of itemsToCheck) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) continue;
|
||||
|
||||
if (checkedUrls.has(url)) {
|
||||
const cached = checkedUrls.get(url);
|
||||
if (cached !== undefined) {
|
||||
this.applyRapidgatorCheckResult(item, cached);
|
||||
}
|
||||
this.emitState();
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await checkRapidgatorOnline(url);
|
||||
checkedUrls.set(url, result);
|
||||
this.applyRapidgatorCheckResult(item, result);
|
||||
} catch (err) {
|
||||
logger.warn(`checkRapidgatorOnline Fehler für ${url}: ${compactErrorText(err)}`);
|
||||
item.onlineStatus = undefined;
|
||||
}
|
||||
this.emitState();
|
||||
}
|
||||
const checkedUrls = new Map<string, ReturnType<typeof checkRapidgatorOnline>>();
|
||||
|
||||
await runWithLimitedConcurrency(itemsToCheck, 8, async ({ itemId, url }) => {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) return;
|
||||
|
||||
let check = checkedUrls.get(url);
|
||||
if (!check) {
|
||||
check = checkRapidgatorOnline(url);
|
||||
checkedUrls.set(url, check);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await check;
|
||||
this.applyRapidgatorCheckResult(item, result);
|
||||
} catch (err) {
|
||||
logger.warn(`checkRapidgatorOnline Fehler für ${url}: ${compactErrorText(err)}`);
|
||||
item.onlineStatus = undefined;
|
||||
}
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
});
|
||||
|
||||
this.persistSoon();
|
||||
}
|
||||
@@ -3354,8 +3364,11 @@ export class DownloadManager extends EventEmitter {
|
||||
if (result.fileName && looksLikeOpaqueFilename(item.fileName)) {
|
||||
item.fileName = sanitizeFilename(result.fileName);
|
||||
this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, item.fileName));
|
||||
}
|
||||
item.onlineStatus = "online";
|
||||
}
|
||||
if (result.fileSizeBytes !== null && result.fileSizeBytes > 0) {
|
||||
item.totalBytes = result.fileSizeBytes;
|
||||
}
|
||||
item.onlineStatus = "online";
|
||||
item.updatedAt = nowMs();
|
||||
}
|
||||
}
|
||||
@@ -3364,7 +3377,8 @@ export class DownloadManager extends EventEmitter {
|
||||
const uncheckedIds: string[] = [];
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
if (item.status !== "queued") continue;
|
||||
if (item.onlineStatus) continue;
|
||||
if (item.onlineStatus === "offline") continue;
|
||||
if (item.onlineStatus === "online" && item.totalBytes !== null && item.totalBytes > 0) continue;
|
||||
try {
|
||||
const host = new URL(item.url).hostname.toLowerCase();
|
||||
if (host !== "rapidgator.net" && !host.endsWith(".rapidgator.net") && host !== "rg.to" && !host.endsWith(".rg.to")) continue;
|
||||
|
||||
+5
-3
@@ -11,6 +11,8 @@ import { APP_NAME } from "./constants";
|
||||
import { extractHttpLinksFromText } from "./utils";
|
||||
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
|
||||
import { revealHistoryEntry } from "./history-reveal";
|
||||
import { DEV_SERVER_URL } from "./dev-server-url";
|
||||
import { resolveAppIconPath } from "./app-icon";
|
||||
|
||||
function validateString(value: unknown, name: string): string {
|
||||
if (typeof value !== "string") {
|
||||
@@ -115,7 +117,7 @@ function createWindow(): BrowserWindow {
|
||||
minHeight: 760,
|
||||
backgroundColor: "#070b14",
|
||||
title: `${APP_NAME} - v${controller.getVersion()}`,
|
||||
icon: path.join(app.getAppPath(), "assets", "app_icon.ico"),
|
||||
icon: resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
@@ -140,7 +142,7 @@ function createWindow(): BrowserWindow {
|
||||
window.setAutoHideMenuBar(true);
|
||||
|
||||
if (isDevMode()) {
|
||||
void window.loadURL("http://localhost:5173");
|
||||
void window.loadURL(DEV_SERVER_URL);
|
||||
} else {
|
||||
void window.loadFile(path.join(app.getAppPath(), "build", "renderer", "index.html"));
|
||||
}
|
||||
@@ -212,7 +214,7 @@ function createTray(): void {
|
||||
if (tray) {
|
||||
return;
|
||||
}
|
||||
const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
|
||||
const iconPath = resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath);
|
||||
try {
|
||||
tray = new Tray(iconPath);
|
||||
} catch (error) {
|
||||
|
||||
+22
-13
@@ -135,10 +135,10 @@ function migrateLegacyDefaultDirectories(settings: AppSettings, defaults: AppSet
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"];
|
||||
const ALL_VALID_COLUMNS = new Set([...DEFAULT_COLUMN_ORDER, "added"]);
|
||||
|
||||
function normalizeColumnOrder(raw: unknown): string[] {
|
||||
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"];
|
||||
const ALL_VALID_COLUMNS = new Set([...DEFAULT_COLUMN_ORDER, "added"]);
|
||||
|
||||
function normalizeColumnOrder(raw: unknown, version: unknown): string[] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return [...DEFAULT_COLUMN_ORDER];
|
||||
}
|
||||
@@ -151,10 +151,14 @@ function normalizeColumnOrder(raw: unknown): string[] {
|
||||
result.push(col);
|
||||
}
|
||||
}
|
||||
if (!seen.has("name")) {
|
||||
result.unshift("name");
|
||||
}
|
||||
return result;
|
||||
if (!seen.has("name")) {
|
||||
result.unshift("name");
|
||||
}
|
||||
if (version !== 3 && !seen.has("availability")) {
|
||||
const speedIndex = result.indexOf("speed");
|
||||
result.splice(speedIndex >= 0 ? speedIndex + 1 : result.length, 0, "availability");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPreferredMegaDebridProvider(megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridProvider {
|
||||
@@ -421,6 +425,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
);
|
||||
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
|
||||
const normalized: AppSettings = {
|
||||
language: settings.language === "de" ? "de" : "en",
|
||||
token: asText(settings.token),
|
||||
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
|
||||
megaLogin,
|
||||
@@ -506,7 +511,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
|
||||
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
|
||||
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
|
||||
columnOrder: normalizeColumnOrder(settings.columnOrder),
|
||||
columnOrder: normalizeColumnOrder(settings.columnOrder, settings.columnOrderVersion),
|
||||
columnOrderVersion: 3,
|
||||
extractCpuPriority: settings.extractCpuPriority,
|
||||
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped,
|
||||
disabledProviders: normalizeDisabledProviders(settings.disabledProviders),
|
||||
@@ -677,10 +683,13 @@ function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
|
||||
function readSettingsFile(filePath: string): AppSettings | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
|
||||
const merged = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
...migrateLegacyMegaEnableFlags(parsed)
|
||||
});
|
||||
const migratedLanguage = (parsed as Partial<AppSettings>).language === undefined ? "de" : parsed.language;
|
||||
const merged = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
...migrateLegacyMegaEnableFlags(parsed),
|
||||
language: migratedLanguage,
|
||||
columnOrderVersion: parsed.columnOrderVersion
|
||||
});
|
||||
return sanitizeCredentialPersistence(merged);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code || "";
|
||||
|
||||
Reference in New Issue
Block a user