H1: split store/downloads.ts into types + item builder + preview seed

Extract from the 614-line store:
- downloadTypes.ts: all public types (DownloadItem, AddOptions, AddEntry,
  ChosenFormat, DownloadStatus, DownloadState). Re-exported from downloads.ts
  so every `import { ... } from '../store/downloads'` site is unchanged.
- downloadItem.ts: buildItem/titleFromUrl + the RESOLVING placeholder.
- downloadSeed.ts: the preview-only mock seed array.

downloads.ts now holds only the store, recordCompletion, and the preview
ticker. Behaviour unchanged; 248 tests + typecheck green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:21:33 -04:00
parent 4492eee054
commit ac99fd86a5
4 changed files with 264 additions and 238 deletions
+64
View File
@@ -0,0 +1,64 @@
import { type DownloadMeta, type MediaKind } from '@shared/ipc'
import { newId } from '../id'
import { youtubeId } from '../lib/urlHelpers'
import { type AddOptions, type DownloadItem } from './downloadTypes'
// Placeholder channel shown while metadata is still being fetched. Tracked as a
// constant so the meta/error/done handlers can recognise and clear it instead of
// letting it stick forever when a probe returns no channel (SR6).
export const RESOLVING = 'Resolving…'
/**
* Build a fresh queued DownloadItem from a URL + options. Pure (no store access)
* so addFromUrl and addMany share it. If the caller already probed the URL, its
* metadata is carried as probedMeta so main can skip a redundant probe (audit P2).
*/
export function buildItem(
url: string,
kind: MediaKind,
quality: string,
opts?: AddOptions
): DownloadItem {
const probedMeta: DownloadMeta | undefined =
opts?.title || opts?.channel || opts?.durationLabel
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
: undefined
// A future scheduled time parks the item as 'saved' until its moment arrives;
// a past/absent time starts it queued as usual.
const scheduledFor =
opts?.scheduledFor && opts.scheduledFor > Date.now() ? opts.scheduledFor : undefined
return {
id: newId('item'),
url,
title: opts?.title ?? titleFromUrl(url),
channel: opts?.channel ?? RESOLVING,
durationLabel: opts?.durationLabel,
thumbnail: opts?.thumbnail,
kind,
quality,
status: scheduledFor ? 'saved' : 'queued',
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio,
options: opts?.options,
extraArgs: opts?.extraArgs,
trim: opts?.trim,
scheduledFor,
incognito: opts?.incognito,
collection: opts?.collection,
mediaItemId: opts?.mediaItemId,
probedMeta
}
}
export function titleFromUrl(url: string): string {
const id = youtubeId(url)
if (id) return `YouTube video (${id})`
try {
const host = new URL(url).hostname
if (host) return `${host} download`
} catch {
/* not a URL */
}
return 'New download'
}
+59
View File
@@ -0,0 +1,59 @@
import { isPreview as PREVIEW } from '../isPreview'
import { newId } from '../id'
import { type DownloadItem } from './downloadTypes'
// Mock seed data so every visual state is visible in the UI preview. Empty in a
// real run -- the queue starts blank.
export const seed: DownloadItem[] = PREVIEW
? [
{
id: newId('item'),
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
title: 'Building a Desktop App with Electron -- Full Course',
channel: 'DevChannel',
durationLabel: '1:42:08',
kind: 'video',
quality: '1080p',
status: 'downloading',
progress: 0.42,
speedBytesPerSec: 4.7 * 1024 * 1024,
etaSeconds: 72,
sizeLabel: '512 MB'
},
{
id: newId('item'),
url: 'https://youtube.com/watch?v=abcd1234',
title: 'Lo-fi beats to code to (1 hour mix)',
channel: 'ChillStudio',
durationLabel: '1:00:21',
kind: 'audio',
quality: 'Best',
status: 'queued',
progress: 0
},
{
id: newId('item'),
url: 'https://youtube.com/watch?v=zzz9999',
title: 'How yt-dlp Works Under the Hood',
channel: 'Open Source Weekly',
durationLabel: '14:03',
kind: 'video',
quality: '720p',
status: 'completed',
progress: 1,
sizeLabel: '184 MB',
filePath: 'C:\\Users\\you\\Downloads\\How yt-dlp Works Under the Hood.mp4'
},
{
id: newId('item'),
url: 'https://youtube.com/watch?v=err0r',
title: 'Private video',
channel: undefined,
kind: 'video',
quality: 'Best available',
status: 'error',
progress: 0,
error: 'Video unavailable: this video is private.'
}
]
: []
+127
View File
@@ -0,0 +1,127 @@
import {
type DownloadEvent,
type DownloadMeta,
type DownloadOptions,
type CollectionContext,
type MediaKind
} from '@shared/ipc'
// Single-sourced from the IPC contract (L105) and re-exported so existing
// `import { MediaKind } from '../store/downloads'` sites keep working.
export type { MediaKind }
export type DownloadStatus =
'queued' | 'downloading' | 'paused' | 'saved' | 'completed' | 'error' | 'canceled'
/** An exact probed format chosen for a download (omitted for the preset path). */
export interface ChosenFormat {
id: string
hasAudio: boolean
label: string
}
export interface DownloadItem {
id: string
url: string
title: string
channel?: string
durationLabel?: string
thumbnail?: string
kind: MediaKind
quality: string
status: DownloadStatus
/** 0..1 */
progress: number
/** raw transfer rate in bytes/sec (formatted for display via @shared/format) */
speedBytesPerSec?: number
/** raw time remaining in seconds (formatted for display via @shared/format) */
etaSeconds?: number
sizeLabel?: string
/** yt-dlp reported no total size -- render the bar indeterminate, not 0% (L137) */
sizeUnknown?: boolean
/** true once the main download stream finished and yt-dlp is fetching the
* remaining stream / merging / post-processing -- the bar goes indeterminate
* instead of visibly restarting 0→100% for the second stream (SR7) */
finishing?: boolean
filePath?: string
error?: string
/** exact format carried so retry re-downloads the same thing */
formatId?: string
formatHasAudio?: boolean
/** per-download post-processing override (omitted = use persisted defaults) */
options?: DownloadOptions
/** per-download custom-command override (omitted = use the persisted default template) */
extraArgs?: string
/** raw trim spec -- time ranges to keep (parsed to --download-sections in main) */
trim?: string
/** epoch ms to auto-start a 'saved' item; undefined = parked indefinitely (Phase M) */
scheduledFor?: number
/** private download -- completion is never recorded to history */
incognito?: boolean
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
probedMeta?: DownloadMeta
/** media-manager folder context -- files this into <channel>/<playlist>/<NNN> - <title> */
collection?: CollectionContext
/** when set, the source MediaItem id this download came from -- marked downloaded on completion */
mediaItemId?: string
}
/** Options accepted when enqueuing a URL (shared by addFromUrl + addMany). */
export interface AddOptions {
format?: ChosenFormat
title?: string
channel?: string
durationLabel?: string
thumbnail?: string
options?: DownloadOptions
extraArgs?: string
trim?: string
/** when set and in the future, the item starts 'saved' and auto-queues at this time */
scheduledFor?: number
incognito?: boolean
collection?: CollectionContext
mediaItemId?: string
}
/** One URL to enqueue in a single addMany batch. */
export interface AddEntry {
url: string
kind: MediaKind
quality: string
opts?: AddOptions
}
export interface DownloadState {
items: DownloadItem[]
addFromUrl: (url: string, kind: MediaKind, quality: string, opts?: AddOptions) => void
/**
* Enqueue many URLs at once with a single state update and a single pump, so
* queuing an entire channel (1000s of items) doesn't churn the store O(n²) the
* way looping addFromUrl would.
*/
addMany: (entries: AddEntry[]) => void
retry: (id: string) => void
/** re-queue every failed item at once (Phase M) */
retryAll: () => void
/** stop a running download but keep its partial file for a later resume (Phase M) */
pause: (id: string) => void
/** re-queue a paused download; yt-dlp continues its partial file on relaunch */
resume: (id: string) => void
/** move a queued item to the front of the promotion order ("download next", Phase M) */
prioritize: (id: string) => void
/** park a queued item indefinitely as 'saved' (Phase M) */
saveForLater: (id: string) => void
/** un-park a 'saved' item back into the queue now (clears any schedule) */
queueNow: (id: string) => void
/** internal: promote any 'saved' item whose scheduledFor time has arrived */
promoteDueScheduled: () => void
cancel: (id: string) => void
remove: (id: string) => void
clearFinished: () => void
openFile: (id: string) => void
showInFolder: (id: string) => void
/** promote queued items into free download slots (respects MAX_CONCURRENT) */
pump: () => void
/** internal: apply a main-process download event */
applyEvent: (ev: DownloadEvent) => void
}
+14 -238
View File
@@ -1,247 +1,23 @@
import { create } from 'zustand'
import {
type DownloadEvent,
type DownloadMeta,
type DownloadOptions,
type CollectionContext,
type MediaKind
} from '@shared/ipc'
import { type MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { useSettings } from './settings'
import { useHistory } from './history'
import { emit, on } from './coordinator'
import { newId } from '../id'
import { youtubeId } from '../lib/urlHelpers'
import { RESOLVING, buildItem } from './downloadItem'
import { seed } from './downloadSeed'
import {
type ChosenFormat,
type DownloadItem,
type DownloadStatus,
type AddOptions,
type AddEntry,
type DownloadState
} from './downloadTypes'
// Single-sourced from the IPC contract (L105) and re-exported so existing
// `import { MediaKind } from '../store/downloads'` sites keep working.
export type { MediaKind }
export type DownloadStatus =
'queued' | 'downloading' | 'paused' | 'saved' | 'completed' | 'error' | 'canceled'
/** An exact probed format chosen for a download (omitted for the preset path). */
export interface ChosenFormat {
id: string
hasAudio: boolean
label: string
}
export interface DownloadItem {
id: string
url: string
title: string
channel?: string
durationLabel?: string
thumbnail?: string
kind: MediaKind
quality: string
status: DownloadStatus
/** 0..1 */
progress: number
/** raw transfer rate in bytes/sec (formatted for display via @shared/format) */
speedBytesPerSec?: number
/** raw time remaining in seconds (formatted for display via @shared/format) */
etaSeconds?: number
sizeLabel?: string
/** yt-dlp reported no total size -- render the bar indeterminate, not 0% (L137) */
sizeUnknown?: boolean
/** true once the main download stream finished and yt-dlp is fetching the
* remaining stream / merging / post-processing -- the bar goes indeterminate
* instead of visibly restarting 0→100% for the second stream (SR7) */
finishing?: boolean
filePath?: string
error?: string
/** exact format carried so retry re-downloads the same thing */
formatId?: string
formatHasAudio?: boolean
/** per-download post-processing override (omitted = use persisted defaults) */
options?: DownloadOptions
/** per-download custom-command override (omitted = use the persisted default template) */
extraArgs?: string
/** raw trim spec -- time ranges to keep (parsed to --download-sections in main) */
trim?: string
/** epoch ms to auto-start a 'saved' item; undefined = parked indefinitely (Phase M) */
scheduledFor?: number
/** private download -- completion is never recorded to history */
incognito?: boolean
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
probedMeta?: DownloadMeta
/** media-manager folder context -- files this into <channel>/<playlist>/<NNN> - <title> */
collection?: CollectionContext
/** when set, the source MediaItem id this download came from -- marked downloaded on completion */
mediaItemId?: string
}
// Placeholder channel shown while metadata is still being fetched. Tracked as a
// constant so the meta/error/done handlers can recognise and clear it instead of
// letting it stick forever when a probe returns no channel (SR6).
const RESOLVING = 'Resolving…'
/** Options accepted when enqueuing a URL (shared by addFromUrl + addMany). */
export interface AddOptions {
format?: ChosenFormat
title?: string
channel?: string
durationLabel?: string
thumbnail?: string
options?: DownloadOptions
extraArgs?: string
trim?: string
/** when set and in the future, the item starts 'saved' and auto-queues at this time */
scheduledFor?: number
incognito?: boolean
collection?: CollectionContext
mediaItemId?: string
}
/** One URL to enqueue in a single addMany batch. */
export interface AddEntry {
url: string
kind: MediaKind
quality: string
opts?: AddOptions
}
interface DownloadState {
items: DownloadItem[]
addFromUrl: (url: string, kind: MediaKind, quality: string, opts?: AddOptions) => void
/**
* Enqueue many URLs at once with a single state update and a single pump, so
* queuing an entire channel (1000s of items) doesn't churn the store O(n²) the
* way looping addFromUrl would.
*/
addMany: (entries: AddEntry[]) => void
retry: (id: string) => void
/** re-queue every failed item at once (Phase M) */
retryAll: () => void
/** stop a running download but keep its partial file for a later resume (Phase M) */
pause: (id: string) => void
/** re-queue a paused download; yt-dlp continues its partial file on relaunch */
resume: (id: string) => void
/** move a queued item to the front of the promotion order ("download next", Phase M) */
prioritize: (id: string) => void
/** park a queued item indefinitely as 'saved' (Phase M) */
saveForLater: (id: string) => void
/** un-park a 'saved' item back into the queue now (clears any schedule) */
queueNow: (id: string) => void
/** internal: promote any 'saved' item whose scheduledFor time has arrived */
promoteDueScheduled: () => void
cancel: (id: string) => void
remove: (id: string) => void
clearFinished: () => void
openFile: (id: string) => void
showInFolder: (id: string) => void
/** promote queued items into free download slots (respects MAX_CONCURRENT) */
pump: () => void
/** internal: apply a main-process download event */
applyEvent: (ev: DownloadEvent) => void
}
/**
* Build a fresh queued DownloadItem from a URL + options. Pure (no store access)
* so addFromUrl and addMany share it. If the caller already probed the URL, its
* metadata is carried as probedMeta so main can skip a redundant probe (audit P2).
*/
function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOptions): DownloadItem {
const probedMeta: DownloadMeta | undefined =
opts?.title || opts?.channel || opts?.durationLabel
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
: undefined
// A future scheduled time parks the item as 'saved' until its moment arrives;
// a past/absent time starts it queued as usual.
const scheduledFor =
opts?.scheduledFor && opts.scheduledFor > Date.now() ? opts.scheduledFor : undefined
return {
id: newId('item'),
url,
title: opts?.title ?? titleFromUrl(url),
channel: opts?.channel ?? RESOLVING,
durationLabel: opts?.durationLabel,
thumbnail: opts?.thumbnail,
kind,
quality,
status: scheduledFor ? 'saved' : 'queued',
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio,
options: opts?.options,
extraArgs: opts?.extraArgs,
trim: opts?.trim,
scheduledFor,
incognito: opts?.incognito,
collection: opts?.collection,
mediaItemId: opts?.mediaItemId,
probedMeta
}
}
function titleFromUrl(url: string): string {
const id = youtubeId(url)
if (id) return `YouTube video (${id})`
try {
const host = new URL(url).hostname
if (host) return `${host} download`
} catch {
/* not a URL */
}
return 'New download'
}
// --- Mock seed data so every visual state is visible in the UI preview ------
const seed: DownloadItem[] = PREVIEW
? [
{
id: newId('item'),
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
title: 'Building a Desktop App with Electron -- Full Course',
channel: 'DevChannel',
durationLabel: '1:42:08',
kind: 'video',
quality: '1080p',
status: 'downloading',
progress: 0.42,
speedBytesPerSec: 4.7 * 1024 * 1024,
etaSeconds: 72,
sizeLabel: '512 MB'
},
{
id: newId('item'),
url: 'https://youtube.com/watch?v=abcd1234',
title: 'Lo-fi beats to code to (1 hour mix)',
channel: 'ChillStudio',
durationLabel: '1:00:21',
kind: 'audio',
quality: 'Best',
status: 'queued',
progress: 0
},
{
id: newId('item'),
url: 'https://youtube.com/watch?v=zzz9999',
title: 'How yt-dlp Works Under the Hood',
channel: 'Open Source Weekly',
durationLabel: '14:03',
kind: 'video',
quality: '720p',
status: 'completed',
progress: 1,
sizeLabel: '184 MB',
filePath: 'C:\\Users\\you\\Downloads\\How yt-dlp Works Under the Hood.mp4'
},
{
id: newId('item'),
url: 'https://youtube.com/watch?v=err0r',
title: 'Private video',
channel: undefined,
kind: 'video',
quality: 'Best available',
status: 'error',
progress: 0,
error: 'Video unavailable: this video is private.'
}
]
: []
// 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 }
// Side-effects to run exactly once when a download completes: record it to
// history (unless private) and mark its source MediaItem downloaded. Shared by