feat: re-download from history, reset-all-failed, scheduled start, fix provider order dirty flag
This commit is contained in:
parent
6d6453dc4b
commit
2e6074337a
1
.gitignore
vendored
1
.gitignore
vendored
@ -37,3 +37,4 @@ deploy/forgejo/caddy/data/
|
||||
deploy/forgejo/caddy/config/
|
||||
deploy/forgejo/caddy/logs/
|
||||
deploy/forgejo/backups/
|
||||
.secrets
|
||||
|
||||
13
CLAUDE.md
13
CLAUDE.md
@ -6,10 +6,15 @@
|
||||
|
||||
## Releasen
|
||||
|
||||
1. Token setzen:
|
||||
- PowerShell: `$env:GITEA_TOKEN="<token>"`
|
||||
2. Release ausführen:
|
||||
- `npm run release:gitea -- <version> [notes]`
|
||||
Der Token liegt in `.secrets` (gitignored) und wird automatisch geladen.
|
||||
|
||||
Als KI-Agent: Token aus `.secrets` lesen und als Umgebungsvariable setzen, dann Release-Script ausführen:
|
||||
```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:
|
||||
- bumped `package.json`
|
||||
|
||||
@ -102,6 +102,7 @@ export function defaultSettings(): AppSettings {
|
||||
extractCpuPriority: "high",
|
||||
autoExtractWhenStopped: true,
|
||||
disabledProviders: [],
|
||||
hosterRouting: {}
|
||||
hosterRouting: {},
|
||||
scheduledStartEpochMs: 0
|
||||
};
|
||||
}
|
||||
|
||||
@ -52,6 +52,7 @@ let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastClipboardText = "";
|
||||
const controller = new AppController();
|
||||
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
|
||||
@ -266,6 +267,26 @@ function registerIpcHandlers(): void {
|
||||
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
||||
updateClipboardWatcher();
|
||||
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;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {
|
||||
|
||||
@ -1017,6 +1017,9 @@ export function App(): ReactElement {
|
||||
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
|
||||
const [accountColumnWidths, setAccountColumnWidths] = useState<Record<AccountColumnKey, number>>(loadAccountColumnWidths);
|
||||
const [settingsDirty, setSettingsDirty] = useState(false);
|
||||
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
||||
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
|
||||
const [scheduleCountdown, setScheduleCountdown] = useState("");
|
||||
const settingsDirtyRef = useRef(false);
|
||||
const settingsDraftRevisionRef = useRef(0);
|
||||
const panelDirtyRevisionRef = useRef(0);
|
||||
@ -1199,6 +1202,23 @@ export function App(): ReactElement {
|
||||
setSpeedLimitInput(formatMbpsInputFromKbps(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 => {
|
||||
setStatusToast(message);
|
||||
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
||||
@ -1564,6 +1584,10 @@ export function App(): ReactElement {
|
||||
|
||||
// Setzt providerOrder + backwards-kompatible Felder synchron
|
||||
const setProviderOrder = useCallback((newOrder: DebridProvider[]) => {
|
||||
settingsDraftRevisionRef.current += 1;
|
||||
panelDirtyRevisionRef.current += 1;
|
||||
settingsDirtyRef.current = true;
|
||||
setSettingsDirty(true);
|
||||
setSettingsDraft((prev) => ({
|
||||
...prev,
|
||||
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>
|
||||
</button>
|
||||
<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
|
||||
className="ctrl-icon-btn ctrl-move"
|
||||
title="Ausgewählte nach oben"
|
||||
@ -3530,7 +3592,17 @@ export function App(): ReactElement {
|
||||
<span className="stat-label">In Warteschlange</span>
|
||||
<span className="stat-value">{itemStatusCounts.queued}</span>
|
||||
</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-value danger">{itemStatusCounts.failed}</span>
|
||||
</div>
|
||||
@ -4566,6 +4638,14 @@ export function App(): ReactElement {
|
||||
{hasUrls && !multi && (
|
||||
<>
|
||||
<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={() => {
|
||||
const urls = contextEntry!.urls!;
|
||||
const links = urls.map((u) => ({ name: u, url: u }));
|
||||
|
||||
@ -365,6 +365,67 @@ body,
|
||||
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 {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
@ -2042,6 +2103,13 @@ td {
|
||||
border-radius: 10px;
|
||||
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 {
|
||||
color: var(--muted);
|
||||
|
||||
@ -110,6 +110,7 @@ export interface AppSettings {
|
||||
autoExtractWhenStopped: boolean;
|
||||
disabledProviders: DebridProvider[];
|
||||
hosterRouting: Record<string, DebridProvider>;
|
||||
scheduledStartEpochMs: number;
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user