feat: re-download from history, reset-all-failed, scheduled start, fix provider order dirty flag

This commit is contained in:
Sucukdeluxe 2026-03-06 22:53:20 +01:00
parent 6d6453dc4b
commit 2e6074337a
7 changed files with 183 additions and 6 deletions

1
.gitignore vendored
View File

@ -37,3 +37,4 @@ deploy/forgejo/caddy/data/
deploy/forgejo/caddy/config/ deploy/forgejo/caddy/config/
deploy/forgejo/caddy/logs/ deploy/forgejo/caddy/logs/
deploy/forgejo/backups/ deploy/forgejo/backups/
.secrets

View File

@ -6,10 +6,15 @@
## Releasen ## Releasen
1. Token setzen: Der Token liegt in `.secrets` (gitignored) und wird automatisch geladen.
- PowerShell: `$env:GITEA_TOKEN="<token>"`
2. Release ausführen: Als KI-Agent: Token aus `.secrets` lesen und als Umgebungsvariable setzen, dann Release-Script ausführen:
- `npm run release:gitea -- <version> [notes]` ```bash
export $(cat .secrets | xargs) && npm run release:gitea -- <version> [notes]
```
Manuell in PowerShell (falls nötig):
- `npm run release:gitea -- <version> [notes]` (Token ist bereits als Benutzervariable gesetzt)
Das Script: Das Script:
- bumped `package.json` - bumped `package.json`

View File

@ -102,6 +102,7 @@ export function defaultSettings(): AppSettings {
extractCpuPriority: "high", extractCpuPriority: "high",
autoExtractWhenStopped: true, autoExtractWhenStopped: true,
disabledProviders: [], disabledProviders: [],
hosterRouting: {} hosterRouting: {},
scheduledStartEpochMs: 0
}; };
} }

View File

