Merge pull request 'Feat/binary management and library scale' (#6) from feat/binary-management-and-library-scale into main

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-06-26 12:44:12 -04:00
40 changed files with 3128 additions and 255 deletions
+20 -17
View File
@@ -16,6 +16,9 @@ Sources: [Pinchflat GitHub](https://github.com/kieraneglin/pinchflat) ·
[Pinchflat architecture (DeepWiki)](https://deepwiki.com/kieraneglin/pinchflat) · [Pinchflat architecture (DeepWiki)](https://deepwiki.com/kieraneglin/pinchflat) ·
[Pinchflat FAQ — indexing](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions). [Pinchflat FAQ — indexing](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions).
> **See also:** [ROADMAP.md](ROADMAP.md) → **Post-parity** — YTDLnis-derived editing features,
> Windows-native polish, and reliability work, Phases L onward.
--- ---
## Implementation status — Phases FK shipped (2026-06-23) ## Implementation status — Phases FK shipped (2026-06-23)
@@ -88,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 Make AeroFetch able to *enumerate and remember* a whole channel without downloading anything
yet. This is the prerequisite for every later phase. 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'` `/channel/<id>`, `/c/<name>`, `/user/<name>`) resolves to a new `kind: 'channel'`
`ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the `ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the
nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists → nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists →
videos: probe `…/playlists` (flat) for the playlist list, plus a synthetic **"Uploads"** 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 from `…/videos` for videos that belong to no playlist. Lazy-probe each
playlist's video list on demand rather than all up front. 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`: storing `Source` + `MediaItem` records. New IPC types in `src/shared/ipc.ts`:
```ts ```ts
interface Source { interface Source {
@@ -119,11 +122,11 @@ yet. This is the prerequisite for every later phase.
filePath?: string 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 (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 in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in
no real playlist. 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 IPC channel (`index:progress`) the way `download.ts` pushes download events — "indexed
N of M playlists." Reuse `cleanError` and the same `assertHttpUrl` guard. N of M playlists." Reuse `cleanError` and the same `assertHttpUrl` guard.
@@ -131,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`. 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 `buildCommand` (`src/main/download.ts`). Add an `outputSubdir` (or reuse `outputDir` with a
joined subpath) so each `MediaItem` downloads into joined subpath) so each `MediaItem` downloads into
`<root>/<Channel>/<Playlist>/<NNN> - <Title>.ext`. The `NNN` index comes from `<root>/<Channel>/<Playlist>/<NNN> - <Title>.ext`. The `NNN` index comes from
`MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is `MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is
empty under the per-item `--no-playlist` path that AeroFetch keeps using. 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`). directory chars (`< > : " / \ | ? *`, trailing dots/spaces, reserved names like `CON`).
Critical because `--restrict-filenames` only sanitizes the *filename* yt-dlp generates, Critical because `--restrict-filenames` only sanitizes the *filename* yt-dlp generates,
not the directory segments AeroFetch constructs from channel/playlist names. not the directory segments AeroFetch constructs from channel/playlist names.
@@ -155,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. 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. 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), showing each `MediaItem`'s state (indexed · pending · downloading · downloaded · error),
reusing the checkbox-selection pattern already in `DownloadBar.tsx`'s playlist panel. reusing the checkbox-selection pattern already in `DownloadBar.tsx`'s playlist panel.
Per-playlist select-all, live counts. 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 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 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 source of truth for the full list. Completion flips `MediaItem.downloaded = true` and
records to history exactly as today. 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 `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`). 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. if JSON-store load times bite, revisit the Phase-D better-sqlite3 decision.
## Phase I — Incremental sync & dedup ✅ COMPLETE ## Phase I — Incremental sync & dedup ✅ COMPLETE
Re-running a channel should grab only what's new — the everyday media-manager loop. 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 persisted `MediaItem`s and marks only the new ones; existing records (and their
`downloaded` state) are preserved. "X new since last sync" badge. `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`, backed by the existing **`--download-archive`** setting (`Settings.downloadArchive`,
`getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index `getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index
can't re-download. can't re-download.
@@ -192,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 + 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). 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. - [x] **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] **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 (`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 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 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 Phase-F scan). Pinchflat draws the same line ("fast indexing is not recommended for
playlists / initial scans"). 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 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 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 window instead of double-launching). Notify on new downloads via the existing
@@ -210,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 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. 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 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. Phase C custom-command args; a checkbox group in the output settings makes it first-class.
+209 -5
View File
@@ -13,6 +13,9 @@ Sources: [Seal GitHub](https://github.com/JunkFood02/Seal) ·
> AeroFetch from a one-shot downloader into a [Pinchflat](https://github.com/kieraneglin/pinchflat)-style > AeroFetch from a one-shot downloader into a [Pinchflat](https://github.com/kieraneglin/pinchflat)-style
> media manager (index entire channels → organize into `Channel / Playlist / Title` folders → > media manager (index entire channels → organize into `Channel / Playlist / Title` folders →
> keep in sync), reusing the existing queue/history/options rather than replacing them. > keep in sync), reusing the existing queue/history/options rather than replacing them.
>
> **Post-parity work** (YTDLnis-derived editing features + Windows-native/UX polish + reliability)
> is tracked at the end of this document under **Post-parity** (Phases L onward).
--- ---
@@ -163,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 non-zero yt-dlp exit); **Settings → Diagnostics** lists recent entries with
**Copy full report** and **Clear log**. **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. Some items are Android-specific in Seal and adapted to Windows here.
@@ -184,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, 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 switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar
toggle's break-out-of-system behavior all checked visually. 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 - [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
(non-MSIX) app can't register as an actual Share Target: (non-MSIX) app can't register as an actual Share Target:
- **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at - **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at
@@ -234,3 +233,208 @@ Some items are Android-specific in Seal and adapted to Windows here.
+ Explorer "Send to" integration (a true Share Target needs an MSIX package, out of scope + Explorer "Send to" integration (a true Share Target needs an MSIX package, out of scope
for this app's NSIS/portable distribution). for this app's NSIS/portable distribution).
- F-Droid distribution → N/A (AeroFetch ships NSIS + portable). - F-Droid distribution → N/A (AeroFetch ships NSIS + portable).
---
# Post-parity — editing, native polish & reliability
With both parity tracks complete — Seal (Phases AE above) and the
[Pinchflat media-manager roadmap](ROADMAP-PINCHFLAT.md) (Phases FK ✅) — this final track
collects what's left, from two sources:
1. A **feature study of [YTDLnis](https://github.com/deniscerri/ytdlnis)** (the other major
Android yt-dlp frontend), keeping only the features AeroFetch doesn't already have.
2. A **Windows-native + daily-use UX gap analysis** of AeroFetch itself (things neither Seal
nor Pinchflat cover, but that a desktop app should do).
Nothing here is built yet — every box is unchecked. Phases continue the existing lettering
(AE Seal, FK Pinchflat) from **L onward**, and each item carries the yt-dlp flag and the
AeroFetch file it touches so it can be driven straight from the existing `buildArgs` /
`DownloadOptions` / queue plumbing rather than new infrastructure.
Sources: [YTDLnis GitHub](https://github.com/deniscerri/ytdlnis) ·
[YTDLnis docs](https://ytdlnis.org/) · [YTDLnis changelogs](https://ytdlnis.org/changelogs/) ·
[F-Droid listing](https://f-droid.org/packages/com.deniscerri.ytdl/).
### Already at parity with YTDLnis (do **not** rebuild)
Format/quality/container picking · SponsorBlock (remove/mark + embed-as-chapters) · embed
subtitles/metadata/chapters · cookies + login · custom command templates · backup/restore ·
**Observe Sources** (= our Phase J watched sources + RSS) · incognito mode · history with
filter + bulk re-download · filename templating · self-managing yt-dlp + ffmpeg binaries.
AeroFetch is even with, or ahead of, YTDLnis on all of these.
## Phase L — Media editing (YTDLnis headline features)
YTDLnis's signature differentiator. The keyframe plumbing already exists in AeroFetch
(`--force-keyframes-at-cuts` is emitted today for SponsorBlock-remove in
`src/main/buildArgs.ts`), so this is cheaper than it looks.
- [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. 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` /
`--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 ✅ 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.
- [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. *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 ✅ COMPLETE
YTDLnis leans hard into this; AeroFetch had the building blocks (command preview, templates,
per-item `options`) but not the surfaces. typecheck + test + `npm run build` clean.
- [x] **Built-in terminal mode.** *Done: `src/main/terminal.ts` runs the bundled yt-dlp with
raw user args (binary fixed; gated on `customCommandEnabled`, enforced in main), streaming
stdout/stderr line-by-line over a new `terminal:output` channel (`terminal:run`/`cancel`
IPC + preload). New `TerminalView.tsx` (args box, Run/Stop/Clear, live log, Ctrl+Enter)
and a Terminal sidebar tab.*
- [x] **Per-playlist-item editing.** *Done: the playlist panel in `DownloadBar.tsx` gained a
per-row video/audio toggle + **All video** / **All audio** batch buttons; `addPlaylist`
enqueues via `addMany` with each entry's own kind (quality falls back to that kind's
default). Per-item exact-format selection (needs probing each entry) left as a further
extension.*
- [x] **Weighted format sorting ("format aspect importance").** *Done: a `formatSort` field on
`DownloadOptions` (raw yt-dlp `-S` string); when set it emits `-S <value>`, overriding the
codec tiebreaker (`buildArgs.ts`); an "advanced" input in `DownloadOptionsForm`. Unit-tested.*
- [x] **URL-regex template auto-matching.** *Done: `CommandTemplate.urlPattern` (regex);
`selectExtraArgs` auto-applies a matching template ahead of the global default
(`matchesUrl`, bad patterns never match), URL threaded via `resolveExtraArgs`. urlPattern
field in `TemplateManager` + persisted in `templates.ts`. Unit-tested.*
- [x] **Command palette + keyboard shortcuts.** *Done: a portal-free `CommandPalette.tsx`
(filter, ↑/↓, Enter, Esc) opened with Ctrl/Cmd+K from `App.tsx`; actions = navigate tabs,
new download (focuses the `aerofetch-url` input), toggle theme.*
## Phase O — Windows-native integration & discoverability ✅ COMPLETE
All in the main process. typecheck + test + `npm run build` clean.
- [x] **Taskbar progress bar.** *Done: renderer pushes the `summarizeQueue` aggregate over a new
`taskbar:progress` channel (App-level store subscription, no re-render);
`mainWindow.setProgressBar` with `mode: 'normal' | 'error'` (red when any failed) and `-1`
to clear when idle.*
- [x] **System tray + minimize-to-tray.** *Done: `src/main/tray.ts` builds a `Tray` (icon resolved
via `getAppIconPath()`; `build/icon.ico` added to electron-builder `extraResources` for
prod) with Show / Quit. A new `Settings.minimizeToTray` (Settings → Appearance) makes the
window's `close` hide to tray instead of quitting; `before-quit`/tray-Quit set an
`isQuitting` flag so real exits still work.*
- [x] **Jump list.** *Done: `app.setUserTasks` adds an "Open AeroFetch" task (focuses the
single instance). **Thumbnail toolbar buttons deferred** — they need per-button `NativeImage`
assets this repo doesn't have tooling to generate here.*
- [ ] **Taskbar overlay badge** (`win.setOverlayIcon`) showing active-download count / error state.
*Deferred: a numeric/status badge needs a drawn `NativeImage` (no canvas in main); the
taskbar progress bar's `error` mode already signals failures.*
- [x] **Settings discoverability.** *Done: a search box atop `SettingsView` filters the ~11 cards
live by matching each card's text (DOM `display` toggle keyed off the root's children — no
per-card refactor), with a "no settings match" hint.*
## Phase P — Reliability, longevity & trust ✅ (code shipped; cert + manual passes still required)
The least glamorous, highest-leverage track. The buildable code is done (typecheck + test +
build clean); the parts that inherently need a human or a paid cert are delivered as artifacts.
- [x] **PO Token / YouTube reliability plumbing.** *Done: `Settings.youtubePlayerClient` +
`youtubePoToken` → one `--extractor-args "youtube:player_client=…;po_token=…"` group
(`accessArgs` in `buildArgs.ts`, unit-tested), with "advanced" fields in Settings → Network.
Lets a power user switch extraction client (`web_safari`/`tv`/`mweb`) or paste a token.*
**Deferred: automatic WebView token minting** (the YTDLnis headline) — the hard part;
this is the argv plumbing it would feed. Cookies remain the easier bot-check fix.
- [x] **Verify the shipped-but-untested OS wiring.** *Can't be executed here (no real install).
Delivered as a release-gate checklist — [docs/SMOKE-TEST.md](docs/SMOKE-TEST.md) —
enumerating every untested path (cookies window, `aerofetch://` + Send-to, scheduled
`--sync` + RSS, aria2c, proxy, plus the new tray/taskbar/pause-resume/terminal) with exact
steps + expected results. **Still needs a human to run it.**
- [x] **Code signing.** *Build is signing-ready: electron-builder picks the cert up from
`CSC_LINK` / `CSC_KEY_PASSWORD` with no yml change (unset = unsigned, as today). Documented
in [docs/SIGNING.md](docs/SIGNING.md) (OV vs EV, local + CI, HSM/EV hook, signature
verification). **Actual signing needs a purchased certificate.***
- [ ] **i18n / language setting.** Still deferred — deliberately not faked. Shipping a language
picker that doesn't change anything would be misleading; doing it properly means wiring an
i18n library + extracting every string, with translations arriving incrementally. Tracked,
not started.
---
## Post-parity — what we reuse vs. build
**Reuse unchanged:** `buildArgs`/`buildCommand` flag emission, `DownloadOptions` +
`DownloadOptionsForm`, the renderer queue scheduler + concurrency cap + per-item `options`,
the persisted cookies `BrowserWindow`, the probe's `chapters[]`/format data, `errorlog.ts`,
`CommandTemplate`s, `extractIncomingUrl()`, and the self-managing binary updater.
**Build new:** the trim/split UI + `--download-sections`/`--split-chapters` wiring (L), the
pause/reorder/schedule queue states (M), the terminal view + per-item playlist editor (N), the
main-process taskbar/tray/jumplist integration (O), and the PO-token provider (P).
## Post-parity — suggested order
**L is the high-value headline** — trim + split-chapters share one UI surface and the keyframe
plumbing already exists, so they land cheaply and give AeroFetch YTDLnis's marquee capability.
Then **P's PO-token + verify-wiring** as a reliability block (keeps YouTube working and de-risks
shipped features). **M** and **O** are the daily-use polish and pair well (aggregate progress →
taskbar bar). **N** is the power-user surface, each item independently shippable. Each phase is
self-contained and rebuild-gated (`npm run typecheck` + `npm run test` + `npm run build`) the
same way Phases AK were.
+77
View File
@@ -0,0 +1,77 @@
# AeroFetch — Windows code signing
Unsigned builds run, but Windows SmartScreen shows a blue **"Windows protected your PC"**
prompt the first time each user runs the installer (they must click *More info → Run anyway*).
This is the single biggest first-run friction when sharing AeroFetch. Code signing removes it
(EV certificates remove it immediately; OV certificates build reputation over time/installs).
**This step needs a certificate that can't live in the repo**, so it isn't wired on by default.
The build is otherwise ready for it — electron-builder picks the cert up from environment
variables with no `electron-builder.yml` changes required.
## What you need
- An **Authenticode code-signing certificate** for Windows:
- **OV (Organization Validation)** — cheaper; SmartScreen reputation accrues as more users
install, so early downloads still see the prompt.
- **EV (Extended Validation)** — pricier, usually on a hardware token / cloud HSM; clears
SmartScreen immediately. Token-based EV can't be fed as a plain `.pfx` (see "HSM/EV" below).
- The cert as a password-protected `.pfx`/`.p12` (for OV), or an HSM/cloud-signing setup (EV).
## Signing an OV `.pfx` locally
electron-builder reads these env vars automatically:
```bash
# PowerShell
$env:CSC_LINK = "C:\path\to\codesign.pfx" # or a base64 string of the .pfx
$env:CSC_KEY_PASSWORD = "<pfx password>"
npm run build:win
```
```bash
# bash / CI
export CSC_LINK="$(base64 -w0 codesign.pfx)" # base64 is convenient for CI secrets
export CSC_KEY_PASSWORD="$PFX_PASSWORD"
npm run build:win
```
electron-builder then signs `AeroFetch Setup <ver>.exe`, the portable `.exe`, and the app
binary. No changes to `electron-builder.yml` are needed; if `CSC_LINK` is unset, the build
proceeds **unsigned** (current behavior).
## CI (GitHub Actions sketch)
Store the cert + password as encrypted secrets and export them before the build:
```yaml
- name: Build (signed)
env:
CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} # base64 of the .pfx
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
run: npm run build:win
```
## HSM / EV certificates
EV certs (and many newer OV certs) ship on a hardware token or cloud HSM and can't be exported
to a `.pfx`. Sign via a custom signing hook instead — set `win.sign` in `electron-builder.yml`
to a script that invokes your provider's tool (Azure Trusted Signing, DigiCert KeyLocker,
SSL.com eSigner, or `signtool` with the token). Each provider documents the exact `signtool`
invocation; electron-builder calls your hook once per artifact with the file path.
## Verifying a signature
```powershell
Get-AuthenticodeSignature ".\dist\AeroFetch Setup <ver>.exe" | Format-List
# Status should be 'Valid'; SignerCertificate should be your cert.
signtool verify /pa /v ".\dist\AeroFetch Setup <ver>.exe"
```
## Notes
- The LGPL ffmpeg + yt-dlp binaries shipped in `resources/bin` are third-party; signing the
AeroFetch artifacts doesn't (and needn't) re-sign them.
- Timestamp the signature (electron-builder does by default) so it stays valid after the cert
expires.
- Signing does **not** replace the smoke test — see [SMOKE-TEST.md](SMOKE-TEST.md).
+61
View File
@@ -0,0 +1,61 @@
# AeroFetch — manual smoke-test checklist
Some features can only be exercised against a **real install** (or a real yt-dlp run) —
they're main-process / Electron / OS-integration code that the Vite browser preview and the
unit suite can't reach. They're implemented and typecheck/test/build clean, but the roadmaps
flag them as needing a hands-on pass. Run this checklist after building
(`npm run build:win`) and installing the NSIS artifact (or launching the portable `.exe`).
Mark each ✅/❌ and note anything surprising.
## Downloads & post-processing (Phases A, L, M)
- [ ] **Basic download** — paste a URL, Fetch, pick a format, Download → file lands in
Documents\Video (or your folder), progress + speed update, "Completed".
- [ ] **Trim / cut** (Phase L) — set a section like `0:10-0:20`, download → output is ~10s and
starts/ends near the cut points (`--download-sections` + `--force-keyframes-at-cuts`).
- [ ] **Split by chapters** (Phase L) — on a video with chapters, enable it → per-chapter files
appear alongside the full file.
- [ ] **Pause / resume** (Phase M) — start a large download, Pause (process stops, `.part`
stays), Resume → it **continues** from where it stopped, not from 0% (yt-dlp `--continue`).
- [ ] **Scheduling** (Phase M) — schedule a download a couple of minutes out → it sits "Saved /
Scheduled", then auto-starts at the time (only while the app is running).
## Access & networking (Phase B, P)
- [ ] **Cookies — sign-in window** — Settings → Cookies → "Sign-in window", log into a site,
close it → "cookies saved" with a timestamp; a members-only/age-gated video then downloads.
- [ ] **Cookies — from browser** — pick an installed browser → a gated video downloads.
- [ ] **Proxy** — set a working proxy → downloads route through it (verify via the proxy logs).
- [ ] **aria2c** — drop `aria2c.exe` into `resources/bin`, enable it → multi-connection download
works; remove it → silently falls back to yt-dlp's downloader.
- [ ] **YouTube client / PO token** (Phase P) — set `youtubePlayerClient` (e.g. `web_safari`) →
the run includes `--extractor-args youtube:player_client=web_safari` (check Terminal/Preview).
## OS integration (Phase E, J, O)
- [ ] **`aerofetch://` protocol** — from a browser, open `aerofetch://download?url=<encoded>`
AeroFetch focuses and the URL appears in the "Link received" banner.
- [ ] **Explorer "Send to AeroFetch"** — drag a tab to the desktop to make a `.url`, right-click →
Send to → AeroFetch → the link is received.
- [ ] **Scheduled sync** (Phase J) — Settings → enable the daily sync → a Windows Task Scheduler
task exists; run it (or `AeroFetch.exe --sync`) → watched sources index and new items enqueue.
- [ ] **RSS fast-check** (Phase J) — "Check for new" on a watched channel → newly-posted videos
show as new without a full re-index.
- [ ] **System tray** (Phase O) — enable "Keep running in the tray", close the window → it hides
to the tray; tray click reopens; tray → Quit actually exits.
- [ ] **Taskbar progress** (Phase O) — during downloads the taskbar icon shows a progress bar;
it turns red if one fails and clears when the queue goes idle.
- [ ] **Jump list** (Phase O) — right-click the taskbar icon → "Open AeroFetch" focuses the app.
## Terminal (Phase N)
- [ ] Enable "Run custom commands", open Terminal, run `--version` → the version prints; run
`-F <url>` → the format table streams in; Stop cancels a long run.
---
If anything here fails, capture the Settings → Diagnostics "Copy full report" output and the
exact steps. These items have no automated coverage, so this checklist is the release gate for
them. See also [SIGNING.md](SIGNING.md) for the code-signing step that removes the SmartScreen
prompt for end users.
+4
View File
@@ -30,6 +30,10 @@ extraResources:
to: bin to: bin
filter: filter:
- '**/*' - '**/*'
# The app icon, copied so the runtime (system tray) can load it at
# <resources>/icon.ico — build/ itself isn't bundled into the app.
- from: build/icon.ico
to: icon.ico
win: win:
# Two artifacts: # Two artifacts:
+30 -2
View File
@@ -1,16 +1,17 @@
{ {
"name": "aerofetch", "name": "aerofetch",
"version": "0.1.0", "version": "0.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aerofetch", "name": "aerofetch",
"version": "0.1.0", "version": "0.4.1",
"dependencies": { "dependencies": {
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@fluentui/react-components": "^9.74.1", "@fluentui/react-components": "^9.74.1",
"@fluentui/react-icons": "^2.0.330", "@fluentui/react-icons": "^2.0.330",
"@tanstack/react-virtual": "^3.14.3",
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
@@ -3373,6 +3374,33 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.14.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz",
"integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.1",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
"integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aerofetch", "name": "aerofetch",
"version": "0.4.1", "version": "0.5.0",
"description": "A yt-dlp frontend for Windows", "description": "A yt-dlp frontend for Windows",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "AeroFetch", "author": "AeroFetch",
@@ -25,6 +25,7 @@
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@fluentui/react-components": "^9.74.1", "@fluentui/react-components": "^9.74.1",
"@fluentui/react-icons": "^2.0.330", "@fluentui/react-icons": "^2.0.330",
"@tanstack/react-virtual": "^3.14.3",
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
+33 -1
View File
@@ -13,10 +13,42 @@ export function getBinDir(): string {
return is.dev ? join(app.getAppPath(), 'resources', 'bin') : join(process.resourcesPath, 'bin') return is.dev ? join(app.getAppPath(), 'resources', 'bin') : join(process.resourcesPath, 'bin')
} }
export function getYtdlpPath(): string { /**
* The app icon (.ico), for the system tray. In dev it's the repo's build/icon.ico;
* in a packaged build, electron-builder's extraResources copies it to
* <resources>/icon.ico (see electron-builder.yml).
*/
export function getAppIconPath(): string {
return is.dev
? join(app.getAppPath(), 'build', 'icon.ico')
: join(process.resourcesPath, 'icon.ico')
}
/**
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
* from the read-only bundled seed in resources/bin. The managed copy is what
* actually gets spawned and self-updated (`--update-to`), so an app reinstall or
* portable re-extraction — which only ever replace the bundled seed — can never
* roll a freshly-updated yt-dlp back to a stale version. See ensureManagedYtdlp
* in ytdlp.ts, which seeds this from getBundledYtdlpPath() on first run.
*
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
*/
export function getManagedBinDir(): string {
return join(app.getPath('userData'), 'bin')
}
/** The bundled, read-only yt-dlp.exe seeded into the managed copy when missing. */
export function getBundledYtdlpPath(): string {
return join(getBinDir(), 'yt-dlp.exe') return join(getBinDir(), 'yt-dlp.exe')
} }
/** The managed (spawned + auto-updated) yt-dlp.exe under userData. */
export function getYtdlpPath(): string {
return join(getManagedBinDir(), 'yt-dlp.exe')
}
export function getFfmpegPath(): string { export function getFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe') return join(getBinDir(), 'ffmpeg.exe')
} }
+75 -4
View File
@@ -100,6 +100,10 @@ export interface AccessOptions {
restrictFilenames: boolean restrictFilenames: boolean
/** absolute path to a --download-archive file; omit to disable archive tracking */ /** absolute path to a --download-archive file; omit to disable archive tracking */
downloadArchivePath?: string downloadArchivePath?: string
/** YouTube extraction client override (e.g. 'web_safari'); empty/omitted = default */
youtubePlayerClient?: string
/** manually-supplied YouTube Proof-of-Origin token; empty/omitted = none */
youtubePoToken?: string
} }
// Tuned for typical CDN-served video: 16 connections split across 16 segments, // Tuned for typical CDN-served video: 16 connections split across 16 segments,
@@ -143,6 +147,7 @@ export function parseExtraArgs(raw: string): string[] {
* *
* - customCommandEnabled off → [] (always) * - customCommandEnabled off → [] (always)
* - perDownloadExtraArgs defined (even '') → those args * - perDownloadExtraArgs defined (even '') → those args
* - else a template whose urlPattern matches → that template's args (most specific)
* - else a matching defaultTemplateId → that template's args * - else a matching defaultTemplateId → that template's args
* - else → [] * - else → []
*/ */
@@ -150,10 +155,20 @@ export function selectExtraArgs(params: {
customCommandEnabled: boolean customCommandEnabled: boolean
perDownloadExtraArgs: string | undefined perDownloadExtraArgs: string | undefined
defaultTemplateId: string | null defaultTemplateId: string | null
templates: Pick<CommandTemplate, 'id' | 'args'>[] templates: Pick<CommandTemplate, 'id' | 'args' | 'urlPattern'>[]
/** the download URL, for urlPattern auto-matching */
url?: string
}): string[] { }): string[] {
if (!params.customCommandEnabled) return [] if (!params.customCommandEnabled) return []
if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs) if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs)
// A template whose urlPattern matches this URL auto-applies, ahead of the global
// default — it's the more specific choice.
if (params.url) {
const matched = params.templates.find(
(t) => t.urlPattern && matchesUrl(t.urlPattern, params.url!)
)
if (matched) return parseExtraArgs(matched.args)
}
if (params.defaultTemplateId) { if (params.defaultTemplateId) {
const tpl = params.templates.find((t) => t.id === params.defaultTemplateId) const tpl = params.templates.find((t) => t.id === params.defaultTemplateId)
if (tpl) return parseExtraArgs(tpl.args) if (tpl) return parseExtraArgs(tpl.args)
@@ -161,6 +176,39 @@ export function selectExtraArgs(params: {
return [] return []
} }
/** Case-insensitive regex test of a template's urlPattern; a bad pattern never matches. */
export function matchesUrl(pattern: string, url: string): boolean {
try {
return new RegExp(pattern, 'i').test(url)
} catch {
return false
}
}
/**
* 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 // Wrap a single argv token for human-readable display only (Phase C command
// preview) — never used to build the real argv that gets spawned. // preview) — never used to build the real argv that gets spawned.
function quoteForDisplay(arg: string): string { function quoteForDisplay(arg: string): string {
@@ -234,6 +282,12 @@ function accessArgs(a: AccessOptions): string[] {
else if (a.cookiesFile) args.push('--cookies', a.cookiesFile) else if (a.cookiesFile) args.push('--cookies', a.cookiesFile)
if (a.restrictFilenames) args.push('--restrict-filenames') if (a.restrictFilenames) args.push('--restrict-filenames')
if (a.downloadArchivePath) args.push('--download-archive', a.downloadArchivePath) if (a.downloadArchivePath) args.push('--download-archive', a.downloadArchivePath)
// YouTube reliability: combine the player-client + PO-token overrides into one
// `youtube:` extractor-args group (semicolon-separated, as yt-dlp expects).
const yt: string[] = []
if (a.youtubePlayerClient?.trim()) yt.push(`player_client=${a.youtubePlayerClient.trim()}`)
if (a.youtubePoToken?.trim()) yt.push(`po_token=${a.youtubePoToken.trim()}`)
if (yt.length > 0) args.push('--extractor-args', `youtube:${yt.join(';')}`)
return args return args
} }
@@ -250,17 +304,30 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
} }
// SponsorBlock. // SponsorBlock.
let forcedKeyframes = false
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) { if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
const cats = o.sponsorBlockCategories.join(',') const cats = o.sponsorBlockCategories.join(',')
if (o.sponsorBlockMode === 'remove') { if (o.sponsorBlockMode === 'remove') {
// Force keyframes at the cut points so segment boundaries are frame-accurate. // Force keyframes at the cut points so segment boundaries are frame-accurate.
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts') args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
forcedKeyframes = true
} else { } else {
args.push('--sponsorblock-mark', cats) 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') 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') if (o.embedMetadata) args.push('--embed-metadata')
// Media-server sidecar files (Phase K) — written next to the output file so // Media-server sidecar files (Phase K) — written next to the output file so
@@ -310,9 +377,13 @@ export function buildArgs(
} }
} else { } else {
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer) args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the // A raw format-sort string (advanced) wins outright; otherwise the codec
// pick toward the chosen codec without overriding the requested quality. // preference is a sort tiebreaker AFTER resolution/fps, nudging the pick toward
if (o.preferredVideoCodec !== 'any') { // the chosen codec without overriding the requested quality.
const sort = o.formatSort.trim()
if (sort) {
args.push('-S', sort)
} else if (o.preferredVideoCodec !== 'any') {
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
args.push('-S', `res,fps,vcodec:${token}`) args.push('-S', `res,fps,vcodec:${token}`)
} }
+42 -13
View File
@@ -11,6 +11,7 @@ import {
getSystem32Path getSystem32Path
} from './binaries' } from './binaries'
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings' import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { getCookiesFilePath } from './cookies' import { getCookiesFilePath } from './cookies'
import { listTemplates } from './templates' import { listTemplates } from './templates'
import { assertHttpUrl } from './url' import { assertHttpUrl } from './url'
@@ -37,6 +38,7 @@ import {
interface ActiveDownload { interface ActiveDownload {
child: ChildProcess child: ChildProcess
canceled: boolean canceled: boolean
paused: boolean
} }
const active = new Map<string, ActiveDownload>() const active = new Map<string, ActiveDownload>()
@@ -168,7 +170,8 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
customCommandEnabled: settings.customCommandEnabled, customCommandEnabled: settings.customCommandEnabled,
perDownloadExtraArgs: opts.extraArgs, perDownloadExtraArgs: opts.extraArgs,
defaultTemplateId: settings.defaultTemplateId, defaultTemplateId: settings.defaultTemplateId,
templates: listTemplates() templates: listTemplates(),
url: opts.url
}) })
} }
@@ -211,7 +214,9 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined, cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
cookiesFile, cookiesFile,
restrictFilenames: settings.restrictFilenames, restrictFilenames: settings.restrictFilenames,
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
youtubePlayerClient: settings.youtubePlayerClient,
youtubePoToken: settings.youtubePoToken
} }
const extraArgs = resolveExtraArgs(opts, settings) const extraArgs = resolveExtraArgs(opts, settings)
return buildArgs(opts, outputTemplate, options, getBinDir(), access, extraArgs) return buildArgs(opts, outputTemplate, options, getBinDir(), access, extraArgs)
@@ -240,11 +245,14 @@ export function startDownload(
wc: WebContents, wc: WebContents,
opts: StartDownloadOptions opts: StartDownloadOptions
): StartDownloadResult { ): StartDownloadResult {
// Self-heal the managed copy from the bundled seed before spawning, so a
// never-seeded or deleted yt-dlp.exe doesn't fail an otherwise-fine download.
ensureManagedYtdlp()
const ytdlp = getYtdlpPath() const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) { if (!existsSync(ytdlp)) {
return { return {
ok: false, ok: false,
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).` error: `yt-dlp.exe is missing and couldn't be restored from the bundle.\nReinstall AeroFetch, or drop yt-dlp.exe into resources/bin/ (see the README there).`
} }
} }
// ffmpeg is used by nearly every download (merge, audio extract, thumbnail/ // ffmpeg is used by nearly every download (merge, audio extract, thumbnail/
@@ -291,7 +299,7 @@ export function startDownload(
return { ok: false, error: (e as Error).message } 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) active.set(opts.id, rec)
// Title/channel/duration are kept locally so the completion notification and // Title/channel/duration are kept locally so the completion notification and
@@ -340,7 +348,8 @@ export function startDownload(
if (settled) return if (settled) return
settled = true settled = true
active.delete(opts.id) 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 }) send(wc, { type: 'error', id: opts.id, error: err.message })
logFailure(opts, resolvedTitle, err.message) logFailure(opts, resolvedTitle, err.message)
notify(wc, resolvedTitle ?? 'Download failed', err.message) notify(wc, resolvedTitle ?? 'Download failed', err.message)
@@ -351,7 +360,9 @@ export function startDownload(
if (settled) return if (settled) return
settled = true settled = true
active.delete(opts.id) 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) { if (code === 0) {
send(wc, { type: 'done', id: opts.id, filePath }) send(wc, { type: 'done', id: opts.id, filePath })
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.') notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
@@ -366,15 +377,12 @@ export function startDownload(
return { ok: true } return { ok: true }
} }
export function cancelDownload(id: string): void { // Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
const rec = active.get(id) // taskkill from System32 by absolute path, never the bare name, so a planted
if (!rec) return // taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
rec.canceled = true function killTree(rec: ActiveDownload): void {
const pid = rec.child.pid const pid = rec.child.pid
if (pid != null) { 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( execFile(
getSystem32Path('taskkill.exe'), getSystem32Path('taskkill.exe'),
['/pid', String(pid), '/T', '/F'], ['/pid', String(pid), '/T', '/F'],
@@ -385,3 +393,24 @@ export function cancelDownload(id: string): void {
rec.child.kill() 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)
}
+36
View File
@@ -0,0 +1,36 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getFfmpegPath, getFfprobePath } from './binaries'
import type { FfmpegVersionResult } from '@shared/ipc'
/**
* Read the version token from an ffmpeg/ffprobe binary. Both print a first line
* of the form "<tool> version <VERSION> Copyright ..." (e.g.
* "ffmpeg version 6.1.1-full_build-www.gyan.dev Copyright ..."), so we return the
* token after "version". Resolves null if the binary is absent, errors, times
* out, or the line doesn't parse — Settings then shows "not found" instead of
* failing. Unlike yt-dlp these are never self-updated, so there's no update path.
*/
function readToolVersion(path: string): Promise<string | null> {
if (!existsSync(path)) return Promise.resolve(null)
return new Promise((resolve) => {
execFile(path, ['-version'], { windowsHide: true, timeout: 15_000 }, (err, stdout) => {
if (err) {
resolve(null)
return
}
const firstLine = stdout.split('\n', 1)[0] ?? ''
const m = firstLine.match(/version\s+(\S+)/i)
resolve(m ? m[1] : null)
})
})
}
/** Versions of the bundled ffmpeg + ffprobe, read in parallel for the Settings panel. */
export async function getFfmpegVersions(): Promise<FfmpegVersionResult> {
const [ffmpeg, ffprobe] = await Promise.all([
readToolVersion(getFfmpegPath()),
readToolVersion(getFfprobePath())
])
return { ffmpeg, ffprobe }
}
+58 -3
View File
@@ -8,12 +8,15 @@ import {
type HistoryEntry, type HistoryEntry,
type CommandTemplate, type CommandTemplate,
type YtdlpUpdateChannel, type YtdlpUpdateChannel,
type SystemThemeInfo type SystemThemeInfo,
type TaskbarProgress
} from '@shared/ipc' } from '@shared/ipc'
import { getYtdlpVersion, updateYtdlp } from './ytdlp' import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp'
import { getFfmpegVersions } from './ffmpeg'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater' import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
import { probeMedia } from './probe' import { probeMedia } from './probe'
import { startDownload, cancelDownload, previewCommand } from './download' import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
import { runTerminal, cancelTerminal } from './terminal'
import { getSettings, setSettings, ensureMediaDirs } from './settings' import { getSettings, setSettings, ensureMediaDirs } from './settings'
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history' import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
import { listTemplates, saveTemplate, removeTemplate } from './templates' import { listTemplates, saveTemplate, removeTemplate } from './templates'
@@ -34,6 +37,7 @@ import {
import { indexSource } from './indexer' import { indexSource } from './indexer'
import { syncWatchedSources } from './sync' import { syncWatchedSources } from './sync'
import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule' import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule'
import { createTray, markQuitting, isQuitting } from './tray'
// Only one instance ever runs. A second launch — e.g. the OS invoking us again // Only one instance ever runs. A second launch — e.g. the OS invoking us again
// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to // for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
@@ -133,6 +137,15 @@ function createWindow(): void {
else win.show() else win.show()
}) })
// Minimize-to-tray: when enabled, closing the window hides it to the tray
// instead of quitting. A real quit (tray menu / before-quit) sets isQuitting().
win.on('close', (e) => {
if (getSettings().minimizeToTray && !isQuitting()) {
e.preventDefault()
win.hide()
}
})
win.on('closed', () => { win.on('closed', () => {
if (mainWindow === win) mainWindow = null if (mainWindow === win) mainWindow = null
}) })
@@ -190,6 +203,8 @@ function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion()) ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url)) ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => { ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
@@ -208,6 +223,11 @@ function registerIpcHandlers(): void {
return result return result
}) })
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id)) ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) =>
runTerminal(e.sender, id, args)
)
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads')) ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
@@ -312,6 +332,13 @@ function registerIpcHandlers(): void {
) )
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync()) ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled)) ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled))
// Reflect overall queue progress on the Windows taskbar (fire-and-forget).
ipcMain.on(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
if (!mainWindow || mainWindow.isDestroyed()) return
if (p.mode === 'none') mainWindow.setProgressBar(-1)
else mainWindow.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
})
} }
// Push OS theme/contrast changes to every window, and keep the native // Push OS theme/contrast changes to every window, and keep the native
@@ -352,12 +379,40 @@ if (isPrimaryInstance) {
registerSystemThemeBridge() registerSystemThemeBridge()
registerSendToShortcut() registerSendToShortcut()
createWindow() createWindow()
createTray(() => mainWindow)
// Taskbar right-click jump list: a quick "Open AeroFetch" task. Launching the
// exe again is caught by the single-instance lock, which focuses this window.
app.setUserTasks([
{
program: process.execPath,
arguments: '',
title: 'Open AeroFetch',
description: 'Open the AeroFetch window',
iconPath: process.execPath,
iconIndex: 0
}
])
// Keep yt-dlp current on its own: seed the managed copy, then (throttled to
// once a day) self-update it to the chosen channel so a stale binary can't
// silently cause YouTube 403s. Best-effort and off the critical path — it
// never blocks startup, and status is pushed to the window if it's listening.
runStartupYtdlpAutoUpdate((status) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
}
}).catch(() => {})
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow() if (BrowserWindow.getAllWindows().length === 0) createWindow()
}) })
}) })
// Any real quit path (tray "Quit", OS shutdown, the updater's app.quit) must set
// the quitting flag so the window's close handler exits instead of hiding to tray.
app.on('before-quit', () => markQuitting())
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit() app.quit()
+27 -1
View File
@@ -11,6 +11,7 @@ import {
COOKIE_BROWSERS, COOKIE_BROWSERS,
ACCENT_COLORS, ACCENT_COLORS,
DEFAULT_DOWNLOAD_OPTIONS, DEFAULT_DOWNLOAD_OPTIONS,
isYtdlpUpdateChannel,
type Settings, type Settings,
type DownloadOptions, type DownloadOptions,
type SponsorBlockCategory type SponsorBlockCategory
@@ -35,13 +36,21 @@ const DEFAULTS: Settings = {
useAria2c: false, useAria2c: false,
cookieSource: 'none', cookieSource: 'none',
cookiesBrowser: 'chrome', cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false, restrictFilenames: false,
downloadArchive: false, downloadArchive: false,
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
// nightly channel (fastest to follow YouTube changes that cause 403s).
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false, customCommandEnabled: false,
defaultTemplateId: null, defaultTemplateId: null,
notifyOnComplete: true, notifyOnComplete: true,
autoDownloadNew: true, autoDownloadNew: true,
hasCompletedOnboarding: false hasCompletedOnboarding: false,
minimizeToTray: false
} }
/** Fixed path for the --download-archive file; not user-configurable. */ /** Fixed path for the --download-archive file; not user-configurable. */
@@ -95,6 +104,7 @@ function sanitizeOptions(input: unknown): DownloadOptions {
preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never) preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never)
? o.preferredVideoCodec! ? o.preferredVideoCodec!
: d.preferredVideoCodec, : d.preferredVideoCodec,
formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort,
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles), embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
subtitleLanguages: subtitleLanguages:
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim() typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
@@ -105,6 +115,7 @@ function sanitizeOptions(input: unknown): DownloadOptions {
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove', sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
sponsorBlockCategories: cats, sponsorBlockCategories: cats,
embedChapters: bool(o.embedChapters, d.embedChapters), embedChapters: bool(o.embedChapters, d.embedChapters),
splitChapters: bool(o.splitChapters, d.splitChapters),
embedMetadata: bool(o.embedMetadata, d.embedMetadata), embedMetadata: bool(o.embedMetadata, d.embedMetadata),
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail), embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail), cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
@@ -193,12 +204,25 @@ export function setSettings(partial: Partial<Settings>): Settings {
case 'useAria2c': case 'useAria2c':
case 'restrictFilenames': case 'restrictFilenames':
case 'downloadArchive': case 'downloadArchive':
case 'autoUpdateYtdlp':
case 'customCommandEnabled': case 'customCommandEnabled':
case 'notifyOnComplete': case 'notifyOnComplete':
case 'autoDownloadNew': case 'autoDownloadNew':
case 'hasCompletedOnboarding': case 'hasCompletedOnboarding':
case 'minimizeToTray':
if (typeof value === 'boolean') s.set(key, value) if (typeof value === 'boolean') s.set(key, value)
break break
case 'ytdlpChannel':
// Same allowlist that guards the `--update-to` flag (audit F1).
if (isYtdlpUpdateChannel(value)) s.set('ytdlpChannel', value)
break
case 'ytdlpLastUpdateCheck': {
// Set by the main-process auto-updater; validated here since setSettings
// is the only writer. A bogus value at worst skips/forces one check.
const n = Number(value)
if (Number.isFinite(n) && n >= 0) s.set('ytdlpLastUpdateCheck', n)
break
}
case 'defaultTemplateId': case 'defaultTemplateId':
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value) if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
break break
@@ -234,6 +258,8 @@ export function setSettings(partial: Partial<Settings>): Settings {
// and exported by exportBackup as-is — documented, not masked. // and exported by exportBackup as-is — documented, not masked.
case 'proxy': case 'proxy':
case 'rateLimit': case 'rateLimit':
case 'youtubePlayerClient':
case 'youtubePoToken':
if (typeof value === 'string') s.set(key, value) if (typeof value === 'string') s.set(key, value)
break break
} }
+4 -1
View File
@@ -36,10 +36,13 @@ function save(templates: CommandTemplate[]): void {
} }
function sanitize(t: CommandTemplate): CommandTemplate { function sanitize(t: CommandTemplate): CommandTemplate {
const urlPattern = typeof t.urlPattern === 'string' ? t.urlPattern.trim() : ''
return { return {
id: String(t.id), id: String(t.id),
name: (t.name ?? '').trim() || 'Untitled command', name: (t.name ?? '').trim() || 'Untitled command',
args: (t.args ?? '').trim() args: (t.args ?? '').trim(),
// Only persist a urlPattern when one was provided, keeping older files clean.
...(urlPattern ? { urlPattern } : {})
} }
} }
+99
View File
@@ -0,0 +1,99 @@
/**
* Built-in yt-dlp terminal (Phase N): runs the bundled yt-dlp with raw, user-typed
* arguments and streams stdout/stderr back to the renderer line-by-line.
*
* SECURITY: the executable is fixed to the managed yt-dlp (never an arbitrary exe),
* and the whole feature is gated on Settings.customCommandEnabled — the same consent
* flag that guards per-download extra args, because yt-dlp args can run arbitrary
* code (`--exec`). The gate is enforced here in main, not just in the renderer UI.
*/
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { type WebContents } from 'electron'
import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
import { getSettings } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { parseExtraArgs } from './buildArgs'
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
const active = new Map<string, ChildProcess>()
function send(wc: WebContents, ev: TerminalEvent): void {
if (!wc.isDestroyed()) wc.send(IpcChannels.terminalOutput, ev)
}
export function runTerminal(wc: WebContents, id: string, argsRaw: string): TerminalRunResult {
if (!getSettings().customCommandEnabled) {
return {
ok: false,
error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.'
}
}
ensureManagedYtdlp()
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return { ok: false, error: 'yt-dlp.exe is missing and could not be restored.' }
}
if (active.has(id)) return { ok: false, error: 'A command is already running.' }
// Always pin ffmpeg so post-processing works; the rest is whatever the user typed.
const args = ['--ffmpeg-location', getBinDir(), ...parseExtraArgs(argsRaw)]
let child: ChildProcess
try {
child = spawn(ytdlp, args, { windowsHide: true })
} catch (e) {
return { ok: false, error: (e as Error).message }
}
active.set(id, child)
// Buffer each stream and emit on newline boundaries (flush the remainder on close).
const buffers: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }
function feed(stream: 'stdout' | 'stderr', chunk: string): void {
buffers[stream] += chunk
let nl: number
while ((nl = buffers[stream].indexOf('\n')) >= 0) {
const line = buffers[stream].slice(0, nl).replace(/\r$/, '')
buffers[stream] = buffers[stream].slice(nl + 1)
send(wc, { type: 'output', id, line, stream })
}
}
child.stdout?.on('data', (c: Buffer) => feed('stdout', c.toString()))
child.stderr?.on('data', (c: Buffer) => feed('stderr', c.toString()))
let settled = false
child.on('error', (err) => {
if (settled) return
settled = true
active.delete(id)
send(wc, { type: 'error', id, error: err.message })
})
child.on('close', (code) => {
if (settled) return
settled = true
active.delete(id)
// Flush any trailing partial lines that never hit a newline.
for (const stream of ['stdout', 'stderr'] as const) {
if (buffers[stream]) send(wc, { type: 'output', id, line: buffers[stream], stream })
}
send(wc, { type: 'done', id, code })
})
return { ok: true }
}
export function cancelTerminal(id: string): void {
const child = active.get(id)
if (!child) return
const pid = child.pid
if (pid != null) {
// Tree-kill via System32 taskkill by absolute path (same hardening as download.ts).
execFile(
getSystem32Path('taskkill.exe'),
['/pid', String(pid), '/T', '/F'],
{ windowsHide: true },
() => {}
)
} else {
child.kill()
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* System tray + minimize-to-tray (Phase O). When Settings.minimizeToTray is on,
* closing the window hides it to the tray instead of quitting; the tray's "Quit"
* really exits. The tray gives the app a background presence (a natural home for
* the watched-source sync) and a quick show/quit menu.
*/
import { app, Tray, Menu, nativeImage, type BrowserWindow } from 'electron'
import { getAppIconPath } from './binaries'
let tray: Tray | null = null
// True once the user has chosen to really quit (tray menu, or app.quit from the
// updater) — lets the window 'close' handler tell "hide to tray" from "exit".
let quitting = false
export function isQuitting(): boolean {
return quitting
}
export function markQuitting(): void {
quitting = true
}
function show(getWindow: () => BrowserWindow | null): void {
const win = getWindow()
if (!win) return
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
/** Create the tray icon + menu once. No-op if it already exists or the icon is missing. */
export function createTray(getWindow: () => BrowserWindow | null): void {
if (tray) return
const icon = nativeImage.createFromPath(getAppIconPath())
// Without a usable icon a Windows tray entry is invisible/unclickable, which is
// worse than no tray — so skip it rather than ship a dead tray.
if (icon.isEmpty()) return
tray = new Tray(icon)
tray.setToolTip('AeroFetch')
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Show AeroFetch', click: () => show(getWindow) },
{ type: 'separator' },
{
label: 'Quit AeroFetch',
click: () => {
quitting = true
app.quit()
}
}
])
)
tray.on('click', () => show(getWindow))
}
+72 -3
View File
@@ -1,13 +1,37 @@
import { execFile } from 'child_process' import { execFile } from 'child_process'
import { existsSync } from 'fs' import { existsSync, mkdirSync, copyFileSync } from 'fs'
import { getYtdlpPath } from './binaries' import { dirname } from 'path'
import { getYtdlpPath, getBundledYtdlpPath } from './binaries'
import { getSettings, setSettings } from './settings'
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
import { import {
isYtdlpUpdateChannel, isYtdlpUpdateChannel,
type YtdlpVersionResult, type YtdlpVersionResult,
type YtdlpUpdateChannel, type YtdlpUpdateChannel,
type YtdlpUpdateResult type YtdlpUpdateResult,
type YtdlpAutoUpdateStatus
} from '@shared/ipc' } from '@shared/ipc'
/**
* Ensure the writable managed yt-dlp.exe exists, copying the bundled seed in if
* it doesn't (first run, or after the user/AV deleted it). Best-effort and
* idempotent — when the copy is already present this is just one existsSync, so
* it's cheap to call before any spawn/update. Does nothing if the seed itself is
* gone (a broken install); callers surface their own missing-binary error then.
*/
export function ensureManagedYtdlp(): void {
const managed = getYtdlpPath()
if (existsSync(managed)) return
const seed = getBundledYtdlpPath()
if (!existsSync(seed)) return
try {
mkdirSync(dirname(managed), { recursive: true })
copyFileSync(seed, managed)
} catch {
/* best-effort; a failed seed just leaves the managed copy absent */
}
}
/** /**
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`. * Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
* Proves the main-process → bundled-binary → IPC path end to end. * Proves the main-process → bundled-binary → IPC path end to end.
@@ -51,6 +75,9 @@ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateRes
return Promise.resolve({ ok: false, error: 'Unsupported update channel.' }) return Promise.resolve({ ok: false, error: 'Unsupported update channel.' })
} }
// Self-heal: restore the managed copy from the bundled seed before updating, so
// a deleted yt-dlp.exe is re-seeded and then updated in one click.
ensureManagedYtdlp()
const ytdlpPath = getYtdlpPath() const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) { if (!existsSync(ytdlpPath)) {
@@ -78,3 +105,45 @@ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateRes
) )
}) })
} }
/**
* Startup yt-dlp maintenance: always seed the managed copy, then — if auto-update
* is on and the daily throttle has elapsed — self-update it to the configured
* channel. Whether it changed is decided by comparing `--version` before/after
* (robust against yt-dlp's localized "up to date" wording). Status is pushed to
* the renderer so the Settings UI reflects a check it didn't trigger.
*
* Best-effort: every failure path still records the attempt time (so an
* unreachable update server doesn't re-hit the network every launch) and never
* throws — a stale binary must never block the app from starting.
*/
export async function runStartupYtdlpAutoUpdate(
send: (status: YtdlpAutoUpdateStatus) => void
): Promise<void> {
ensureManagedYtdlp()
const settings = getSettings()
if (!shouldAutoCheckYtdlp(settings.autoUpdateYtdlp, settings.ytdlpLastUpdateCheck, Date.now())) {
return
}
const channel = settings.ytdlpChannel
send({ phase: 'checking', channel })
const before = await getYtdlpVersion()
const result = await updateYtdlp(channel)
setSettings({ ytdlpLastUpdateCheck: Date.now() })
if (!result.ok) {
send({ phase: 'error', channel, error: result.error, checkedAt: Date.now() })
return
}
const after = await getYtdlpVersion()
const changed = before.ok && after.ok && before.version !== after.version
send({
phase: changed ? 'updated' : 'current',
channel,
version: after.ok ? after.version : undefined,
checkedAt: Date.now()
})
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Pure auto-update scheduling policy for yt-dlp. Kept free of any electron /
* node-runtime dependency (like buildArgs.ts) so the throttle can be unit-tested
* without spinning up Electron. ytdlp.ts imports these to drive the real check.
*/
/** Once-a-day throttle for the startup auto-update check. */
export const AUTO_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000
/**
* Whether a background yt-dlp update check should run now: only when auto-update
* is enabled AND at least one interval has elapsed since the last check. A
* zero/absent lastCheck means "never checked" → run. `now` and the interval are
* passed in so the decision is a pure function of its inputs.
*/
export function shouldAutoCheckYtdlp(
enabled: boolean,
lastCheck: number,
now: number,
intervalMs: number = AUTO_UPDATE_INTERVAL_MS
): boolean {
if (!enabled) return false
if (!lastCheck) return true
return now - lastCheck >= intervalMs
}
+41 -2
View File
@@ -7,6 +7,8 @@ import {
type YtdlpVersionResult, type YtdlpVersionResult,
type YtdlpUpdateChannel, type YtdlpUpdateChannel,
type YtdlpUpdateResult, type YtdlpUpdateResult,
type YtdlpAutoUpdateStatus,
type FfmpegVersionResult,
type ProbeResult, type ProbeResult,
type StartDownloadOptions, type StartDownloadOptions,
type StartDownloadResult, type StartDownloadResult,
@@ -26,7 +28,10 @@ import {
type IndexProgress, type IndexProgress,
type IndexSourceResult, type IndexSourceResult,
type SyncResult, type SyncResult,
type ScheduledSyncStatus type ScheduledSyncStatus,
type TerminalEvent,
type TerminalRunResult,
type TaskbarProgress
} from '@shared/ipc' } from '@shared/ipc'
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC. // The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
@@ -55,6 +60,10 @@ const api = {
getYtdlpVersion: (): Promise<YtdlpVersionResult> => getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpVersion), ipcRenderer.invoke(IpcChannels.ytdlpVersion),
/** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */
getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
ipcRenderer.invoke(IpcChannels.ffmpegVersion),
probe: (url: string): Promise<ProbeResult> => ipcRenderer.invoke(IpcChannels.probe, url), probe: (url: string): Promise<ProbeResult> => ipcRenderer.invoke(IpcChannels.probe, url),
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> => startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
@@ -63,6 +72,9 @@ const api = {
cancelDownload: (id: string): Promise<void> => cancelDownload: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.downloadCancel, id), ipcRenderer.invoke(IpcChannels.downloadCancel, id),
pauseDownload: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.downloadPause, id),
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder), getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder), chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
@@ -115,6 +127,13 @@ const api = {
updateYtdlp: (channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> => updateYtdlp: (channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpUpdate, channel), ipcRenderer.invoke(IpcChannels.ytdlpUpdate, channel),
/** Subscribe to background yt-dlp auto-update status. Returns an unsubscribe function. */
onYtdlpAutoUpdateStatus: (cb: (s: YtdlpAutoUpdateStatus) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, s: YtdlpAutoUpdateStatus): void => cb(s)
ipcRenderer.on(IpcChannels.ytdlpAutoUpdateStatus, listener)
return () => ipcRenderer.removeListener(IpcChannels.ytdlpAutoUpdateStatus, listener)
},
listErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogList), listErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogList),
clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear), clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear),
@@ -193,7 +212,27 @@ const api = {
const listener = (_e: IpcRendererEvent, p: IndexProgress): void => cb(p) const listener = (_e: IpcRendererEvent, p: IndexProgress): void => cb(p)
ipcRenderer.on(IpcChannels.indexProgress, listener) ipcRenderer.on(IpcChannels.indexProgress, listener)
return () => ipcRenderer.removeListener(IpcChannels.indexProgress, listener) return () => ipcRenderer.removeListener(IpcChannels.indexProgress, listener)
} },
// --- Built-in yt-dlp terminal (Phase N) ---
/** Run the bundled yt-dlp with raw args; output streams via onTerminalOutput. */
runTerminal: (id: string, args: string): Promise<TerminalRunResult> =>
ipcRenderer.invoke(IpcChannels.terminalRun, id, args),
cancelTerminal: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.terminalCancel, id),
/** Subscribe to live terminal output. Returns an unsubscribe function. */
onTerminalOutput: (cb: (ev: TerminalEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, ev: TerminalEvent): void => cb(ev)
ipcRenderer.on(IpcChannels.terminalOutput, listener)
return () => ipcRenderer.removeListener(IpcChannels.terminalOutput, listener)
},
/** Reflect overall queue progress on the Windows taskbar (fire-and-forget). */
setTaskbarProgress: (p: TaskbarProgress): void =>
ipcRenderer.send(IpcChannels.taskbarProgress, p)
} }
export type Api = typeof api export type Api = typeof api
+58
View File
@@ -4,10 +4,14 @@ import { Sidebar, type TabValue } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView' import { DownloadsView } from './components/DownloadsView'
import { LibraryView } from './components/LibraryView' import { LibraryView } from './components/LibraryView'
import { HistoryView } from './components/HistoryView' import { HistoryView } from './components/HistoryView'
import { TerminalView } from './components/TerminalView'
import { SettingsView } from './components/SettingsView' import { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding' import { Onboarding } from './components/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { getTheme, pageBackground } from './theme' import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings' import { useSettings } from './store/settings'
import { useDownloads } from './store/downloads'
import { summarizeQueue } from './store/queueStats'
import { useResolvedDark } from './store/systemTheme' import { useResolvedDark } from './store/systemTheme'
const useStyles = makeStyles({ const useStyles = makeStyles({
@@ -34,6 +38,55 @@ function App(): React.JSX.Element {
const updateSettings = useSettings((s) => s.update) const updateSettings = useSettings((s) => s.update)
const isDark = useResolvedDark() const isDark = useResolvedDark()
const [tab, setTab] = useState<TabValue>('downloads') const [tab, setTab] = useState<TabValue>('downloads')
const [paletteOpen, setPaletteOpen] = useState(false)
// Ctrl/Cmd+K toggles the command palette.
useEffect(() => {
function onKey(e: KeyboardEvent): void {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault()
setPaletteOpen((o) => !o)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
// Mirror overall queue progress onto the Windows taskbar. Subscribe to the store
// directly (not via a selector) so taskbar updates don't re-render the app.
useEffect(() => {
function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
const s = summarizeQueue(items)
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode })
}
push(useDownloads.getState().items)
return useDownloads.subscribe((st) => push(st.items))
}, [])
const paletteActions: PaletteAction[] = [
{ id: 'go-downloads', label: 'Go to Downloads', hint: 'Navigate', run: () => setTab('downloads') },
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
{ id: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') },
{ id: 'go-settings', label: 'Go to Settings', hint: 'Navigate', run: () => setTab('settings') },
{
id: 'new-download',
label: 'New download',
hint: 'Focus URL',
run: () => {
setTab('downloads')
// Wait for the download bar to mount after the tab switch, then focus.
setTimeout(() => document.getElementById('aerofetch-url')?.focus(), 60)
}
},
{
id: 'toggle-theme',
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
hint: 'Appearance',
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
}
]
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC. // AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
const [version, setVersion] = useState('') const [version, setVersion] = useState('')
@@ -89,8 +142,13 @@ function App(): React.JSX.Element {
{tab === 'downloads' && <DownloadsView />} {tab === 'downloads' && <DownloadsView />}
{tab === 'library' && <LibraryView />} {tab === 'library' && <LibraryView />}
{tab === 'history' && <HistoryView />} {tab === 'history' && <HistoryView />}
{tab === 'terminal' && <TerminalView />}
{tab === 'settings' && <SettingsView />} {tab === 'settings' && <SettingsView />}
</main> </main>
{paletteOpen && (
<CommandPalette actions={paletteActions} onClose={() => setPaletteOpen(false)} />
)}
</> </>
)} )}
</div> </div>
@@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from 'react'
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
export interface PaletteAction {
id: string
label: string
/** short right-aligned hint, e.g. a shortcut or category */
hint?: string
run: () => void
}
const useStyles = makeStyles({
// A plain fixed overlay — NOT a Fluent Dialog, since this app avoids Fluent's
// portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx).
backdrop: {
position: 'fixed',
inset: 0,
zIndex: 1000,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
paddingTop: '14vh',
backgroundColor: 'rgba(0,0,0,0.32)'
},
panel: {
width: 'min(560px, 92vw)',
display: 'flex',
flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
boxShadow: tokens.shadow28,
overflow: 'hidden'
},
input: {
appearance: 'none',
border: 'none',
outline: 'none',
padding: '14px 16px',
fontSize: tokens.fontSizeBase400,
fontFamily: tokens.fontFamilyBase,
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground1,
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`
},
list: {
maxHeight: '50vh',
overflowY: 'auto',
padding: '6px'
},
item: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
width: '100%',
padding: '9px 12px',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground1,
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
textAlign: 'left',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
itemActive: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
itemHint: {
flexShrink: 0,
color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase200
},
empty: {
padding: '14px 12px',
color: tokens.colorNeutralForeground3
}
})
/**
* A Ctrl/Cmd+K command palette (Phase N). Filter by typing, ↑/↓ to move, Enter to
* run, Esc or a backdrop click to dismiss. Actions are supplied by App.
*/
export function CommandPalette({
actions,
onClose
}: {
actions: PaletteAction[]
onClose: () => void
}): React.JSX.Element {
const styles = useStyles()
const [q, setQ] = useState('')
const [sel, setSel] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
useEffect(() => {
inputRef.current?.focus()
}, [])
useEffect(() => {
setSel(0)
}, [q])
function onKeyDown(e: React.KeyboardEvent): void {
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSel((s) => Math.min(filtered.length - 1, s + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSel((s) => Math.max(0, s - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
const a = filtered[sel]
if (a) {
a.run()
onClose()
}
}
}
return (
<div className={styles.backdrop} onClick={onClose}>
<div
className={styles.panel}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Command palette"
>
<input
ref={inputRef}
className={styles.input}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Type a command…"
aria-label="Command palette search"
/>
<div className={styles.list}>
{filtered.length === 0 ? (
<div className={styles.empty}>No matching commands</div>
) : (
filtered.map((a, i) => (
<button
key={a.id}
type="button"
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
onClick={() => {
a.run()
onClose()
}}
onMouseEnter={() => setSel(i)}
>
<span>{a.label}</span>
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
</button>
))
)}
</div>
</div>
</div>
)
}
+320 -30
View File
@@ -1,6 +1,8 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { import {
Input, Input,
Textarea,
Field,
Button, Button,
Checkbox, Checkbox,
Spinner, Spinner,
@@ -20,10 +22,14 @@ import {
ErrorCircleRegular, ErrorCircleRegular,
LinkRegular, LinkRegular,
DismissRegular, DismissRegular,
AppsListRegular AppsListRegular,
CutRegular,
CalendarClockRegular,
WarningRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc' import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads' import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
import { sameVideo } from '../store/queueStats'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { Select } from './Select' import { Select } from './Select'
import { Hint } from './Hint' 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({ const useStyles = makeStyles({
root: { root: {
display: 'flex', display: 'flex',
@@ -50,6 +73,11 @@ const useStyles = makeStyles({
...shorthands.borderRadius(tokens.borderRadiusXLarge), ...shorthands.borderRadius(tokens.borderRadiusXLarge),
boxShadow: tokens.shadow4 boxShadow: tokens.shadow4
}, },
// Highlight while a link / .url file is dragged over the card.
rootDragging: {
outline: `2px dashed ${tokens.colorBrandStroke1}`,
outlineOffset: '-2px'
},
urlRow: { urlRow: {
display: 'flex', display: 'flex',
gap: '8px' gap: '8px'
@@ -195,10 +223,35 @@ const useStyles = makeStyles({
overflowY: 'auto', overflowY: 'auto',
paddingRight: '4px' paddingRight: '4px'
}, },
plItemRow: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
plItem: { plItem: {
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'flex-start',
padding: '2px 0' padding: '2px 0',
flexGrow: 1,
minWidth: 0
},
plKindBtn: {
flexShrink: 0,
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground2
}
}, },
plItemLabel: { plItemLabel: {
display: 'flex', display: 'flex',
@@ -212,12 +265,52 @@ const useStyles = makeStyles({
}, },
plItemMeta: { plItemMeta: {
color: tokens.colorNeutralForeground3 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'
} }
}) })
export function DownloadBar(): React.JSX.Element { export function DownloadBar(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const addFromUrl = useDownloads((s) => s.addFromUrl) const addFromUrl = useDownloads((s) => s.addFromUrl)
const addMany = useDownloads((s) => s.addMany)
const settingsLoaded = useSettings((s) => s.loaded) const settingsLoaded = useSettings((s) => s.loaded)
const defaultKind = useSettings((s) => s.defaultKind) const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality) const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
@@ -227,6 +320,47 @@ export function DownloadBar(): React.JSX.Element {
const [kind, setKind] = useState<MediaKind>('video') const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0]) 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. // Apply the saved default format once, when persisted settings first arrive.
const appliedDefaults = useRef(false) const appliedDefaults = useRef(false)
useEffect(() => { useEffect(() => {
@@ -246,6 +380,9 @@ export function DownloadBar(): React.JSX.Element {
// Probe state for a playlist URL. // Probe state for a playlist URL.
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null) const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
const [selected, setSelected] = useState<Set<number>>(new Set()) const [selected, setSelected] = useState<Set<number>>(new Set())
// Per-entry kind override (entry.index → 'video' | 'audio'); absent = use the
// bar's global kind. Lets a playlist mix video and audio downloads.
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
// Clipboard auto-detect: when the window gains focus and the clipboard holds a // Clipboard auto-detect: when the window gains focus and the clipboard holds a
// fresh link, offer it (without clobbering anything the user is already typing). // fresh link, offer it (without clobbering anything the user is already typing).
@@ -316,12 +453,15 @@ export function DownloadBar(): React.JSX.Element {
setFormatId('') setFormatId('')
setPlaylist(null) setPlaylist(null)
setSelected(new Set()) setSelected(new Set())
setItemKinds({})
} }
function onUrlChange(next: string): void { function onUrlChange(next: string): void {
setUrl(next) 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 (info || probeError || playlist) clearProbe()
if (dup) setDup(null)
confirmDup.current = false
} }
function onKindChange(next: MediaKind): void { function onKindChange(next: MediaKind): void {
@@ -379,10 +519,41 @@ export function DownloadBar(): React.JSX.Element {
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index))) setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
} }
// Per-entry kind: the override if set, else the bar's global kind.
function effKind(index: number): MediaKind {
return itemKinds[index] ?? kind
}
function toggleItemKind(index: number): void {
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
}
function setAllKinds(k: MediaKind): void {
if (!playlist) return
const all: Record<number, MediaKind> = {}
for (const e of playlist.entries) all[e.index] = k
setItemKinds(all)
}
function download(): void { function download(): void {
const trimmed = url.trim() const trimmed = url.trim()
if (!trimmed) return 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 const meta = info
? { ? {
title: info.title, title: info.title,
@@ -392,9 +563,14 @@ export function DownloadBar(): React.JSX.Element {
} }
: {} : {}
const trimSpec = trim.trim() || undefined
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
if (usingFormats && selectedFormat) { if (usingFormats && selectedFormat) {
addFromUrl(trimmed, 'video', selectedFormat.label, { addFromUrl(trimmed, 'video', selectedFormat.label, {
...meta, ...meta,
trim: trimSpec,
scheduledFor,
format: { format: {
id: selectedFormat.id, id: selectedFormat.id,
hasAudio: selectedFormat.hasAudio, hasAudio: selectedFormat.hasAudio,
@@ -402,32 +578,53 @@ export function DownloadBar(): React.JSX.Element {
} }
}) })
} else { } else {
addFromUrl(trimmed, kind, quality, meta) addFromUrl(trimmed, kind, quality, { ...meta, trim: trimSpec, scheduledFor })
} }
setUrl('') setUrl('')
setTrim('')
setShowTrim(false)
setScheduleAt('')
setShowSchedule(false)
clearProbe() clearProbe()
} }
function downloadAnyway(): void {
confirmDup.current = true
download()
}
function addPlaylist(): void { function addPlaylist(): void {
if (!playlist) return if (!playlist) return
const chosen = playlist.entries.filter((e) => selected.has(e.index)) const chosen = playlist.entries.filter((e) => selected.has(e.index))
for (const e of chosen) { // Each entry uses its own kind override; quality falls back to that kind's
addFromUrl(e.url, kind, quality, { // default unless the entry matches the bar's current kind/quality.
title: e.title, addMany(
channel: e.uploader, chosen.map((e) => {
durationLabel: e.durationLabel const k = effKind(e.index)
return {
url: e.url,
kind: k,
quality: k === kind ? quality : QUALITY_OPTIONS[k][0],
opts: { title: e.title, channel: e.uploader, durationLabel: e.durationLabel }
}
}) })
} )
setUrl('') setUrl('')
clearProbe() clearProbe()
} }
return ( return (
<div className={styles.root}> <div
className={mergeClasses(styles.root, dragActive && styles.rootDragging)}
onDragOver={onDragOver}
onDragLeave={() => setDragActive(false)}
onDrop={onDrop}
>
<div className={styles.urlRow}> <div className={styles.urlRow}>
<Input <Input
className={styles.url} className={styles.url}
input={{ id: 'aerofetch-url' }}
value={url} value={url}
onChange={(_, d) => onUrlChange(d.value)} onChange={(_, d) => onUrlChange(d.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()} onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
@@ -474,6 +671,23 @@ export function DownloadBar(): React.JSX.Element {
</div> </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 && ( {probing && (
<div className={styles.statusRow}> <div className={styles.statusRow}>
<Spinner size="tiny" /> <Spinner size="tiny" />
@@ -524,32 +738,108 @@ export function DownloadBar(): React.JSX.Element {
<Button size="small" appearance="subtle" onClick={toggleAll}> <Button size="small" appearance="subtle" onClick={toggleAll}>
{allSelected ? 'Select none' : 'Select all'} {allSelected ? 'Select none' : 'Select all'}
</Button> </Button>
<Button size="small" appearance="subtle" onClick={() => setAllKinds('video')}>
All video
</Button>
<Button size="small" appearance="subtle" onClick={() => setAllKinds('audio')}>
All audio
</Button>
</div> </div>
<div className={styles.plList}> <div className={styles.plList}>
{playlist.entries.map((e) => ( {playlist.entries.map((e) => (
<Checkbox <div key={e.index} className={styles.plItemRow}>
key={e.index} <Checkbox
className={styles.plItem} className={styles.plItem}
checked={selected.has(e.index)} checked={selected.has(e.index)}
onChange={(_, d) => toggleEntry(e.index, !!d.checked)} onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
label={ label={
<span className={styles.plItemLabel}> <span className={styles.plItemLabel}>
<span className={styles.plItemTitle}> <span className={styles.plItemTitle}>
{e.index}. {e.title} {e.index}. {e.title}
</span>
{(e.durationLabel || e.uploader) && (
<Caption1 className={styles.plItemMeta}>
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')}
</Caption1>
)}
</span> </span>
{(e.durationLabel || e.uploader) && ( }
<Caption1 className={styles.plItemMeta}> />
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')} <Hint
</Caption1> label={
)} effKind(e.index) === 'audio' ? 'Audio — click for video' : 'Video — click for audio'
</span> }
} placement="top"
/> align="end"
>
<button
type="button"
className={styles.plKindBtn}
onClick={() => toggleItemKind(e.index)}
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
>
{effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
</button>
</Hint>
</div>
))} ))}
</div> </div>
</div> </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.controls}>
<div className={styles.control}> <div className={styles.control}>
<Caption1>Format</Caption1> <Caption1>Format</Caption1>
@@ -606,11 +896,11 @@ export function DownloadBar(): React.JSX.Element {
<Button <Button
appearance="primary" appearance="primary"
size="large" size="large"
icon={<ArrowDownloadRegular />} icon={scheduleAt ? <CalendarClockRegular /> : <ArrowDownloadRegular />}
onClick={download} onClick={download}
disabled={!url.trim()} disabled={!url.trim()}
> >
Download {scheduleAt ? 'Schedule' : 'Download'}
</Button> </Button>
)} )}
</div> </div>
@@ -133,6 +133,17 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
</Field> </Field>
</div> </div>
<Field
label="Format sorting (advanced)"
hint="Raw yt-dlp -S string to rank formats by priority, e.g. res:1080,vcodec:av01,size. Overrides the preferred-codec tiebreaker. Leave empty unless you know yt-dlp's -S syntax."
>
<Input
value={value.formatSort}
placeholder="res,fps,vcodec:av01"
onChange={(_, d) => setOpt('formatSort', d.value)}
/>
</Field>
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video."> <Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
<Switch <Switch
checked={value.embedSubtitles} checked={value.embedSubtitles}
@@ -204,6 +215,16 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
label={value.embedChapters ? 'On' : 'Off'} label={value.embedChapters ? 'On' : 'Off'}
/> />
</Field> </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."> <Field label="Embed metadata" hint="Title, artist, date and similar tags.">
<Switch <Switch
checked={value.embedMetadata} checked={value.embedMetadata}
+77 -16
View File
@@ -1,30 +1,61 @@
import { import {
Subtitle2, Subtitle2,
Body1, Body1,
Caption1,
Button, Button,
ProgressBar,
makeStyles, makeStyles,
tokens tokens,
shorthands
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { ArrowDownloadRegular } from '@fluentui/react-icons' import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
import { useDownloads } from '../store/downloads' import { useDownloads } from '../store/downloads'
import { summarizeQueue } from '../store/queueStats'
import { DownloadBar } from './DownloadBar' import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem' import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
// Fill the scroll area so the queue list below can flex to the remaining
// height and scroll internally (it's virtualized), instead of the whole page
// growing to thousands of rows.
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '20px' gap: '20px',
height: '100%'
}, },
queueHeader: { queueHeader: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between' justifyContent: 'space-between'
}, },
list: { headerActions: {
display: 'flex', display: 'flex',
flexDirection: 'column', gap: '8px'
gap: '10px' },
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,
// not the page, owns the scrolling.
minHeight: 0
}, },
empty: { empty: {
display: 'flex', display: 'flex',
@@ -41,7 +72,9 @@ export function DownloadsView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const items = useDownloads((s) => s.items) const items = useDownloads((s) => s.items)
const clearFinished = useDownloads((s) => s.clearFinished) const clearFinished = useDownloads((s) => s.clearFinished)
const retryAll = useDownloads((s) => s.retryAll)
const summary = summarizeQueue(items)
const hasFinished = items.some( const hasFinished = items.some(
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled' (i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
) )
@@ -50,13 +83,37 @@ export function DownloadsView(): React.JSX.Element {
<div className={styles.root}> <div className={styles.root}>
<DownloadBar /> <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}> <div className={styles.queueHeader}>
<Subtitle2>Queue ({items.length})</Subtitle2> <Subtitle2>Queue ({items.length})</Subtitle2>
{hasFinished && ( <div className={styles.headerActions}>
<Button size="small" appearance="subtle" onClick={clearFinished}> {summary.failed > 0 && (
Clear finished <Button
</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> </div>
{items.length === 0 ? ( {items.length === 0 ? (
@@ -65,11 +122,15 @@ export function DownloadsView(): React.JSX.Element {
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1> <Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
</div> </div>
) : ( ) : (
<div className={styles.list}> <VirtualList
{items.map((item) => ( items={items}
<QueueItem key={item.id} item={item} /> className={styles.listScroll}
))} gap={10}
</div> overscan={6}
estimateSize={() => 100}
getKey={(item) => item.id}
renderItem={(item) => <QueueItem item={item} />}
/>
)} )}
</div> </div>
) )
+198 -60
View File
@@ -28,11 +28,12 @@ import {
LibraryRegular LibraryRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { MediaItem, Source } from '@shared/ipc' import type { MediaItem, Source } from '@shared/ipc'
import { useSources, MAX_ENQUEUE_BATCH } from '../store/sources' import { useSources } from '../store/sources'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { useDownloads, type DownloadStatus } from '../store/downloads' import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb' import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb' import { MediaThumb } from './MediaThumb'
import { VirtualList } from './VirtualList'
// True in the standalone browser preview (no Electron preload). // True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -40,10 +41,24 @@ const PREVIEW = typeof window === 'undefined' || !window.electron
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */ /** Per-item status shown in the library: a live queue status, or pending/downloaded. */
type ItemStatus = DownloadStatus | 'pending' type ItemStatus = DownloadStatus | 'pending'
/**
* A flattened row for the virtualized item list: either a playlist header or a
* single video. Flattening the groups into one list lets a 1000s-video source
* window cleanly (one virtualizer over a flat array, headers included).
*/
type LibRow =
| { kind: 'header'; title: string; items: MediaItem[] }
| { kind: 'item'; item: MediaItem }
/** Above this many flattened rows, the item list switches to a virtualized panel. */
const VIRTUALIZE_AT = 100
const STATUS_LABEL: Record<ItemStatus, string> = { const STATUS_LABEL: Record<ItemStatus, string> = {
pending: 'Pending', pending: 'Pending',
queued: 'Queued', queued: 'Queued',
downloading: 'Downloading', downloading: 'Downloading',
paused: 'Paused',
saved: 'Saved',
completed: 'Downloaded', completed: 'Downloaded',
error: 'Failed', error: 'Failed',
canceled: 'Canceled' canceled: 'Canceled'
@@ -134,22 +149,24 @@ const useStyles = makeStyles({
}, },
actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }, actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
actionSpacer: { flexGrow: 1 }, actionSpacer: { flexGrow: 1 },
group: { display: 'flex', flexDirection: 'column' },
groupHead: { groupHead: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '8px',
padding: '6px 4px', padding: '6px 4px',
color: tokens.colorNeutralForeground2 color: tokens.colorNeutralForeground2,
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
}, },
groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 }, groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
rows: { display: 'flex', flexDirection: 'column' },
row: { row: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '10px', gap: '10px',
padding: '5px 4px 5px 18px' padding: '5px 4px 5px 18px'
}, },
plainList: { display: 'flex', flexDirection: 'column' },
rowThumb: { rowThumb: {
width: '60px', width: '60px',
height: '34px', height: '34px',
@@ -236,7 +253,11 @@ export function LibraryView(): React.JSX.Element {
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchNote, setBatchNote] = useState<string | null>(null)
const [syncNote, setSyncNote] = useState<string | null>(null) const [syncNote, setSyncNote] = useState<string | null>(null)
// Which playlist groups are expanded. Empty = all collapsed (the default), so
// an expanded source shows just its group headers until one is opened.
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const [scheduled, setScheduled] = useState(false) const [scheduled, setScheduled] = useState(false)
const watchedCount = sources.filter((s) => s.watched).length const watchedCount = sources.filter((s) => s.watched).length
@@ -261,8 +282,12 @@ export function LibraryView(): React.JSX.Element {
if (res.error) setError(res.error) if (res.error) setError(res.error)
} }
// Reset the selection whenever the expanded source changes. // Reset the selection (and any batch note) whenever the expanded source changes.
useEffect(() => setSelected(new Set()), [selectedSourceId]) useEffect(() => {
setSelected(new Set())
setBatchNote(null)
setExpandedGroups(new Set())
}, [selectedSourceId])
// Live per-URL queue status so a video row reflects its real download state. // Live per-URL queue status so a video row reflects its real download state.
const statusByUrl = useMemo(() => { const statusByUrl = useMemo(() => {
@@ -273,12 +298,40 @@ export function LibraryView(): React.JSX.Element {
const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : [] const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : []
const groups = useMemo(() => groupByPlaylist(items), [items]) const groups = useMemo(() => groupByPlaylist(items), [items])
// A group is shown when toggled open, or auto-expanded when it's the only group
// (a single "Uploads" channel shouldn't need a second click to reach its videos).
const isGroupOpen = (title: string): boolean =>
groups.length === 1 || expandedGroups.has(title)
// Flatten groups → [header, ...its items, header, ...] for the virtualized list.
const flatRows = useMemo<LibRow[]>(() => {
const rows: LibRow[] = []
for (const g of groups) {
rows.push({ kind: 'header', title: g.title, items: g.items })
if (groups.length === 1 || expandedGroups.has(g.title)) {
for (const it of g.items) rows.push({ kind: 'item', item: it })
}
}
return rows
}, [groups, expandedGroups])
const effStatus = (it: MediaItem): ItemStatus => const effStatus = (it: MediaItem): ItemStatus =>
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending') statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
const pendingItems = items.filter((it) => effStatus(it) === 'pending') const pendingItems = items.filter((it) => effStatus(it) === 'pending')
// An item is worth (re)queuing when it isn't already downloaded or in flight:
// pending, or a previous failure/cancel to retry. Skipping completed/queued/
// downloading items keeps "Select all → Download" from enqueuing duplicates,
// since addFromUrl doesn't dedupe by URL.
const actionable = (it: MediaItem): boolean => {
const st = effStatus(it)
return st === 'pending' || st === 'error' || st === 'canceled'
}
const actionableItems = items.filter(actionable)
const selectedActionable = actionableItems.filter((it) => selected.has(it.id))
const allActionableSelected =
actionableItems.length > 0 && actionableItems.every((it) => selected.has(it.id))
async function onIndex(): Promise<void> { async function onIndex(): Promise<void> {
setError(null) setError(null)
const u = url.trim() const u = url.trim()
@@ -297,6 +350,15 @@ export function LibraryView(): React.JSX.Element {
}) })
} }
function toggleGroupExpand(title: string): void {
setExpandedGroups((prev) => {
const next = new Set(prev)
if (next.has(title)) next.delete(title)
else next.add(title)
return next
})
}
function toggleGroup(groupItems: MediaItem[], on: boolean): void { function toggleGroup(groupItems: MediaItem[], on: boolean): void {
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev) const next = new Set(prev)
@@ -308,16 +370,34 @@ export function LibraryView(): React.JSX.Element {
}) })
} }
// Select / clear every downloadable item across all groups in this source.
function toggleAll(on: boolean): void {
setSelected(on ? new Set(actionableItems.map((it) => it.id)) : new Set())
}
// One click queues every chosen item — maxConcurrent gates how many actually
// run, the rest wait in the queue. Selection clears since nothing is held back.
function downloadSelected(): void { function downloadSelected(): void {
if (!selectedSourceId) return if (!selectedSourceId || selectedActionable.length === 0) return
const chosen = items.filter((it) => selected.has(it.id)) const n = enqueueItems(selectedSourceId, selectedActionable)
enqueueItems(selectedSourceId, chosen)
setSelected(new Set()) setSelected(new Set())
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
} }
function downloadPending(): void { function downloadPending(): void {
if (!selectedSourceId || pendingItems.length === 0) return
const n = enqueueItems(selectedSourceId, pendingItems)
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
// Queue every actionable (pending/failed/canceled) video in one playlist group,
// so "download this whole playlist" is a single click.
function downloadGroup(groupItems: MediaItem[]): void {
if (!selectedSourceId) return if (!selectedSourceId) return
enqueueItems(selectedSourceId, pendingItems) const toQueue = groupItems.filter(actionable)
if (toQueue.length === 0) return
const n = enqueueItems(selectedSourceId, toQueue)
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
} }
function pillClass(status: ItemStatus): string { function pillClass(status: ItemStatus): string {
@@ -327,6 +407,83 @@ export function LibraryView(): React.JSX.Element {
return styles.pill return styles.pill
} }
// One row of the item list — a playlist header or a video — shared by the
// inline (small source) and virtualized (large source) render paths.
function rowKey(row: LibRow): string {
return row.kind === 'header' ? `h:${row.title}` : row.item.id
}
function renderRow(row: LibRow): React.JSX.Element {
if (row.kind === 'header') {
const allOn = row.items.every((it) => selected.has(it.id))
const open = isGroupOpen(row.title)
const groupActionable = row.items.filter(actionable).length
return (
<div
className={styles.groupHead}
onClick={() => toggleGroupExpand(row.title)}
role="button"
tabIndex={0}
onKeyDown={(e) =>
(e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)
}
aria-expanded={open}
>
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
<AppsListRegular />
<span className={styles.groupTitle}>{row.title}</span>
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
<Button
size="small"
appearance="subtle"
onClick={(e) => {
e.stopPropagation()
toggleGroup(row.items, !allOn)
}}
>
{allOn ? 'None' : 'All'}
</Button>
<Button
size="small"
appearance="subtle"
icon={<ArrowDownloadRegular />}
disabled={groupActionable === 0}
onClick={(e) => {
e.stopPropagation()
downloadGroup(row.items)
}}
>
Download{groupActionable > 0 ? ` ${groupActionable}` : ''}
</Button>
</div>
)
}
const it = row.item
const status = effStatus(it)
return (
<div className={styles.row}>
<Checkbox
checked={selected.has(it.id)}
onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`}
/>
<MediaThumb
className={styles.rowThumb}
src={thumbUrl({ url: it.url, videoId: it.videoId })}
kind="video"
iconSize={16}
/>
<div className={styles.rowMain}>
<span className={styles.rowTitle}>
{it.playlistIndex}. {it.title}
</span>
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
</div>
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
</div>
)
}
return ( return (
<div className={styles.root}> <div className={styles.root}>
<div className={styles.header}> <div className={styles.header}>
@@ -418,6 +575,12 @@ export function LibraryView(): React.JSX.Element {
> >
<div className={styles.detail}> <div className={styles.detail}>
<div className={styles.actionRow}> <div className={styles.actionRow}>
<Checkbox
checked={allActionableSelected ? true : selected.size > 0 ? 'mixed' : false}
onChange={(_, d) => toggleAll(!!d.checked)}
label="Select all"
disabled={actionableItems.length === 0}
/>
<Caption1 className={styles.srcSub}> <Caption1 className={styles.srcSub}>
{items.length} videos · {pendingItems.length} pending · indexed{' '} {items.length} videos · {pendingItems.length} pending · indexed{' '}
{relTime(src.lastIndexedAt)} {relTime(src.lastIndexedAt)}
@@ -433,8 +596,9 @@ export function LibraryView(): React.JSX.Element {
appearance="primary" appearance="primary"
icon={<ArrowDownloadRegular />} icon={<ArrowDownloadRegular />}
onClick={downloadSelected} onClick={downloadSelected}
disabled={selectedActionable.length === 0}
> >
Download {Math.min(selected.size, MAX_ENQUEUE_BATCH)} selected Download {selectedActionable.length} selected
</Button> </Button>
</> </>
) : ( ) : (
@@ -445,7 +609,7 @@ export function LibraryView(): React.JSX.Element {
onClick={downloadPending} onClick={downloadPending}
disabled={pendingItems.length === 0} disabled={pendingItems.length === 0}
> >
Download {Math.min(pendingItems.length, MAX_ENQUEUE_BATCH)} pending Download {pendingItems.length} pending
</Button> </Button>
)} )}
<span className={styles.switchRow}> <span className={styles.switchRow}>
@@ -475,54 +639,28 @@ export function LibraryView(): React.JSX.Element {
</Button> </Button>
</div> </div>
{groups.map((g) => { {batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
const allOn = g.items.every((it) => selected.has(it.id))
return ( {flatRows.length > VIRTUALIZE_AT ? (
<div key={g.title} className={styles.group}> // Big source: a fixed-height, internally-scrolling virtualized
<div className={styles.groupHead}> // panel so 1000s of rows stay light. A definite height (not
<AppsListRegular /> // max-height) gives the virtualizer a viewport to measure.
<span className={styles.groupTitle}>{g.title}</span> <VirtualList
<Caption1 className={styles.srcSub}>{g.items.length}</Caption1> items={flatRows}
<Button style={{ height: '58vh' }}
size="small" overscan={10}
appearance="subtle" estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)}
onClick={() => toggleGroup(g.items, !allOn)} getKey={(row) => rowKey(row)}
> renderItem={(row) => renderRow(row)}
{allOn ? 'None' : 'All'} />
</Button> ) : (
</div> // Small source: render inline so the card grows naturally.
<div className={styles.rows}> <div className={styles.plainList}>
{g.items.map((it) => { {flatRows.map((row) => (
const status = effStatus(it) <div key={rowKey(row)}>{renderRow(row)}</div>
return ( ))}
<div key={it.id} className={styles.row}> </div>
<Checkbox )}
checked={selected.has(it.id)}
onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`}
/>
<MediaThumb
className={styles.rowThumb}
src={thumbUrl({ url: it.url, videoId: it.videoId })}
kind="video"
iconSize={16}
/>
<div className={styles.rowMain}>
<span className={styles.rowTitle}>
{it.playlistIndex}. {it.title}
</span>
{it.durationLabel && (
<Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>
)}
</div>
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
</div>
)
})}
</div>
</div>
)
})}
</div> </div>
</SourceCard> </SourceCard>
))} ))}
+94 -2
View File
@@ -1,3 +1,4 @@
import { memo } from 'react'
import { import {
Text, Text,
Caption1, Caption1,
@@ -15,6 +16,11 @@ import {
OpenRegular, OpenRegular,
FolderRegular, FolderRegular,
ArrowClockwiseRegular, ArrowClockwiseRegular,
ArrowDownloadRegular,
ArrowUpRegular,
BookmarkRegular,
PauseRegular,
PlayRegular,
CheckmarkCircleFilled, CheckmarkCircleFilled,
ErrorCircleFilled, ErrorCircleFilled,
EyeOffRegular EyeOffRegular
@@ -90,6 +96,8 @@ const useStyles = makeStyles({
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = { const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
queued: { label: 'Queued', color: 'subtle' }, queued: { label: 'Queued', color: 'subtle' },
downloading: { label: 'Downloading', color: 'brand' }, downloading: { label: 'Downloading', color: 'brand' },
paused: { label: 'Paused', color: 'warning' },
saved: { label: 'Saved', color: 'subtle' },
completed: { label: 'Completed', color: 'success' }, completed: { label: 'Completed', color: 'success' },
error: { label: 'Failed', color: 'danger' }, error: { label: 'Failed', color: 'danger' },
canceled: { label: 'Canceled', color: 'warning' } canceled: { label: 'Canceled', color: 'warning' }
@@ -99,9 +107,26 @@ function pct(progress: number): string {
return `${Math.round(progress * 100)}%` return `${Math.round(progress * 100)}%`
} }
export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element { function fmtSchedule(ms: number): string {
try {
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return ''
}
}
export const QueueItem = memo(function QueueItem({
item
}: {
item: DownloadItem
}): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const cancel = useDownloads((s) => s.cancel) 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 remove = useDownloads((s) => s.remove)
const retry = useDownloads((s) => s.retry) const retry = useDownloads((s) => s.retry)
const openFile = useDownloads((s) => s.openFile) const openFile = useDownloads((s) => s.openFile)
@@ -172,12 +197,79 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
</div> </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 && ( {item.status === 'error' && item.error && (
<Caption1 className={styles.error}>{item.error}</Caption1> <Caption1 className={styles.error}>{item.error}</Caption1>
)} )}
</div> </div>
<div className={styles.actions}> <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 && ( {active && (
<Hint label="Cancel" placement="top" align="end"> <Hint label="Cancel" placement="top" align="end">
<Button <Button
@@ -234,4 +326,4 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
</div> </div>
</div> </div>
) )
} })
+146 -15
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { import {
Field, Field,
Input, Input,
@@ -34,13 +34,15 @@ import {
PaintBucketRegular, PaintBucketRegular,
AccessibilityRegular, AccessibilityRegular,
ArrowSyncRegular, ArrowSyncRegular,
OpenRegular OpenRegular,
SearchRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import { import {
COOKIE_BROWSERS, COOKIE_BROWSERS,
type YtdlpVersionResult, type YtdlpVersionResult,
type YtdlpUpdateChannel, type YtdlpUpdateChannel,
type YtdlpUpdateResult, type YtdlpUpdateResult,
type FfmpegVersionResult,
type AppUpdateInfo, type AppUpdateInfo,
type MediaKind, type MediaKind,
type CookieSource, type CookieSource,
@@ -185,6 +187,28 @@ const UPDATE_CHANNEL_OPTIONS = [
export function SettingsView(): React.JSX.Element { export function SettingsView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
// Settings search (Phase O). Rather than thread a query through all ~11 cards,
// filter by toggling each card's `display` based on whether its text matches.
// The cards are the root's direct children; the search box (marked
// data-settings-search) is skipped. React never sets `style` on the cards, so
// our inline display survives their re-renders.
const [search, setSearch] = useState('')
const [noResults, setNoResults] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = rootRef.current
if (!root) return
const q = search.trim().toLowerCase()
let shown = 0
for (const el of Array.from(root.children) as HTMLElement[]) {
if (el.dataset.settingsSearch !== undefined) continue
const match = !q || (el.textContent ?? '').toLowerCase().includes(q)
el.style.display = match ? '' : 'none'
if (match) shown++
}
setNoResults(q !== '' && shown === 0)
}, [search])
const videoDir = useSettings((s) => s.videoDir) const videoDir = useSettings((s) => s.videoDir)
const audioDir = useSettings((s) => s.audioDir) const audioDir = useSettings((s) => s.audioDir)
const chooseDir = useSettings((s) => s.chooseDir) const chooseDir = useSettings((s) => s.chooseDir)
@@ -202,6 +226,8 @@ export function SettingsView(): React.JSX.Element {
const proxy = useSettings((s) => s.proxy) const proxy = useSettings((s) => s.proxy)
const rateLimit = useSettings((s) => s.rateLimit) const rateLimit = useSettings((s) => s.rateLimit)
const useAria2c = useSettings((s) => s.useAria2c) const useAria2c = useSettings((s) => s.useAria2c)
const youtubePlayerClient = useSettings((s) => s.youtubePlayerClient)
const youtubePoToken = useSettings((s) => s.youtubePoToken)
const cookieSource = useSettings((s) => s.cookieSource) const cookieSource = useSettings((s) => s.cookieSource)
const cookiesBrowser = useSettings((s) => s.cookiesBrowser) const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
const restrictFilenames = useSettings((s) => s.restrictFilenames) const restrictFilenames = useSettings((s) => s.restrictFilenames)
@@ -209,6 +235,10 @@ export function SettingsView(): React.JSX.Element {
const customCommandEnabled = useSettings((s) => s.customCommandEnabled) const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const defaultTemplateId = useSettings((s) => s.defaultTemplateId) const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
const notifyOnComplete = useSettings((s) => s.notifyOnComplete) const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
const minimizeToTray = useSettings((s) => s.minimizeToTray)
const autoUpdateYtdlp = useSettings((s) => s.autoUpdateYtdlp)
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
const update = useSettings((s) => s.update) const update = useSettings((s) => s.update)
const templates = useTemplates((s) => s.templates) const templates = useTemplates((s) => s.templates)
const errorEntries = useErrorLog((s) => s.entries) const errorEntries = useErrorLog((s) => s.entries)
@@ -219,8 +249,8 @@ export function SettingsView(): React.JSX.Element {
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null) const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
const [updateChannel, setUpdateChannel] = useState<YtdlpUpdateChannel>('stable')
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null) const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
@@ -251,6 +281,28 @@ export function SettingsView(): React.JSX.Element {
return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction)) return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
}, []) }, [])
useEffect(() => {
// Show the current yt-dlp version without a manual click, and reflect a
// background auto-update (which may run on launch) live in this panel.
window.api.getYtdlpVersion().then(setVersion).catch(() => {})
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
window.api.getFfmpegVersions().then(setFfmpeg).catch(() => {})
return window.api.onYtdlpAutoUpdateStatus((s) => {
if (s.phase === 'checking') {
setChecking(true)
return
}
setChecking(false)
if (s.version) setVersion({ ok: true, version: s.version })
if (s.checkedAt) useSettings.setState({ ytdlpLastUpdateCheck: s.checkedAt })
if (s.phase === 'updated') {
setUpdateResult({ ok: true, output: `Updated to yt-dlp ${s.version ?? ''}`.trim() })
} else if (s.phase === 'error' && s.error) {
setUpdateResult({ ok: false, error: s.error })
}
})
}, [])
async function checkAppUpdate(): Promise<void> { async function checkAppUpdate(): Promise<void> {
setAppChecking(true) setAppChecking(true)
setAppUpd(null) setAppUpd(null)
@@ -378,7 +430,7 @@ export function SettingsView(): React.JSX.Element {
setUpdating(true) setUpdating(true)
setUpdateResult(null) setUpdateResult(null)
try { try {
const result = await window.api.updateYtdlp(updateChannel) const result = await window.api.updateYtdlp(ytdlpChannel)
setUpdateResult(result) setUpdateResult(result)
if (result.ok) setVersion(null) // stale — prompt a re-check rather than show a wrong version if (result.ok) setVersion(null) // stale — prompt a re-check rather than show a wrong version
} catch (e) { } catch (e) {
@@ -389,7 +441,22 @@ export function SettingsView(): React.JSX.Element {
} }
return ( return (
<div className={styles.root}> <div className={styles.root} ref={rootRef}>
<div data-settings-search>
<Input
value={search}
onChange={(_, d) => setSearch(d.value)}
placeholder="Search settings…"
contentBefore={<SearchRegular />}
style={{ width: '100%' }}
/>
{noResults && (
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}>
No settings match {search}.
</Caption1>
)}
</div>
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<ArrowDownloadRegular className={styles.sectionIcon} /> <ArrowDownloadRegular className={styles.sectionIcon} />
@@ -491,6 +558,17 @@ export function SettingsView(): React.JSX.Element {
label={notifyOnComplete ? 'On' : 'Off'} label={notifyOnComplete ? 'On' : 'Off'}
/> />
</Field> </Field>
<Field
label="Keep running in the tray"
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting. Use the tray icon to reopen or quit."
>
<Switch
checked={minimizeToTray}
onChange={(_, d) => update({ minimizeToTray: d.checked })}
label={minimizeToTray ? 'On' : 'Off'}
/>
</Field>
</Card> </Card>
<Card className={styles.card}> <Card className={styles.card}>
@@ -599,6 +677,28 @@ export function SettingsView(): React.JSX.Element {
label={useAria2c ? 'On' : 'Off'} label={useAria2c ? 'On' : 'Off'}
/> />
</Field> </Field>
<Field
label="YouTube client (advanced)"
hint="Override the YouTube extraction client when downloads start failing the bot check. Try web_safari, tv, or mweb. Leave blank for yt-dlp's default."
>
<Input
value={youtubePlayerClient}
placeholder="web_safari"
onChange={(_, d) => update({ youtubePlayerClient: d.value })}
/>
</Field>
<Field
label="YouTube PO token (advanced)"
hint="A manually-obtained Proof-of-Origin token for YouTube's bot check, sent via --extractor-args. Usually cookies (above) are the easier fix; automatic minting is planned."
>
<Input
value={youtubePoToken}
placeholder="(none)"
onChange={(_, d) => update({ youtubePoToken: d.value })}
/>
</Field>
</Card> </Card>
<Card className={styles.card}> <Card className={styles.card}>
@@ -900,14 +1000,21 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>About</Subtitle2> <Subtitle2>About</Subtitle2>
</div> </div>
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles yt-dlp and ffmpeg. AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its
own copy of yt-dlp, keeping it up to date automatically.
</Caption1> </Caption1>
<div className={styles.folderRow}>
<Button onClick={checkVersion} disabled={checking}> <Field
Check yt-dlp version label="Keep yt-dlp updated automatically"
</Button> hint="Checks once a day on launch and updates yt-dlp in the background, so YouTube changes don't start breaking downloads with 403 errors."
{checking && <Spinner size="tiny" />} >
</div> <Switch
checked={autoUpdateYtdlp}
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
label={autoUpdateYtdlp ? 'On' : 'Off'}
/>
</Field>
{version?.ok && ( {version?.ok && (
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text> <Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
)} )}
@@ -916,13 +1023,37 @@ export function SettingsView(): React.JSX.Element {
{version.error} {version.error}
</Caption1> </Caption1>
)} )}
{ffmpeg && (
<>
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
</Text>
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
ffprobe {ffmpeg.ffprobe ?? 'not found'}
</Text>
</>
)}
{ytdlpLastUpdateCheck > 0 && (
<Caption1 className={styles.hint}>
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
</Caption1>
)}
<div className={styles.folderRow}>
<Button onClick={checkVersion} disabled={checking}>
Check yt-dlp version
</Button>
{checking && <Spinner size="tiny" />}
</div>
<Field label="Update channel" hint="Nightly tracks yt-dlp's daily build; stable is the tagged release."> <Field
label="Update channel"
hint="Nightly tracks yt-dlp's daily build; stable is the tagged release. Nightly follows YouTube changes fastest."
>
<Select <Select
aria-label="Update channel" aria-label="Update channel"
value={updateChannel} value={ytdlpChannel}
options={UPDATE_CHANNEL_OPTIONS} options={UPDATE_CHANNEL_OPTIONS}
onChange={(v) => setUpdateChannel(v as YtdlpUpdateChannel)} onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
/> />
</Field> </Field>
<div className={styles.folderRow}> <div className={styles.folderRow}>
+3 -1
View File
@@ -4,6 +4,7 @@ import {
ArrowDownloadRegular, ArrowDownloadRegular,
HistoryRegular, HistoryRegular,
LibraryRegular, LibraryRegular,
WindowConsoleRegular,
SettingsRegular, SettingsRegular,
WeatherMoonRegular, WeatherMoonRegular,
WeatherSunnyRegular, WeatherSunnyRegular,
@@ -14,7 +15,7 @@ import {
import type { ThemeMode } from '@shared/ipc' import type { ThemeMode } from '@shared/ipc'
import { Hint } from './Hint' import { Hint } from './Hint'
export type TabValue = 'downloads' | 'library' | 'history' | 'settings' export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -179,6 +180,7 @@ const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> }, { value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
{ value: 'library', label: 'Library', icon: <LibraryRegular /> }, { value: 'library', label: 'Library', icon: <LibraryRegular /> },
{ value: 'history', label: 'History', icon: <HistoryRegular /> }, { value: 'history', label: 'History', icon: <HistoryRegular /> },
{ value: 'terminal', label: 'Terminal', icon: <WindowConsoleRegular /> },
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> } { value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
] ]
@@ -74,8 +74,10 @@ interface Draft {
id: string | null id: string | null
name: string name: string
args: string args: string
/** optional regex; auto-applies the template to matching URLs */
urlPattern: string
} }
const BLANK_DRAFT: Draft = { id: null, name: '', args: '' } const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
function newId(): string { function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}` return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
@@ -95,14 +97,20 @@ export function TemplateManager(): React.JSX.Element {
const [draft, setDraft] = useState<Draft | null>(null) const [draft, setDraft] = useState<Draft | null>(null)
function startEdit(t: CommandTemplate): void { function startEdit(t: CommandTemplate): void {
setDraft({ id: t.id, name: t.name, args: t.args }) setDraft({ id: t.id, name: t.name, args: t.args, urlPattern: t.urlPattern ?? '' })
} }
function commit(): void { function commit(): void {
if (!draft) return if (!draft) return
const name = draft.name.trim() const name = draft.name.trim()
if (!name) return if (!name) return
save({ id: draft.id ?? newId(), name, args: draft.args.trim() }) const urlPattern = draft.urlPattern.trim()
save({
id: draft.id ?? newId(),
name,
args: draft.args.trim(),
...(urlPattern ? { urlPattern } : {})
})
setDraft(null) setDraft(null)
} }
@@ -154,6 +162,11 @@ export function TemplateManager(): React.JSX.Element {
resize="vertical" resize="vertical"
onChange={(_, d) => setDraft({ ...draft, args: d.value })} onChange={(_, d) => setDraft({ ...draft, args: d.value })}
/> />
<Input
value={draft.urlPattern}
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com"
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })}
/>
<div className={styles.formActions}> <div className={styles.formActions}>
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}> <Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
Cancel Cancel
@@ -0,0 +1,203 @@
import { useEffect, useRef, useState } from 'react'
import {
Button,
Textarea,
Subtitle2,
Body1,
Caption1,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line {
text: string
kind: LineKind
}
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
sub: { color: tokens.colorNeutralForeground3 },
gate: {
padding: '12px 14px',
backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
inputRow: { display: 'flex', gap: '8px', alignItems: 'flex-end' },
inputCol: { flexGrow: 1, display: 'flex', flexDirection: 'column', gap: '4px' },
prefix: { color: tokens.colorNeutralForeground3, fontFamily: tokens.fontFamilyMonospace },
buttons: { display: 'flex', gap: '8px' },
log: {
flexGrow: 1,
minHeight: '160px',
overflowY: 'auto',
margin: 0,
padding: '12px 14px',
backgroundColor: tokens.colorNeutralBackground1,
color: tokens.colorNeutralForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
lineHeight: tokens.lineHeightBase300,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
},
cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
stderr: { color: tokens.colorPaletteRedForeground1 },
sys: { color: tokens.colorNeutralForeground3 },
empty: { color: tokens.colorNeutralForeground3 }
})
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `t-${Date.now()}`
}
/**
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
* binary, and watch its output stream. Gated on the customCommandEnabled consent
* flag (enforced again in main — see src/main/terminal.ts).
*/
export function TerminalView(): React.JSX.Element {
const styles = useStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const [args, setArgs] = useState('')
const [lines, setLines] = useState<Line[]>([])
const [running, setRunning] = useState(false)
const runId = useRef<string | null>(null)
const logRef = useRef<HTMLPreElement>(null)
// Stream terminal output for the current run into the log.
useEffect(
() =>
window.api.onTerminalOutput((ev) => {
if (ev.id !== runId.current) return
if (ev.type === 'output') {
setLines((ls) => [...ls, { text: ev.line, kind: ev.stream }])
} else if (ev.type === 'error') {
setLines((ls) => [...ls, { text: ev.error, kind: 'stderr' }])
setRunning(false)
runId.current = null
} else {
setLines((ls) => [...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' }])
setRunning(false)
runId.current = null
}
}),
[]
)
// Keep the log pinned to the latest output.
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
}, [lines])
function run(): void {
const a = args.trim()
if (!a || running) return
const id = newId()
runId.current = id
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
setRunning(true)
window.api
.runTerminal(id, a)
.then((res) => {
if (!res.ok) {
setLines((ls) => [...ls, { text: res.error ?? 'Failed to start.', kind: 'stderr' }])
setRunning(false)
runId.current = null
}
})
.catch((e: unknown) => {
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }])
setRunning(false)
runId.current = null
})
}
function stop(): void {
if (runId.current) window.api.cancelTerminal(runId.current)
}
const lineClass = (kind: LineKind): string | undefined =>
kind === 'cmd' ? styles.cmd : kind === 'stderr' ? styles.stderr : kind === 'sys' ? styles.sys : undefined
return (
<div className={styles.root}>
<div className={styles.header}>
<Subtitle2>Terminal</Subtitle2>
<Caption1 className={styles.sub}>
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
</Caption1>
</div>
{!customCommandEnabled && (
<Body1 className={styles.gate}>
The terminal is part of custom commands. Turn on Run custom commands in Settings
Custom commands to use it.
</Body1>
)}
<div className={styles.inputRow}>
<div className={styles.inputCol}>
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
<Textarea
value={args}
onChange={(_, d) => setArgs(d.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
run()
}
}}
placeholder="--version (Ctrl+Enter to run)"
resize="vertical"
disabled={!customCommandEnabled}
/>
</div>
<div className={styles.buttons}>
{running ? (
<Button appearance="secondary" icon={<DismissRegular />} onClick={stop}>
Stop
</Button>
) : (
<Button
appearance="primary"
icon={<PlayRegular />}
onClick={run}
disabled={!customCommandEnabled || !args.trim()}
>
Run
</Button>
)}
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setLines([])}
disabled={lines.length === 0}
aria-label="Clear output"
/>
</div>
</div>
<pre className={styles.log} ref={logRef}>
{lines.length === 0 ? (
<span className={styles.empty}>Output will appear here.</span>
) : (
lines.map((l, i) => (
<div key={i} className={lineClass(l.kind)}>
{l.text}
</div>
))
)}
</pre>
</div>
)
}
@@ -0,0 +1,70 @@
import { useRef, type CSSProperties, type ReactNode } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
/**
* Windowed list: renders only the rows near the viewport, so a list thousands of
* items long stays light — one DOM subtree per visible row, not per item. This
* keeps the Downloads queue and a big channel's Library list responsive when a
* source has 1000s of videos.
*
* It owns its own scroll container, so size that container via `style`/`className`
* (e.g. `flex: 1` + `minHeight: 0`, or a `maxHeight`). Owning the scroller means
* the virtualizer anchors at scroll offset 0 and never has to track where the
* list sits within the page — robust even though the app shares one outer
* scroll area with content above each list.
*
* Row heights are measured (measureElement), so variable-height rows are fine;
* `estimateSize` only needs to be a rough first guess before measurement.
*/
export function VirtualList<T>({
items,
estimateSize,
getKey,
renderItem,
overscan = 8,
gap = 0,
className,
style
}: {
items: T[]
estimateSize: (index: number) => number
getKey: (item: T, index: number) => string
renderItem: (item: T, index: number) => ReactNode
overscan?: number
gap?: number
className?: string
style?: CSSProperties
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize,
overscan,
gap,
getItemKey: (index) => getKey(items[index], index)
})
return (
<div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
{virtualizer.getVirtualItems().map((vrow) => (
<div
key={vrow.key}
data-index={vrow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vrow.start}px)`
}}
>
{renderItem(items[vrow.index], vrow.index)}
</div>
))}
</div>
</div>
)
}
+16 -2
View File
@@ -27,13 +27,19 @@ if (import.meta.env.DEV && !window.api) {
useAria2c: false, useAria2c: false,
cookieSource: 'none', cookieSource: 'none',
cookiesBrowser: 'chrome', cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false, restrictFilenames: false,
downloadArchive: false, downloadArchive: false,
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
customCommandEnabled: false, customCommandEnabled: false,
defaultTemplateId: null, defaultTemplateId: null,
notifyOnComplete: true, notifyOnComplete: true,
autoDownloadNew: true, autoDownloadNew: true,
hasCompletedOnboarding: true hasCompletedOnboarding: true,
minimizeToTray: false
} }
// Stands in for the cookies.txt file's mtime — lets the Cookies card's // Stands in for the cookies.txt file's mtime — lets the Cookies card's
// sign-in/clear flow be exercised in this browser-only preview. // sign-in/clear flow be exercised in this browser-only preview.
@@ -67,6 +73,7 @@ if (import.meta.env.DEV && !window.api) {
runAppUpdate: async () => ({ ok: true }), runAppUpdate: async () => ({ ok: true }),
onAppUpdateProgress: () => () => {}, onAppUpdateProgress: () => () => {},
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }), getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
getFfmpegVersions: async () => ({ ffmpeg: '7.1-full_build (UI preview mock)', ffprobe: '7.1-full_build (UI preview mock)' }),
probe: async (url: string) => { probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency await new Promise((r) => setTimeout(r, 700)) // simulate network latency
// A 'list'/'playlist' URL exercises the playlist selection UI in preview. // A 'list'/'playlist' URL exercises the playlist selection UI in preview.
@@ -109,6 +116,7 @@ if (import.meta.env.DEV && !window.api) {
}, },
startDownload: async () => ({ ok: true }), startDownload: async () => ({ ok: true }),
cancelDownload: async () => {}, cancelDownload: async () => {},
pauseDownload: async () => {},
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads', getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
chooseFolder: async () => null, chooseFolder: async () => null,
openPath: async () => '', openPath: async () => '',
@@ -154,6 +162,8 @@ if (import.meta.env.DEV && !window.api) {
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
return { ok: true, output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)` } return { ok: true, output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)` }
}, },
// No background updater in the browser preview — hand back an inert unsubscribe.
onYtdlpAutoUpdateStatus: () => () => {},
listErrorLog: async () => [], listErrorLog: async () => [],
clearErrorLog: async () => [], clearErrorLog: async () => [],
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }), exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
@@ -213,7 +223,11 @@ if (import.meta.env.DEV && !window.api) {
durationLabel: `${10 + i}:0${i}`, durationLabel: `${10 + i}:0${i}`,
downloaded: i < 2 downloaded: i < 2
})), })),
onIndexProgress: () => () => {} onIndexProgress: () => () => {},
runTerminal: async () => ({ ok: true }),
cancelTerminal: async () => {},
onTerminalOutput: () => () => {},
setTaskbarProgress: () => {}
} }
} }
+210 -48
View File
@@ -6,7 +6,14 @@ import { useSources } from './sources'
export type MediaKind = 'video' | 'audio' 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). */ /** An exact probed format chosen for a download (omitted for the preset path). */
export interface ChosenFormat { export interface ChosenFormat {
@@ -39,6 +46,10 @@ export interface DownloadItem {
options?: DownloadOptions options?: DownloadOptions
/** per-download custom-command override (omitted = use the persisted default template) */ /** per-download custom-command override (omitted = use the persisted default template) */
extraArgs?: string 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 */ /** private download — completion is never recorded to history */
incognito?: boolean incognito?: boolean
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */ /** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
@@ -54,30 +65,59 @@ export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps'] audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps']
} }
/** Options accepted when enqueuing a URL (shared by addFromUrl + addMany). */
export interface AddOptions {
format?: ChosenFormat
title?: string
channel?: string
durationLabel?: string
thumbnail?: string
options?: DownloadOptions
extraArgs?: string
trim?: string
/** when set and in the future, the item starts 'saved' and auto-queues at this time */
scheduledFor?: number
incognito?: boolean
collection?: CollectionContext
mediaItemId?: string
}
/** One URL to enqueue in a single addMany batch. */
export interface AddEntry {
url: string
kind: MediaKind
quality: string
opts?: AddOptions
}
// True when running in the standalone browser preview (no Electron preload). // True when running in the standalone browser preview (no Electron preload).
// The real preload injects window.electron; the browser stub does not. // The real preload injects window.electron; the browser stub does not.
const PREVIEW = typeof window === 'undefined' || !window.electron const PREVIEW = typeof window === 'undefined' || !window.electron
interface DownloadState { interface DownloadState {
items: DownloadItem[] items: DownloadItem[]
addFromUrl: ( addFromUrl: (url: string, kind: MediaKind, quality: string, opts?: AddOptions) => void
url: string, /**
kind: MediaKind, * Enqueue many URLs at once with a single state update and a single pump, so
quality: string, * queuing an entire channel (1000s of items) doesn't churn the store O(n²) the
opts?: { * way looping addFromUrl would.
format?: ChosenFormat */
title?: string addMany: (entries: AddEntry[]) => void
channel?: string
durationLabel?: string
thumbnail?: string
options?: DownloadOptions
extraArgs?: string
incognito?: boolean
collection?: CollectionContext
mediaItemId?: string
}
) => void
retry: (id: string) => 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 cancel: (id: string) => void
remove: (id: string) => void remove: (id: string) => void
clearFinished: () => void clearFinished: () => void
@@ -95,6 +135,44 @@ function newId(): string {
return `item-${++idCounter}` return `item-${++idCounter}`
} }
/**
* Build a fresh queued DownloadItem from a URL + options. Pure (no store access)
* so addFromUrl and addMany share it. If the caller already probed the URL, its
* metadata is carried as probedMeta so main can skip a redundant probe (audit P2).
*/
function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOptions): DownloadItem {
const probedMeta: DownloadMeta | undefined =
opts?.title || opts?.channel || opts?.durationLabel
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
: undefined
// A future scheduled time parks the item as 'saved' until its moment arrives;
// a past/absent time starts it queued as usual.
const scheduledFor =
opts?.scheduledFor && opts.scheduledFor > Date.now() ? opts.scheduledFor : undefined
return {
id: newId(),
url,
title: opts?.title ?? titleFromUrl(url),
channel: opts?.channel ?? 'Resolving…',
durationLabel: opts?.durationLabel,
thumbnail: opts?.thumbnail,
kind,
quality,
status: scheduledFor ? 'saved' : 'queued',
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio,
options: opts?.options,
extraArgs: opts?.extraArgs,
trim: opts?.trim,
scheduledFor,
incognito: opts?.incognito,
collection: opts?.collection,
mediaItemId: opts?.mediaItemId,
probedMeta
}
}
function titleFromUrl(url: string): string { function titleFromUrl(url: string): string {
try { try {
const u = new URL(url) const u = new URL(url)
@@ -253,6 +331,7 @@ export const useDownloads = create<DownloadState>((set, get) => {
formatHasAudio: item.formatHasAudio, formatHasAudio: item.formatHasAudio,
options: item.options, options: item.options,
extraArgs: item.extraArgs, extraArgs: item.extraArgs,
trim: item.trim,
meta: item.probedMeta, meta: item.probedMeta,
collection: item.collection collection: item.collection
}) })
@@ -291,35 +370,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
pump, pump,
addFromUrl: (url, kind, quality, opts) => { addFromUrl: (url, kind, quality, opts) => {
const id = newId() set((s) => ({ items: [buildItem(url, kind, quality, opts), ...s.items] }))
// If the caller already probed the URL, carry that metadata so main can skip pump()
// its own redundant probe (audit P2). Only set it when something real was },
// provided — a bare paste with no probe leaves it undefined.
const probedMeta: DownloadMeta | undefined = addMany: (entries) => {
opts?.title || opts?.channel || opts?.durationLabel if (entries.length === 0) return
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel } const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts))
: undefined set((s) => ({ items: [...built, ...s.items] }))
const item: DownloadItem = {
id,
url,
title: opts?.title ?? titleFromUrl(url),
channel: opts?.channel ?? 'Resolving…',
durationLabel: opts?.durationLabel,
thumbnail: opts?.thumbnail,
kind,
quality,
status: 'queued',
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio,
options: opts?.options,
extraArgs: opts?.extraArgs,
incognito: opts?.incognito,
collection: opts?.collection,
mediaItemId: opts?.mediaItemId,
probedMeta
}
set((s) => ({ items: [item, ...s.items] }))
pump() pump()
}, },
@@ -334,6 +392,93 @@ export const useDownloads = create<DownloadState>((set, get) => {
pump() 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) => { cancel: (id) => {
const cur = get().items.find((i) => i.id === id) const cur = get().items.find((i) => i.id === id)
if (!cur) return if (!cur) return
@@ -356,7 +501,13 @@ export const useDownloads = create<DownloadState>((set, get) => {
clearFinished: () => clearFinished: () =>
set((s) => ({ 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) => { openFile: (id) => {
@@ -438,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.) // Wire main → renderer push events. (Output folder + cap live in the settings store.)
if (!PREVIEW) { if (!PREVIEW) {
window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev)) 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)
}
+7 -1
View File
@@ -21,15 +21,21 @@ const FALLBACK: Settings = {
useAria2c: false, useAria2c: false,
cookieSource: 'none', cookieSource: 'none',
cookiesBrowser: 'chrome', cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false, restrictFilenames: false,
downloadArchive: false, downloadArchive: false,
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false, customCommandEnabled: false,
defaultTemplateId: null, defaultTemplateId: null,
notifyOnComplete: true, notifyOnComplete: true,
autoDownloadNew: true, autoDownloadNew: true,
// True in preview so design work isn't blocked behind the welcome screen; // True in preview so design work isn't blocked behind the welcome screen;
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead. // a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
hasCompletedOnboarding: PREVIEW hasCompletedOnboarding: PREVIEW,
minimizeToTray: false
} }
interface SettingsState extends Settings { interface SettingsState extends Settings {
+24 -23
View File
@@ -6,11 +6,6 @@ import { useSettings } from './settings'
// True in the standalone browser preview (no Electron preload). // True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron const PREVIEW = typeof window === 'undefined' || !window.electron
// Cap on how many items one "Download" click enqueues, so an entire channel
// can't flood the live queue at once — the rest stay pending for a follow-up
// click. (The automatic incremental feeder is Phase I; see ROADMAP-PINCHFLAT.md.)
export const MAX_ENQUEUE_BATCH = 100
// --- Preview seed data ------------------------------------------------------ // --- Preview seed data ------------------------------------------------------
function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] { function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] {
@@ -87,8 +82,10 @@ interface SourcesState {
reindexSource: (id: string) => Promise<void> reindexSource: (id: string) => Promise<void>
removeSource: (id: string) => void removeSource: (id: string) => void
/** /**
* Enqueue the given media items into the download queue with folder context, * Enqueue the given media items into the download queue with folder context.
* capped at MAX_ENQUEUE_BATCH. Returns how many were actually enqueued. * Queues all of them — one click can fill the queue with an entire channel,
* which maxConcurrent then gates into download slots. Returns how many were
* enqueued.
*/ */
enqueueItems: (sourceId: string, items: MediaItem[]) => number enqueueItems: (sourceId: string, items: MediaItem[]) => number
/** mark an item downloaded once its queue download completes (called by the downloads store) */ /** mark an item downloaded once its queue download completes (called by the downloads store) */
@@ -185,22 +182,26 @@ export const useSources = create<SourcesState>((set, get) => ({
const settings = useSettings.getState() const settings = useSettings.getState()
const kind = settings.defaultKind const kind = settings.defaultKind
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
const batch = items.slice(0, MAX_ENQUEUE_BATCH) // One batched state update + pump, so queuing a whole channel stays O(n).
const add = useDownloads.getState().addFromUrl useDownloads.getState().addMany(
for (const it of batch) { items.map((it) => ({
add(it.url, kind, quality, { url: it.url,
title: it.title, kind,
channel: src?.channel, quality,
durationLabel: it.durationLabel, opts: {
collection: { title: it.title,
channel: src?.channel || src?.title || 'Channel', channel: src?.channel,
playlist: it.playlistTitle, durationLabel: it.durationLabel,
index: it.playlistIndex collection: {
}, channel: src?.channel || src?.title || 'Channel',
mediaItemId: it.id playlist: it.playlistTitle,
}) index: it.playlistIndex
} },
return batch.length mediaItemId: it.id
}
}))
)
return items.length
}, },
markDownloaded: (itemId, filePath) => { markDownloaded: (itemId, filePath) => {
+112 -1
View File
@@ -15,9 +15,11 @@ export const IpcChannels = {
/** main → renderer push channel for installer download progress */ /** main → renderer push channel for installer download progress */
appUpdateProgress: 'app:update-progress', appUpdateProgress: 'app:update-progress',
ytdlpVersion: 'ytdlp:version', ytdlpVersion: 'ytdlp:version',
ffmpegVersion: 'ffmpeg:version',
probe: 'media:probe', probe: 'media:probe',
downloadStart: 'download:start', downloadStart: 'download:start',
downloadCancel: 'download:cancel', downloadCancel: 'download:cancel',
downloadPause: 'download:pause',
defaultFolder: 'download:default-folder', defaultFolder: 'download:default-folder',
chooseFolder: 'download:choose-folder', chooseFolder: 'download:choose-folder',
openPath: 'shell:open-path', openPath: 'shell:open-path',
@@ -40,6 +42,8 @@ export const IpcChannels = {
templatesRemove: 'templates:remove', templatesRemove: 'templates:remove',
commandPreview: 'command:preview', commandPreview: 'command:preview',
ytdlpUpdate: 'ytdlp:update', ytdlpUpdate: 'ytdlp:update',
/** main → renderer push channel for background yt-dlp auto-update status */
ytdlpAutoUpdateStatus: 'ytdlp:auto-update-status',
errorLogList: 'errorlog:list', errorLogList: 'errorlog:list',
errorLogClear: 'errorlog:clear', errorLogClear: 'errorlog:clear',
backupExport: 'backup:export', backupExport: 'backup:export',
@@ -67,7 +71,14 @@ export const IpcChannels = {
scheduledSyncGet: 'sources:scheduled-sync-get', scheduledSyncGet: 'sources:scheduled-sync-get',
scheduledSyncSet: 'sources:scheduled-sync-set', scheduledSyncSet: 'sources:scheduled-sync-set',
/** main → renderer push channel for live indexing progress */ /** main → renderer push channel for live indexing progress */
indexProgress: 'sources:index-progress' indexProgress: 'sources:index-progress',
// --- Built-in yt-dlp terminal (Phase N) ---
terminalRun: 'terminal:run',
terminalCancel: 'terminal:cancel',
/** main → renderer push channel for live terminal output lines */
terminalOutput: 'terminal:output',
/** renderer → main: reflect overall queue progress on the Windows taskbar (Phase O) */
taskbarProgress: 'taskbar:progress'
} as const } as const
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */ /** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
@@ -143,6 +154,13 @@ export interface DownloadOptions {
videoContainer: VideoContainer videoContainer: VideoContainer
/** preferred video codec (sort tiebreaker, not a hard filter) */ /** preferred video codec (sort tiebreaker, not a hard filter) */
preferredVideoCodec: VideoCodecPref preferredVideoCodec: VideoCodecPref
/**
* Advanced: a raw yt-dlp `-S` format-sort string (e.g. 'res:1080,vcodec:av01,size').
* When non-empty it replaces the codec-derived sort, letting power users rank
* resolution / codec / container / size by weighted priority. Empty = use the
* preferredVideoCodec tiebreaker.
*/
formatSort: string
/** download + embed subtitles into video */ /** download + embed subtitles into video */
embedSubtitles: boolean embedSubtitles: boolean
/** comma-separated yt-dlp sub-lang selectors, e.g. 'en' or 'en.*,es' */ /** comma-separated yt-dlp sub-lang selectors, e.g. 'en' or 'en.*,es' */
@@ -155,6 +173,8 @@ export interface DownloadOptions {
sponsorBlockCategories: SponsorBlockCategory[] sponsorBlockCategories: SponsorBlockCategory[]
/** embed chapter markers */ /** embed chapter markers */
embedChapters: boolean embedChapters: boolean
/** split the download into one file per chapter (--split-chapters) */
splitChapters: boolean
/** embed metadata (title/artist/etc.) */ /** embed metadata (title/artist/etc.) */
embedMetadata: boolean embedMetadata: boolean
/** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */ /** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */
@@ -173,6 +193,7 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
audioFormat: 'mp3', audioFormat: 'mp3',
videoContainer: 'mp4', videoContainer: 'mp4',
preferredVideoCodec: 'any', preferredVideoCodec: 'any',
formatSort: '',
embedSubtitles: false, embedSubtitles: false,
subtitleLanguages: 'en', subtitleLanguages: 'en',
autoSubtitles: false, autoSubtitles: false,
@@ -180,6 +201,7 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
sponsorBlockMode: 'remove', sponsorBlockMode: 'remove',
sponsorBlockCategories: ['sponsor'], sponsorBlockCategories: ['sponsor'],
embedChapters: false, embedChapters: false,
splitChapters: false,
embedMetadata: true, embedMetadata: true,
embedThumbnail: true, embedThumbnail: true,
cropThumbnail: false, cropThumbnail: false,
@@ -196,6 +218,18 @@ export interface YtdlpVersionResult {
error?: string error?: string
} }
/**
* Versions of the bundled ffmpeg + ffprobe, for display in Settings. Each is the
* parsed version token, or null when that binary is missing or unreadable. These
* ship with AeroFetch and aren't auto-updated (ffmpeg has no self-update and,
* unlike yt-dlp, doesn't break as sites change), so there's no ok/error here —
* just "what's installed".
*/
export interface FfmpegVersionResult {
ffmpeg: string | null
ffprobe: string | null
}
/** Result of checking the configured Gitea repo for a newer AeroFetch release. */ /** Result of checking the configured Gitea repo for a newer AeroFetch release. */
export interface AppUpdateInfo { export interface AppUpdateInfo {
ok: boolean ok: boolean
@@ -260,6 +294,24 @@ export interface YtdlpUpdateResult {
error?: string error?: string
} }
/**
* Status of a background (startup) yt-dlp auto-update, pushed main → renderer on
* IpcChannels.ytdlpAutoUpdateStatus so the Settings UI can reflect a check that
* ran on its own — no button click needed.
*/
export interface YtdlpAutoUpdateStatus {
/** 'checking' when the update starts; the rest are terminal */
phase: 'checking' | 'updated' | 'current' | 'error'
/** which channel was checked */
channel: YtdlpUpdateChannel
/** yt-dlp version after the run, when known (phases 'updated' | 'current') */
version?: string
/** epoch ms the check finished (terminal phases) — feeds the "last checked" line */
checkedAt?: number
/** error text when phase === 'error' */
error?: string
}
/** A single selectable output format, derived from `yt-dlp -J`. */ /** A single selectable output format, derived from `yt-dlp -J`. */
export interface FormatOption { export interface FormatOption {
/** yt-dlp format_id, or BEST_FORMAT_ID for the auto-best option */ /** yt-dlp format_id, or BEST_FORMAT_ID for the auto-best option */
@@ -356,6 +408,13 @@ export interface StartDownloadOptions {
* download even when the settings default is enabled. * download even when the settings default is enabled.
*/ */
extraArgs?: string 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/ * Metadata the renderer already fetched when probing the URL (title/channel/
* duration). When present, main uses it directly instead of spawning a second * duration). When present, main uses it directly instead of spawning a second
@@ -433,10 +492,27 @@ export interface Settings {
cookieSource: CookieSource cookieSource: CookieSource
/** which browser to read cookies from when cookieSource is 'browser' */ /** which browser to read cookies from when cookieSource is 'browser' */
cookiesBrowser: CookieBrowser cookiesBrowser: CookieBrowser
/**
* YouTube reliability (Phase P). When set, emitted as
* `--extractor-args "youtube:player_client=…;po_token=…"`:
* - playerClient: an alternate extraction client (e.g. 'web_safari', 'tv',
* 'mweb') — a common workaround for YouTube throttling/extraction breakage.
* - poToken: a manually-supplied Proof-of-Origin token for the bot check.
* Both empty = yt-dlp's defaults. (Automatic WebView token minting is deferred;
* this is the argv plumbing it would feed.)
*/
youtubePlayerClient: string
youtubePoToken: string
/** sanitize output filenames to a portable ASCII-only subset (--restrict-filenames) */ /** sanitize output filenames to a portable ASCII-only subset (--restrict-filenames) */
restrictFilenames: boolean restrictFilenames: boolean
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */ /** record completed downloads in a yt-dlp --download-archive file to skip repeats */
downloadArchive: boolean downloadArchive: boolean
/** keep the managed yt-dlp.exe auto-updated in the background on launch */
autoUpdateYtdlp: boolean
/** channel the auto-updater (and the manual "Update yt-dlp" button) updates to */
ytdlpChannel: YtdlpUpdateChannel
/** epoch ms of the last automatic yt-dlp update check; 0 = never run */
ytdlpLastUpdateCheck: number
/** apply a named custom-command template's extra args to every new download */ /** apply a named custom-command template's extra args to every new download */
customCommandEnabled: boolean customCommandEnabled: boolean
/** id of the CommandTemplate applied when customCommandEnabled is on; null = none picked yet */ /** id of the CommandTemplate applied when customCommandEnabled is on; null = none picked yet */
@@ -447,6 +523,8 @@ export interface Settings {
autoDownloadNew: boolean autoDownloadNew: boolean
/** false until the first-run welcome screen has been dismissed */ /** false until the first-run welcome screen has been dismissed */
hasCompletedOnboarding: boolean hasCompletedOnboarding: boolean
/** keep AeroFetch running in the system tray when its window is closed (Phase O) */
minimizeToTray: boolean
} }
/** /**
@@ -459,6 +537,13 @@ export interface CommandTemplate {
id: string id: string
name: string name: string
args: string args: string
/**
* Optional regex (matched case-insensitively against the download URL). When
* set and custom commands are enabled, this template auto-applies to any URL it
* matches — taking precedence over the global defaultTemplateId. Empty/undefined
* means the template is only applied when explicitly chosen as the default.
*/
urlPattern?: string
} }
/** Result of building the exact yt-dlp command line for the current form state, without running it. */ /** Result of building the exact yt-dlp command line for the current form state, without running it. */
@@ -621,3 +706,29 @@ export interface ScheduledSyncStatus {
enabled: boolean enabled: boolean
error?: string error?: string
} }
// --- Built-in yt-dlp terminal (Phase N) -------------------------------------
// A power-user console that runs the bundled yt-dlp with raw, user-typed args
// and streams its output. The binary is fixed to yt-dlp (never arbitrary exes),
// and the feature is gated on the same customCommandEnabled consent flag as the
// per-download extra args (extra args can run code via --exec — audit F2).
/** Live output pushed on IpcChannels.terminalOutput while a terminal command runs. */
export type TerminalEvent =
| { type: 'output'; id: string; line: string; stream: 'stdout' | 'stderr' }
| { type: 'done'; id: string; code: number | null }
| { type: 'error'; id: string; error: string }
/** Result of starting a terminal command (pre-spawn validation only). */
export interface TerminalRunResult {
ok: boolean
error?: string
}
/** Overall queue state for the Windows taskbar progress bar (Phase O). */
export interface TaskbarProgress {
/** 0..1 overall fraction; ignored when mode is 'none' */
fraction: number
/** taskbar bar mode: hidden, normal, or red (some failed) */
mode: 'none' | 'normal' | 'error'
}
+142
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import { import {
buildArgs, buildArgs,
parseExtraArgs, parseExtraArgs,
parseTrimSections,
selectExtraArgs, selectExtraArgs,
formatCommandLine, formatCommandLine,
sanitizeDirSegment, sanitizeDirSegment,
@@ -460,6 +461,147 @@ describe('sidecar files', () => {
}) })
}) })
describe('YouTube extractor-args', () => {
it('combines player_client and po_token into one youtube: group', () => {
const argv = build(opts(), dlo(), {
...NO_ACCESS,
youtubePlayerClient: 'web_safari',
youtubePoToken: 'web.gvs+ABC'
})
expect(hasSeq(argv, '--extractor-args', 'youtube:player_client=web_safari;po_token=web.gvs+ABC')).toBe(
true
)
})
it('emits only the fields that are set', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, youtubePlayerClient: 'tv' })
expect(hasSeq(argv, '--extractor-args', 'youtube:player_client=tv')).toBe(true)
})
it('emits nothing when both are empty', () => {
expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--extractor-args')
})
})
describe('format sorting (-S)', () => {
it('emits a raw -S string when formatSort is set, overriding the codec tiebreaker', () => {
const argv = build(opts(), dlo({ formatSort: 'res:1080,vcodec:av01', preferredVideoCodec: 'h264' }))
expect(hasSeq(argv, '-S', 'res:1080,vcodec:av01')).toBe(true)
expect(hasSeq(argv, '-S', 'res,fps,vcodec:h264')).toBe(false)
})
it('falls back to the codec tiebreaker when formatSort is empty', () => {
const argv = build(opts(), dlo({ formatSort: '', preferredVideoCodec: 'vp9' }))
expect(hasSeq(argv, '-S', 'res,fps,vcodec:vp9')).toBe(true)
})
})
describe('selectExtraArgs url-pattern matching', () => {
const base = {
customCommandEnabled: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: null as string | null
}
const templates = [
{ id: 'a', args: '--audio-quality 0', urlPattern: 'soundcloud\\.com' },
{ id: 'b', args: '--write-thumbnail' }
]
it('auto-applies a template whose urlPattern matches the URL', () => {
const out = selectExtraArgs({ ...base, templates, url: 'https://soundcloud.com/x/y' })
expect(out).toEqual(['--audio-quality', '0'])
})
it('does not apply a urlPattern template to a non-matching URL', () => {
const out = selectExtraArgs({ ...base, templates, url: 'https://youtube.com/watch?v=x' })
expect(out).toEqual([])
})
it('urlPattern match takes precedence over the global default template', () => {
const out = selectExtraArgs({
...base,
defaultTemplateId: 'b',
templates,
url: 'https://soundcloud.com/x'
})
expect(out).toEqual(['--audio-quality', '0'])
})
it('a bad regex never matches (and is skipped safely)', () => {
const out = selectExtraArgs({
...base,
templates: [{ id: 'c', args: '--x', urlPattern: '(' }],
url: 'https://soundcloud.com/x'
})
expect(out).toEqual([])
})
})
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) ----------------------- // --- Collection folder paths (Phase G, media-manager) -----------------------
describe('sanitizeDirSegment', () => { 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)
})
})
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { shouldAutoCheckYtdlp, AUTO_UPDATE_INTERVAL_MS } from '../src/main/ytdlpPolicy'
const NOW = 1_782_435_469_486 // arbitrary fixed "now"
describe('shouldAutoCheckYtdlp', () => {
it('never checks when auto-update is disabled', () => {
expect(shouldAutoCheckYtdlp(false, 0, NOW)).toBe(false)
// …even if a full interval has elapsed.
expect(shouldAutoCheckYtdlp(false, NOW - AUTO_UPDATE_INTERVAL_MS * 2, NOW)).toBe(false)
})
it('checks on first run (lastCheck is 0 / never)', () => {
expect(shouldAutoCheckYtdlp(true, 0, NOW)).toBe(true)
})
it('skips while inside the throttle window', () => {
// Checked one minute ago → far inside the 24h window.
expect(shouldAutoCheckYtdlp(true, NOW - 60_000, NOW)).toBe(false)
// Exactly one ms short of the interval still skips.
expect(shouldAutoCheckYtdlp(true, NOW - (AUTO_UPDATE_INTERVAL_MS - 1), NOW)).toBe(false)
})
it('checks once the interval has elapsed (boundary inclusive)', () => {
expect(shouldAutoCheckYtdlp(true, NOW - AUTO_UPDATE_INTERVAL_MS, NOW)).toBe(true)
expect(shouldAutoCheckYtdlp(true, NOW - AUTO_UPDATE_INTERVAL_MS * 3, NOW)).toBe(true)
})
it('honours a custom interval', () => {
const hour = 60 * 60 * 1000
expect(shouldAutoCheckYtdlp(true, NOW - 30 * 60_000, NOW, hour)).toBe(false)
expect(shouldAutoCheckYtdlp(true, NOW - hour, NOW, hour)).toBe(true)
})
})