diff --git a/src/renderer/src/store/downloadItem.ts b/src/renderer/src/store/downloadItem.ts new file mode 100644 index 0000000..878560e --- /dev/null +++ b/src/renderer/src/store/downloadItem.ts @@ -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' +} diff --git a/src/renderer/src/store/downloadSeed.ts b/src/renderer/src/store/downloadSeed.ts new file mode 100644 index 0000000..42787b3 --- /dev/null +++ b/src/renderer/src/store/downloadSeed.ts @@ -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.' + } + ] + : [] diff --git a/src/renderer/src/store/downloadTypes.ts b/src/renderer/src/store/downloadTypes.ts new file mode 100644 index 0000000..c5de272 --- /dev/null +++ b/src/renderer/src/store/downloadTypes.ts @@ -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 // - */ + 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 +} diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index abe0eb0..f627799 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -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