Files
AeroFetch/src/renderer/src/store/downloads.ts
T
debont80 8ab85da67e Fix H5/H6/M21/L37-40/L47/L55/L68/L72/L76/L91/L98/L108/L165/L166 audit items
H5: HistoryEntry now stores formatId+formatHasAudio; redownload passes them
    back to addFromUrl so the original yt-dlp format is reused, not guessed.
    Compound quality labels stripped to the preset prefix for old history.
L72: thumbnail now passed in redownload so re-queued rows keep their art.
H6: TerminalView log capped at MAX_LOG_LINES=2000 to prevent unbounded growth.
M21: --audio-quality omitted for lossless audioFormats (flac/wav).
L166: fmtEta hour rollover fixed in all 3 locations -- 2h00:00 not 120:00.
L165: queueStats formatSpeed precision aligned with fmtBytes.
L55: QueueItem suppresses sizeLabel when already in probed-format quality label.
L47: probeMeta null sends empty meta event so Resolving clears via SR6.
L68: notifiedBackground resets on window show for subsequent close-while-downloading.
L76: dup warning falls back to URL when title is still a placeholder.
L91: SettingsView onFormatSelect uses indexOf instead of unsafe tuple cast.
L98: Embed chapters field gets a hint text.
L108: BrowserWindow created with explicit title AeroFetch.
L37: pure formatters extracted to src/main/lib/formatters.ts and unit-tested.
L38: buildArgs test covers webm thumbnail suppression.
L39: CROP_SQUARE_PPA test is structural not exact-string.
L40: looksLikeUrl/looksLikeSingleVideo extracted to src/renderer/src/lib/urlHelpers.ts.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:29:35 -04:00

677 lines
23 KiB
TypeScript

import { create } from 'zustand'
import {
VIDEO_QUALITY_OPTIONS,
AUDIO_QUALITY_OPTIONS,
type DownloadEvent,
type DownloadMeta,
type DownloadOptions,
type CollectionContext,
type MediaKind
} from '@shared/ipc'
import { useSettings } from './settings'
import { useHistory } from './history'
import { useSources } from './sources'
import { newId } from '../id'
// 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
speed?: string
eta?: string
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
}
// `satisfies` (not an annotation) so the tuple element types survive: under
// noUncheckedIndexedAccess a `readonly string[]` index yields `string | undefined`,
// but the const tuples keep `QUALITY_OPTIONS[kind][0]` known-defined (L168).
export const QUALITY_OPTIONS = {
video: VIDEO_QUALITY_OPTIONS,
audio: AUDIO_QUALITY_OPTIONS
} satisfies Record<MediaKind, readonly 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
}
// True when running in the standalone browser preview (no Electron preload).
// The real preload injects window.electron; the browser stub does not.
const PREVIEW = typeof window === 'undefined' || !window.electron
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 {
try {
const u = new URL(url)
const v = u.searchParams.get('v')
if (v) return `YouTube video (${v})`
if (u.hostname) return `${u.hostname} download`
} catch {
/* not a URL */
}
return 'New download'
}
function fmtEta(seconds: number): string {
const s = Math.max(0, Math.round(seconds))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`
return `${m}:${String(r).padStart(2, '0')}`
}
// --- 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,
speed: '4.7 MB/s',
eta: '1:12',
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 (MP3)',
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.'
}
]
: []
// 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',
speed: done ? undefined : `${(2 + Math.random() * 5).toFixed(1)} MB/s`,
eta: done ? undefined : fmtEta((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 && !finished.incognito) {
useHistory.getState().add({
id: finished.id,
title: finished.title,
channel: finished.channel,
url: finished.url,
kind: finished.kind,
quality: finished.quality,
filePath: finished.filePath,
sizeLabel: finished.sizeLabel,
thumbnail: finished.thumbnail,
completedAt: Date.now()
})
}
// Mirror the real applyEvent path so collection completions mark their
// source item downloaded in the preview too.
if (finished?.mediaItemId) {
useSources.getState().markDownloaded(finished.mediaItemId, finished.filePath)
}
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,
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,
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,
speed: undefined,
eta: undefined
}
: i
)
}))
pump()
},
retryAll: () => {
set((s) => ({
items: s.items.map((i) =>
i.status === 'error'
? {
...i,
status: 'queued',
progress: 0,
error: undefined,
speed: undefined,
eta: 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', speed: undefined, eta: 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: () => {
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', speed: undefined, eta: 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) window.api.openPath(item.filePath)
},
showInFolder: (id) => {
const item = get().items.find((i) => i.id === id)
if (!PREVIEW && item?.filePath) window.api.showInFolder(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,
speed: ev.progress.speed,
eta: ev.progress.eta,
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,
speed: undefined,
eta: 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,
speed: undefined,
eta: undefined,
channel: i.channel === RESOLVING ? undefined : i.channel,
error: ev.error
}
default:
return i
}
})
}))
// Record completed downloads to history (unless this was a private download).
if (ev.type === 'done') {
const item = get().items.find((i) => i.id === ev.id)
if (item && item.status === 'completed' && !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,
completedAt: Date.now()
})
}
// Persist media-manager completion so an incremental re-sync skips it.
if (item?.mediaItemId) {
useSources.getState().markDownloaded(item.mediaItemId, item.filePath)
}
}
// A finished item frees a slot — promote whatever is queued next.
if (ev.type === 'done' || ev.type === 'error') pump()
}
}
})
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
// Session-only: the queue isn't persisted, so a schedule only fires while the app
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
setInterval(() => {
const st = useDownloads.getState()
const now = Date.now()
if (
st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)
) {
st.promoteDueScheduled()
}
}, 15_000)
// 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)))
}