Files
AeroFetch/src/renderer/src/store/downloads.ts
T
debont80 4d1c33a081 feat(audit): Batch 15 — download-engine lifecycle (safe part; rest live-verify)
This is the batch the run reserves for a WATCHED LIVE SESSION, so only the safe,
non-file-deleting item is implemented here; the rest are staged with per-item
live-verify rationale in CODE-AUDIT.md.

Landed:
- L157: removeSource cancels its in-flight downloads. removeSource emits a
  `sourceRemoved` event on the coordinator bus (C2); the downloads store cancels
  every active (downloading/queued) item whose mediaItemId starts with
  `${sourceId}:` — MediaItem ids are `${sourceId}:${videoId}` (indexerCore), so the
  prefix reliably identifies the source's downloads. Reuses the proven cancel()
  path (process kill + pump). Type/lint/test-green; process-kill worth a live confirm.

Resolved by prior work:
- UX11: M4 persists the queue, so a scheduled item fires on next launch once due —
  no longer "never fires if quit".
- UX20: L68 resets notifiedBackground on window show, so every close-to-tray
  re-fires the "still running in the tray" notice, not just the first.

Deferred to the watched live pass (each needs a real download to verify):
R4 (delete .part on cancel — file-deletion data-safety), L65 (graceful Ctrl-C
pause), L139 (spawn↔auto-update interlock — timing race), B2 (index AbortSignal +
Cancel), B6 (app-update cancel — ties to CL4's deferred updater), L143 (in-window
quit-anyway).

Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:41:48 -04:00

516 lines
18 KiB
TypeScript

