4d1c33a081
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>
516 lines
18 KiB
TypeScript
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)))
|
|
}
|