Release v1.4.30 with startup and UI race-condition fixes
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-02-28 22:33:19 +01:00
parent eda9754d30
commit 6ae687f3ab
10 changed files with 235 additions and 52 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.29", "version": "1.4.30",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.29", "version": "1.4.30",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

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

@ -5,7 +5,7 @@ import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
const API_TIMEOUT_MS = 30000; const API_TIMEOUT_MS = 30000;
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.29"; const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.30";
const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024; const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024;
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1"; const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";

View File

@ -343,6 +343,13 @@ function isNoExtractorError(errorText: string): boolean {
return String(errorText || "").toLowerCase().includes("nicht gefunden"); return String(errorText || "").toLowerCase().includes("nicht gefunden");
} }
function isUnsupportedArchiveFormatError(errorText: string): boolean {
const text = String(errorText || "").toLowerCase();
return text.includes("kein rar-archiv")
|| text.includes("not a rar archive")
|| text.includes("is not a rar archive");
}
function isUnsupportedExtractorSwitchError(errorText: string): boolean { function isUnsupportedExtractorSwitchError(errorText: string): boolean {
const text = String(errorText || "").toLowerCase(); const text = String(errorText || "").toLowerCase();
return text.includes("unknown switch") return text.includes("unknown switch")
@ -705,17 +712,26 @@ async function runExternalExtract(
} }
function isZipSafetyGuardError(error: unknown): boolean { function isZipSafetyGuardError(error: unknown): boolean {
const text = String(error || "").toLowerCase();
return text.includes("path traversal")
|| text.includes("zip-eintrag verdächtig groß")
|| text.includes("zip-eintrag verdaechtig gross");
}
function isZipInternalLimitError(error: unknown): boolean {
const text = String(error || "").toLowerCase(); const text = String(error || "").toLowerCase();
return text.includes("zip-eintrag zu groß") return text.includes("zip-eintrag zu groß")
|| text.includes("zip-eintrag komprimiert zu groß") || text.includes("zip-eintrag komprimiert zu groß")
|| text.includes("zip-eintrag ohne sichere groessenangabe") || text.includes("zip-eintrag ohne sichere groessenangabe");
|| text.includes("path traversal");
} }
function shouldFallbackToExternalZip(error: unknown): boolean { function shouldFallbackToExternalZip(error: unknown): boolean {
if (isZipSafetyGuardError(error)) { if (isZipSafetyGuardError(error)) {
return false; return false;
} }
if (isZipInternalLimitError(error)) {
return true;
}
const text = String(error || "").toLowerCase(); const text = String(error || "").toLowerCase();
if (text.includes("aborted:extract") || text.includes("extract_aborted")) { if (text.includes("aborted:extract") || text.includes("extract_aborted")) {
return false; return false;
@ -1190,11 +1206,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (!shouldFallbackToExternalZip(error)) { if (!shouldFallbackToExternalZip(error)) {
throw error; throw error;
} }
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { try {
archivePercent = Math.max(archivePercent, value); const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => {
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); archivePercent = Math.max(archivePercent, value);
}, options.signal); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); }, options.signal);
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
} catch (externalError) {
if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) {
throw error;
}
throw externalError;
}
} }
} }
} else { } else {

View File

@ -34,7 +34,8 @@ function validateStringArray(value: unknown, name: string): string[] {
/* ── Single Instance Lock ───────────────────────────────────────── */ /* ── Single Instance Lock ───────────────────────────────────────── */
const gotLock = app.requestSingleInstanceLock(); const gotLock = app.requestSingleInstanceLock();
if (!gotLock) { if (!gotLock) {
app.quit(); app.exit(0);
process.exit(0);
} }
/* ── Unhandled error protection ─────────────────────────────────── */ /* ── Unhandled error protection ─────────────────────────────────── */

View File

@ -1,7 +1,7 @@
import { API_BASE_URL, REQUEST_RETRIES } from "./constants"; import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
import { compactErrorText, sleep } from "./utils"; import { compactErrorText, sleep } from "./utils";
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.29"; const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.30";
export interface UnrestrictedLink { export interface UnrestrictedLink {
fileName: string; fileName: string;

View File

@ -59,9 +59,6 @@ function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] {
function normalizeAbsoluteDir(value: unknown, fallback: string): string { function normalizeAbsoluteDir(value: unknown, fallback: string): string {
const text = asText(value); const text = asText(value);
if (/^\/[\s\S]+/.test(text)) {
return text.replace(/\\/g, "/");
}
if (!text || !path.isAbsolute(text)) { if (!text || !path.isAbsolute(text)) {
return path.resolve(fallback); return path.resolve(fallback);
} }

View File

@ -116,11 +116,42 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
return sorted; return sorted;
} }
function sameStringArray(a: string[], b: string[]): boolean {
if (a.length !== b.length) {
return false;
}
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) {
return false;
}
}
return true;
}
function formatMbpsInputFromKbps(kbps: number): string {
const mbps = Math.max(0, Number(kbps) || 0) / 1024;
return String(Number(mbps.toFixed(2)));
}
function parseMbpsInput(value: string): number | null {
const normalized = String(value || "").trim().replace(/,/g, ".");
if (!normalized) {
return 0;
}
const parsed = Number(normalized);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}
return parsed;
}
export function App(): ReactElement { export function App(): ReactElement {
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot); const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
const [tab, setTab] = useState<Tab>("collector"); const [tab, setTab] = useState<Tab>("collector");
const [statusToast, setStatusToast] = useState(""); const [statusToast, setStatusToast] = useState("");
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings); const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
const [settingsDirty, setSettingsDirty] = useState(false); const [settingsDirty, setSettingsDirty] = useState(false);
const settingsDirtyRef = useRef(false); const settingsDirtyRef = useRef(false);
const settingsDraftRevisionRef = useRef(0); const settingsDraftRevisionRef = useRef(0);
@ -138,6 +169,9 @@ export function App(): ReactElement {
const activeCollectorTabRef = useRef(activeCollectorTab); const activeCollectorTabRef = useRef(activeCollectorTab);
const activeTabRef = useRef<Tab>(tab); const activeTabRef = useRef<Tab>(tab);
const packageOrderRef = useRef<string[]>([]); const packageOrderRef = useRef<string[]>([]);
const serverPackageOrderRef = useRef<string[]>([]);
const pendingPackageOrderRef = useRef<string[] | null>(null);
const pendingPackageOrderAtRef = useRef(0);
const draggedPackageIdRef = useRef<string | null>(null); const draggedPackageIdRef = useRef<string | null>(null);
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({}); const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
const [downloadSearch, setDownloadSearch] = useState(""); const [downloadSearch, setDownloadSearch] = useState("");
@ -153,6 +187,8 @@ export function App(): ReactElement {
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null); const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null); const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
@ -169,9 +205,37 @@ export function App(): ReactElement {
}, [tab]); }, [tab]);
useEffect(() => { useEffect(() => {
packageOrderRef.current = snapshot.session.packageOrder; const incoming = snapshot.session.packageOrder;
serverPackageOrderRef.current = incoming;
const pending = pendingPackageOrderRef.current;
if (!pending) {
packageOrderRef.current = incoming;
return;
}
if (sameStringArray(pending, incoming)) {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = incoming;
return;
}
const maxOptimisticHoldMs = 1500;
if (Date.now() - pendingPackageOrderAtRef.current >= maxOptimisticHoldMs) {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = incoming;
return;
}
packageOrderRef.current = pending;
}, [snapshot.session.packageOrder]); }, [snapshot.session.packageOrder]);
useEffect(() => {
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
}, [settingsDraft.speedLimitKbps]);
const showToast = useCallback((message: string, timeoutMs = 2200): void => { const showToast = useCallback((message: string, timeoutMs = 2200): void => {
setStatusToast(message); setStatusToast(message);
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
@ -181,6 +245,15 @@ export function App(): ReactElement {
}, timeoutMs); }, timeoutMs);
}, []); }, []);
const clearImportQueueFocusListener = useCallback((): void => {
const handler = importQueueFocusHandlerRef.current;
if (!handler) {
return;
}
window.removeEventListener("focus", handler);
importQueueFocusHandlerRef.current = null;
}, []);
useEffect(() => { useEffect(() => {
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
let unsubClipboard: (() => void) | null = null; let unsubClipboard: (() => void) | null = null;
@ -243,6 +316,7 @@ export function App(): ReactElement {
if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); } if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); } if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); }
clearImportQueueFocusListener();
if (startConflictResolverRef.current) { if (startConflictResolverRef.current) {
const resolver = startConflictResolverRef.current; const resolver = startConflictResolverRef.current;
startConflictResolverRef.current = null; startConflictResolverRef.current = null;
@ -253,10 +327,14 @@ export function App(): ReactElement {
confirmResolverRef.current = null; confirmResolverRef.current = null;
resolver(false); resolver(false);
} }
while (confirmQueueRef.current.length > 0) {
const request = confirmQueueRef.current.shift();
request?.resolve(false);
}
if (unsubscribe) { unsubscribe(); } if (unsubscribe) { unsubscribe(); }
if (unsubClipboard) { unsubClipboard(); } if (unsubClipboard) { unsubClipboard(); }
}; };
}, []); }, [clearImportQueueFocusListener]);
const downloadsTabActive = tab === "downloads"; const downloadsTabActive = tab === "downloads";
const deferredDownloadSearch = useDeferredValue(downloadSearch); const deferredDownloadSearch = useDeferredValue(downloadSearch);
@ -449,6 +527,9 @@ export function App(): ReactElement {
message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`, message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`,
confirmLabel: "Jetzt installieren" confirmLabel: "Jetzt installieren"
}); });
if (!mountedRef.current) {
return;
}
if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; } if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; }
const install = await window.rd.installUpdate(); const install = await window.rd.installUpdate();
if (!mountedRef.current) { if (!mountedRef.current) {
@ -507,21 +588,34 @@ export function App(): ReactElement {
}); });
}; };
const closeConfirmPrompt = (confirmed: boolean): void => { const pumpConfirmQueue = useCallback((): void => {
if (confirmResolverRef.current) {
return;
}
const next = confirmQueueRef.current.shift();
if (!next) {
return;
}
confirmResolverRef.current = next.resolve;
setConfirmPrompt(next.prompt);
}, []);
const closeConfirmPrompt = useCallback((confirmed: boolean): void => {
const resolver = confirmResolverRef.current; const resolver = confirmResolverRef.current;
confirmResolverRef.current = null; confirmResolverRef.current = null;
setConfirmPrompt(null); setConfirmPrompt(null);
if (resolver) { if (resolver) {
resolver(confirmed); resolver(confirmed);
} }
}; pumpConfirmQueue();
}, [pumpConfirmQueue]);
const askConfirmPrompt = (prompt: ConfirmPromptState): Promise<boolean> => { const askConfirmPrompt = useCallback((prompt: ConfirmPromptState): Promise<boolean> => {
return new Promise((resolve) => { return new Promise((resolve) => {
confirmResolverRef.current = resolve; confirmQueueRef.current.push({ prompt, resolve });
setConfirmPrompt(prompt); pumpConfirmQueue();
}); });
}; }, [pumpConfirmQueue]);
const onStartDownloads = async (): Promise<void> => { const onStartDownloads = async (): Promise<void> => {
await performQuickAction(async () => { await performQuickAction(async () => {
@ -665,14 +759,14 @@ export function App(): ReactElement {
}; };
const onWindowFocus = (): void => { const onWindowFocus = (): void => {
window.removeEventListener("focus", onWindowFocus); clearImportQueueFocusListener();
if (!input.files || input.files.length === 0) { if (!input.files || input.files.length === 0) {
releasePickerBusy(); releasePickerBusy();
} }
}; };
input.onchange = async () => { input.onchange = async () => {
window.removeEventListener("focus", onWindowFocus); clearImportQueueFocusListener();
const file = input.files?.[0]; const file = input.files?.[0];
if (!file) { if (!file) {
releasePickerBusy(); releasePickerBusy();
@ -688,6 +782,8 @@ export function App(): ReactElement {
}); });
}; };
clearImportQueueFocusListener();
importQueueFocusHandlerRef.current = onWindowFocus;
window.addEventListener("focus", onWindowFocus, { once: true }); window.addEventListener("focus", onWindowFocus, { once: true });
input.click(); input.click();
}; };
@ -760,8 +856,13 @@ export function App(): ReactElement {
if (target < 0 || target >= order.length) { return; } if (target < 0 || target >= order.length) { return; }
[order[idx], order[target]] = [order[target], order[idx]]; [order[idx], order[target]] = [order[target], order[idx]];
setDownloadsSortDescending(false); setDownloadsSortDescending(false);
packageOrderRef.current = order; pendingPackageOrderRef.current = [...order];
pendingPackageOrderAtRef.current = Date.now();
packageOrderRef.current = [...order];
void window.rd.reorderPackages(order).catch((error) => { void window.rd.reorderPackages(order).catch((error) => {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = serverPackageOrderRef.current;
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
}); });
}, [showToast]); }, [showToast]);
@ -775,8 +876,13 @@ export function App(): ReactElement {
return; return;
} }
setDownloadsSortDescending(false); setDownloadsSortDescending(false);
packageOrderRef.current = nextOrder; pendingPackageOrderRef.current = [...nextOrder];
pendingPackageOrderAtRef.current = Date.now();
packageOrderRef.current = [...nextOrder];
void window.rd.reorderPackages(nextOrder).catch((error) => { void window.rd.reorderPackages(nextOrder).catch((error) => {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = serverPackageOrderRef.current;
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
}); });
}, [showToast]); }, [showToast]);
@ -874,6 +980,33 @@ export function App(): ReactElement {
}, [showToast]); }, [showToast]);
const schedules = settingsDraft.bandwidthSchedules ?? []; const schedules = settingsDraft.bandwidthSchedules ?? [];
useEffect(() => {
setScheduleSpeedInputs((prev) => {
const syncFromSettings = !settingsDirtyRef.current;
let changed = false;
const next: Record<string, string> = {};
for (let index = 0; index < schedules.length; index += 1) {
const schedule = schedules[index];
const key = schedule.id || `schedule-${index}`;
const normalized = formatMbpsInputFromKbps(schedule.speedLimitKbps);
if (syncFromSettings || !Object.prototype.hasOwnProperty.call(prev, key)) {
next[key] = normalized;
if (prev[key] !== normalized) {
changed = true;
}
} else {
next[key] = prev[key];
}
}
const prevKeys = Object.keys(prev);
if (prevKeys.length !== Object.keys(next).length) {
changed = true;
}
return changed ? next : prev;
});
}, [schedules, settingsDirty]);
const addSchedule = (): void => { const addSchedule = (): void => {
settingsDraftRevisionRef.current += 1; settingsDraftRevisionRef.current += 1;
settingsDirtyRef.current = true; settingsDirtyRef.current = true;
@ -1066,7 +1199,10 @@ export function App(): ReactElement {
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder; const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
const sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending); const sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
packageOrderRef.current = sorted; packageOrderRef.current = sorted;
void window.rd.reorderPackages(sorted); void window.rd.reorderPackages(sorted).catch((error) => {
packageOrderRef.current = serverPackageOrderRef.current;
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
});
}} }}
> >
{downloadsSortDescending ? "Z-A" : "A-Z"} {downloadsSortDescending ? "Z-A" : "A-Z"}
@ -1221,8 +1357,17 @@ export function App(): ReactElement {
type="number" type="number"
min={0} min={0}
step={0.1} step={0.1}
value={Number((settingsDraft.speedLimitKbps / 1024).toFixed(2))} value={speedLimitInput}
onChange={(e) => setSpeedLimitMbps(Number(e.target.value) || 0)} onChange={(event) => setSpeedLimitInput(event.target.value)}
onBlur={(event) => {
const parsed = parseMbpsInput(event.target.value);
if (parsed === null) {
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
return;
}
setSpeedLimitMbps(parsed);
setSpeedLimitInput(formatMbpsInputFromKbps(Math.floor(parsed * 1024)));
}}
disabled={!settingsDraft.speedLimitEnabled} disabled={!settingsDraft.speedLimitEnabled}
/> />
</div> </div>
@ -1243,25 +1388,42 @@ export function App(): ReactElement {
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
<h4>Bandbreitenplanung</h4> <h4>Bandbreitenplanung</h4>
{schedules.map((s, i) => ( {schedules.map((s, i) => {
<div key={s.id || `schedule-${i}`} className="schedule-row"> const scheduleKey = s.id || `schedule-${i}`;
<input type="number" min={0} max={23} value={s.startHour} onChange={(e) => updateSchedule(i, "startHour", Number(e.target.value))} title="Von (Stunde)" /> const speedInput = scheduleSpeedInputs[scheduleKey] ?? formatMbpsInputFromKbps(s.speedLimitKbps);
<span>-</span> return (
<input type="number" min={0} max={23} value={s.endHour} onChange={(e) => updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" /> <div key={scheduleKey} className="schedule-row">
<span>Uhr</span> <input type="number" min={0} max={23} value={s.startHour} onChange={(e) => updateSchedule(i, "startHour", Number(e.target.value))} title="Von (Stunde)" />
<input <span>-</span>
type="number" <input type="number" min={0} max={23} value={s.endHour} onChange={(e) => updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" />
min={0} <span>Uhr</span>
step={0.1} <input
value={Number((s.speedLimitKbps / 1024).toFixed(2))} type="number"
onChange={(e) => updateSchedule(i, "speedLimitKbps", Math.floor((Number(e.target.value) || 0) * 1024))} min={0}
title="MB/s (0=unbegrenzt)" step={0.1}
/> value={speedInput}
<span>MB/s</span> onChange={(event) => {
<input type="checkbox" checked={s.enabled} onChange={(e) => updateSchedule(i, "enabled", e.target.checked)} /> const nextText = event.target.value;
<button className="btn danger" onClick={() => removeSchedule(i)}>X</button> setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: nextText }));
</div> }}
))} onBlur={(event) => {
const parsed = parseMbpsInput(event.target.value);
if (parsed === null) {
setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: formatMbpsInputFromKbps(s.speedLimitKbps) }));
return;
}
const nextKbps = Math.floor(parsed * 1024);
setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: formatMbpsInputFromKbps(nextKbps) }));
updateSchedule(i, "speedLimitKbps", nextKbps);
}}
title="MB/s (0=unbegrenzt)"
/>
<span>MB/s</span>
<input type="checkbox" checked={s.enabled} onChange={(e) => updateSchedule(i, "enabled", e.target.checked)} />
<button className="btn danger" onClick={() => removeSchedule(i)}>X</button>
</div>
);
})}
<button className="btn" onClick={addSchedule}>Zeitregel hinzufügen</button> <button className="btn" onClick={addSchedule}>Zeitregel hinzufügen</button>
</article> </article>

View File

@ -555,7 +555,7 @@ describe("extractor", () => {
expect(targets.has(r02)).toBe(true); expect(targets.has(r02)).toBe(true);
}); });
it("does not fallback to external extractor when ZIP safety guard triggers", async () => { it("keeps original ZIP size guard error when external fallback is unavailable", async () => {
const previousLimit = process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB; const previousLimit = process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = "8"; process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = "8";

View File

@ -409,7 +409,7 @@ describe("settings storage", () => {
// Old fields should be preserved // Old fields should be preserved
expect(loaded.token).toBe("my-token"); expect(loaded.token).toBe("my-token");
expect(loaded.outputDir).toBe("/custom/output"); expect(loaded.outputDir).toBe(path.resolve("/custom/output"));
// Missing new fields should get default values // Missing new fields should get default values
expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback); expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback);