Fix provider selection persistence, queue naming, cancel removal, and update prompts
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-02-27 04:56:53 +01:00
parent 3ef2ee732a
commit 7ac61ce64a
14 changed files with 267 additions and 74 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "real-debrid-downloader",
"version": "1.1.16",
"version": "1.1.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "real-debrid-downloader",
"version": "1.1.16",
"version": "1.1.17",
"license": "MIT",
"dependencies": {
"adm-zip": "^0.5.16",

View File

@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "1.1.16",
"version": "1.1.17",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",

View File

@ -3,7 +3,7 @@ import os from "node:os";
import { AppSettings } from "../shared/types";
export const APP_NAME = "Debrid Download Manager";
export const APP_VERSION = "1.1.16";
export const APP_VERSION = "1.1.17";
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";

View File

@ -1,7 +1,7 @@
import { AppSettings, DebridProvider } from "../shared/types";
import { REQUEST_RETRIES } from "./constants";
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
@ -108,6 +108,70 @@ function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] {
return result;
}
function isRapidgatorLink(link: string): boolean {
try {
return new URL(link).hostname.toLowerCase().includes("rapidgator.net");
} catch {
return false;
}
}
function decodeHtmlEntities(text: string): string {
return text
.replace(/&/g, "&")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
if (items.length === 0) {
return;
}
const size = Math.max(1, Math.min(concurrency, items.length));
let index = 0;
const runners = Array.from({ length: size }, async () => {
while (index < items.length) {
const current = items[index];
index += 1;
await worker(current);
}
});
await Promise.all(runners);
}
async function resolveRapidgatorFilename(link: string): Promise<string> {
if (!isRapidgatorLink(link)) {
return "";
}
try {
const response = await fetch(link, {
method: "GET",
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
}
});
if (!response.ok) {
return "";
}
const html = await response.text();
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
const title = decodeHtmlEntities((titleMatch?.[1] || "").trim());
if (!title) {
return "";
}
const preferred = title.match(/download\s+file\s+(.+)$/i)?.[1]?.trim() || title;
if (!preferred) {
return "";
}
const withoutSuffix = preferred.replace(/\s*-\s*rapidgator.*$/i, "").trim();
return withoutSuffix;
} catch {
return "";
}
}
function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] {
const linkParam = encodeURIComponent(link);
const authParam = encodeURIComponent(token);
@ -421,39 +485,42 @@ export class DebridService {
}
public async resolveFilenames(links: string[]): Promise<Map<string, string>> {
const unresolved = links.filter((link) => filenameFromUrl(link) === "download.bin");
const unresolved = links.filter((link) => looksLikeOpaqueFilename(filenameFromUrl(link)));
if (unresolved.length === 0) {
return new Map<string, string>();
}
const clean = new Map<string, string>();
const token = this.settings.allDebridToken.trim();
if (!token) {
return new Map<string, string>();
if (token) {
try {
const infos = await this.allDebridClient.getLinkInfos(unresolved);
for (const [link, fileName] of infos.entries()) {
if (fileName.trim() && !looksLikeOpaqueFilename(fileName.trim())) {
clean.set(link, fileName.trim());
}
}
} catch {
// ignore and continue with host page fallback
}
}
try {
const infos = await this.allDebridClient.getLinkInfos(unresolved);
const clean = new Map<string, string>();
for (const [link, fileName] of infos.entries()) {
if (fileName.trim() && fileName.trim().toLowerCase() !== "download.bin") {
clean.set(link, fileName.trim());
}
const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link));
await runWithConcurrency(remaining, 6, async (link) => {
const fromPage = await resolveRapidgatorFilename(link);
if (fromPage && !looksLikeOpaqueFilename(fromPage)) {
clean.set(link, fromPage);
}
return clean;
} catch {
return new Map<string, string>();
}
});
return clean;
}
public async unrestrictLink(link: string): Promise<ProviderUnrestrictedLink> {
const order = uniqueProviderOrder([
this.settings.providerPrimary,
this.settings.providerSecondary,
this.settings.providerTertiary,
"realdebrid",
"megadebrid",
"bestdebrid",
"alldebrid"
this.settings.providerTertiary
]);
let configuredFound = false;

View File

@ -11,7 +11,7 @@ import { extractPackageArchives } from "./extractor";
import { validateFileAgainstManifest } from "./integrity";
import { logger } from "./logger";
import { StoragePaths, saveSession } from "./storage";
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, nowMs, sanitizeFilename, sleep } from "./utils";
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils";
type ActiveTask = {
itemId: string;
@ -99,6 +99,10 @@ export class DownloadManager extends EventEmitter {
private nonResumableActive = 0;
private stateEmitTimer: NodeJS.Timeout | null = null;
private speedBytesLastWindow = 0;
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths) {
super();
this.settings = settings;
@ -129,8 +133,8 @@ export class DownloadManager extends EventEmitter {
public getSnapshot(): UiSnapshot {
const now = nowMs();
this.speedEvents = this.speedEvents.filter((event) => event.at >= now - 3000);
const speedBps = this.speedEvents.reduce((acc, event) => acc + event.bytes, 0) / 3;
this.pruneSpeedEvents(now);
const speedBps = this.speedBytesLastWindow / 3;
const totalItems = Object.keys(this.session.items).length;
const doneItems = Object.values(this.session.items).filter((item) => isFinishedStatus(item.status)).length;
@ -159,7 +163,7 @@ export class DownloadManager extends EventEmitter {
this.session.summaryText = "";
this.summary = null;
this.persistNow();
this.emitState();
this.emitState(true);
}
public addPackages(packages: ParsedPackageInput[]): { addedPackages: number; addedLinks: number } {
@ -212,7 +216,7 @@ export class DownloadManager extends EventEmitter {
};
packageEntry.itemIds.push(itemId);
this.session.items[itemId] = item;
if (fileName === "download.bin") {
if (looksLikeOpaqueFilename(fileName)) {
const existing = unresolvedByLink.get(link) ?? [];
existing.push(itemId);
unresolvedByLink.set(link, existing);
@ -256,7 +260,7 @@ export class DownloadManager extends EventEmitter {
if (!item) {
continue;
}
if (item.fileName !== "download.bin") {
if (!looksLikeOpaqueFilename(item.fileName)) {
continue;
}
item.fileName = normalized;
@ -280,20 +284,13 @@ export class DownloadManager extends EventEmitter {
if (!pkg) {
return;
}
pkg.cancelled = true;
pkg.status = "cancelled";
pkg.updatedAt = nowMs();
const itemIds = [...pkg.itemIds];
for (const itemId of pkg.itemIds) {
for (const itemId of itemIds) {
const item = this.session.items[itemId];
if (!item) {
continue;
}
if (item.status === "queued" || item.status === "validating" || item.status === "reconnect_wait") {
item.status = "cancelled";
item.fullStatus = "Entfernt";
item.updatedAt = nowMs();
}
const active = this.activeTasks.get(itemId);
if (active) {
active.abortReason = "cancel";
@ -302,9 +299,10 @@ export class DownloadManager extends EventEmitter {
}
const removed = cleanupCancelledPackageArtifacts(pkg.outputDir);
this.removePackageFromSession(packageId, itemIds);
logger.info(`Paket ${pkg.name} abgebrochen, ${removed} Artefakte gelöscht`);
this.persistSoon();
this.emitState();
this.emitState(true);
}
public start(): void {
@ -316,7 +314,7 @@ export class DownloadManager extends EventEmitter {
this.session.runStartedAt = this.session.runStartedAt || nowMs();
this.summary = null;
this.persistSoon();
this.emitState();
this.emitState(true);
this.ensureScheduler();
}
@ -328,7 +326,7 @@ export class DownloadManager extends EventEmitter {
active.abortController.abort("stop");
}
this.persistSoon();
this.emitState();
this.emitState(true);
}
public togglePause(): boolean {
@ -337,7 +335,7 @@ export class DownloadManager extends EventEmitter {
}
this.session.paused = !this.session.paused;
this.persistSoon();
this.emitState();
this.emitState(true);
return this.session.paused;
}
@ -400,8 +398,46 @@ export class DownloadManager extends EventEmitter {
saveSession(this.storagePaths, this.session);
}
private emitState(): void {
this.emit("state", this.getSnapshot());
private emitState(force = false): void {
if (force) {
if (this.stateEmitTimer) {
clearTimeout(this.stateEmitTimer);
this.stateEmitTimer = null;
}
this.emit("state", this.getSnapshot());
return;
}
if (this.stateEmitTimer) {
return;
}
this.stateEmitTimer = setTimeout(() => {
this.stateEmitTimer = null;
this.emit("state", this.getSnapshot());
}, 140);
}
private pruneSpeedEvents(now: number): void {
while (this.speedEvents.length > 0 && this.speedEvents[0].at < now - 3000) {
const event = this.speedEvents.shift();
if (event) {
this.speedBytesLastWindow = Math.max(0, this.speedBytesLastWindow - event.bytes);
}
}
}
private recordSpeed(bytes: number): void {
const now = nowMs();
this.speedEvents.push({ at: now, bytes });
this.speedBytesLastWindow += bytes;
this.pruneSpeedEvents(now);
}
private removePackageFromSession(packageId: string, itemIds: string[]): void {
for (const itemId of itemIds) {
delete this.session.items[itemId];
}
delete this.session.packages[packageId];
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
}
private async ensureScheduler(): Promise<void> {
@ -748,8 +784,7 @@ export class DownloadManager extends EventEmitter {
written += buffer.length;
windowBytes += buffer.length;
this.session.totalDownloadedBytes += buffer.length;
this.speedEvents.push({ at: nowMs(), bytes: buffer.length });
this.speedEvents = this.speedEvents.filter((event) => event.at >= nowMs() - 3000);
this.recordSpeed(buffer.length);
const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.1);
const speed = windowBytes / elapsed;
@ -801,7 +836,8 @@ export class DownloadManager extends EventEmitter {
return;
}
const globalBytes = this.speedEvents.reduce((acc, event) => acc + event.bytes, 0) + chunkBytes;
this.pruneSpeedEvents(now);
const globalBytes = this.speedBytesLastWindow + chunkBytes;
const globalAllowed = bytesPerSecond * 3;
if (globalBytes > globalAllowed) {
await sleep(Math.min(250, Math.ceil(((globalBytes - globalAllowed) / bytesPerSecond) * 1000)));

View File

@ -1,5 +1,5 @@
import path from "node:path";
import { app, BrowserWindow, dialog, ipcMain, IpcMainInvokeEvent } from "electron";
import { app, BrowserWindow, dialog, ipcMain, IpcMainInvokeEvent, shell } from "electron";
import { AddLinksPayload, AppSettings } from "../shared/types";
import { AppController } from "./app-controller";
import { IPC_CHANNELS } from "../shared/ipc";
@ -41,6 +41,18 @@ function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.GET_SNAPSHOT, () => controller.getSnapshot());
ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
ipcMain.handle(IPC_CHANNELS.CHECK_UPDATES, async () => controller.checkUpdates());
ipcMain.handle(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => {
try {
const parsed = new URL(String(rawUrl || "").trim());
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return false;
}
await shell.openExternal(parsed.toString());
return true;
} catch {
return false;
}
});
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => controller.updateSettings(partial ?? {}));
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => controller.addLinks(payload));
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => controller.addContainers(filePaths ?? []));

View File

@ -62,15 +62,21 @@ export function filenameFromUrl(url: string): string {
const normalized = decoded
.replace(/\.(rar|zip|7z|tar|gz|bz2|xz|iso|part\d+\.rar|r\d{2})\.html$/i, ".$1")
.replace(/\.(mp4|mkv|avi|mp3|flac|srt)\.html$/i, ".$1");
if (/^[a-f0-9]{24,}$/i.test(normalized)) {
return "download.bin";
}
return sanitizeFilename(normalized || "download.bin");
} catch {
return "download.bin";
}
}
export function looksLikeOpaqueFilename(name: string): boolean {
const cleaned = sanitizeFilename(name || "").toLowerCase();
if (!cleaned || cleaned === "download.bin") {
return true;
}
const parsed = path.parse(cleaned);
return /^[a-f0-9]{24,}$/i.test(parsed.name || cleaned);
}
export function inferPackageNameFromLinks(links: string[]): string {
if (links.length === 0) {
return "Paket";

View File

@ -7,6 +7,7 @@ const api: ElectronApi = {
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),

View File

@ -1,5 +1,5 @@
import { DragEvent, ReactElement, useEffect, useMemo, useState } from "react";
import type { AppSettings, DebridProvider, DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
import type { AppSettings, DebridProvider, DownloadItem, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types";
type Tab = "collector" | "downloads" | "settings";
@ -86,10 +86,7 @@ export function App(): ReactElement {
setSettingsDraft(state.settings);
if (state.settings.autoUpdateCheck) {
void window.rd.checkUpdates().then((result) => {
if (result.updateAvailable) {
setStatusToast(`Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})`);
setTimeout(() => setStatusToast(""), 3800);
}
void handleUpdateResult(result, "startup");
});
}
});
@ -107,6 +104,37 @@ export function App(): ReactElement {
.map((id: string) => snapshot.session.packages[id])
.filter(Boolean), [snapshot]);
const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise<void> => {
if (result.error) {
if (source === "manual") {
setStatusToast(`Update-Check fehlgeschlagen: ${result.error}`);
setTimeout(() => setStatusToast(""), 2800);
}
return;
}
if (!result.updateAvailable) {
if (source === "manual") {
setStatusToast(`Kein Update verfügbar (v${result.currentVersion})`);
setTimeout(() => setStatusToast(""), 2000);
}
return;
}
const approved = window.confirm(
`Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt Download-Seite öffnen?`
);
if (!approved) {
setStatusToast(`Update verfügbar: ${result.latestTag}`);
setTimeout(() => setStatusToast(""), 2600);
return;
}
const opened = await window.rd.openExternal(result.releaseUrl);
setStatusToast(opened ? "Download-Seite im Browser geöffnet" : "Konnte Download-Seite nicht öffnen");
setTimeout(() => setStatusToast(""), 2600);
};
const onSaveSettings = async (): Promise<void> => {
await window.rd.updateSettings(settingsDraft);
setStatusToast("Settings gespeichert");
@ -115,18 +143,7 @@ export function App(): ReactElement {
const onCheckUpdates = async (): Promise<void> => {
const result = await window.rd.checkUpdates();
if (result.error) {
setStatusToast(`Update-Check fehlgeschlagen: ${result.error}`);
setTimeout(() => setStatusToast(""), 2800);
return;
}
if (result.updateAvailable) {
setStatusToast(`Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})`);
setTimeout(() => setStatusToast(""), 3200);
return;
}
setStatusToast(`Kein Update verfügbar (v${result.currentVersion})`);
setTimeout(() => setStatusToast(""), 2000);
await handleUpdateResult(result, "manual");
};
const onAddLinks = async (): Promise<void> => {
@ -193,7 +210,14 @@ export function App(): ReactElement {
<section className="control-strip">
<div className="buttons">
<button className="btn accent" disabled={!snapshot.canStart} onClick={() => window.rd.start()}>Start</button>
<button
className="btn accent"
disabled={!snapshot.canStart}
onClick={async () => {
await window.rd.updateSettings(settingsDraft);
await window.rd.start();
}}
>Start</button>
<button className="btn" disabled={!snapshot.canPause} onClick={() => window.rd.togglePause()}>
{snapshot.session.paused ? "Resume" : "Pause"}
</button>

View File

@ -2,6 +2,7 @@ export const IPC_CHANNELS = {
GET_SNAPSHOT: "app:get-snapshot",
GET_VERSION: "app:get-version",
CHECK_UPDATES: "app:check-updates",
OPEN_EXTERNAL: "app:open-external",
UPDATE_SETTINGS: "app:update-settings",
ADD_LINKS: "queue:add-links",
ADD_CONTAINERS: "queue:add-containers",

View File

@ -4,6 +4,7 @@ export interface ElectronApi {
getSnapshot: () => Promise<UiSnapshot>;
getVersion: () => Promise<string>;
checkUpdates: () => Promise<UpdateCheckResult>;
openExternal: (url: string) => Promise<boolean>;
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;

View File

@ -142,4 +142,41 @@ describe("debrid service", () => {
expect(result.directUrl).toBe("https://alldebrid.example/file.bin");
expect(result.fileSize).toBe(4096);
});
it("respects provider selection and does not append hidden fallback providers", async () => {
const settings = {
...defaultSettings(),
token: "",
megaToken: "mega-token",
bestToken: "",
allDebridToken: "ad-token",
providerPrimary: "megadebrid" as const,
providerSecondary: "megadebrid" as const,
providerTertiary: "megadebrid" as const,
autoProviderFallback: true
};
let allDebridCalls = 0;
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("mega-debrid.eu/api.php?action=getLink")) {
return new Response(JSON.stringify({ response_code: "error", response_text: "host unavailable" }), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
if (url.includes("api.alldebrid.com/v4/link/unlock")) {
allDebridCalls += 1;
return new Response(JSON.stringify({ status: "success", data: { link: "https://alldebrid.example/file.bin" } }), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html")).rejects.toThrow();
expect(allDebridCalls).toBe(0);
});
});

View File

@ -185,8 +185,13 @@ async function main(): Promise<void> {
manager4.cancelPackage(pkgId);
await waitFor(() => !manager4.getSnapshot().session.running || Object.values(manager4.getSnapshot().session.items).every((item) => item.status !== "downloading"), 15000);
const cancelSnapshot = manager4.getSnapshot();
const cancelItem = Object.values(cancelSnapshot.session.items)[0];
assert(cancelItem?.status === "cancelled" || cancelItem?.status === "queued", "Paketabbruch nicht wirksam");
const remainingItems = Object.values(cancelSnapshot.session.items);
if (remainingItems.length === 0) {
assert(cancelSnapshot.session.packageOrder.length === 0, "Abgebrochenes Paket wurde nicht entfernt");
} else {
const cancelItem = remainingItems[0];
assert(cancelItem?.status === "cancelled" || cancelItem?.status === "queued", "Paketabbruch nicht wirksam");
}
const packageDir = path.join(path.join(tempRoot, "downloads-cancel"), "cancel");
assert(!fs.existsSync(path.join(packageDir, "release.part1.rar")), "RAR-Artefakt wurde nicht gelöscht");

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parsePackagesFromLinksText, isHttpLink, sanitizeFilename, formatEta, filenameFromUrl } from "../src/main/utils";
import { parsePackagesFromLinksText, isHttpLink, sanitizeFilename, formatEta, filenameFromUrl, looksLikeOpaqueFilename } from "../src/main/utils";
describe("utils", () => {
it("validates http links", () => {
@ -34,6 +34,9 @@ describe("utils", () => {
it("normalizes filenames from links", () => {
expect(filenameFromUrl("https://rapidgator.net/file/id/show.part1.rar.html")).toBe("show.part1.rar");
expect(filenameFromUrl("https://debrid.example/dl/abc?filename=Movie.S01E01.mkv")).toBe("Movie.S01E01.mkv");
expect(filenameFromUrl("https://debrid.example/dl/e51f6809bb6ca615601f5ac5db433737")).toBe("download.bin");
expect(filenameFromUrl("https://debrid.example/dl/e51f6809bb6ca615601f5ac5db433737")).toBe("e51f6809bb6ca615601f5ac5db433737");
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
expect(looksLikeOpaqueFilename("e51f6809bb6ca615601f5ac5db433737")).toBe(true);
expect(looksLikeOpaqueFilename("movie.part1.rar")).toBe(false);
});
});