diff --git a/package-lock.json b/package-lock.json index 5352d31..df8fbae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.8", + "version": "1.4.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.8", + "version": "1.4.10", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 1a91134..54a2264 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.9", + "version": "1.4.10", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 12f597a..516f2f2 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -173,6 +173,10 @@ export class DownloadManager extends EventEmitter { private speedBytesLastWindow = 0; + private statsCache: DownloadStats | null = null; + + private statsCacheAt = 0; + private lastPersistAt = 0; private cleanupQueue: Promise = Promise.resolve(); @@ -239,11 +243,10 @@ export class DownloadManager extends EventEmitter { 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; + let totalItems = 0; + let doneItems = 0; if (this.session.running && this.runItemIds.size > 0) { totalItems = this.runItemIds.size; - doneItems = 0; for (const itemId of this.runItemIds) { if (this.runOutcomes.has(itemId)) { doneItems += 1; @@ -254,6 +257,14 @@ export class DownloadManager extends EventEmitter { doneItems += 1; } } + } else { + const sessionItems = Object.values(this.session.items); + totalItems = sessionItems.length; + for (const item of sessionItems) { + if (isFinishedStatus(item.status)) { + doneItems += 1; + } + } } const elapsed = this.session.runStartedAt > 0 ? (now - this.session.runStartedAt) / 1000 : 0; const rate = doneItems > 0 && elapsed > 0 ? doneItems / elapsed : 0; @@ -266,7 +277,7 @@ export class DownloadManager extends EventEmitter { settings: this.settings, session: this.session, summary: this.summary, - stats: this.getStats(), + stats: this.getStats(now), speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, etaText: paused ? "ETA: --" : `ETA: ${formatEta(eta)}`, canStart: !this.session.running, @@ -277,7 +288,12 @@ export class DownloadManager extends EventEmitter { }; } - public getStats(): DownloadStats { + public getStats(now = nowMs()): DownloadStats { + const itemCount = Object.keys(this.session.items).length; + if (this.statsCache && this.session.running && itemCount >= 500 && now - this.statsCacheAt < 1500) { + return this.statsCache; + } + let totalDownloaded = 0; let totalFiles = 0; for (const item of Object.values(this.session.items)) { @@ -300,12 +316,15 @@ export class DownloadManager extends EventEmitter { totalDownloaded = Math.max(totalDownloaded, this.session.totalDownloadedBytes); } - return { + const stats = { totalDownloaded, totalFiles, totalPackages: Object.keys(this.session.packages).length, sessionStartedAt: this.session.runStartedAt }; + this.statsCache = stats; + this.statsCacheAt = now; + return stats; } public renamePackage(packageId: string, newName: string): void { @@ -771,6 +790,7 @@ export class DownloadManager extends EventEmitter { } const cleanupTargetsByPackage = new Map>(); + const dirFilesCache = new Map(); for (const packageId of this.session.packageOrder) { const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled || pkg.status !== "completed") { @@ -802,7 +822,20 @@ export class DownloadManager extends EventEmitter { if (!targetPath || !isArchiveLikePath(targetPath)) { continue; } - for (const cleanupTarget of collectArchiveCleanupTargets(targetPath)) { + const dir = path.dirname(targetPath); + let filesInDir = dirFilesCache.get(dir); + if (!filesInDir) { + try { + filesInDir = fs.readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + } catch { + filesInDir = []; + } + dirFilesCache.set(dir, filesInDir); + } + + for (const cleanupTarget of collectArchiveCleanupTargets(targetPath, filesInDir)) { packageTargets.add(cleanupTarget); } } @@ -860,8 +893,14 @@ export class DownloadManager extends EventEmitter { if (!rootDir || !fs.existsSync(rootDir)) { return false; } + const deadline = nowMs() + 55; + let inspectedDirs = 0; const stack = [rootDir]; while (stack.length > 0) { + inspectedDirs += 1; + if (inspectedDirs > 6000 || nowMs() > deadline) { + return true; + } const current = stack.pop() as string; let entries: fs.Dirent[] = []; try { @@ -1215,13 +1254,13 @@ export class DownloadManager extends EventEmitter { const itemCount = Object.keys(this.session.items).length; const minGapMs = this.session.running ? itemCount >= 1500 - ? 1300 + ? 3000 : itemCount >= 700 - ? 950 + ? 2200 : itemCount >= 250 - ? 700 - : 450 - : 250; + ? 1500 + : 700 + : 300; const sinceLastPersist = nowMs() - this.lastPersistAt; const delay = Math.max(120, minGapMs - sinceLastPersist); @@ -1251,12 +1290,12 @@ export class DownloadManager extends EventEmitter { const itemCount = Object.keys(this.session.items).length; const emitDelay = this.session.running ? itemCount >= 1500 - ? 900 + ? 1200 : itemCount >= 700 - ? 650 + ? 900 : itemCount >= 250 - ? 420 - : 280 + ? 560 + : 320 : 260; this.stateEmitTimer = setTimeout(() => { this.stateEmitTimer = null; @@ -2568,6 +2607,35 @@ export class DownloadManager extends EventEmitter { } pkg.updatedAt = nowMs(); logger.info(`Post-Processing Ende: pkg=${pkg.name}, status=${pkg.status}`); + + this.applyPackageDoneCleanup(packageId); + } + + private applyPackageDoneCleanup(packageId: string): void { + if (this.settings.completedCleanupPolicy !== "package_done") { + return; + } + + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.status !== "completed") { + return; + } + + const allCompleted = pkg.itemIds.every((itemId) => { + const item = this.session.items[itemId]; + return !item || item.status === "completed"; + }); + if (!allCompleted) { + return; + } + + for (const itemId of pkg.itemIds) { + delete this.session.items[itemId]; + } + delete this.session.packages[packageId]; + this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); + this.runPackageIds.delete(packageId); + this.runCompletedPackages.delete(packageId); } private applyCompletedCleanupPolicy(packageId: string, itemId: string): void { diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 88644e0..1d6f400 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -163,6 +163,21 @@ function archivePasswords(listInput: string): string[] { return Array.from(new Set(["", ...custom, ...fromEnv, ...DEFAULT_ARCHIVE_PASSWORDS])); } +function prioritizePassword(passwords: string[], successful: string): string[] { + const target = String(successful || ""); + if (!target || passwords.length <= 1) { + return passwords; + } + const index = passwords.findIndex((candidate) => candidate === target); + if (index <= 0) { + return passwords; + } + const next = [...passwords]; + const [value] = next.splice(index, 1); + next.unshift(value); + return next; +} + function winRarCandidates(): string[] { const programFiles = process.env.ProgramFiles || "C:\\Program Files"; const programFilesX86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"; @@ -334,7 +349,7 @@ async function runExternalExtract( passwordCandidates: string[], onArchiveProgress?: (percent: number) => void, signal?: AbortSignal -): Promise { +): Promise { const command = await resolveExtractorCommand(); const passwords = passwordCandidates; let lastError = ""; @@ -363,7 +378,7 @@ async function runExternalExtract( }, signal); if (result.ok) { onArchiveProgress?.(100); - return; + return password; } if (result.aborted) { @@ -417,18 +432,20 @@ function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -export function collectArchiveCleanupTargets(sourceArchivePath: string): string[] { +export function collectArchiveCleanupTargets(sourceArchivePath: string, directoryFiles?: string[]): string[] { const targets = new Set([sourceArchivePath]); const dir = path.dirname(sourceArchivePath); const fileName = path.basename(sourceArchivePath); - let filesInDir: string[] = []; - try { - filesInDir = fs.readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isFile()) - .map((entry) => entry.name); - } catch { - return Array.from(targets); + let filesInDir: string[] = directoryFiles ?? []; + if (!directoryFiles) { + try { + filesInDir = fs.readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + } catch { + return Array.from(targets); + } } const addMatching = (pattern: RegExp): void => { @@ -476,8 +493,22 @@ function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode): numbe } const targets = new Set(); + const dirFilesCache = new Map(); for (const sourceFile of sourceFiles) { - for (const target of collectArchiveCleanupTargets(sourceFile)) { + const dir = path.dirname(sourceFile); + let filesInDir = dirFilesCache.get(dir); + if (!filesInDir) { + try { + filesInDir = fs.readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + } catch { + filesInDir = []; + } + dirFilesCache.set(dir, filesInDir); + } + + for (const target of collectArchiveCleanupTargets(sourceFile, filesInDir)) { targets.add(target); } } @@ -501,8 +532,14 @@ function hasAnyFilesRecursive(rootDir: string): boolean { if (!fs.existsSync(rootDir)) { return false; } + const deadline = Date.now() + 70; + let inspectedDirs = 0; const stack = [rootDir]; while (stack.length > 0) { + inspectedDirs += 1; + if (inspectedDirs > 8000 || Date.now() > deadline) { + return true; + } const current = stack.pop() as string; let entries: fs.Dirent[] = []; try { @@ -523,6 +560,17 @@ function hasAnyFilesRecursive(rootDir: string): boolean { return false; } +function hasAnyEntries(rootDir: string): boolean { + if (!rootDir || !fs.existsSync(rootDir)) { + return false; + } + try { + return fs.readdirSync(rootDir).length > 0; + } catch { + return false; + } +} + function removeEmptyDirectoryTree(rootDir: string): number { if (!fs.existsSync(rootDir)) { return 0; @@ -572,7 +620,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`); if (candidates.length === 0) { const existingResume = readExtractResumeState(options.packageDir); - if (existingResume.size > 0 && hasAnyFilesRecursive(options.targetDir)) { + if (existingResume.size > 0 && hasAnyEntries(options.targetDir)) { clearExtractResumeState(options.packageDir); logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`); options.onProgress?.({ @@ -590,7 +638,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } const conflictMode = effectiveConflictMode(options.conflictMode); - const passwordCandidates = archivePasswords(options.passwordList || ""); + let passwordCandidates = archivePasswords(options.passwordList || ""); const resumeCompleted = readExtractResumeState(options.packageDir); const resumeCompletedAtStart = resumeCompleted.size; const candidateNames = new Set(candidates.map((archivePath) => path.basename(archivePath))); @@ -664,10 +712,11 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const preferExternal = shouldPreferExternalZip(archivePath); if (preferExternal) { try { - await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { + const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { archivePercent = Math.max(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); }, options.signal); + passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } catch (error) { if (isNoExtractorError(String(error))) { extractZipArchive(archivePath, options.targetDir, options.conflictMode); @@ -680,17 +729,19 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ extractZipArchive(archivePath, options.targetDir, options.conflictMode); archivePercent = 100; } catch { - await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { + const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { archivePercent = Math.max(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); }, options.signal); + passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } } } else { - await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { + const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { archivePercent = Math.max(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); }, options.signal); + passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } extracted += 1; extractedArchives.add(archivePath); @@ -722,7 +773,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } if (extracted > 0) { - const hasOutputAfter = hasAnyFilesRecursive(options.targetDir); + const hasOutputAfter = hasAnyEntries(options.targetDir); const hadResumeProgress = resumeCompletedAtStart > 0; if (!hasOutputAfter && conflictMode !== "skip" && !hadResumeProgress) { lastError = "Keine entpackten Dateien erkannt"; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 013f5e0..db7a4dc 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -61,6 +61,8 @@ const cleanupLabels: Record = { never: "Nie", immediate: "Sofort", on_start: "Beim App-Start", package_done: "Sobald Paket fertig ist" }; +const AUTO_RENDER_PACKAGE_LIMIT = 260; + const providerLabels: Record = { realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid" }; @@ -101,6 +103,7 @@ export function App(): ReactElement { const draggedPackageIdRef = useRef(null); const [collapsedPackages, setCollapsedPackages] = useState>({}); const [downloadSearch, setDownloadSearch] = useState(""); + const [showAllPackages, setShowAllPackages] = useState(false); const [actionBusy, setActionBusy] = useState(false); const actionBusyRef = useRef(false); const dragOverRef = useRef(false); @@ -272,6 +275,27 @@ export function App(): ReactElement { return packages.filter((pkg) => pkg.name.toLowerCase().includes(query)); }, [packages, deferredDownloadSearch]); + const downloadSearchActive = deferredDownloadSearch.trim().length > 0; + const shouldLimitPackageRendering = snapshot.session.running + && !downloadSearchActive + && filteredPackages.length > AUTO_RENDER_PACKAGE_LIMIT + && !showAllPackages; + + const visiblePackages = useMemo(() => { + if (!shouldLimitPackageRendering) { + return filteredPackages; + } + return filteredPackages.slice(0, AUTO_RENDER_PACKAGE_LIMIT); + }, [filteredPackages, shouldLimitPackageRendering]); + + const hiddenPackageCount = filteredPackages.length - visiblePackages.length; + + useEffect(() => { + if (!snapshot.session.running) { + setShowAllPackages(false); + } + }, [snapshot.session.running]); + const allPackagesCollapsed = useMemo(() => ( packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id]) ), [packages, collapsedPackages]); @@ -833,7 +857,13 @@ export function App(): ReactElement { {packages.length === 0 &&
Noch keine Pakete in der Queue.
} {packages.length > 0 && filteredPackages.length === 0 &&
Keine Pakete passend zur Suche.
} - {filteredPackages.map((pkg) => ( + {hiddenPackageCount > 0 && ( +
+ Performance-Modus aktiv: {hiddenPackageCount} Paket(e) sind temporar ausgeblendet. + +
+ )} + {visiblePackages.map((pkg) => ( { } }); + it("removes finished package when package_done cleanup policy is enabled", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const zip = new AdmZip(); + zip.addFile("episode.txt", Buffer.from("ok")); + const archiveBinary = zip.toBuffer(); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/cleanup-package") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(archiveBinary.length)); + res.end(archiveBinary); + }); + + 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}/cleanup-package`; + + 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: "cleanup-package.zip", + filesize: archiveBinary.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: true, + enableIntegrityCheck: false, + cleanupMode: "none", + completedCleanupPolicy: "package_done" + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "cleanup-package", links: ["https://dummy/cleanup-package"] }]); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const snapshot = manager.getSnapshot(); + const summary = manager.getSummary(); + expect(snapshot.session.packageOrder).toHaveLength(0); + expect(Object.keys(snapshot.session.items)).toHaveLength(0); + expect(summary).not.toBeNull(); + expect(summary?.success).toBe(1); + } finally { + server.close(); + await once(server, "close"); + } + }); + it("counts queued package cancellations in run summary", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);