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:
@@ -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'
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
]
|
||||
: []
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user