Fix provider selection persistence, queue naming, cancel removal, and update prompts
Some checks are pending
Build and Release / build (push) Waiting to run
Some checks are pending
Build and Release / build (push) Waiting to run
This commit is contained in:
parent
3ef2ee732a
commit
7ac61ce64a
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.1.16",
|
"version": "1.1.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.1.16",
|
"version": "1.1.17",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.1.16",
|
"version": "1.1.17",
|
||||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import os from "node:os";
|
|||||||
import { AppSettings } from "../shared/types";
|
import { AppSettings } from "../shared/types";
|
||||||
|
|
||||||
export const APP_NAME = "Debrid Download Manager";
|
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 API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
|
||||||
|
|
||||||
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
|
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { AppSettings, DebridProvider } from "../shared/types";
|
import { AppSettings, DebridProvider } from "../shared/types";
|
||||||
import { REQUEST_RETRIES } from "./constants";
|
import { REQUEST_RETRIES } from "./constants";
|
||||||
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
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 MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
|
||||||
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
|
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
|
||||||
@ -108,6 +108,70 @@ function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] {
|
|||||||
return result;
|
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(/</g, "<")
|
||||||
|
.replace(/>/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[] {
|
function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] {
|
||||||
const linkParam = encodeURIComponent(link);
|
const linkParam = encodeURIComponent(link);
|
||||||
const authParam = encodeURIComponent(token);
|
const authParam = encodeURIComponent(token);
|
||||||
@ -421,39 +485,42 @@ export class DebridService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async resolveFilenames(links: string[]): Promise<Map<string, string>> {
|
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) {
|
if (unresolved.length === 0) {
|
||||||
return new Map<string, string>();
|
return new Map<string, string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clean = new Map<string, string>();
|
||||||
const token = this.settings.allDebridToken.trim();
|
const token = this.settings.allDebridToken.trim();
|
||||||
if (!token) {
|
if (token) {
|
||||||
return new Map<string, string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const infos = await this.allDebridClient.getLinkInfos(unresolved);
|
const infos = await this.allDebridClient.getLinkInfos(unresolved);
|
||||||
const clean = new Map<string, string>();
|
|
||||||
for (const [link, fileName] of infos.entries()) {
|
for (const [link, fileName] of infos.entries()) {
|
||||||
if (fileName.trim() && fileName.trim().toLowerCase() !== "download.bin") {
|
if (fileName.trim() && !looksLikeOpaqueFilename(fileName.trim())) {
|
||||||
clean.set(link, fileName.trim());
|
clean.set(link, fileName.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return clean;
|
|
||||||
} catch {
|
} catch {
|
||||||
return new Map<string, string>();
|
// ignore and continue with host page fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string): Promise<ProviderUnrestrictedLink> {
|
public async unrestrictLink(link: string): Promise<ProviderUnrestrictedLink> {
|
||||||
const order = uniqueProviderOrder([
|
const order = uniqueProviderOrder([
|
||||||
this.settings.providerPrimary,
|
this.settings.providerPrimary,
|
||||||
this.settings.providerSecondary,
|
this.settings.providerSecondary,
|
||||||
this.settings.providerTertiary,
|
this.settings.providerTertiary
|
||||||
"realdebrid",
|
|
||||||
"megadebrid",
|
|
||||||
"bestdebrid",
|
|
||||||
"alldebrid"
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let configuredFound = false;
|
let configuredFound = false;
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { extractPackageArchives } from "./extractor";
|
|||||||
import { validateFileAgainstManifest } from "./integrity";
|
import { validateFileAgainstManifest } from "./integrity";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { StoragePaths, saveSession } from "./storage";
|
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 = {
|
type ActiveTask = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@ -99,6 +99,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private nonResumableActive = 0;
|
private nonResumableActive = 0;
|
||||||
|
|
||||||
|
private stateEmitTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
private speedBytesLastWindow = 0;
|
||||||
|
|
||||||
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths) {
|
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths) {
|
||||||
super();
|
super();
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
@ -129,8 +133,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
public getSnapshot(): UiSnapshot {
|
public getSnapshot(): UiSnapshot {
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
this.speedEvents = this.speedEvents.filter((event) => event.at >= now - 3000);
|
this.pruneSpeedEvents(now);
|
||||||
const speedBps = this.speedEvents.reduce((acc, event) => acc + event.bytes, 0) / 3;
|
const speedBps = this.speedBytesLastWindow / 3;
|
||||||
|
|
||||||
const totalItems = Object.keys(this.session.items).length;
|
const totalItems = Object.keys(this.session.items).length;
|
||||||
const doneItems = Object.values(this.session.items).filter((item) => isFinishedStatus(item.status)).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.session.summaryText = "";
|
||||||
this.summary = null;
|
this.summary = null;
|
||||||
this.persistNow();
|
this.persistNow();
|
||||||
this.emitState();
|
this.emitState(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public addPackages(packages: ParsedPackageInput[]): { addedPackages: number; addedLinks: number } {
|
public addPackages(packages: ParsedPackageInput[]): { addedPackages: number; addedLinks: number } {
|
||||||
@ -212,7 +216,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
};
|
};
|
||||||
packageEntry.itemIds.push(itemId);
|
packageEntry.itemIds.push(itemId);
|
||||||
this.session.items[itemId] = item;
|
this.session.items[itemId] = item;
|
||||||
if (fileName === "download.bin") {
|
if (looksLikeOpaqueFilename(fileName)) {
|
||||||
const existing = unresolvedByLink.get(link) ?? [];
|
const existing = unresolvedByLink.get(link) ?? [];
|
||||||
existing.push(itemId);
|
existing.push(itemId);
|
||||||
unresolvedByLink.set(link, existing);
|
unresolvedByLink.set(link, existing);
|
||||||
@ -256,7 +260,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!item) {
|
if (!item) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (item.fileName !== "download.bin") {
|
if (!looksLikeOpaqueFilename(item.fileName)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
item.fileName = normalized;
|
item.fileName = normalized;
|
||||||
@ -280,20 +284,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!pkg) {
|
if (!pkg) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pkg.cancelled = true;
|
const itemIds = [...pkg.itemIds];
|
||||||
pkg.status = "cancelled";
|
|
||||||
pkg.updatedAt = nowMs();
|
|
||||||
|
|
||||||
for (const itemId of pkg.itemIds) {
|
for (const itemId of itemIds) {
|
||||||
const item = this.session.items[itemId];
|
const item = this.session.items[itemId];
|
||||||
if (!item) {
|
if (!item) {
|
||||||
continue;
|
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);
|
const active = this.activeTasks.get(itemId);
|
||||||
if (active) {
|
if (active) {
|
||||||
active.abortReason = "cancel";
|
active.abortReason = "cancel";
|
||||||
@ -302,9 +299,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removed = cleanupCancelledPackageArtifacts(pkg.outputDir);
|
const removed = cleanupCancelledPackageArtifacts(pkg.outputDir);
|
||||||
|
this.removePackageFromSession(packageId, itemIds);
|
||||||
logger.info(`Paket ${pkg.name} abgebrochen, ${removed} Artefakte gelöscht`);
|
logger.info(`Paket ${pkg.name} abgebrochen, ${removed} Artefakte gelöscht`);
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
@ -316,7 +314,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.session.runStartedAt = this.session.runStartedAt || nowMs();
|
this.session.runStartedAt = this.session.runStartedAt || nowMs();
|
||||||
this.summary = null;
|
this.summary = null;
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState(true);
|
||||||
this.ensureScheduler();
|
this.ensureScheduler();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -328,7 +326,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
active.abortController.abort("stop");
|
active.abortController.abort("stop");
|
||||||
}
|
}
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public togglePause(): boolean {
|
public togglePause(): boolean {
|
||||||
@ -337,7 +335,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.session.paused = !this.session.paused;
|
this.session.paused = !this.session.paused;
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState(true);
|
||||||
return this.session.paused;
|
return this.session.paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -400,8 +398,46 @@ export class DownloadManager extends EventEmitter {
|
|||||||
saveSession(this.storagePaths, this.session);
|
saveSession(this.storagePaths, this.session);
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitState(): void {
|
private emitState(force = false): void {
|
||||||
|
if (force) {
|
||||||
|
if (this.stateEmitTimer) {
|
||||||
|
clearTimeout(this.stateEmitTimer);
|
||||||
|
this.stateEmitTimer = null;
|
||||||
|
}
|
||||||
this.emit("state", this.getSnapshot());
|
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> {
|
private async ensureScheduler(): Promise<void> {
|
||||||
@ -748,8 +784,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
written += buffer.length;
|
written += buffer.length;
|
||||||
windowBytes += buffer.length;
|
windowBytes += buffer.length;
|
||||||
this.session.totalDownloadedBytes += buffer.length;
|
this.session.totalDownloadedBytes += buffer.length;
|
||||||
this.speedEvents.push({ at: nowMs(), bytes: buffer.length });
|
this.recordSpeed(buffer.length);
|
||||||
this.speedEvents = this.speedEvents.filter((event) => event.at >= nowMs() - 3000);
|
|
||||||
|
|
||||||
const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.1);
|
const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.1);
|
||||||
const speed = windowBytes / elapsed;
|
const speed = windowBytes / elapsed;
|
||||||
@ -801,7 +836,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
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;
|
const globalAllowed = bytesPerSecond * 3;
|
||||||
if (globalBytes > globalAllowed) {
|
if (globalBytes > globalAllowed) {
|
||||||
await sleep(Math.min(250, Math.ceil(((globalBytes - globalAllowed) / bytesPerSecond) * 1000)));
|
await sleep(Math.min(250, Math.ceil(((globalBytes - globalAllowed) / bytesPerSecond) * 1000)));
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import path from "node:path";
|
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 { AddLinksPayload, AppSettings } from "../shared/types";
|
||||||
import { AppController } from "./app-controller";
|
import { AppController } from "./app-controller";
|
||||||
import { IPC_CHANNELS } from "../shared/ipc";
|
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_SNAPSHOT, () => controller.getSnapshot());
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
|
ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
|
||||||
ipcMain.handle(IPC_CHANNELS.CHECK_UPDATES, async () => controller.checkUpdates());
|
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.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_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => controller.addLinks(payload));
|
||||||
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => controller.addContainers(filePaths ?? []));
|
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => controller.addContainers(filePaths ?? []));
|
||||||
|
|||||||
@ -62,15 +62,21 @@ export function filenameFromUrl(url: string): string {
|
|||||||
const normalized = decoded
|
const normalized = decoded
|
||||||
.replace(/\.(rar|zip|7z|tar|gz|bz2|xz|iso|part\d+\.rar|r\d{2})\.html$/i, ".$1")
|
.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");
|
.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");
|
return sanitizeFilename(normalized || "download.bin");
|
||||||
} catch {
|
} catch {
|
||||||
return "download.bin";
|
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 {
|
export function inferPackageNameFromLinks(links: string[]): string {
|
||||||
if (links.length === 0) {
|
if (links.length === 0) {
|
||||||
return "Paket";
|
return "Paket";
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const api: ElectronApi = {
|
|||||||
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
|
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
|
||||||
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
|
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
|
||||||
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
|
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),
|
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
|
||||||
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
|
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
|
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { DragEvent, ReactElement, useEffect, useMemo, useState } from "react";
|
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";
|
type Tab = "collector" | "downloads" | "settings";
|
||||||
|
|
||||||
@ -86,10 +86,7 @@ export function App(): ReactElement {
|
|||||||
setSettingsDraft(state.settings);
|
setSettingsDraft(state.settings);
|
||||||
if (state.settings.autoUpdateCheck) {
|
if (state.settings.autoUpdateCheck) {
|
||||||
void window.rd.checkUpdates().then((result) => {
|
void window.rd.checkUpdates().then((result) => {
|
||||||
if (result.updateAvailable) {
|
void handleUpdateResult(result, "startup");
|
||||||
setStatusToast(`Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})`);
|
|
||||||
setTimeout(() => setStatusToast(""), 3800);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -107,6 +104,37 @@ export function App(): ReactElement {
|
|||||||
.map((id: string) => snapshot.session.packages[id])
|
.map((id: string) => snapshot.session.packages[id])
|
||||||
.filter(Boolean), [snapshot]);
|
.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> => {
|
const onSaveSettings = async (): Promise<void> => {
|
||||||
await window.rd.updateSettings(settingsDraft);
|
await window.rd.updateSettings(settingsDraft);
|
||||||
setStatusToast("Settings gespeichert");
|
setStatusToast("Settings gespeichert");
|
||||||
@ -115,18 +143,7 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const onCheckUpdates = async (): Promise<void> => {
|
const onCheckUpdates = async (): Promise<void> => {
|
||||||
const result = await window.rd.checkUpdates();
|
const result = await window.rd.checkUpdates();
|
||||||
if (result.error) {
|
await handleUpdateResult(result, "manual");
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddLinks = async (): Promise<void> => {
|
const onAddLinks = async (): Promise<void> => {
|
||||||
@ -193,7 +210,14 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
<section className="control-strip">
|
<section className="control-strip">
|
||||||
<div className="buttons">
|
<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()}>
|
<button className="btn" disabled={!snapshot.canPause} onClick={() => window.rd.togglePause()}>
|
||||||
{snapshot.session.paused ? "Resume" : "Pause"}
|
{snapshot.session.paused ? "Resume" : "Pause"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export const IPC_CHANNELS = {
|
|||||||
GET_SNAPSHOT: "app:get-snapshot",
|
GET_SNAPSHOT: "app:get-snapshot",
|
||||||
GET_VERSION: "app:get-version",
|
GET_VERSION: "app:get-version",
|
||||||
CHECK_UPDATES: "app:check-updates",
|
CHECK_UPDATES: "app:check-updates",
|
||||||
|
OPEN_EXTERNAL: "app:open-external",
|
||||||
UPDATE_SETTINGS: "app:update-settings",
|
UPDATE_SETTINGS: "app:update-settings",
|
||||||
ADD_LINKS: "queue:add-links",
|
ADD_LINKS: "queue:add-links",
|
||||||
ADD_CONTAINERS: "queue:add-containers",
|
ADD_CONTAINERS: "queue:add-containers",
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export interface ElectronApi {
|
|||||||
getSnapshot: () => Promise<UiSnapshot>;
|
getSnapshot: () => Promise<UiSnapshot>;
|
||||||
getVersion: () => Promise<string>;
|
getVersion: () => Promise<string>;
|
||||||
checkUpdates: () => Promise<UpdateCheckResult>;
|
checkUpdates: () => Promise<UpdateCheckResult>;
|
||||||
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
|
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
|
||||||
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
|
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
|
||||||
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
|
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||||
|
|||||||
@ -142,4 +142,41 @@ describe("debrid service", () => {
|
|||||||
expect(result.directUrl).toBe("https://alldebrid.example/file.bin");
|
expect(result.directUrl).toBe("https://alldebrid.example/file.bin");
|
||||||
expect(result.fileSize).toBe(4096);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -185,8 +185,13 @@ async function main(): Promise<void> {
|
|||||||
manager4.cancelPackage(pkgId);
|
manager4.cancelPackage(pkgId);
|
||||||
await waitFor(() => !manager4.getSnapshot().session.running || Object.values(manager4.getSnapshot().session.items).every((item) => item.status !== "downloading"), 15000);
|
await waitFor(() => !manager4.getSnapshot().session.running || Object.values(manager4.getSnapshot().session.items).every((item) => item.status !== "downloading"), 15000);
|
||||||
const cancelSnapshot = manager4.getSnapshot();
|
const cancelSnapshot = manager4.getSnapshot();
|
||||||
const cancelItem = Object.values(cancelSnapshot.session.items)[0];
|
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");
|
assert(cancelItem?.status === "cancelled" || cancelItem?.status === "queued", "Paketabbruch nicht wirksam");
|
||||||
|
}
|
||||||
const packageDir = path.join(path.join(tempRoot, "downloads-cancel"), "cancel");
|
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");
|
assert(!fs.existsSync(path.join(packageDir, "release.part1.rar")), "RAR-Artefakt wurde nicht gelöscht");
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("utils", () => {
|
||||||
it("validates http links", () => {
|
it("validates http links", () => {
|
||||||
@ -34,6 +34,9 @@ describe("utils", () => {
|
|||||||
it("normalizes filenames from links", () => {
|
it("normalizes filenames from links", () => {
|
||||||
expect(filenameFromUrl("https://rapidgator.net/file/id/show.part1.rar.html")).toBe("show.part1.rar");
|
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/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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user