import { create } from 'zustand'
import { type MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError'
import { revealFile } from '../reveal'
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,
type DownloadItem,
type DownloadStatus,
type AddOptions,
type AddEntry,
type DownloadState
} from './downloadTypes'
// Re-export the store's public types from their canonical home so existing
// `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
// drift (M3).
function recordCompletion(item: DownloadItem): void {
if (!item.incognito) {
useHistory.getState().add({
id: item.id,
title: item.title,
channel: item.channel,
url: item.url,
kind: item.kind,
quality: item.quality,
formatId: item.formatId,
formatHasAudio: item.formatHasAudio,
filePath: item.filePath,
sizeLabel: item.sizeLabel,
thumbnail: item.thumbnail,
// Carry the post-processing options so a re-download reproduces the same
// output rather than the current global defaults (L87).
options: item.options,
completedAt: Date.now()
})
}
if (item.mediaItemId) {
// Tell the sources store to mark its MediaItem downloaded — via the event bus
// rather than a direct import, so downloads and sources don't form a cycle (C2).
emit('downloadCompleted', { mediaItemId: item.mediaItemId, filePath: item.filePath })
}
}
// UI-preview only: animate fake progress so the queue feels alive without yt-dlp.
function startFakeTicker(
id: string,
get: () => DownloadState,
set: (fn: (s: DownloadState) => Partial<DownloadState>) => void
): void {
const timer = setInterval(() => {
const cur = get().items.find((i) => i.id === id)
if (!cur || cur.status !== 'downloading') {
clearInterval(timer)
return
}
const next = Math.min(1, cur.progress + 0.06 + Math.random() * 0.04)
const done = next >= 1
set((s) => ({
items: s.items.map((i) =>
i.id === id
? {
...i,
progress: next,
channel: 'Sample Channel',
durationLabel: '12:34',
sizeLabel: '96.4 MB',
speedBytesPerSec: done ? undefined : (2 + Math.random() * 5) * 1024 * 1024,
etaSeconds: done ? undefined : (1 - next) * 90,
status: done ? 'completed' : 'downloading',
filePath: done ? `C:\\Users\\you\\Downloads\\${cur.title}.mp4` : undefined
}
: i
)
}))
if (done) {
clearInterval(timer)
const finished = get().items.find((i) => i.id === id)
if (finished) recordCompletion(finished)
get().pump() // a slot just freed -- promote the next queued item
}
}, 650)
}
export const useDownloads = create<DownloadState>((set, get) => {
function markError(id: string, error?: string): void {
set((s) => ({
items: s.items.map((i) => (i.id === id ? { ...i, status: 'error', error } : i))
}))
pump() // a slot freed
}
// Actually start an item that pump() has just moved to 'downloading'.
function launchItem(item: DownloadItem): void {
if (PREVIEW) {
startFakeTicker(item.id, get, set)
return
}
window.api
.startDownload({
id: item.id,
url: item.url,
kind: item.kind,
quality: item.quality,
// No outputDir here: main routes each download into the user's per-kind
// folder (Settings → Video/Audio folder), or the Documents\… default.
formatId: item.formatId,
formatHasAudio: item.formatHasAudio,
options: item.options,
extraArgs: item.extraArgs,
trim: item.trim,
// Carry the private flag to main so it also skips errorlog, notification
// detail, and cookies — not just the renderer's history skip (L136).
incognito: item.incognito,
meta: item.probedMeta,
collection: item.collection
})
.then((res) => {
if (!res.ok) markError(item.id, res.error)
})
.catch((e: unknown) => markError(item.id, String(e)))
}
// Promote queued items (oldest first) into free slots, then launch them.
//
// Note: this only gates *future* promotions. Lowering maxConcurrent while N
// downloads are already active does NOT pause the overflow -- the running
// processes finish on their own, and the lower cap takes effect only as slots
// free up. (audit P3 -- documented behaviour, not a bug.)
function pump(): void {
const items = get().items
const running = items.filter((i) => i.status === 'downloading').length
const slots = useSettings.getState().maxConcurrent - running
if (slots <= 0) return
// items are newest-first, so reverse the queued list to get oldest-first.
const toStart = items
.filter((i) => i.status === 'queued')
.reverse()
.slice(0, slots)
if (toStart.length === 0) return
const ids = new Set(toStart.map((i) => i.id))
set((s) => ({
items: s.items.map((i) => (ids.has(i.id) ? { ...i, status: 'downloading' } : i))
}))
for (const item of toStart) launchItem({ ...item, status: 'downloading' })
}
return {
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()
},
addMany: (entries) => {
if (entries.length === 0) return
// The queue is a newest-first array and pump() promotes the highest-index
// (oldest) queued item first, so a batch must be stored last-entry-first for
// the playlist/channel to download 1→N instead of N→1 (M32). Entry 1 then
// sits at the bottom of the list -- the same way repeated single adds stack.
const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts)).reverse()
set((s) => ({ items: [...built, ...s.items] }))
pump()
},
retry: (id) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id
? {
...i,
status: 'queued',
progress: 0,
error: undefined,
speedBytesPerSec: undefined,
etaSeconds: undefined
}
: i
)
}))
pump()
},
retryAll: () => {
set((s) => ({
items: s.items.map((i) =>
i.status === 'error'
? {
...i,
status: 'queued',
progress: 0,
error: undefined,
speedBytesPerSec: undefined,
etaSeconds: undefined
}
: i
)
}))
pump()
},
pause: (id) => {
const cur = get().items.find((i) => i.id === id)
if (!cur) return
// Only a running download has a live process to stop; a queued item is
// just parked in place. Either way it becomes 'paused' (pump ignores it).
if (!PREVIEW && cur.status === 'downloading') window.api.pauseDownload(id).catch(() => {})
set((s) => ({
items: s.items.map((i) =>
i.id === id && (i.status === 'downloading' || i.status === 'queued')
? { ...i, status: 'paused', speedBytesPerSec: undefined, etaSeconds: undefined }
: i
)
}))
pump() // pausing a running item frees a slot
},
resume: (id) => {
// Re-queue WITHOUT resetting progress (unlike retry): pump() promotes it and
// launchItem re-spawns yt-dlp, which continues the partial file by default.
set((s) => ({
items: s.items.map((i) =>
i.id === id && i.status === 'paused' ? { ...i, status: 'queued' } : i
)
}))
pump()
},
prioritize: (id) => {
// Reposition the item so pump() -- which promotes the highest-index queued
// item first (oldest-first over a newest-first array) -- picks it next.
set((s) => {
const idx = s.items.findIndex((i) => i.id === id)
if (idx < 0 || s.items[idx]?.status !== 'queued') return {}
const items = s.items.slice()
const [it] = items.splice(idx, 1)
if (!it) return {}
let after = -1
for (let k = 0; k < items.length; k++) if (items[k]?.status === 'queued') after = k
items.splice(after + 1, 0, it)
return { items }
})
pump()
},
saveForLater: (id) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id && i.status === 'queued'
? { ...i, status: 'saved', scheduledFor: undefined }
: i
)
}))
},
queueNow: (id) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id && i.status === 'saved'
? { ...i, status: 'queued', scheduledFor: undefined }
: i
)
}))
pump()
},
promoteDueScheduled: () => {
// Wall-clock comparison: each scheduled item fires once (the promotion flips it
// out of 'saved'), so a backward clock jump only delays it and can't double-fire;
// a forward jump can fire it early. Accepted as minor per R9 — a monotonic
// scheduler would be overkill for a park-until-time feature.
const now = Date.now()
set((s) => ({
items: s.items.map((i) =>
i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
? { ...i, status: 'queued', scheduledFor: undefined }
: i
)
}))
pump()
},
cancel: (id) => {
const cur = get().items.find((i) => i.id === id)
if (!cur) return
// Only running items have a live process to kill; queued ones never started.
if (!PREVIEW && cur.status === 'downloading') window.api.cancelDownload(id).catch(() => {})
set((s) => ({
items: s.items.map((i) =>
i.id === id && (i.status === 'downloading' || i.status === 'queued')
? { ...i, status: 'canceled', speedBytesPerSec: undefined, etaSeconds: undefined }
: i
)
}))
pump()
},
cancelAll: () => {
const active = get().items.filter((i) => i.status === 'downloading' || i.status === 'queued')
if (active.length === 0) return
// Kill the live processes for the running ones; queued items never started.
if (!PREVIEW) {
for (const i of active) {
if (i.status === 'downloading') window.api.cancelDownload(i.id).catch(() => {})
}
}
set((s) => ({
items: s.items.map((i) =>
i.status === 'downloading' || i.status === 'queued'
? { ...i, status: 'canceled', speedBytesPerSec: undefined, etaSeconds: undefined }
: i
)
}))
pump()
},
remove: (id) => {
set((s) => ({ items: s.items.filter((i) => i.id !== id) }))
pump()
},
clearFinished: () =>
set((s) => ({
items: s.items.filter(
(i) =>
i.status === 'downloading' ||
i.status === 'queued' ||
i.status === 'paused' ||
i.status === 'saved'
)
})),
openFile: (id) => {
const item = get().items.find((i) => i.id === id)
if (!PREVIEW && item?.filePath) revealFile('open', item.filePath)
},
showInFolder: (id) => {
const item = get().items.find((i) => i.id === id)
if (!PREVIEW && item?.filePath) revealFile('folder', item.filePath)
},
applyEvent: (ev) => {
set((s) => ({
items: s.items.map((i) => {
if (i.id !== ev.id) return i
switch (ev.type) {
case 'meta':
// A late meta event can arrive between cancel() and the child's
// close -- don't overwrite a canceled item's fields (B5; mirrors the
// canceled guard on the done/error cases below).
if (i.status === 'canceled') return i
return {
...i,
title: ev.meta.title ?? i.title,
// Clear the 'Resolving…' placeholder even when the probe returns
// no channel, so it can't stick forever (SR6); a real channel is
// always preserved.
channel: ev.meta.channel ?? (i.channel === RESOLVING ? undefined : i.channel),
durationLabel: ev.meta.durationLabel ?? i.durationLabel
}
case 'progress':
// Only a launched (downloading) item should take progress. Never
// promote a 'queued' item here -- pump() owns the queued→downloading
// transition and the concurrency cap (L88); a stray progress event
// for a non-downloading item is ignored.
if (i.status !== 'downloading') return i
return {
...i,
status: 'downloading',
progress: ev.progress.progress,
// Main owns the latch and resends it on every tick, so reflecting
// it directly resets cleanly on a retry's fresh spawn (SR7).
finishing: ev.progress.finishing,
speedBytesPerSec: ev.progress.speedBytesPerSec,
etaSeconds: ev.progress.etaSeconds,
sizeUnknown: ev.progress.sizeUnknown,
sizeLabel: ev.progress.sizeLabel ?? i.sizeLabel
}
case 'done':
if (i.status === 'canceled') return i
return {
...i,
status: 'completed',
progress: 1,
finishing: false,
speedBytesPerSec: undefined,
etaSeconds: undefined,
channel: i.channel === RESOLVING ? undefined : i.channel,
filePath: ev.filePath ?? i.filePath
}
case 'error':
if (i.status === 'canceled') return i
return {
...i,
status: 'error',
finishing: false,
speedBytesPerSec: undefined,
etaSeconds: undefined,
channel: i.channel === RESOLVING ? undefined : i.channel,
error: ev.error
}
default:
return i
}
})
}))
// Record completion (history + source markDownloaded) via the shared helper.
if (ev.type === 'done') {
const item = get().items.find((i) => i.id === ev.id)
if (item && item.status === 'completed') recordCompletion(item)
}
// A finished item frees a slot -- promote whatever is queued next.
if (ev.type === 'done' || ev.type === 'error') pump()
}
}
})
// Consume the sources store's enqueue requests (C2): when the user downloads a
// channel's media items, sources emits the batch on the bus and we add it here —
// no direct import from sources, so the two stores stay acyclic.
on('enqueueDownloads', (entries) => useDownloads.getState().addMany(entries))
// A removed source cancels its in-flight downloads (L157): MediaItem ids are
// `${sourceId}:${videoId}` (indexerCore), so a download's mediaItemId prefix
// identifies its source. Only active (downloading/queued) items are canceled;
// finished/failed ones are left alone. cancel() owns the process kill + pump.
on('sourceRemoved', (sourceId) => {
const st = useDownloads.getState()
const prefix = `${sourceId}:`
for (const it of st.items) {
if (
(it.status === 'downloading' || it.status === 'queued') &&
it.mediaItemId?.startsWith(prefix)
) {
st.cancel(it.id)
}
}
})
// 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.
// Wire main → renderer push events. (Output folder + cap live in the settings store.)
if (!PREVIEW) {
window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev))
} else {
// Browser preview: animate any seed item that starts out 'downloading' so the
// queue is live (and its completion promotes the queued seed item -- a real
// demo of the concurrency cap).
useDownloads
.getState()
.items.filter((i) => i.status === 'downloading')
.forEach((i) => startFakeTicker(i.id, useDownloads.getState, (fn) => useDownloads.setState(fn)))
}