diff --git a/ROADMAP-PINCHFLAT.md b/ROADMAP-PINCHFLAT.md index 999dc26..2c72b62 100644 --- a/ROADMAP-PINCHFLAT.md +++ b/ROADMAP-PINCHFLAT.md @@ -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/`, `/c/`, `/user/`) 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 `/// - .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. diff --git a/ROADMAP.md b/ROADMAP.md index e2be86c..021a5e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/docs/SIGNING.md b/docs/SIGNING.md new file mode 100644 index 0000000..dcdfdf2 --- /dev/null +++ b/docs/SIGNING.md @@ -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). diff --git a/docs/SMOKE-TEST.md b/docs/SMOKE-TEST.md new file mode 100644 index 0000000..d2b9e58 --- /dev/null +++ b/docs/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. diff --git a/electron-builder.yml b/electron-builder.yml index 529d87a..7ace0c8 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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: diff --git a/package-lock.json b/package-lock.json index a0fcf77..904574b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9424335..f438460 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/binaries.ts b/src/main/binaries.ts index 18eef62..098256f 100644 --- a/src/main/binaries.ts +++ b/src/main/binaries.ts @@ -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') } diff --git a/src/main/buildArgs.ts b/src/main/buildArgs.ts index 1dd697b..7b66cf3 100644 --- a/src/main/buildArgs.ts +++ b/src/main/buildArgs.ts @@ -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}`) } diff --git a/src/main/download.ts b/src/main/download.ts index c4fe548..48f424b 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -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 +// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve +// taskkill from System32 by absolute path, never the bare name, so a planted +// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3) +function killTree(rec: ActiveDownload): void { const pid = rec.child.pid if (pid != null) { - // Kill the whole tree (/T) so the spawned ffmpeg child dies too. Resolve - // taskkill from System32 by absolute path, never the bare name, so a planted - // taskkill.exe on PATH / in the CWD can't run in its place. (audit F3) execFile( getSystem32Path('taskkill.exe'), ['/pid', String(pid), '/T', '/F'], @@ -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) +} diff --git a/src/main/ffmpeg.ts b/src/main/ffmpeg.ts new file mode 100644 index 0000000..e6886b8 --- /dev/null +++ b/src/main/ffmpeg.ts @@ -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 } +} diff --git a/src/main/index.ts b/src/main/index.ts index fc0184c..c5dc614 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() diff --git a/src/main/settings.ts b/src/main/settings.ts index f99a049..96c2558 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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 } diff --git a/src/main/templates.ts b/src/main/templates.ts index 17b7420..3f12c89 100644 --- a/src/main/templates.ts +++ b/src/main/templates.ts @@ -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 } : {}) } } diff --git a/src/main/terminal.ts b/src/main/terminal.ts new file mode 100644 index 0000000..77dd2ee --- /dev/null +++ b/src/main/terminal.ts @@ -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() + } +} diff --git a/src/main/tray.ts b/src/main/tray.ts new file mode 100644 index 0000000..1227fba --- /dev/null +++ b/src/main/tray.ts @@ -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)) +} diff --git a/src/main/ytdlp.ts b/src/main/ytdlp.ts index 4890cd8..ffec8fa 100644 --- a/src/main/ytdlp.ts +++ b/src/main/ytdlp.ts @@ -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() + }) +} diff --git a/src/main/ytdlpPolicy.ts b/src/main/ytdlpPolicy.ts new file mode 100644 index 0000000..a2d54f5 --- /dev/null +++ b/src/main/ytdlpPolicy.ts @@ -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 +} diff --git a/src/preload/index.ts b/src/preload/index.ts index eb95485..7999e4a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c90b0aa..7775d38 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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> diff --git a/src/renderer/src/components/CommandPalette.tsx b/src/renderer/src/components/CommandPalette.tsx new file mode 100644 index 0000000..ac4ecae --- /dev/null +++ b/src/renderer/src/components/CommandPalette.tsx @@ -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> + ) +} diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index a76f075..9fea3e1 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -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,32 +738,108 @@ 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) => ( - <Checkbox - key={e.index} - className={styles.plItem} - checked={selected.has(e.index)} - onChange={(_, d) => toggleEntry(e.index, !!d.checked)} - label={ - <span className={styles.plItemLabel}> - <span className={styles.plItemTitle}> - {e.index}. {e.title} + <div key={e.index} className={styles.plItemRow}> + <Checkbox + className={styles.plItem} + checked={selected.has(e.index)} + onChange={(_, d) => toggleEntry(e.index, !!d.checked)} + label={ + <span className={styles.plItemLabel}> + <span className={styles.plItemTitle}> + {e.index}. {e.title} + </span> + {(e.durationLabel || e.uploader) && ( + <Caption1 className={styles.plItemMeta}> + {[e.durationLabel, e.uploader].filter(Boolean).join(' • ')} + </Caption1> + )} </span> - {(e.durationLabel || e.uploader) && ( - <Caption1 className={styles.plItemMeta}> - {[e.durationLabel, e.uploader].filter(Boolean).join(' • ')} - </Caption1> - )} - </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> diff --git a/src/renderer/src/components/DownloadOptionsForm.tsx b/src/renderer/src/components/DownloadOptionsForm.tsx index d5c4aae..d287f8b 100644 --- a/src/renderer/src/components/DownloadOptionsForm.tsx +++ b/src/renderer/src/components/DownloadOptionsForm.tsx @@ -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} diff --git a/src/renderer/src/components/DownloadsView.tsx b/src/renderer/src/components/DownloadsView.tsx index efd0e55..e35334f 100644 --- a/src/renderer/src/components/DownloadsView.tsx +++ b/src/renderer/src/components/DownloadsView.tsx @@ -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,13 +83,37 @@ export function DownloadsView(): React.JSX.Element { <div className={styles.root}> <DownloadBar /> + {summary.active && ( + <div className={styles.summary}> + <ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" /> + <Caption1 className={styles.summaryText}> + {summary.downloading} downloading + {summary.queued ? `, ${summary.queued} queued` : ''} + {summary.speedLabel ? ` • ${summary.speedLabel}` : ''} + {summary.etaLabel ? ` • ~${summary.etaLabel} left` : ''} + </Caption1> + </div> + )} + <div className={styles.queueHeader}> <Subtitle2>Queue ({items.length})</Subtitle2> - {hasFinished && ( - <Button size="small" appearance="subtle" onClick={clearFinished}> - Clear finished - </Button> - )} + <div className={styles.headerActions}> + {summary.failed > 0 && ( + <Button + size="small" + appearance="subtle" + icon={<ArrowClockwiseRegular />} + onClick={retryAll} + > + Retry all failed ({summary.failed}) + </Button> + )} + {hasFinished && ( + <Button size="small" appearance="subtle" onClick={clearFinished}> + Clear finished + </Button> + )} + </div> </div> {items.length === 0 ? ( @@ -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> ) diff --git a/src/renderer/src/components/LibraryView.tsx b/src/renderer/src/components/LibraryView.tsx index 0fffe6a..42ab3ea 100644 --- a/src/renderer/src/components/LibraryView.tsx +++ b/src/renderer/src/components/LibraryView.tsx @@ -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,54 +639,28 @@ 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> - </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> - ) - })} + {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> </SourceCard> ))} diff --git a/src/renderer/src/components/QueueItem.tsx b/src/renderer/src/components/QueueItem.tsx index 394d5bc..cdf2297 100644 --- a/src/renderer/src/components/QueueItem.tsx +++ b/src/renderer/src/components/QueueItem.tsx @@ -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> ) -} +}) diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 8f52a91..2d8aab7 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -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}> diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 550d66f..4f0b61f 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -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 /> } ] diff --git a/src/renderer/src/components/TemplateManager.tsx b/src/renderer/src/components/TemplateManager.tsx index 4a6e05c..412961c 100644 --- a/src/renderer/src/components/TemplateManager.tsx +++ b/src/renderer/src/components/TemplateManager.tsx @@ -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 diff --git a/src/renderer/src/components/TerminalView.tsx b/src/renderer/src/components/TerminalView.tsx new file mode 100644 index 0000000..4fd1ee9 --- /dev/null +++ b/src/renderer/src/components/TerminalView.tsx @@ -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> + ) +} diff --git a/src/renderer/src/components/VirtualList.tsx b/src/renderer/src/components/VirtualList.tsx new file mode 100644 index 0000000..9bd9bc0 --- /dev/null +++ b/src/renderer/src/components/VirtualList.tsx @@ -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> + ) +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 9362047..81c4d4e 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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: () => {} } } diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index 14ce7ca..de168d4 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -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,30 +65,59 @@ export const QUALITY_OPTIONS: Record<MediaKind, string[]> = { audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps'] } +/** Options accepted when enqueuing a URL (shared by addFromUrl + addMany). */ +export interface AddOptions { + format?: ChosenFormat + title?: string + channel?: string + durationLabel?: string + thumbnail?: string + options?: DownloadOptions + extraArgs?: string + trim?: string + /** when set and in the future, the item starts 'saved' and auto-queues at this time */ + scheduledFor?: number + incognito?: boolean + collection?: CollectionContext + mediaItemId?: string +} + +/** One URL to enqueue in a single addMany batch. */ +export interface AddEntry { + url: string + kind: MediaKind + quality: string + opts?: AddOptions +} + // True when running in the standalone browser preview (no Electron preload). // 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?: { - format?: ChosenFormat - title?: string - channel?: string - durationLabel?: string - thumbnail?: string - options?: DownloadOptions - extraArgs?: string - incognito?: boolean - collection?: CollectionContext - mediaItemId?: string - } - ) => void + 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)) diff --git a/src/renderer/src/store/queueStats.ts b/src/renderer/src/store/queueStats.ts new file mode 100644 index 0000000..f12f077 --- /dev/null +++ b/src/renderer/src/store/queueStats.ts @@ -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) +} diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index bd5f490..d147810 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -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 { diff --git a/src/renderer/src/store/sources.ts b/src/renderer/src/store/sources.ts index 1b89a4c..0cd1a37 100644 --- a/src/renderer/src/store/sources.ts +++ b/src/renderer/src/store/sources.ts @@ -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,22 +182,26 @@ 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, { - title: it.title, - channel: src?.channel, - durationLabel: it.durationLabel, - collection: { - channel: src?.channel || src?.title || 'Channel', - playlist: it.playlistTitle, - index: it.playlistIndex - }, - mediaItemId: it.id - }) - } - return batch.length + // 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, + collection: { + channel: src?.channel || src?.title || 'Channel', + playlist: it.playlistTitle, + index: it.playlistIndex + }, + mediaItemId: it.id + } + })) + ) + return items.length }, markDownloaded: (itemId, filePath) => { diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 100eaa2..a6e6689 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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' +} diff --git a/test/buildArgs.test.ts b/test/buildArgs.test.ts index 876a627..fb125d7 100644 --- a/test/buildArgs.test.ts +++ b/test/buildArgs.test.ts @@ -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', () => { diff --git a/test/queueStats.test.ts b/test/queueStats.test.ts new file mode 100644 index 0000000..50f9de7 --- /dev/null +++ b/test/queueStats.test.ts @@ -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) + }) +}) diff --git a/test/ytdlpPolicy.test.ts b/test/ytdlpPolicy.test.ts new file mode 100644 index 0000000..1f5101e --- /dev/null +++ b/test/ytdlpPolicy.test.ts @@ -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) + }) +})