feat: queue UX (Phase M) + media editing (split-chapters, trim)
Phase L (media editing): - Split by chapters (--split-chapters) as a DownloadOptions toggle - Trim/cut by time range: parseTrimSections -> --download-sections "*A-B" + --force-keyframes-at-cuts (deduped vs SponsorBlock), per-download Trim panel Phase M (queue & daily-use UX), all 7: - Pause/resume: main-side killTree + paused flag (silent close), download:pause IPC, store pause/resume + paused status, QueueItem controls - Reorder via "Download next" (prioritize) - Aggregate progress strip (pure summarizeQueue) + combined speed/ETA - Drag-and-drop links / .url files onto the download card - Retry all failed - Duplicate detection (sameVideo) with "Download anyway" guard - Per-download scheduling (datetime-local) + save-for-later (saved status, 15s promotion ticker); session-only (queue not persisted) New pure modules unit-tested (queueStats); buildArgs trim/split tested. typecheck + test green (178 passed). Roadmap updated: Phase M COMPLETE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -161,6 +161,30 @@ export function selectExtraArgs(params: {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw trim string (one or more time ranges, comma- or newline-separated)
|
||||
* into yt-dlp `--download-sections` specs. Each range is normalised to the
|
||||
* `*START-END` time-range form yt-dlp expects (the leading `*` distinguishes a
|
||||
* timestamp range from a chapter-title regex). Tokens that don't look like a
|
||||
* numeric time range are dropped, so malformed input can't reach yt-dlp's argv.
|
||||
*
|
||||
* Accepts H:MM:SS / M:SS / SS with optional fractional seconds, e.g. '1:30-2:00',
|
||||
* '90-120', '0:00:10.5-0:00:20'. A token may already carry the leading `*`.
|
||||
*/
|
||||
export function parseTrimSections(raw: string | undefined): string[] {
|
||||
if (!raw) return []
|
||||
const TIME = String.raw`\d+(?::\d{1,2})*(?:\.\d+)?`
|
||||
const RANGE = new RegExp(`^${TIME}-${TIME}$`)
|
||||
const out: string[] = []
|
||||
for (const piece of raw.split(/[,\n]/)) {
|
||||
let t = piece.trim()
|
||||
if (!t) continue
|
||||
if (t.startsWith('*')) t = t.slice(1).trim()
|
||||
if (RANGE.test(t)) out.push(`*${t}`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Wrap a single argv token for human-readable display only (Phase C command
|
||||
// preview) — never used to build the real argv that gets spawned.
|
||||
function quoteForDisplay(arg: string): string {
|
||||
@@ -250,17 +274,30 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
|
||||
}
|
||||
|
||||
// SponsorBlock.
|
||||
let forcedKeyframes = false
|
||||
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
|
||||
const cats = o.sponsorBlockCategories.join(',')
|
||||
if (o.sponsorBlockMode === 'remove') {
|
||||
// Force keyframes at the cut points so segment boundaries are frame-accurate.
|
||||
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
|
||||
forcedKeyframes = true
|
||||
} else {
|
||||
args.push('--sponsorblock-mark', cats)
|
||||
}
|
||||
}
|
||||
|
||||
// Trim — download only the given time ranges (works for video + audio). Force
|
||||
// keyframes at the cut points for frame-accurate boundaries, unless
|
||||
// SponsorBlock-remove already requested it (the flag is idempotent, but we
|
||||
// avoid emitting it twice).
|
||||
const sections = parseTrimSections(opts.trim)
|
||||
for (const sec of sections) args.push('--download-sections', sec)
|
||||
if (sections.length > 0 && !forcedKeyframes) args.push('--force-keyframes-at-cuts')
|
||||
|
||||
if (o.embedChapters) args.push('--embed-chapters')
|
||||
// Split into one file per chapter. yt-dlp also keeps the full file; the
|
||||
// per-chapter files use yt-dlp's default `chapter:` output template.
|
||||
if (o.splitChapters) args.push('--split-chapters')
|
||||
if (o.embedMetadata) args.push('--embed-metadata')
|
||||
|
||||
// Media-server sidecar files (Phase K) — written next to the output file so
|
||||
|
||||
+32
-10
@@ -38,6 +38,7 @@ import {
|
||||
interface ActiveDownload {
|
||||
child: ChildProcess
|
||||
canceled: boolean
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
const active = new Map<string, ActiveDownload>()
|
||||
@@ -295,7 +296,7 @@ export function startDownload(
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
|
||||
const rec: ActiveDownload = { child, canceled: false }
|
||||
const rec: ActiveDownload = { child, canceled: false, paused: false }
|
||||
active.set(opts.id, rec)
|
||||
|
||||
// Title/channel/duration are kept locally so the completion notification and
|
||||
@@ -344,7 +345,8 @@ export function startDownload(
|
||||
if (settled) return
|
||||
settled = true
|
||||
active.delete(opts.id)
|
||||
if (!rec.canceled) {
|
||||
// A paused download was killed on purpose — stay silent, like a cancel.
|
||||
if (!rec.canceled && !rec.paused) {
|
||||
send(wc, { type: 'error', id: opts.id, error: err.message })
|
||||
logFailure(opts, resolvedTitle, err.message)
|
||||
notify(wc, resolvedTitle ?? 'Download failed', err.message)
|
||||
@@ -355,7 +357,9 @@ export function startDownload(
|
||||
if (settled) return
|
||||
settled = true
|
||||
active.delete(opts.id)
|
||||
if (rec.canceled) return // renderer already showed 'canceled' optimistically
|
||||
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
|
||||
// 'paused' and keeps the .part for a later resume. Either way, no event.
|
||||
if (rec.canceled || rec.paused) return
|
||||
if (code === 0) {
|
||||
send(wc, { type: 'done', id: opts.id, filePath })
|
||||
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
|
||||
@@ -370,15 +374,12 @@ export function startDownload(
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export function cancelDownload(id: string): void {
|
||||
const rec = active.get(id)
|
||||
if (!rec) return
|
||||
rec.canceled = true
|
||||
// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
|
||||
// taskkill from System32 by absolute path, never the bare name, so a planted
|
||||
// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
|
||||
function killTree(rec: ActiveDownload): void {
|
||||
const pid = rec.child.pid
|
||||
if (pid != null) {
|
||||
// Kill the whole tree (/T) so the spawned ffmpeg child dies too. Resolve
|
||||
// taskkill from System32 by absolute path, never the bare name, so a planted
|
||||
// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
|
||||
execFile(
|
||||
getSystem32Path('taskkill.exe'),
|
||||
['/pid', String(pid), '/T', '/F'],
|
||||
@@ -389,3 +390,24 @@ export function cancelDownload(id: string): void {
|
||||
rec.child.kill()
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelDownload(id: string): void {
|
||||
const rec = active.get(id)
|
||||
if (!rec) return
|
||||
rec.canceled = true
|
||||
killTree(rec)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a running download: kill the process tree but flag it `paused` (not
|
||||
* `canceled`) so the close handler stays silent — no error event, no error log,
|
||||
* no notification. yt-dlp leaves the partial `.part` file in place, so resuming
|
||||
* is just a fresh startDownload with the same options: yt-dlp's default
|
||||
* `--continue` picks the partial file back up.
|
||||
*/
|
||||
export function pauseDownload(id: string): void {
|
||||
const rec = active.get(id)
|
||||
if (!rec) return
|
||||
rec.paused = true
|
||||
killTree(rec)
|
||||
}
|
||||
|
||||
+2
-1
@@ -14,7 +14,7 @@ import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp
|
||||
import { getFfmpegVersions } from './ffmpeg'
|
||||
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||
import { probeMedia } from './probe'
|
||||
import { startDownload, cancelDownload, previewCommand } from './download'
|
||||
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
|
||||
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||
@@ -211,6 +211,7 @@ function registerIpcHandlers(): void {
|
||||
return result
|
||||
})
|
||||
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
|
||||
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
|
||||
|
||||
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ function sanitizeOptions(input: unknown): DownloadOptions {
|
||||
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
|
||||
sponsorBlockCategories: cats,
|
||||
embedChapters: bool(o.embedChapters, d.embedChapters),
|
||||
splitChapters: bool(o.splitChapters, d.splitChapters),
|
||||
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
|
||||
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
|
||||
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
|
||||
|
||||
@@ -69,6 +69,9 @@ const api = {
|
||||
cancelDownload: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannels.downloadCancel, id),
|
||||
|
||||
pauseDownload: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannels.downloadPause, id),
|
||||
|
||||
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
|
||||
|
||||
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Input,
|
||||
Textarea,
|
||||
Field,
|
||||
Button,
|
||||
Checkbox,
|
||||
Spinner,
|
||||
@@ -20,10 +22,14 @@ import {
|
||||
ErrorCircleRegular,
|
||||
LinkRegular,
|
||||
DismissRegular,
|
||||
AppsListRegular
|
||||
AppsListRegular,
|
||||
CutRegular,
|
||||
CalendarClockRegular,
|
||||
WarningRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
|
||||
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
|
||||
import { sameVideo } from '../store/queueStats'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { Select } from './Select'
|
||||
import { Hint } from './Hint'
|
||||
@@ -40,6 +46,23 @@ function looksLikeUrl(text: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */
|
||||
function firstUrl(text: string): string | null {
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
if (looksLikeUrl(line)) return line
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
|
||||
function parseUrlFile(content: string): string | null {
|
||||
const m = /^\s*URL\s*=\s*(\S+)/im.exec(content)
|
||||
const url = m?.[1]?.trim()
|
||||
return url && looksLikeUrl(url) ? url : null
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
@@ -50,6 +73,11 @@ const useStyles = makeStyles({
|
||||
...shorthands.borderRadius(tokens.borderRadiusXLarge),
|
||||
boxShadow: tokens.shadow4
|
||||
},
|
||||
// Highlight while a link / .url file is dragged over the card.
|
||||
rootDragging: {
|
||||
outline: `2px dashed ${tokens.colorBrandStroke1}`,
|
||||
outlineOffset: '-2px'
|
||||
},
|
||||
urlRow: {
|
||||
display: 'flex',
|
||||
gap: '8px'
|
||||
@@ -212,6 +240,45 @@ const useStyles = makeStyles({
|
||||
},
|
||||
plItemMeta: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
// --- duplicate warning ---
|
||||
dupRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 8px 8px 12px',
|
||||
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||
color: tokens.colorStatusWarningForeground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||
},
|
||||
// --- trim / schedule panels ---
|
||||
trimBlock: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start'
|
||||
},
|
||||
optButtons: {
|
||||
display: 'flex',
|
||||
gap: '8px'
|
||||
},
|
||||
trimPanel: {
|
||||
alignSelf: 'stretch',
|
||||
padding: '12px',
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
},
|
||||
// Native datetime-local input, themed to sit beside the Fluent controls.
|
||||
dtInput: {
|
||||
fontFamily: tokens.fontFamilyBase,
|
||||
fontSize: tokens.fontSizeBase300,
|
||||
padding: '6px 10px',
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
color: tokens.colorNeutralForeground1,
|
||||
colorScheme: 'light dark'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -227,6 +294,47 @@ export function DownloadBar(): React.JSX.Element {
|
||||
const [kind, setKind] = useState<MediaKind>('video')
|
||||
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
|
||||
|
||||
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
|
||||
const [showTrim, setShowTrim] = useState(false)
|
||||
const [trim, setTrim] = useState('')
|
||||
|
||||
// Optional schedule: a datetime-local value; a future time parks the download.
|
||||
const [showSchedule, setShowSchedule] = useState(false)
|
||||
const [scheduleAt, setScheduleAt] = useState('')
|
||||
|
||||
// Drag-and-drop: highlight while a link / .url file hovers the card.
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
|
||||
// Duplicate guard: warn before enqueuing a URL already in the active queue.
|
||||
const [dup, setDup] = useState<string | null>(null)
|
||||
const confirmDup = useRef(false)
|
||||
|
||||
function onDragOver(e: React.DragEvent): void {
|
||||
e.preventDefault()
|
||||
if (!dragActive) setDragActive(true)
|
||||
}
|
||||
function onDrop(e: React.DragEvent): void {
|
||||
e.preventDefault()
|
||||
setDragActive(false)
|
||||
const dt = e.dataTransfer
|
||||
const fromText = firstUrl(dt.getData('text/uri-list') || dt.getData('text/plain') || '')
|
||||
if (fromText) {
|
||||
onUrlChange(fromText)
|
||||
return
|
||||
}
|
||||
// A dropped Windows .url Internet Shortcut — read its URL= line.
|
||||
const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url'))
|
||||
if (file) {
|
||||
file
|
||||
.text()
|
||||
.then((content) => {
|
||||
const u = parseUrlFile(content)
|
||||
if (u) onUrlChange(u)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the saved default format once, when persisted settings first arrive.
|
||||
const appliedDefaults = useRef(false)
|
||||
useEffect(() => {
|
||||
@@ -320,8 +428,10 @@ export function DownloadBar(): React.JSX.Element {
|
||||
|
||||
function onUrlChange(next: string): void {
|
||||
setUrl(next)
|
||||
// Any probed info is stale once the URL changes.
|
||||
// Any probed info — or a duplicate warning — is stale once the URL changes.
|
||||
if (info || probeError || playlist) clearProbe()
|
||||
if (dup) setDup(null)
|
||||
confirmDup.current = false
|
||||
}
|
||||
|
||||
function onKindChange(next: MediaKind): void {
|
||||
@@ -383,6 +493,23 @@ export function DownloadBar(): React.JSX.Element {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
// Duplicate guard: if this URL is already in the queue (and the user hasn't
|
||||
// confirmed via "Download anyway"), warn instead of silently enqueuing a copy.
|
||||
// Canceled/failed items don't count — re-adding those is a legitimate retry.
|
||||
if (!confirmDup.current) {
|
||||
const existing = useDownloads
|
||||
.getState()
|
||||
.items.find(
|
||||
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
|
||||
)
|
||||
if (existing) {
|
||||
setDup(existing.title)
|
||||
return
|
||||
}
|
||||
}
|
||||
confirmDup.current = false
|
||||
setDup(null)
|
||||
|
||||
const meta = info
|
||||
? {
|
||||
title: info.title,
|
||||
@@ -392,9 +519,14 @@ export function DownloadBar(): React.JSX.Element {
|
||||
}
|
||||
: {}
|
||||
|
||||
const trimSpec = trim.trim() || undefined
|
||||
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
|
||||
|
||||
if (usingFormats && selectedFormat) {
|
||||
addFromUrl(trimmed, 'video', selectedFormat.label, {
|
||||
...meta,
|
||||
trim: trimSpec,
|
||||
scheduledFor,
|
||||
format: {
|
||||
id: selectedFormat.id,
|
||||
hasAudio: selectedFormat.hasAudio,
|
||||
@@ -402,13 +534,22 @@ export function DownloadBar(): React.JSX.Element {
|
||||
}
|
||||
})
|
||||
} else {
|
||||
addFromUrl(trimmed, kind, quality, meta)
|
||||
addFromUrl(trimmed, kind, quality, { ...meta, trim: trimSpec, scheduledFor })
|
||||
}
|
||||
|
||||
setUrl('')
|
||||
setTrim('')
|
||||
setShowTrim(false)
|
||||
setScheduleAt('')
|
||||
setShowSchedule(false)
|
||||
clearProbe()
|
||||
}
|
||||
|
||||
function downloadAnyway(): void {
|
||||
confirmDup.current = true
|
||||
download()
|
||||
}
|
||||
|
||||
function addPlaylist(): void {
|
||||
if (!playlist) return
|
||||
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
||||
@@ -424,7 +565,12 @@ export function DownloadBar(): React.JSX.Element {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div
|
||||
className={mergeClasses(styles.root, dragActive && styles.rootDragging)}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={() => setDragActive(false)}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<div className={styles.urlRow}>
|
||||
<Input
|
||||
className={styles.url}
|
||||
@@ -474,6 +620,23 @@ export function DownloadBar(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dup && (
|
||||
<div className={styles.dupRow}>
|
||||
<WarningRegular />
|
||||
<Caption1 className={styles.suggestionText}>Already in your queue: “{dup}”.</Caption1>
|
||||
<Button size="small" appearance="primary" onClick={downloadAnyway}>
|
||||
Download anyway
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={() => setDup(null)}
|
||||
aria-label="Dismiss"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{probing && (
|
||||
<div className={styles.statusRow}>
|
||||
<Spinner size="tiny" />
|
||||
@@ -550,6 +713,59 @@ export function DownloadBar(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!playlist && (
|
||||
<div className={styles.trimBlock}>
|
||||
<div className={styles.optButtons}>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<CutRegular />}
|
||||
onClick={() => setShowTrim((v) => !v)}
|
||||
>
|
||||
{trim.trim() ? 'Trim · on' : 'Trim'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<CalendarClockRegular />}
|
||||
onClick={() => setShowSchedule((v) => !v)}
|
||||
>
|
||||
{scheduleAt ? 'Scheduled' : 'Schedule'}
|
||||
</Button>
|
||||
</div>
|
||||
{showTrim && (
|
||||
<div className={styles.trimPanel}>
|
||||
<Field
|
||||
label="Sections to keep"
|
||||
hint="Time ranges like 1:30-2:00 — one per line or comma-separated. Leave empty to download the whole thing."
|
||||
>
|
||||
<Textarea
|
||||
value={trim}
|
||||
onChange={(_, d) => setTrim(d.value)}
|
||||
placeholder={'0:30-1:45\n3:00-3:30'}
|
||||
resize="vertical"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{showSchedule && (
|
||||
<div className={styles.trimPanel}>
|
||||
<Field
|
||||
label="Start at"
|
||||
hint="Pick a date and time to start this download. Leave empty to start now. Scheduled downloads only fire while AeroFetch is running."
|
||||
>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className={styles.dtInput}
|
||||
value={scheduleAt}
|
||||
onChange={(e) => setScheduleAt(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.control}>
|
||||
<Caption1>Format</Caption1>
|
||||
@@ -606,11 +822,11 @@ export function DownloadBar(): React.JSX.Element {
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="large"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
icon={scheduleAt ? <CalendarClockRegular /> : <ArrowDownloadRegular />}
|
||||
onClick={download}
|
||||
disabled={!url.trim()}
|
||||
>
|
||||
Download
|
||||
{scheduleAt ? 'Schedule' : 'Download'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -204,6 +204,16 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
label={value.embedChapters ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Split by chapters"
|
||||
hint="Also save each chapter as its own file. The full-length file is kept too."
|
||||
>
|
||||
<Switch
|
||||
checked={value.splitChapters}
|
||||
onChange={(_, d) => setOpt('splitChapters', d.checked)}
|
||||
label={value.splitChapters ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
|
||||
<Switch
|
||||
checked={value.embedMetadata}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
Subtitle2,
|
||||
Body1,
|
||||
Caption1,
|
||||
Button,
|
||||
ProgressBar,
|
||||
makeStyles,
|
||||
tokens
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import { ArrowDownloadRegular } from '@fluentui/react-icons'
|
||||
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
|
||||
import { useDownloads } from '../store/downloads'
|
||||
import { summarizeQueue } from '../store/queueStats'
|
||||
import { DownloadBar } from './DownloadBar'
|
||||
import { QueueItem } from './QueueItem'
|
||||
import { VirtualList } from './VirtualList'
|
||||
@@ -26,6 +30,27 @@ const useStyles = makeStyles({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
headerActions: {
|
||||
display: 'flex',
|
||||
gap: '8px'
|
||||
},
|
||||
summary: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '10px 14px',
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
},
|
||||
summaryBar: {
|
||||
flexGrow: 1
|
||||
},
|
||||
summaryText: {
|
||||
flexShrink: 0,
|
||||
color: tokens.colorNeutralForeground3,
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
listScroll: {
|
||||
flexGrow: 1,
|
||||
// min-height:0 lets this flex child shrink below its content height so it,
|
||||
@@ -47,7 +72,9 @@ export function DownloadsView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const items = useDownloads((s) => s.items)
|
||||
const clearFinished = useDownloads((s) => s.clearFinished)
|
||||
const retryAll = useDownloads((s) => s.retryAll)
|
||||
|
||||
const summary = summarizeQueue(items)
|
||||
const hasFinished = items.some(
|
||||
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
||||
)
|
||||
@@ -56,13 +83,37 @@ export function DownloadsView(): React.JSX.Element {
|
||||
<div className={styles.root}>
|
||||
<DownloadBar />
|
||||
|
||||
{summary.active && (
|
||||
<div className={styles.summary}>
|
||||
<ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" />
|
||||
<Caption1 className={styles.summaryText}>
|
||||
{summary.downloading} downloading
|
||||
{summary.queued ? `, ${summary.queued} queued` : ''}
|
||||
{summary.speedLabel ? ` • ${summary.speedLabel}` : ''}
|
||||
{summary.etaLabel ? ` • ~${summary.etaLabel} left` : ''}
|
||||
</Caption1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.queueHeader}>
|
||||
<Subtitle2>Queue ({items.length})</Subtitle2>
|
||||
{hasFinished && (
|
||||
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
||||
Clear finished
|
||||
</Button>
|
||||
)}
|
||||
<div className={styles.headerActions}>
|
||||
{summary.failed > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowClockwiseRegular />}
|
||||
onClick={retryAll}
|
||||
>
|
||||
Retry all failed ({summary.failed})
|
||||
</Button>
|
||||
)}
|
||||
{hasFinished && (
|
||||
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
||||
Clear finished
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
|
||||
@@ -57,6 +57,8 @@ const STATUS_LABEL: Record<ItemStatus, string> = {
|
||||
pending: 'Pending',
|
||||
queued: 'Queued',
|
||||
downloading: 'Downloading',
|
||||
paused: 'Paused',
|
||||
saved: 'Saved',
|
||||
completed: 'Downloaded',
|
||||
error: 'Failed',
|
||||
canceled: 'Canceled'
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
OpenRegular,
|
||||
FolderRegular,
|
||||
ArrowClockwiseRegular,
|
||||
ArrowDownloadRegular,
|
||||
ArrowUpRegular,
|
||||
BookmarkRegular,
|
||||
PauseRegular,
|
||||
PlayRegular,
|
||||
CheckmarkCircleFilled,
|
||||
ErrorCircleFilled,
|
||||
EyeOffRegular
|
||||
@@ -91,6 +96,8 @@ const useStyles = makeStyles({
|
||||
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
|
||||
queued: { label: 'Queued', color: 'subtle' },
|
||||
downloading: { label: 'Downloading', color: 'brand' },
|
||||
paused: { label: 'Paused', color: 'warning' },
|
||||
saved: { label: 'Saved', color: 'subtle' },
|
||||
completed: { label: 'Completed', color: 'success' },
|
||||
error: { label: 'Failed', color: 'danger' },
|
||||
canceled: { label: 'Canceled', color: 'warning' }
|
||||
@@ -100,6 +107,14 @@ function pct(progress: number): string {
|
||||
return `${Math.round(progress * 100)}%`
|
||||
}
|
||||
|
||||
function fmtSchedule(ms: number): string {
|
||||
try {
|
||||
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const QueueItem = memo(function QueueItem({
|
||||
item
|
||||
}: {
|
||||
@@ -107,6 +122,11 @@ export const QueueItem = memo(function QueueItem({
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const cancel = useDownloads((s) => s.cancel)
|
||||
const pause = useDownloads((s) => s.pause)
|
||||
const resume = useDownloads((s) => s.resume)
|
||||
const prioritize = useDownloads((s) => s.prioritize)
|
||||
const saveForLater = useDownloads((s) => s.saveForLater)
|
||||
const queueNow = useDownloads((s) => s.queueNow)
|
||||
const remove = useDownloads((s) => s.remove)
|
||||
const retry = useDownloads((s) => s.retry)
|
||||
const openFile = useDownloads((s) => s.openFile)
|
||||
@@ -177,12 +197,79 @@ export const QueueItem = memo(function QueueItem({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'paused' && (
|
||||
<div className={styles.progressRow}>
|
||||
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
|
||||
<Caption1 className={styles.stats}>Paused • {pct(item.progress)}</Caption1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'saved' && (
|
||||
<Caption1 className={styles.stats}>
|
||||
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'}
|
||||
</Caption1>
|
||||
)}
|
||||
|
||||
{item.status === 'error' && item.error && (
|
||||
<Caption1 className={styles.error}>{item.error}</Caption1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{item.status === 'downloading' && (
|
||||
<Hint label="Pause" placement="top" align="end">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<PauseRegular />}
|
||||
onClick={() => pause(item.id)}
|
||||
aria-label="Pause"
|
||||
/>
|
||||
</Hint>
|
||||
)}
|
||||
|
||||
{item.status === 'queued' && (
|
||||
<>
|
||||
<Hint label="Download next" placement="top" align="end">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<ArrowUpRegular />}
|
||||
onClick={() => prioritize(item.id)}
|
||||
aria-label="Download next"
|
||||
/>
|
||||
</Hint>
|
||||
<Hint label="Save for later" placement="top" align="end">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<BookmarkRegular />}
|
||||
onClick={() => saveForLater(item.id)}
|
||||
aria-label="Save for later"
|
||||
/>
|
||||
</Hint>
|
||||
</>
|
||||
)}
|
||||
|
||||
{item.status === 'paused' && (
|
||||
<Hint label="Resume" placement="top" align="end">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<PlayRegular />}
|
||||
onClick={() => resume(item.id)}
|
||||
aria-label="Resume"
|
||||
/>
|
||||
</Hint>
|
||||
)}
|
||||
|
||||
{item.status === 'saved' && (
|
||||
<Hint label="Add to queue" placement="top" align="end">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
onClick={() => queueNow(item.id)}
|
||||
aria-label="Add to queue"
|
||||
/>
|
||||
</Hint>
|
||||
)}
|
||||
|
||||
{active && (
|
||||
<Hint label="Cancel" placement="top" align="end">
|
||||
<Button
|
||||
|
||||
@@ -113,6 +113,7 @@ if (import.meta.env.DEV && !window.api) {
|
||||
},
|
||||
startDownload: async () => ({ ok: true }),
|
||||
cancelDownload: async () => {},
|
||||
pauseDownload: async () => {},
|
||||
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
|
||||
chooseFolder: async () => null,
|
||||
openPath: async () => '',
|
||||
|
||||
@@ -6,7 +6,14 @@ import { useSources } from './sources'
|
||||
|
||||
export type MediaKind = 'video' | 'audio'
|
||||
|
||||
export type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'error' | 'canceled'
|
||||
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 {
|
||||
@@ -39,6 +46,10 @@ export interface DownloadItem {
|
||||
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 */
|
||||
@@ -63,6 +74,9 @@ export interface AddOptions {
|
||||
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
|
||||
@@ -90,6 +104,20 @@ interface DownloadState {
|
||||
*/
|
||||
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
|
||||
@@ -117,6 +145,10 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti
|
||||
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(),
|
||||
url,
|
||||
@@ -126,12 +158,14 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti
|
||||
thumbnail: opts?.thumbnail,
|
||||
kind,
|
||||
quality,
|
||||
status: 'queued',
|
||||
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,
|
||||
@@ -297,6 +331,7 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
formatHasAudio: item.formatHasAudio,
|
||||
options: item.options,
|
||||
extraArgs: item.extraArgs,
|
||||
trim: item.trim,
|
||||
meta: item.probedMeta,
|
||||
collection: item.collection
|
||||
})
|
||||
@@ -357,6 +392,93 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
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)
|
||||
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
|
||||
@@ -379,7 +501,13 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
|
||||
clearFinished: () =>
|
||||
set((s) => ({
|
||||
items: s.items.filter((i) => i.status === 'downloading' || i.status === 'queued')
|
||||
items: s.items.filter(
|
||||
(i) =>
|
||||
i.status === 'downloading' ||
|
||||
i.status === 'queued' ||
|
||||
i.status === 'paused' ||
|
||||
i.status === 'saved'
|
||||
)
|
||||
})),
|
||||
|
||||
openFile: (id) => {
|
||||
@@ -461,6 +589,17 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 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))
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Pure helpers for the Downloads view (Phase M): a one-line aggregate of the live
|
||||
* queue, and same-video detection for the duplicate guard. Kept free of any store
|
||||
* / window dependency (only a type-only import of DownloadItem) so it can be
|
||||
* unit-tested without constructing the Zustand store or its preview ticker.
|
||||
*/
|
||||
import type { DownloadItem } from './downloads'
|
||||
|
||||
// Parse a human speed string ('4.7 MB/s', '512 KiB/s') into bytes/second. Tolerant
|
||||
// of the SI (KB) and binary (KiB) suffixes yt-dlp may emit; the 1000-vs-1024
|
||||
// difference is immaterial for a live display estimate.
|
||||
function parseSpeed(s?: string): number {
|
||||
if (!s) return 0
|
||||
const m = /([\d.]+)\s*([KMGT]?)i?B\/s/i.exec(s)
|
||||
if (!m) return 0
|
||||
const n = parseFloat(m[1])
|
||||
if (!isFinite(n)) return 0
|
||||
const mult: Record<string, number> = { '': 1, K: 1e3, M: 1e6, G: 1e9, T: 1e12 }
|
||||
return n * (mult[m[2].toUpperCase()] ?? 1)
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec: number): string {
|
||||
if (bytesPerSec <= 0) return ''
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']
|
||||
let v = bytesPerSec
|
||||
let i = 0
|
||||
while (v >= 1000 && i < units.length - 1) {
|
||||
v /= 1000
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
// Parse 'M:SS' / 'H:MM:SS' (or bare seconds) into seconds.
|
||||
function parseEtaSeconds(s?: string): number {
|
||||
if (!s) return 0
|
||||
const parts = s.split(':').map((p) => Number(p))
|
||||
if (parts.length === 0 || parts.some((p) => !isFinite(p))) return 0
|
||||
return parts.reduce((acc, p) => acc * 60 + p, 0)
|
||||
}
|
||||
|
||||
function formatEta(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return ''
|
||||
const s = Math.round(totalSeconds)
|
||||
const m = Math.floor(s / 60)
|
||||
return `${m}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export interface QueueSummary {
|
||||
downloading: number
|
||||
queued: number
|
||||
failed: number
|
||||
/** true when something is downloading or waiting */
|
||||
active: boolean
|
||||
/** 0..1, averaged over downloading + queued items */
|
||||
progress: number
|
||||
/** combined download speed, formatted; '' when nothing is downloading */
|
||||
speedLabel: string
|
||||
/** rough time remaining (the longest active ETA), formatted; '' when unknown */
|
||||
etaLabel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the live queue into a one-line summary for the Downloads header.
|
||||
* Combined speed is summed across downloading items; the "time left" is the
|
||||
* longest single ETA — a reasonable proxy for when the current batch finishes
|
||||
* (we don't track remaining bytes, so a true combined ETA isn't available).
|
||||
* Queued items count toward the progress average as 0%, so the bar reflects
|
||||
* "how much of the visible work is done", not just the active downloads.
|
||||
*/
|
||||
export function summarizeQueue(items: DownloadItem[]): QueueSummary {
|
||||
let downloading = 0
|
||||
let queued = 0
|
||||
let failed = 0
|
||||
let progressSum = 0
|
||||
let progressCount = 0
|
||||
let speed = 0
|
||||
let maxEta = 0
|
||||
for (const i of items) {
|
||||
if (i.status === 'downloading') {
|
||||
downloading++
|
||||
progressSum += i.progress
|
||||
progressCount++
|
||||
speed += parseSpeed(i.speed)
|
||||
maxEta = Math.max(maxEta, parseEtaSeconds(i.eta))
|
||||
} else if (i.status === 'queued') {
|
||||
queued++
|
||||
progressCount++
|
||||
} else if (i.status === 'error') {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
return {
|
||||
downloading,
|
||||
queued,
|
||||
failed,
|
||||
active: downloading > 0 || queued > 0,
|
||||
progress: progressCount > 0 ? progressSum / progressCount : 0,
|
||||
speedLabel: formatSpeed(speed),
|
||||
etaLabel: formatEta(maxEta)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Duplicate detection ----------------------------------------------------
|
||||
|
||||
function youtubeId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const host = u.hostname.replace(/^www\./, '')
|
||||
if (host === 'youtu.be') return u.pathname.slice(1) || null
|
||||
if (host.endsWith('youtube.com')) return u.searchParams.get('v')
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const host = u.hostname.replace(/^www\./, '').toLowerCase()
|
||||
return `${u.protocol}//${host}${u.pathname.replace(/\/$/, '')}${u.search}`
|
||||
} catch {
|
||||
return url.trim()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when two URLs point at the same video: a matching YouTube id when both
|
||||
* are YouTube links, otherwise normalised-URL equality (host lower-cased, `www.`
|
||||
* and a trailing slash stripped). Powers the "already in your queue" guard.
|
||||
*/
|
||||
export function sameVideo(a: string, b: string): boolean {
|
||||
if (a === b) return true
|
||||
const ya = youtubeId(a)
|
||||
const yb = youtubeId(b)
|
||||
if (ya && yb) return ya === yb
|
||||
return normalizeUrl(a) === normalizeUrl(b)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export const IpcChannels = {
|
||||
probe: 'media:probe',
|
||||
downloadStart: 'download:start',
|
||||
downloadCancel: 'download:cancel',
|
||||
downloadPause: 'download:pause',
|
||||
defaultFolder: 'download:default-folder',
|
||||
chooseFolder: 'download:choose-folder',
|
||||
openPath: 'shell:open-path',
|
||||
@@ -158,6 +159,8 @@ export interface DownloadOptions {
|
||||
sponsorBlockCategories: SponsorBlockCategory[]
|
||||
/** embed chapter markers */
|
||||
embedChapters: boolean
|
||||
/** split the download into one file per chapter (--split-chapters) */
|
||||
splitChapters: boolean
|
||||
/** embed metadata (title/artist/etc.) */
|
||||
embedMetadata: boolean
|
||||
/** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */
|
||||
@@ -183,6 +186,7 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
|
||||
sponsorBlockMode: 'remove',
|
||||
sponsorBlockCategories: ['sponsor'],
|
||||
embedChapters: false,
|
||||
splitChapters: false,
|
||||
embedMetadata: true,
|
||||
embedThumbnail: true,
|
||||
cropThumbnail: false,
|
||||
@@ -389,6 +393,13 @@ export interface StartDownloadOptions {
|
||||
* download even when the settings default is enabled.
|
||||
*/
|
||||
extraArgs?: string
|
||||
/**
|
||||
* Optional trim: keep only these time ranges. Raw user text — ranges like
|
||||
* '1:30-2:00', comma- or newline-separated — normalised to yt-dlp
|
||||
* `--download-sections` specs in buildArgs (parseTrimSections). Empty or
|
||||
* undefined downloads the whole media.
|
||||
*/
|
||||
trim?: string
|
||||
/**
|
||||
* Metadata the renderer already fetched when probing the URL (title/channel/
|
||||
* duration). When present, main uses it directly instead of spawning a second
|
||||
|
||||
Reference in New Issue
Block a user