Release v1.5.86

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-04 00:58:30 +01:00
parent 15d0969cd9
commit d63afcce89
10 changed files with 203 additions and 35 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.5.85", "version": "1.5.86",
"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",

View File

@ -12,6 +12,7 @@ import {
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackageEntry, PackageEntry,
PackagePriority,
ParsedPackageInput, ParsedPackageInput,
SessionState, SessionState,
StartConflictEntry, StartConflictEntry,
@ -1210,6 +1211,7 @@ export class DownloadManager extends EventEmitter {
itemIds: [], itemIds: [],
cancelled: false, cancelled: false,
enabled: true, enabled: true,
priority: "normal",
createdAt: nowMs(), createdAt: nowMs(),
updatedAt: nowMs() updatedAt: nowMs()
}; };
@ -2430,6 +2432,30 @@ export class DownloadManager extends EventEmitter {
this.emitState(true); 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<void> { public async startPackages(packageIds: string[]): Promise<void> {
const targetSet = new Set(packageIds); const targetSet = new Set(packageIds);
@ -2839,6 +2865,9 @@ export class DownloadManager extends EventEmitter {
if (pkg.enabled === undefined) { if (pkg.enabled === undefined) {
pkg.enabled = true; pkg.enabled = true;
} }
if (!pkg.priority) {
pkg.priority = "normal";
}
if (pkg.status === "downloading" if (pkg.status === "downloading"
|| pkg.status === "validating" || pkg.status === "validating"
|| pkg.status === "extracting" || pkg.status === "extracting"
@ -3720,11 +3749,16 @@ export class DownloadManager extends EventEmitter {
private findNextQueuedItem(): { packageId: string; itemId: string } | null { private findNextQueuedItem(): { packageId: string; itemId: string } | null {
const now = nowMs(); const now = nowMs();
const priorityOrder: Array<PackagePriority> = ["high", "normal", "low"];
for (const prio of priorityOrder) {
for (const packageId of this.session.packageOrder) { for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) { if (!pkg || pkg.cancelled || !pkg.enabled) {
continue; continue;
} }
if ((pkg.priority || "normal") !== prio) {
continue;
}
if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) { if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) {
continue; continue;
} }
@ -3745,6 +3779,7 @@ export class DownloadManager extends EventEmitter {
} }
} }
} }
}
return null; return null;
} }
@ -3995,7 +4030,7 @@ export class DownloadManager extends EventEmitter {
item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget)); item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget));
item.totalBytes = unrestricted.fileSize; item.totalBytes = unrestricted.fileSize;
item.status = "downloading"; item.status = "downloading";
item.fullStatus = `Download läuft (${unrestricted.providerLabel})`; item.fullStatus = `Starte... (${unrestricted.providerLabel})`;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
this.emitState(); this.emitState();
@ -4888,7 +4923,9 @@ export class DownloadManager extends EventEmitter {
item.downloadedBytes = written; item.downloadedBytes = written;
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100; item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100;
item.speedBps = 0; item.speedBps = 0;
item.fullStatus = "Finalisierend...";
item.updatedAt = nowMs(); item.updatedAt = nowMs();
this.emitState();
return { resumable }; return { resumable };
} catch (error) { } catch (error) {
if (active.abortController.signal.aborted || String(error).includes("aborted:")) { 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 activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); 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(); const updatedAt = nowMs();
for (const entry of archItems) { for (const entry of archItems) {
if (!isExtractedLabel(entry.fullStatus)) { if (!isExtractedLabel(entry.fullStatus)) {
@ -5713,7 +5758,15 @@ export class DownloadManager extends EventEmitter {
: ""; : "";
const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); 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(); const updatedAt = nowMs();
for (const entry of archiveItems) { for (const entry of archiveItems) {
if (!isExtractedLabel(entry.fullStatus) && entry.fullStatus !== label) { 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 activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); 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); emitExtractStatus(overallLabel);
} }
}); });

View File

