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:
@@ -98,11 +98,17 @@ function App(): React.JSX.Element {
|
||||
return useDownloads.subscribe((st) => push(st.items))
|
||||
}, [])
|
||||
|
||||
// Rehydrate the persisted download queue on launch (M4) so saved/scheduled and
|
||||
// other pending items return from the previous session.
|
||||
useEffect(() => {
|
||||
useDownloads.getState().hydrate()
|
||||
}, [])
|
||||
|
||||
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
|
||||
// Session-only: the queue isn't persisted, so a schedule only fires while the app
|
||||
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
|
||||
// Lives here (not at the downloads store's module load) so importing the store in
|
||||
// tests/preview doesn't start a stray timer, and the interval is cleared cleanly
|
||||
// The queue is persisted (M4), so a scheduled item survives a quit and fires on the
|
||||
// next launch once its time has passed. A 15s tick is plenty for minute-granularity
|
||||
// times. Lives here (not at the downloads store's module load) so importing the store
|
||||
// in tests/preview doesn't start a stray timer, and the interval is cleared cleanly
|
||||
// on unmount (L1).
|
||||
useEffect(() => {
|
||||
const tick = setInterval(() => {
|
||||
|
||||
@@ -184,6 +184,8 @@ export const mockApi: Api = {
|
||||
onYtdlpAutoUpdateStatus: () => () => {},
|
||||
listErrorLog: async () => [],
|
||||
clearErrorLog: async () => [],
|
||||
listQueue: async () => [],
|
||||
saveQueue: async () => {},
|
||||
exportBackup: async () => ({
|
||||
ok: true,
|
||||
path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json'
|
||||
|
||||
@@ -124,4 +124,6 @@ export interface DownloadState {
|
||||
pump: () => void
|
||||
/** internal: apply a main-process download event */
|
||||
applyEvent: (ev: DownloadEvent) => void
|
||||
/** Load the persisted queue from main on launch (M4); no-op in preview. */
|
||||
hydrate: () => void
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { create } from 'zustand'
|
||||
import { type MediaKind } from '@shared/ipc'
|
||||
import { isPreview as PREVIEW } from '../isPreview'
|
||||
import { logError } from '../reportError'
|
||||
import { useSettings } from './settings'
|
||||
import { useHistory } from './history'
|
||||
import { emit, on } from './coordinator'
|
||||
import { RESOLVING, buildItem } from './downloadItem'
|
||||
import { fromPersisted, persistableItems } from './queuePersist'
|
||||
import { seed } from './downloadSeed'
|
||||
import {
|
||||
type ChosenFormat,
|
||||
@@ -19,6 +21,11 @@ import {
|
||||
// `import { … } from '../store/downloads'` sites keep working.
|
||||
export type { MediaKind, ChosenFormat, DownloadItem, DownloadStatus, AddOptions, AddEntry }
|
||||
|
||||
// M4: gate the persist subscription until the initial hydrate has read the saved
|
||||
// queue, so a launch-time store mutation can't overwrite queue.json with [] before
|
||||
// we've loaded it back.
|
||||
let queueHydrated = false
|
||||
|
||||
// Side-effects to run exactly once when a download completes: record it to
|
||||
// history (unless private) and mark its source MediaItem downloaded. Shared by
|
||||
// the real `applyEvent('done')` path and the preview ticker so the two can't
|
||||
@@ -154,6 +161,29 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
items: seed,
|
||||
pump,
|
||||
|
||||
hydrate: () => {
|
||||
if (PREVIEW) return
|
||||
window.api
|
||||
.listQueue()
|
||||
.then((persisted) => {
|
||||
if (persisted.length === 0) return
|
||||
// Merge ahead of anything already present (normally nothing at launch),
|
||||
// de-duping by id so a double-hydrate can't double-insert.
|
||||
set((s) => {
|
||||
const have = new Set(s.items.map((i) => i.id))
|
||||
const add = persisted.filter((p) => !have.has(p.id)).map(fromPersisted)
|
||||
return add.length ? { items: [...s.items, ...add] } : {}
|
||||
})
|
||||
pump()
|
||||
})
|
||||
.catch(logError('listQueue'))
|
||||
.finally(() => {
|
||||
// Enable persistence once the read has completed (success or failure), so
|
||||
// subsequent queue changes are saved but the pre-hydrate window can't wipe.
|
||||
queueHydrated = true
|
||||
})
|
||||
},
|
||||
|
||||
addFromUrl: (url, kind, quality, opts) => {
|
||||
set((s) => ({ items: [buildItem(url, kind, quality, opts), ...s.items] }))
|
||||
pump()
|
||||
@@ -409,6 +439,24 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
// no direct import from sources, so the two stores stay acyclic.
|
||||
on('enqueueDownloads', (entries) => useDownloads.getState().addMany(entries))
|
||||
|
||||
// M4: mirror the durable queue to main whenever it changes, so saved/scheduled and
|
||||
// other pending items survive a quit. Skipped in preview (no IPC bridge). A
|
||||
// progress-only update doesn't churn this — toPersisted drops progress/speed/eta, so
|
||||
// the persistable snapshot is unchanged by a tick and no redundant save fires. Main's
|
||||
// jsonStore coalesces the actual disk writes.
|
||||
if (!PREVIEW) {
|
||||
let lastSig = ''
|
||||
useDownloads.subscribe((state) => {
|
||||
// Don't persist until the initial hydrate has read the saved file (above).
|
||||
if (!queueHydrated) return
|
||||
const persistable = persistableItems(state.items)
|
||||
const sig = JSON.stringify(persistable)
|
||||
if (sig === lastSig) return
|
||||
lastSig = sig
|
||||
window.api.saveQueue(persistable).catch(logError('saveQueue'))
|
||||
})
|
||||
}
|
||||
|
||||
// The scheduled-download promoter tick lives in App (a useEffect), not here, so
|
||||
// importing this store in tests/preview doesn't start a stray never-cleared timer
|
||||
// on module load (L1). See SCHEDULE_TICK_MS / App.tsx.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { type PersistedQueueItem, type PersistedQueueStatus } from '@shared/ipc'
|
||||
import { type DownloadItem, type DownloadStatus } from './downloadTypes'
|
||||
|
||||
/**
|
||||
* Pure mapping between the live `DownloadItem` and the durable `PersistedQueueItem`
|
||||
* main stores across restarts (M4). Kept free of any store/zustand import so it can
|
||||
* be unit-tested in isolation (mirrors queueStats / formatters). Only stable fields
|
||||
* persist — runtime-only progress/speed/eta/sizeLabel/finishing are dropped and
|
||||
* re-derived on restore.
|
||||
*/
|
||||
|
||||
// Statuses worth surviving a quit. 'completed'/'canceled' are transient results
|
||||
// (completions already live in history) and are never persisted.
|
||||
export const PERSISTED_STATUSES: readonly DownloadStatus[] = [
|
||||
'queued',
|
||||
'downloading',
|
||||
'paused',
|
||||
'saved',
|
||||
'error'
|
||||
]
|
||||
|
||||
export function toPersisted(i: DownloadItem): PersistedQueueItem {
|
||||
return {
|
||||
id: i.id,
|
||||
url: i.url,
|
||||
title: i.title,
|
||||
channel: i.channel,
|
||||
durationLabel: i.durationLabel,
|
||||
thumbnail: i.thumbnail,
|
||||
kind: i.kind,
|
||||
quality: i.quality,
|
||||
status: i.status as PersistedQueueStatus,
|
||||
filePath: i.filePath,
|
||||
error: i.error,
|
||||
formatId: i.formatId,
|
||||
formatHasAudio: i.formatHasAudio,
|
||||
options: i.options,
|
||||
extraArgs: i.extraArgs,
|
||||
trim: i.trim,
|
||||
scheduledFor: i.scheduledFor,
|
||||
incognito: i.incognito,
|
||||
probedMeta: i.probedMeta,
|
||||
collection: i.collection,
|
||||
mediaItemId: i.mediaItemId
|
||||
}
|
||||
}
|
||||
|
||||
export function fromPersisted(p: PersistedQueueItem): DownloadItem {
|
||||
return {
|
||||
...p,
|
||||
// A download in flight when we quit lost its process; re-queue it (yt-dlp
|
||||
// continues the .part on relaunch). Progress restarts from 0.
|
||||
status: p.status === 'downloading' ? 'queued' : p.status,
|
||||
progress: 0
|
||||
}
|
||||
}
|
||||
|
||||
/** The persistable subset of the live queue, in durable form. */
|
||||
export function persistableItems(items: DownloadItem[]): PersistedQueueItem[] {
|
||||
return items
|
||||
.filter((i) => (PERSISTED_STATUSES as readonly string[]).includes(i.status))
|
||||
.map(toPersisted)
|
||||
}
|
||||
Reference in New Issue
Block a user