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:
+20
-17
@@ -16,6 +16,9 @@ Sources: [Pinchflat GitHub](https://github.com/kieraneglin/pinchflat) ·
|
||||
[Pinchflat architecture (DeepWiki)](https://deepwiki.com/kieraneglin/pinchflat) ·
|
||||
[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 F–K 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
|
||||
yet. This is the prerequisite for every later phase.
|
||||
|
||||
- [ ] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`,
|
||||
- [x] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`,
|
||||
`/channel/<id>`, `/c/<name>`, `/user/<name>`) resolves to a new `kind: 'channel'`
|
||||
`ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the
|
||||
nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists →
|
||||
videos: probe `…/playlists` (flat) for the playlist list, plus a synthetic **"Uploads"**
|
||||
playlist from `…/videos` for videos that belong to no playlist. Lazy-probe each
|
||||
playlist's video list on demand rather than all up front.
|
||||
- [ ] **Persisted index.** New `src/main/sources.ts` (plain JSON, mirrors `src/main/history.ts`)
|
||||
- [x] **Persisted index.** New `src/main/sources.ts` (plain JSON, mirrors `src/main/history.ts`)
|
||||
storing `Source` + `MediaItem` records. New IPC types in `src/shared/ipc.ts`:
|
||||
```ts
|
||||
interface Source {
|
||||
@@ -119,11 +122,11 @@ yet. This is the prerequisite for every later phase.
|
||||
filePath?: string
|
||||
}
|
||||
```
|
||||
- [ ] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem`
|
||||
- [x] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem`
|
||||
(keyed by video id); first playlist seen wins the folder assignment (surface this choice
|
||||
in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in
|
||||
no real playlist.
|
||||
- [ ] **Index job.** Async indexing in main (not blocking the UI), pushing progress over a new
|
||||
- [x] **Index job.** Async indexing in main (not blocking the UI), pushing progress over a new
|
||||
IPC channel (`index:progress`) the way `download.ts` pushes download events — "indexed
|
||||
N of M playlists." Reuse `cleanError` and the same `assertHttpUrl` guard.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
- [ ] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through
|
||||
- [x] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through
|
||||
`buildCommand` (`src/main/download.ts`). Add an `outputSubdir` (or reuse `outputDir` with a
|
||||
joined subpath) so each `MediaItem` downloads into
|
||||
`<root>/<Channel>/<Playlist>/<NNN> - <Title>.ext`. The `NNN` index comes from
|
||||
`MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is
|
||||
empty under the per-item `--no-playlist` path that AeroFetch keeps using.
|
||||
- [ ] **Directory sanitizer.** A small helper in `buildArgs.ts` that strips Windows-illegal
|
||||
- [x] **Directory sanitizer.** A small helper in `buildArgs.ts` that strips Windows-illegal
|
||||
directory chars (`< > : " / \ | ? *`, trailing dots/spaces, reserved names like `CON`).
|
||||
Critical because `--restrict-filenames` only sanitizes the *filename* yt-dlp generates,
|
||||
not the directory segments AeroFetch constructs from channel/playlist names.
|
||||
@@ -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.
|
||||
|
||||
- [ ] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside
|
||||
- [x] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside
|
||||
Downloads / History / Settings. Lists added Sources.
|
||||
- [ ] **Source detail — playlist/video tree.** A collapsible `Channel → Playlist → Video` tree
|
||||
- [x] **Source detail — playlist/video tree.** A collapsible `Channel → Playlist → Video` tree
|
||||
showing each `MediaItem`'s state (indexed · pending · downloading · downloaded · error),
|
||||
reusing the checkbox-selection pattern already in `DownloadBar.tsx`'s playlist panel.
|
||||
Per-playlist select-all, live counts.
|
||||
- [ ] **"Download all pending" → existing queue, batched.** Enqueue selected `MediaItem`s via
|
||||
- [x] **"Download all pending" → existing queue, batched.** Enqueue selected `MediaItem`s via
|
||||
the existing `addFromUrl` path, **a batch at a time** (e.g. 50) so the queue/store never
|
||||
holds the entire channel at once — the live queue stays small while the Library view is the
|
||||
source of truth for the full list. Completion flips `MediaItem.downloaded = true` and
|
||||
records to history exactly as today.
|
||||
- [ ] **New `useSources` store** (`src/renderer/src/store/sources.ts`, mirrors
|
||||
- [x] **New `useSources` store** (`src/renderer/src/store/sources.ts`, mirrors
|
||||
`store/downloads.ts` / `store/history.ts`) plus its IPC bridge. Browser-preview seed data
|
||||
so the view is demoable without Electron (same convention as `downloads.ts`'s `PREVIEW`).
|
||||
- [ ] **Risk to watch:** rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and,
|
||||
- [x] **Risk to watch:** rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and,
|
||||
if JSON-store load times bite, revisit the Phase-D better-sqlite3 decision.
|
||||
|
||||
## Phase I — Incremental sync & dedup ✅ COMPLETE
|
||||
|
||||
Re-running a channel should grab only what's new — the everyday media-manager loop.
|
||||
|
||||
- [ ] **Re-index = diff.** Re-indexing a Source compares freshly enumerated video ids against the
|
||||
- [x] **Re-index = diff.** Re-indexing a Source compares freshly enumerated video ids against the
|
||||
persisted `MediaItem`s and marks only the new ones; existing records (and their
|
||||
`downloaded` state) are preserved. "X new since last sync" badge.
|
||||
- [ ] **"Download new only."** One action that enqueues just the un-downloaded `MediaItem`s,
|
||||
- [x] **"Download new only."** One action that enqueues just the un-downloaded `MediaItem`s,
|
||||
backed by the existing **`--download-archive`** setting (`Settings.downloadArchive`,
|
||||
`getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index
|
||||
can't re-download.
|
||||
@@ -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 +
|
||||
single-instance Mutex + a headless run mode — see the DashMail/Weather Radar patterns).
|
||||
|
||||
- [ ] **Watched sources.** A `Source.watched` flag + an "auto-download new" toggle per source.
|
||||
- [ ] **Fast indexing via RSS.** YouTube exposes a per-channel Atom feed
|
||||
- [x] **Watched sources.** A `Source.watched` flag + an "auto-download new" toggle per source.
|
||||
- [x] **Fast indexing via RSS.** YouTube exposes a per-channel Atom feed
|
||||
(`https://www.youtube.com/feeds/videos.xml?channel_id=<id>`) — a cheap check for new
|
||||
uploads without a full yt-dlp scan. **Caveat:** the feed only carries the latest ~15
|
||||
videos, so RSS is for *staying current*, not initial indexing (which stays a full
|
||||
Phase-F scan). Pinchflat draws the same line ("fast indexing is not recommended for
|
||||
playlists / initial scans").
|
||||
- [ ] **Scheduled headless run.** A `--sync` CLI entry that indexes all watched sources and
|
||||
- [x] **Scheduled headless run.** A `--sync` CLI entry that indexes all watched sources and
|
||||
enqueues new items, wired to **Windows Task Scheduler** (reuse the
|
||||
single-instance-lock already added in Phase E so a scheduled run hands off to a running
|
||||
window instead of double-launching). Notify on new downloads via the existing
|
||||
@@ -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
|
||||
because organized channel folders are exactly what those servers ingest.
|
||||
|
||||
- [ ] **Sidecar metadata.** `--write-info-json`, `--write-thumbnail`, `--write-description`,
|
||||
- [x] **Sidecar metadata.** `--write-info-json`, `--write-thumbnail`, `--write-description`,
|
||||
and optional Kodi/Jellyfin `.nfo` generation. Most of this is already expressible through
|
||||
Phase C custom-command args; a checkbox group in the output settings makes it first-class.
|
||||
|
||||
|
||||
+209
-5
@@ -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
|
||||
> media manager (index entire channels → organize into `Channel / Playlist / Title` folders →
|
||||
> 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
|
||||
**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.
|
||||
|
||||
@@ -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,
|
||||
switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar
|
||||
toggle's break-out-of-system behavior all checked visually.
|
||||
- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional — deferred,
|
||||
since shipping ~40 real translations isn't something to fake; doing this properly means
|
||||
wiring up an i18n library + extracting every string now, with translation content
|
||||
arriving incrementally from contributors.
|
||||
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
|
||||
(non-MSIX) app can't register as an actual Share Target:
|
||||
- **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at
|
||||
@@ -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
|
||||
for this app's NSIS/portable distribution).
|
||||
- F-Droid distribution → N/A (AeroFetch ships NSIS + portable).
|
||||
|
||||
---
|
||||
|
||||
# Post-parity — editing, native polish & reliability
|
||||
|
||||
With both parity tracks complete — Seal (Phases A–E above) and the
|
||||
[Pinchflat media-manager roadmap](ROADMAP-PINCHFLAT.md) (Phases F–K ✅) — 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
|
||||
(A–E Seal, F–K 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 A–K were.
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -30,6 +30,10 @@ extraResources:
|
||||
to: bin
|
||||
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:
|
||||
# Two artifacts:
|
||||
|
||||
Generated
+30
-2
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "aerofetch",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aerofetch",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.1",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fluentui/react-components": "^9.74.1",
|
||||
"@fluentui/react-icons": "^2.0.330",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"electron-store": "^11.0.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
@@ -3373,6 +3374,33 @@
|
||||
"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": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aerofetch",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"description": "A yt-dlp frontend for Windows",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "AeroFetch",
|
||||
@@ -25,6 +25,7 @@
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fluentui/react-components": "^9.74.1",
|
||||
"@fluentui/react-icons": "^2.0.330",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"electron-store": "^11.0.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
|
||||
+33
-1
@@ -13,10 +13,42 @@ export function getBinDir(): string {
|
||||
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')
|
||||
}
|
||||
|
||||
/** The managed (spawned + auto-updated) yt-dlp.exe under userData. */
|
||||
export function getYtdlpPath(): string {
|
||||
return join(getManagedBinDir(), 'yt-dlp.exe')
|
||||
}
|
||||
|
||||
export function getFfmpegPath(): string {
|
||||
return join(getBinDir(), 'ffmpeg.exe')
|
||||
}
|
||||
|
||||
+75
-4
@@ -100,6 +100,10 @@ export interface AccessOptions {
|
||||
restrictFilenames: boolean
|
||||
/** absolute path to a --download-archive file; omit to disable archive tracking */
|
||||
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,
|
||||
@@ -143,6 +147,7 @@ export function parseExtraArgs(raw: string): string[] {
|
||||
*
|
||||
* - customCommandEnabled off → [] (always)
|
||||
* - 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 → []
|
||||
*/
|
||||
@@ -150,10 +155,20 @@ export function selectExtraArgs(params: {
|
||||
customCommandEnabled: boolean
|
||||
perDownloadExtraArgs: string | undefined
|
||||
defaultTemplateId: string | null
|
||||
templates: Pick<CommandTemplate, 'id' | 'args'>[]
|
||||
templates: Pick<CommandTemplate, 'id' | 'args' | 'urlPattern'>[]
|
||||
/** the download URL, for urlPattern auto-matching */
|
||||
url?: string
|
||||
}): string[] {
|
||||
if (!params.customCommandEnabled) return []
|
||||
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) {
|
||||
const tpl = params.templates.find((t) => t.id === params.defaultTemplateId)
|
||||
if (tpl) return parseExtraArgs(tpl.args)
|
||||
@@ -161,6 +176,39 @@ export function selectExtraArgs(params: {
|
||||
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
|
||||
// preview) — never used to build the real argv that gets spawned.
|
||||
function quoteForDisplay(arg: string): string {
|
||||
@@ -234,6 +282,12 @@ function accessArgs(a: AccessOptions): string[] {
|
||||
else if (a.cookiesFile) args.push('--cookies', a.cookiesFile)
|
||||
if (a.restrictFilenames) args.push('--restrict-filenames')
|
||||
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
|
||||
}
|
||||
|
||||
@@ -250,17 +304,30 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
|
||||
}
|
||||
|
||||
// SponsorBlock.
|
||||
let forcedKeyframes = false
|
||||
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
|
||||
const cats = o.sponsorBlockCategories.join(',')
|
||||
if (o.sponsorBlockMode === 'remove') {
|
||||
// Force keyframes at the cut points so segment boundaries are frame-accurate.
|
||||
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
|
||||
forcedKeyframes = true
|
||||
} else {
|
||||
args.push('--sponsorblock-mark', cats)
|
||||
}
|
||||
}
|
||||
|
||||
// Trim — download only the given time ranges (works for video + audio). Force
|
||||
// keyframes at the cut points for frame-accurate boundaries, unless
|
||||
// SponsorBlock-remove already requested it (the flag is idempotent, but we
|
||||
// avoid emitting it twice).
|
||||
const sections = parseTrimSections(opts.trim)
|
||||
for (const sec of sections) args.push('--download-sections', sec)
|
||||
if (sections.length > 0 && !forcedKeyframes) args.push('--force-keyframes-at-cuts')
|
||||
|
||||
if (o.embedChapters) args.push('--embed-chapters')
|
||||
// Split into one file per chapter. yt-dlp also keeps the full file; the
|
||||
// per-chapter files use yt-dlp's default `chapter:` output template.
|
||||
if (o.splitChapters) args.push('--split-chapters')
|
||||
if (o.embedMetadata) args.push('--embed-metadata')
|
||||
|
||||
// Media-server sidecar files (Phase K) — written next to the output file so
|
||||
@@ -310,9 +377,13 @@ export function buildArgs(
|
||||
}
|
||||
} else {
|
||||
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
|
||||
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the
|
||||
// pick toward the chosen codec without overriding the requested quality.
|
||||
if (o.preferredVideoCodec !== 'any') {
|
||||
// A raw format-sort string (advanced) wins outright; otherwise the codec
|
||||
// preference is a sort tiebreaker AFTER resolution/fps, nudging the pick toward
|
||||
// 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
|
||||
args.push('-S', `res,fps,vcodec:${token}`)
|
||||
}
|
||||
|
||||
+42
-13
@@ -11,6 +11,7 @@ import {
|
||||
getSystem32Path
|
||||
} from './binaries'
|
||||
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
|
||||
import { ensureManagedYtdlp } from './ytdlp'
|
||||
import { getCookiesFilePath } from './cookies'
|
||||
import { listTemplates } from './templates'
|
||||
import { assertHttpUrl } from './url'
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
interface ActiveDownload {
|
||||
child: ChildProcess
|
||||
canceled: boolean
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
const active = new Map<string, ActiveDownload>()
|
||||
@@ -168,7 +170,8 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
|
||||
customCommandEnabled: settings.customCommandEnabled,
|
||||
perDownloadExtraArgs: opts.extraArgs,
|
||||
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,
|
||||
cookiesFile,
|
||||
restrictFilenames: settings.restrictFilenames,
|
||||
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined
|
||||
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
|
||||
youtubePlayerClient: settings.youtubePlayerClient,
|
||||
youtubePoToken: settings.youtubePoToken
|
||||
}
|
||||
const extraArgs = resolveExtraArgs(opts, settings)
|
||||
return buildArgs(opts, outputTemplate, options, getBinDir(), access, extraArgs)
|
||||
@@ -240,11 +245,14 @@ export function startDownload(
|
||||
wc: WebContents,
|
||||
opts: StartDownloadOptions
|
||||
): 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()
|
||||
if (!existsSync(ytdlp)) {
|
||||
return {
|
||||
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/
|
||||
@@ -291,7 +299,7 @@ export function startDownload(
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
|
||||
const rec: ActiveDownload = { child, canceled: false }
|
||||
const rec: ActiveDownload = { child, canceled: false, paused: false }
|
||||
active.set(opts.id, rec)
|
||||
|
||||
// Title/channel/duration are kept locally so the completion notification and
|
||||
@@ -340,7 +348,8 @@ export function startDownload(
|
||||
if (settled) return
|
||||
settled = true
|
||||
active.delete(opts.id)
|
||||
if (!rec.canceled) {
|
||||
// A paused download was killed on purpose — stay silent, like a cancel.
|
||||
if (!rec.canceled && !rec.paused) {
|
||||
send(wc, { type: 'error', id: opts.id, error: err.message })
|
||||
logFailure(opts, resolvedTitle, err.message)
|
||||
notify(wc, resolvedTitle ?? 'Download failed', err.message)
|
||||
@@ -351,7 +360,9 @@ export function startDownload(
|
||||
if (settled) return
|
||||
settled = true
|
||||
active.delete(opts.id)
|
||||
if (rec.canceled) return // renderer already showed 'canceled' optimistically
|
||||
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
|
||||
// 'paused' and keeps the .part for a later resume. Either way, no event.
|
||||
if (rec.canceled || rec.paused) return
|
||||
if (code === 0) {
|
||||
send(wc, { type: 'done', id: opts.id, filePath })
|
||||
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
|
||||
@@ -366,15 +377,12 @@ export function startDownload(
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export function cancelDownload(id: string): void {
|
||||
const rec = active.get(id)
|
||||
if (!rec) return
|
||||
rec.canceled = true
|
||||
const pid = rec.child.pid
|
||||
if (pid != null) {
|
||||
// Kill the whole tree (/T) so the spawned ffmpeg child dies too. Resolve
|
||||
// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
|
||||
// taskkill from System32 by absolute path, never the bare name, so a planted
|
||||
// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
|
||||
function killTree(rec: ActiveDownload): void {
|
||||
const pid = rec.child.pid
|
||||
if (pid != null) {
|
||||
execFile(
|
||||
getSystem32Path('taskkill.exe'),
|
||||
['/pid', String(pid), '/T', '/F'],
|
||||
@@ -385,3 +393,24 @@ export function cancelDownload(id: string): void {
|
||||
rec.child.kill()
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelDownload(id: string): void {
|
||||
const rec = active.get(id)
|
||||
if (!rec) return
|
||||
rec.canceled = true
|
||||
killTree(rec)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a running download: kill the process tree but flag it `paused` (not
|
||||
* `canceled`) so the close handler stays silent — no error event, no error log,
|
||||
* no notification. yt-dlp leaves the partial `.part` file in place, so resuming
|
||||
* is just a fresh startDownload with the same options: yt-dlp's default
|
||||
* `--continue` picks the partial file back up.
|
||||
*/
|
||||
export function pauseDownload(id: string): void {
|
||||
const rec = active.get(id)
|
||||
if (!rec) return
|
||||
rec.paused = true
|
||||
killTree(rec)
|
||||
}
|
||||
|
||||
@@ -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
@@ -8,12 +8,15 @@ import {
|
||||
type HistoryEntry,
|
||||
type CommandTemplate,
|
||||
type YtdlpUpdateChannel,
|
||||
type SystemThemeInfo
|
||||
type SystemThemeInfo,
|
||||
type TaskbarProgress
|
||||
} 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 { 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 { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||
@@ -34,6 +37,7 @@ import {
|
||||
import { indexSource } from './indexer'
|
||||
import { syncWatchedSources } from './sync'
|
||||
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
|
||||
// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
|
||||
@@ -133,6 +137,15 @@ function createWindow(): void {
|
||||
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', () => {
|
||||
if (mainWindow === win) mainWindow = null
|
||||
})
|
||||
@@ -190,6 +203,8 @@ function registerIpcHandlers(): void {
|
||||
|
||||
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
||||
|
||||
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
|
||||
|
||||
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
||||
|
||||
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
|
||||
@@ -208,6 +223,11 @@ function registerIpcHandlers(): void {
|
||||
return result
|
||||
})
|
||||
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
|
||||
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
|
||||
ipcMain.handle(IpcChannels.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'))
|
||||
|
||||
@@ -312,6 +332,13 @@ function registerIpcHandlers(): void {
|
||||
)
|
||||
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
|
||||
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
|
||||
@@ -352,12 +379,40 @@ if (isPrimaryInstance) {
|
||||
registerSystemThemeBridge()
|
||||
registerSendToShortcut()
|
||||
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', () => {
|
||||
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', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
|
||||
+27
-1
@@ -11,6 +11,7 @@ import {
|
||||
COOKIE_BROWSERS,
|
||||
ACCENT_COLORS,
|
||||
DEFAULT_DOWNLOAD_OPTIONS,
|
||||
isYtdlpUpdateChannel,
|
||||
type Settings,
|
||||
type DownloadOptions,
|
||||
type SponsorBlockCategory
|
||||
@@ -35,13 +36,21 @@ const DEFAULTS: Settings = {
|
||||
useAria2c: false,
|
||||
cookieSource: 'none',
|
||||
cookiesBrowser: 'chrome',
|
||||
youtubePlayerClient: '',
|
||||
youtubePoToken: '',
|
||||
restrictFilenames: 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,
|
||||
defaultTemplateId: null,
|
||||
notifyOnComplete: true,
|
||||
autoDownloadNew: true,
|
||||
hasCompletedOnboarding: false
|
||||
hasCompletedOnboarding: false,
|
||||
minimizeToTray: false
|
||||
}
|
||||
|
||||
/** 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)
|
||||
? o.preferredVideoCodec!
|
||||
: d.preferredVideoCodec,
|
||||
formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort,
|
||||
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
|
||||
subtitleLanguages:
|
||||
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
|
||||
@@ -105,6 +115,7 @@ function sanitizeOptions(input: unknown): DownloadOptions {
|
||||
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
|
||||
sponsorBlockCategories: cats,
|
||||
embedChapters: bool(o.embedChapters, d.embedChapters),
|
||||
splitChapters: bool(o.splitChapters, d.splitChapters),
|
||||
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
|
||||
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
|
||||
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
|
||||
@@ -193,12 +204,25 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
||||
case 'useAria2c':
|
||||
case 'restrictFilenames':
|
||||
case 'downloadArchive':
|
||||
case 'autoUpdateYtdlp':
|
||||
case 'customCommandEnabled':
|
||||
case 'notifyOnComplete':
|
||||
case 'autoDownloadNew':
|
||||
case 'hasCompletedOnboarding':
|
||||
case 'minimizeToTray':
|
||||
if (typeof value === 'boolean') s.set(key, value)
|
||||
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':
|
||||
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
|
||||
break
|
||||
@@ -234,6 +258,8 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
||||
// and exported by exportBackup as-is — documented, not masked.
|
||||
case 'proxy':
|
||||
case 'rateLimit':
|
||||
case 'youtubePlayerClient':
|
||||
case 'youtubePoToken':
|
||||
if (typeof value === 'string') s.set(key, value)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -36,10 +36,13 @@ function save(templates: CommandTemplate[]): void {
|
||||
}
|
||||
|
||||
function sanitize(t: CommandTemplate): CommandTemplate {
|
||||
const urlPattern = typeof t.urlPattern === 'string' ? t.urlPattern.trim() : ''
|
||||
return {
|
||||
id: String(t.id),
|
||||
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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,13 +1,37 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { getYtdlpPath } from './binaries'
|
||||
import { existsSync, mkdirSync, copyFileSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { getYtdlpPath, getBundledYtdlpPath } from './binaries'
|
||||
import { getSettings, setSettings } from './settings'
|
||||
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
|
||||
import {
|
||||
isYtdlpUpdateChannel,
|
||||
type YtdlpVersionResult,
|
||||
type YtdlpUpdateChannel,
|
||||
type YtdlpUpdateResult
|
||||
type YtdlpUpdateResult,
|
||||
type YtdlpAutoUpdateStatus
|
||||
} 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`.
|
||||
* 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.' })
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -7,6 +7,8 @@ import {
|
||||
type YtdlpVersionResult,
|
||||
type YtdlpUpdateChannel,
|
||||
type YtdlpUpdateResult,
|
||||
type YtdlpAutoUpdateStatus,
|
||||
type FfmpegVersionResult,
|
||||
type ProbeResult,
|
||||
type StartDownloadOptions,
|
||||
type StartDownloadResult,
|
||||
@@ -26,7 +28,10 @@ import {
|
||||
type IndexProgress,
|
||||
type IndexSourceResult,
|
||||
type SyncResult,
|
||||
type ScheduledSyncStatus
|
||||
type ScheduledSyncStatus,
|
||||
type TerminalEvent,
|
||||
type TerminalRunResult,
|
||||
type TaskbarProgress
|
||||
} from '@shared/ipc'
|
||||
|
||||
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
|
||||
@@ -55,6 +60,10 @@ const api = {
|
||||
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
|
||||
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),
|
||||
|
||||
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
|
||||
@@ -63,6 +72,9 @@ const api = {
|
||||
cancelDownload: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannels.downloadCancel, id),
|
||||
|
||||
pauseDownload: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannels.downloadPause, id),
|
||||
|
||||
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
|
||||
|
||||
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
|
||||
@@ -115,6 +127,13 @@ const api = {
|
||||
updateYtdlp: (channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> =>
|
||||
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),
|
||||
|
||||
clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear),
|
||||
@@ -193,7 +212,27 @@ const api = {
|
||||
const listener = (_e: IpcRendererEvent, p: IndexProgress): void => cb(p)
|
||||
ipcRenderer.on(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
|
||||
|
||||
@@ -4,10 +4,14 @@ import { Sidebar, type TabValue } from './components/Sidebar'
|
||||
import { DownloadsView } from './components/DownloadsView'
|
||||
import { LibraryView } from './components/LibraryView'
|
||||
import { HistoryView } from './components/HistoryView'
|
||||
import { TerminalView } from './components/TerminalView'
|
||||
import { SettingsView } from './components/SettingsView'
|
||||
import { Onboarding } from './components/Onboarding'
|
||||
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
||||
import { getTheme, pageBackground } from './theme'
|
||||
import { useSettings } from './store/settings'
|
||||
import { useDownloads } from './store/downloads'
|
||||
import { summarizeQueue } from './store/queueStats'
|
||||
import { useResolvedDark } from './store/systemTheme'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
@@ -34,6 +38,55 @@ function App(): React.JSX.Element {
|
||||
const updateSettings = useSettings((s) => s.update)
|
||||
const isDark = useResolvedDark()
|
||||
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.
|
||||
const [version, setVersion] = useState('')
|
||||
@@ -89,8 +142,13 @@ function App(): React.JSX.Element {
|
||||
{tab === 'downloads' && <DownloadsView />}
|
||||
{tab === 'library' && <LibraryView />}
|
||||
{tab === 'history' && <HistoryView />}
|
||||
{tab === 'terminal' && <TerminalView />}
|
||||
{tab === 'settings' && <SettingsView />}
|
||||
</main>
|
||||
|
||||
{paletteOpen && (
|
||||
<CommandPalette actions={paletteActions} onClose={() => setPaletteOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Input,
|
||||
Textarea,
|
||||
Field,
|
||||
Button,
|
||||
Checkbox,
|
||||
Spinner,
|
||||
@@ -20,10 +22,14 @@ import {
|
||||
ErrorCircleRegular,
|
||||
LinkRegular,
|
||||
DismissRegular,
|
||||
AppsListRegular
|
||||
AppsListRegular,
|
||||
CutRegular,
|
||||
CalendarClockRegular,
|
||||
WarningRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
|
||||
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
|
||||
import { sameVideo } from '../store/queueStats'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { Select } from './Select'
|
||||
import { Hint } from './Hint'
|
||||
@@ -40,6 +46,23 @@ function looksLikeUrl(text: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */
|
||||
function firstUrl(text: string): string | null {
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
if (looksLikeUrl(line)) return line
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
|
||||
function parseUrlFile(content: string): string | null {
|
||||
const m = /^\s*URL\s*=\s*(\S+)/im.exec(content)
|
||||
const url = m?.[1]?.trim()
|
||||
return url && looksLikeUrl(url) ? url : null
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
@@ -50,6 +73,11 @@ const useStyles = makeStyles({
|
||||
...shorthands.borderRadius(tokens.borderRadiusXLarge),
|
||||
boxShadow: tokens.shadow4
|
||||
},
|
||||
// Highlight while a link / .url file is dragged over the card.
|
||||
rootDragging: {
|
||||
outline: `2px dashed ${tokens.colorBrandStroke1}`,
|
||||
outlineOffset: '-2px'
|
||||
},
|
||||
urlRow: {
|
||||
display: 'flex',
|
||||
gap: '8px'
|
||||
@@ -195,10 +223,35 @@ const useStyles = makeStyles({
|
||||
overflowY: 'auto',
|
||||
paddingRight: '4px'
|
||||
},
|
||||
plItemRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
},
|
||||
plItem: {
|
||||
display: 'flex',
|
||||
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: {
|
||||
display: 'flex',
|
||||
@@ -212,12 +265,52 @@ const useStyles = makeStyles({
|
||||
},
|
||||
plItemMeta: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
// --- duplicate warning ---
|
||||
dupRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 8px 8px 12px',
|
||||
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||
color: tokens.colorStatusWarningForeground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||
},
|
||||
// --- trim / schedule panels ---
|
||||
trimBlock: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start'
|
||||
},
|
||||
optButtons: {
|
||||
display: 'flex',
|
||||
gap: '8px'
|
||||
},
|
||||
trimPanel: {
|
||||
alignSelf: 'stretch',
|
||||
padding: '12px',
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
},
|
||||
// Native datetime-local input, themed to sit beside the Fluent controls.
|
||||
dtInput: {
|
||||
fontFamily: tokens.fontFamilyBase,
|
||||
fontSize: tokens.fontSizeBase300,
|
||||
padding: '6px 10px',
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
color: tokens.colorNeutralForeground1,
|
||||
colorScheme: 'light dark'
|
||||
}
|
||||
})
|
||||
|
||||
export function DownloadBar(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
||||
const addMany = useDownloads((s) => s.addMany)
|
||||
const settingsLoaded = useSettings((s) => s.loaded)
|
||||
const defaultKind = useSettings((s) => s.defaultKind)
|
||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||
@@ -227,6 +320,47 @@ export function DownloadBar(): React.JSX.Element {
|
||||
const [kind, setKind] = useState<MediaKind>('video')
|
||||
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
|
||||
|
||||
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
|
||||
const [showTrim, setShowTrim] = useState(false)
|
||||
const [trim, setTrim] = useState('')
|
||||
|
||||
// Optional schedule: a datetime-local value; a future time parks the download.
|
||||
const [showSchedule, setShowSchedule] = useState(false)
|
||||
const [scheduleAt, setScheduleAt] = useState('')
|
||||
|
||||
// Drag-and-drop: highlight while a link / .url file hovers the card.
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
|
||||
// Duplicate guard: warn before enqueuing a URL already in the active queue.
|
||||
const [dup, setDup] = useState<string | null>(null)
|
||||
const confirmDup = useRef(false)
|
||||
|
||||
function onDragOver(e: React.DragEvent): void {
|
||||
e.preventDefault()
|
||||
if (!dragActive) setDragActive(true)
|
||||
}
|
||||
function onDrop(e: React.DragEvent): void {
|
||||
e.preventDefault()
|
||||
setDragActive(false)
|
||||
const dt = e.dataTransfer
|
||||
const fromText = firstUrl(dt.getData('text/uri-list') || dt.getData('text/plain') || '')
|
||||
if (fromText) {
|
||||
onUrlChange(fromText)
|
||||
return
|
||||
}
|
||||
// A dropped Windows .url Internet Shortcut — read its URL= line.
|
||||
const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url'))
|
||||
if (file) {
|
||||
file
|
||||
.text()
|
||||
.then((content) => {
|
||||
const u = parseUrlFile(content)
|
||||
if (u) onUrlChange(u)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the saved default format once, when persisted settings first arrive.
|
||||
const appliedDefaults = useRef(false)
|
||||
useEffect(() => {
|
||||
@@ -246,6 +380,9 @@ export function DownloadBar(): React.JSX.Element {
|
||||
// Probe state for a playlist URL.
|
||||
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
|
||||
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
|
||||
// fresh link, offer it (without clobbering anything the user is already typing).
|
||||
@@ -316,12 +453,15 @@ export function DownloadBar(): React.JSX.Element {
|
||||
setFormatId('')
|
||||
setPlaylist(null)
|
||||
setSelected(new Set())
|
||||
setItemKinds({})
|
||||
}
|
||||
|
||||
function onUrlChange(next: string): void {
|
||||
setUrl(next)
|
||||
// Any probed info is stale once the URL changes.
|
||||
// Any probed info — or a duplicate warning — is stale once the URL changes.
|
||||
if (info || probeError || playlist) clearProbe()
|
||||
if (dup) setDup(null)
|
||||
confirmDup.current = false
|
||||
}
|
||||
|
||||
function onKindChange(next: MediaKind): void {
|
||||
@@ -379,10 +519,41 @@ export function DownloadBar(): React.JSX.Element {
|
||||
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 {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
// Duplicate guard: if this URL is already in the queue (and the user hasn't
|
||||
// confirmed via "Download anyway"), warn instead of silently enqueuing a copy.
|
||||
// Canceled/failed items don't count — re-adding those is a legitimate retry.
|
||||
if (!confirmDup.current) {
|
||||
const existing = useDownloads
|
||||
.getState()
|
||||
.items.find(
|
||||
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
|
||||
)
|
||||
if (existing) {
|
||||
setDup(existing.title)
|
||||
return
|
||||
}
|
||||
}
|
||||
confirmDup.current = false
|
||||
setDup(null)
|
||||
|
||||
const meta = info
|
||||
? {
|
||||
title: info.title,
|
||||
@@ -392,9 +563,14 @@ export function DownloadBar(): React.JSX.Element {
|
||||
}
|
||||
: {}
|
||||
|
||||
const trimSpec = trim.trim() || undefined
|
||||
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
|
||||
|
||||
if (usingFormats && selectedFormat) {
|
||||
addFromUrl(trimmed, 'video', selectedFormat.label, {
|
||||
...meta,
|
||||
trim: trimSpec,
|
||||
scheduledFor,
|
||||
format: {
|
||||
id: selectedFormat.id,
|
||||
hasAudio: selectedFormat.hasAudio,
|
||||
@@ -402,32 +578,53 @@ export function DownloadBar(): React.JSX.Element {
|
||||
}
|
||||
})
|
||||
} else {
|
||||
addFromUrl(trimmed, kind, quality, meta)
|
||||
addFromUrl(trimmed, kind, quality, { ...meta, trim: trimSpec, scheduledFor })
|
||||
}
|
||||
|
||||
setUrl('')
|
||||
setTrim('')
|
||||
setShowTrim(false)
|
||||
setScheduleAt('')
|
||||
setShowSchedule(false)
|
||||
clearProbe()
|
||||
}
|
||||
|
||||
function downloadAnyway(): void {
|
||||
confirmDup.current = true
|
||||
download()
|
||||
}
|
||||
|
||||
function addPlaylist(): void {
|
||||
if (!playlist) return
|
||||
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
||||
for (const e of chosen) {
|
||||
addFromUrl(e.url, kind, quality, {
|
||||
title: e.title,
|
||||
channel: e.uploader,
|
||||
durationLabel: e.durationLabel
|
||||
})
|
||||
// Each entry uses its own kind override; quality falls back to that kind's
|
||||
// default unless the entry matches the bar's current kind/quality.
|
||||
addMany(
|
||||
chosen.map((e) => {
|
||||
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('')
|
||||
clearProbe()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div
|
||||
className={mergeClasses(styles.root, dragActive && styles.rootDragging)}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={() => setDragActive(false)}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<div className={styles.urlRow}>
|
||||
<Input
|
||||
className={styles.url}
|
||||
input={{ id: 'aerofetch-url' }}
|
||||
value={url}
|
||||
onChange={(_, d) => onUrlChange(d.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
|
||||
@@ -474,6 +671,23 @@ export function DownloadBar(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dup && (
|
||||
<div className={styles.dupRow}>
|
||||
<WarningRegular />
|
||||
<Caption1 className={styles.suggestionText}>Already in your queue: “{dup}”.</Caption1>
|
||||
<Button size="small" appearance="primary" onClick={downloadAnyway}>
|
||||
Download anyway
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={() => setDup(null)}
|
||||
aria-label="Dismiss"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{probing && (
|
||||
<div className={styles.statusRow}>
|
||||
<Spinner size="tiny" />
|
||||
@@ -524,11 +738,17 @@ export function DownloadBar(): React.JSX.Element {
|
||||
<Button size="small" appearance="subtle" onClick={toggleAll}>
|
||||
{allSelected ? 'Select none' : 'Select all'}
|
||||
</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 className={styles.plList}>
|
||||
{playlist.entries.map((e) => (
|
||||
<div key={e.index} className={styles.plItemRow}>
|
||||
<Checkbox
|
||||
key={e.index}
|
||||
className={styles.plItem}
|
||||
checked={selected.has(e.index)}
|
||||
onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
|
||||
@@ -545,11 +765,81 @@ export function DownloadBar(): React.JSX.Element {
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Hint
|
||||
label={
|
||||
effKind(e.index) === 'audio' ? 'Audio — click for video' : 'Video — click for audio'
|
||||
}
|
||||
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>
|
||||
)}
|
||||
|
||||
{!playlist && (
|
||||
<div className={styles.trimBlock}>
|
||||
<div className={styles.optButtons}>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<CutRegular />}
|
||||
onClick={() => setShowTrim((v) => !v)}
|
||||
>
|
||||
{trim.trim() ? 'Trim · on' : 'Trim'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<CalendarClockRegular />}
|
||||
onClick={() => setShowSchedule((v) => !v)}
|
||||
>
|
||||
{scheduleAt ? 'Scheduled' : 'Schedule'}
|
||||
</Button>
|
||||
</div>
|
||||
{showTrim && (
|
||||
<div className={styles.trimPanel}>
|
||||
<Field
|
||||
label="Sections to keep"
|
||||
hint="Time ranges like 1:30-2:00 — one per line or comma-separated. Leave empty to download the whole thing."
|
||||
>
|
||||
<Textarea
|
||||
value={trim}
|
||||
onChange={(_, d) => setTrim(d.value)}
|
||||
placeholder={'0:30-1:45\n3:00-3:30'}
|
||||
resize="vertical"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{showSchedule && (
|
||||
<div className={styles.trimPanel}>
|
||||
<Field
|
||||
label="Start at"
|
||||
hint="Pick a date and time to start this download. Leave empty to start now. Scheduled downloads only fire while AeroFetch is running."
|
||||
>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className={styles.dtInput}
|
||||
value={scheduleAt}
|
||||
onChange={(e) => setScheduleAt(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.control}>
|
||||
<Caption1>Format</Caption1>
|
||||
@@ -606,11 +896,11 @@ export function DownloadBar(): React.JSX.Element {
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="large"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
icon={scheduleAt ? <CalendarClockRegular /> : <ArrowDownloadRegular />}
|
||||
onClick={download}
|
||||
disabled={!url.trim()}
|
||||
>
|
||||
Download
|
||||
{scheduleAt ? 'Schedule' : 'Download'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -133,6 +133,17 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
</Field>
|
||||
</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.">
|
||||
<Switch
|
||||
checked={value.embedSubtitles}
|
||||
@@ -204,6 +215,16 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
label={value.embedChapters ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Split by chapters"
|
||||
hint="Also save each chapter as its own file. The full-length file is kept too."
|
||||
>
|
||||
<Switch
|
||||
checked={value.splitChapters}
|
||||
onChange={(_, d) => setOpt('splitChapters', d.checked)}
|
||||
label={value.splitChapters ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
|
||||
<Switch
|
||||
checked={value.embedMetadata}
|
||||
|
||||
@@ -1,30 +1,61 @@
|
||||
import {
|
||||
Subtitle2,
|
||||
Body1,
|
||||
Caption1,
|
||||
Button,
|
||||
ProgressBar,
|
||||
makeStyles,
|
||||
tokens
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import { ArrowDownloadRegular } from '@fluentui/react-icons'
|
||||
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
|
||||
import { useDownloads } from '../store/downloads'
|
||||
import { summarizeQueue } from '../store/queueStats'
|
||||
import { DownloadBar } from './DownloadBar'
|
||||
import { QueueItem } from './QueueItem'
|
||||
import { VirtualList } from './VirtualList'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
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',
|
||||
flexDirection: 'column',
|
||||
gap: '20px'
|
||||
gap: '20px',
|
||||
height: '100%'
|
||||
},
|
||||
queueHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
list: {
|
||||
headerActions: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px'
|
||||
gap: '8px'
|
||||
},
|
||||
summary: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '10px 14px',
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
},
|
||||
summaryBar: {
|
||||
flexGrow: 1
|
||||
},
|
||||
summaryText: {
|
||||
flexShrink: 0,
|
||||
color: tokens.colorNeutralForeground3,
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
listScroll: {
|
||||
flexGrow: 1,
|
||||
// min-height:0 lets this flex child shrink below its content height so it,
|
||||
// not the page, owns the scrolling.
|
||||
minHeight: 0
|
||||
},
|
||||
empty: {
|
||||
display: 'flex',
|
||||
@@ -41,7 +72,9 @@ export function DownloadsView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const items = useDownloads((s) => s.items)
|
||||
const clearFinished = useDownloads((s) => s.clearFinished)
|
||||
const retryAll = useDownloads((s) => s.retryAll)
|
||||
|
||||
const summary = summarizeQueue(items)
|
||||
const hasFinished = items.some(
|
||||
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
||||
)
|
||||
@@ -50,14 +83,38 @@ export function DownloadsView(): React.JSX.Element {
|
||||
<div className={styles.root}>
|
||||
<DownloadBar />
|
||||
|
||||
{summary.active && (
|
||||
<div className={styles.summary}>
|
||||
<ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" />
|
||||
<Caption1 className={styles.summaryText}>
|
||||
{summary.downloading} downloading
|
||||
{summary.queued ? `, ${summary.queued} queued` : ''}
|
||||
{summary.speedLabel ? ` • ${summary.speedLabel}` : ''}
|
||||
{summary.etaLabel ? ` • ~${summary.etaLabel} left` : ''}
|
||||
</Caption1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.queueHeader}>
|
||||
<Subtitle2>Queue ({items.length})</Subtitle2>
|
||||
<div className={styles.headerActions}>
|
||||
{summary.failed > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowClockwiseRegular />}
|
||||
onClick={retryAll}
|
||||
>
|
||||
Retry all failed ({summary.failed})
|
||||
</Button>
|
||||
)}
|
||||
{hasFinished && (
|
||||
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
||||
Clear finished
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
@@ -65,11 +122,15 @@ export function DownloadsView(): React.JSX.Element {
|
||||
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
{items.map((item) => (
|
||||
<QueueItem key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
<VirtualList
|
||||
items={items}
|
||||
className={styles.listScroll}
|
||||
gap={10}
|
||||
overscan={6}
|
||||
estimateSize={() => 100}
|
||||
getKey={(item) => item.id}
|
||||
renderItem={(item) => <QueueItem item={item} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -28,11 +28,12 @@ import {
|
||||
LibraryRegular
|
||||
} from '@fluentui/react-icons'
|
||||
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 { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||
import { thumbUrl } from '../thumb'
|
||||
import { MediaThumb } from './MediaThumb'
|
||||
import { VirtualList } from './VirtualList'
|
||||
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
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. */
|
||||
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> = {
|
||||
pending: 'Pending',
|
||||
queued: 'Queued',
|
||||
downloading: 'Downloading',
|
||||
paused: 'Paused',
|
||||
saved: 'Saved',
|
||||
completed: 'Downloaded',
|
||||
error: 'Failed',
|
||||
canceled: 'Canceled'
|
||||
@@ -134,22 +149,24 @@ const useStyles = makeStyles({
|
||||
},
|
||||
actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
|
||||
actionSpacer: { flexGrow: 1 },
|
||||
group: { display: 'flex', flexDirection: 'column' },
|
||||
groupHead: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
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 },
|
||||
rows: { display: 'flex', flexDirection: 'column' },
|
||||
row: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '5px 4px 5px 18px'
|
||||
},
|
||||
plainList: { display: 'flex', flexDirection: 'column' },
|
||||
rowThumb: {
|
||||
width: '60px',
|
||||
height: '34px',
|
||||
@@ -236,7 +253,11 @@ export function LibraryView(): React.JSX.Element {
|
||||
const [url, setUrl] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchNote, setBatchNote] = 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 watchedCount = sources.filter((s) => s.watched).length
|
||||
@@ -261,8 +282,12 @@ export function LibraryView(): React.JSX.Element {
|
||||
if (res.error) setError(res.error)
|
||||
}
|
||||
|
||||
// Reset the selection whenever the expanded source changes.
|
||||
useEffect(() => setSelected(new Set()), [selectedSourceId])
|
||||
// Reset the selection (and any batch note) whenever the expanded source changes.
|
||||
useEffect(() => {
|
||||
setSelected(new Set())
|
||||
setBatchNote(null)
|
||||
setExpandedGroups(new Set())
|
||||
}, [selectedSourceId])
|
||||
|
||||
// Live per-URL queue status so a video row reflects its real download state.
|
||||
const statusByUrl = useMemo(() => {
|
||||
@@ -273,12 +298,40 @@ export function LibraryView(): React.JSX.Element {
|
||||
|
||||
const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : []
|
||||
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 =>
|
||||
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : '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> {
|
||||
setError(null)
|
||||
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 {
|
||||
setSelected((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 {
|
||||
if (!selectedSourceId) return
|
||||
const chosen = items.filter((it) => selected.has(it.id))
|
||||
enqueueItems(selectedSourceId, chosen)
|
||||
if (!selectedSourceId || selectedActionable.length === 0) return
|
||||
const n = enqueueItems(selectedSourceId, selectedActionable)
|
||||
setSelected(new Set())
|
||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||
}
|
||||
|
||||
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
|
||||
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 {
|
||||
@@ -327,6 +407,83 @@ export function LibraryView(): React.JSX.Element {
|
||||
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 (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
@@ -418,6 +575,12 @@ export function LibraryView(): React.JSX.Element {
|
||||
>
|
||||
<div className={styles.detail}>
|
||||
<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}>
|
||||
{items.length} videos · {pendingItems.length} pending · indexed{' '}
|
||||
{relTime(src.lastIndexedAt)}
|
||||
@@ -433,8 +596,9 @@ export function LibraryView(): React.JSX.Element {
|
||||
appearance="primary"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
onClick={downloadSelected}
|
||||
disabled={selectedActionable.length === 0}
|
||||
>
|
||||
Download {Math.min(selected.size, MAX_ENQUEUE_BATCH)} selected
|
||||
Download {selectedActionable.length} selected
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -445,7 +609,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
onClick={downloadPending}
|
||||
disabled={pendingItems.length === 0}
|
||||
>
|
||||
Download {Math.min(pendingItems.length, MAX_ENQUEUE_BATCH)} pending
|
||||
Download {pendingItems.length} pending
|
||||
</Button>
|
||||
)}
|
||||
<span className={styles.switchRow}>
|
||||
@@ -475,55 +639,29 @@ export function LibraryView(): React.JSX.Element {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groups.map((g) => {
|
||||
const allOn = g.items.every((it) => selected.has(it.id))
|
||||
return (
|
||||
<div key={g.title} className={styles.group}>
|
||||
<div className={styles.groupHead}>
|
||||
<AppsListRegular />
|
||||
<span className={styles.groupTitle}>{g.title}</span>
|
||||
<Caption1 className={styles.srcSub}>{g.items.length}</Caption1>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
onClick={() => toggleGroup(g.items, !allOn)}
|
||||
>
|
||||
{allOn ? 'None' : 'All'}
|
||||
</Button>
|
||||
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
|
||||
|
||||
{flatRows.length > VIRTUALIZE_AT ? (
|
||||
// Big source: a fixed-height, internally-scrolling virtualized
|
||||
// panel so 1000s of rows stay light. A definite height (not
|
||||
// max-height) gives the virtualizer a viewport to measure.
|
||||
<VirtualList
|
||||
items={flatRows}
|
||||
style={{ height: '58vh' }}
|
||||
overscan={10}
|
||||
estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)}
|
||||
getKey={(row) => rowKey(row)}
|
||||
renderItem={(row) => renderRow(row)}
|
||||
/>
|
||||
) : (
|
||||
// Small source: render inline so the card grows naturally.
|
||||
<div className={styles.plainList}>
|
||||
{flatRows.map((row) => (
|
||||
<div key={rowKey(row)}>{renderRow(row)}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.rows}>
|
||||
{g.items.map((it) => {
|
||||
const status = effStatus(it)
|
||||
return (
|
||||
<div key={it.id} 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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SourceCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
Text,
|
||||
Caption1,
|
||||
@@ -15,6 +16,11 @@ import {
|
||||
OpenRegular,
|
||||
FolderRegular,
|
||||
ArrowClockwiseRegular,
|
||||
ArrowDownloadRegular,
|
||||
ArrowUpRegular,
|
||||
BookmarkRegular,
|
||||
PauseRegular,
|
||||
PlayRegular,
|
||||
CheckmarkCircleFilled,
|
||||
ErrorCircleFilled,
|
||||
EyeOffRegular
|
||||
@@ -90,6 +96,8 @@ const useStyles = makeStyles({
|
||||
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
|
||||
queued: { label: 'Queued', color: 'subtle' },
|
||||
downloading: { label: 'Downloading', color: 'brand' },
|
||||
paused: { label: 'Paused', color: 'warning' },
|
||||
saved: { label: 'Saved', color: 'subtle' },
|
||||
completed: { label: 'Completed', color: 'success' },
|
||||
error: { label: 'Failed', color: 'danger' },
|
||||
canceled: { label: 'Canceled', color: 'warning' }
|
||||
@@ -99,9 +107,26 @@ function pct(progress: number): string {
|
||||
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 cancel = useDownloads((s) => s.cancel)
|
||||
const pause = useDownloads((s) => s.pause)
|
||||
const resume = useDownloads((s) => s.resume)
|
||||
const prioritize = useDownloads((s) => s.prioritize)
|
||||
const saveForLater = useDownloads((s) => s.saveForLater)
|
||||
const queueNow = useDownloads((s) => s.queueNow)
|
||||
const remove = useDownloads((s) => s.remove)
|
||||
const retry = useDownloads((s) => s.retry)
|
||||
const openFile = useDownloads((s) => s.openFile)
|
||||
@@ -172,12 +197,79 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'paused' && (
|
||||
<div className={styles.progressRow}>
|
||||
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
|
||||
<Caption1 className={styles.stats}>Paused • {pct(item.progress)}</Caption1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'saved' && (
|
||||
<Caption1 className={styles.stats}>
|
||||
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'}
|
||||
</Caption1>
|
||||
)}
|
||||
|
||||
{item.status === 'error' && item.error && (
|
||||
<Caption1 className={styles.error}>{item.error}</Caption1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{item.status === 'downloading' && (
|
||||
<Hint label="Pause" placement="top" align="end">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<PauseRegular />}
|
||||
onClick={() => pause(item.id)}
|
||||
aria-label="Pause"
|
||||
/>
|
||||
</Hint>
|
||||
)}
|
||||
|
||||
{item.status === 'queued' && (
|
||||
<>
|
||||
<Hint label="Download next" placement="top" align="end">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<ArrowUpRegular />}
|
||||
onClick={() => prioritize(item.id)}
|
||||
aria-label="Download next"
|
||||
/>
|
||||
</Hint>
|
||||
<Hint label="Save for later" placement="top" align="end">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<BookmarkRegular />}
|
||||
onClick={() => saveForLater(item.id)}
|
||||
aria-label="Save for later"
|
||||
/>
|
||||
</Hint>
|
||||
</>
|
||||
)}
|
||||
|
||||
{item.status === 'paused' && (
|
||||
<Hint label="Resume" placement="top" align="end">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<PlayRegular />}
|
||||
onClick={() => resume(item.id)}
|
||||
aria-label="Resume"
|
||||
/>
|
||||
</Hint>
|
||||
)}
|
||||
|
||||
{item.status === 'saved' && (
|
||||
<Hint label="Add to queue" placement="top" align="end">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
onClick={() => queueNow(item.id)}
|
||||
aria-label="Add to queue"
|
||||
/>
|
||||
</Hint>
|
||||
)}
|
||||
|
||||
{active && (
|
||||
<Hint label="Cancel" placement="top" align="end">
|
||||
<Button
|
||||
@@ -234,4 +326,4 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
@@ -34,13 +34,15 @@ import {
|
||||
PaintBucketRegular,
|
||||
AccessibilityRegular,
|
||||
ArrowSyncRegular,
|
||||
OpenRegular
|
||||
OpenRegular,
|
||||
SearchRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import {
|
||||
COOKIE_BROWSERS,
|
||||
type YtdlpVersionResult,
|
||||
type YtdlpUpdateChannel,
|
||||
type YtdlpUpdateResult,
|
||||
type FfmpegVersionResult,
|
||||
type AppUpdateInfo,
|
||||
type MediaKind,
|
||||
type CookieSource,
|
||||
@@ -185,6 +187,28 @@ const UPDATE_CHANNEL_OPTIONS = [
|
||||
export function SettingsView(): React.JSX.Element {
|
||||
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 audioDir = useSettings((s) => s.audioDir)
|
||||
const chooseDir = useSettings((s) => s.chooseDir)
|
||||
@@ -202,6 +226,8 @@ export function SettingsView(): React.JSX.Element {
|
||||
const proxy = useSettings((s) => s.proxy)
|
||||
const rateLimit = useSettings((s) => s.rateLimit)
|
||||
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 cookiesBrowser = useSettings((s) => s.cookiesBrowser)
|
||||
const restrictFilenames = useSettings((s) => s.restrictFilenames)
|
||||
@@ -209,6 +235,10 @@ export function SettingsView(): React.JSX.Element {
|
||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
||||
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
||||
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 templates = useTemplates((s) => s.templates)
|
||||
const errorEntries = useErrorLog((s) => s.entries)
|
||||
@@ -219,8 +249,8 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
const [checking, setChecking] = useState(false)
|
||||
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 [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
|
||||
|
||||
@@ -251,6 +281,28 @@ export function SettingsView(): React.JSX.Element {
|
||||
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> {
|
||||
setAppChecking(true)
|
||||
setAppUpd(null)
|
||||
@@ -378,7 +430,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
setUpdating(true)
|
||||
setUpdateResult(null)
|
||||
try {
|
||||
const result = await window.api.updateYtdlp(updateChannel)
|
||||
const result = await window.api.updateYtdlp(ytdlpChannel)
|
||||
setUpdateResult(result)
|
||||
if (result.ok) setVersion(null) // stale — prompt a re-check rather than show a wrong version
|
||||
} catch (e) {
|
||||
@@ -389,7 +441,22 @@ export function SettingsView(): React.JSX.Element {
|
||||
}
|
||||
|
||||
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}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<ArrowDownloadRegular className={styles.sectionIcon} />
|
||||
@@ -491,6 +558,17 @@ export function SettingsView(): React.JSX.Element {
|
||||
label={notifyOnComplete ? 'On' : 'Off'}
|
||||
/>
|
||||
</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 className={styles.card}>
|
||||
@@ -599,6 +677,28 @@ export function SettingsView(): React.JSX.Element {
|
||||
label={useAria2c ? 'On' : 'Off'}
|
||||
/>
|
||||
</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 className={styles.card}>
|
||||
@@ -900,14 +1000,21 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Subtitle2>About</Subtitle2>
|
||||
</div>
|
||||
<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>
|
||||
<div className={styles.folderRow}>
|
||||
<Button onClick={checkVersion} disabled={checking}>
|
||||
Check yt-dlp version
|
||||
</Button>
|
||||
{checking && <Spinner size="tiny" />}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Keep yt-dlp updated automatically"
|
||||
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."
|
||||
>
|
||||
<Switch
|
||||
checked={autoUpdateYtdlp}
|
||||
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
|
||||
label={autoUpdateYtdlp ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{version?.ok && (
|
||||
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
|
||||
)}
|
||||
@@ -916,13 +1023,37 @@ export function SettingsView(): React.JSX.Element {
|
||||
{version.error}
|
||||
</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
|
||||
aria-label="Update channel"
|
||||
value={updateChannel}
|
||||
value={ytdlpChannel}
|
||||
options={UPDATE_CHANNEL_OPTIONS}
|
||||
onChange={(v) => setUpdateChannel(v as YtdlpUpdateChannel)}
|
||||
onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.folderRow}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ArrowDownloadRegular,
|
||||
HistoryRegular,
|
||||
LibraryRegular,
|
||||
WindowConsoleRegular,
|
||||
SettingsRegular,
|
||||
WeatherMoonRegular,
|
||||
WeatherSunnyRegular,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
import type { ThemeMode } from '@shared/ipc'
|
||||
import { Hint } from './Hint'
|
||||
|
||||
export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
|
||||
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -179,6 +180,7 @@ const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
|
||||
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
|
||||
{ value: 'library', label: 'Library', icon: <LibraryRegular /> },
|
||||
{ value: 'history', label: 'History', icon: <HistoryRegular /> },
|
||||
{ value: 'terminal', label: 'Terminal', icon: <WindowConsoleRegular /> },
|
||||
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
|
||||
]
|
||||
|
||||
|
||||
@@ -74,8 +74,10 @@ interface Draft {
|
||||
id: string | null
|
||||
name: 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 {
|
||||
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)
|
||||
|
||||
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 {
|
||||
if (!draft) return
|
||||
const name = draft.name.trim()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -154,6 +162,11 @@ export function TemplateManager(): React.JSX.Element {
|
||||
resize="vertical"
|
||||
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}>
|
||||
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -27,13 +27,19 @@ if (import.meta.env.DEV && !window.api) {
|
||||
useAria2c: false,
|
||||
cookieSource: 'none',
|
||||
cookiesBrowser: 'chrome',
|
||||
youtubePlayerClient: '',
|
||||
youtubePoToken: '',
|
||||
restrictFilenames: false,
|
||||
downloadArchive: false,
|
||||
autoUpdateYtdlp: true,
|
||||
ytdlpChannel: 'nightly',
|
||||
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
|
||||
customCommandEnabled: false,
|
||||
defaultTemplateId: null,
|
||||
notifyOnComplete: true,
|
||||
autoDownloadNew: true,
|
||||
hasCompletedOnboarding: true
|
||||
hasCompletedOnboarding: true,
|
||||
minimizeToTray: false
|
||||
}
|
||||
// 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.
|
||||
@@ -67,6 +73,7 @@ if (import.meta.env.DEV && !window.api) {
|
||||
runAppUpdate: async () => ({ ok: true }),
|
||||
onAppUpdateProgress: () => () => {},
|
||||
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) => {
|
||||
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
||||
// 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 }),
|
||||
cancelDownload: async () => {},
|
||||
pauseDownload: async () => {},
|
||||
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
|
||||
chooseFolder: async () => null,
|
||||
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
|
||||
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 () => [],
|
||||
clearErrorLog: async () => [],
|
||||
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}`,
|
||||
downloaded: i < 2
|
||||
})),
|
||||
onIndexProgress: () => () => {}
|
||||
onIndexProgress: () => () => {},
|
||||
runTerminal: async () => ({ ok: true }),
|
||||
cancelTerminal: async () => {},
|
||||
onTerminalOutput: () => () => {},
|
||||
setTaskbarProgress: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,14 @@ import { useSources } from './sources'
|
||||
|
||||
export type MediaKind = 'video' | 'audio'
|
||||
|
||||
export type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'error' | 'canceled'
|
||||
export type DownloadStatus =
|
||||
| 'queued'
|
||||
| 'downloading'
|
||||
| 'paused'
|
||||
| 'saved'
|
||||
| 'completed'
|
||||
| 'error'
|
||||
| 'canceled'
|
||||
|
||||
/** An exact probed format chosen for a download (omitted for the preset path). */
|
||||
export interface ChosenFormat {
|
||||
@@ -39,6 +46,10 @@ export interface DownloadItem {
|
||||
options?: DownloadOptions
|
||||
/** per-download custom-command override (omitted = use the persisted default template) */
|
||||
extraArgs?: string
|
||||
/** raw trim spec — time ranges to keep (parsed to --download-sections in main) */
|
||||
trim?: string
|
||||
/** epoch ms to auto-start a 'saved' item; undefined = parked indefinitely (Phase M) */
|
||||
scheduledFor?: number
|
||||
/** private download — completion is never recorded to history */
|
||||
incognito?: boolean
|
||||
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
|
||||
@@ -54,17 +65,8 @@ export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
|
||||
audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps']
|
||||
}
|
||||
|
||||
// True when running in the standalone browser preview (no Electron preload).
|
||||
// The real preload injects window.electron; the browser stub does not.
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
|
||||
interface DownloadState {
|
||||
items: DownloadItem[]
|
||||
addFromUrl: (
|
||||
url: string,
|
||||
kind: MediaKind,
|
||||
quality: string,
|
||||
opts?: {
|
||||
/** Options accepted when enqueuing a URL (shared by addFromUrl + addMany). */
|
||||
export interface AddOptions {
|
||||
format?: ChosenFormat
|
||||
title?: string
|
||||
channel?: string
|
||||
@@ -72,12 +74,50 @@ interface DownloadState {
|
||||
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
|
||||
}
|
||||
) => void
|
||||
|
||||
/** One URL to enqueue in a single addMany batch. */
|
||||
export interface AddEntry {
|
||||
url: string
|
||||
kind: MediaKind
|
||||
quality: string
|
||||
opts?: AddOptions
|
||||
}
|
||||
|
||||
// True when running in the standalone browser preview (no Electron preload).
|
||||
// The real preload injects window.electron; the browser stub does not.
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
|
||||
interface DownloadState {
|
||||
items: DownloadItem[]
|
||||
addFromUrl: (url: string, kind: MediaKind, quality: string, opts?: AddOptions) => void
|
||||
/**
|
||||
* Enqueue many URLs at once with a single state update and a single pump, so
|
||||
* queuing an entire channel (1000s of items) doesn't churn the store O(n²) the
|
||||
* way looping addFromUrl would.
|
||||
*/
|
||||
addMany: (entries: AddEntry[]) => void
|
||||
retry: (id: string) => void
|
||||
/** re-queue every failed item at once (Phase M) */
|
||||
retryAll: () => void
|
||||
/** stop a running download but keep its partial file for a later resume (Phase M) */
|
||||
pause: (id: string) => void
|
||||
/** re-queue a paused download; yt-dlp continues its partial file on relaunch */
|
||||
resume: (id: string) => void
|
||||
/** move a queued item to the front of the promotion order ("download next", Phase M) */
|
||||
prioritize: (id: string) => void
|
||||
/** park a queued item indefinitely as 'saved' (Phase M) */
|
||||
saveForLater: (id: string) => void
|
||||
/** un-park a 'saved' item back into the queue now (clears any schedule) */
|
||||
queueNow: (id: string) => void
|
||||
/** internal: promote any 'saved' item whose scheduledFor time has arrived */
|
||||
promoteDueScheduled: () => void
|
||||
cancel: (id: string) => void
|
||||
remove: (id: string) => void
|
||||
clearFinished: () => void
|
||||
@@ -95,6 +135,44 @@ function newId(): string {
|
||||
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 {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
@@ -253,6 +331,7 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
formatHasAudio: item.formatHasAudio,
|
||||
options: item.options,
|
||||
extraArgs: item.extraArgs,
|
||||
trim: item.trim,
|
||||
meta: item.probedMeta,
|
||||
collection: item.collection
|
||||
})
|
||||
@@ -291,35 +370,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
pump,
|
||||
|
||||
addFromUrl: (url, kind, quality, opts) => {
|
||||
const id = newId()
|
||||
// If the caller already probed the URL, carry that metadata so main can skip
|
||||
// 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 =
|
||||
opts?.title || opts?.channel || opts?.durationLabel
|
||||
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
|
||||
: undefined
|
||||
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] }))
|
||||
set((s) => ({ items: [buildItem(url, kind, quality, opts), ...s.items] }))
|
||||
pump()
|
||||
},
|
||||
|
||||
addMany: (entries) => {
|
||||
if (entries.length === 0) return
|
||||
const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts))
|
||||
set((s) => ({ items: [...built, ...s.items] }))
|
||||
pump()
|
||||
},
|
||||
|
||||
@@ -334,6 +392,93 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
pump()
|
||||
},
|
||||
|
||||
retryAll: () => {
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.status === 'error'
|
||||
? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined }
|
||||
: i
|
||||
)
|
||||
}))
|
||||
pump()
|
||||
},
|
||||
|
||||
pause: (id) => {
|
||||
const cur = get().items.find((i) => i.id === id)
|
||||
if (!cur) return
|
||||
// Only a running download has a live process to stop; a queued item is
|
||||
// just parked in place. Either way it becomes 'paused' (pump ignores it).
|
||||
if (!PREVIEW && cur.status === 'downloading') window.api.pauseDownload(id).catch(() => {})
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.id === id && (i.status === 'downloading' || i.status === 'queued')
|
||||
? { ...i, status: 'paused', speed: undefined, eta: undefined }
|
||||
: i
|
||||
)
|
||||
}))
|
||||
pump() // pausing a running item frees a slot
|
||||
},
|
||||
|
||||
resume: (id) => {
|
||||
// Re-queue WITHOUT resetting progress (unlike retry): pump() promotes it and
|
||||
// launchItem re-spawns yt-dlp, which continues the partial file by default.
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.id === id && i.status === 'paused' ? { ...i, status: 'queued' } : i
|
||||
)
|
||||
}))
|
||||
pump()
|
||||
},
|
||||
|
||||
prioritize: (id) => {
|
||||
// Reposition the item so pump() — which promotes the highest-index queued
|
||||
// item first (oldest-first over a newest-first array) — picks it next.
|
||||
set((s) => {
|
||||
const idx = s.items.findIndex((i) => i.id === id)
|
||||
if (idx < 0 || s.items[idx].status !== 'queued') return {}
|
||||
const items = s.items.slice()
|
||||
const [it] = items.splice(idx, 1)
|
||||
let after = -1
|
||||
for (let k = 0; k < items.length; k++) if (items[k].status === 'queued') after = k
|
||||
items.splice(after + 1, 0, it)
|
||||
return { items }
|
||||
})
|
||||
pump()
|
||||
},
|
||||
|
||||
saveForLater: (id) => {
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.id === id && i.status === 'queued'
|
||||
? { ...i, status: 'saved', scheduledFor: undefined }
|
||||
: i
|
||||
)
|
||||
}))
|
||||
},
|
||||
|
||||
queueNow: (id) => {
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.id === id && i.status === 'saved'
|
||||
? { ...i, status: 'queued', scheduledFor: undefined }
|
||||
: i
|
||||
)
|
||||
}))
|
||||
pump()
|
||||
},
|
||||
|
||||
promoteDueScheduled: () => {
|
||||
const now = Date.now()
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
|
||||
? { ...i, status: 'queued', scheduledFor: undefined }
|
||||
: i
|
||||
)
|
||||
}))
|
||||
pump()
|
||||
},
|
||||
|
||||
cancel: (id) => {
|
||||
const cur = get().items.find((i) => i.id === id)
|
||||
if (!cur) return
|
||||
@@ -356,7 +501,13 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
|
||||
clearFinished: () =>
|
||||
set((s) => ({
|
||||
items: s.items.filter((i) => i.status === 'downloading' || i.status === 'queued')
|
||||
items: s.items.filter(
|
||||
(i) =>
|
||||
i.status === 'downloading' ||
|
||||
i.status === 'queued' ||
|
||||
i.status === 'paused' ||
|
||||
i.status === 'saved'
|
||||
)
|
||||
})),
|
||||
|
||||
openFile: (id) => {
|
||||
@@ -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.)
|
||||
if (!PREVIEW) {
|
||||
window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev))
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Pure helpers for the Downloads view (Phase M): a one-line aggregate of the live
|
||||
* queue, and same-video detection for the duplicate guard. Kept free of any store
|
||||
* / window dependency (only a type-only import of DownloadItem) so it can be
|
||||
* unit-tested without constructing the Zustand store or its preview ticker.
|
||||
*/
|
||||
import type { DownloadItem } from './downloads'
|
||||
|
||||
// Parse a human speed string ('4.7 MB/s', '512 KiB/s') into bytes/second. Tolerant
|
||||
// of the SI (KB) and binary (KiB) suffixes yt-dlp may emit; the 1000-vs-1024
|
||||
// difference is immaterial for a live display estimate.
|
||||
function parseSpeed(s?: string): number {
|
||||
if (!s) return 0
|
||||
const m = /([\d.]+)\s*([KMGT]?)i?B\/s/i.exec(s)
|
||||
if (!m) return 0
|
||||
const n = parseFloat(m[1])
|
||||
if (!isFinite(n)) return 0
|
||||
const mult: Record<string, number> = { '': 1, K: 1e3, M: 1e6, G: 1e9, T: 1e12 }
|
||||
return n * (mult[m[2].toUpperCase()] ?? 1)
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec: number): string {
|
||||
if (bytesPerSec <= 0) return ''
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']
|
||||
let v = bytesPerSec
|
||||
let i = 0
|
||||
while (v >= 1000 && i < units.length - 1) {
|
||||
v /= 1000
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
// Parse 'M:SS' / 'H:MM:SS' (or bare seconds) into seconds.
|
||||
function parseEtaSeconds(s?: string): number {
|
||||
if (!s) return 0
|
||||
const parts = s.split(':').map((p) => Number(p))
|
||||
if (parts.length === 0 || parts.some((p) => !isFinite(p))) return 0
|
||||
return parts.reduce((acc, p) => acc * 60 + p, 0)
|
||||
}
|
||||
|
||||
function formatEta(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return ''
|
||||
const s = Math.round(totalSeconds)
|
||||
const m = Math.floor(s / 60)
|
||||
return `${m}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export interface QueueSummary {
|
||||
downloading: number
|
||||
queued: number
|
||||
failed: number
|
||||
/** true when something is downloading or waiting */
|
||||
active: boolean
|
||||
/** 0..1, averaged over downloading + queued items */
|
||||
progress: number
|
||||
/** combined download speed, formatted; '' when nothing is downloading */
|
||||
speedLabel: string
|
||||
/** rough time remaining (the longest active ETA), formatted; '' when unknown */
|
||||
etaLabel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the live queue into a one-line summary for the Downloads header.
|
||||
* Combined speed is summed across downloading items; the "time left" is the
|
||||
* longest single ETA — a reasonable proxy for when the current batch finishes
|
||||
* (we don't track remaining bytes, so a true combined ETA isn't available).
|
||||
* Queued items count toward the progress average as 0%, so the bar reflects
|
||||
* "how much of the visible work is done", not just the active downloads.
|
||||
*/
|
||||
export function summarizeQueue(items: DownloadItem[]): QueueSummary {
|
||||
let downloading = 0
|
||||
let queued = 0
|
||||
let failed = 0
|
||||
let progressSum = 0
|
||||
let progressCount = 0
|
||||
let speed = 0
|
||||
let maxEta = 0
|
||||
for (const i of items) {
|
||||
if (i.status === 'downloading') {
|
||||
downloading++
|
||||
progressSum += i.progress
|
||||
progressCount++
|
||||
speed += parseSpeed(i.speed)
|
||||
maxEta = Math.max(maxEta, parseEtaSeconds(i.eta))
|
||||
} else if (i.status === 'queued') {
|
||||
queued++
|
||||
progressCount++
|
||||
} else if (i.status === 'error') {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
return {
|
||||
downloading,
|
||||
queued,
|
||||
failed,
|
||||
active: downloading > 0 || queued > 0,
|
||||
progress: progressCount > 0 ? progressSum / progressCount : 0,
|
||||
speedLabel: formatSpeed(speed),
|
||||
etaLabel: formatEta(maxEta)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Duplicate detection ----------------------------------------------------
|
||||
|
||||
function youtubeId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const host = u.hostname.replace(/^www\./, '')
|
||||
if (host === 'youtu.be') return u.pathname.slice(1) || null
|
||||
if (host.endsWith('youtube.com')) return u.searchParams.get('v')
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const host = u.hostname.replace(/^www\./, '').toLowerCase()
|
||||
return `${u.protocol}//${host}${u.pathname.replace(/\/$/, '')}${u.search}`
|
||||
} catch {
|
||||
return url.trim()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when two URLs point at the same video: a matching YouTube id when both
|
||||
* are YouTube links, otherwise normalised-URL equality (host lower-cased, `www.`
|
||||
* and a trailing slash stripped). Powers the "already in your queue" guard.
|
||||
*/
|
||||
export function sameVideo(a: string, b: string): boolean {
|
||||
if (a === b) return true
|
||||
const ya = youtubeId(a)
|
||||
const yb = youtubeId(b)
|
||||
if (ya && yb) return ya === yb
|
||||
return normalizeUrl(a) === normalizeUrl(b)
|
||||
}
|
||||
@@ -21,15 +21,21 @@ const FALLBACK: Settings = {
|
||||
useAria2c: false,
|
||||
cookieSource: 'none',
|
||||
cookiesBrowser: 'chrome',
|
||||
youtubePlayerClient: '',
|
||||
youtubePoToken: '',
|
||||
restrictFilenames: false,
|
||||
downloadArchive: false,
|
||||
autoUpdateYtdlp: true,
|
||||
ytdlpChannel: 'nightly',
|
||||
ytdlpLastUpdateCheck: 0,
|
||||
customCommandEnabled: false,
|
||||
defaultTemplateId: null,
|
||||
notifyOnComplete: true,
|
||||
autoDownloadNew: true,
|
||||
// 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.
|
||||
hasCompletedOnboarding: PREVIEW
|
||||
hasCompletedOnboarding: PREVIEW,
|
||||
minimizeToTray: false
|
||||
}
|
||||
|
||||
interface SettingsState extends Settings {
|
||||
|
||||
@@ -6,11 +6,6 @@ import { useSettings } from './settings'
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
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 ------------------------------------------------------
|
||||
|
||||
function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] {
|
||||
@@ -87,8 +82,10 @@ interface SourcesState {
|
||||
reindexSource: (id: string) => Promise<void>
|
||||
removeSource: (id: string) => void
|
||||
/**
|
||||
* Enqueue the given media items into the download queue with folder context,
|
||||
* capped at MAX_ENQUEUE_BATCH. Returns how many were actually enqueued.
|
||||
* Enqueue the given media items into the download queue with folder context.
|
||||
* 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
|
||||
/** mark an item downloaded once its queue download completes (called by the downloads store) */
|
||||
@@ -185,10 +182,13 @@ export const useSources = create<SourcesState>((set, get) => ({
|
||||
const settings = useSettings.getState()
|
||||
const kind = settings.defaultKind
|
||||
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
|
||||
const batch = items.slice(0, MAX_ENQUEUE_BATCH)
|
||||
const add = useDownloads.getState().addFromUrl
|
||||
for (const it of batch) {
|
||||
add(it.url, kind, quality, {
|
||||
// One batched state update + pump, so queuing a whole channel stays O(n).
|
||||
useDownloads.getState().addMany(
|
||||
items.map((it) => ({
|
||||
url: it.url,
|
||||
kind,
|
||||
quality,
|
||||
opts: {
|
||||
title: it.title,
|
||||
channel: src?.channel,
|
||||
durationLabel: it.durationLabel,
|
||||
@@ -198,9 +198,10 @@ export const useSources = create<SourcesState>((set, get) => ({
|
||||
index: it.playlistIndex
|
||||
},
|
||||
mediaItemId: it.id
|
||||
})
|
||||
}
|
||||
return batch.length
|
||||
}))
|
||||
)
|
||||
return items.length
|
||||
},
|
||||
|
||||
markDownloaded: (itemId, filePath) => {
|
||||
|
||||
+112
-1
@@ -15,9 +15,11 @@ export const IpcChannels = {
|
||||
/** main → renderer push channel for installer download progress */
|
||||
appUpdateProgress: 'app:update-progress',
|
||||
ytdlpVersion: 'ytdlp:version',
|
||||
ffmpegVersion: 'ffmpeg:version',
|
||||
probe: 'media:probe',
|
||||
downloadStart: 'download:start',
|
||||
downloadCancel: 'download:cancel',
|
||||
downloadPause: 'download:pause',
|
||||
defaultFolder: 'download:default-folder',
|
||||
chooseFolder: 'download:choose-folder',
|
||||
openPath: 'shell:open-path',
|
||||
@@ -40,6 +42,8 @@ export const IpcChannels = {
|
||||
templatesRemove: 'templates:remove',
|
||||
commandPreview: 'command:preview',
|
||||
ytdlpUpdate: 'ytdlp:update',
|
||||
/** main → renderer push channel for background yt-dlp auto-update status */
|
||||
ytdlpAutoUpdateStatus: 'ytdlp:auto-update-status',
|
||||
errorLogList: 'errorlog:list',
|
||||
errorLogClear: 'errorlog:clear',
|
||||
backupExport: 'backup:export',
|
||||
@@ -67,7 +71,14 @@ export const IpcChannels = {
|
||||
scheduledSyncGet: 'sources:scheduled-sync-get',
|
||||
scheduledSyncSet: 'sources:scheduled-sync-set',
|
||||
/** 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
|
||||
|
||||
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
|
||||
@@ -143,6 +154,13 @@ export interface DownloadOptions {
|
||||
videoContainer: VideoContainer
|
||||
/** preferred video codec (sort tiebreaker, not a hard filter) */
|
||||
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 */
|
||||
embedSubtitles: boolean
|
||||
/** comma-separated yt-dlp sub-lang selectors, e.g. 'en' or 'en.*,es' */
|
||||
@@ -155,6 +173,8 @@ export interface DownloadOptions {
|
||||
sponsorBlockCategories: SponsorBlockCategory[]
|
||||
/** embed chapter markers */
|
||||
embedChapters: boolean
|
||||
/** split the download into one file per chapter (--split-chapters) */
|
||||
splitChapters: boolean
|
||||
/** embed metadata (title/artist/etc.) */
|
||||
embedMetadata: boolean
|
||||
/** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */
|
||||
@@ -173,6 +193,7 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
|
||||
audioFormat: 'mp3',
|
||||
videoContainer: 'mp4',
|
||||
preferredVideoCodec: 'any',
|
||||
formatSort: '',
|
||||
embedSubtitles: false,
|
||||
subtitleLanguages: 'en',
|
||||
autoSubtitles: false,
|
||||
@@ -180,6 +201,7 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
|
||||
sponsorBlockMode: 'remove',
|
||||
sponsorBlockCategories: ['sponsor'],
|
||||
embedChapters: false,
|
||||
splitChapters: false,
|
||||
embedMetadata: true,
|
||||
embedThumbnail: true,
|
||||
cropThumbnail: false,
|
||||
@@ -196,6 +218,18 @@ export interface YtdlpVersionResult {
|
||||
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. */
|
||||
export interface AppUpdateInfo {
|
||||
ok: boolean
|
||||
@@ -260,6 +294,24 @@ export interface YtdlpUpdateResult {
|
||||
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`. */
|
||||
export interface FormatOption {
|
||||
/** 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.
|
||||
*/
|
||||
extraArgs?: string
|
||||
/**
|
||||
* Optional trim: keep only these time ranges. Raw user text — ranges like
|
||||
* '1:30-2:00', comma- or newline-separated — normalised to yt-dlp
|
||||
* `--download-sections` specs in buildArgs (parseTrimSections). Empty or
|
||||
* undefined downloads the whole media.
|
||||
*/
|
||||
trim?: string
|
||||
/**
|
||||
* Metadata the renderer already fetched when probing the URL (title/channel/
|
||||
* duration). When present, main uses it directly instead of spawning a second
|
||||
@@ -433,10 +492,27 @@ export interface Settings {
|
||||
cookieSource: CookieSource
|
||||
/** which browser to read cookies from when cookieSource is 'browser' */
|
||||
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) */
|
||||
restrictFilenames: boolean
|
||||
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */
|
||||
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 */
|
||||
customCommandEnabled: boolean
|
||||
/** id of the CommandTemplate applied when customCommandEnabled is on; null = none picked yet */
|
||||
@@ -447,6 +523,8 @@ export interface Settings {
|
||||
autoDownloadNew: boolean
|
||||
/** false until the first-run welcome screen has been dismissed */
|
||||
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
|
||||
name: 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. */
|
||||
@@ -621,3 +706,29 @@ export interface ScheduledSyncStatus {
|
||||
enabled: boolean
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildArgs,
|
||||
parseExtraArgs,
|
||||
parseTrimSections,
|
||||
selectExtraArgs,
|
||||
formatCommandLine,
|
||||
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) -----------------------
|
||||
|
||||
describe('sanitizeDirSegment', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user