diff --git a/package-lock.json b/package-lock.json index a6470db..03f7394 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.1.25", + "version": "1.1.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.1.25", + "version": "1.1.26", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index d19aa89..cec6579 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.1.25", + "version": "1.1.26", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/constants.ts b/src/main/constants.ts index 2d1929b..4f06d6e 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -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.25"; +export const APP_VERSION = "1.1.26"; export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0"; export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload"; diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 715ef0d..1f310ae 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1,4 +1,4 @@ -import { AppSettings, DebridProvider } from "../shared/types"; +import { AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types"; import { REQUEST_RETRIES } from "./constants"; import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; @@ -113,6 +113,17 @@ function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] { return result; } +function toProviderOrder(primary: DebridProvider, secondary: DebridFallbackProvider, tertiary: DebridFallbackProvider): DebridProvider[] { + const order: DebridProvider[] = [primary]; + if (secondary !== "none") { + order.push(secondary); + } + if (tertiary !== "none") { + order.push(tertiary); + } + return uniqueProviderOrder(order); +} + function isRapidgatorLink(link: string): boolean { try { return new URL(link).hostname.toLowerCase().includes("rapidgator.net"); @@ -492,11 +503,11 @@ export class DebridService { } public async unrestrictLink(link: string): Promise { - const order = uniqueProviderOrder([ + const order = toProviderOrder( this.settings.providerPrimary, this.settings.providerSecondary, this.settings.providerTertiary - ]); + ); let configuredFound = false; const attempts: string[] = []; diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 62f1ffc..6c0ef14 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -87,6 +87,31 @@ function isPathInsideDir(filePath: string, dirPath: string): boolean { return file.startsWith(withSep); } +function directoryHasFiles(dirPath: string): boolean { + if (!fs.existsSync(dirPath)) { + return false; + } + const stack = [dirPath]; + while (stack.length > 0) { + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isFile()) { + return true; + } + if (entry.isDirectory()) { + stack.push(path.join(current, entry.name)); + } + } + } + return false; +} + export class DownloadManager extends EventEmitter { private settings: AppSettings; @@ -114,6 +139,10 @@ export class DownloadManager extends EventEmitter { private cleanupQueue: Promise = Promise.resolve(); + private packagePostProcessQueue: Promise = Promise.resolve(); + + private packagePostProcessTasks = new Map>(); + private reservedTargetPaths = new Map(); private claimedTargetPathByItem = new Map(); @@ -134,6 +163,7 @@ export class DownloadManager extends EventEmitter { this.debridService = new DebridService(settings, { megaWebUnrestrict: options.megaWebUnrestrict }); this.applyOnStartCleanupPolicy(); this.normalizeSessionStatuses(); + this.recoverPostProcessingOnStartup(); } public setSettings(next: AppSettings): void { @@ -157,7 +187,8 @@ export class DownloadManager extends EventEmitter { public getSnapshot(): UiSnapshot { const now = nowMs(); this.pruneSpeedEvents(now); - const speedBps = this.speedBytesLastWindow / 3; + const paused = this.session.running && this.session.paused; + const speedBps = paused ? 0 : this.speedBytesLastWindow / 3; let totalItems = Object.keys(this.session.items).length; let doneItems = Object.values(this.session.items).filter((item) => isFinishedStatus(item.status)).length; @@ -185,7 +216,7 @@ export class DownloadManager extends EventEmitter { session: this.getSession(), summary: this.summary, speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, - etaText: `ETA: ${formatEta(eta)}`, + etaText: paused ? "ETA: --" : `ETA: ${formatEta(eta)}`, canStart: !this.session.running, canStop: this.session.running, canPause: this.session.running @@ -204,6 +235,8 @@ export class DownloadManager extends EventEmitter { this.runCompletedPackages.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); + this.packagePostProcessTasks.clear(); + this.packagePostProcessQueue = Promise.resolve(); this.summary = null; this.persistNow(); this.emitState(true); @@ -592,6 +625,95 @@ export class DownloadManager extends EventEmitter { this.claimedTargetPathByItem.delete(itemId); } + private runPackagePostProcessing(packageId: string): Promise { + const existing = this.packagePostProcessTasks.get(packageId); + if (existing) { + return existing; + } + + const task = this.packagePostProcessQueue + .catch(() => undefined) + .then(async () => { + await this.handlePackagePostProcessing(packageId); + }) + .catch((error) => { + logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`); + }) + .finally(() => { + this.packagePostProcessTasks.delete(packageId); + this.persistSoon(); + this.emitState(); + }); + + this.packagePostProcessTasks.set(packageId, task); + this.packagePostProcessQueue = task; + return task; + } + + private recoverPostProcessingOnStartup(): void { + const packageIds = [...this.session.packageOrder]; + if (packageIds.length === 0) { + return; + } + + let changed = false; + for (const packageId of packageIds) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled) { + continue; + } + + const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; + if (items.length === 0) { + continue; + } + + const success = items.filter((item) => item.status === "completed").length; + const failed = items.filter((item) => item.status === "failed").length; + const cancelled = items.filter((item) => item.status === "cancelled").length; + if (success + failed + cancelled < items.length) { + continue; + } + + if (this.settings.autoExtract && failed === 0 && success > 0) { + if (this.settings.createExtractSubfolder && directoryHasFiles(pkg.extractDir)) { + for (const item of items) { + if (item.status === "completed" && item.fullStatus !== "Entpackt") { + item.fullStatus = "Entpackt"; + item.updatedAt = nowMs(); + changed = true; + } + } + if (pkg.status !== "completed") { + pkg.status = "completed"; + pkg.updatedAt = nowMs(); + changed = true; + } + continue; + } + + const needsPostProcess = pkg.status !== "completed" + || items.some((item) => item.status === "completed" && item.fullStatus !== "Entpackt"); + if (needsPostProcess) { + void this.runPackagePostProcessing(packageId); + } + continue; + } + + const targetStatus = failed > 0 ? "failed" : cancelled > 0 && success === 0 ? "cancelled" : "completed"; + if (pkg.status !== targetStatus) { + pkg.status = targetStatus; + pkg.updatedAt = nowMs(); + changed = true; + } + } + + if (changed) { + this.persistSoon(); + this.emitState(); + } + } + private removePackageFromSession(packageId: string, itemIds: string[]): void { for (const itemId of itemIds) { delete this.session.items[itemId]; @@ -814,7 +936,7 @@ export class DownloadManager extends EventEmitter { pkg.updatedAt = nowMs(); this.recordRunOutcome(item.id, "completed"); - await this.handlePackagePostProcessing(pkg.id); + await this.runPackagePostProcessing(pkg.id); this.applyCompletedCleanupPolicy(pkg.id, item.id); this.persistSoon(); this.emitState(); @@ -1140,29 +1262,43 @@ export class DownloadManager extends EventEmitter { return; } - if (this.settings.autoExtract && failed === 0 && success > 0) { + const completedItems = items.filter((item) => item.status === "completed"); + const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => item.fullStatus === "Entpackt"); + + if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) { pkg.status = "extracting"; this.emitState(); - const result = await extractPackageArchives({ - packageDir: pkg.outputDir, - targetDir: pkg.extractDir, - cleanupMode: this.settings.cleanupMode, - conflictMode: this.settings.extractConflictMode, - removeLinks: this.settings.removeLinkFilesAfterExtract, - removeSamples: this.settings.removeSamplesAfterExtract - }); - if (result.failed > 0) { - pkg.status = "failed"; - } else { - if (result.extracted > 0) { - for (const entry of items) { - if (entry.status === "completed") { + try { + const result = await extractPackageArchives({ + packageDir: pkg.outputDir, + targetDir: pkg.extractDir, + cleanupMode: this.settings.cleanupMode, + conflictMode: this.settings.extractConflictMode, + removeLinks: this.settings.removeLinkFilesAfterExtract, + removeSamples: this.settings.removeSamplesAfterExtract + }); + if (result.failed > 0) { + for (const entry of completedItems) { + entry.fullStatus = "Entpack-Fehler"; + entry.updatedAt = nowMs(); + } + pkg.status = "failed"; + } else { + if (result.extracted > 0) { + for (const entry of completedItems) { entry.fullStatus = "Entpackt"; entry.updatedAt = nowMs(); } } + pkg.status = "completed"; } - pkg.status = "completed"; + } catch (error) { + const reason = compactErrorText(error); + for (const entry of completedItems) { + entry.fullStatus = `Entpack-Fehler: ${reason}`; + entry.updatedAt = nowMs(); + } + pkg.status = "failed"; } } else if (failed > 0) { pkg.status = "failed"; diff --git a/src/main/storage.ts b/src/main/storage.ts index 50945af..c30aaa2 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -4,7 +4,8 @@ import { AppSettings, SessionState } from "../shared/types"; import { defaultSettings } from "./constants"; import { logger } from "./logger"; -const VALID_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid"]); +const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid"]); +const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid", "bestdebrid", "alldebrid"]); const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]); const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]); const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]); @@ -53,14 +54,14 @@ export function normalizeSettings(settings: AppSettings): AppSettings { updateRepo: asText(settings.updateRepo) || defaults.updateRepo }; - if (!VALID_PROVIDERS.has(normalized.providerPrimary)) { + if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) { normalized.providerPrimary = defaults.providerPrimary; } - if (!VALID_PROVIDERS.has(normalized.providerSecondary)) { - normalized.providerSecondary = defaults.providerSecondary; + if (!VALID_FALLBACK_PROVIDERS.has(normalized.providerSecondary)) { + normalized.providerSecondary = "none"; } - if (!VALID_PROVIDERS.has(normalized.providerTertiary)) { - normalized.providerTertiary = defaults.providerTertiary; + if (!VALID_FALLBACK_PROVIDERS.has(normalized.providerTertiary)) { + normalized.providerTertiary = "none"; } if (!VALID_CLEANUP_MODES.has(normalized.cleanupMode)) { normalized.cleanupMode = defaults.cleanupMode; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0107091..6573b19 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,5 @@ import { DragEvent, ReactElement, useEffect, useMemo, useRef, useState } from "react"; -import type { AppSettings, DebridProvider, DownloadItem, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types"; +import type { AppSettings, DebridFallbackProvider, DebridProvider, DownloadItem, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types"; type Tab = "collector" | "downloads" | "settings"; @@ -73,6 +73,14 @@ const providerLabels: Record = { alldebrid: "AllDebrid" }; +const fallbackProviderOptions: Array<{ value: DebridFallbackProvider; label: string }> = [ + { value: "none", label: "Kein Fallback" }, + { value: "realdebrid", label: providerLabels.realdebrid }, + { value: "megadebrid", label: providerLabels.megadebrid }, + { value: "bestdebrid", label: providerLabels.bestdebrid }, + { value: "alldebrid", label: providerLabels.alldebrid } +]; + function formatSpeedMbps(speedBps: number): string { const mbps = Math.max(0, speedBps) / (1024 * 1024); return `${mbps.toFixed(2)} MB/s`; @@ -397,16 +405,16 @@ export function App(): ReactElement {
diff --git a/src/shared/types.ts b/src/shared/types.ts index 661d395..43581ea 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -15,6 +15,7 @@ export type ConflictMode = "overwrite" | "skip" | "rename" | "ask"; export type SpeedMode = "global" | "per_download"; export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done"; export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid"; +export type DebridFallbackProvider = DebridProvider | "none"; export interface AppSettings { token: string; @@ -24,8 +25,8 @@ export interface AppSettings { allDebridToken: string; rememberToken: boolean; providerPrimary: DebridProvider; - providerSecondary: DebridProvider; - providerTertiary: DebridProvider; + providerSecondary: DebridFallbackProvider; + providerTertiary: DebridFallbackProvider; autoProviderFallback: boolean; outputDir: string; packageName: string; diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 0da2e40..091e312 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -215,4 +215,39 @@ describe("debrid service", () => { await expect(service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html")).rejects.toThrow(); expect(allDebridCalls).toBe(0); }); + + it("allows disabling secondary and tertiary providers", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + megaLogin: "user", + megaPassword: "pass", + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response(JSON.stringify({ error: "traffic_limit" }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const megaWeb = vi.fn(async () => ({ + fileName: "unused.bin", + directUrl: "https://unused", + fileSize: null, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + await expect(service.unrestrictLink("https://rapidgator.net/file/example.part6.rar.html")).rejects.toThrow(); + expect(megaWeb).toHaveBeenCalledTimes(0); + }); }); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 5029a84..5ea0b80 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -1024,4 +1024,153 @@ describe("download manager", () => { await once(server, "close"); } }); + + it("shows stable ETA while paused", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(320 * 1024, 10); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/pause") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + const chunk = Math.floor(binary.length / 2); + res.write(binary.subarray(0, chunk)); + setTimeout(() => { + res.end(binary.subarray(chunk)); + }, 900); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/pause`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "pause.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "pause-case", links: ["https://dummy/pause"] }]); + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 120)); + manager.togglePause(); + const pausedSnapshot = manager.getSnapshot(); + expect(pausedSnapshot.session.paused).toBe(true); + expect(pausedSnapshot.etaText).toBe("ETA: --"); + + manager.stop(); + await waitFor(() => !manager.getSnapshot().session.running, 15000); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("recovers pending extraction on startup for completed package", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const outputDir = path.join(root, "downloads", "recovery"); + const extractDir = path.join(root, "extract", "recovery"); + fs.mkdirSync(outputDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("episode.txt", Buffer.from("ok")); + const archivePath = path.join(outputDir, "episode.zip"); + zip.writeZip(archivePath); + + const session = emptySession(); + const packageId = "recover-pkg"; + const itemId = "recover-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "recovery", + outputDir, + extractDir, + status: "downloading", + itemIds: [itemId], + cancelled: false, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/recover", + provider: "megadebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: fs.statSync(archivePath).size, + totalBytes: fs.statSync(archivePath).size, + progressPercent: 100, + fileName: "episode.zip", + targetPath: archivePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (100 MB)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + createExtractSubfolder: true, + autoExtract: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => fs.existsSync(path.join(extractDir, "episode.txt")), 25000); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]?.status).toBe("completed"); + expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt"); + }); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index a97ec42..cd26052 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -73,6 +73,8 @@ describe("settings storage", () => { const normalized = normalizeSettings({ ...defaultSettings(), providerPrimary: "invalid-provider" as unknown as AppSettings["providerPrimary"], + providerSecondary: "invalid-provider" as unknown as AppSettings["providerSecondary"], + providerTertiary: "invalid-provider" as unknown as AppSettings["providerTertiary"], cleanupMode: "broken" as unknown as AppSettings["cleanupMode"], extractConflictMode: "broken" as unknown as AppSettings["extractConflictMode"], completedCleanupPolicy: "broken" as unknown as AppSettings["completedCleanupPolicy"], @@ -86,6 +88,8 @@ describe("settings storage", () => { }); expect(normalized.providerPrimary).toBe("realdebrid"); + expect(normalized.providerSecondary).toBe("none"); + expect(normalized.providerTertiary).toBe("none"); expect(normalized.cleanupMode).toBe("none"); expect(normalized.extractConflictMode).toBe("overwrite"); expect(normalized.completedCleanupPolicy).toBe("never"); @@ -124,4 +128,15 @@ describe("settings storage", () => { expect(loaded.speedLimitMode).toBe("global"); expect(loaded.updateRepo).toBe(defaultSettings().updateRepo); }); + + it("keeps explicit none as fallback provider choice", () => { + const normalized = normalizeSettings({ + ...defaultSettings(), + providerSecondary: "none", + providerTertiary: "none" + }); + + expect(normalized.providerSecondary).toBe("none"); + expect(normalized.providerTertiary).toBe("none"); + }); });