8ab85da67e
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>
677 lines
23 KiB
TypeScript
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)))
|
|
}
|