@ -98,6 +98,9 @@ export interface ExtractProgressUpdate {
archivePercent?: number; archivePercent?: number;
elapsedMs?: number; elapsedMs?: number;
phase: "extracting" | "done"; phase: "extracting" | "done";
passwordAttempt?: number;
passwordTotal?: number;
passwordFound?: boolean;
} }
const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024; const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024;
@ -1242,7 +1245,8 @@ async function runExternalExtract(
passwordCandidates: string[], passwordCandidates: string[],
onArchiveProgress?: (percent: number) => void, onArchiveProgress?: (percent: number) => void,
signal?: AbortSignal, signal?: AbortSignal,
hybridMode = false hybridMode = false,
onPasswordAttempt?: (attempt: number, total: number) => void
): Promise<string> { ): Promise<string> {
const timeoutMs = await computeExtractTimeoutMs(archivePath); const timeoutMs = await computeExtractTimeoutMs(archivePath);
const backendMode = extractorBackendMode(); const backendMode = extractorBackendMode();
@ -1328,7 +1332,8 @@ async function runExternalExtract(
onArchiveProgress, onArchiveProgress,
signal, signal,
timeoutMs, timeoutMs,
hybridMode hybridMode,
onPasswordAttempt
); );
const extractorName = path.basename(command).replace(/\.exe$/i, ""); const extractorName = path.basename(command).replace(/\.exe$/i, "");
if (jvmFailureReason) { if (jvmFailureReason) {
@ -1351,7 +1356,8 @@ async function runExternalExtractInner(
onArchiveProgress: ((percent: number) => void) | undefined, onArchiveProgress: ((percent: number) => void) | undefined,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
timeoutMs: number, timeoutMs: number,
hybridMode = false hybridMode = false,
onPasswordAttempt?: (attempt: number, total: number) => void
): Promise<string> { ): Promise<string> {
const passwords = passwordCandidates; const passwords = passwordCandidates;
let lastError = ""; let lastError = "";
@ -1375,6 +1381,9 @@ async function runExternalExtractInner(
passwordAttempt += 1; passwordAttempt += 1;
const quotedPw = password === "" ? '""' : `"${password}"`; const quotedPw = password === "" ? '""' : `"${password}"`;
logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`); 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 args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode);
let result = await runExtractCommand(command, args, (chunk) => { let result = await runExtractCommand(command, args, (chunk) => {
const parsed = parseProgressPercent(chunk); const parsed = parseProgressPercent(chunk);
@ -1889,7 +1898,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
archiveName: string, archiveName: string,
phase: "extracting" | "done", phase: "extracting" | "done",
archivePercent?: number, archivePercent?: number,
elapsedMs?: number elapsedMs?: number,
pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean }
): void => { ): void => {
if (!options.onProgress) { if (!options.onProgress) {
return; return;
@ -1909,7 +1919,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
archiveName, archiveName,
archivePercent, archivePercent,
elapsedMs, elapsedMs,
phase phase,
...(pwInfo || {})
}); });
} catch (error) { } catch (error) {
logger.warn(`onProgress callback Fehler unterdrückt: ${cleanErrorText(String(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)" : ""}`); 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 { try {
const ext = path.extname(archivePath).toLowerCase(); const ext = path.extname(archivePath).toLowerCase();
if (ext === ".zip") { 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) => { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
archivePercent = Math.max(archivePercent, value); archivePercent = Math.max(archivePercent, value);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, options.signal, hybrid); }, options.signal, hybrid, onPwAttempt);
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
} catch (error) { } catch (error) {
if (isNoExtractorError(String(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) => { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
archivePercent = Math.max(archivePercent, value); archivePercent = Math.max(archivePercent, value);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, options.signal, hybrid); }, options.signal, hybrid, onPwAttempt);
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
} catch (externalError) { } catch (externalError) {
if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(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) => { const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
archivePercent = Math.max(archivePercent, value); archivePercent = Math.max(archivePercent, value);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, options.signal, hybrid); }, options.signal, hybrid, onPwAttempt);
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
} }
extracted += 1; extracted += 1;
@ -2006,7 +2024,11 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
archivePercent = 100; archivePercent = 100;
if (hasManyPasswords) {
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordFound: true });
} else {
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}
} catch (error) { } catch (error) {
failed += 1; failed += 1;
const errorText = String(error); const errorText = String(error);

View File

@ -330,6 +330,15 @@ function registerIpcHandlers(): void {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
return controller.resetPackage(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.GET_HISTORY, () => controller.getHistory());
ipcMain.handle(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory()); ipcMain.handle(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory());
ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => { ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => {

View File

@ -56,6 +56,8 @@ const api: ElectronApi = {
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
setPackagePriority: (packageId: string, priority: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority),
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => { onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot); const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener); ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);

View File

@ -2229,6 +2229,7 @@ export function App(): ReactElement {
]; ];
})} })}
<span className="pkg-col pkg-col-account">Service</span> <span className="pkg-col pkg-col-account">Service</span>
<span className="pkg-col pkg-col-prio">Priorität</span>
<span className="pkg-col pkg-col-status">Status</span> <span className="pkg-col pkg-col-status">Status</span>
<span className="pkg-col pkg-col-speed">Geschwindigkeit</span> <span className="pkg-col pkg-col-speed">Geschwindigkeit</span>
</div> </div>
@ -2339,6 +2340,7 @@ export function App(): ReactElement {
<span className="pkg-col pkg-col-progress">{entry.status === "completed" ? "100%" : "-"}</span> <span className="pkg-col pkg-col-progress">{entry.status === "completed" ? "100%" : "-"}</span>
<span className="pkg-col pkg-col-hoster">-</span> <span className="pkg-col pkg-col-hoster">-</span>
<span className="pkg-col pkg-col-account">{entry.provider ? providerLabels[entry.provider] : "-"}</span> <span className="pkg-col pkg-col-account">{entry.provider ? providerLabels[entry.provider] : "-"}</span>
<span className="pkg-col pkg-col-prio"></span>
<span className="pkg-col pkg-col-status">{entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"}</span> <span className="pkg-col pkg-col-status">{entry.status === "completed" ? "Abgeschlossen" : "Gelöscht"}</span>
<span className="pkg-col pkg-col-speed">-</span> <span className="pkg-col pkg-col-speed">-</span>
</div> </div>
@ -2814,6 +2816,26 @@ export function App(): ReactElement {
)} )}
</>); </>);
})()} })()}
{hasPackages && !contextMenu.itemId && (<>
<div className="ctx-menu-sep" />
<div className="ctx-menu-sub">
<button className="ctx-menu-item">Priorität </button>
<div className="ctx-menu-sub-items">
{(["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 <button key={p} className={`ctx-menu-item${allMatch ? " ctx-menu-active" : ""}`} onClick={() => { for (const id of pkgIds) void window.rd.setPackagePriority(id, p); setContextMenu(null); }}>{allMatch ? `${label}` : label}</button>;
})}
</div>
</div>
</>)}
{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 <button className="ctx-menu-item" onClick={() => { void window.rd.skipItems(skippable); setContextMenu(null); }}>Überspringen{skippable.length > 1 ? ` (${skippable.length})` : ""}</button>;
})()}
{hasPackages && ( {hasPackages && (
<button className="ctx-menu-item ctx-danger" onClick={() => { <button className="ctx-menu-item ctx-danger" onClick={() => {
setContextMenu(null); setContextMenu(null);
@ -3019,6 +3041,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
const providers = [...new Set(items.map((item) => item.provider).filter(Boolean))]; const providers = [...new Set(items.map((item) => item.provider).filter(Boolean))];
return providers.length > 0 ? providers.map((p) => providerLabels[p!] || p).join(", ") : "-"; return providers.length > 0 ? providers.map((p) => providerLabels[p!] || p).join(", ") : "-";
})()}</span> })()}</span>
<span className={`pkg-col pkg-col-prio${pkg.priority === "high" ? " prio-high" : pkg.priority === "low" ? " prio-low" : ""}`}>{pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : "-"}</span>
<span className="pkg-col pkg-col-status">[{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` · ${failed} Fehler` : ""}{cancelled > 0 ? ` · ${cancelled} abgebr.` : ""}]</span> <span className="pkg-col pkg-col-status">[{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` · ${failed} Fehler` : ""}{cancelled > 0 ? ` · ${cancelled} abgebr.` : ""}]</span>
<span className="pkg-col pkg-col-speed">{packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : "-"}</span> <span className="pkg-col pkg-col-speed">{packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : "-"}</span>
</div> </div>
@ -3057,6 +3080,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
</span> </span>
<span className="pkg-col pkg-col-hoster" title={extractHoster(item.url)}>{extractHoster(item.url) || "-"}</span> <span className="pkg-col pkg-col-hoster" title={extractHoster(item.url)}>{extractHoster(item.url) || "-"}</span>
<span className="pkg-col pkg-col-account">{item.provider ? providerLabels[item.provider] : "-"}</span> <span className="pkg-col pkg-col-account">{item.provider ? providerLabels[item.provider] : "-"}</span>
<span className="pkg-col pkg-col-prio"></span>
<span className="pkg-col pkg-col-status" title={item.retries > 0 ? `${item.fullStatus} · R${item.retries}` : item.fullStatus}> <span className="pkg-col pkg-col-status" title={item.retries > 0 ? `${item.fullStatus} · R${item.retries}` : item.fullStatus}>
{item.fullStatus} {item.fullStatus}
</span> </span>

View File

@ -577,7 +577,7 @@ body,
.pkg-column-header { .pkg-column-header {
display: grid; 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; gap: 8px;
padding: 5px 12px; padding: 5px 12px;
background: var(--card); background: var(--card);
@ -593,6 +593,7 @@ body,
.pkg-column-header .pkg-col-size, .pkg-column-header .pkg-col-size,
.pkg-column-header .pkg-col-hoster, .pkg-column-header .pkg-col-hoster,
.pkg-column-header .pkg-col-account, .pkg-column-header .pkg-col-account,
.pkg-column-header .pkg-col-prio,
.pkg-column-header .pkg-col-status, .pkg-column-header .pkg-col-status,
.pkg-column-header .pkg-col-speed { .pkg-column-header .pkg-col-speed {
text-align: center; text-align: center;
@ -612,7 +613,7 @@ body,
.pkg-columns { .pkg-columns {
display: grid; 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; gap: 8px;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
@ -636,6 +637,7 @@ body,
.pkg-columns .pkg-col-size, .pkg-columns .pkg-col-size,
.pkg-columns .pkg-col-hoster, .pkg-columns .pkg-col-hoster,
.pkg-columns .pkg-col-account, .pkg-columns .pkg-col-account,
.pkg-columns .pkg-col-prio,
.pkg-columns .pkg-col-status, .pkg-columns .pkg-col-status,
.pkg-columns .pkg-col-speed { .pkg-columns .pkg-col-speed {
font-size: 13px; font-size: 13px;
@ -1284,7 +1286,7 @@ td {
.item-row { .item-row {
display: grid; 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; gap: 8px;
align-items: center; align-items: center;
margin: 0 -12px; margin: 0 -12px;
@ -1337,6 +1339,45 @@ td {
box-shadow: 0 0 4px #f59e0b80; 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 { .item-remove {
background: none; background: none;
border: none; border: none;
@ -1774,6 +1815,7 @@ td {
.pkg-column-header .pkg-col-size, .pkg-column-header .pkg-col-size,
.pkg-column-header .pkg-col-hoster, .pkg-column-header .pkg-col-hoster,
.pkg-column-header .pkg-col-account, .pkg-column-header .pkg-col-account,
.pkg-column-header .pkg-col-prio,
.pkg-column-header .pkg-col-status, .pkg-column-header .pkg-col-status,
.pkg-column-header .pkg-col-speed { .pkg-column-header .pkg-col-speed {
display: none; display: none;
@ -1783,6 +1825,7 @@ td {
.pkg-columns .pkg-col-size, .pkg-columns .pkg-col-size,
.pkg-columns .pkg-col-hoster, .pkg-columns .pkg-col-hoster,
.pkg-columns .pkg-col-account, .pkg-columns .pkg-col-account,
.pkg-columns .pkg-col-prio,
.pkg-columns .pkg-col-status, .pkg-columns .pkg-col-status,
.pkg-columns .pkg-col-speed { .pkg-columns .pkg-col-speed {
display: none; display: none;

View File

@ -39,5 +39,7 @@ export const IPC_CHANNELS = {
RESET_PACKAGE: "queue:reset-package", RESET_PACKAGE: "queue:reset-package",
GET_HISTORY: "history:get", GET_HISTORY: "history:get",
CLEAR_HISTORY: "history:clear", 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; } as const;

View File

@ -3,6 +3,7 @@ import type {
AppSettings, AppSettings,
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackagePriority,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
@ -51,6 +52,8 @@ export interface ElectronApi {
getHistory: () => Promise<HistoryEntry[]>; getHistory: () => Promise<HistoryEntry[]>;
clearHistory: () => Promise<void>; clearHistory: () => Promise<void>;
removeHistoryEntry: (entryId: string) => Promise<void>; removeHistoryEntry: (entryId: string) => Promise<void>;
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
skipItems: (itemIds: string[]) => Promise<void>;
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
onClipboardDetected: (callback: (links: string[]) => void) => () => void; onClipboardDetected: (callback: (links: string[]) => void) => () => void;
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;

View File

@ -17,6 +17,7 @@ export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "packag
export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid"; export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid";
export type DebridFallbackProvider = DebridProvider | "none"; export type DebridFallbackProvider = DebridProvider | "none";
export type AppTheme = "dark" | "light"; export type AppTheme = "dark" | "light";
export type PackagePriority = "high" | "normal" | "low";
export interface BandwidthScheduleEntry { export interface BandwidthScheduleEntry {
id: string; id: string;
@ -113,6 +114,7 @@ export interface PackageEntry {
itemIds: string[]; itemIds: string[];
cancelled: boolean; cancelled: boolean;
enabled: boolean; enabled: boolean;
priority: PackagePriority;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }