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>
This commit is contained in:
2026-07-01 17:41:48 -04:00
parent 0f784847a3
commit 4d1c33a081
4 changed files with 95 additions and 5 deletions
+3
View File
@@ -13,12 +13,15 @@ import type { AddEntry } from './downloads'
*
* sources --enqueueDownloads--> downloads (queue a channel's media items)
* downloads --downloadCompleted--> sources (mark the source item downloaded)
* sources --sourceRemoved------> downloads (cancel that source's in-flight downloads)
*/
export interface StoreEvents {
/** sources → downloads: enqueue a batch of media items as downloads */
enqueueDownloads: AddEntry[]
/** downloads → sources: a download tied to a source MediaItem finished */
downloadCompleted: { mediaItemId: string; filePath?: string }
/** sources → downloads: a source was removed; cancel its in-flight downloads (L157) */
sourceRemoved: string
}
type Handler<K extends keyof StoreEvents> = (payload: StoreEvents[K]) => void
+17
View File
@@ -462,6 +462,23 @@ 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))
// 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
+4
View File
@@ -178,6 +178,10 @@ export const useSources = create<SourcesState>((set, get) => ({
sources: s.sources.filter((x) => x.id !== id),
selectedSourceId: s.selectedSourceId === id ? null : s.selectedSourceId
}))
// Cancel any of this source's downloads still in flight, so removing a source
// stops its videos from finishing into now-orphaned folders (L157). Routed via
// the event bus (C2) so sources doesn't import downloads.
emit('sourceRemoved', id)
if (!PREVIEW) window.api.removeSource(id).catch(logError('removeSource'))
},