diff --git a/package.json b/package.json index 750922d..d1c6fe3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.5.85", + "version": "1.5.86", "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 8af1baa..88c2a4f 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -12,6 +12,7 @@ import { DuplicatePolicy, HistoryEntry, PackageEntry, + PackagePriority, ParsedPackageInput, SessionState, StartConflictEntry, @@ -1210,6 +1211,7 @@ export class DownloadManager extends EventEmitter { itemIds: [], cancelled: false, enabled: true, + priority: "normal", createdAt: nowMs(), updatedAt: nowMs() }; @@ -2430,6 +2432,30 @@ export class DownloadManager extends EventEmitter { this.emitState(true); } + public setPackagePriority(packageId: string, priority: PackagePriority): void { + const pkg = this.session.packages[packageId]; + if (!pkg) return; + if (priority !== "high" && priority !== "normal" && priority !== "low") return; + pkg.priority = priority; + pkg.updatedAt = nowMs(); + this.persistSoon(); + this.emitState(); + } + + public skipItems(itemIds: string[]): void { + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + if (item.status !== "queued" && item.status !== "reconnect_wait") continue; + item.status = "cancelled"; + item.fullStatus = "Übersprungen"; + item.speedBps = 0; + item.updatedAt = nowMs(); + } + this.persistSoon(); + this.emitState(); + } + public async startPackages(packageIds: string[]): Promise { const targetSet = new Set(packageIds); @@ -2839,6 +2865,9 @@ export class DownloadManager extends EventEmitter { if (pkg.enabled === undefined) { pkg.enabled = true; } + if (!pkg.priority) { + pkg.priority = "normal"; + } if (pkg.status === "downloading" || pkg.status === "validating" || pkg.status === "extracting" @@ -3720,28 +3749,34 @@ export class DownloadManager extends EventEmitter { private findNextQueuedItem(): { packageId: string; itemId: string } | null { const now = nowMs(); - for (const packageId of this.session.packageOrder) { - const pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled || !pkg.enabled) { - continue; - } - if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) { - continue; - } - for (const itemId of pkg.itemIds) { - const item = this.session.items[itemId]; - if (!item) { + const priorityOrder: Array = ["high", "normal", "low"]; + for (const prio of priorityOrder) { + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) { continue; } - const retryAfter = this.retryAfterByItem.get(itemId) || 0; - if (retryAfter > now) { + if ((pkg.priority || "normal") !== prio) { continue; } - if (retryAfter > 0) { - this.retryAfterByItem.delete(itemId); + if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) { + continue; } - if (item.status === "queued" || item.status === "reconnect_wait") { - return { packageId, itemId }; + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) { + continue; + } + const retryAfter = this.retryAfterByItem.get(itemId) || 0; + if (retryAfter > now) { + continue; + } + if (retryAfter > 0) { + this.retryAfterByItem.delete(itemId); + } + if (item.status === "queued" || item.status === "reconnect_wait") { + return { packageId, itemId }; + } } } } @@ -3995,7 +4030,7 @@ export class DownloadManager extends EventEmitter { item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget)); item.totalBytes = unrestricted.fileSize; item.status = "downloading"; - item.fullStatus = `Download läuft (${unrestricted.providerLabel})`; + item.fullStatus = `Starte... (${unrestricted.providerLabel})`; item.updatedAt = nowMs(); this.emitState(); @@ -4888,7 +4923,9 @@ export class DownloadManager extends EventEmitter { item.downloadedBytes = written; item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100; item.speedBps = 0; + item.fullStatus = "Finalisierend..."; item.updatedAt = nowMs(); + this.emitState(); return { resumable }; } catch (error) { if (active.abortController.signal.aborted || String(error).includes("aborted:")) { @@ -5474,7 +5511,15 @@ export class DownloadManager extends EventEmitter { : ""; const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); - const label = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`; + let label: string; + if (progress.passwordFound) { + label = `Passwort gefunden · ${progress.archiveName}`; + } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { + const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); + label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`; + } else { + label = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`; + } const updatedAt = nowMs(); for (const entry of archItems) { if (!isExtractedLabel(entry.fullStatus)) { @@ -5713,7 +5758,15 @@ export class DownloadManager extends EventEmitter { : ""; const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); - const label = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`; + let label: string; + if (progress.passwordFound) { + label = `Passwort gefunden · ${progress.archiveName}`; + } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { + const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); + label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`; + } else { + label = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`; + } const updatedAt = nowMs(); for (const entry of archiveItems) { if (!isExtractedLabel(entry.fullStatus) && entry.fullStatus !== label) { @@ -5731,7 +5784,15 @@ export class DownloadManager extends EventEmitter { : ""; const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); - const overallLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`; + let overallLabel: string; + if (progress.passwordFound) { + overallLabel = `Passwort gefunden · ${progress.archiveName || ""}`; + } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { + const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); + overallLabel = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName || ""}`; + } else { + overallLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`; + } emitExtractStatus(overallLabel); } }); diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 6cd8cff..721b13d 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -98,6 +98,9 @@ export interface ExtractProgressUpdate { archivePercent?: number; elapsedMs?: number; phase: "extracting" | "done"; + passwordAttempt?: number; + passwordTotal?: number; + passwordFound?: boolean; } const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024; @@ -1242,7 +1245,8 @@ async function runExternalExtract( passwordCandidates: string[], onArchiveProgress?: (percent: number) => void, signal?: AbortSignal, - hybridMode = false + hybridMode = false, + onPasswordAttempt?: (attempt: number, total: number) => void ): Promise { const timeoutMs = await computeExtractTimeoutMs(archivePath); const backendMode = extractorBackendMode(); @@ -1328,7 +1332,8 @@ async function runExternalExtract( onArchiveProgress, signal, timeoutMs, - hybridMode + hybridMode, + onPasswordAttempt ); const extractorName = path.basename(command).replace(/\.exe$/i, ""); if (jvmFailureReason) { @@ -1351,7 +1356,8 @@ async function runExternalExtractInner( onArchiveProgress: ((percent: number) => void) | undefined, signal: AbortSignal | undefined, timeoutMs: number, - hybridMode = false + hybridMode = false, + onPasswordAttempt?: (attempt: number, total: number) => void ): Promise { const passwords = passwordCandidates; let lastError = ""; @@ -1375,6 +1381,9 @@ async function runExternalExtractInner( passwordAttempt += 1; const quotedPw = password === "" ? '""' : `"${password}"`; logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`); + if (passwords.length > 1) { + onPasswordAttempt?.(passwordAttempt, passwords.length); + } let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode); let result = await runExtractCommand(command, args, (chunk) => { const parsed = parseProgressPercent(chunk); @@ -1889,7 +1898,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ archiveName: string, phase: "extracting" | "done", archivePercent?: number, - elapsedMs?: number + elapsedMs?: number, + pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean } ): void => { if (!options.onProgress) { return; @@ -1909,7 +1919,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ archiveName, archivePercent, elapsedMs, - phase + phase, + ...(pwInfo || {}) }); } catch (error) { logger.warn(`onProgress callback Fehler unterdrückt: ${cleanErrorText(String(error))}`); @@ -1953,6 +1964,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`); + const hasManyPasswords = archivePasswordCandidates.length > 1; + if (hasManyPasswords) { + emitProgress(extracted + failed, archiveName, "extracting", 0, 0, { passwordAttempt: 0, passwordTotal: archivePasswordCandidates.length }); + } + const onPwAttempt = hasManyPasswords + ? (attempt: number, total: number) => { emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordAttempt: attempt, passwordTotal: total }); } + : undefined; try { const ext = path.extname(archivePath).toLowerCase(); if (ext === ".zip") { @@ -1962,7 +1980,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { archivePercent = Math.max(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); - }, options.signal, hybrid); + }, options.signal, hybrid, onPwAttempt); passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } catch (error) { if (isNoExtractorError(String(error))) { @@ -1983,7 +2001,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { archivePercent = Math.max(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); - }, options.signal, hybrid); + }, options.signal, hybrid, onPwAttempt); passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } catch (externalError) { if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) { @@ -1997,7 +2015,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => { archivePercent = Math.max(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); - }, options.signal, hybrid); + }, options.signal, hybrid, onPwAttempt); passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } extracted += 1; @@ -2006,7 +2024,11 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); archivePercent = 100; - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); + if (hasManyPasswords) { + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordFound: true }); + } else { + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); + } } catch (error) { failed += 1; const errorText = String(error); diff --git a/src/main/main.ts b/src/main/main.ts index 69f7699..c4d3686 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -330,6 +330,15 @@ function registerIpcHandlers(): void { validateString(packageId, "packageId"); return controller.resetPackage(packageId); }); + ipcMain.handle(IPC_CHANNELS.SET_PACKAGE_PRIORITY, (_event: IpcMainInvokeEvent, packageId: string, priority: string) => { + validateString(packageId, "packageId"); + validateString(priority, "priority"); + return controller.setPackagePriority(packageId, priority as any); + }); + ipcMain.handle(IPC_CHANNELS.SKIP_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { + if (!Array.isArray(itemIds)) throw new Error("itemIds must be an array"); + return controller.skipItems(itemIds); + }); ipcMain.handle(IPC_CHANNELS.GET_HISTORY, () => controller.getHistory()); ipcMain.handle(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory()); ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => { diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 064033e..259141d 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -56,6 +56,8 @@ const api: ElectronApi = { getHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), clearHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), removeHistoryEntry: (entryId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), + setPackagePriority: (packageId: string, priority: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority), + skipItems: (itemIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds), onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => { const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot); ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9c93b83..71afd9d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2229,6 +2229,7 @@ export function App(): ReactElement { ]; })} Service + Priorität Status Geschwindigkeit @@ -2339,6 +2340,7 @@ export function App(): ReactElement { {entry.status === "completed" ? "100%" : "-"} - {entry.provider ? providerLabels[entry.provider] : "-"} + {entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"} - @@ -2814,6 +2816,26 @@ export function App(): ReactElement { )} ); })()} + {hasPackages && !contextMenu.itemId && (<> +
+
+ +
+ {(["high", "normal", "low"] as const).map((p) => { + const label = p === "high" ? "Hoch" : p === "low" ? "Niedrig" : "Standard"; + const pkgIds = [...selectedIds].filter((id) => snapshot.session.packages[id]); + const allMatch = pkgIds.every((id) => (snapshot.session.packages[id]?.priority || "normal") === p); + return ; + })} +
+
+ )} + {hasItems && (() => { + const itemIds = [...selectedIds].filter((id) => snapshot.session.items[id]); + const skippable = itemIds.filter((id) => { const it = snapshot.session.items[id]; return it && (it.status === "queued" || it.status === "reconnect_wait"); }); + if (skippable.length === 0) return null; + return ; + })()} {hasPackages && (
@@ -3057,6 +3080,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs {extractHoster(item.url) || "-"} {item.provider ? providerLabels[item.provider] : "-"} + 0 ? `${item.fullStatus} · R${item.retries}` : item.fullStatus}> {item.fullStatus} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 1fcdf40..e6800b9 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -577,7 +577,7 @@ body, .pkg-column-header { display: grid; - grid-template-columns: 1fr 170px 90px 140px 130px 180px 100px; + grid-template-columns: 1fr 160px 80px 110px 110px 70px 160px 90px; gap: 8px; padding: 5px 12px; background: var(--card); @@ -593,6 +593,7 @@ body, .pkg-column-header .pkg-col-size, .pkg-column-header .pkg-col-hoster, .pkg-column-header .pkg-col-account, +.pkg-column-header .pkg-col-prio, .pkg-column-header .pkg-col-status, .pkg-column-header .pkg-col-speed { text-align: center; @@ -612,7 +613,7 @@ body, .pkg-columns { display: grid; - grid-template-columns: 1fr 170px 90px 140px 130px 180px 100px; + grid-template-columns: 1fr 160px 80px 110px 110px 70px 160px 90px; gap: 8px; align-items: center; min-width: 0; @@ -636,6 +637,7 @@ body, .pkg-columns .pkg-col-size, .pkg-columns .pkg-col-hoster, .pkg-columns .pkg-col-account, +.pkg-columns .pkg-col-prio, .pkg-columns .pkg-col-status, .pkg-columns .pkg-col-speed { font-size: 13px; @@ -1284,7 +1286,7 @@ td { .item-row { display: grid; - grid-template-columns: 1fr 170px 90px 140px 130px 180px 100px; + grid-template-columns: 1fr 160px 80px 110px 110px 70px 160px 90px; gap: 8px; align-items: center; margin: 0 -12px; @@ -1337,6 +1339,45 @@ td { box-shadow: 0 0 4px #f59e0b80; } +.prio-high { + color: #f59e0b !important; + font-weight: 700; +} + +.prio-low { + color: #64748b !important; +} + +.ctx-menu-sub { + position: relative; +} + +.ctx-menu-sub > .ctx-menu-item::after { + content: ""; +} + +.ctx-menu-sub-items { + display: none; + position: absolute; + left: 100%; + top: 0; + min-width: 120px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 0; + box-shadow: 0 4px 12px rgba(0,0,0,.3); + z-index: 1001; +} + +.ctx-menu-sub:hover .ctx-menu-sub-items { + display: block; +} + +.ctx-menu-active { + color: var(--accent) !important; +} + .item-remove { background: none; border: none; @@ -1774,6 +1815,7 @@ td { .pkg-column-header .pkg-col-size, .pkg-column-header .pkg-col-hoster, .pkg-column-header .pkg-col-account, + .pkg-column-header .pkg-col-prio, .pkg-column-header .pkg-col-status, .pkg-column-header .pkg-col-speed { display: none; @@ -1783,6 +1825,7 @@ td { .pkg-columns .pkg-col-size, .pkg-columns .pkg-col-hoster, .pkg-columns .pkg-col-account, + .pkg-columns .pkg-col-prio, .pkg-columns .pkg-col-status, .pkg-columns .pkg-col-speed { display: none; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index d7e69dd..a352555 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -39,5 +39,7 @@ export const IPC_CHANNELS = { RESET_PACKAGE: "queue:reset-package", GET_HISTORY: "history:get", CLEAR_HISTORY: "history:clear", - REMOVE_HISTORY_ENTRY: "history:remove-entry" + REMOVE_HISTORY_ENTRY: "history:remove-entry", + SET_PACKAGE_PRIORITY: "queue:set-package-priority", + SKIP_ITEMS: "queue:skip-items" } as const; diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index cbf9d67..05af6cd 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -3,6 +3,7 @@ import type { AppSettings, DuplicatePolicy, HistoryEntry, + PackagePriority, SessionStats, StartConflictEntry, StartConflictResolutionResult, @@ -51,6 +52,8 @@ export interface ElectronApi { getHistory: () => Promise; clearHistory: () => Promise; removeHistoryEntry: (entryId: string) => Promise; + setPackagePriority: (packageId: string, priority: PackagePriority) => Promise; + skipItems: (itemIds: string[]) => Promise; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; onClipboardDetected: (callback: (links: string[]) => void) => () => void; onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; diff --git a/src/shared/types.ts b/src/shared/types.ts index 8844b37..eb224a9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -17,6 +17,7 @@ export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "packag export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid"; export type DebridFallbackProvider = DebridProvider | "none"; export type AppTheme = "dark" | "light"; +export type PackagePriority = "high" | "normal" | "low"; export interface BandwidthScheduleEntry { id: string; @@ -113,6 +114,7 @@ export interface PackageEntry { itemIds: string[]; cancelled: boolean; enabled: boolean; + priority: PackagePriority; createdAt: number; updatedAt: number; }