feat(m4): persist the download queue across restarts

The queue/scheduler lived only in renderer memory, so saved/scheduled and other
pending downloads were silently lost on quit (audit M4). Persist them:

- main: queue.ts store (proven cached-atomic createJsonStore, queue.json) +
  queue:list / queue:save IPC; QUEUE_MAX cap.
- shared: PersistedQueueItem = the durable subset of DownloadItem (runtime-only
  progress/speed/eta/sizeLabel/finishing dropped).
- renderer: mirror the persistable items on every queue change (a signature over
  the subset means progress ticks don't re-save; main's jsonStore coalesces the
  writes) and rehydrate on launch via an App useEffect. Pure mappers extracted
  to store/queuePersist.ts and unit-tested.

Restore semantics: downloading -> queued (yt-dlp continues the .part), saved +
scheduledFor survives so the promoter fires when due, paused/error return
actionable; completed/canceled are never persisted. A queueHydrated gate stops a
launch-time store change from wiping queue.json before it's read.

Reconciles ROADMAP.md. typecheck + 268 tests + eslint + prettier green.
(OS-level quit/relaunch cycle is worth a manual smoke test.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 09:34:55 -04:00
parent 63687aaec3
commit 0a681f864f
13 changed files with 320 additions and 10 deletions
+46
View File
@@ -0,0 +1,46 @@
import { createJsonStore } from './jsonStore'
import { QUEUE_MAX } from './constants'
import type { PersistedQueueItem, PersistedQueueStatus } from '@shared/ipc'
/**
* Persistence for the download queue (M4). The queue lived only in renderer memory,
* so `saved` / scheduled and other pending downloads were silently lost on quit.
* The renderer now mirrors its durable items here (via `queueSave`) and rehydrates
* them on launch (via `queueList`), so a parked/scheduled download returns.
*
* Reuses the shared cached-atomic `createJsonStore` (R1/R2/R3), so writes are atomic
* and coalesced — the renderer can call `queueSave` freely on queue changes.
*/
const PERSISTED_STATUSES: readonly PersistedQueueStatus[] = [
'queued',
'downloading',
'paused',
'saved',
'error'
]
/** Defensive row validator (a hand-edited or partially-written file can't crash a read). */
function isValidQueueItem(o: unknown): o is PersistedQueueItem {
if (typeof o !== 'object' || o === null) return false
const it = o as Record<string, unknown>
return (
typeof it.id === 'string' &&
typeof it.url === 'string' &&
typeof it.title === 'string' &&
(it.kind === 'video' || it.kind === 'audio') &&
typeof it.quality === 'string' &&
typeof it.status === 'string' &&
(PERSISTED_STATUSES as readonly string[]).includes(it.status)
)
}
const store = createJsonStore('queue.json', isValidQueueItem, QUEUE_MAX)
export function listQueue(): PersistedQueueItem[] {
return store.read()
}
export function saveQueue(items: PersistedQueueItem[]): void {
store.write(items)
}