@ -52,6 +52,7 @@ let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null; let tray: Tray | null = null;
let clipboardTimer: ReturnType<typeof setInterval> | null = null; let clipboardTimer: ReturnType<typeof setInterval> | null = null;
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null; let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
let lastClipboardText = ""; let lastClipboardText = "";
const controller = new AppController(); const controller = new AppController();
const CLIPBOARD_MAX_TEXT_CHARS = 50_000; const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
@ -266,6 +267,26 @@ function registerIpcHandlers(): void {
const result = controller.updateSettings(validated as Partial<AppSettings>); const result = controller.updateSettings(validated as Partial<AppSettings>);
updateClipboardWatcher(); updateClipboardWatcher();
updateTray(); updateTray();
// Manage scheduled-start timer
if (scheduledStartTimer !== null) {
clearTimeout(scheduledStartTimer);
scheduledStartTimer = null;
}
const schedMs = result.scheduledStartEpochMs || 0;
if (schedMs > 0) {
const delay = schedMs - Date.now();
if (delay <= 0) {
// Time already passed — start immediately and clear setting
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
controller.updateSettings({ scheduledStartEpochMs: 0 });
} else {
scheduledStartTimer = setTimeout(() => {
scheduledStartTimer = null;
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
controller.updateSettings({ scheduledStartEpochMs: 0 });
}, delay);
}
}
return result; return result;
}); });
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {

View File

@ -1017,6 +1017,9 @@ export function App(): ReactElement {
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({}); const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
const [accountColumnWidths, setAccountColumnWidths] = useState<Record<AccountColumnKey, number>>(loadAccountColumnWidths); const [accountColumnWidths, setAccountColumnWidths] = useState<Record<AccountColumnKey, number>>(loadAccountColumnWidths);
const [settingsDirty, setSettingsDirty] = useState(false); const [settingsDirty, setSettingsDirty] = useState(false);
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
const [scheduleCountdown, setScheduleCountdown] = useState("");
const settingsDirtyRef = useRef(false); const settingsDirtyRef = useRef(false);
const settingsDraftRevisionRef = useRef(0); const settingsDraftRevisionRef = useRef(0);
const panelDirtyRevisionRef = useRef(0); const panelDirtyRevisionRef = useRef(0);
@ -1199,6 +1202,23 @@ export function App(): ReactElement {
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
}, [settingsDraft.speedLimitKbps]); }, [settingsDraft.speedLimitKbps]);
useEffect(() => {
const schedMs = snapshot.settings.scheduledStartEpochMs || 0;
if (schedMs <= 0) { setScheduleCountdown(""); return; }
const update = (): void => {
const remaining = schedMs - Date.now();
if (remaining <= 0) { setScheduleCountdown(""); return; }
const totalSec = Math.ceil(remaining / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
setScheduleCountdown(h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`);
};
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [snapshot.settings.scheduledStartEpochMs]);
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); }
@ -1564,6 +1584,10 @@ export function App(): ReactElement {
// Setzt providerOrder + backwards-kompatible Felder synchron // Setzt providerOrder + backwards-kompatible Felder synchron
const setProviderOrder = useCallback((newOrder: DebridProvider[]) => { const setProviderOrder = useCallback((newOrder: DebridProvider[]) => {
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ setSettingsDraft((prev) => ({
...prev, ...prev,
providerOrder: newOrder, providerOrder: newOrder,
@ -3187,6 +3211,44 @@ export function App(): ReactElement {
<svg viewBox="0 0 24 24" width="18" height="18"><rect x="4" y="4" width="16" height="16" rx="2" fill="currentColor" /></svg> <svg viewBox="0 0 24 24" width="18" height="18"><rect x="4" y="4" width="16" height="16" rx="2" fill="currentColor" /></svg>
</button> </button>
<div className="ctrl-separator" /> <div className="ctrl-separator" />
<div className="schedule-ctrl">
{(snapshot.settings.scheduledStartEpochMs || 0) > 0 ? (
<div className="schedule-active">
<span className="schedule-badge" title="Geplanter Start"> {scheduleCountdown || new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>
<button className="schedule-cancel" title="Geplanten Start abbrechen" onClick={() => { void window.rd.updateSettings({ scheduledStartEpochMs: 0 }).catch(() => {}); }}></button>
</div>
) : (
<button
className={`ctrl-icon-btn schedule-btn${schedulePickerOpen ? " active" : ""}`}
title="Download-Start planen"
onClick={() => { setSchedulePickerOpen((v) => !v); setScheduleTimeInput(""); }}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="9"/><polyline points="12,6 12,12 16,14"/></svg>
</button>
)}
{schedulePickerOpen && (snapshot.settings.scheduledStartEpochMs || 0) === 0 && (
<div className="schedule-picker">
<span className="schedule-picker-label">Starten um</span>
<input
type="time"
className="schedule-time-input"
value={scheduleTimeInput}
onChange={(e) => setScheduleTimeInput(e.target.value)}
/>
<button className="btn btn-sm btn-primary" onClick={() => {
if (!scheduleTimeInput) return;
const [hStr, mStr] = scheduleTimeInput.split(":");
const now = new Date();
const target = new Date(now);
target.setHours(Number(hStr), Number(mStr), 0, 0);
if (target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1);
void window.rd.updateSettings({ scheduledStartEpochMs: target.getTime() }).catch(() => {});
setSchedulePickerOpen(false);
}}>Aktivieren</button>
</div>
)}
</div>
<div className="ctrl-separator" />
<button <button
className="ctrl-icon-btn ctrl-move" className="ctrl-icon-btn ctrl-move"
title="Ausgewählte nach oben" title="Ausgewählte nach oben"
@ -3530,7 +3592,17 @@ export function App(): ReactElement {
<span className="stat-label">In Warteschlange</span> <span className="stat-label">In Warteschlange</span>
<span className="stat-value">{itemStatusCounts.queued}</span> <span className="stat-value">{itemStatusCounts.queued}</span>
</div> </div>
<div className="stat-item"> <div
className={`stat-item${itemStatusCounts.failed > 0 ? " stat-item-clickable" : ""}`}
title={itemStatusCounts.failed > 0 ? "Klicken zum Zurücksetzen aller fehlerhaften Downloads" : undefined}
onClick={() => {
if (itemStatusCounts.failed === 0) return;
const failedIds = Object.values(snapshot.session.items)
.filter((it) => it.status === "failed")
.map((it) => it.id);
void window.rd.resetItems(failedIds).catch(() => {});
}}
>
<span className="stat-label">Fehlerhaft</span> <span className="stat-label">Fehlerhaft</span>
<span className="stat-value danger">{itemStatusCounts.failed}</span> <span className="stat-value danger">{itemStatusCounts.failed}</span>
</div> </div>
@ -4566,6 +4638,14 @@ export function App(): ReactElement {
{hasUrls && !multi && ( {hasUrls && !multi && (
<> <>
<div className="ctx-menu-sep" /> <div className="ctx-menu-sep" />
<button className="ctx-menu-item" onClick={() => {
const rawText = contextEntry!.urls!.join("\n");
void window.rd.addLinks({ rawText, packageName: contextEntry!.name }).then((result) => {
if (result.addedLinks > 0) showToast(`${result.addedLinks} Link(s) zur Queue hinzugefügt`);
else showToast("Keine Links hinzugefügt");
}).catch(() => showToast("Fehler beim Hinzufügen"));
setHistoryCtxMenu(null);
}}>Erneut herunterladen</button>
<button className="ctx-menu-item" onClick={() => { <button className="ctx-menu-item" onClick={() => {
const urls = contextEntry!.urls!; const urls = contextEntry!.urls!;
const links = urls.map((u) => ({ name: u, url: u })); const links = urls.map((u) => ({ name: u, url: u }));

View File

@ -365,6 +365,67 @@ body,
background: rgba(245, 158, 11, 0.1); background: rgba(245, 158, 11, 0.1);
} }
.schedule-ctrl {
position: relative;
display: flex;
align-items: center;
}
.schedule-btn.active {
color: var(--accent);
}
.schedule-active {
display: flex;
align-items: center;
gap: 4px;
}
.schedule-badge {
font-size: 11px;
color: var(--accent);
white-space: nowrap;
padding: 2px 6px;
background: rgba(99, 179, 237, 0.12);
border-radius: 6px;
cursor: default;
}
.schedule-cancel {
background: none;
border: none;
cursor: pointer;
color: var(--muted);
font-size: 11px;
padding: 2px 4px;
border-radius: 4px;
line-height: 1;
}
.schedule-cancel:hover { color: var(--danger, #e05555); }
.schedule-picker {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 200;
display: flex;
align-items: center;
gap: 6px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
white-space: nowrap;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.schedule-picker-label {
font-size: 12px;
color: var(--muted);
}
.schedule-time-input {
background: var(--field);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 3px 6px;
font-size: 13px;
width: 90px;
}
.ctrl-separator { .ctrl-separator {
width: 1px; width: 1px;
height: 24px; height: 24px;
@ -2042,6 +2103,13 @@ td {
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.stat-item.stat-item-clickable {
cursor: pointer;
transition: border-color 0.15s;
}
.stat-item.stat-item-clickable:hover {
border-color: var(--danger, #e05555);
}
.stat-label { .stat-label {
color: var(--muted); color: var(--muted);

View File

@ -110,6 +110,7 @@ export interface AppSettings {
autoExtractWhenStopped: boolean; autoExtractWhenStopped: boolean;
disabledProviders: DebridProvider[]; disabledProviders: DebridProvider[];
hosterRouting: Record<string, DebridProvider>; hosterRouting: Record<string, DebridProvider>;
scheduledStartEpochMs: number;
} }
export interface DownloadItem { export interface DownloadItem {