Feat/binary management and library scale #6

Merged
debont80 merged 9 commits from feat/binary-management-and-library-scale into main 2026-06-26 12:44:12 -04:00
18 changed files with 994 additions and 83 deletions
Showing only changes of commit 9ac11ceb6c - Show all commits
+17 -17
View File
@@ -91,14 +91,14 @@ user indexes many large channels and the JSON files get unwieldy (noted as a Pha
Make AeroFetch able to *enumerate and remember* a whole channel without downloading anything
yet. This is the prerequisite for every later phase.
- [ ] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`,
- [x] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`,
`/channel/<id>`, `/c/<name>`, `/user/<name>`) resolves to a new `kind: 'channel'`
`ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the
nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists →
videos: probe `…/playlists` (flat) for the playlist list, plus a synthetic **"Uploads"**
playlist from `…/videos` for videos that belong to no playlist. Lazy-probe each
playlist's video list on demand rather than all up front.
- [ ] **Persisted index.** New `src/main/sources.ts` (plain JSON, mirrors `src/main/history.ts`)
- [x] **Persisted index.** New `src/main/sources.ts` (plain JSON, mirrors `src/main/history.ts`)
storing `Source` + `MediaItem` records. New IPC types in `src/shared/ipc.ts`:
```ts
interface Source {
@@ -122,11 +122,11 @@ yet. This is the prerequisite for every later phase.
filePath?: string
}
```
- [ ] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem`
- [x] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem`
(keyed by video id); first playlist seen wins the folder assignment (surface this choice
in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in
no real playlist.
- [ ] **Index job.** Async indexing in main (not blocking the UI), pushing progress over a new
- [x] **Index job.** Async indexing in main (not blocking the UI), pushing progress over a new
IPC channel (`index:progress`) the way `download.ts` pushes download events — "indexed
N of M playlists." Reuse `cleanError` and the same `assertHttpUrl` guard.
@@ -134,13 +134,13 @@ yet. This is the prerequisite for every later phase.
Turn a `MediaItem` into a real per-item download that lands in `Channel / Playlist / Title`.
- [ ] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through
- [x] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through
`buildCommand` (`src/main/download.ts`). Add an `outputSubdir` (or reuse `outputDir` with a
joined subpath) so each `MediaItem` downloads into
`<root>/<Channel>/<Playlist>/<NNN> - <Title>.ext`. The `NNN` index comes from
`MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is
empty under the per-item `--no-playlist` path that AeroFetch keeps using.
- [ ] **Directory sanitizer.** A small helper in `buildArgs.ts` that strips Windows-illegal
- [x] **Directory sanitizer.** A small helper in `buildArgs.ts` that strips Windows-illegal
directory chars (`< > : " / \ | ? *`, trailing dots/spaces, reserved names like `CON`).
Critical because `--restrict-filenames` only sanitizes the *filename* yt-dlp generates,
not the directory segments AeroFetch constructs from channel/playlist names.
@@ -158,31 +158,31 @@ Turn a `MediaItem` into a real per-item download that lands in `Channel / Playli
The new surface where channels live. Everything below feeds the **existing** download queue.
- [ ] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside
- [x] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside
Downloads / History / Settings. Lists added Sources.
- [ ] **Source detail — playlist/video tree.** A collapsible `Channel → Playlist → Video` tree
- [x] **Source detail — playlist/video tree.** A collapsible `Channel → Playlist → Video` tree
showing each `MediaItem`'s state (indexed · pending · downloading · downloaded · error),
reusing the checkbox-selection pattern already in `DownloadBar.tsx`'s playlist panel.
Per-playlist select-all, live counts.
- [ ] **"Download all pending" → existing queue, batched.** Enqueue selected `MediaItem`s via
- [x] **"Download all pending" → existing queue, batched.** Enqueue selected `MediaItem`s via
the existing `addFromUrl` path, **a batch at a time** (e.g. 50) so the queue/store never
holds the entire channel at once — the live queue stays small while the Library view is the
source of truth for the full list. Completion flips `MediaItem.downloaded = true` and
records to history exactly as today.
- [ ] **New `useSources` store** (`src/renderer/src/store/sources.ts`, mirrors
- [x] **New `useSources` store** (`src/renderer/src/store/sources.ts`, mirrors
`store/downloads.ts` / `store/history.ts`) plus its IPC bridge. Browser-preview seed data
so the view is demoable without Electron (same convention as `downloads.ts`'s `PREVIEW`).
- [ ] **Risk to watch:** rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and,
- [x] **Risk to watch:** rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and,
if JSON-store load times bite, revisit the Phase-D better-sqlite3 decision.
## Phase I — Incremental sync & dedup ✅ COMPLETE
Re-running a channel should grab only what's new — the everyday media-manager loop.
- [ ] **Re-index = diff.** Re-indexing a Source compares freshly enumerated video ids against the
- [x] **Re-index = diff.** Re-indexing a Source compares freshly enumerated video ids against the
persisted `MediaItem`s and marks only the new ones; existing records (and their
`downloaded` state) are preserved. "X new since last sync" badge.
- [ ] **"Download new only."** One action that enqueues just the un-downloaded `MediaItem`s,
- [x] **"Download new only."** One action that enqueues just the un-downloaded `MediaItem`s,
backed by the existing **`--download-archive`** setting (`Settings.downloadArchive`,
`getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index
can't re-download.
@@ -195,14 +195,14 @@ Pinchflat's headline feature: a Source you *watch*, that downloads new uploads o
AeroFetch already has the Windows pieces for this elsewhere in this workspace (Task Scheduler +
single-instance Mutex + a headless run mode — see the DashMail/Weather Radar patterns).
- [ ] **Watched sources.** A `Source.watched` flag + an "auto-download new" toggle per source.
- [ ] **Fast indexing via RSS.** YouTube exposes a per-channel Atom feed
- [x] **Watched sources.** A `Source.watched` flag + an "auto-download new" toggle per source.
- [x] **Fast indexing via RSS.** YouTube exposes a per-channel Atom feed
(`https://www.youtube.com/feeds/videos.xml?channel_id=<id>`) — a cheap check for new
uploads without a full yt-dlp scan. **Caveat:** the feed only carries the latest ~15
videos, so RSS is for *staying current*, not initial indexing (which stays a full
Phase-F scan). Pinchflat draws the same line ("fast indexing is not recommended for
playlists / initial scans").
- [ ] **Scheduled headless run.** A `--sync` CLI entry that indexes all watched sources and
- [x] **Scheduled headless run.** A `--sync` CLI entry that indexes all watched sources and
enqueues new items, wired to **Windows Task Scheduler** (reuse the
single-instance-lock already added in Phase E so a scheduled run hands off to a running
window instead of double-launching). Notify on new downloads via the existing
@@ -213,7 +213,7 @@ single-instance Mutex + a headless run mode — see the DashMail/Weather Radar p
Make AeroFetch's output drop-in for Jellyfin/Plex/Kodi the way Pinchflat does — relevant
because organized channel folders are exactly what those servers ingest.
- [ ] **Sidecar metadata.** `--write-info-json`, `--write-thumbnail`, `--write-description`,
- [x] **Sidecar metadata.** `--write-info-json`, `--write-thumbnail`, `--write-description`,
and optional Kodi/Jellyfin `.nfo` generation. Most of this is already expressible through
Phase C custom-command args; a checkbox group in the output settings makes it first-class.
+63 -39
View File
@@ -166,7 +166,7 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes
non-zero yt-dlp exit); **Settings → Diagnostics** lists recent entries with
**Copy full report** and **Clear log**.
## Phase E — Theming & platform polish
## Phase E — Theming & platform polish ✅ COMPLETE
Some items are Android-specific in Seal and adapted to Windows here.
@@ -187,10 +187,6 @@ Some items are Android-specific in Seal and adapted to Windows here.
auto. Verified in the UI preview (typecheck + `npm run test` clean): default Toffee,
switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar
toggle's break-out-of-system behavior all checked visually.
- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional — deferred,
since shipping ~40 real translations isn't something to fake; doing this properly means
wiring up an i18n library + extracting every string now, with translation content
arriving incrementally from contributors.
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
(non-MSIX) app can't register as an actual Share Target:
- **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at
@@ -274,49 +270,77 @@ YTDLnis's signature differentiator. The keyframe plumbing already exists in Aero
(`--force-keyframes-at-cuts` is emitted today for SponsorBlock-remove in
`src/main/buildArgs.ts`), so this is cheaper than it looks.
- [ ] **Trim / cut downloads.** Download only part of a video, or remove segments — by
**timestamp** or by **chapter**, with multiple cuts allowed. → yt-dlp
`--download-sections "*START-END"` (repeatable) + `--force-keyframes-at-cuts`.
Chapters come free from the probe's `yt-dlp -J` `chapters[]` array (`src/main/probe.ts`).
New fields on `DownloadOptions` (or a per-download override), surfaced as a **Trim**
panel in `src/renderer/src/components/DownloadBar.tsx`. *v1 can skip a scrubber/preview
player — timestamp inputs + checkboxes of the probed chapter list is enough. Smoke-test
the exact multi-section output behaviour (one file vs. per-section) the same way
Phases AC smoke-tested live yt-dlp.*
- [ ] **Split by chapters into separate files.**`--split-chapters` (already flagged as a
- [x] **Trim / cut downloads.** Keep only chosen time ranges (multiple allowed). →
`parseTrimSections` (`src/main/buildArgs.ts`) normalises raw user text into yt-dlp
`--download-sections "*START-END"` specs (+ `--force-keyframes-at-cuts`, deduped against
SponsorBlock-remove). Threaded as `StartDownloadOptions.trim` through
`store/downloads.ts`, surfaced as a collapsible **Trim** panel in `DownloadBar.tsx`
(single downloads only). *Implemented + unit-tested (`buildArgs.test.ts` — 7 new tests;
typecheck + test green). Live smoke test pending, plus two follow-ups: timestamp-range
only so far (chapter-based cuts via the probe `chapters[]` array, and a scrubber/preview
player, still TODO).*
- [x] **Split by chapters into separate files.**`--split-chapters` (already flagged as a
TODO in Phase A above). One toggle on `DownloadOptions` +
`src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. Uses an
`--output chapter:` template for the per-chapter filenames.
`src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. yt-dlp
keeps the full file and writes per-chapter files via its default `chapter:` template.
*Implemented + unit-tested (`buildArgs.test.ts`, typecheck + test green); live smoke test
still pending.*
- [ ] **Metadata editing before download.** Change title / author / artist pre-download
(YTDLnis: "modify metadata such as title and author"). → `--parse-metadata` /
`--ppa "Metadata:-metadata title=…"`; fits the audio path of `DownloadOptions`. Good for
building a clean music library where the source title is messy.
`--replace-in-metadata`; fits the audio path. Good for building a clean music library
where the source title is messy. *Held: the exact set-a-literal recipe is fragile
(`--parse-metadata FROM:TO` splits on a colon that titles often contain;
`--replace-in-metadata FIELD REGEX REPLACE` has empty-field + regex-replacement edge
cases) — confirm against a live yt-dlp run before wiring it, rather than guessing the
flag.*
## Phase M — Queue & daily-use UX
## Phase M — Queue & daily-use UX ✅ COMPLETE
Neither Seal nor Pinchflat covers these; they're what makes the queue pleasant to live in.
Today `src/renderer/src/store/downloads.ts` has cancel + retry only.
- [ ] **Pause / resume individual downloads.** → yt-dlp resumes `.part` files via `--continue`
(already the default). Pause = kill the child (existing `taskkill /pid /T /F` path) but
keep the partial + mark the item `paused`; resume = relaunch the same item, which
continues. New `paused` state in the queue store.
- [ ] **Reorder / prioritize the queue.** Drag a queued item up. The renderer scheduler
(`pump()` promotes oldest-queued-first) already treats the queued array as the order, so
this is reordering that array.
- [ ] **Aggregate progress + total ETA + combined speed.** A header strip on the Downloads
- [x] **Pause / resume individual downloads.** → yt-dlp resumes `.part` files via `--continue`
(already the default). *Done: `pauseDownload()` (`src/main/download.ts`) reuses the
`taskkill /T /F` tree-kill via a new `killTree` helper but flags the record `paused` so
the close/error handlers stay silent (no error event/log/notification); new
`download:pause` IPC + preload bridge. Renderer `pause`/`resume` (`store/downloads.ts`) +
a `paused` status: pause frees a concurrency slot, resume re-queues WITHOUT resetting
progress so the relaunch continues the partial. Pause/Resume buttons + a frozen progress
row in `QueueItem.tsx`; paused items survive "Clear finished". typecheck + test green;
live resume-continues-the-.part still wants a smoke test.*
- [x] **Reorder / prioritize the queue.** *Done as a **"Download next"** action rather than
drag: since the queue renders newest-first while `pump()` promotes oldest-first, a
reposition primitive is clearer than drag. `prioritize(id)` (`store/downloads.ts`) moves a
queued item to the front of the promotion order; an up-arrow button on queued rows
(`QueueItem.tsx`).*
- [x] **Aggregate progress + total ETA + combined speed.** A header strip on the Downloads
view summing bytes/speed across active items. Feeds straight into Phase O's taskbar
progress bar.
- [ ] **Drag-and-drop a link or `.url` file onto the window.** A window `drop` handler that
accepts `text/uri-list` and Internet-Shortcut files, reusing `extractIncomingUrl()`
from `src/main/deeplink.ts` (already validates http(s)-only).
- [ ] **Retry all failed.** One action that re-enqueues every entry in `src/main/errorlog.ts`.
- [ ] **Duplicate detection.** Warn ("you already have this") when a URL/video-id is already in
history or the active queue before enqueuing.
- [ ] **Per-download scheduling + "save for later."** YTDLnis lets you "schedule by date and
time" and park items. Add `scheduledFor?: number` + a `saved` state to `DownloadItem`;
`pump()` gains a time gate before promotion. Distinct from the existing background
`--sync` schedule (Phase J), which is for *sources*, not one-off items.
progress bar. *Done: pure `summarizeQueue` (`store/queueStats.ts`) → a strip in
`DownloadsView.tsx` (overall %, combined speed, longest-ETA "time left"); unit-tested
(`test/queueStats.test.ts`). A true remaining-bytes combined ETA is still out of reach —
we don't track per-item sizes — so "time left" is the longest single ETA.*
- [x] **Drag-and-drop a link or `.url` file onto the window.** *Done: the download card
(`DownloadBar.tsx`) handles `onDragOver`/`onDrop` with a dashed-outline drag highlight,
accepting `text/uri-list`/`text/plain` (`firstUrl`) and dropped Windows `.url`
Internet-Shortcut files (`parseUrlFile`, read via `File.text()`); both http(s)-validated by
the existing `looksLikeUrl`. A dropped link fills the URL field.*
- [x] **Retry all failed.** One action that re-enqueues every failed item. *Done:
`retryAll()` in `store/downloads.ts` re-queues all `error` items + a "Retry all failed (N)"
button in `DownloadsView.tsx`. (Re-queues live queue items rather than reading
`errorlog.json` — same effect for the on-screen failures.)*
- [x] **Duplicate detection.** Warn ("you already have this") before enqueuing a URL already in
the active queue. *Done: pure `sameVideo()` (`store/queueStats.ts`, YouTube-id aware +
normalised-URL fallback) drives a "Already in your queue — Download anyway?" banner in
`DownloadBar.tsx`; unit-tested. Checks the live queue (which still holds recently-completed
items); cross-checking `history.json` for older downloads is a possible extension.*
- [x] **Per-download scheduling + "save for later."** *Done: a `saved` status + optional
`scheduledFor` (`store/downloads.ts`); `pump()` ignores `saved` (the time gate), and a 15s
ticker promotes due scheduled items to `queued`. UI: a **Schedule** panel with a native
`datetime-local` input in `DownloadBar.tsx` (future time parks the new download as
scheduled), plus per-row **Save for later** / **Add to queue** actions in `QueueItem.tsx`;
saved items survive "Clear finished". Distinct from the Phase J `--sync` schedule (which is
for *sources*). **Limitation: the queue isn't persisted, so a schedule only fires while
AeroFetch is running** — noted in the UI hint and the code.*
## Phase N — Power-user surface
+37
View File
@@ -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
View File
@@ -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
const pid = rec.child.pid
if (pid != null) {
// Kill the whole tree (/T) so the spawned ffmpeg child dies too. Resolve
// 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) {
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
View File
@@ -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'))
+1
View File
@@ -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),
+3
View File
@@ -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),
+222 -6
View File
@@ -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}
+53 -2
View File
@@ -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,14 +83,38 @@ 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>
<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 ? (
<div className={styles.empty}>
@@ -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'
+87
View File
@@ -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
+1
View File
@@ -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 () => '',
+142 -3
View File
@@ -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))
+139
View File
@@ -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)
}
+11
View File
@@ -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
+66
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
buildArgs,
parseExtraArgs,
parseTrimSections,
selectExtraArgs,
formatCommandLine,
sanitizeDirSegment,
@@ -460,6 +461,71 @@ describe('sidecar files', () => {
})
})
describe('chapters', () => {
it('emits --split-chapters when splitChapters is on', () => {
expect(build(opts(), dlo({ splitChapters: true }))).toContain('--split-chapters')
})
it('does not emit --split-chapters by default', () => {
expect(build(opts(), dlo())).not.toContain('--split-chapters')
})
it('emits --split-chapters independently of --embed-chapters', () => {
const argv = build(opts(), dlo({ splitChapters: true, embedChapters: false }))
expect(argv).toContain('--split-chapters')
expect(argv).not.toContain('--embed-chapters')
})
})
describe('parseTrimSections', () => {
it('normalises plain time ranges to *START-END specs', () => {
expect(parseTrimSections('1:30-2:00')).toEqual(['*1:30-2:00'])
expect(parseTrimSections('90-120')).toEqual(['*90-120'])
expect(parseTrimSections('0:00:10.5-0:00:20')).toEqual(['*0:00:10.5-0:00:20'])
})
it('splits on commas and newlines and trims whitespace', () => {
expect(parseTrimSections(' 0:30-1:00 , 2:00-2:30 \n 3:00-3:30 ')).toEqual([
'*0:30-1:00',
'*2:00-2:30',
'*3:00-3:30'
])
})
it('keeps an existing leading * but does not double it', () => {
expect(parseTrimSections('*1:00-2:00')).toEqual(['*1:00-2:00'])
})
it('drops malformed tokens so garbage never reaches the argv', () => {
expect(parseTrimSections('not-a-range')).toEqual([])
expect(parseTrimSections('1:30')).toEqual([]) // no end
expect(parseTrimSections('')).toEqual([])
expect(parseTrimSections(undefined)).toEqual([])
})
})
describe('trim (--download-sections)', () => {
it('emits a --download-sections spec per range plus --force-keyframes-at-cuts', () => {
const argv = build(opts({ trim: '0:30-1:00, 2:00-2:30' }), dlo())
expect(hasSeq(argv, '--download-sections', '*0:30-1:00')).toBe(true)
expect(hasSeq(argv, '--download-sections', '*2:00-2:30')).toBe(true)
expect(argv).toContain('--force-keyframes-at-cuts')
})
it('emits nothing when there is no trim', () => {
const argv = build(opts(), dlo())
expect(argv).not.toContain('--download-sections')
})
it('does not double --force-keyframes-at-cuts when SponsorBlock-remove is also on', () => {
const argv = build(
opts({ trim: '0:30-1:00' }),
dlo({ sponsorBlock: true, sponsorBlockMode: 'remove', sponsorBlockCategories: ['sponsor'] })
)
expect(argv.filter((a) => a === '--force-keyframes-at-cuts')).toHaveLength(1)
})
})
// --- Collection folder paths (Phase G, media-manager) -----------------------
describe('sanitizeDirSegment', () => {
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest'
import { summarizeQueue, sameVideo } from '../src/renderer/src/store/queueStats'
import type { DownloadItem } from '../src/renderer/src/store/downloads'
// Minimal item factory — only the fields summarizeQueue reads.
function item(overrides: Partial<DownloadItem>): DownloadItem {
return {
id: Math.random().toString(36).slice(2),
url: 'https://youtube.com/watch?v=x',
title: 'x',
kind: 'video',
quality: '1080p',
status: 'queued',
progress: 0,
...overrides
}
}
describe('summarizeQueue', () => {
it('counts statuses and flags active', () => {
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.5 }),
item({ status: 'queued' }),
item({ status: 'error' }),
item({ status: 'completed', progress: 1 }),
item({ status: 'canceled' })
])
expect(s.downloading).toBe(1)
expect(s.queued).toBe(1)
expect(s.failed).toBe(1)
expect(s.active).toBe(true)
})
it('is inactive and zeroed when nothing is downloading or queued', () => {
const s = summarizeQueue([item({ status: 'completed', progress: 1 })])
expect(s.active).toBe(false)
expect(s.progress).toBe(0)
expect(s.speedLabel).toBe('')
expect(s.etaLabel).toBe('')
})
it('averages progress over downloading + queued (queued counts as 0%)', () => {
// one at 80%, one queued at 0% → 40%
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.8 }),
item({ status: 'queued' })
])
expect(s.progress).toBeCloseTo(0.4, 5)
})
it('sums download speeds across MB/s and MiB/s strings', () => {
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.1, speed: '4.0 MB/s' }),
item({ status: 'downloading', progress: 0.1, speed: '2000 KiB/s' })
])
// 4.0 MB/s + ~2.0 MB/s ≈ 6.0 MB/s
expect(s.speedLabel).toBe('6.0 MB/s')
})
it('reports the longest active ETA as the time left', () => {
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.5, eta: '0:30' }),
item({ status: 'downloading', progress: 0.2, eta: '2:10' })
])
expect(s.etaLabel).toBe('2:10')
})
})
describe('sameVideo', () => {
it('matches identical URLs', () => {
expect(sameVideo('https://x.com/a', 'https://x.com/a')).toBe(true)
})
it('matches YouTube links by video id across watch/youtu.be/extra params', () => {
expect(
sameVideo(
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://youtu.be/dQw4w9WgXcQ'
)
).toBe(true)
expect(
sameVideo(
'https://youtube.com/watch?v=dQw4w9WgXcQ&list=PL123',
'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
)
).toBe(true)
})
it('does not match different videos', () => {
expect(
sameVideo(
'https://youtube.com/watch?v=aaaaaaaaaaa',
'https://youtube.com/watch?v=bbbbbbbbbbb'
)
).toBe(false)
})
it('normalises www. and trailing slash for non-YouTube URLs', () => {
expect(sameVideo('https://www.vimeo.com/123/', 'https://vimeo.com/123')).toBe(true)
})
})