Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4fcd19e17 | |||
| 24c4c807a3 | |||
| 20ee913394 | |||
| da37690b42 | |||
| 9ac11ceb6c | |||
| 43a62dc4a2 | |||
| 28237bdaa2 | |||
| 8e161fe9ce | |||
| 4854fcc947 | |||
| fa699a803d | |||
| 97e725c774 | |||
| fa92383ef4 | |||
| 7134a3d634 | |||
| 981d2dd34d | |||
| 08b9ed9e7a | |||
| 5e3512f366 | |||
| e8ab8b9c73 | |||
| 76098c6928 | |||
| 3536626a8a | |||
| 2718624828 | |||
| 37687f9870 | |||
| a2763f10b4 | |||
| 831d0a7dc2 | |||
| 47da3e6746 |
@@ -7,3 +7,6 @@ dist
|
|||||||
|
|
||||||
# Local screen-capture frames (not source)
|
# Local screen-capture frames (not source)
|
||||||
.frames/
|
.frames/
|
||||||
|
|
||||||
|
# Secrets — never commit
|
||||||
|
.gitea-token
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# AeroFetch Roadmap — media-manager mode (Pinchflat parity)
|
||||||
|
|
||||||
|
The [Seal-parity roadmap](ROADMAP.md) took AeroFetch from "paste a URL → download" to a
|
||||||
|
full-featured yt-dlp frontend (Phases A–E, complete). This document is a **second, parallel
|
||||||
|
track**: turning AeroFetch from a one-shot *downloader* into a *media manager* in the spirit
|
||||||
|
of [Pinchflat](https://github.com/kieraneglin/pinchflat) — download **entire channels**,
|
||||||
|
keep them **organized into `Channel / Playlist / Title` folders**, and (eventually) **keep
|
||||||
|
them in sync** as the channel posts new videos.
|
||||||
|
|
||||||
|
Nothing here removes or replaces existing behaviour. The single-video / hand-picked-playlist
|
||||||
|
flow (probe → format pick → queue → history) stays exactly as-is and remains the default. The
|
||||||
|
media-manager features are **additive**: a new "Library" section feeds the *same* download
|
||||||
|
queue, concurrency cap, history, error log, cookies, and post-processing options already built.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
All six phases are implemented and **rebuild-gated** (`npm run typecheck` + `npm run test`
|
||||||
|
+ `npm run build` clean after each). The unit suite grew from 76 → **106 tests** (new pure
|
||||||
|
logic in `indexerCore.ts` + the folder/sidecar args). UI phases were additionally verified
|
||||||
|
in the Vite browser preview.
|
||||||
|
|
||||||
|
| Phase | What shipped | Verification |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **F** | `classifySource`/`buildMediaItems`/`stableSourceId` (`indexerCore.ts`); channel-walk + persist (`indexer.ts`, `sources.ts`); IPC + preload | typecheck · 88 tests · build |
|
||||||
|
| **G** | `sanitizeDirSegment` + `collectionOutputTemplate` (`buildArgs.ts`); `CollectionContext` threaded through `buildCommand` | typecheck · 97 tests · build |
|
||||||
|
| **H** | Library sidebar tab + `LibraryView.tsx` + `store/sources.ts`; grouped tree, "Download N pending" → existing queue, live status pills | **preview-verified** |
|
||||||
|
| **I** | `mergeItemsPreservingState` (re-index keeps downloaded state, drops vanished, counts new); persist-on-complete via `markDownloaded` | **preview-verified** (state survives a queue clear) |
|
||||||
|
| **J** | Watched sources + RSS fast-check (`sync.ts`), per-source Watch toggle, "Check for new", `autoDownloadNew`; Task Scheduler (`schedule.ts`) + `--sync` launch | UI preview-verified; **OS-level scheduling/RSS fenced — needs a real-install smoke test** (same caveat as the `aerofetch://` wiring in ROADMAP.md) |
|
||||||
|
| **K** | `.info.json` / thumbnail / `.description` sidecars via `DownloadOptions` + `DownloadOptionsForm` "Sidecar files" group | typecheck · 106 tests · build + preview-verified |
|
||||||
|
|
||||||
|
**Deferred sub-items (noted, not yet built):** the power-user raw output-path template
|
||||||
|
setting (G bullet 3 — current layout is the fixed `Channel/Playlist/NNN - Title`); an
|
||||||
|
automatic incremental feeder so the live queue self-refills (H — today "Download pending"
|
||||||
|
enqueues a capped batch of `MAX_ENQUEUE_BATCH`); the retention / quality-upgrade re-download
|
||||||
|
(I stretch); a true headless quit-when-done for the scheduled `--sync` run (J — today it
|
||||||
|
launches the normal window unobtrusively and syncs on startup); and Kodi/Jellyfin `.nfo`
|
||||||
|
generation (K — the three native yt-dlp sidecars ship; `.nfo` needs a custom post-step).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The core idea borrowed from Pinchflat
|
||||||
|
|
||||||
|
Pinchflat's insight is to **separate indexing from downloading**, with a persisted index in
|
||||||
|
between:
|
||||||
|
|
||||||
|
1. **Index** a *Source* (a channel or playlist) — enumerate *all* its videos with
|
||||||
|
`yt-dlp --flat-playlist` and persist one lightweight record per video, **regardless of any
|
||||||
|
filters**. Changing what you want to download never forces a re-index.
|
||||||
|
2. **Filter** that persisted list to decide what to actually fetch.
|
||||||
|
3. **Download** each chosen video as its own per-item yt-dlp run, placed into folders by an
|
||||||
|
output-path template, with `--download-archive` + a per-item `downloaded` flag preventing
|
||||||
|
re-downloads.
|
||||||
|
|
||||||
|
This is why "download an entire channel" doesn't have to flood the queue with thousands of
|
||||||
|
live cards (the failure mode of naïve per-video enumeration) and doesn't need a custom
|
||||||
|
playlist-progress parser (the failure mode of native `yt-dlp` playlist expansion in one
|
||||||
|
process): **the channel's full video list lives in a store; the live queue only holds what's
|
||||||
|
actively downloading.** AeroFetch's per-video progress, concurrency, cancel, retry, and
|
||||||
|
history all keep working unchanged.
|
||||||
|
|
||||||
|
### Data model — Pinchflat → AeroFetch
|
||||||
|
|
||||||
|
| Pinchflat (Elixir + SQLite + Oban) | AeroFetch (Electron + JSON store + existing queue) |
|
||||||
|
| --- | --- |
|
||||||
|
| **Source** (watched channel/playlist) | `Source` record in a new `sources.json` (mirrors `history.ts`) |
|
||||||
|
| **Media Item** (one discovered video) | `MediaItem` record (id, title, playlist, index, source id, `downloaded`) |
|
||||||
|
| **Media Profile** (download preset) | Maps onto existing `DownloadOptions` + `CommandTemplate` + a new output-path template |
|
||||||
|
| Oban `media_collection_indexing` queue | An async **index job** in main (the probe, persisted) |
|
||||||
|
| Oban `media_fetching` queue | The existing renderer download queue + concurrency cap |
|
||||||
|
| Oban `fast_indexing` (RSS) | Phase J — YouTube RSS re-sync |
|
||||||
|
| `--download-archive` + item state | The existing `--download-archive` setting + `MediaItem.downloaded` |
|
||||||
|
|
||||||
|
**Storage decision:** stay with plain-JSON stores (the pattern Phase D deliberately kept over
|
||||||
|
better-sqlite3 to avoid a native-module build step). A single channel is up to a few thousand
|
||||||
|
`MediaItem`s — still fine to hold and filter client-side. Revisit better-sqlite3 only if a
|
||||||
|
user indexes many large channels and the JSON files get unwieldy (noted as a Phase H risk).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase F — Channel & playlist indexing (foundation) ✅ COMPLETE
|
||||||
|
|
||||||
|
Make AeroFetch able to *enumerate and remember* a whole channel without downloading anything
|
||||||
|
yet. This is the prerequisite for every later phase.
|
||||||
|
|
||||||
|
- [x] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`,
|
||||||
|
`/channel/<id>`, `/c/<name>`, `/user/<name>`) resolves to a new `kind: 'channel'`
|
||||||
|
`ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the
|
||||||
|
nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists →
|
||||||
|
videos: probe `…/playlists` (flat) for the playlist list, plus a synthetic **"Uploads"**
|
||||||
|
playlist from `…/videos` for videos that belong to no playlist. Lazy-probe each
|
||||||
|
playlist's video list on demand rather than all up front.
|
||||||
|
- [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 {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
kind: 'channel' | 'playlist'
|
||||||
|
title: string
|
||||||
|
channel?: string
|
||||||
|
addedAt: number
|
||||||
|
lastIndexedAt?: number
|
||||||
|
}
|
||||||
|
interface MediaItem {
|
||||||
|
id: string // yt-dlp video id — the dedup key
|
||||||
|
sourceId: string
|
||||||
|
title: string
|
||||||
|
playlistTitle?: string // for the folder path; 'Uploads' fallback
|
||||||
|
playlistIndex?: number // 1-based, for NNN numbering
|
||||||
|
durationLabel?: string
|
||||||
|
downloaded: boolean
|
||||||
|
downloadedAt?: number
|
||||||
|
filePath?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [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.
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
## Phase G — Folder organization & output-path templates ✅ COMPLETE
|
||||||
|
|
||||||
|
Turn a `MediaItem` into a real per-item download that lands in `Channel / Playlist / Title`.
|
||||||
|
|
||||||
|
- [x] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through
|
||||||
|
`buildCommand` (`src/main/download.ts`). Add an `outputSubdir` (or reuse `outputDir` with a
|
||||||
|
joined subpath) so each `MediaItem` downloads into
|
||||||
|
`<root>/<Channel>/<Playlist>/<NNN> - <Title>.ext`. The `NNN` index comes from
|
||||||
|
`MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is
|
||||||
|
empty under the per-item `--no-playlist` path that AeroFetch keeps using.
|
||||||
|
- [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.
|
||||||
|
- [ ] **Output-path setting.** A new Settings → Downloads field for the folder layout, defaulting
|
||||||
|
to `Channel / Playlist / Title`, with the raw yt-dlp `-o` template exposed for power users
|
||||||
|
(parallels how Phase C exposed raw extra args). Keeps the existing flat
|
||||||
|
`filenameTemplate` as the default for non-collection downloads.
|
||||||
|
- [ ] **"Media Profile" = named download preset (optional sugar).** Pinchflat bundles
|
||||||
|
quality + subs + output template into a reusable Profile. AeroFetch already persists
|
||||||
|
`DownloadOptions` and named `CommandTemplate`s; a Profile is just a named bundle of
|
||||||
|
`{ DownloadOptions, outputTemplate, extraArgs }` a Source can point at. Worth it once
|
||||||
|
multiple sources want different rules; skip for v1 (use the global defaults).
|
||||||
|
|
||||||
|
## Phase H — Library view (the media-manager UI) ✅ COMPLETE
|
||||||
|
|
||||||
|
The new surface where channels live. Everything below feeds the **existing** download queue.
|
||||||
|
|
||||||
|
- [x] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside
|
||||||
|
Downloads / History / Settings. Lists added Sources.
|
||||||
|
- [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.
|
||||||
|
- [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.
|
||||||
|
- [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`).
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
- [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.
|
||||||
|
- [ ] **Retention / quality-upgrade (stretch, from Pinchflat).** Optional re-download to upgrade
|
||||||
|
quality/metadata, and optional pruning of old items. Defer — niche for a desktop app.
|
||||||
|
|
||||||
|
## Phase J — Subscriptions & scheduled auto-download ✅ COMPLETE (OS wiring needs smoke test)
|
||||||
|
|
||||||
|
Pinchflat's headline feature: a Source you *watch*, that downloads new uploads on its own.
|
||||||
|
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).
|
||||||
|
|
||||||
|
- [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").
|
||||||
|
- [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
|
||||||
|
`Notification` path.
|
||||||
|
|
||||||
|
## Phase K — Media-server output (optional stretch) ✅ COMPLETE
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What we reuse vs. build
|
||||||
|
|
||||||
|
**Reuse unchanged:** the download queue + concurrency cap + cancel/retry (`store/downloads.ts`),
|
||||||
|
history + error log, cookies + proxy + rate-limit + aria2c (`AccessOptions`), all
|
||||||
|
post-processing `DownloadOptions`, `--download-archive`, native notifications, the
|
||||||
|
single-instance lock, and the JSON-store pattern.
|
||||||
|
|
||||||
|
**Build new:** channel-depth probing (Phase F), the `sources.json` index + `Source`/`MediaItem`
|
||||||
|
model (F), per-item folder output paths + sanitizer (G), the Library view + `useSources` store
|
||||||
|
(H), incremental diff-sync (I), and RSS + scheduled sync (J).
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
F → G are the foundation and unlock a usable "download whole channel into folders" the moment
|
||||||
|
they land (drive it from a temporary button before the full Library UI exists). H makes it a
|
||||||
|
real product surface. I and J turn it into a true media manager. K is polish. Phases F–H are
|
||||||
|
the high-value core; I–K are incremental and each shippable on its own.
|
||||||
+214
-5
@@ -9,6 +9,14 @@ Sources: [Seal GitHub](https://github.com/JunkFood02/Seal) ·
|
|||||||
[Seal on F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ·
|
[Seal on F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ·
|
||||||
[It's FOSS overview](https://itsfoss.com/news/seal/).
|
[It's FOSS overview](https://itsfoss.com/news/seal/).
|
||||||
|
|
||||||
|
> **See also:** [ROADMAP-PINCHFLAT.md](ROADMAP-PINCHFLAT.md) — a parallel track that extends
|
||||||
|
> 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Already at parity ✅
|
## Already at parity ✅
|
||||||
@@ -158,7 +166,7 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes
|
|||||||
non-zero yt-dlp exit); **Settings → Diagnostics** lists recent entries with
|
non-zero yt-dlp exit); **Settings → Diagnostics** lists recent entries with
|
||||||
**Copy full report** and **Clear log**.
|
**Copy full report** and **Clear log**.
|
||||||
|
|
||||||
## Phase E — Theming & platform polish
|
## Phase E — Theming & platform polish ✅ COMPLETE
|
||||||
|
|
||||||
Some items are Android-specific in Seal and adapted to Windows here.
|
Some items are Android-specific in Seal and adapted to Windows here.
|
||||||
|
|
||||||
@@ -179,10 +187,6 @@ Some items are Android-specific in Seal and adapted to Windows here.
|
|||||||
auto. Verified in the UI preview (typecheck + `npm run test` clean): default Toffee,
|
auto. Verified in the UI preview (typecheck + `npm run test` clean): default Toffee,
|
||||||
switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar
|
switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar
|
||||||
toggle's break-out-of-system behavior all checked visually.
|
toggle's break-out-of-system behavior all checked visually.
|
||||||
- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional — deferred,
|
|
||||||
since shipping ~40 real translations isn't something to fake; doing this properly means
|
|
||||||
wiring up an i18n library + extracting every string now, with translation content
|
|
||||||
arriving incrementally from contributors.
|
|
||||||
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
|
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
|
||||||
(non-MSIX) app can't register as an actual Share Target:
|
(non-MSIX) app can't register as an actual Share Target:
|
||||||
- **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at
|
- **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at
|
||||||
@@ -229,3 +233,208 @@ Some items are Android-specific in Seal and adapted to Windows here.
|
|||||||
+ Explorer "Send to" integration (a true Share Target needs an MSIX package, out of scope
|
+ Explorer "Send to" integration (a true Share Target needs an MSIX package, out of scope
|
||||||
for this app's NSIS/portable distribution).
|
for this app's NSIS/portable distribution).
|
||||||
- F-Droid distribution → N/A (AeroFetch ships NSIS + portable).
|
- F-Droid distribution → N/A (AeroFetch ships NSIS + portable).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Post-parity — editing, native polish & reliability
|
||||||
|
|
||||||
|
With both parity tracks complete — Seal (Phases A–E above) and the
|
||||||
|
[Pinchflat media-manager roadmap](ROADMAP-PINCHFLAT.md) (Phases F–K ✅) — this final track
|
||||||
|
collects what's left, from two sources:
|
||||||
|
|
||||||
|
1. A **feature study of [YTDLnis](https://github.com/deniscerri/ytdlnis)** (the other major
|
||||||
|
Android yt-dlp frontend), keeping only the features AeroFetch doesn't already have.
|
||||||
|
2. A **Windows-native + daily-use UX gap analysis** of AeroFetch itself (things neither Seal
|
||||||
|
nor Pinchflat cover, but that a desktop app should do).
|
||||||
|
|
||||||
|
Nothing here is built yet — every box is unchecked. Phases continue the existing lettering
|
||||||
|
(A–E Seal, F–K Pinchflat) from **L onward**, and each item carries the yt-dlp flag and the
|
||||||
|
AeroFetch file it touches so it can be driven straight from the existing `buildArgs` /
|
||||||
|
`DownloadOptions` / queue plumbing rather than new infrastructure.
|
||||||
|
|
||||||
|
Sources: [YTDLnis GitHub](https://github.com/deniscerri/ytdlnis) ·
|
||||||
|
[YTDLnis docs](https://ytdlnis.org/) · [YTDLnis changelogs](https://ytdlnis.org/changelogs/) ·
|
||||||
|
[F-Droid listing](https://f-droid.org/packages/com.deniscerri.ytdl/).
|
||||||
|
|
||||||
|
### Already at parity with YTDLnis (do **not** rebuild)
|
||||||
|
|
||||||
|
Format/quality/container picking · SponsorBlock (remove/mark + embed-as-chapters) · embed
|
||||||
|
subtitles/metadata/chapters · cookies + login · custom command templates · backup/restore ·
|
||||||
|
**Observe Sources** (= our Phase J watched sources + RSS) · incognito mode · history with
|
||||||
|
filter + bulk re-download · filename templating · self-managing yt-dlp + ffmpeg binaries.
|
||||||
|
AeroFetch is even with, or ahead of, YTDLnis on all of these.
|
||||||
|
|
||||||
|
## Phase L — Media editing (YTDLnis headline features)
|
||||||
|
|
||||||
|
YTDLnis's signature differentiator. The keyframe plumbing already exists in AeroFetch
|
||||||
|
(`--force-keyframes-at-cuts` is emitted today for SponsorBlock-remove in
|
||||||
|
`src/main/buildArgs.ts`), so this is cheaper than it looks.
|
||||||
|
|
||||||
|
- [x] **Trim / cut downloads.** Keep only chosen time ranges (multiple allowed). →
|
||||||
|
`parseTrimSections` (`src/main/buildArgs.ts`) normalises raw user text into yt-dlp
|
||||||
|
`--download-sections "*START-END"` specs (+ `--force-keyframes-at-cuts`, deduped against
|
||||||
|
SponsorBlock-remove). Threaded as `StartDownloadOptions.trim` through
|
||||||
|
`store/downloads.ts`, surfaced as a collapsible **Trim** panel in `DownloadBar.tsx`
|
||||||
|
(single downloads only). *Implemented + unit-tested (`buildArgs.test.ts` — 7 new tests;
|
||||||
|
typecheck + test green). Live smoke test pending, plus two follow-ups: timestamp-range
|
||||||
|
only so far (chapter-based cuts via the probe `chapters[]` array, and a scrubber/preview
|
||||||
|
player, still TODO).*
|
||||||
|
- [x] **Split by chapters into separate files.** → `--split-chapters` (already flagged as a
|
||||||
|
TODO in Phase A above). One toggle on `DownloadOptions` +
|
||||||
|
`src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. yt-dlp
|
||||||
|
keeps the full file and writes per-chapter files via its default `chapter:` template.
|
||||||
|
*Implemented + unit-tested (`buildArgs.test.ts`, typecheck + test green); live smoke test
|
||||||
|
still pending.*
|
||||||
|
- [ ] **Metadata editing before download.** Change title / author / artist pre-download
|
||||||
|
(YTDLnis: "modify metadata such as title and author"). → `--parse-metadata` /
|
||||||
|
`--replace-in-metadata`; fits the audio path. Good for building a clean music library
|
||||||
|
where the source title is messy. *Held: the exact set-a-literal recipe is fragile
|
||||||
|
(`--parse-metadata FROM:TO` splits on a colon that titles often contain;
|
||||||
|
`--replace-in-metadata FIELD REGEX REPLACE` has empty-field + regex-replacement edge
|
||||||
|
cases) — confirm against a live yt-dlp run before wiring it, rather than guessing the
|
||||||
|
flag.*
|
||||||
|
|
||||||
|
## Phase M — Queue & daily-use UX ✅ COMPLETE
|
||||||
|
|
||||||
|
Neither Seal nor Pinchflat covers these; they're what makes the queue pleasant to live in.
|
||||||
|
Today `src/renderer/src/store/downloads.ts` has cancel + retry only.
|
||||||
|
|
||||||
|
- [x] **Pause / resume individual downloads.** → yt-dlp resumes `.part` files via `--continue`
|
||||||
|
(already the default). *Done: `pauseDownload()` (`src/main/download.ts`) reuses the
|
||||||
|
`taskkill /T /F` tree-kill via a new `killTree` helper but flags the record `paused` so
|
||||||
|
the close/error handlers stay silent (no error event/log/notification); new
|
||||||
|
`download:pause` IPC + preload bridge. Renderer `pause`/`resume` (`store/downloads.ts`) +
|
||||||
|
a `paused` status: pause frees a concurrency slot, resume re-queues WITHOUT resetting
|
||||||
|
progress so the relaunch continues the partial. Pause/Resume buttons + a frozen progress
|
||||||
|
row in `QueueItem.tsx`; paused items survive "Clear finished". typecheck + test green;
|
||||||
|
live resume-continues-the-.part still wants a smoke test.*
|
||||||
|
- [x] **Reorder / prioritize the queue.** *Done as a **"Download next"** action rather than
|
||||||
|
drag: since the queue renders newest-first while `pump()` promotes oldest-first, a
|
||||||
|
reposition primitive is clearer than drag. `prioritize(id)` (`store/downloads.ts`) moves a
|
||||||
|
queued item to the front of the promotion order; an up-arrow button on queued rows
|
||||||
|
(`QueueItem.tsx`).*
|
||||||
|
- [x] **Aggregate progress + total ETA + combined speed.** A header strip on the Downloads
|
||||||
|
view summing bytes/speed across active items. Feeds straight into Phase O's taskbar
|
||||||
|
progress bar. *Done: pure `summarizeQueue` (`store/queueStats.ts`) → a strip in
|
||||||
|
`DownloadsView.tsx` (overall %, combined speed, longest-ETA "time left"); unit-tested
|
||||||
|
(`test/queueStats.test.ts`). A true remaining-bytes combined ETA is still out of reach —
|
||||||
|
we don't track per-item sizes — so "time left" is the longest single ETA.*
|
||||||
|
- [x] **Drag-and-drop a link or `.url` file onto the window.** *Done: the download card
|
||||||
|
(`DownloadBar.tsx`) handles `onDragOver`/`onDrop` with a dashed-outline drag highlight,
|
||||||
|
accepting `text/uri-list`/`text/plain` (`firstUrl`) and dropped Windows `.url`
|
||||||
|
Internet-Shortcut files (`parseUrlFile`, read via `File.text()`); both http(s)-validated by
|
||||||
|
the existing `looksLikeUrl`. A dropped link fills the URL field.*
|
||||||
|
- [x] **Retry all failed.** One action that re-enqueues every failed item. *Done:
|
||||||
|
`retryAll()` in `store/downloads.ts` re-queues all `error` items + a "Retry all failed (N)"
|
||||||
|
button in `DownloadsView.tsx`. (Re-queues live queue items rather than reading
|
||||||
|
`errorlog.json` — same effect for the on-screen failures.)*
|
||||||
|
- [x] **Duplicate detection.** Warn ("you already have this") before enqueuing a URL already in
|
||||||
|
the active queue. *Done: pure `sameVideo()` (`store/queueStats.ts`, YouTube-id aware +
|
||||||
|
normalised-URL fallback) drives a "Already in your queue — Download anyway?" banner in
|
||||||
|
`DownloadBar.tsx`; unit-tested. Checks the live queue (which still holds recently-completed
|
||||||
|
items); cross-checking `history.json` for older downloads is a possible extension.*
|
||||||
|
- [x] **Per-download scheduling + "save for later."** *Done: a `saved` status + optional
|
||||||
|
`scheduledFor` (`store/downloads.ts`); `pump()` ignores `saved` (the time gate), and a 15s
|
||||||
|
ticker promotes due scheduled items to `queued`. UI: a **Schedule** panel with a native
|
||||||
|
`datetime-local` input in `DownloadBar.tsx` (future time parks the new download as
|
||||||
|
scheduled), plus per-row **Save for later** / **Add to queue** actions in `QueueItem.tsx`;
|
||||||
|
saved items survive "Clear finished". Distinct from the Phase J `--sync` schedule (which is
|
||||||
|
for *sources*). **Limitation: the queue isn't persisted, so a schedule only fires while
|
||||||
|
AeroFetch is running** — noted in the UI hint and the code.*
|
||||||
|
|
||||||
|
## Phase N — Power-user surface ✅ COMPLETE
|
||||||
|
|
||||||
|
YTDLnis leans hard into this; AeroFetch had the building blocks (command preview, templates,
|
||||||
|
per-item `options`) but not the surfaces. typecheck + test + `npm run build` clean.
|
||||||
|
|
||||||
|
- [x] **Built-in terminal mode.** *Done: `src/main/terminal.ts` runs the bundled yt-dlp with
|
||||||
|
raw user args (binary fixed; gated on `customCommandEnabled`, enforced in main), streaming
|
||||||
|
stdout/stderr line-by-line over a new `terminal:output` channel (`terminal:run`/`cancel`
|
||||||
|
IPC + preload). New `TerminalView.tsx` (args box, Run/Stop/Clear, live log, Ctrl+Enter)
|
||||||
|
and a Terminal sidebar tab.*
|
||||||
|
- [x] **Per-playlist-item editing.** *Done: the playlist panel in `DownloadBar.tsx` gained a
|
||||||
|
per-row video/audio toggle + **All video** / **All audio** batch buttons; `addPlaylist`
|
||||||
|
enqueues via `addMany` with each entry's own kind (quality falls back to that kind's
|
||||||
|
default). Per-item exact-format selection (needs probing each entry) left as a further
|
||||||
|
extension.*
|
||||||
|
- [x] **Weighted format sorting ("format aspect importance").** *Done: a `formatSort` field on
|
||||||
|
`DownloadOptions` (raw yt-dlp `-S` string); when set it emits `-S <value>`, overriding the
|
||||||
|
codec tiebreaker (`buildArgs.ts`); an "advanced" input in `DownloadOptionsForm`. Unit-tested.*
|
||||||
|
- [x] **URL-regex template auto-matching.** *Done: `CommandTemplate.urlPattern` (regex);
|
||||||
|
`selectExtraArgs` auto-applies a matching template ahead of the global default
|
||||||
|
(`matchesUrl`, bad patterns never match), URL threaded via `resolveExtraArgs`. urlPattern
|
||||||
|
field in `TemplateManager` + persisted in `templates.ts`. Unit-tested.*
|
||||||
|
- [x] **Command palette + keyboard shortcuts.** *Done: a portal-free `CommandPalette.tsx`
|
||||||
|
(filter, ↑/↓, Enter, Esc) opened with Ctrl/Cmd+K from `App.tsx`; actions = navigate tabs,
|
||||||
|
new download (focuses the `aerofetch-url` input), toggle theme.*
|
||||||
|
|
||||||
|
## Phase O — Windows-native integration & discoverability ✅ COMPLETE
|
||||||
|
|
||||||
|
All in the main process. typecheck + test + `npm run build` clean.
|
||||||
|
|
||||||
|
- [x] **Taskbar progress bar.** *Done: renderer pushes the `summarizeQueue` aggregate over a new
|
||||||
|
`taskbar:progress` channel (App-level store subscription, no re-render);
|
||||||
|
`mainWindow.setProgressBar` with `mode: 'normal' | 'error'` (red when any failed) and `-1`
|
||||||
|
to clear when idle.*
|
||||||
|
- [x] **System tray + minimize-to-tray.** *Done: `src/main/tray.ts` builds a `Tray` (icon resolved
|
||||||
|
via `getAppIconPath()`; `build/icon.ico` added to electron-builder `extraResources` for
|
||||||
|
prod) with Show / Quit. A new `Settings.minimizeToTray` (Settings → Appearance) makes the
|
||||||
|
window's `close` hide to tray instead of quitting; `before-quit`/tray-Quit set an
|
||||||
|
`isQuitting` flag so real exits still work.*
|
||||||
|
- [x] **Jump list.** *Done: `app.setUserTasks` adds an "Open AeroFetch" task (focuses the
|
||||||
|
single instance). **Thumbnail toolbar buttons deferred** — they need per-button `NativeImage`
|
||||||
|
assets this repo doesn't have tooling to generate here.*
|
||||||
|
- [ ] **Taskbar overlay badge** (`win.setOverlayIcon`) showing active-download count / error state.
|
||||||
|
*Deferred: a numeric/status badge needs a drawn `NativeImage` (no canvas in main); the
|
||||||
|
taskbar progress bar's `error` mode already signals failures.*
|
||||||
|
- [x] **Settings discoverability.** *Done: a search box atop `SettingsView` filters the ~11 cards
|
||||||
|
live by matching each card's text (DOM `display` toggle keyed off the root's children — no
|
||||||
|
per-card refactor), with a "no settings match" hint.*
|
||||||
|
|
||||||
|
## Phase P — Reliability, longevity & trust ✅ (code shipped; cert + manual passes still required)
|
||||||
|
|
||||||
|
The least glamorous, highest-leverage track. The buildable code is done (typecheck + test +
|
||||||
|
build clean); the parts that inherently need a human or a paid cert are delivered as artifacts.
|
||||||
|
|
||||||
|
- [x] **PO Token / YouTube reliability plumbing.** *Done: `Settings.youtubePlayerClient` +
|
||||||
|
`youtubePoToken` → one `--extractor-args "youtube:player_client=…;po_token=…"` group
|
||||||
|
(`accessArgs` in `buildArgs.ts`, unit-tested), with "advanced" fields in Settings → Network.
|
||||||
|
Lets a power user switch extraction client (`web_safari`/`tv`/`mweb`) or paste a token.*
|
||||||
|
**Deferred: automatic WebView token minting** (the YTDLnis headline) — the hard part;
|
||||||
|
this is the argv plumbing it would feed. Cookies remain the easier bot-check fix.
|
||||||
|
- [x] **Verify the shipped-but-untested OS wiring.** *Can't be executed here (no real install).
|
||||||
|
Delivered as a release-gate checklist — [docs/SMOKE-TEST.md](docs/SMOKE-TEST.md) —
|
||||||
|
enumerating every untested path (cookies window, `aerofetch://` + Send-to, scheduled
|
||||||
|
`--sync` + RSS, aria2c, proxy, plus the new tray/taskbar/pause-resume/terminal) with exact
|
||||||
|
steps + expected results. **Still needs a human to run it.**
|
||||||
|
- [x] **Code signing.** *Build is signing-ready: electron-builder picks the cert up from
|
||||||
|
`CSC_LINK` / `CSC_KEY_PASSWORD` with no yml change (unset = unsigned, as today). Documented
|
||||||
|
in [docs/SIGNING.md](docs/SIGNING.md) (OV vs EV, local + CI, HSM/EV hook, signature
|
||||||
|
verification). **Actual signing needs a purchased certificate.***
|
||||||
|
- [ ] **i18n / language setting.** Still deferred — deliberately not faked. Shipping a language
|
||||||
|
picker that doesn't change anything would be misleading; doing it properly means wiring an
|
||||||
|
i18n library + extracting every string, with translations arriving incrementally. Tracked,
|
||||||
|
not started.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-parity — what we reuse vs. build
|
||||||
|
|
||||||
|
**Reuse unchanged:** `buildArgs`/`buildCommand` flag emission, `DownloadOptions` +
|
||||||
|
`DownloadOptionsForm`, the renderer queue scheduler + concurrency cap + per-item `options`,
|
||||||
|
the persisted cookies `BrowserWindow`, the probe's `chapters[]`/format data, `errorlog.ts`,
|
||||||
|
`CommandTemplate`s, `extractIncomingUrl()`, and the self-managing binary updater.
|
||||||
|
|
||||||
|
**Build new:** the trim/split UI + `--download-sections`/`--split-chapters` wiring (L), the
|
||||||
|
pause/reorder/schedule queue states (M), the terminal view + per-item playlist editor (N), the
|
||||||
|
main-process taskbar/tray/jumplist integration (O), and the PO-token provider (P).
|
||||||
|
|
||||||
|
## Post-parity — suggested order
|
||||||
|
|
||||||
|
**L is the high-value headline** — trim + split-chapters share one UI surface and the keyframe
|
||||||
|
plumbing already exists, so they land cheaply and give AeroFetch YTDLnis's marquee capability.
|
||||||
|
Then **P's PO-token + verify-wiring** as a reliability block (keeps YouTube working and de-risks
|
||||||
|
shipped features). **M** and **O** are the daily-use polish and pair well (aggregate progress →
|
||||||
|
taskbar bar). **N** is the power-user surface, each item independently shippable. Each phase is
|
||||||
|
self-contained and rebuild-gated (`npm run typecheck` + `npm run test` + `npm run build`) the
|
||||||
|
same way Phases A–K were.
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# AeroFetch — Windows code signing
|
||||||
|
|
||||||
|
Unsigned builds run, but Windows SmartScreen shows a blue **"Windows protected your PC"**
|
||||||
|
prompt the first time each user runs the installer (they must click *More info → Run anyway*).
|
||||||
|
This is the single biggest first-run friction when sharing AeroFetch. Code signing removes it
|
||||||
|
(EV certificates remove it immediately; OV certificates build reputation over time/installs).
|
||||||
|
|
||||||
|
**This step needs a certificate that can't live in the repo**, so it isn't wired on by default.
|
||||||
|
The build is otherwise ready for it — electron-builder picks the cert up from environment
|
||||||
|
variables with no `electron-builder.yml` changes required.
|
||||||
|
|
||||||
|
## What you need
|
||||||
|
|
||||||
|
- An **Authenticode code-signing certificate** for Windows:
|
||||||
|
- **OV (Organization Validation)** — cheaper; SmartScreen reputation accrues as more users
|
||||||
|
install, so early downloads still see the prompt.
|
||||||
|
- **EV (Extended Validation)** — pricier, usually on a hardware token / cloud HSM; clears
|
||||||
|
SmartScreen immediately. Token-based EV can't be fed as a plain `.pfx` (see "HSM/EV" below).
|
||||||
|
- The cert as a password-protected `.pfx`/`.p12` (for OV), or an HSM/cloud-signing setup (EV).
|
||||||
|
|
||||||
|
## Signing an OV `.pfx` locally
|
||||||
|
|
||||||
|
electron-builder reads these env vars automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PowerShell
|
||||||
|
$env:CSC_LINK = "C:\path\to\codesign.pfx" # or a base64 string of the .pfx
|
||||||
|
$env:CSC_KEY_PASSWORD = "<pfx password>"
|
||||||
|
npm run build:win
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# bash / CI
|
||||||
|
export CSC_LINK="$(base64 -w0 codesign.pfx)" # base64 is convenient for CI secrets
|
||||||
|
export CSC_KEY_PASSWORD="$PFX_PASSWORD"
|
||||||
|
npm run build:win
|
||||||
|
```
|
||||||
|
|
||||||
|
electron-builder then signs `AeroFetch Setup <ver>.exe`, the portable `.exe`, and the app
|
||||||
|
binary. No changes to `electron-builder.yml` are needed; if `CSC_LINK` is unset, the build
|
||||||
|
proceeds **unsigned** (current behavior).
|
||||||
|
|
||||||
|
## CI (GitHub Actions sketch)
|
||||||
|
|
||||||
|
Store the cert + password as encrypted secrets and export them before the build:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Build (signed)
|
||||||
|
env:
|
||||||
|
CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} # base64 of the .pfx
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
|
||||||
|
run: npm run build:win
|
||||||
|
```
|
||||||
|
|
||||||
|
## HSM / EV certificates
|
||||||
|
|
||||||
|
EV certs (and many newer OV certs) ship on a hardware token or cloud HSM and can't be exported
|
||||||
|
to a `.pfx`. Sign via a custom signing hook instead — set `win.sign` in `electron-builder.yml`
|
||||||
|
to a script that invokes your provider's tool (Azure Trusted Signing, DigiCert KeyLocker,
|
||||||
|
SSL.com eSigner, or `signtool` with the token). Each provider documents the exact `signtool`
|
||||||
|
invocation; electron-builder calls your hook once per artifact with the file path.
|
||||||
|
|
||||||
|
## Verifying a signature
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-AuthenticodeSignature ".\dist\AeroFetch Setup <ver>.exe" | Format-List
|
||||||
|
# Status should be 'Valid'; SignerCertificate should be your cert.
|
||||||
|
signtool verify /pa /v ".\dist\AeroFetch Setup <ver>.exe"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The LGPL ffmpeg + yt-dlp binaries shipped in `resources/bin` are third-party; signing the
|
||||||
|
AeroFetch artifacts doesn't (and needn't) re-sign them.
|
||||||
|
- Timestamp the signature (electron-builder does by default) so it stays valid after the cert
|
||||||
|
expires.
|
||||||
|
- Signing does **not** replace the smoke test — see [SMOKE-TEST.md](SMOKE-TEST.md).
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# AeroFetch — manual smoke-test checklist
|
||||||
|
|
||||||
|
Some features can only be exercised against a **real install** (or a real yt-dlp run) —
|
||||||
|
they're main-process / Electron / OS-integration code that the Vite browser preview and the
|
||||||
|
unit suite can't reach. They're implemented and typecheck/test/build clean, but the roadmaps
|
||||||
|
flag them as needing a hands-on pass. Run this checklist after building
|
||||||
|
(`npm run build:win`) and installing the NSIS artifact (or launching the portable `.exe`).
|
||||||
|
|
||||||
|
Mark each ✅/❌ and note anything surprising.
|
||||||
|
|
||||||
|
## Downloads & post-processing (Phases A, L, M)
|
||||||
|
|
||||||
|
- [ ] **Basic download** — paste a URL, Fetch, pick a format, Download → file lands in
|
||||||
|
Documents\Video (or your folder), progress + speed update, "Completed".
|
||||||
|
- [ ] **Trim / cut** (Phase L) — set a section like `0:10-0:20`, download → output is ~10s and
|
||||||
|
starts/ends near the cut points (`--download-sections` + `--force-keyframes-at-cuts`).
|
||||||
|
- [ ] **Split by chapters** (Phase L) — on a video with chapters, enable it → per-chapter files
|
||||||
|
appear alongside the full file.
|
||||||
|
- [ ] **Pause / resume** (Phase M) — start a large download, Pause (process stops, `.part`
|
||||||
|
stays), Resume → it **continues** from where it stopped, not from 0% (yt-dlp `--continue`).
|
||||||
|
- [ ] **Scheduling** (Phase M) — schedule a download a couple of minutes out → it sits "Saved /
|
||||||
|
Scheduled", then auto-starts at the time (only while the app is running).
|
||||||
|
|
||||||
|
## Access & networking (Phase B, P)
|
||||||
|
|
||||||
|
- [ ] **Cookies — sign-in window** — Settings → Cookies → "Sign-in window", log into a site,
|
||||||
|
close it → "cookies saved" with a timestamp; a members-only/age-gated video then downloads.
|
||||||
|
- [ ] **Cookies — from browser** — pick an installed browser → a gated video downloads.
|
||||||
|
- [ ] **Proxy** — set a working proxy → downloads route through it (verify via the proxy logs).
|
||||||
|
- [ ] **aria2c** — drop `aria2c.exe` into `resources/bin`, enable it → multi-connection download
|
||||||
|
works; remove it → silently falls back to yt-dlp's downloader.
|
||||||
|
- [ ] **YouTube client / PO token** (Phase P) — set `youtubePlayerClient` (e.g. `web_safari`) →
|
||||||
|
the run includes `--extractor-args youtube:player_client=web_safari` (check Terminal/Preview).
|
||||||
|
|
||||||
|
## OS integration (Phase E, J, O)
|
||||||
|
|
||||||
|
- [ ] **`aerofetch://` protocol** — from a browser, open `aerofetch://download?url=<encoded>` →
|
||||||
|
AeroFetch focuses and the URL appears in the "Link received" banner.
|
||||||
|
- [ ] **Explorer "Send to AeroFetch"** — drag a tab to the desktop to make a `.url`, right-click →
|
||||||
|
Send to → AeroFetch → the link is received.
|
||||||
|
- [ ] **Scheduled sync** (Phase J) — Settings → enable the daily sync → a Windows Task Scheduler
|
||||||
|
task exists; run it (or `AeroFetch.exe --sync`) → watched sources index and new items enqueue.
|
||||||
|
- [ ] **RSS fast-check** (Phase J) — "Check for new" on a watched channel → newly-posted videos
|
||||||
|
show as new without a full re-index.
|
||||||
|
- [ ] **System tray** (Phase O) — enable "Keep running in the tray", close the window → it hides
|
||||||
|
to the tray; tray click reopens; tray → Quit actually exits.
|
||||||
|
- [ ] **Taskbar progress** (Phase O) — during downloads the taskbar icon shows a progress bar;
|
||||||
|
it turns red if one fails and clears when the queue goes idle.
|
||||||
|
- [ ] **Jump list** (Phase O) — right-click the taskbar icon → "Open AeroFetch" focuses the app.
|
||||||
|
|
||||||
|
## Terminal (Phase N)
|
||||||
|
|
||||||
|
- [ ] Enable "Run custom commands", open Terminal, run `--version` → the version prints; run
|
||||||
|
`-F <url>` → the format table streams in; Stop cancels a long run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If anything here fails, capture the Settings → Diagnostics "Copy full report" output and the
|
||||||
|
exact steps. These items have no automated coverage, so this checklist is the release gate for
|
||||||
|
them. See also [SIGNING.md](SIGNING.md) for the code-signing step that removes the SmartScreen
|
||||||
|
prompt for end users.
|
||||||
@@ -30,6 +30,10 @@ extraResources:
|
|||||||
to: bin
|
to: bin
|
||||||
filter:
|
filter:
|
||||||
- '**/*'
|
- '**/*'
|
||||||
|
# The app icon, copied so the runtime (system tray) can load it at
|
||||||
|
# <resources>/icon.ico — build/ itself isn't bundled into the app.
|
||||||
|
- from: build/icon.ico
|
||||||
|
to: icon.ico
|
||||||
|
|
||||||
win:
|
win:
|
||||||
# Two artifacts:
|
# Two artifacts:
|
||||||
|
|||||||
Generated
+30
-2
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "aerofetch",
|
"name": "aerofetch",
|
||||||
"version": "0.1.0",
|
"version": "0.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aerofetch",
|
"name": "aerofetch",
|
||||||
"version": "0.1.0",
|
"version": "0.4.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@fluentui/react-components": "^9.74.1",
|
"@fluentui/react-components": "^9.74.1",
|
||||||
"@fluentui/react-icons": "^2.0.330",
|
"@fluentui/react-icons": "^2.0.330",
|
||||||
|
"@tanstack/react-virtual": "^3.14.3",
|
||||||
"electron-store": "^11.0.2",
|
"electron-store": "^11.0.2",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
@@ -3373,6 +3374,33 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.14.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz",
|
||||||
|
"integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.17.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
|
||||||
|
"integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aerofetch",
|
"name": "aerofetch",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"description": "A yt-dlp frontend for Windows",
|
"description": "A yt-dlp frontend for Windows",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "AeroFetch",
|
"author": "AeroFetch",
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@fluentui/react-components": "^9.74.1",
|
"@fluentui/react-components": "^9.74.1",
|
||||||
"@fluentui/react-icons": "^2.0.330",
|
"@fluentui/react-icons": "^2.0.330",
|
||||||
|
"@tanstack/react-virtual": "^3.14.3",
|
||||||
"electron-store": "^11.0.2",
|
"electron-store": "^11.0.2",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
|||||||
+6
-1
@@ -2,6 +2,7 @@ import { dialog, type BrowserWindow } from 'electron'
|
|||||||
import { readFileSync, writeFileSync } from 'fs'
|
import { readFileSync, writeFileSync } from 'fs'
|
||||||
import { getSettings, setSettings } from './settings'
|
import { getSettings, setSettings } from './settings'
|
||||||
import { listTemplates, replaceTemplates } from './templates'
|
import { listTemplates, replaceTemplates } from './templates'
|
||||||
|
import { isTemplateLike } from './validation'
|
||||||
import type {
|
import type {
|
||||||
BackupExportResult,
|
BackupExportResult,
|
||||||
BackupImportResult,
|
BackupImportResult,
|
||||||
@@ -55,8 +56,12 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
|
|||||||
file.settings && typeof file.settings === 'object'
|
file.settings && typeof file.settings === 'object'
|
||||||
? (file.settings as Partial<Settings>)
|
? (file.settings as Partial<Settings>)
|
||||||
: undefined
|
: undefined
|
||||||
|
// Drop non-object / id-less entries up front (audit T3) so both the consent
|
||||||
|
// check below and replaceTemplates see only well-shaped rows. Without this a
|
||||||
|
// backup with a null/garbage templates entry throws in sanitize() instead of
|
||||||
|
// degrading gracefully as this function promises.
|
||||||
const incomingTemplates = Array.isArray(file.templates)
|
const incomingTemplates = Array.isArray(file.templates)
|
||||||
? (file.templates as CommandTemplate[])
|
? (file.templates as CommandTemplate[]).filter(isTemplateLike)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
// A custom-command template's `args` are extra yt-dlp flags that get spawned on
|
// A custom-command template's `args` are extra yt-dlp flags that get spawned on
|
||||||
|
|||||||
+45
-1
@@ -13,10 +13,42 @@ export function getBinDir(): string {
|
|||||||
return is.dev ? join(app.getAppPath(), 'resources', 'bin') : join(process.resourcesPath, 'bin')
|
return is.dev ? join(app.getAppPath(), 'resources', 'bin') : join(process.resourcesPath, 'bin')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getYtdlpPath(): string {
|
/**
|
||||||
|
* The app icon (.ico), for the system tray. In dev it's the repo's build/icon.ico;
|
||||||
|
* in a packaged build, electron-builder's extraResources copies it to
|
||||||
|
* <resources>/icon.ico (see electron-builder.yml).
|
||||||
|
*/
|
||||||
|
export function getAppIconPath(): string {
|
||||||
|
return is.dev
|
||||||
|
? join(app.getAppPath(), 'build', 'icon.ico')
|
||||||
|
: join(process.resourcesPath, 'icon.ico')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
|
||||||
|
* from the read-only bundled seed in resources/bin. The managed copy is what
|
||||||
|
* actually gets spawned and self-updated (`--update-to`), so an app reinstall or
|
||||||
|
* portable re-extraction — which only ever replace the bundled seed — can never
|
||||||
|
* roll a freshly-updated yt-dlp back to a stale version. See ensureManagedYtdlp
|
||||||
|
* in ytdlp.ts, which seeds this from getBundledYtdlpPath() on first run.
|
||||||
|
*
|
||||||
|
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
|
||||||
|
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
|
||||||
|
*/
|
||||||
|
export function getManagedBinDir(): string {
|
||||||
|
return join(app.getPath('userData'), 'bin')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bundled, read-only yt-dlp.exe seeded into the managed copy when missing. */
|
||||||
|
export function getBundledYtdlpPath(): string {
|
||||||
return join(getBinDir(), 'yt-dlp.exe')
|
return join(getBinDir(), 'yt-dlp.exe')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The managed (spawned + auto-updated) yt-dlp.exe under userData. */
|
||||||
|
export function getYtdlpPath(): string {
|
||||||
|
return join(getManagedBinDir(), 'yt-dlp.exe')
|
||||||
|
}
|
||||||
|
|
||||||
export function getFfmpegPath(): string {
|
export function getFfmpegPath(): string {
|
||||||
return join(getBinDir(), 'ffmpeg.exe')
|
return join(getBinDir(), 'ffmpeg.exe')
|
||||||
}
|
}
|
||||||
@@ -35,3 +67,15 @@ export function getFfprobePath(): string {
|
|||||||
export function getAria2cPath(): string {
|
export function getAria2cPath(): string {
|
||||||
return join(getBinDir(), 'aria2c.exe')
|
return join(getBinDir(), 'aria2c.exe')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute path to a Windows system executable (e.g. taskkill.exe, schtasks.exe).
|
||||||
|
*
|
||||||
|
* SECURITY (audit F3): system tools are resolved by full path under System32
|
||||||
|
* rather than by bare name, so a same-named binary planted in the current
|
||||||
|
* working directory or earlier on PATH can't be invoked in their place — a real
|
||||||
|
* risk for the portable build, which runs from user-writable locations.
|
||||||
|
*/
|
||||||
|
export function getSystem32Path(exe: string): string {
|
||||||
|
return join(process.env.SystemRoot || 'C:\\Windows', 'System32', exe)
|
||||||
|
}
|
||||||
|
|||||||
+165
-4
@@ -8,11 +8,14 @@
|
|||||||
* resolving it itself; the caller in download.ts passes `getBinDir()`.
|
* resolving it itself; the caller in download.ts passes `getBinDir()`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { join } from 'path'
|
||||||
import {
|
import {
|
||||||
BEST_FORMAT_ID,
|
BEST_FORMAT_ID,
|
||||||
type StartDownloadOptions,
|
type StartDownloadOptions,
|
||||||
type DownloadOptions,
|
type DownloadOptions,
|
||||||
type CookieBrowser
|
type CollectionContext,
|
||||||
|
type CookieBrowser,
|
||||||
|
type CommandTemplate
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
// --- Quality → yt-dlp selector mapping --------------------------------------
|
// --- Quality → yt-dlp selector mapping --------------------------------------
|
||||||
@@ -97,6 +100,10 @@ export interface AccessOptions {
|
|||||||
restrictFilenames: boolean
|
restrictFilenames: boolean
|
||||||
/** absolute path to a --download-archive file; omit to disable archive tracking */
|
/** absolute path to a --download-archive file; omit to disable archive tracking */
|
||||||
downloadArchivePath?: string
|
downloadArchivePath?: string
|
||||||
|
/** YouTube extraction client override (e.g. 'web_safari'); empty/omitted = default */
|
||||||
|
youtubePlayerClient?: string
|
||||||
|
/** manually-supplied YouTube Proof-of-Origin token; empty/omitted = none */
|
||||||
|
youtubePoToken?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tuned for typical CDN-served video: 16 connections split across 16 segments,
|
// Tuned for typical CDN-served video: 16 connections split across 16 segments,
|
||||||
@@ -125,6 +132,83 @@ export function parseExtraArgs(raw: string): string[] {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the extra yt-dlp args for a download, enforcing the custom-command
|
||||||
|
* consent gate (audit F2).
|
||||||
|
*
|
||||||
|
* Extra args are powerful enough to run arbitrary code (e.g. `--exec`), so they
|
||||||
|
* are honoured ONLY when custom commands are explicitly enabled in settings —
|
||||||
|
* the same persisted flag the Settings UI and backup-import treat as consent. A
|
||||||
|
* per-download override wins over the persisted default template, but NEITHER is
|
||||||
|
* applied while the gate is off. This keeps a compromised renderer from smuggling
|
||||||
|
* code-exec flags through a lone `startDownload({ extraArgs })` call: it would
|
||||||
|
* first have to flip the visible `customCommandEnabled` setting, leaving a trace
|
||||||
|
* (the same defence-in-depth posture as the main-side maxConcurrent cap).
|
||||||
|
*
|
||||||
|
* - 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 → []
|
||||||
|
*/
|
||||||
|
export function selectExtraArgs(params: {
|
||||||
|
customCommandEnabled: boolean
|
||||||
|
perDownloadExtraArgs: string | undefined
|
||||||
|
defaultTemplateId: string | null
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive regex test of a template's urlPattern; a bad pattern never matches. */
|
||||||
|
export function matchesUrl(pattern: string, url: string): boolean {
|
||||||
|
try {
|
||||||
|
return new RegExp(pattern, 'i').test(url)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a raw trim string (one or more time ranges, comma- or newline-separated)
|
||||||
|
* into yt-dlp `--download-sections` specs. Each range is normalised to the
|
||||||
|
* `*START-END` time-range form yt-dlp expects (the leading `*` distinguishes a
|
||||||
|
* timestamp range from a chapter-title regex). Tokens that don't look like a
|
||||||
|
* numeric time range are dropped, so malformed input can't reach yt-dlp's argv.
|
||||||
|
*
|
||||||
|
* Accepts H:MM:SS / M:SS / SS with optional fractional seconds, e.g. '1:30-2:00',
|
||||||
|
* '90-120', '0:00:10.5-0:00:20'. A token may already carry the leading `*`.
|
||||||
|
*/
|
||||||
|
export function parseTrimSections(raw: string | undefined): string[] {
|
||||||
|
if (!raw) return []
|
||||||
|
const TIME = String.raw`\d+(?::\d{1,2})*(?:\.\d+)?`
|
||||||
|
const RANGE = new RegExp(`^${TIME}-${TIME}$`)
|
||||||
|
const out: string[] = []
|
||||||
|
for (const piece of raw.split(/[,\n]/)) {
|
||||||
|
let t = piece.trim()
|
||||||
|
if (!t) continue
|
||||||
|
if (t.startsWith('*')) t = t.slice(1).trim()
|
||||||
|
if (RANGE.test(t)) out.push(`*${t}`)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap a single argv token for human-readable display only (Phase C command
|
// Wrap a single argv token for human-readable display only (Phase C command
|
||||||
// preview) — never used to build the real argv that gets spawned.
|
// preview) — never used to build the real argv that gets spawned.
|
||||||
function quoteForDisplay(arg: string): string {
|
function quoteForDisplay(arg: string): string {
|
||||||
@@ -138,6 +222,54 @@ export function formatCommandLine(exe: string, args: string[]): string {
|
|||||||
return [exe, ...args].map(quoteForDisplay).join(' ')
|
return [exe, ...args].map(quoteForDisplay).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Collection (media-manager) folder paths --------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize one path segment (a channel or playlist name) into a Windows-safe
|
||||||
|
* directory name. This matters because, unlike the filename yt-dlp itself writes
|
||||||
|
* (which `--restrict-filenames` can clean), these directory segments are built by
|
||||||
|
* AeroFetch from untrusted channel/playlist titles and joined onto the output
|
||||||
|
* dir — so they must be neutered for both illegal characters AND path traversal.
|
||||||
|
*
|
||||||
|
* - illegal chars (`< > : " / \ | ? *`) and control chars → space
|
||||||
|
* - leading/trailing dots and spaces stripped (illegal / invisible on Windows),
|
||||||
|
* which also turns a bare `..` traversal segment into nothing
|
||||||
|
* - reserved device names (CON, PRN, NUL, COM1…) get an underscore prefix
|
||||||
|
* - length-capped so the full path stays well under MAX_PATH
|
||||||
|
* - empty result falls back to 'Untitled'
|
||||||
|
*/
|
||||||
|
export function sanitizeDirSegment(name: string): string {
|
||||||
|
// C0 control chars (charCode < 0x20) and Windows-illegal chars both become a
|
||||||
|
// space. The control-char filter is done by char code so no literal control
|
||||||
|
// byte ever appears in this source file.
|
||||||
|
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
|
||||||
|
s = s.replace(/[<>:"/\\|?*]/g, ' ')
|
||||||
|
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
|
||||||
|
// Reserved device names are reserved even WITH an extension ('CON.txt' is still
|
||||||
|
// the CON device), so match an optional trailing '.<ext>' too (audit T3).
|
||||||
|
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(s)) s = `_${s}`
|
||||||
|
s = s.slice(0, 80).trim()
|
||||||
|
return s || 'Untitled'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `-o` output template for a collection download:
|
||||||
|
* <outDir>/<Channel>/<Playlist>/<NNN> - <baseFilename>
|
||||||
|
* where NNN is the 1-based playlist index zero-padded to three digits and
|
||||||
|
* baseFilename keeps its yt-dlp field tokens (e.g. '%(title)s.%(ext)s') so they
|
||||||
|
* still expand. Channel/playlist are sanitized via sanitizeDirSegment.
|
||||||
|
*/
|
||||||
|
export function collectionOutputTemplate(
|
||||||
|
outDir: string,
|
||||||
|
c: CollectionContext,
|
||||||
|
baseFilename: string
|
||||||
|
): string {
|
||||||
|
const n = Number.isFinite(c.index) && c.index > 0 ? Math.floor(c.index) : 1
|
||||||
|
const nnn = String(n).padStart(3, '0')
|
||||||
|
const segments = [sanitizeDirSegment(c.channel), sanitizeDirSegment(c.playlist)]
|
||||||
|
return join(outDir, ...segments, `${nnn} - ${baseFilename}`)
|
||||||
|
}
|
||||||
|
|
||||||
function accessArgs(a: AccessOptions): string[] {
|
function accessArgs(a: AccessOptions): string[] {
|
||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
|
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
|
||||||
@@ -150,6 +282,12 @@ function accessArgs(a: AccessOptions): string[] {
|
|||||||
else if (a.cookiesFile) args.push('--cookies', a.cookiesFile)
|
else if (a.cookiesFile) args.push('--cookies', a.cookiesFile)
|
||||||
if (a.restrictFilenames) args.push('--restrict-filenames')
|
if (a.restrictFilenames) args.push('--restrict-filenames')
|
||||||
if (a.downloadArchivePath) args.push('--download-archive', a.downloadArchivePath)
|
if (a.downloadArchivePath) args.push('--download-archive', a.downloadArchivePath)
|
||||||
|
// YouTube reliability: combine the player-client + PO-token overrides into one
|
||||||
|
// `youtube:` extractor-args group (semicolon-separated, as yt-dlp expects).
|
||||||
|
const yt: string[] = []
|
||||||
|
if (a.youtubePlayerClient?.trim()) yt.push(`player_client=${a.youtubePlayerClient.trim()}`)
|
||||||
|
if (a.youtubePoToken?.trim()) yt.push(`po_token=${a.youtubePoToken.trim()}`)
|
||||||
|
if (yt.length > 0) args.push('--extractor-args', `youtube:${yt.join(';')}`)
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,19 +304,38 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SponsorBlock.
|
// SponsorBlock.
|
||||||
|
let forcedKeyframes = false
|
||||||
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
|
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
|
||||||
const cats = o.sponsorBlockCategories.join(',')
|
const cats = o.sponsorBlockCategories.join(',')
|
||||||
if (o.sponsorBlockMode === 'remove') {
|
if (o.sponsorBlockMode === 'remove') {
|
||||||
// Force keyframes at the cut points so segment boundaries are frame-accurate.
|
// Force keyframes at the cut points so segment boundaries are frame-accurate.
|
||||||
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
|
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
|
||||||
|
forcedKeyframes = true
|
||||||
} else {
|
} else {
|
||||||
args.push('--sponsorblock-mark', cats)
|
args.push('--sponsorblock-mark', cats)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trim — download only the given time ranges (works for video + audio). Force
|
||||||
|
// keyframes at the cut points for frame-accurate boundaries, unless
|
||||||
|
// SponsorBlock-remove already requested it (the flag is idempotent, but we
|
||||||
|
// avoid emitting it twice).
|
||||||
|
const sections = parseTrimSections(opts.trim)
|
||||||
|
for (const sec of sections) args.push('--download-sections', sec)
|
||||||
|
if (sections.length > 0 && !forcedKeyframes) args.push('--force-keyframes-at-cuts')
|
||||||
|
|
||||||
if (o.embedChapters) args.push('--embed-chapters')
|
if (o.embedChapters) args.push('--embed-chapters')
|
||||||
|
// Split into one file per chapter. yt-dlp also keeps the full file; the
|
||||||
|
// per-chapter files use yt-dlp's default `chapter:` output template.
|
||||||
|
if (o.splitChapters) args.push('--split-chapters')
|
||||||
if (o.embedMetadata) args.push('--embed-metadata')
|
if (o.embedMetadata) args.push('--embed-metadata')
|
||||||
|
|
||||||
|
// Media-server sidecar files (Phase K) — written next to the output file so
|
||||||
|
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
|
||||||
|
if (o.writeInfoJson) args.push('--write-info-json')
|
||||||
|
if (o.writeThumbnailFile) args.push('--write-thumbnail')
|
||||||
|
if (o.writeDescription) args.push('--write-description')
|
||||||
|
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,9 +377,13 @@ export function buildArgs(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
|
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
|
||||||
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the
|
// A raw format-sort string (advanced) wins outright; otherwise the codec
|
||||||
// pick toward the chosen codec without overriding the requested quality.
|
// preference is a sort tiebreaker AFTER resolution/fps, nudging the pick toward
|
||||||
if (o.preferredVideoCodec !== 'any') {
|
// the chosen codec without overriding the requested quality.
|
||||||
|
const sort = o.formatSort.trim()
|
||||||
|
if (sort) {
|
||||||
|
args.push('-S', sort)
|
||||||
|
} else if (o.preferredVideoCodec !== 'any') {
|
||||||
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
|
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
|
||||||
args.push('-S', `res,fps,vcodec:${token}`)
|
args.push('-S', `res,fps,vcodec:${token}`)
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-21
@@ -1,4 +1,4 @@
|
|||||||
import { app, session, BrowserWindow, type Cookie } from 'electron'
|
import { app, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
|
||||||
import { existsSync, statSync, unlinkSync, writeFileSync } from 'fs'
|
import { existsSync, statSync, unlinkSync, writeFileSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
@@ -11,6 +11,55 @@ import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
|
|||||||
*/
|
*/
|
||||||
const PARTITION = 'persist:aerofetch-login'
|
const PARTITION = 'persist:aerofetch-login'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sign-in window renders untrusted remote content, so every navigation and
|
||||||
|
* popup is confined to web URLs — http(s), plus about:blank. This stops a
|
||||||
|
* logged-in (or malicious) page from steering the window to a file:// URL, to
|
||||||
|
* the app's own aerofetch:// protocol handler, or to any other external URI
|
||||||
|
* scheme used as a pivot. (audit T4)
|
||||||
|
*/
|
||||||
|
export function isAllowedLoginUrl(target: string): boolean {
|
||||||
|
try {
|
||||||
|
const { protocol } = new URL(target)
|
||||||
|
return protocol === 'http:' || protocol === 'https:' || protocol === 'about:'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the web-only confinement to a sign-in webContents: block top-frame
|
||||||
|
* navigations and server redirects to non-web URLs, restrict popups to web URLs
|
||||||
|
* (reusing the same secure, partitioned webPreferences), and recurse into any
|
||||||
|
* popup the page opens so a nested window can't escape the policy either. The
|
||||||
|
* window-open handler alone only governs NEW windows, not navigations of an
|
||||||
|
* existing one — both vectors are covered here. (audit T4)
|
||||||
|
*/
|
||||||
|
function hardenLoginWebContents(wc: WebContents): void {
|
||||||
|
wc.setWindowOpenHandler((details) => {
|
||||||
|
if (!isAllowedLoginUrl(details.url)) return { action: 'deny' }
|
||||||
|
return {
|
||||||
|
action: 'allow',
|
||||||
|
overrideBrowserWindowOptions: {
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
partition: PARTITION,
|
||||||
|
sandbox: true,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
wc.on('will-navigate', (e, navUrl) => {
|
||||||
|
if (!isAllowedLoginUrl(navUrl)) e.preventDefault()
|
||||||
|
})
|
||||||
|
wc.on('will-redirect', (e, navUrl) => {
|
||||||
|
if (!isAllowedLoginUrl(navUrl)) e.preventDefault()
|
||||||
|
})
|
||||||
|
wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents))
|
||||||
|
}
|
||||||
|
|
||||||
export function getCookiesFilePath(): string {
|
export function getCookiesFilePath(): string {
|
||||||
return join(app.getPath('userData'), 'cookies.txt')
|
return join(app.getPath('userData'), 'cookies.txt')
|
||||||
}
|
}
|
||||||
@@ -114,26 +163,12 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
|
|||||||
}
|
}
|
||||||
loginWindow = win
|
loginWindow = win
|
||||||
|
|
||||||
// Some sites log in via an OAuth/SSO popup. Let those open as real windows
|
// Confine every navigation/popup to web URLs (recursively, so OAuth/SSO
|
||||||
// sharing the same partition, rather than silently swallowing the click.
|
// popups sharing the cookie partition are covered too), and deny all gated
|
||||||
// Restrict popups to http(s) only — the same defence the main window applies
|
// web permissions — signing in needs no camera/mic/geolocation/etc., and the
|
||||||
// (index.ts) — so a logged-in page can't open a file:// popup to exfil cookies
|
// page is untrusted. (audit T4)
|
||||||
// to disk or use a custom-protocol popup as a pivot.
|
hardenLoginWebContents(win.webContents)
|
||||||
win.webContents.setWindowOpenHandler((details) => {
|
win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false))
|
||||||
try {
|
|
||||||
const { protocol } = new URL(details.url)
|
|
||||||
if (protocol !== 'http:' && protocol !== 'https:') return { action: 'deny' }
|
|
||||||
} catch {
|
|
||||||
return { action: 'deny' } // unparseable URL — never open it
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
action: 'allow',
|
|
||||||
overrideBrowserWindowOptions: {
|
|
||||||
autoHideMenuBar: true,
|
|
||||||
webPreferences: { partition: PARTITION, sandbox: true, contextIsolation: true, nodeIntegration: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
loginWindow = null
|
loginWindow = null
|
||||||
|
|||||||
+21
-8
@@ -1,27 +1,40 @@
|
|||||||
import { app, shell, type BrowserWindow } from 'electron'
|
import { app, shell, type BrowserWindow } from 'electron'
|
||||||
import { existsSync, readFileSync } from 'fs'
|
import { existsSync, openSync, readSync, closeSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { assertHttpUrl } from './url'
|
||||||
|
|
||||||
/** Only ever forward http(s) targets into the app — same restriction the
|
/** Only ever forward http(s) targets into the app — same restriction the
|
||||||
* external-link window-open handler in index.ts applies to in-page links. */
|
* external-link window-open handler in index.ts applies to in-page links.
|
||||||
|
* Delegates to the download path's guard so an incoming deep-link target is
|
||||||
|
* validated AND parser-normalised (no embedded tabs/newlines/control chars
|
||||||
|
* reach the renderer); returns null instead of throwing for the argv scan.
|
||||||
|
* (audit T3 / F5) */
|
||||||
function asHttpUrl(candidate: string): string | null {
|
function asHttpUrl(candidate: string): string | null {
|
||||||
try {
|
try {
|
||||||
const u = new URL(candidate)
|
return assertHttpUrl(candidate)
|
||||||
return u.protocol === 'http:' || u.protocol === 'https:' ? candidate : null
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads a Windows Internet Shortcut (.url) file's target — what Explorer's
|
/** Reads a Windows Internet Shortcut (.url) file's target — what Explorer's
|
||||||
* "Send to" menu hands us when the user sends a saved link to AeroFetch. */
|
* "Send to" menu hands us when the user sends a saved link to AeroFetch.
|
||||||
|
* Only the first 64 KB is read: a real shortcut is a few hundred bytes, so this
|
||||||
|
* caps memory and limits exposure if argv points at a pathological or oversized
|
||||||
|
* file that merely ends in `.url`. (audit T5) */
|
||||||
|
const MAX_URL_FILE_BYTES = 64 * 1024
|
||||||
function readUrlShortcut(path: string): string | null {
|
function readUrlShortcut(path: string): string | null {
|
||||||
|
let fd: number | null = null
|
||||||
try {
|
try {
|
||||||
const text = readFileSync(path, 'utf8')
|
fd = openSync(path, 'r')
|
||||||
const match = /^URL=(.+)$/im.exec(text)
|
const buf = Buffer.alloc(MAX_URL_FILE_BYTES)
|
||||||
|
const bytes = readSync(fd, buf, 0, buf.length, 0)
|
||||||
|
const match = /^URL=(.+)$/im.exec(buf.toString('utf8', 0, bytes))
|
||||||
return match ? asHttpUrl(match[1].trim()) : null
|
return match ? asHttpUrl(match[1].trim()) : null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
} finally {
|
||||||
|
if (fd !== null) closeSync(fd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +45,7 @@ function readUrlShortcut(path: string): string | null {
|
|||||||
*/
|
*/
|
||||||
export function extractIncomingUrl(argv: string[]): string | null {
|
export function extractIncomingUrl(argv: string[]): string | null {
|
||||||
for (const arg of argv) {
|
for (const arg of argv) {
|
||||||
if (arg.startsWith('aerofetch://')) {
|
if (/^aerofetch:\/\//i.test(arg)) {
|
||||||
try {
|
try {
|
||||||
const target = new URL(arg).searchParams.get('url')
|
const target = new URL(arg).searchParams.get('url')
|
||||||
const valid = target && asHttpUrl(target)
|
const valid = target && asHttpUrl(target)
|
||||||
|
|||||||
+104
-30
@@ -1,13 +1,27 @@
|
|||||||
import { spawn, execFile, type ChildProcess } from 'child_process'
|
import { spawn, execFile, type ChildProcess } from 'child_process'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { app, BrowserWindow, Notification, type WebContents } from 'electron'
|
import { BrowserWindow, Notification, type WebContents } from 'electron'
|
||||||
import { getYtdlpPath, getBinDir, getAria2cPath, getFfmpegPath, getFfprobePath } from './binaries'
|
import {
|
||||||
import { getSettings, getDownloadArchivePath } from './settings'
|
getYtdlpPath,
|
||||||
|
getBinDir,
|
||||||
|
getAria2cPath,
|
||||||
|
getFfmpegPath,
|
||||||
|
getFfprobePath,
|
||||||
|
getSystem32Path
|
||||||
|
} from './binaries'
|
||||||
|
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
|
||||||
|
import { ensureManagedYtdlp } from './ytdlp'
|
||||||
import { getCookiesFilePath } from './cookies'
|
import { getCookiesFilePath } from './cookies'
|
||||||
import { listTemplates } from './templates'
|
import { listTemplates } from './templates'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
import { buildArgs, parseExtraArgs, formatCommandLine } from './buildArgs'
|
import { isSafeOutputDir } from './validation'
|
||||||
|
import {
|
||||||
|
buildArgs,
|
||||||
|
selectExtraArgs,
|
||||||
|
formatCommandLine,
|
||||||
|
collectionOutputTemplate
|
||||||
|
} from './buildArgs'
|
||||||
import { cleanError } from './log'
|
import { cleanError } from './log'
|
||||||
import { addErrorLog } from './errorlog'
|
import { addErrorLog } from './errorlog'
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +38,7 @@ import {
|
|||||||
interface ActiveDownload {
|
interface ActiveDownload {
|
||||||
child: ChildProcess
|
child: ChildProcess
|
||||||
canceled: boolean
|
canceled: boolean
|
||||||
|
paused: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = new Map<string, ActiveDownload>()
|
const active = new Map<string, ActiveDownload>()
|
||||||
@@ -146,22 +161,41 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
|
|||||||
|
|
||||||
// --- Argv construction (shared by startDownload and the command preview) ---
|
// --- Argv construction (shared by startDownload and the command preview) ---
|
||||||
|
|
||||||
// A per-download override (opts.extraArgs, even '') always wins; otherwise
|
// A per-download override (opts.extraArgs, even '') wins over the persisted
|
||||||
// fall back to the persisted default template when custom-command mode is on.
|
// default template — but BOTH are gated on the customCommandEnabled consent flag
|
||||||
|
// (see selectExtraArgs / audit F2). The gate is enforced here in main, not just
|
||||||
|
// in the renderer UI, so the renderer can't be trusted to apply it.
|
||||||
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
|
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
|
||||||
if (opts.extraArgs !== undefined) return parseExtraArgs(opts.extraArgs)
|
return selectExtraArgs({
|
||||||
if (settings.customCommandEnabled && settings.defaultTemplateId) {
|
customCommandEnabled: settings.customCommandEnabled,
|
||||||
const tpl = listTemplates().find((t) => t.id === settings.defaultTemplateId)
|
perDownloadExtraArgs: opts.extraArgs,
|
||||||
if (tpl) return parseExtraArgs(tpl.args)
|
defaultTemplateId: settings.defaultTemplateId,
|
||||||
}
|
templates: listTemplates(),
|
||||||
return []
|
url: opts.url
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
|
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
|
||||||
export function buildCommand(opts: StartDownloadOptions): string[] {
|
export function buildCommand(opts: StartDownloadOptions): string[] {
|
||||||
const settings = getSettings()
|
const settings = getSettings()
|
||||||
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
|
// Output dir resolution: a per-download override wins, then the user's explicit
|
||||||
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
|
||||||
|
// blank — the per-kind default (Documents\Video for video, Documents\Audio for audio).
|
||||||
|
//
|
||||||
|
// A per-download outputDir override must clear the same safety check the persisted
|
||||||
|
// setting does (absolute path only); a renderer-supplied override is otherwise
|
||||||
|
// untrusted — it dictates where downloaded files get written. An unsafe override is
|
||||||
|
// ignored in favour of the per-kind folder, then the per-kind default. (audit F4)
|
||||||
|
const override = opts.outputDir?.trim()
|
||||||
|
const safeOverride = override && isSafeOutputDir(override) ? override : ''
|
||||||
|
const perKindDir = (opts.kind === 'audio' ? settings.audioDir : settings.videoDir)?.trim()
|
||||||
|
const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind)
|
||||||
|
// A collection (media-manager) download is filed into <channel>/<playlist>/
|
||||||
|
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
|
||||||
|
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
||||||
|
const outputTemplate = opts.collection
|
||||||
|
? collectionOutputTemplate(outDir, opts.collection, '%(title)s.%(ext)s')
|
||||||
|
: join(outDir, filenameTemplate)
|
||||||
// Per-download override wins; otherwise use the persisted defaults.
|
// Per-download override wins; otherwise use the persisted defaults.
|
||||||
const options = opts.options ?? settings.downloadOptions
|
const options = opts.options ?? settings.downloadOptions
|
||||||
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
|
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
|
||||||
@@ -180,21 +214,26 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
|
|||||||
cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
|
cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
|
||||||
cookiesFile,
|
cookiesFile,
|
||||||
restrictFilenames: settings.restrictFilenames,
|
restrictFilenames: settings.restrictFilenames,
|
||||||
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined
|
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
|
||||||
|
youtubePlayerClient: settings.youtubePlayerClient,
|
||||||
|
youtubePoToken: settings.youtubePoToken
|
||||||
}
|
}
|
||||||
const extraArgs = resolveExtraArgs(opts, settings)
|
const extraArgs = resolveExtraArgs(opts, settings)
|
||||||
return buildArgs(opts, join(outDir, template), options, getBinDir(), access, extraArgs)
|
return buildArgs(opts, outputTemplate, options, getBinDir(), access, extraArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the exact command line for the current form state, without running it. */
|
/** Build the exact command line for the current form state, without running it. */
|
||||||
export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult {
|
export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult {
|
||||||
|
let normalized: StartDownloadOptions
|
||||||
try {
|
try {
|
||||||
assertHttpUrl(opts.url)
|
// Use the parser-normalised URL (audit F5), so the previewed command matches
|
||||||
|
// exactly what startDownload would spawn.
|
||||||
|
normalized = { ...opts, url: assertHttpUrl(opts.url) }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: (e as Error).message }
|
return { ok: false, error: (e as Error).message }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return { ok: true, command: formatCommandLine(getYtdlpPath(), buildCommand(opts)) }
|
return { ok: true, command: formatCommandLine(getYtdlpPath(), buildCommand(normalized)) }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: (e as Error).message }
|
return { ok: false, error: (e as Error).message }
|
||||||
}
|
}
|
||||||
@@ -206,11 +245,14 @@ export function startDownload(
|
|||||||
wc: WebContents,
|
wc: WebContents,
|
||||||
opts: StartDownloadOptions
|
opts: StartDownloadOptions
|
||||||
): StartDownloadResult {
|
): StartDownloadResult {
|
||||||
|
// Self-heal the managed copy from the bundled seed before spawning, so a
|
||||||
|
// never-seeded or deleted yt-dlp.exe doesn't fail an otherwise-fine download.
|
||||||
|
ensureManagedYtdlp()
|
||||||
const ytdlp = getYtdlpPath()
|
const ytdlp = getYtdlpPath()
|
||||||
if (!existsSync(ytdlp)) {
|
if (!existsSync(ytdlp)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
|
error: `yt-dlp.exe is missing and couldn't be restored from the bundle.\nReinstall AeroFetch, or drop yt-dlp.exe into resources/bin/ (see the README there).`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ffmpeg is used by nearly every download (merge, audio extract, thumbnail/
|
// ffmpeg is used by nearly every download (merge, audio extract, thumbnail/
|
||||||
@@ -229,9 +271,13 @@ export function startDownload(
|
|||||||
`Add the ffmpeg build's binaries to resources/bin/ (see the README there).`
|
`Add the ffmpeg build's binaries to resources/bin/ (see the README there).`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv.
|
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv,
|
||||||
|
// and replace opts.url with the parser-normalised form so the exact string we
|
||||||
|
// validated is the one that gets spawned/probed — not a raw variant carrying
|
||||||
|
// interior tabs/newlines or leading control chars that URL parsing silently
|
||||||
|
// tolerates. (audit F5)
|
||||||
try {
|
try {
|
||||||
assertHttpUrl(opts.url)
|
opts = { ...opts, url: assertHttpUrl(opts.url) }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: (e as Error).message }
|
return { ok: false, error: (e as Error).message }
|
||||||
}
|
}
|
||||||
@@ -253,7 +299,7 @@ export function startDownload(
|
|||||||
return { ok: false, error: (e as Error).message }
|
return { ok: false, error: (e as Error).message }
|
||||||
}
|
}
|
||||||
|
|
||||||
const rec: ActiveDownload = { child, canceled: false }
|
const rec: ActiveDownload = { child, canceled: false, paused: false }
|
||||||
active.set(opts.id, rec)
|
active.set(opts.id, rec)
|
||||||
|
|
||||||
// Title/channel/duration are kept locally so the completion notification and
|
// Title/channel/duration are kept locally so the completion notification and
|
||||||
@@ -302,7 +348,8 @@ export function startDownload(
|
|||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
active.delete(opts.id)
|
active.delete(opts.id)
|
||||||
if (!rec.canceled) {
|
// A paused download was killed on purpose — stay silent, like a cancel.
|
||||||
|
if (!rec.canceled && !rec.paused) {
|
||||||
send(wc, { type: 'error', id: opts.id, error: err.message })
|
send(wc, { type: 'error', id: opts.id, error: err.message })
|
||||||
logFailure(opts, resolvedTitle, err.message)
|
logFailure(opts, resolvedTitle, err.message)
|
||||||
notify(wc, resolvedTitle ?? 'Download failed', err.message)
|
notify(wc, resolvedTitle ?? 'Download failed', err.message)
|
||||||
@@ -313,7 +360,9 @@ export function startDownload(
|
|||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
active.delete(opts.id)
|
active.delete(opts.id)
|
||||||
if (rec.canceled) return // renderer already showed 'canceled' optimistically
|
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
|
||||||
|
// 'paused' and keeps the .part for a later resume. Either way, no event.
|
||||||
|
if (rec.canceled || rec.paused) return
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
send(wc, { type: 'done', id: opts.id, filePath })
|
send(wc, { type: 'done', id: opts.id, filePath })
|
||||||
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
|
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
|
||||||
@@ -328,15 +377,40 @@ export function startDownload(
|
|||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelDownload(id: string): void {
|
// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
|
||||||
const rec = active.get(id)
|
// taskkill from System32 by absolute path, never the bare name, so a planted
|
||||||
if (!rec) return
|
// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
|
||||||
rec.canceled = true
|
function killTree(rec: ActiveDownload): void {
|
||||||
const pid = rec.child.pid
|
const pid = rec.child.pid
|
||||||
if (pid != null) {
|
if (pid != null) {
|
||||||
// Kill the whole tree (/T) so the spawned ffmpeg child dies too.
|
execFile(
|
||||||
execFile('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, () => {})
|
getSystem32Path('taskkill.exe'),
|
||||||
|
['/pid', String(pid), '/T', '/F'],
|
||||||
|
{ windowsHide: true },
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
rec.child.kill()
|
rec.child.kill()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cancelDownload(id: string): void {
|
||||||
|
const rec = active.get(id)
|
||||||
|
if (!rec) return
|
||||||
|
rec.canceled = true
|
||||||
|
killTree(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause a running download: kill the process tree but flag it `paused` (not
|
||||||
|
* `canceled`) so the close handler stays silent — no error event, no error log,
|
||||||
|
* no notification. yt-dlp leaves the partial `.part` file in place, so resuming
|
||||||
|
* is just a fresh startDownload with the same options: yt-dlp's default
|
||||||
|
* `--continue` picks the partial file back up.
|
||||||
|
*/
|
||||||
|
export function pauseDownload(id: string): void {
|
||||||
|
const rec = active.get(id)
|
||||||
|
if (!rec) return
|
||||||
|
rec.paused = true
|
||||||
|
killTree(rec)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
+147
-5
@@ -8,12 +8,16 @@ import {
|
|||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
type CommandTemplate,
|
type CommandTemplate,
|
||||||
type YtdlpUpdateChannel,
|
type YtdlpUpdateChannel,
|
||||||
type SystemThemeInfo
|
type SystemThemeInfo,
|
||||||
|
type TaskbarProgress
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp'
|
||||||
|
import { getFfmpegVersions } from './ffmpeg'
|
||||||
|
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||||
import { probeMedia } from './probe'
|
import { probeMedia } from './probe'
|
||||||
import { startDownload, cancelDownload, previewCommand } from './download'
|
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
|
||||||
import { getSettings, setSettings } from './settings'
|
import { runTerminal, cancelTerminal } from './terminal'
|
||||||
|
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
||||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||||
import { setupPortableData } from './portable'
|
import { setupPortableData } from './portable'
|
||||||
@@ -22,6 +26,18 @@ import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies
|
|||||||
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
||||||
import { exportBackup, importBackup } from './backup'
|
import { exportBackup, importBackup } from './backup'
|
||||||
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
|
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
|
||||||
|
import {
|
||||||
|
listSources,
|
||||||
|
getSource,
|
||||||
|
removeSource,
|
||||||
|
listMediaItems,
|
||||||
|
setMediaItemDownloaded,
|
||||||
|
setSourceWatched
|
||||||
|
} from './sources'
|
||||||
|
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
|
// Only one instance ever runs. A second launch — e.g. the OS invoking us again
|
||||||
// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
|
// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
|
||||||
@@ -79,6 +95,25 @@ function getSystemThemeInfo(): SystemThemeInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Web permissions a download manager never needs. They're denied for the app
|
||||||
|
// window as defence-in-depth (audit T6): even if the renderer were compromised
|
||||||
|
// (e.g. XSS via remote video metadata) it can't open the camera/mic, read
|
||||||
|
// location, or reach USB/HID/serial/Bluetooth devices — none of which the IPC
|
||||||
|
// surface grants either. Clipboard (paste/copy) and everything else is left to
|
||||||
|
// the default so the app's own features keep working.
|
||||||
|
const DENIED_PERMISSIONS = new Set([
|
||||||
|
'media', // camera + microphone
|
||||||
|
'geolocation',
|
||||||
|
'midi',
|
||||||
|
'midiSysex',
|
||||||
|
'hid',
|
||||||
|
'serial',
|
||||||
|
'usb',
|
||||||
|
'bluetooth',
|
||||||
|
'speaker-selection',
|
||||||
|
'idle-detection'
|
||||||
|
])
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 920,
|
width: 920,
|
||||||
@@ -96,7 +131,19 @@ function createWindow(): void {
|
|||||||
mainWindow = win
|
mainWindow = win
|
||||||
|
|
||||||
win.on('ready-to-show', () => {
|
win.on('ready-to-show', () => {
|
||||||
win.show()
|
// A scheduled `--sync` launch starts unobtrusively (shown but not focused) so
|
||||||
|
// the daily background sync doesn't steal focus; a normal launch shows + focuses.
|
||||||
|
if (isSyncLaunch(process.argv)) win.showInactive()
|
||||||
|
else win.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Minimize-to-tray: when enabled, closing the window hides it to the tray
|
||||||
|
// instead of quitting. A real quit (tray menu / before-quit) sets isQuitting().
|
||||||
|
win.on('close', (e) => {
|
||||||
|
if (getSettings().minimizeToTray && !isQuitting()) {
|
||||||
|
e.preventDefault()
|
||||||
|
win.hide()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
@@ -129,6 +176,15 @@ function createWindow(): void {
|
|||||||
// away from it (defence in depth; HMR uses websockets, not navigation).
|
// away from it (defence in depth; HMR uses websockets, not navigation).
|
||||||
win.webContents.on('will-navigate', (e) => e.preventDefault())
|
win.webContents.on('will-navigate', (e) => e.preventDefault())
|
||||||
|
|
||||||
|
// Deny the sensitive hardware/location web permissions the app never uses, so
|
||||||
|
// a compromised renderer can't escalate to capabilities the IPC surface
|
||||||
|
// doesn't grant. Both the async request and the sync check are covered. (audit T6)
|
||||||
|
const ses = win.webContents.session
|
||||||
|
ses.setPermissionRequestHandler((_wc, permission, callback) =>
|
||||||
|
callback(!DENIED_PERMISSIONS.has(permission))
|
||||||
|
)
|
||||||
|
ses.setPermissionCheckHandler((_wc, permission) => !DENIED_PERMISSIONS.has(permission))
|
||||||
|
|
||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
win.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
win.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
} else {
|
} else {
|
||||||
@@ -137,8 +193,18 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerIpcHandlers(): void {
|
function registerIpcHandlers(): void {
|
||||||
|
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
|
||||||
|
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
|
||||||
|
downloadAppUpdate(url, e.sender)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
|
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
|
||||||
@@ -157,6 +223,11 @@ function registerIpcHandlers(): void {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
|
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
|
||||||
|
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
|
||||||
|
ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) =>
|
||||||
|
runTerminal(e.sender, id, args)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
|
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
|
||||||
|
|
||||||
@@ -229,6 +300,45 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Media-manager sources (Pinchflat-style index; see ROADMAP-PINCHFLAT.md) ---
|
||||||
|
ipcMain.handle(IpcChannels.sourcesList, () => listSources())
|
||||||
|
ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId))
|
||||||
|
ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id))
|
||||||
|
ipcMain.handle(IpcChannels.sourceItemDownloaded, (_e, id: string, filePath?: string) =>
|
||||||
|
setMediaItemDownloaded(id, filePath)
|
||||||
|
)
|
||||||
|
// Indexing pushes live progress to the requesting renderer over `indexProgress`
|
||||||
|
// and resolves with the final result.
|
||||||
|
ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) =>
|
||||||
|
indexSource(url, (p) => {
|
||||||
|
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => {
|
||||||
|
const src = getSource(id)
|
||||||
|
if (!src) return { ok: false, error: 'Source not found.' }
|
||||||
|
return indexSource(src.url, (p) => {
|
||||||
|
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
|
||||||
|
setSourceWatched(id, watched)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
|
||||||
|
syncWatchedSources((p) => {
|
||||||
|
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
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
|
// Push OS theme/contrast changes to every window, and keep the native
|
||||||
@@ -257,6 +367,10 @@ if (isPrimaryInstance) {
|
|||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
electronApp.setAppUserModelId('com.aerofetch.app')
|
electronApp.setAppUserModelId('com.aerofetch.app')
|
||||||
|
|
||||||
|
// Create the default Documents\Video and Documents\Audio destinations so they
|
||||||
|
// exist from first launch (downloads are routed into them by kind).
|
||||||
|
ensureMediaDirs()
|
||||||
|
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
@@ -265,12 +379,40 @@ if (isPrimaryInstance) {
|
|||||||
registerSystemThemeBridge()
|
registerSystemThemeBridge()
|
||||||
registerSendToShortcut()
|
registerSendToShortcut()
|
||||||
createWindow()
|
createWindow()
|
||||||
|
createTray(() => mainWindow)
|
||||||
|
|
||||||
|
// Taskbar right-click jump list: a quick "Open AeroFetch" task. Launching the
|
||||||
|
// exe again is caught by the single-instance lock, which focuses this window.
|
||||||
|
app.setUserTasks([
|
||||||
|
{
|
||||||
|
program: process.execPath,
|
||||||
|
arguments: '',
|
||||||
|
title: 'Open AeroFetch',
|
||||||
|
description: 'Open the AeroFetch window',
|
||||||
|
iconPath: process.execPath,
|
||||||
|
iconIndex: 0
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// Keep yt-dlp current on its own: seed the managed copy, then (throttled to
|
||||||
|
// once a day) self-update it to the chosen channel so a stale binary can't
|
||||||
|
// silently cause YouTube 403s. Best-effort and off the critical path — it
|
||||||
|
// never blocks startup, and status is pushed to the window if it's listening.
|
||||||
|
runStartupYtdlpAutoUpdate((status) => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Any real quit path (tray "Quit", OS shutdown, the updater's app.quit) must set
|
||||||
|
// the quitting flag so the window's close handler exits instead of hiding to tray.
|
||||||
|
app.on('before-quit', () => markQuitting())
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit()
|
app.quit()
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Source indexing orchestration (Pinchflat-style media manager; see
|
||||||
|
* ROADMAP-PINCHFLAT.md). Walks a channel (its /playlists + /videos tabs) or a
|
||||||
|
* single playlist with `yt-dlp --flat-playlist`, merges the result into a deduped
|
||||||
|
* MediaItem list, and persists it as a Source. The download step (Phase G+) then
|
||||||
|
* pulls from that persisted list rather than the live queue holding it all.
|
||||||
|
*
|
||||||
|
* The pure URL-classification + merge logic lives in indexerCore.ts (unit-tested);
|
||||||
|
* this module is the impure shell that spawns yt-dlp and writes to disk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'child_process'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { getYtdlpPath } from './binaries'
|
||||||
|
import { cleanError } from './log'
|
||||||
|
import { assertHttpUrl } from './url'
|
||||||
|
import {
|
||||||
|
classifySource,
|
||||||
|
buildMediaItems,
|
||||||
|
buildFeedUrl,
|
||||||
|
entryUrl,
|
||||||
|
stripTabSuffix,
|
||||||
|
stableSourceId,
|
||||||
|
type RawEntry,
|
||||||
|
type NamedPlaylist
|
||||||
|
} from './indexerCore'
|
||||||
|
import { getSource, upsertSource, mergeMediaItems } from './sources'
|
||||||
|
import type { IndexProgress, IndexSourceResult, Source, SourceKind } from '@shared/ipc'
|
||||||
|
|
||||||
|
/** The slice of `yt-dlp -J --flat-playlist` output the indexer reads. */
|
||||||
|
interface FlatInfo {
|
||||||
|
_type?: string
|
||||||
|
id?: string
|
||||||
|
title?: string
|
||||||
|
uploader?: string
|
||||||
|
channel?: string
|
||||||
|
/** the UC… channel id, used to build the RSS feed URL */
|
||||||
|
channel_id?: string
|
||||||
|
entries?: RawEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `yt-dlp -J --flat-playlist` on a URL and parse the JSON. Rejects on a
|
||||||
|
* non-zero exit or unparseable output. maxBuffer is large because a big channel's
|
||||||
|
* flat upload list can be a few MB of JSON; the timeout is generous for the same
|
||||||
|
* reason. `--` terminates option parsing so the URL can't be read as a flag.
|
||||||
|
*/
|
||||||
|
function probeFlat(url: string): Promise<FlatInfo> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
getYtdlpPath(),
|
||||||
|
['-J', '--flat-playlist', '--no-warnings', '--', url],
|
||||||
|
{ windowsHide: true, maxBuffer: 256 * 1024 * 1024, timeout: 180_000 },
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
const msg = (err as { killed?: boolean }).killed
|
||||||
|
? 'Timed out indexing source. Check the link or your connection.'
|
||||||
|
: cleanError(stderr) || err.message
|
||||||
|
reject(new Error(msg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout) as FlatInfo)
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Could not parse source info from yt-dlp.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe a channel tab (e.g. /videos, /playlists); never throws — returns null. */
|
||||||
|
function probeTab(url: string): Promise<FlatInfo | null> {
|
||||||
|
return probeFlat(url).catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index (or re-index) a Source. Resolves to the persisted Source + item count, or
|
||||||
|
* an error. `onProgress` is invoked throughout so the renderer can show a live
|
||||||
|
* status line — it's best-effort and never affects the result.
|
||||||
|
*/
|
||||||
|
export async function indexSource(
|
||||||
|
url: string,
|
||||||
|
onProgress: (p: IndexProgress) => void
|
||||||
|
): Promise<IndexSourceResult> {
|
||||||
|
let normalizedUrl: string
|
||||||
|
try {
|
||||||
|
normalizedUrl = assertHttpUrl(url) // normalised form for spawning (audit F5)
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: (e as Error).message }
|
||||||
|
}
|
||||||
|
if (!existsSync(getYtdlpPath())) {
|
||||||
|
return { ok: false, error: 'yt-dlp.exe not found. Drop it into resources/bin/.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cls = classifySource(url)
|
||||||
|
onProgress({ url, phase: 'start', message: 'Reading source…' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
let kind: SourceKind
|
||||||
|
let title: string
|
||||||
|
let channel: string | undefined
|
||||||
|
let feedId: string | undefined
|
||||||
|
const playlists: NamedPlaylist[] = []
|
||||||
|
let uploads: RawEntry[] = []
|
||||||
|
|
||||||
|
if (cls?.kind === 'channel') {
|
||||||
|
kind = 'channel'
|
||||||
|
|
||||||
|
// 1. Enumerate the channel's playlists, then each playlist's videos.
|
||||||
|
onProgress({ url, phase: 'playlists', message: 'Finding playlists…' })
|
||||||
|
const playlistTab = await probeTab(`${cls.base}/playlists`)
|
||||||
|
const playlistEntries = playlistTab?.entries ?? []
|
||||||
|
const total = playlistEntries.length
|
||||||
|
let done = 0
|
||||||
|
for (const pe of playlistEntries) {
|
||||||
|
done++
|
||||||
|
const purl = entryUrl(pe)
|
||||||
|
if (!purl) continue
|
||||||
|
onProgress({
|
||||||
|
url,
|
||||||
|
phase: 'playlist',
|
||||||
|
message: `Indexing playlist ${done}/${total}: ${pe.title ?? ''}`.trim(),
|
||||||
|
current: done,
|
||||||
|
total
|
||||||
|
})
|
||||||
|
const pdata = await probeTab(purl)
|
||||||
|
if (pdata?.entries?.length) {
|
||||||
|
playlists.push({ title: pe.title || 'Playlist', entries: pdata.entries })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The full uploads feed — the catch-all for videos in no playlist.
|
||||||
|
onProgress({ url, phase: 'uploads', message: 'Indexing channel uploads…' })
|
||||||
|
const videoTab = await probeTab(`${cls.base}/videos`)
|
||||||
|
uploads = videoTab?.entries ?? []
|
||||||
|
|
||||||
|
title =
|
||||||
|
stripTabSuffix(videoTab?.channel) ||
|
||||||
|
stripTabSuffix(videoTab?.title) ||
|
||||||
|
stripTabSuffix(playlistTab?.title) ||
|
||||||
|
cls.base
|
||||||
|
channel = stripTabSuffix(videoTab?.channel || videoTab?.uploader) || title
|
||||||
|
feedId = videoTab?.channel_id || playlistTab?.channel_id
|
||||||
|
} else {
|
||||||
|
// A single playlist (classified) — or an unclassified URL that might still
|
||||||
|
// resolve to a playlist when probed. A lone video has no entries → error.
|
||||||
|
kind = cls?.kind ?? 'playlist'
|
||||||
|
onProgress({ url, phase: 'uploads', message: 'Indexing playlist…' })
|
||||||
|
const data = await probeFlat(cls?.base ?? normalizedUrl)
|
||||||
|
const entries = data.entries ?? []
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'That link is a single video, not a channel or playlist. Use the download bar for one video.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title = data.title || 'Playlist'
|
||||||
|
channel = data.uploader || data.channel
|
||||||
|
// A playlist feed keys off the playlist id (PL…), carried as `id` here.
|
||||||
|
feedId = data.id
|
||||||
|
// File everything under the playlist's own name (not the 'Uploads' fallback).
|
||||||
|
playlists.push({ title, entries })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceId = stableSourceId(cls?.base ?? url.trim())
|
||||||
|
const fresh = buildMediaItems(sourceId, playlists, uploads)
|
||||||
|
if (fresh.length === 0) {
|
||||||
|
return { ok: false, error: 'No downloadable videos found for this source.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incremental merge: preserve the downloaded state of anything already on
|
||||||
|
// disk, and report how many videos are new since the last index.
|
||||||
|
const { items, newCount } = mergeMediaItems(sourceId, fresh)
|
||||||
|
|
||||||
|
const prev = getSource(sourceId)
|
||||||
|
const source: Source = {
|
||||||
|
id: sourceId,
|
||||||
|
url: url.trim(),
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
channel,
|
||||||
|
addedAt: prev?.addedAt ?? Date.now(),
|
||||||
|
lastIndexedAt: Date.now(),
|
||||||
|
itemCount: items.length,
|
||||||
|
// Preserve the watched flag across a re-index; refresh the RSS feed URL.
|
||||||
|
watched: prev?.watched,
|
||||||
|
feedUrl: buildFeedUrl(kind, feedId) ?? prev?.feedUrl
|
||||||
|
}
|
||||||
|
upsertSource(source)
|
||||||
|
|
||||||
|
const newNote = newCount === items.length ? '' : ` (${newCount} new)`
|
||||||
|
onProgress({
|
||||||
|
url,
|
||||||
|
phase: 'done',
|
||||||
|
message: `Indexed ${items.length} videos${newNote}.`,
|
||||||
|
total: items.length
|
||||||
|
})
|
||||||
|
return { ok: true, source, itemCount: items.length, newCount }
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
onProgress({ url, phase: 'error', message: msg })
|
||||||
|
return { ok: false, error: msg }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Pure, dependency-free core for Source indexing (Pinchflat-style media manager;
|
||||||
|
* see ROADMAP-PINCHFLAT.md). Like buildArgs.ts / validation.ts this module imports
|
||||||
|
* nothing from electron or the node runtime, so its URL-classification and
|
||||||
|
* item-merge logic can be unit-tested without spinning up Electron.
|
||||||
|
*
|
||||||
|
* The impure orchestration (spawning yt-dlp, persisting to disk) lives in
|
||||||
|
* indexer.ts, which composes these helpers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MediaItem, SourceKind } from '@shared/ipc'
|
||||||
|
|
||||||
|
// --- yt-dlp --flat-playlist entry shape (shared with probe.ts) --------------
|
||||||
|
|
||||||
|
/** The slice of a flat-playlist entry we read (a video, or a nested playlist). */
|
||||||
|
export interface RawEntry {
|
||||||
|
id?: string
|
||||||
|
title?: string
|
||||||
|
url?: string
|
||||||
|
webpage_url?: string
|
||||||
|
duration?: number
|
||||||
|
uploader?: string
|
||||||
|
channel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the canonical URL for a flat-playlist entry. Prefer an explicit http(s)
|
||||||
|
* URL; otherwise build a YouTube watch URL from the id (the common case where flat
|
||||||
|
* entries carry only an id). Returns null when nothing usable is present.
|
||||||
|
*
|
||||||
|
* Note: the returned value is always either an http(s) URL or null, so it can
|
||||||
|
* never begin with '-' and be mis-read as a yt-dlp option (callers also pass `--`).
|
||||||
|
*/
|
||||||
|
export function entryUrl(e: RawEntry): string | null {
|
||||||
|
const cand = e.url || e.webpage_url
|
||||||
|
if (cand && /^https?:\/\//i.test(cand)) return cand
|
||||||
|
// e.id is untrusted JSON from yt-dlp, so percent-encode it rather than splicing
|
||||||
|
// it raw into the query string (audit T2). A normal 11-char id is unaffected.
|
||||||
|
if (e.id) return `https://www.youtube.com/watch?v=${encodeURIComponent(e.id)}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds → 'M:SS' or 'H:MM:SS'. Flat entries give duration as a number. */
|
||||||
|
export function fmtDuration(sec?: number): string | undefined {
|
||||||
|
if (sec == null || !Number.isFinite(sec)) return undefined
|
||||||
|
const s = Math.max(0, Math.round(sec))
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.floor((s % 3600) / 60)
|
||||||
|
const r = s % 60
|
||||||
|
const mm = h ? String(m).padStart(2, '0') : String(m)
|
||||||
|
return `${h ? `${h}:` : ''}${mm}:${String(r).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Source-URL classification ----------------------------------------------
|
||||||
|
|
||||||
|
/** A classified Source URL: the kind, plus a normalised base for tab probing. */
|
||||||
|
export interface SourceClass {
|
||||||
|
kind: SourceKind
|
||||||
|
/**
|
||||||
|
* For 'channel': the channel root (e.g. https://www.youtube.com/@handle) onto
|
||||||
|
* which '/videos' and '/playlists' tabs are appended. For 'playlist': the
|
||||||
|
* canonical playlist URL.
|
||||||
|
*/
|
||||||
|
base: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a pasted URL as a YouTube channel, a playlist, or neither.
|
||||||
|
*
|
||||||
|
* Channel forms: /@handle, /channel/<id>, /c/<name>, /user/<name> — any trailing
|
||||||
|
* tab (/videos, /playlists, /streams, /shorts, /featured) is stripped to the root.
|
||||||
|
* Playlist form: any URL carrying a `list=` query param. Returns null for a lone
|
||||||
|
* video or a non-YouTube URL (Phase F scopes the channel walk to YouTube, whose
|
||||||
|
* /videos + /playlists tab structure this relies on).
|
||||||
|
*/
|
||||||
|
export function classifySource(raw: string): SourceClass | null {
|
||||||
|
let u: URL
|
||||||
|
try {
|
||||||
|
u = new URL((raw ?? '').trim())
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
|
||||||
|
|
||||||
|
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
|
||||||
|
const isYouTube = host === 'youtube.com' || host.endsWith('.youtube.com') || host === 'youtu.be'
|
||||||
|
|
||||||
|
// A playlist is identified purely by its list= param (works on any youtube host).
|
||||||
|
const list = u.searchParams.get('list')
|
||||||
|
if (isYouTube && list) {
|
||||||
|
return { kind: 'playlist', base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isYouTube) return null
|
||||||
|
|
||||||
|
// Channel roots. The handle/id segment is captured; later path segments
|
||||||
|
// (the tab) are discarded so we always probe from the channel root.
|
||||||
|
const m = u.pathname.match(/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)/i)
|
||||||
|
if (m) return { kind: 'channel', base: `https://www.youtube.com/${m[1]}` }
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel tab titles from yt-dlp often look like "<Name> - Videos" or
|
||||||
|
* "<Name> - Playlists". Strip a trailing known-tab suffix to recover the bare
|
||||||
|
* channel name. Leaves anything else untouched.
|
||||||
|
*/
|
||||||
|
export function stripTabSuffix(title: string | undefined): string | undefined {
|
||||||
|
if (!title) return title
|
||||||
|
return title.replace(/\s[-–—]\s(Videos|Playlists|Shorts|Live|Streams|Home|Featured)$/i, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic 32-bit FNV-1a hash → 8 hex chars. Used to derive a stable Source
|
||||||
|
* id from its normalised base URL so re-indexing the same channel updates the
|
||||||
|
* existing record instead of creating a duplicate. Pure (no crypto import).
|
||||||
|
*/
|
||||||
|
export function stableSourceId(base: string): string {
|
||||||
|
let h = 0x811c9dc5
|
||||||
|
for (let i = 0; i < base.length; i++) {
|
||||||
|
h ^= base.charCodeAt(i)
|
||||||
|
h = Math.imul(h, 0x01000193)
|
||||||
|
}
|
||||||
|
return (h >>> 0).toString(16).padStart(8, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RSS fast-check (Phase J: watched sources) ------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a YouTube RSS feed URL for cheap "anything new?" polling of a watched
|
||||||
|
* source. Channels feed by `channel_id` (UC…), playlists by `playlist_id` (PL…).
|
||||||
|
* Returns undefined when the id is missing (the sync then falls back to a full
|
||||||
|
* re-index). The feed only carries the latest ~15 uploads, so it's a freshness
|
||||||
|
* check, not a substitute for the initial full index.
|
||||||
|
*/
|
||||||
|
export function buildFeedUrl(kind: SourceKind, ytId: string | undefined): string | undefined {
|
||||||
|
if (!ytId) return undefined
|
||||||
|
const param = kind === 'channel' ? 'channel_id' : 'playlist_id'
|
||||||
|
return `https://www.youtube.com/feeds/videos.xml?${param}=${encodeURIComponent(ytId)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard for the watched-source RSS pre-check (audit T7). The only feed AeroFetch
|
||||||
|
* ever builds is a YouTube videos.xml feed (see buildFeedUrl), so the sync refuses
|
||||||
|
* to fetch anything else — this keeps a hand-edited / corrupted sources.json from
|
||||||
|
* pointing fetch() at an internal service, a cloud-metadata endpoint, or any other
|
||||||
|
* arbitrary host (SSRF). Requires https, the youtube.com host (www optional), and
|
||||||
|
* the exact feed path; the channel_id/playlist_id query is free.
|
||||||
|
*/
|
||||||
|
export function isYouTubeFeedUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url)
|
||||||
|
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
|
||||||
|
return u.protocol === 'https:' && host === 'youtube.com' && u.pathname === '/feeds/videos.xml'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the video ids from a YouTube RSS/Atom feed body (the <yt:videoId> tags). */
|
||||||
|
export function parseRssVideoIds(xml: string): string[] {
|
||||||
|
const ids: string[] = []
|
||||||
|
const re = /<yt:videoId>\s*([\w-]+)\s*<\/yt:videoId>/g
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = re.exec(xml)) !== null) ids.push(m[1])
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Entry merge / dedup ----------------------------------------------------
|
||||||
|
|
||||||
|
/** A named playlist and its (flat) video entries, ready to merge. */
|
||||||
|
export interface NamedPlaylist {
|
||||||
|
title: string
|
||||||
|
entries: RawEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a Source's playlists (and its catch-all uploads) into a deduped list of
|
||||||
|
* MediaItem records. Dedup is by video id: the FIRST playlist a video appears in
|
||||||
|
* wins its folder assignment, so videos grouped into a real playlist land there,
|
||||||
|
* and only videos in no playlist fall through to the synthetic 'Uploads' folder.
|
||||||
|
*
|
||||||
|
* `playlistIndex` is the 1-based position WITHIN the winning playlist (not the
|
||||||
|
* global upload order), so the on-disk "NNN - Title" numbering matches the
|
||||||
|
* playlist the file is filed under.
|
||||||
|
*/
|
||||||
|
export function buildMediaItems(
|
||||||
|
sourceId: string,
|
||||||
|
playlists: NamedPlaylist[],
|
||||||
|
uploads: RawEntry[]
|
||||||
|
): MediaItem[] {
|
||||||
|
const byVideo = new Map<string, MediaItem>()
|
||||||
|
|
||||||
|
const add = (e: RawEntry, playlistTitle: string, index: number): void => {
|
||||||
|
const videoId = e.id
|
||||||
|
if (!videoId) return
|
||||||
|
if (byVideo.has(videoId)) return // first playlist wins
|
||||||
|
const url = entryUrl(e)
|
||||||
|
if (!url) return // not turnable into a downloadable URL
|
||||||
|
byVideo.set(videoId, {
|
||||||
|
id: `${sourceId}:${videoId}`,
|
||||||
|
sourceId,
|
||||||
|
videoId,
|
||||||
|
title: e.title || `Video ${videoId}`,
|
||||||
|
url,
|
||||||
|
playlistTitle,
|
||||||
|
playlistIndex: index,
|
||||||
|
durationLabel: fmtDuration(e.duration),
|
||||||
|
downloaded: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of playlists) p.entries.forEach((e, i) => add(e, p.title, i + 1))
|
||||||
|
uploads.forEach((e, i) => add(e, 'Uploads', i + 1))
|
||||||
|
|
||||||
|
return [...byVideo.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a freshly-enumerated item list with the previously-persisted one for the
|
||||||
|
* same source (incremental re-index; see ROADMAP-PINCHFLAT.md Phase I). The fresh
|
||||||
|
* list defines the current membership/ordering, but any video already marked
|
||||||
|
* downloaded keeps its `downloaded`/`downloadedAt`/`filePath` so a re-sync never
|
||||||
|
* loses that state or re-downloads it. Videos that vanished from the source (now
|
||||||
|
* private/deleted) are dropped. `newCount` is how many fresh videos weren't in
|
||||||
|
* the previous set — the "X new since last sync" figure.
|
||||||
|
*/
|
||||||
|
export function mergeItemsPreservingState(
|
||||||
|
existing: MediaItem[],
|
||||||
|
fresh: MediaItem[]
|
||||||
|
): { items: MediaItem[]; newCount: number } {
|
||||||
|
const prevByVideo = new Map(existing.map((m) => [m.videoId, m]))
|
||||||
|
let newCount = 0
|
||||||
|
const items = fresh.map((f) => {
|
||||||
|
const prev = prevByVideo.get(f.videoId)
|
||||||
|
if (!prev) {
|
||||||
|
newCount++
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
return prev.downloaded
|
||||||
|
? { ...f, downloaded: true, downloadedAt: prev.downloadedAt, filePath: prev.filePath }
|
||||||
|
: f
|
||||||
|
})
|
||||||
|
return { items, newCount }
|
||||||
|
}
|
||||||
+4
-35
@@ -4,6 +4,7 @@ import { getYtdlpPath } from './binaries'
|
|||||||
import { fmtBytes } from './download'
|
import { fmtBytes } from './download'
|
||||||
import { cleanError } from './log'
|
import { cleanError } from './log'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
|
import { entryUrl, fmtDuration, type RawEntry } from './indexerCore'
|
||||||
import {
|
import {
|
||||||
BEST_FORMAT_ID,
|
BEST_FORMAT_ID,
|
||||||
type ProbeResult,
|
type ProbeResult,
|
||||||
@@ -26,16 +27,6 @@ interface RawFormat {
|
|||||||
filesize_approx?: number
|
filesize_approx?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawEntry {
|
|
||||||
id?: string
|
|
||||||
title?: string
|
|
||||||
url?: string
|
|
||||||
webpage_url?: string
|
|
||||||
duration?: number
|
|
||||||
uploader?: string
|
|
||||||
channel?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawInfo {
|
interface RawInfo {
|
||||||
_type?: string
|
_type?: string
|
||||||
title?: string
|
title?: string
|
||||||
@@ -92,29 +83,6 @@ function buildInfo(data: RawInfo): MediaInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seconds → 'M:SS' or 'H:MM:SS'. Flat playlist entries give duration as a number. */
|
|
||||||
function fmtDuration(sec?: number): string | undefined {
|
|
||||||
if (sec == null || !Number.isFinite(sec)) return undefined
|
|
||||||
const s = Math.max(0, Math.round(sec))
|
|
||||||
const h = Math.floor(s / 3600)
|
|
||||||
const m = Math.floor((s % 3600) / 60)
|
|
||||||
const r = s % 60
|
|
||||||
const mm = h ? String(m).padStart(2, '0') : String(m)
|
|
||||||
return `${h ? `${h}:` : ''}${mm}:${String(r).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the URL we'll enqueue for a playlist entry. Prefer an explicit http(s)
|
|
||||||
* URL; fall back to a YouTube watch URL built from the id (the common case where
|
|
||||||
* flat entries carry only an id). Returns null when nothing usable is present.
|
|
||||||
*/
|
|
||||||
function entryUrl(e: RawEntry): string | null {
|
|
||||||
const cand = e.url || e.webpage_url
|
|
||||||
if (cand && /^https?:\/\//i.test(cand)) return cand
|
|
||||||
if (e.id) return `https://www.youtube.com/watch?v=${e.id}`
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPlaylist(data: RawInfo): PlaylistInfo {
|
function buildPlaylist(data: RawInfo): PlaylistInfo {
|
||||||
const entries: PlaylistEntry[] = []
|
const entries: PlaylistEntry[] = []
|
||||||
;(data.entries ?? []).forEach((e, i) => {
|
;(data.entries ?? []).forEach((e, i) => {
|
||||||
@@ -150,8 +118,9 @@ export function probeMedia(url: string): Promise<ProbeResult> {
|
|||||||
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
|
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
let target: string
|
||||||
try {
|
try {
|
||||||
assertHttpUrl(url)
|
target = assertHttpUrl(url) // normalised form (audit F5)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Promise.resolve({ ok: false, error: (e as Error).message })
|
return Promise.resolve({ ok: false, error: (e as Error).message })
|
||||||
}
|
}
|
||||||
@@ -160,7 +129,7 @@ export function probeMedia(url: string): Promise<ProbeResult> {
|
|||||||
execFile(
|
execFile(
|
||||||
ytdlp,
|
ytdlp,
|
||||||
// `--` terminates option parsing so the URL can never be read as a flag.
|
// `--` terminates option parsing so the URL can never be read as a flag.
|
||||||
['-J', '--flat-playlist', '--no-warnings', '--', url],
|
['-J', '--flat-playlist', '--no-warnings', '--', target],
|
||||||
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024, timeout: 60_000 },
|
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024, timeout: 60_000 },
|
||||||
(err, stdout, stderr) => {
|
(err, stdout, stderr) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Windows Task Scheduler integration for the daily watched-source sync
|
||||||
|
* (ROADMAP-PINCHFLAT.md Phase J). Registers a task that launches AeroFetch with
|
||||||
|
* `--sync` once a day; the app then runs its startup sync of watched sources.
|
||||||
|
*
|
||||||
|
* NOTE: this is OS-level wiring and cannot be exercised in the Vite UI preview or
|
||||||
|
* the unit tests — like the `aerofetch://` protocol registration, it needs a real
|
||||||
|
* install + manual smoke test. `schtasks` is invoked via execFile (no shell), and
|
||||||
|
* the only interpolated value is the trusted `process.execPath`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'child_process'
|
||||||
|
import { getSystem32Path } from './binaries'
|
||||||
|
import type { ScheduledSyncStatus } from '@shared/ipc'
|
||||||
|
|
||||||
|
const TASK_NAME = 'AeroFetchDailySync'
|
||||||
|
/** The argv flag the scheduled task passes so startup knows it's a sync launch. */
|
||||||
|
export const SYNC_FLAG = '--sync'
|
||||||
|
|
||||||
|
function schtasks(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Resolve schtasks from System32 by absolute path, not the bare name, so a
|
||||||
|
// planted schtasks.exe on PATH / in the CWD can't be invoked instead. (audit F3)
|
||||||
|
execFile(getSystem32Path('schtasks.exe'), args, { windowsHide: true }, (err, stdout, stderr) => {
|
||||||
|
const code = err ? ((err as { code?: number }).code ?? 1) : 0
|
||||||
|
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this launch came from the scheduled task (argv carries --sync). */
|
||||||
|
export function isSyncLaunch(argv: string[]): boolean {
|
||||||
|
return argv.includes(SYNC_FLAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the daily-sync scheduled task is currently registered. */
|
||||||
|
export async function getScheduledSync(): Promise<ScheduledSyncStatus> {
|
||||||
|
const r = await schtasks(['/Query', '/TN', TASK_NAME])
|
||||||
|
return { enabled: r.code === 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register or remove the daily-sync scheduled task. Creating runs AeroFetch with
|
||||||
|
* `--sync` every day at 09:00 (overwriting any prior task of the same name).
|
||||||
|
*/
|
||||||
|
export async function setScheduledSync(enabled: boolean): Promise<ScheduledSyncStatus> {
|
||||||
|
if (enabled) {
|
||||||
|
const tr = `"${process.execPath}" ${SYNC_FLAG}`
|
||||||
|
const r = await schtasks([
|
||||||
|
'/Create',
|
||||||
|
'/F',
|
||||||
|
'/SC',
|
||||||
|
'DAILY',
|
||||||
|
'/ST',
|
||||||
|
'09:00',
|
||||||
|
'/TN',
|
||||||
|
TASK_NAME,
|
||||||
|
'/TR',
|
||||||
|
tr
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
enabled: r.code === 0,
|
||||||
|
error: r.code === 0 ? undefined : r.stderr.trim() || 'Could not create the scheduled task.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const r = await schtasks(['/Delete', '/F', '/TN', TASK_NAME])
|
||||||
|
// A missing task ("cannot find") is success for our purposes — it's already gone.
|
||||||
|
const gone = r.code === 0 || /cannot find|does not exist/i.test(r.stderr)
|
||||||
|
return { enabled: gone ? false : true, error: gone ? undefined : r.stderr.trim() }
|
||||||
|
}
|
||||||
+73
-9
@@ -1,5 +1,6 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { mkdirSync } from 'fs'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
||||||
import {
|
import {
|
||||||
@@ -10,13 +11,17 @@ import {
|
|||||||
COOKIE_BROWSERS,
|
COOKIE_BROWSERS,
|
||||||
ACCENT_COLORS,
|
ACCENT_COLORS,
|
||||||
DEFAULT_DOWNLOAD_OPTIONS,
|
DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
|
isYtdlpUpdateChannel,
|
||||||
type Settings,
|
type Settings,
|
||||||
type DownloadOptions,
|
type DownloadOptions,
|
||||||
type SponsorBlockCategory
|
type SponsorBlockCategory
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
const DEFAULTS: Settings = {
|
const DEFAULTS: Settings = {
|
||||||
outputDir: '', // resolved to the OS Downloads folder on first read
|
// Both blank by default → downloads land in Documents\Video / Documents\Audio
|
||||||
|
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
|
||||||
|
videoDir: '',
|
||||||
|
audioDir: '',
|
||||||
defaultKind: 'video',
|
defaultKind: 'video',
|
||||||
defaultVideoQuality: 'Best available',
|
defaultVideoQuality: 'Best available',
|
||||||
defaultAudioQuality: 'Best (MP3)',
|
defaultAudioQuality: 'Best (MP3)',
|
||||||
@@ -31,12 +36,21 @@ const DEFAULTS: Settings = {
|
|||||||
useAria2c: false,
|
useAria2c: false,
|
||||||
cookieSource: 'none',
|
cookieSource: 'none',
|
||||||
cookiesBrowser: 'chrome',
|
cookiesBrowser: 'chrome',
|
||||||
|
youtubePlayerClient: '',
|
||||||
|
youtubePoToken: '',
|
||||||
restrictFilenames: false,
|
restrictFilenames: false,
|
||||||
downloadArchive: false,
|
downloadArchive: false,
|
||||||
|
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
|
||||||
|
// nightly channel (fastest to follow YouTube changes that cause 403s).
|
||||||
|
autoUpdateYtdlp: true,
|
||||||
|
ytdlpChannel: 'nightly',
|
||||||
|
ytdlpLastUpdateCheck: 0,
|
||||||
customCommandEnabled: false,
|
customCommandEnabled: false,
|
||||||
defaultTemplateId: null,
|
defaultTemplateId: null,
|
||||||
notifyOnComplete: true,
|
notifyOnComplete: true,
|
||||||
hasCompletedOnboarding: false
|
autoDownloadNew: true,
|
||||||
|
hasCompletedOnboarding: false,
|
||||||
|
minimizeToTray: false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fixed path for the --download-archive file; not user-configurable. */
|
/** Fixed path for the --download-archive file; not user-configurable. */
|
||||||
@@ -44,6 +58,31 @@ export function getDownloadArchivePath(): string {
|
|||||||
return join(app.getPath('userData'), 'download-archive.txt')
|
return join(app.getPath('userData'), 'download-archive.txt')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default per-kind download destination: video → Documents\Video,
|
||||||
|
* audio → Documents\Audio. Used when the user hasn't set an explicit output
|
||||||
|
* folder (Settings → Download folder), so downloads are sorted by type.
|
||||||
|
*/
|
||||||
|
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
|
||||||
|
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the Documents\Video and Documents\Audio folders up front (called once
|
||||||
|
* at startup) so they exist the moment the app opens, not just after the first
|
||||||
|
* download. Best-effort: yt-dlp also creates the output dir at download time, so
|
||||||
|
* a failure here (read-only Documents, redirected folder) is non-fatal.
|
||||||
|
*/
|
||||||
|
export function ensureMediaDirs(): void {
|
||||||
|
for (const kind of ['video', 'audio'] as const) {
|
||||||
|
try {
|
||||||
|
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
|
||||||
|
} catch {
|
||||||
|
/* non-fatal — the download path will be created on demand instead */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
||||||
// defaults for any missing/invalid field. Used both to migrate older settings
|
// defaults for any missing/invalid field. Used both to migrate older settings
|
||||||
// files (which predate downloadOptions) and to validate renderer writes.
|
// files (which predate downloadOptions) and to validate renderer writes.
|
||||||
@@ -65,6 +104,7 @@ function sanitizeOptions(input: unknown): DownloadOptions {
|
|||||||
preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never)
|
preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never)
|
||||||
? o.preferredVideoCodec!
|
? o.preferredVideoCodec!
|
||||||
: d.preferredVideoCodec,
|
: d.preferredVideoCodec,
|
||||||
|
formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort,
|
||||||
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
|
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
|
||||||
subtitleLanguages:
|
subtitleLanguages:
|
||||||
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
|
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
|
||||||
@@ -75,9 +115,13 @@ function sanitizeOptions(input: unknown): DownloadOptions {
|
|||||||
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
|
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
|
||||||
sponsorBlockCategories: cats,
|
sponsorBlockCategories: cats,
|
||||||
embedChapters: bool(o.embedChapters, d.embedChapters),
|
embedChapters: bool(o.embedChapters, d.embedChapters),
|
||||||
|
splitChapters: bool(o.splitChapters, d.splitChapters),
|
||||||
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
|
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
|
||||||
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
|
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
|
||||||
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail)
|
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
|
||||||
|
writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson),
|
||||||
|
writeThumbnailFile: bool(o.writeThumbnailFile, d.writeThumbnailFile),
|
||||||
|
writeDescription: bool(o.writeDescription, d.writeDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +140,9 @@ export function getSettings(): Settings {
|
|||||||
// `set`, so only write when something actually changed — otherwise this churns
|
// `set`, so only write when something actually changed — otherwise this churns
|
||||||
// the settings file on every read. (audit P1)
|
// the settings file on every read. (audit P1)
|
||||||
const cur = s.store
|
const cur = s.store
|
||||||
if (!cur.outputDir) {
|
// videoDir/audioDir are intentionally left blank by default — an empty value
|
||||||
// Fill in the real Downloads path the first time, and persist it once.
|
// routes that kind into Documents\Video / Documents\Audio (see buildCommand).
|
||||||
s.set('outputDir', app.getPath('downloads'))
|
// Only an explicit user choice (Settings → folders) overrides that.
|
||||||
}
|
|
||||||
// Migrate settings files that predate downloadOptions (or hold a partial one),
|
// Migrate settings files that predate downloadOptions (or hold a partial one),
|
||||||
// but only persist when sanitizing actually altered the stored value.
|
// but only persist when sanitizing actually altered the stored value.
|
||||||
const sanitized = sanitizeOptions(cur.downloadOptions)
|
const sanitized = sanitizeOptions(cur.downloadOptions)
|
||||||
@@ -161,11 +204,25 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
|||||||
case 'useAria2c':
|
case 'useAria2c':
|
||||||
case 'restrictFilenames':
|
case 'restrictFilenames':
|
||||||
case 'downloadArchive':
|
case 'downloadArchive':
|
||||||
|
case 'autoUpdateYtdlp':
|
||||||
case 'customCommandEnabled':
|
case 'customCommandEnabled':
|
||||||
case 'notifyOnComplete':
|
case 'notifyOnComplete':
|
||||||
|
case 'autoDownloadNew':
|
||||||
case 'hasCompletedOnboarding':
|
case 'hasCompletedOnboarding':
|
||||||
|
case 'minimizeToTray':
|
||||||
if (typeof value === 'boolean') s.set(key, value)
|
if (typeof value === 'boolean') s.set(key, value)
|
||||||
break
|
break
|
||||||
|
case 'ytdlpChannel':
|
||||||
|
// Same allowlist that guards the `--update-to` flag (audit F1).
|
||||||
|
if (isYtdlpUpdateChannel(value)) s.set('ytdlpChannel', value)
|
||||||
|
break
|
||||||
|
case 'ytdlpLastUpdateCheck': {
|
||||||
|
// Set by the main-process auto-updater; validated here since setSettings
|
||||||
|
// is the only writer. A bogus value at worst skips/forces one check.
|
||||||
|
const n = Number(value)
|
||||||
|
if (Number.isFinite(n) && n >= 0) s.set('ytdlpLastUpdateCheck', n)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'defaultTemplateId':
|
case 'defaultTemplateId':
|
||||||
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
|
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
|
||||||
break
|
break
|
||||||
@@ -182,8 +239,13 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
|||||||
// group (it merges field changes locally before calling setSettings).
|
// group (it merges field changes locally before calling setSettings).
|
||||||
s.set('downloadOptions', sanitizeOptions(value))
|
s.set('downloadOptions', sanitizeOptions(value))
|
||||||
break
|
break
|
||||||
case 'outputDir':
|
case 'videoDir':
|
||||||
if (typeof value === 'string' && isSafeOutputDir(value.trim())) s.set('outputDir', value.trim())
|
case 'audioDir':
|
||||||
|
// An empty string is allowed — it clears the override and restores the
|
||||||
|
// Documents\Video / Documents\Audio default for that kind.
|
||||||
|
if (typeof value === 'string' && (value.trim() === '' || isSafeOutputDir(value.trim()))) {
|
||||||
|
s.set(key, value.trim())
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case 'filenameTemplate':
|
case 'filenameTemplate':
|
||||||
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
|
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
|
||||||
@@ -196,6 +258,8 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
|||||||
// and exported by exportBackup as-is — documented, not masked.
|
// and exported by exportBackup as-is — documented, not masked.
|
||||||
case 'proxy':
|
case 'proxy':
|
||||||
case 'rateLimit':
|
case 'rateLimit':
|
||||||
|
case 'youtubePlayerClient':
|
||||||
|
case 'youtubePoToken':
|
||||||
if (typeof value === 'string') s.set(key, value)
|
if (typeof value === 'string') s.set(key, value)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Persistence for the media-manager index (Pinchflat-style; see
|
||||||
|
* ROADMAP-PINCHFLAT.md). Two plain-JSON stores in userData, mirroring the
|
||||||
|
* history.ts pattern (and the same deliberate choice to stay on JSON rather than
|
||||||
|
* better-sqlite3 — revisit if a user indexes many large channels, see the
|
||||||
|
* Phase H risk note in the roadmap):
|
||||||
|
*
|
||||||
|
* sources.json — one Source record per added channel/playlist
|
||||||
|
* media-items.json — every MediaItem across all sources (queried by sourceId)
|
||||||
|
*
|
||||||
|
* Per-row validation on read (validation.ts) so a hand-edited or corrupted file
|
||||||
|
* can't feed the UI malformed records (same approach as history/errorlog).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||||
|
import type { Source, MediaItem } from '@shared/ipc'
|
||||||
|
import { isValidSource, isValidMediaItem } from './validation'
|
||||||
|
import { mergeItemsPreservingState } from './indexerCore'
|
||||||
|
|
||||||
|
// A generous global cap so a runaway index can't grow the file unbounded; large
|
||||||
|
// enough for several big channels. When exceeded, the most-recently-written
|
||||||
|
// source's items are kept (they're placed first by replaceMediaItems).
|
||||||
|
const MAX_ITEMS = 20000
|
||||||
|
|
||||||
|
function sourcesFile(): string {
|
||||||
|
return join(app.getPath('userData'), 'sources.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemsFile(): string {
|
||||||
|
return join(app.getPath('userData'), 'media-items.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonArray<T>(path: string, isValid: (o: unknown) => o is T): T[] {
|
||||||
|
try {
|
||||||
|
if (!existsSync(path)) return []
|
||||||
|
const data = JSON.parse(readFileSync(path, 'utf8'))
|
||||||
|
return Array.isArray(data) ? data.filter(isValid) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(path: string, value: unknown): void {
|
||||||
|
try {
|
||||||
|
writeFileSync(path, JSON.stringify(value, null, 2))
|
||||||
|
} catch {
|
||||||
|
/* best-effort; a read-only data dir just means no persisted index */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sources ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export function listSources(): Source[] {
|
||||||
|
return readJsonArray(sourcesFile(), isValidSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSource(id: string): Source | undefined {
|
||||||
|
return listSources().find((s) => s.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert or replace a source by id (a re-index updates the existing record). */
|
||||||
|
export function upsertSource(source: Source): Source[] {
|
||||||
|
const sources = [source, ...listSources().filter((s) => s.id !== source.id)]
|
||||||
|
writeJson(sourcesFile(), sources)
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle whether a source is watched for new uploads (Phase J). Returns all sources. */
|
||||||
|
export function setSourceWatched(id: string, watched: boolean): Source[] {
|
||||||
|
const sources = listSources().map((s) => (s.id === id ? { ...s, watched } : s))
|
||||||
|
writeJson(sourcesFile(), sources)
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a source and all of its media items. Returns the remaining sources. */
|
||||||
|
export function removeSource(id: string): Source[] {
|
||||||
|
const sources = listSources().filter((s) => s.id !== id)
|
||||||
|
writeJson(sourcesFile(), sources)
|
||||||
|
const items = listAllItems().filter((m) => m.sourceId !== id)
|
||||||
|
writeJson(itemsFile(), items)
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Media items ------------------------------------------------------------
|
||||||
|
|
||||||
|
function listAllItems(): MediaItem[] {
|
||||||
|
return readJsonArray(itemsFile(), isValidMediaItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMediaItems(sourceId: string): MediaItem[] {
|
||||||
|
return listAllItems().filter((m) => m.sourceId === sourceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace all media items for one source with a fresh set (the result of a
|
||||||
|
* (re)index). Other sources' items are preserved; the new items are placed first
|
||||||
|
* so they survive the MAX_ITEMS cap.
|
||||||
|
*/
|
||||||
|
export function replaceMediaItems(sourceId: string, items: MediaItem[]): void {
|
||||||
|
const others = listAllItems().filter((m) => m.sourceId !== sourceId)
|
||||||
|
writeJson(itemsFile(), [...items, ...others].slice(0, MAX_ITEMS))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incrementally merge a freshly-indexed item list into the persisted store,
|
||||||
|
* preserving the downloaded state of videos already on disk (see Phase I). The
|
||||||
|
* fresh list defines current membership/order; returns the merged items and how
|
||||||
|
* many were new since the last index.
|
||||||
|
*/
|
||||||
|
export function mergeMediaItems(
|
||||||
|
sourceId: string,
|
||||||
|
fresh: MediaItem[]
|
||||||
|
): { items: MediaItem[]; newCount: number } {
|
||||||
|
const result = mergeItemsPreservingState(listMediaItems(sourceId), fresh)
|
||||||
|
replaceMediaItems(sourceId, result.items)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark one media item downloaded, recording its file path + time. Returns the
|
||||||
|
* updated list for that item's source (or [] if the id is unknown). Used by the
|
||||||
|
* library view once a queued item completes (Phase H/I).
|
||||||
|
*/
|
||||||
|
export function setMediaItemDownloaded(id: string, filePath?: string): MediaItem[] {
|
||||||
|
const all = listAllItems()
|
||||||
|
const target = all.find((m) => m.id === id)
|
||||||
|
if (!target) return []
|
||||||
|
const updated = all.map((m) =>
|
||||||
|
m.id === id ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
|
||||||
|
)
|
||||||
|
writeJson(itemsFile(), updated)
|
||||||
|
return updated.filter((m) => m.sourceId === target.sourceId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Watched-source sync (Pinchflat-style; ROADMAP-PINCHFLAT.md Phase J). Re-indexes
|
||||||
|
* every watched Source and reports the videos that are new since its last index.
|
||||||
|
* A YouTube RSS feed is used as a cheap pre-check so a source with no new uploads
|
||||||
|
* is skipped before the (more expensive) full `yt-dlp` re-index.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { listSources, listMediaItems } from './sources'
|
||||||
|
import { indexSource } from './indexer'
|
||||||
|
import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore'
|
||||||
|
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc'
|
||||||
|
|
||||||
|
/** Fetch a YouTube RSS feed and return its recent video ids. Throws on failure. */
|
||||||
|
async function fetchFeedIds(feedUrl: string): Promise<string[]> {
|
||||||
|
// Only ever fetch a genuine YouTube feed — refuse an arbitrary host that a
|
||||||
|
// corrupted sources.json might carry (SSRF guard, audit T7). A throw here is
|
||||||
|
// caught by the caller, which then falls back to a full yt-dlp re-index.
|
||||||
|
if (!isYouTubeFeedUrl(feedUrl)) throw new Error('Refusing to fetch a non-YouTube feed URL.')
|
||||||
|
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(15_000) })
|
||||||
|
if (!res.ok) throw new Error(`feed responded ${res.status}`)
|
||||||
|
return parseRssVideoIds(await res.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-index every watched source and collect the videos new since the last index.
|
||||||
|
* RSS pre-check: if the feed shows only ids the source already knows, the full
|
||||||
|
* re-index is skipped. `onProgress` is forwarded from the underlying indexSource.
|
||||||
|
*/
|
||||||
|
export async function syncWatchedSources(
|
||||||
|
onProgress: (p: IndexProgress) => void
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
try {
|
||||||
|
const watched = listSources().filter((s) => s.watched)
|
||||||
|
const newItems: MediaItem[] = []
|
||||||
|
for (const src of watched) {
|
||||||
|
// Cheap freshness check — skip the full re-index when nothing is new.
|
||||||
|
if (src.feedUrl) {
|
||||||
|
const known = new Set(listMediaItems(src.id).map((m) => m.videoId))
|
||||||
|
const recent = await fetchFeedIds(src.feedUrl).catch(() => null)
|
||||||
|
if (recent && recent.length > 0 && recent.every((id) => known.has(id))) continue
|
||||||
|
}
|
||||||
|
const before = new Set(listMediaItems(src.id).map((m) => m.videoId))
|
||||||
|
const res = await indexSource(src.url, onProgress)
|
||||||
|
if (res.ok) {
|
||||||
|
for (const it of listMediaItems(src.id)) {
|
||||||
|
if (!before.has(it.videoId)) newItems.push(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, newItems }
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, newItems: [], error: e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,10 +36,13 @@ function save(templates: CommandTemplate[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sanitize(t: CommandTemplate): CommandTemplate {
|
function sanitize(t: CommandTemplate): CommandTemplate {
|
||||||
|
const urlPattern = typeof t.urlPattern === 'string' ? t.urlPattern.trim() : ''
|
||||||
return {
|
return {
|
||||||
id: String(t.id),
|
id: String(t.id),
|
||||||
name: (t.name ?? '').trim() || 'Untitled command',
|
name: (t.name ?? '').trim() || 'Untitled command',
|
||||||
args: (t.args ?? '').trim()
|
args: (t.args ?? '').trim(),
|
||||||
|
// Only persist a urlPattern when one was provided, keeping older files clean.
|
||||||
|
...(urlPattern ? { urlPattern } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Built-in yt-dlp terminal (Phase N): runs the bundled yt-dlp with raw, user-typed
|
||||||
|
* arguments and streams stdout/stderr back to the renderer line-by-line.
|
||||||
|
*
|
||||||
|
* SECURITY: the executable is fixed to the managed yt-dlp (never an arbitrary exe),
|
||||||
|
* and the whole feature is gated on Settings.customCommandEnabled — the same consent
|
||||||
|
* flag that guards per-download extra args, because yt-dlp args can run arbitrary
|
||||||
|
* code (`--exec`). The gate is enforced here in main, not just in the renderer UI.
|
||||||
|
*/
|
||||||
|
import { spawn, execFile, type ChildProcess } from 'child_process'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { type WebContents } from 'electron'
|
||||||
|
import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
|
||||||
|
import { getSettings } from './settings'
|
||||||
|
import { ensureManagedYtdlp } from './ytdlp'
|
||||||
|
import { parseExtraArgs } from './buildArgs'
|
||||||
|
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
|
||||||
|
|
||||||
|
const active = new Map<string, ChildProcess>()
|
||||||
|
|
||||||
|
function send(wc: WebContents, ev: TerminalEvent): void {
|
||||||
|
if (!wc.isDestroyed()) wc.send(IpcChannels.terminalOutput, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTerminal(wc: WebContents, id: string, argsRaw: string): TerminalRunResult {
|
||||||
|
if (!getSettings().customCommandEnabled) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensureManagedYtdlp()
|
||||||
|
const ytdlp = getYtdlpPath()
|
||||||
|
if (!existsSync(ytdlp)) {
|
||||||
|
return { ok: false, error: 'yt-dlp.exe is missing and could not be restored.' }
|
||||||
|
}
|
||||||
|
if (active.has(id)) return { ok: false, error: 'A command is already running.' }
|
||||||
|
|
||||||
|
// Always pin ffmpeg so post-processing works; the rest is whatever the user typed.
|
||||||
|
const args = ['--ffmpeg-location', getBinDir(), ...parseExtraArgs(argsRaw)]
|
||||||
|
let child: ChildProcess
|
||||||
|
try {
|
||||||
|
child = spawn(ytdlp, args, { windowsHide: true })
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: (e as Error).message }
|
||||||
|
}
|
||||||
|
active.set(id, child)
|
||||||
|
|
||||||
|
// Buffer each stream and emit on newline boundaries (flush the remainder on close).
|
||||||
|
const buffers: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }
|
||||||
|
function feed(stream: 'stdout' | 'stderr', chunk: string): void {
|
||||||
|
buffers[stream] += chunk
|
||||||
|
let nl: number
|
||||||
|
while ((nl = buffers[stream].indexOf('\n')) >= 0) {
|
||||||
|
const line = buffers[stream].slice(0, nl).replace(/\r$/, '')
|
||||||
|
buffers[stream] = buffers[stream].slice(nl + 1)
|
||||||
|
send(wc, { type: 'output', id, line, stream })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.stdout?.on('data', (c: Buffer) => feed('stdout', c.toString()))
|
||||||
|
child.stderr?.on('data', (c: Buffer) => feed('stderr', c.toString()))
|
||||||
|
|
||||||
|
let settled = false
|
||||||
|
child.on('error', (err) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
active.delete(id)
|
||||||
|
send(wc, { type: 'error', id, error: err.message })
|
||||||
|
})
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
active.delete(id)
|
||||||
|
// Flush any trailing partial lines that never hit a newline.
|
||||||
|
for (const stream of ['stdout', 'stderr'] as const) {
|
||||||
|
if (buffers[stream]) send(wc, { type: 'output', id, line: buffers[stream], stream })
|
||||||
|
}
|
||||||
|
send(wc, { type: 'done', id, code })
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelTerminal(id: string): void {
|
||||||
|
const child = active.get(id)
|
||||||
|
if (!child) return
|
||||||
|
const pid = child.pid
|
||||||
|
if (pid != null) {
|
||||||
|
// Tree-kill via System32 taskkill by absolute path (same hardening as download.ts).
|
||||||
|
execFile(
|
||||||
|
getSystem32Path('taskkill.exe'),
|
||||||
|
['/pid', String(pid), '/T', '/F'],
|
||||||
|
{ windowsHide: true },
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
child.kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* System tray + minimize-to-tray (Phase O). When Settings.minimizeToTray is on,
|
||||||
|
* closing the window hides it to the tray instead of quitting; the tray's "Quit"
|
||||||
|
* really exits. The tray gives the app a background presence (a natural home for
|
||||||
|
* the watched-source sync) and a quick show/quit menu.
|
||||||
|
*/
|
||||||
|
import { app, Tray, Menu, nativeImage, type BrowserWindow } from 'electron'
|
||||||
|
import { getAppIconPath } from './binaries'
|
||||||
|
|
||||||
|
let tray: Tray | null = null
|
||||||
|
// True once the user has chosen to really quit (tray menu, or app.quit from the
|
||||||
|
// updater) — lets the window 'close' handler tell "hide to tray" from "exit".
|
||||||
|
let quitting = false
|
||||||
|
|
||||||
|
export function isQuitting(): boolean {
|
||||||
|
return quitting
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markQuitting(): void {
|
||||||
|
quitting = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(getWindow: () => BrowserWindow | null): void {
|
||||||
|
const win = getWindow()
|
||||||
|
if (!win) return
|
||||||
|
if (win.isMinimized()) win.restore()
|
||||||
|
win.show()
|
||||||
|
win.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the tray icon + menu once. No-op if it already exists or the icon is missing. */
|
||||||
|
export function createTray(getWindow: () => BrowserWindow | null): void {
|
||||||
|
if (tray) return
|
||||||
|
const icon = nativeImage.createFromPath(getAppIconPath())
|
||||||
|
// Without a usable icon a Windows tray entry is invisible/unclickable, which is
|
||||||
|
// worse than no tray — so skip it rather than ship a dead tray.
|
||||||
|
if (icon.isEmpty()) return
|
||||||
|
|
||||||
|
tray = new Tray(icon)
|
||||||
|
tray.setToolTip('AeroFetch')
|
||||||
|
tray.setContextMenu(
|
||||||
|
Menu.buildFromTemplate([
|
||||||
|
{ label: 'Show AeroFetch', click: () => show(getWindow) },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Quit AeroFetch',
|
||||||
|
click: () => {
|
||||||
|
quitting = true
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
tray.on('click', () => show(getWindow))
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import { app, net, shell, type WebContents } from 'electron'
|
||||||
|
import { createWriteStream, type WriteStream } from 'fs'
|
||||||
|
import { stat, unlink } from 'fs/promises'
|
||||||
|
import { join, normalize, dirname } from 'path'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import {
|
||||||
|
IpcChannels,
|
||||||
|
type AppUpdateInfo,
|
||||||
|
type AppUpdateDownload,
|
||||||
|
type AppUpdateProgress
|
||||||
|
} from '@shared/ipc'
|
||||||
|
|
||||||
|
// --- Update source -----------------------------------------------------------
|
||||||
|
// The Gitea repo whose Releases host the AeroFetch installers. The updater reads
|
||||||
|
// the repo's latest release over the public REST API and downloads the installer
|
||||||
|
// asset attached to it.
|
||||||
|
//
|
||||||
|
// IMPORTANT: this points at a repo whose releases must be ANONYMOUSLY readable —
|
||||||
|
// i.e. a public repo on a Gitea instance that permits anonymous API + downloads.
|
||||||
|
// AeroFetch never ships a token; on a sign-in-required instance the check simply
|
||||||
|
// reports that it couldn't reach the server. Change OWNER/REPO to retarget.
|
||||||
|
const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
|
||||||
|
const UPDATE_OWNER = 'debont80'
|
||||||
|
const UPDATE_REPO = 'AeroFetch'
|
||||||
|
const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
|
||||||
|
|
||||||
|
// Security: only ever download or execute a file served by the trusted update
|
||||||
|
// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects
|
||||||
|
// release downloads to its attachment store — so this is re-checked on EVERY
|
||||||
|
// redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd
|
||||||
|
// response from bouncing us off the trusted host.
|
||||||
|
export function isTrustedDownloadUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url)
|
||||||
|
return u.protocol === 'https:' && u.host === new URL(UPDATE_HOST).host
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where downloaded installers are written (and the only dir we'll run one from). */
|
||||||
|
function updateDir(): string {
|
||||||
|
return app.getPath('temp')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse 'v1.2.3' / '1.2.3-rc.1' into the comparable numeric components of the
|
||||||
|
* release CORE — the x.y.z before any '-prerelease' or '+build' suffix. Dropping
|
||||||
|
* the suffix means a prerelease (e.g. 0.5.0-rc.1) never sorts ABOVE its final
|
||||||
|
* release (0.5.0): at most it compares equal, so we won't offer a prerelease as
|
||||||
|
* an "upgrade" over the same shipped version.
|
||||||
|
*/
|
||||||
|
function parseVersion(v: string): number[] {
|
||||||
|
return v
|
||||||
|
.replace(/^v/i, '')
|
||||||
|
.split(/[-+]/)[0]
|
||||||
|
.split('.')
|
||||||
|
.map((p) => parseInt(p, 10))
|
||||||
|
.filter((n) => !Number.isNaN(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Component-wise numeric compare: -1 if a<b, 0 if equal, 1 if a>b. */
|
||||||
|
export function compareVersions(a: string, b: string): number {
|
||||||
|
const pa = parseVersion(a)
|
||||||
|
const pb = parseVersion(b)
|
||||||
|
const len = Math.max(pa.length, pb.length)
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const x = pa[i] ?? 0
|
||||||
|
const y = pb[i] ?? 0
|
||||||
|
if (x !== y) return x < y ? -1 : 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaAsset {
|
||||||
|
name: string
|
||||||
|
browser_download_url: string
|
||||||
|
}
|
||||||
|
interface GiteaRelease {
|
||||||
|
tag_name: string
|
||||||
|
body?: string
|
||||||
|
html_url?: string
|
||||||
|
assets?: GiteaAsset[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the SHA-256 hex out of a checksum file — a bare digest, `sha256sum`
|
||||||
|
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the
|
||||||
|
* lowercase digest, or null if there's no standalone 64-char hex token (so a
|
||||||
|
* longer run like a sha512 digest is ignored rather than sliced).
|
||||||
|
*/
|
||||||
|
export function extractSha256(text: string): string | null {
|
||||||
|
const m = text.match(/\b[a-f0-9]{64}\b/i)
|
||||||
|
return m ? m[0].toLowerCase() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */
|
||||||
|
function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined {
|
||||||
|
const exes = assets.filter((a) => /\.exe$/i.test(a.name))
|
||||||
|
return exes.find((a) => /setup/i.test(a.name)) ?? exes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query the configured repo's latest release and compare it to the running app. */
|
||||||
|
export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
|
||||||
|
const currentVersion = app.getVersion()
|
||||||
|
// Bound the check so a stalled/unreachable server can't hang the UI spinner
|
||||||
|
// indefinitely — mirrors the timeouts on the yt-dlp calls.
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), 15_000)
|
||||||
|
try {
|
||||||
|
const res = await fetch(RELEASE_API, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const auth = res.status === 401 || res.status === 403 || res.status === 404
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
available: false,
|
||||||
|
currentVersion,
|
||||||
|
error: auth
|
||||||
|
? 'Could not reach the update server (it may require sign-in for anonymous access).'
|
||||||
|
: `Update check failed (HTTP ${res.status}).`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rel = (await res.json()) as GiteaRelease
|
||||||
|
const latestVersion = (rel.tag_name ?? '').replace(/^v/i, '')
|
||||||
|
const asset = pickInstaller(rel.assets ?? [])
|
||||||
|
const available = latestVersion !== '' && compareVersions(latestVersion, currentVersion) > 0
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
available,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
notes: rel.body?.trim() || undefined,
|
||||||
|
// Only ever hand the OS browser a release page on our own trusted host.
|
||||||
|
htmlUrl: rel.html_url && isTrustedDownloadUrl(rel.html_url) ? rel.html_url : undefined,
|
||||||
|
downloadUrl: asset?.browser_download_url,
|
||||||
|
assetName: asset?.name
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const timedOut = e instanceof Error && e.name === 'AbortError'
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
available: false,
|
||||||
|
currentVersion,
|
||||||
|
error: timedOut
|
||||||
|
? 'Update check timed out — the server took too long to respond.'
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: String(e)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long the download may stall (no bytes received) before we give up. */
|
||||||
|
const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
|
// Integrity: every release MUST attach a checksum asset named `<installer>.sha256`
|
||||||
|
// (e.g. AeroFetch-Setup-0.5.0.exe.sha256) whose contents include the installer's
|
||||||
|
// lowercase SHA-256 hex — bare, or in `sha256sum`/`Get-FileHash` form. We refuse
|
||||||
|
// to run an installer we can't verify against it. Note this is INTEGRITY +
|
||||||
|
// defence-in-depth (corruption, truncation, tampering-after-publish, a redirect
|
||||||
|
// bounced to other storage), NOT protection against a fully compromised host —
|
||||||
|
// that host serves both files, so only Authenticode signing defends against it.
|
||||||
|
const REQUIRE_CHECKSUM: boolean = true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET a small text resource from the trusted host, re-validating the host on
|
||||||
|
* every redirect hop (same discipline as the installer download) and capping the
|
||||||
|
* body so a hostile/huge response can't exhaust memory. Used for the .sha256 file.
|
||||||
|
*/
|
||||||
|
function fetchTrustedText(
|
||||||
|
url: string,
|
||||||
|
maxBytes = 64 * 1024,
|
||||||
|
timeoutMs = 15_000
|
||||||
|
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const done = (r: { ok: true; text: string } | { ok: false; status?: number; error: string }): void => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (!r.ok) request.abort()
|
||||||
|
resolve(r)
|
||||||
|
}
|
||||||
|
const request = net.request({ url, redirect: 'manual' })
|
||||||
|
const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
|
||||||
|
request.on('redirect', (_s, _m, redirectUrl) => {
|
||||||
|
if (!isTrustedDownloadUrl(redirectUrl)) {
|
||||||
|
done({ ok: false, error: 'redirect to an untrusted location' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.followRedirect()
|
||||||
|
})
|
||||||
|
request.on('response', (response) => {
|
||||||
|
const status = response.statusCode
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
done({ ok: false, status, error: `HTTP ${status}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
let size = 0
|
||||||
|
response.on('data', (c: Buffer) => {
|
||||||
|
size += c.length
|
||||||
|
if (size > maxBytes) done({ ok: false, error: 'checksum file too large' })
|
||||||
|
else chunks.push(c)
|
||||||
|
})
|
||||||
|
response.on('end', () => done({ ok: true, text: Buffer.concat(chunks).toString('utf8') }))
|
||||||
|
response.on('aborted', () => done({ ok: false, error: 'interrupted' }))
|
||||||
|
response.on('error', (e: Error) => done({ ok: false, error: e.message }))
|
||||||
|
})
|
||||||
|
request.on('error', (e: Error) => done({ ok: false, error: e.message }))
|
||||||
|
request.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stream the installer to a temp file, pushing progress events to the renderer. */
|
||||||
|
export async function downloadAppUpdate(url: string, wc: WebContents): Promise<AppUpdateDownload> {
|
||||||
|
if (!isTrustedDownloadUrl(url)) {
|
||||||
|
return { ok: false, error: 'Refused to download from an untrusted location.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive a safe .exe filename from the URL; fall back to a fixed name.
|
||||||
|
const urlName = decodeURIComponent(new URL(url).pathname.split('/').pop() || '')
|
||||||
|
const safeName = /^[\w.\- ]+\.exe$/i.test(urlName) ? urlName : 'AeroFetch-Setup.exe'
|
||||||
|
const filePath = join(updateDir(), safeName)
|
||||||
|
|
||||||
|
// Resolve the published checksum up front (tiny, fails fast) so we never pull a
|
||||||
|
// ~300 MB installer we'd then refuse to run. The .sha256 sits next to the asset,
|
||||||
|
// so its URL is the installer URL + '.sha256' — still on the host-pinned origin.
|
||||||
|
const checksumUrl = (() => {
|
||||||
|
const u = new URL(url)
|
||||||
|
u.pathname += '.sha256'
|
||||||
|
return u.toString()
|
||||||
|
})()
|
||||||
|
const sum = await fetchTrustedText(checksumUrl)
|
||||||
|
let expectedSha: string | null = null
|
||||||
|
if (sum.ok) {
|
||||||
|
expectedSha = extractSha256(sum.text)
|
||||||
|
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
|
||||||
|
} else if (sum.status === 404) {
|
||||||
|
if (REQUIRE_CHECKSUM) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `This release has no checksum (${safeName}.sha256) attached — refusing to install an unverified update.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return { ok: false, error: `Couldn't fetch the update's checksum — ${sum.error}.` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<AppUpdateDownload>((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
let fileStream: WriteStream | null = null
|
||||||
|
let idle: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
// Single teardown point. On failure it also aborts the request, so a write
|
||||||
|
// error (disk full, etc.) can't leave Electron pulling bytes into a dead
|
||||||
|
// stream. abort() is idempotent, so the call sites below don't repeat it.
|
||||||
|
const finish = (result: AppUpdateDownload): void => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
if (idle) clearTimeout(idle)
|
||||||
|
if (!result.ok) {
|
||||||
|
request.abort()
|
||||||
|
// Close the handle before unlinking (Windows won't delete an open file)
|
||||||
|
// and never leave a partial/aborted installer lying around in temp.
|
||||||
|
if (fileStream && !fileStream.destroyed) fileStream.destroy()
|
||||||
|
unlink(filePath).catch(() => {})
|
||||||
|
}
|
||||||
|
resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// net.request (not fetch) so we can re-validate the host on EVERY redirect
|
||||||
|
// hop: Gitea 302s a release download to its attachment store, and undici's
|
||||||
|
// fetch hides the Location header under redirect:'manual', so it can't gate
|
||||||
|
// hops. 'manual' here means a hop only proceeds if we call followRedirect().
|
||||||
|
const request = net.request({ url, redirect: 'manual' })
|
||||||
|
|
||||||
|
const armIdle = (): void => {
|
||||||
|
if (idle) clearTimeout(idle)
|
||||||
|
idle = setTimeout(() => {
|
||||||
|
finish({ ok: false, error: 'Download stalled — please try again.' })
|
||||||
|
}, DOWNLOAD_IDLE_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.on('redirect', (_status, _method, redirectUrl) => {
|
||||||
|
if (!isTrustedDownloadUrl(redirectUrl)) {
|
||||||
|
finish({ ok: false, error: 'Refused to follow a redirect to an untrusted location.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.followRedirect()
|
||||||
|
})
|
||||||
|
|
||||||
|
request.on('response', (response) => {
|
||||||
|
const status = response.statusCode
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
finish({ ok: false, error: `Download failed (HTTP ${status}).` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lenHeader = response.headers['content-length']
|
||||||
|
const total = Number(Array.isArray(lenHeader) ? lenHeader[0] : lenHeader) || undefined
|
||||||
|
|
||||||
|
const stream = createWriteStream(filePath)
|
||||||
|
fileStream = stream
|
||||||
|
// Hash the bytes as they stream by, so verification needs no second pass.
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
// Electron's IncomingMessage is event-based (no .pipe). pause()/resume()
|
||||||
|
// exist at runtime but aren't in its type, so feature-detect them to apply
|
||||||
|
// backpressure — without it a ~300 MB installer can buffer in memory.
|
||||||
|
const flow = response as unknown as { pause?(): void; resume?(): void }
|
||||||
|
let received = 0
|
||||||
|
armIdle()
|
||||||
|
|
||||||
|
response.on('data', (chunk: Buffer) => {
|
||||||
|
armIdle()
|
||||||
|
received += chunk.length
|
||||||
|
hash.update(chunk)
|
||||||
|
if (!stream.write(chunk) && flow.pause && flow.resume) {
|
||||||
|
flow.pause()
|
||||||
|
stream.once('drain', () => flow.resume?.())
|
||||||
|
}
|
||||||
|
if (!wc.isDestroyed()) {
|
||||||
|
const progress: AppUpdateProgress = {
|
||||||
|
received,
|
||||||
|
total,
|
||||||
|
fraction: total ? received / total : undefined
|
||||||
|
}
|
||||||
|
wc.send(IpcChannels.appUpdateProgress, progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.on('end', () => {
|
||||||
|
// Body fully received — "stalled" is no longer meaningful past this point.
|
||||||
|
if (idle) clearTimeout(idle)
|
||||||
|
stream.end(() => {
|
||||||
|
// A truncated download (connection dropped mid-stream) must never be run.
|
||||||
|
if (total !== undefined && received !== total) {
|
||||||
|
finish({ ok: false, error: 'The download was incomplete — please try again.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Verify the bytes we wrote match the release's published SHA-256.
|
||||||
|
if (expectedSha && hash.digest('hex') !== expectedSha) {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finish({ ok: true, filePath })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// A mid-stream abort emits neither 'end' nor always 'error'; catch it so the
|
||||||
|
// promise can't hang.
|
||||||
|
response.on('aborted', () => finish({ ok: false, error: 'The download was interrupted.' }))
|
||||||
|
response.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
||||||
|
stream.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
||||||
|
})
|
||||||
|
|
||||||
|
request.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
||||||
|
request.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Let the launched installer spawn before we quit to release our files (ms). */
|
||||||
|
const INSTALLER_HANDOFF_MS = 1500
|
||||||
|
|
||||||
|
/** Launch a freshly-downloaded installer, then quit so it can replace the app. */
|
||||||
|
export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
// Defence in depth: only run a .exe that sits DIRECTLY in our own temp dir,
|
||||||
|
// never an arbitrary path handed over IPC. Compare the parent directory by
|
||||||
|
// equality — startsWith() is defeated by a sibling like `<temp>_evil\x.exe`.
|
||||||
|
const expectedDir = normalize(updateDir()).toLowerCase()
|
||||||
|
const target = normalize(filePath)
|
||||||
|
if (dirname(target).toLowerCase() !== expectedDir || !/\.exe$/i.test(target)) {
|
||||||
|
return { ok: false, error: 'Refused to run an unexpected file.' }
|
||||||
|
}
|
||||||
|
await stat(target) // throws if the file is missing
|
||||||
|
const err = await shell.openPath(target) // hand the installer to the OS
|
||||||
|
if (err) return { ok: false, error: err }
|
||||||
|
// Give the installer a beat to spawn, then quit so it can overwrite our files.
|
||||||
|
setTimeout(() => app.quit(), INSTALLER_HANDOFF_MS)
|
||||||
|
return { ok: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-3
@@ -10,17 +10,23 @@
|
|||||||
* (Call sites also pass `--` before the positional URL as defence in depth.)
|
* (Call sites also pass `--` before the positional URL as defence in depth.)
|
||||||
*
|
*
|
||||||
* Throws a user-friendly Error on anything that isn't an http(s) URL.
|
* Throws a user-friendly Error on anything that isn't an http(s) URL.
|
||||||
|
*
|
||||||
|
* Returns the parser-NORMALISED URL (`u.href`), not the raw input (audit F5).
|
||||||
|
* The WHATWG URL parser silently tolerates interior tab/newline characters and
|
||||||
|
* leading C0 control bytes (which `String.trim()` does not strip), so returning
|
||||||
|
* the raw string could hand a downstream consumer — argv, or the sign-in
|
||||||
|
* window's loadURL — a value subtly different from the one actually validated.
|
||||||
|
* Emitting `u.href` guarantees callers use exactly the URL that passed the check.
|
||||||
*/
|
*/
|
||||||
export function assertHttpUrl(raw: string): string {
|
export function assertHttpUrl(raw: string): string {
|
||||||
const trimmed = (raw ?? '').trim()
|
|
||||||
let u: URL
|
let u: URL
|
||||||
try {
|
try {
|
||||||
u = new URL(trimmed)
|
u = new URL((raw ?? '').trim())
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('That doesn’t look like a valid URL.')
|
throw new Error('That doesn’t look like a valid URL.')
|
||||||
}
|
}
|
||||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||||
throw new Error('Only http and https links are supported.')
|
throw new Error('Only http and https links are supported.')
|
||||||
}
|
}
|
||||||
return trimmed
|
return u.href
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-8
@@ -7,21 +7,27 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { isAbsolute } from 'path'
|
import { isAbsolute } from 'path'
|
||||||
import type { HistoryEntry, ErrorLogEntry, CommandTemplate } from '@shared/ipc'
|
import type { HistoryEntry, ErrorLogEntry, CommandTemplate, Source, MediaItem } from '@shared/ipc'
|
||||||
|
|
||||||
// --- Path-traversal sanitization (audit S4) ---------------------------------
|
// --- Path-traversal sanitization (audit S4) ---------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A filenameTemplate is joined onto the output directory and handed to yt-dlp's
|
* A filenameTemplate is joined onto the output directory and handed to yt-dlp's
|
||||||
* `-o`. Reject anything that could write outside that directory — an absolute
|
* `-o`. Reject anything that could write outside that directory so a malicious
|
||||||
* path, or any `..` path segment — so a malicious backup/settings write can't
|
* backup/settings write can't traverse out of the chosen folder (e.g.
|
||||||
* traverse out of the chosen folder (e.g. '%(title)s\..\..\win32.exe'). The
|
* '%(title)s\..\..\win32.exe'). Legitimate templates still contain yt-dlp
|
||||||
* template still legitimately contains yt-dlp `%(field)s` tokens and `/` or `\`
|
* `%(field)s` tokens and `/` or `\` for sub-folders, which are fine.
|
||||||
* for sub-folders, which are fine.
|
*
|
||||||
|
* Two Windows-specific bypasses are guarded beyond the obvious cases (audit T1):
|
||||||
|
* - drive-relative prefixes like 'C:foo' — `path.isAbsolute` returns FALSE for
|
||||||
|
* these, yet they escape the output dir, so a leading drive letter is rejected.
|
||||||
|
* - a '..' segment dressed up with trailing dots/spaces ('.. ', '.. .') —
|
||||||
|
* Windows silently trims those, so an exact `=== '..'` check would miss them.
|
||||||
*/
|
*/
|
||||||
|
const TRAVERSAL_SEGMENT = /^[. ]*\.\.[. ]*$/
|
||||||
export function isSafeFilenameTemplate(template: string): boolean {
|
export function isSafeFilenameTemplate(template: string): boolean {
|
||||||
if (isAbsolute(template)) return false
|
if (isAbsolute(template) || /^[a-zA-Z]:/.test(template)) return false
|
||||||
return !template.split(/[\\/]/).some((segment) => segment === '..')
|
return !template.split(/[\\/]/).some((segment) => TRAVERSAL_SEGMENT.test(segment))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An output directory must be an absolute path ('' resolves to OS Downloads). */
|
/** An output directory must be an absolute path ('' resolves to OS Downloads). */
|
||||||
@@ -75,3 +81,42 @@ export function isTemplateLike(o: unknown): o is CommandTemplate {
|
|||||||
const t = o as Record<string, unknown>
|
const t = o as Record<string, unknown>
|
||||||
return typeof t.id === 'string' || typeof t.id === 'number'
|
return typeof t.id === 'string' || typeof t.id === 'number'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A persisted sources.json row must have the right shape or it's dropped on read. */
|
||||||
|
export function isValidSource(o: unknown): o is Source {
|
||||||
|
if (!o || typeof o !== 'object') return false
|
||||||
|
const s = o as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
typeof s.id === 'string' &&
|
||||||
|
typeof s.url === 'string' &&
|
||||||
|
(s.kind === 'channel' || s.kind === 'playlist') &&
|
||||||
|
typeof s.title === 'string' &&
|
||||||
|
typeof s.addedAt === 'number' &&
|
||||||
|
typeof s.itemCount === 'number' &&
|
||||||
|
isOptionalString(s.channel) &&
|
||||||
|
// feedUrl drives a network fetch in the sync, so validate its shape here too
|
||||||
|
// (audit T7); the host is additionally restricted at the fetch boundary.
|
||||||
|
isOptionalString(s.feedUrl) &&
|
||||||
|
(s.watched === undefined || typeof s.watched === 'boolean') &&
|
||||||
|
(s.lastIndexedAt === undefined || typeof s.lastIndexedAt === 'number')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A persisted media-items.json row must have the right shape or it's dropped on read. */
|
||||||
|
export function isValidMediaItem(o: unknown): o is MediaItem {
|
||||||
|
if (!o || typeof o !== 'object') return false
|
||||||
|
const m = o as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
typeof m.id === 'string' &&
|
||||||
|
typeof m.sourceId === 'string' &&
|
||||||
|
typeof m.videoId === 'string' &&
|
||||||
|
typeof m.title === 'string' &&
|
||||||
|
typeof m.url === 'string' &&
|
||||||
|
typeof m.playlistTitle === 'string' &&
|
||||||
|
typeof m.playlistIndex === 'number' &&
|
||||||
|
typeof m.downloaded === 'boolean' &&
|
||||||
|
isOptionalString(m.durationLabel) &&
|
||||||
|
isOptionalString(m.filePath) &&
|
||||||
|
(m.downloadedAt === undefined || typeof m.downloadedAt === 'number')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+85
-3
@@ -1,7 +1,36 @@
|
|||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync, mkdirSync, copyFileSync } from 'fs'
|
||||||
import { getYtdlpPath } from './binaries'
|
import { dirname } from 'path'
|
||||||
import type { YtdlpVersionResult, YtdlpUpdateChannel, YtdlpUpdateResult } from '@shared/ipc'
|
import { getYtdlpPath, getBundledYtdlpPath } from './binaries'
|
||||||
|
import { getSettings, setSettings } from './settings'
|
||||||
|
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
|
||||||
|
import {
|
||||||
|
isYtdlpUpdateChannel,
|
||||||
|
type YtdlpVersionResult,
|
||||||
|
type YtdlpUpdateChannel,
|
||||||
|
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`.
|
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
|
||||||
@@ -38,6 +67,17 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
|||||||
* a per-user install; it would fail under a locked-down system install.
|
* a per-user install; it would fail under a locked-down system install.
|
||||||
*/
|
*/
|
||||||
export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> {
|
export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> {
|
||||||
|
// Validate against the channel allowlist BEFORE the value reaches `--update-to`.
|
||||||
|
// That flag also accepts `OWNER/REPO@TAG`, which would download and install an
|
||||||
|
// arbitrary binary over yt-dlp.exe — so an unrecognised value (e.g. forged by a
|
||||||
|
// compromised renderer over IPC) must never be forwarded. (audit F1)
|
||||||
|
if (!isYtdlpUpdateChannel(channel)) {
|
||||||
|
return Promise.resolve({ ok: false, error: 'Unsupported update channel.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal: restore the managed copy from the bundled seed before updating, so
|
||||||
|
// a deleted yt-dlp.exe is re-seeded and then updated in one click.
|
||||||
|
ensureManagedYtdlp()
|
||||||
const ytdlpPath = getYtdlpPath()
|
const ytdlpPath = getYtdlpPath()
|
||||||
|
|
||||||
if (!existsSync(ytdlpPath)) {
|
if (!existsSync(ytdlpPath)) {
|
||||||
@@ -65,3 +105,45 @@ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateRes
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startup yt-dlp maintenance: always seed the managed copy, then — if auto-update
|
||||||
|
* is on and the daily throttle has elapsed — self-update it to the configured
|
||||||
|
* channel. Whether it changed is decided by comparing `--version` before/after
|
||||||
|
* (robust against yt-dlp's localized "up to date" wording). Status is pushed to
|
||||||
|
* the renderer so the Settings UI reflects a check it didn't trigger.
|
||||||
|
*
|
||||||
|
* Best-effort: every failure path still records the attempt time (so an
|
||||||
|
* unreachable update server doesn't re-hit the network every launch) and never
|
||||||
|
* throws — a stale binary must never block the app from starting.
|
||||||
|
*/
|
||||||
|
export async function runStartupYtdlpAutoUpdate(
|
||||||
|
send: (status: YtdlpAutoUpdateStatus) => void
|
||||||
|
): Promise<void> {
|
||||||
|
ensureManagedYtdlp()
|
||||||
|
const settings = getSettings()
|
||||||
|
if (!shouldAutoCheckYtdlp(settings.autoUpdateYtdlp, settings.ytdlpLastUpdateCheck, Date.now())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = settings.ytdlpChannel
|
||||||
|
send({ phase: 'checking', channel })
|
||||||
|
|
||||||
|
const before = await getYtdlpVersion()
|
||||||
|
const result = await updateYtdlp(channel)
|
||||||
|
setSettings({ ytdlpLastUpdateCheck: Date.now() })
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
send({ phase: 'error', channel, error: result.error, checkedAt: Date.now() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = await getYtdlpVersion()
|
||||||
|
const changed = before.ok && after.ok && before.version !== after.version
|
||||||
|
send({
|
||||||
|
phase: changed ? 'updated' : 'current',
|
||||||
|
channel,
|
||||||
|
version: after.ok ? after.version : undefined,
|
||||||
|
checkedAt: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Pure auto-update scheduling policy for yt-dlp. Kept free of any electron /
|
||||||
|
* node-runtime dependency (like buildArgs.ts) so the throttle can be unit-tested
|
||||||
|
* without spinning up Electron. ytdlp.ts imports these to drive the real check.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Once-a-day throttle for the startup auto-update check. */
|
||||||
|
export const AUTO_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a background yt-dlp update check should run now: only when auto-update
|
||||||
|
* is enabled AND at least one interval has elapsed since the last check. A
|
||||||
|
* zero/absent lastCheck means "never checked" → run. `now` and the interval are
|
||||||
|
* passed in so the decision is a pure function of its inputs.
|
||||||
|
*/
|
||||||
|
export function shouldAutoCheckYtdlp(
|
||||||
|
enabled: boolean,
|
||||||
|
lastCheck: number,
|
||||||
|
now: number,
|
||||||
|
intervalMs: number = AUTO_UPDATE_INTERVAL_MS
|
||||||
|
): boolean {
|
||||||
|
if (!enabled) return false
|
||||||
|
if (!lastCheck) return true
|
||||||
|
return now - lastCheck >= intervalMs
|
||||||
|
}
|
||||||
+113
-2
@@ -1,9 +1,14 @@
|
|||||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||||
import {
|
import {
|
||||||
IpcChannels,
|
IpcChannels,
|
||||||
|
type AppUpdateInfo,
|
||||||
|
type AppUpdateDownload,
|
||||||
|
type AppUpdateProgress,
|
||||||
type YtdlpVersionResult,
|
type YtdlpVersionResult,
|
||||||
type YtdlpUpdateChannel,
|
type YtdlpUpdateChannel,
|
||||||
type YtdlpUpdateResult,
|
type YtdlpUpdateResult,
|
||||||
|
type YtdlpAutoUpdateStatus,
|
||||||
|
type FfmpegVersionResult,
|
||||||
type ProbeResult,
|
type ProbeResult,
|
||||||
type StartDownloadOptions,
|
type StartDownloadOptions,
|
||||||
type StartDownloadResult,
|
type StartDownloadResult,
|
||||||
@@ -17,14 +22,48 @@ import {
|
|||||||
type ErrorLogEntry,
|
type ErrorLogEntry,
|
||||||
type BackupExportResult,
|
type BackupExportResult,
|
||||||
type BackupImportResult,
|
type BackupImportResult,
|
||||||
type SystemThemeInfo
|
type SystemThemeInfo,
|
||||||
|
type Source,
|
||||||
|
type MediaItem,
|
||||||
|
type IndexProgress,
|
||||||
|
type IndexSourceResult,
|
||||||
|
type SyncResult,
|
||||||
|
type ScheduledSyncStatus,
|
||||||
|
type TerminalEvent,
|
||||||
|
type TerminalRunResult,
|
||||||
|
type TaskbarProgress
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
|
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
|
||||||
const api = {
|
const api = {
|
||||||
|
/** AeroFetch's own version string (e.g. '0.3.1'). */
|
||||||
|
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IpcChannels.appVersion),
|
||||||
|
|
||||||
|
/** Check the configured Gitea repo for a newer AeroFetch release. */
|
||||||
|
checkForAppUpdate: (): Promise<AppUpdateInfo> => ipcRenderer.invoke(IpcChannels.appUpdateCheck),
|
||||||
|
|
||||||
|
/** Download the latest release's installer to a temp file. */
|
||||||
|
downloadAppUpdate: (url: string): Promise<AppUpdateDownload> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.appUpdateDownload, url),
|
||||||
|
|
||||||
|
/** Launch a downloaded installer and quit so it can replace the app. */
|
||||||
|
runAppUpdate: (filePath: string): Promise<{ ok: boolean; error?: string }> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.appUpdateRun, filePath),
|
||||||
|
|
||||||
|
/** Subscribe to installer download progress. Returns an unsubscribe function. */
|
||||||
|
onAppUpdateProgress: (cb: (p: AppUpdateProgress) => void): (() => void) => {
|
||||||
|
const listener = (_e: IpcRendererEvent, p: AppUpdateProgress): void => cb(p)
|
||||||
|
ipcRenderer.on(IpcChannels.appUpdateProgress, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IpcChannels.appUpdateProgress, listener)
|
||||||
|
},
|
||||||
|
|
||||||
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
|
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
|
||||||
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
||||||
|
|
||||||
|
/** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */
|
||||||
|
getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.ffmpegVersion),
|
||||||
|
|
||||||
probe: (url: string): Promise<ProbeResult> => ipcRenderer.invoke(IpcChannels.probe, url),
|
probe: (url: string): Promise<ProbeResult> => ipcRenderer.invoke(IpcChannels.probe, url),
|
||||||
|
|
||||||
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
|
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
|
||||||
@@ -33,6 +72,9 @@ const api = {
|
|||||||
cancelDownload: (id: string): Promise<void> =>
|
cancelDownload: (id: string): Promise<void> =>
|
||||||
ipcRenderer.invoke(IpcChannels.downloadCancel, id),
|
ipcRenderer.invoke(IpcChannels.downloadCancel, id),
|
||||||
|
|
||||||
|
pauseDownload: (id: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.downloadPause, id),
|
||||||
|
|
||||||
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
|
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
|
||||||
|
|
||||||
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
|
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
|
||||||
@@ -85,6 +127,13 @@ const api = {
|
|||||||
updateYtdlp: (channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> =>
|
updateYtdlp: (channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> =>
|
||||||
ipcRenderer.invoke(IpcChannels.ytdlpUpdate, channel),
|
ipcRenderer.invoke(IpcChannels.ytdlpUpdate, channel),
|
||||||
|
|
||||||
|
/** Subscribe to background yt-dlp auto-update status. Returns an unsubscribe function. */
|
||||||
|
onYtdlpAutoUpdateStatus: (cb: (s: YtdlpAutoUpdateStatus) => void): (() => void) => {
|
||||||
|
const listener = (_e: IpcRendererEvent, s: YtdlpAutoUpdateStatus): void => cb(s)
|
||||||
|
ipcRenderer.on(IpcChannels.ytdlpAutoUpdateStatus, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IpcChannels.ytdlpAutoUpdateStatus, listener)
|
||||||
|
},
|
||||||
|
|
||||||
listErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogList),
|
listErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogList),
|
||||||
|
|
||||||
clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear),
|
clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear),
|
||||||
@@ -121,7 +170,69 @@ const api = {
|
|||||||
const listener = (_e: IpcRendererEvent, url: string): void => cb(url)
|
const listener = (_e: IpcRendererEvent, url: string): void => cb(url)
|
||||||
ipcRenderer.on(IpcChannels.externalUrl, listener)
|
ipcRenderer.on(IpcChannels.externalUrl, listener)
|
||||||
return () => ipcRenderer.removeListener(IpcChannels.externalUrl, listener)
|
return () => ipcRenderer.removeListener(IpcChannels.externalUrl, listener)
|
||||||
}
|
},
|
||||||
|
|
||||||
|
// --- Media-manager sources (Pinchflat-style; see ROADMAP-PINCHFLAT.md) ---
|
||||||
|
|
||||||
|
listSources: (): Promise<Source[]> => ipcRenderer.invoke(IpcChannels.sourcesList),
|
||||||
|
|
||||||
|
/** Index (or re-index) a channel/playlist URL into the persisted source list. */
|
||||||
|
indexSource: (url: string): Promise<IndexSourceResult> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.sourceIndex, url),
|
||||||
|
|
||||||
|
/** Re-index an existing source by id (refreshes its media-item list). */
|
||||||
|
reindexSource: (id: string): Promise<IndexSourceResult> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.sourceReindex, id),
|
||||||
|
|
||||||
|
removeSource: (id: string): Promise<Source[]> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.sourceRemove, id),
|
||||||
|
|
||||||
|
listSourceItems: (sourceId: string): Promise<MediaItem[]> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.sourceItems, sourceId),
|
||||||
|
|
||||||
|
/** Persist that a media item has finished downloading (drives incremental sync). */
|
||||||
|
markSourceItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath),
|
||||||
|
|
||||||
|
/** Toggle whether a source is watched for new uploads. */
|
||||||
|
setSourceWatched: (id: string, watched: boolean): Promise<Source[]> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.sourceSetWatched, id, watched),
|
||||||
|
|
||||||
|
/** Re-index all watched sources; resolves with the videos found new. */
|
||||||
|
syncSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
|
||||||
|
|
||||||
|
/** Read / write the Windows daily-sync scheduled task. */
|
||||||
|
getScheduledSync: (): Promise<ScheduledSyncStatus> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.scheduledSyncGet),
|
||||||
|
setScheduledSync: (enabled: boolean): Promise<ScheduledSyncStatus> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.scheduledSyncSet, enabled),
|
||||||
|
|
||||||
|
/** Subscribe to live indexing progress. Returns an unsubscribe function. */
|
||||||
|
onIndexProgress: (cb: (p: IndexProgress) => void): (() => void) => {
|
||||||
|
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
|
export type Api = typeof api
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
|
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
|
||||||
import { Sidebar, type TabValue } from './components/Sidebar'
|
import { Sidebar, type TabValue } from './components/Sidebar'
|
||||||
import { DownloadsView } from './components/DownloadsView'
|
import { DownloadsView } from './components/DownloadsView'
|
||||||
|
import { LibraryView } from './components/LibraryView'
|
||||||
import { HistoryView } from './components/HistoryView'
|
import { HistoryView } from './components/HistoryView'
|
||||||
|
import { TerminalView } from './components/TerminalView'
|
||||||
import { SettingsView } from './components/SettingsView'
|
import { SettingsView } from './components/SettingsView'
|
||||||
import { Onboarding } from './components/Onboarding'
|
import { Onboarding } from './components/Onboarding'
|
||||||
|
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
||||||
import { getTheme, pageBackground } from './theme'
|
import { getTheme, pageBackground } from './theme'
|
||||||
import { useSettings } from './store/settings'
|
import { useSettings } from './store/settings'
|
||||||
import { useSystemTheme } from './store/systemTheme'
|
import { useDownloads } from './store/downloads'
|
||||||
|
import { summarizeQueue } from './store/queueStats'
|
||||||
|
import { useResolvedDark } from './store/systemTheme'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
provider: {
|
provider: {
|
||||||
@@ -31,9 +36,75 @@ function App(): React.JSX.Element {
|
|||||||
const theme = useSettings((s) => s.theme)
|
const theme = useSettings((s) => s.theme)
|
||||||
const accentColor = useSettings((s) => s.accentColor)
|
const accentColor = useSettings((s) => s.accentColor)
|
||||||
const updateSettings = useSettings((s) => s.update)
|
const updateSettings = useSettings((s) => s.update)
|
||||||
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
|
const isDark = useResolvedDark()
|
||||||
const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark'
|
|
||||||
const [tab, setTab] = useState<TabValue>('downloads')
|
const [tab, setTab] = useState<TabValue>('downloads')
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||||
|
|
||||||
|
// Ctrl/Cmd+K toggles the command palette.
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||||
|
e.preventDefault()
|
||||||
|
setPaletteOpen((o) => !o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Mirror overall queue progress onto the Windows taskbar. Subscribe to the store
|
||||||
|
// directly (not via a selector) so taskbar updates don't re-render the app.
|
||||||
|
useEffect(() => {
|
||||||
|
function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
|
||||||
|
const s = summarizeQueue(items)
|
||||||
|
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
|
||||||
|
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode })
|
||||||
|
}
|
||||||
|
push(useDownloads.getState().items)
|
||||||
|
return useDownloads.subscribe((st) => push(st.items))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const paletteActions: PaletteAction[] = [
|
||||||
|
{ id: 'go-downloads', label: 'Go to Downloads', hint: 'Navigate', run: () => setTab('downloads') },
|
||||||
|
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
|
||||||
|
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
|
||||||
|
{ id: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') },
|
||||||
|
{ id: 'go-settings', label: 'Go to Settings', hint: 'Navigate', run: () => setTab('settings') },
|
||||||
|
{
|
||||||
|
id: 'new-download',
|
||||||
|
label: 'New download',
|
||||||
|
hint: 'Focus URL',
|
||||||
|
run: () => {
|
||||||
|
setTab('downloads')
|
||||||
|
// Wait for the download bar to mount after the tab switch, then focus.
|
||||||
|
setTimeout(() => document.getElementById('aerofetch-url')?.focus(), 60)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toggle-theme',
|
||||||
|
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
|
||||||
|
hint: 'Appearance',
|
||||||
|
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
|
||||||
|
const [version, setVersion] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
window.api?.getAppVersion?.().then(setVersion).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Sidebar collapse, persisted across launches in localStorage.
|
||||||
|
const [collapsed, setCollapsed] = useState(
|
||||||
|
() => localStorage.getItem('aerofetch.sidebarCollapsed') === '1'
|
||||||
|
)
|
||||||
|
function toggleCollapsed(): void {
|
||||||
|
setCollapsed((c) => {
|
||||||
|
const next = !c
|
||||||
|
localStorage.setItem('aerofetch.sidebarCollapsed', next ? '1' : '0')
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
// Gate on `loaded` so a returning user's real settings never get clobbered
|
// Gate on `loaded` so a returning user's real settings never get clobbered
|
||||||
// by a one-frame flash of the (default-false) onboarding state.
|
// by a one-frame flash of the (default-false) onboarding state.
|
||||||
const loaded = useSettings((s) => s.loaded)
|
const loaded = useSettings((s) => s.loaded)
|
||||||
@@ -59,16 +130,25 @@ function App(): React.JSX.Element {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
tab={tab}
|
tab={tab}
|
||||||
onTabChange={setTab}
|
onTabChange={setTab}
|
||||||
|
theme={theme}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
followingSystem={theme === 'system'}
|
onSetTheme={(mode) => updateSettings({ theme: mode })}
|
||||||
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
|
version={version}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggleCollapsed={toggleCollapsed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className={styles.content}>
|
<main className={styles.content}>
|
||||||
{tab === 'downloads' && <DownloadsView />}
|
{tab === 'downloads' && <DownloadsView />}
|
||||||
|
{tab === 'library' && <LibraryView />}
|
||||||
{tab === 'history' && <HistoryView />}
|
{tab === 'history' && <HistoryView />}
|
||||||
|
{tab === 'terminal' && <TerminalView />}
|
||||||
{tab === 'settings' && <SettingsView />}
|
{tab === 'settings' && <SettingsView />}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{paletteOpen && (
|
||||||
|
<CommandPalette actions={paletteActions} onClose={() => setPaletteOpen(false)} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||||
|
|
||||||
|
export interface PaletteAction {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
/** short right-aligned hint, e.g. a shortcut or category */
|
||||||
|
hint?: string
|
||||||
|
run: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
// A plain fixed overlay — NOT a Fluent Dialog, since this app avoids Fluent's
|
||||||
|
// portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx).
|
||||||
|
backdrop: {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
paddingTop: '14vh',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.32)'
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
width: 'min(560px, 92vw)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusXLarge),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
|
boxShadow: tokens.shadow28,
|
||||||
|
overflow: 'hidden'
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
appearance: 'none',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
padding: '14px 16px',
|
||||||
|
fontSize: tokens.fontSizeBase400,
|
||||||
|
fontFamily: tokens.fontFamilyBase,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: tokens.colorNeutralForeground1,
|
||||||
|
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
maxHeight: '50vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '6px'
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '12px',
|
||||||
|
width: '100%',
|
||||||
|
padding: '9px 12px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: tokens.colorNeutralForeground1,
|
||||||
|
fontSize: tokens.fontSizeBase300,
|
||||||
|
fontFamily: tokens.fontFamilyBase,
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusMedium)
|
||||||
|
},
|
||||||
|
itemActive: {
|
||||||
|
backgroundColor: tokens.colorBrandBackground2,
|
||||||
|
color: tokens.colorBrandForeground2
|
||||||
|
},
|
||||||
|
itemHint: {
|
||||||
|
flexShrink: 0,
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
fontSize: tokens.fontSizeBase200
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
padding: '14px 12px',
|
||||||
|
color: tokens.colorNeutralForeground3
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Ctrl/Cmd+K command palette (Phase N). Filter by typing, ↑/↓ to move, Enter to
|
||||||
|
* run, Esc or a backdrop click to dismiss. Actions are supplied by App.
|
||||||
|
*/
|
||||||
|
export function CommandPalette({
|
||||||
|
actions,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
actions: PaletteAction[]
|
||||||
|
onClose: () => void
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const styles = useStyles()
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [sel, setSel] = useState(0)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
setSel(0)
|
||||||
|
}, [q])
|
||||||
|
|
||||||
|
function onKeyDown(e: React.KeyboardEvent): void {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setSel((s) => Math.min(filtered.length - 1, s + 1))
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setSel((s) => Math.max(0, s - 1))
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
const a = filtered[sel]
|
||||||
|
if (a) {
|
||||||
|
a.run()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.backdrop} onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className={styles.panel}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Command palette"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className={styles.input}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Type a command…"
|
||||||
|
aria-label="Command palette search"
|
||||||
|
/>
|
||||||
|
<div className={styles.list}>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className={styles.empty}>No matching commands</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((a, i) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
|
||||||
|
onClick={() => {
|
||||||
|
a.run()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setSel(i)}
|
||||||
|
>
|
||||||
|
<span>{a.label}</span>
|
||||||
|
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
Input,
|
Input,
|
||||||
|
Textarea,
|
||||||
|
Field,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Switch,
|
|
||||||
Field,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
Caption1,
|
Caption1,
|
||||||
@@ -16,36 +16,23 @@ import {
|
|||||||
import {
|
import {
|
||||||
ArrowDownloadRegular,
|
ArrowDownloadRegular,
|
||||||
ClipboardPasteRegular,
|
ClipboardPasteRegular,
|
||||||
FolderRegular,
|
|
||||||
SearchRegular,
|
SearchRegular,
|
||||||
VideoClipRegular,
|
VideoClipRegular,
|
||||||
MusicNote2Regular,
|
MusicNote2Regular,
|
||||||
ErrorCircleRegular,
|
ErrorCircleRegular,
|
||||||
LinkRegular,
|
LinkRegular,
|
||||||
DismissRegular,
|
DismissRegular,
|
||||||
OptionsRegular,
|
|
||||||
ChevronDownRegular,
|
|
||||||
ChevronUpRegular,
|
|
||||||
AppsListRegular,
|
AppsListRegular,
|
||||||
CodeRegular,
|
CutRegular,
|
||||||
EyeRegular,
|
CalendarClockRegular,
|
||||||
EyeOffRegular,
|
WarningRegular
|
||||||
CopyRegular
|
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import type {
|
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
|
||||||
MediaInfo,
|
|
||||||
FormatOption,
|
|
||||||
PlaylistInfo,
|
|
||||||
DownloadOptions,
|
|
||||||
CommandPreviewResult,
|
|
||||||
StartDownloadOptions
|
|
||||||
} from '@shared/ipc'
|
|
||||||
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
|
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
|
||||||
|
import { sameVideo } from '../store/queueStats'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
import { useTemplates } from '../store/templates'
|
|
||||||
import { Select } from './Select'
|
import { Select } from './Select'
|
||||||
import { Hint } from './Hint'
|
import { Hint } from './Hint'
|
||||||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
|
||||||
|
|
||||||
/** A quick heuristic for "this clipboard text is a link worth offering". */
|
/** A quick heuristic for "this clipboard text is a link worth offering". */
|
||||||
function looksLikeUrl(text: string): boolean {
|
function looksLikeUrl(text: string): boolean {
|
||||||
@@ -59,6 +46,23 @@ function looksLikeUrl(text: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */
|
||||||
|
function firstUrl(text: string): string | null {
|
||||||
|
for (const raw of text.split(/\r?\n/)) {
|
||||||
|
const line = raw.trim()
|
||||||
|
if (!line || line.startsWith('#')) continue
|
||||||
|
if (looksLikeUrl(line)) return line
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
|
||||||
|
function parseUrlFile(content: string): string | null {
|
||||||
|
const m = /^\s*URL\s*=\s*(\S+)/im.exec(content)
|
||||||
|
const url = m?.[1]?.trim()
|
||||||
|
return url && looksLikeUrl(url) ? url : null
|
||||||
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -69,6 +73,11 @@ const useStyles = makeStyles({
|
|||||||
...shorthands.borderRadius(tokens.borderRadiusXLarge),
|
...shorthands.borderRadius(tokens.borderRadiusXLarge),
|
||||||
boxShadow: tokens.shadow4
|
boxShadow: tokens.shadow4
|
||||||
},
|
},
|
||||||
|
// Highlight while a link / .url file is dragged over the card.
|
||||||
|
rootDragging: {
|
||||||
|
outline: `2px dashed ${tokens.colorBrandStroke1}`,
|
||||||
|
outlineOffset: '-2px'
|
||||||
|
},
|
||||||
urlRow: {
|
urlRow: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '8px'
|
gap: '8px'
|
||||||
@@ -184,33 +193,6 @@ const useStyles = makeStyles({
|
|||||||
spacer: {
|
spacer: {
|
||||||
flexGrow: 1
|
flexGrow: 1
|
||||||
},
|
},
|
||||||
// --- per-download options panel ---
|
|
||||||
optionsBar: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px'
|
|
||||||
},
|
|
||||||
optionsPanel: {
|
|
||||||
padding: '14px',
|
|
||||||
backgroundColor: tokens.colorNeutralBackground2,
|
|
||||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
|
||||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
|
||||||
},
|
|
||||||
// --- command preview ---
|
|
||||||
previewPanel: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '14px',
|
|
||||||
backgroundColor: tokens.colorNeutralBackground2,
|
|
||||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
|
||||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
|
||||||
},
|
|
||||||
previewCommandText: {
|
|
||||||
fontFamily: tokens.fontFamilyMonospace,
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all'
|
|
||||||
},
|
|
||||||
// --- playlist selection ---
|
// --- playlist selection ---
|
||||||
plPanel: {
|
plPanel: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -241,10 +223,35 @@ const useStyles = makeStyles({
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
paddingRight: '4px'
|
paddingRight: '4px'
|
||||||
},
|
},
|
||||||
|
plItemRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
},
|
||||||
plItem: {
|
plItem: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
padding: '2px 0'
|
padding: '2px 0',
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0
|
||||||
|
},
|
||||||
|
plKindBtn: {
|
||||||
|
flexShrink: 0,
|
||||||
|
appearance: 'none',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||||
|
color: tokens.colorNeutralForeground2
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plItemLabel: {
|
plItemLabel: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -259,61 +266,100 @@ const useStyles = makeStyles({
|
|||||||
plItemMeta: {
|
plItemMeta: {
|
||||||
color: tokens.colorNeutralForeground3
|
color: tokens.colorNeutralForeground3
|
||||||
},
|
},
|
||||||
folder: {
|
// --- duplicate warning ---
|
||||||
|
dupRow: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '6px',
|
gap: '8px',
|
||||||
color: tokens.colorNeutralForeground3,
|
padding: '8px 8px 8px 12px',
|
||||||
maxWidth: '360px'
|
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||||
|
color: tokens.colorStatusWarningForeground1,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||||
},
|
},
|
||||||
folderPath: {
|
// --- trim / schedule panels ---
|
||||||
overflow: 'hidden',
|
trimBlock: {
|
||||||
textOverflow: 'ellipsis',
|
display: 'flex',
|
||||||
whiteSpace: 'nowrap'
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'flex-start'
|
||||||
|
},
|
||||||
|
optButtons: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px'
|
||||||
|
},
|
||||||
|
trimPanel: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||||
|
},
|
||||||
|
// Native datetime-local input, themed to sit beside the Fluent controls.
|
||||||
|
dtInput: {
|
||||||
|
fontFamily: tokens.fontFamilyBase,
|
||||||
|
fontSize: tokens.fontSizeBase300,
|
||||||
|
padding: '6px 10px',
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
color: tokens.colorNeutralForeground1,
|
||||||
|
colorScheme: 'light dark'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export function DownloadBar(): React.JSX.Element {
|
export function DownloadBar(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
||||||
const outputDir = useSettings((s) => s.outputDir)
|
const addMany = useDownloads((s) => s.addMany)
|
||||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
|
||||||
const settingsLoaded = useSettings((s) => s.loaded)
|
const settingsLoaded = useSettings((s) => s.loaded)
|
||||||
const defaultKind = useSettings((s) => s.defaultKind)
|
const defaultKind = useSettings((s) => s.defaultKind)
|
||||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||||
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||||
const downloadOptions = useSettings((s) => s.downloadOptions)
|
|
||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const [kind, setKind] = useState<MediaKind>('video')
|
const [kind, setKind] = useState<MediaKind>('video')
|
||||||
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
|
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
|
||||||
|
|
||||||
// Per-download options override (null = use the persisted defaults).
|
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
|
||||||
const [override, setOverride] = useState<DownloadOptions | null>(null)
|
const [showTrim, setShowTrim] = useState(false)
|
||||||
const [showOptions, setShowOptions] = useState(false)
|
const [trim, setTrim] = useState('')
|
||||||
const effectiveOptions = override ?? downloadOptions
|
|
||||||
|
|
||||||
// Private mode — sticky like an incognito tab; the download still runs and
|
// Optional schedule: a datetime-local value; a future time parks the download.
|
||||||
// appears in the queue, but its completion is never recorded to history.
|
const [showSchedule, setShowSchedule] = useState(false)
|
||||||
const [incognito, setIncognito] = useState(false)
|
const [scheduleAt, setScheduleAt] = useState('')
|
||||||
|
|
||||||
// Per-download custom-command override: undefined = defer to the settings
|
// Drag-and-drop: highlight while a link / .url file hovers the card.
|
||||||
// default (customCommandEnabled + defaultTemplateId); null = explicit
|
const [dragActive, setDragActive] = useState(false)
|
||||||
// "None" for just this download; a string = a chosen template id override.
|
|
||||||
const [templateOverride, setTemplateOverride] = useState<string | null | undefined>(undefined)
|
|
||||||
const [showCustomCommand, setShowCustomCommand] = useState(false)
|
|
||||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
|
||||||
const settingsDefaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
|
||||||
const templates = useTemplates((s) => s.templates)
|
|
||||||
const effectiveTemplateId =
|
|
||||||
templateOverride !== undefined
|
|
||||||
? templateOverride
|
|
||||||
: customCommandEnabled
|
|
||||||
? settingsDefaultTemplateId
|
|
||||||
: null
|
|
||||||
|
|
||||||
const [previewResult, setPreviewResult] = useState<CommandPreviewResult | null>(null)
|
// Duplicate guard: warn before enqueuing a URL already in the active queue.
|
||||||
const [previewing, setPreviewing] = useState(false)
|
const [dup, setDup] = useState<string | null>(null)
|
||||||
|
const confirmDup = useRef(false)
|
||||||
|
|
||||||
|
function onDragOver(e: React.DragEvent): void {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!dragActive) setDragActive(true)
|
||||||
|
}
|
||||||
|
function onDrop(e: React.DragEvent): void {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragActive(false)
|
||||||
|
const dt = e.dataTransfer
|
||||||
|
const fromText = firstUrl(dt.getData('text/uri-list') || dt.getData('text/plain') || '')
|
||||||
|
if (fromText) {
|
||||||
|
onUrlChange(fromText)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A dropped Windows .url Internet Shortcut — read its URL= line.
|
||||||
|
const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url'))
|
||||||
|
if (file) {
|
||||||
|
file
|
||||||
|
.text()
|
||||||
|
.then((content) => {
|
||||||
|
const u = parseUrlFile(content)
|
||||||
|
if (u) onUrlChange(u)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply the saved default format once, when persisted settings first arrive.
|
// Apply the saved default format once, when persisted settings first arrive.
|
||||||
const appliedDefaults = useRef(false)
|
const appliedDefaults = useRef(false)
|
||||||
@@ -334,6 +380,9 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
// Probe state for a playlist URL.
|
// Probe state for a playlist URL.
|
||||||
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
|
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||||
|
// Per-entry kind override (entry.index → 'video' | 'audio'); absent = use the
|
||||||
|
// bar's global kind. Lets a playlist mix video and audio downloads.
|
||||||
|
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
|
||||||
|
|
||||||
// Clipboard auto-detect: when the window gains focus and the clipboard holds a
|
// Clipboard auto-detect: when the window gains focus and the clipboard holds a
|
||||||
// fresh link, offer it (without clobbering anything the user is already typing).
|
// fresh link, offer it (without clobbering anything the user is already typing).
|
||||||
@@ -393,7 +442,6 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
setSuggestion(null)
|
setSuggestion(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const folder = outputDir || 'your Downloads folder'
|
|
||||||
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
|
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
|
||||||
const selectedFormat: FormatOption | undefined = usingFormats
|
const selectedFormat: FormatOption | undefined = usingFormats
|
||||||
? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
|
? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
|
||||||
@@ -405,12 +453,15 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
setFormatId('')
|
setFormatId('')
|
||||||
setPlaylist(null)
|
setPlaylist(null)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
|
setItemKinds({})
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUrlChange(next: string): void {
|
function onUrlChange(next: string): void {
|
||||||
setUrl(next)
|
setUrl(next)
|
||||||
// Any probed info is stale once the URL changes.
|
// Any probed info — or a duplicate warning — is stale once the URL changes.
|
||||||
if (info || probeError || playlist) clearProbe()
|
if (info || probeError || playlist) clearProbe()
|
||||||
|
if (dup) setDup(null)
|
||||||
|
confirmDup.current = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKindChange(next: MediaKind): void {
|
function onKindChange(next: MediaKind): void {
|
||||||
@@ -468,65 +519,41 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
|
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolves the per-download custom-command override to the raw extra-args
|
// Per-entry kind: the override if set, else the bar's global kind.
|
||||||
// string sent to main; undefined defers to the settings default there.
|
function effKind(index: number): MediaKind {
|
||||||
function resolveExtraArgs(): string | undefined {
|
return itemKinds[index] ?? kind
|
||||||
if (templateOverride === undefined) return undefined
|
|
||||||
if (templateOverride === null) return ''
|
|
||||||
return templates.find((t) => t.id === templateOverride)?.args ?? ''
|
|
||||||
}
|
}
|
||||||
|
function toggleItemKind(index: number): void {
|
||||||
// The StartDownloadOptions the current form state would produce — shared by
|
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
|
||||||
// the real download() call and the command-preview button so they can never
|
|
||||||
// drift apart.
|
|
||||||
function currentStartOptions(): StartDownloadOptions {
|
|
||||||
const base: StartDownloadOptions = {
|
|
||||||
id: 'preview',
|
|
||||||
url: url.trim(),
|
|
||||||
kind,
|
|
||||||
quality,
|
|
||||||
outputDir: outputDir || undefined,
|
|
||||||
options: override ?? undefined,
|
|
||||||
extraArgs: resolveExtraArgs()
|
|
||||||
}
|
|
||||||
if (usingFormats && selectedFormat) {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
kind: 'video',
|
|
||||||
quality: selectedFormat.label,
|
|
||||||
formatId: selectedFormat.id,
|
|
||||||
formatHasAudio: selectedFormat.hasAudio
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
async function previewCommand(): Promise<void> {
|
|
||||||
if (!url.trim() || previewing) return
|
|
||||||
setPreviewing(true)
|
|
||||||
setPreviewResult(null)
|
|
||||||
try {
|
|
||||||
setPreviewResult(await window.api.previewCommand(currentStartOptions()))
|
|
||||||
} catch (e) {
|
|
||||||
setPreviewResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
|
|
||||||
} finally {
|
|
||||||
setPreviewing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyPreview(): Promise<void> {
|
|
||||||
if (!previewResult?.command) return
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(previewResult.command)
|
|
||||||
} catch {
|
|
||||||
/* clipboard blocked — ignore in preview */
|
|
||||||
}
|
}
|
||||||
|
function setAllKinds(k: MediaKind): void {
|
||||||
|
if (!playlist) return
|
||||||
|
const all: Record<number, MediaKind> = {}
|
||||||
|
for (const e of playlist.entries) all[e.index] = k
|
||||||
|
setItemKinds(all)
|
||||||
}
|
}
|
||||||
|
|
||||||
function download(): void {
|
function download(): void {
|
||||||
const trimmed = url.trim()
|
const trimmed = url.trim()
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
|
|
||||||
|
// Duplicate guard: if this URL is already in the queue (and the user hasn't
|
||||||
|
// confirmed via "Download anyway"), warn instead of silently enqueuing a copy.
|
||||||
|
// Canceled/failed items don't count — re-adding those is a legitimate retry.
|
||||||
|
if (!confirmDup.current) {
|
||||||
|
const existing = useDownloads
|
||||||
|
.getState()
|
||||||
|
.items.find(
|
||||||
|
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
|
||||||
|
)
|
||||||
|
if (existing) {
|
||||||
|
setDup(existing.title)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
confirmDup.current = false
|
||||||
|
setDup(null)
|
||||||
|
|
||||||
const meta = info
|
const meta = info
|
||||||
? {
|
? {
|
||||||
title: info.title,
|
title: info.title,
|
||||||
@@ -536,53 +563,68 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
|
|
||||||
|
const trimSpec = trim.trim() || undefined
|
||||||
|
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
|
||||||
|
|
||||||
if (usingFormats && selectedFormat) {
|
if (usingFormats && selectedFormat) {
|
||||||
addFromUrl(trimmed, 'video', selectedFormat.label, {
|
addFromUrl(trimmed, 'video', selectedFormat.label, {
|
||||||
...meta,
|
...meta,
|
||||||
|
trim: trimSpec,
|
||||||
|
scheduledFor,
|
||||||
format: {
|
format: {
|
||||||
id: selectedFormat.id,
|
id: selectedFormat.id,
|
||||||
hasAudio: selectedFormat.hasAudio,
|
hasAudio: selectedFormat.hasAudio,
|
||||||
label: selectedFormat.label
|
label: selectedFormat.label
|
||||||
},
|
}
|
||||||
options: override ?? undefined,
|
|
||||||
extraArgs: resolveExtraArgs(),
|
|
||||||
incognito
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
addFromUrl(trimmed, kind, quality, {
|
addFromUrl(trimmed, kind, quality, { ...meta, trim: trimSpec, scheduledFor })
|
||||||
...meta,
|
|
||||||
options: override ?? undefined,
|
|
||||||
extraArgs: resolveExtraArgs(),
|
|
||||||
incognito
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setUrl('')
|
setUrl('')
|
||||||
|
setTrim('')
|
||||||
|
setShowTrim(false)
|
||||||
|
setScheduleAt('')
|
||||||
|
setShowSchedule(false)
|
||||||
clearProbe()
|
clearProbe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadAnyway(): void {
|
||||||
|
confirmDup.current = true
|
||||||
|
download()
|
||||||
|
}
|
||||||
|
|
||||||
function addPlaylist(): void {
|
function addPlaylist(): void {
|
||||||
if (!playlist) return
|
if (!playlist) return
|
||||||
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
||||||
for (const e of chosen) {
|
// Each entry uses its own kind override; quality falls back to that kind's
|
||||||
addFromUrl(e.url, kind, quality, {
|
// default unless the entry matches the bar's current kind/quality.
|
||||||
title: e.title,
|
addMany(
|
||||||
channel: e.uploader,
|
chosen.map((e) => {
|
||||||
durationLabel: e.durationLabel,
|
const k = effKind(e.index)
|
||||||
options: override ?? undefined,
|
return {
|
||||||
extraArgs: resolveExtraArgs(),
|
url: e.url,
|
||||||
incognito
|
kind: k,
|
||||||
})
|
quality: k === kind ? quality : QUALITY_OPTIONS[k][0],
|
||||||
|
opts: { title: e.title, channel: e.uploader, durationLabel: e.durationLabel }
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
setUrl('')
|
setUrl('')
|
||||||
clearProbe()
|
clearProbe()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div
|
||||||
|
className={mergeClasses(styles.root, dragActive && styles.rootDragging)}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={() => setDragActive(false)}
|
||||||
|
onDrop={onDrop}
|
||||||
|
>
|
||||||
<div className={styles.urlRow}>
|
<div className={styles.urlRow}>
|
||||||
<Input
|
<Input
|
||||||
className={styles.url}
|
className={styles.url}
|
||||||
|
input={{ id: 'aerofetch-url' }}
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(_, d) => onUrlChange(d.value)}
|
onChange={(_, d) => onUrlChange(d.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
|
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
|
||||||
@@ -629,6 +671,23 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{dup && (
|
||||||
|
<div className={styles.dupRow}>
|
||||||
|
<WarningRegular />
|
||||||
|
<Caption1 className={styles.suggestionText}>Already in your queue: “{dup}”.</Caption1>
|
||||||
|
<Button size="small" appearance="primary" onClick={downloadAnyway}>
|
||||||
|
Download anyway
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DismissRegular />}
|
||||||
|
onClick={() => setDup(null)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{probing && (
|
{probing && (
|
||||||
<div className={styles.statusRow}>
|
<div className={styles.statusRow}>
|
||||||
<Spinner size="tiny" />
|
<Spinner size="tiny" />
|
||||||
@@ -679,11 +738,17 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
<Button size="small" appearance="subtle" onClick={toggleAll}>
|
<Button size="small" appearance="subtle" onClick={toggleAll}>
|
||||||
{allSelected ? 'Select none' : 'Select all'}
|
{allSelected ? 'Select none' : 'Select all'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="small" appearance="subtle" onClick={() => setAllKinds('video')}>
|
||||||
|
All video
|
||||||
|
</Button>
|
||||||
|
<Button size="small" appearance="subtle" onClick={() => setAllKinds('audio')}>
|
||||||
|
All audio
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.plList}>
|
<div className={styles.plList}>
|
||||||
{playlist.entries.map((e) => (
|
{playlist.entries.map((e) => (
|
||||||
|
<div key={e.index} className={styles.plItemRow}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
key={e.index}
|
|
||||||
className={styles.plItem}
|
className={styles.plItem}
|
||||||
checked={selected.has(e.index)}
|
checked={selected.has(e.index)}
|
||||||
onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
|
onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
|
||||||
@@ -700,11 +765,81 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!playlist && (
|
||||||
|
<div className={styles.trimBlock}>
|
||||||
|
<div className={styles.optButtons}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<CutRegular />}
|
||||||
|
onClick={() => setShowTrim((v) => !v)}
|
||||||
|
>
|
||||||
|
{trim.trim() ? 'Trim · on' : 'Trim'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<CalendarClockRegular />}
|
||||||
|
onClick={() => setShowSchedule((v) => !v)}
|
||||||
|
>
|
||||||
|
{scheduleAt ? 'Scheduled' : 'Schedule'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{showTrim && (
|
||||||
|
<div className={styles.trimPanel}>
|
||||||
|
<Field
|
||||||
|
label="Sections to keep"
|
||||||
|
hint="Time ranges like 1:30-2:00 — one per line or comma-separated. Leave empty to download the whole thing."
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={trim}
|
||||||
|
onChange={(_, d) => setTrim(d.value)}
|
||||||
|
placeholder={'0:30-1:45\n3:00-3:30'}
|
||||||
|
resize="vertical"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showSchedule && (
|
||||||
|
<div className={styles.trimPanel}>
|
||||||
|
<Field
|
||||||
|
label="Start at"
|
||||||
|
hint="Pick a date and time to start this download. Leave empty to start now. Scheduled downloads only fire while AeroFetch is running."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className={styles.dtInput}
|
||||||
|
value={scheduleAt}
|
||||||
|
onChange={(e) => setScheduleAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
<div className={styles.control}>
|
<div className={styles.control}>
|
||||||
<Caption1>Format</Caption1>
|
<Caption1>Format</Caption1>
|
||||||
@@ -761,144 +896,14 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
size="large"
|
size="large"
|
||||||
icon={<ArrowDownloadRegular />}
|
icon={scheduleAt ? <CalendarClockRegular /> : <ArrowDownloadRegular />}
|
||||||
onClick={download}
|
onClick={download}
|
||||||
disabled={!url.trim()}
|
disabled={!url.trim()}
|
||||||
>
|
>
|
||||||
Download
|
{scheduleAt ? 'Schedule' : 'Download'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.optionsBar}>
|
|
||||||
{incognito ? <EyeOffRegular /> : <EyeRegular />}
|
|
||||||
<Switch
|
|
||||||
checked={incognito}
|
|
||||||
onChange={(_, d) => setIncognito(d.checked)}
|
|
||||||
label={incognito ? 'Private — won’t be saved to history' : 'Private mode'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.optionsBar}>
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
size="small"
|
|
||||||
icon={<OptionsRegular />}
|
|
||||||
iconPosition="before"
|
|
||||||
onClick={() => setShowOptions((v) => !v)}
|
|
||||||
>
|
|
||||||
Options {showOptions ? <ChevronUpRegular /> : <ChevronDownRegular />}
|
|
||||||
</Button>
|
|
||||||
{override && (
|
|
||||||
<>
|
|
||||||
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
|
|
||||||
<Button appearance="subtle" size="small" onClick={() => setOverride(null)}>
|
|
||||||
Reset to defaults
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showOptions && (
|
|
||||||
<div className={styles.optionsPanel}>
|
|
||||||
<DownloadOptionsForm value={effectiveOptions} onChange={(o) => setOverride(o)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.optionsBar}>
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
size="small"
|
|
||||||
icon={<CodeRegular />}
|
|
||||||
iconPosition="before"
|
|
||||||
onClick={() => setShowCustomCommand((v) => !v)}
|
|
||||||
>
|
|
||||||
Custom command {showCustomCommand ? <ChevronUpRegular /> : <ChevronDownRegular />}
|
|
||||||
</Button>
|
|
||||||
{templateOverride !== undefined && (
|
|
||||||
<>
|
|
||||||
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
|
|
||||||
<Button appearance="subtle" size="small" onClick={() => setTemplateOverride(undefined)}>
|
|
||||||
Reset to default
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className={styles.spacer} />
|
|
||||||
<Hint label="Build and show the exact yt-dlp command line" placement="top" align="end">
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
size="small"
|
|
||||||
icon={previewing ? <Spinner size="tiny" /> : <EyeRegular />}
|
|
||||||
iconPosition="before"
|
|
||||||
onClick={previewCommand}
|
|
||||||
disabled={!url.trim() || previewing}
|
|
||||||
>
|
|
||||||
Preview command
|
|
||||||
</Button>
|
|
||||||
</Hint>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCustomCommand && (
|
|
||||||
<div className={styles.optionsPanel}>
|
|
||||||
<Field label="Template" hint="Extra yt-dlp flags layered onto this download, after every other option.">
|
|
||||||
<Select
|
|
||||||
aria-label="Custom command template"
|
|
||||||
value={effectiveTemplateId ?? 'none'}
|
|
||||||
options={[
|
|
||||||
{ value: 'none', label: 'None' },
|
|
||||||
...templates.map((t) => ({ value: t.id, label: t.name }))
|
|
||||||
]}
|
|
||||||
onChange={(v) => setTemplateOverride(v === 'none' ? null : v)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{templates.length === 0 && (
|
|
||||||
<Caption1 className={styles.previewMeta}>
|
|
||||||
No templates yet — add one in Settings → Custom commands.
|
|
||||||
</Caption1>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{previewResult && (
|
|
||||||
<div className={styles.previewPanel}>
|
|
||||||
<div className={styles.plHeader}>
|
|
||||||
<Caption1 className={styles.plHeaderText}>
|
|
||||||
{previewResult.ok ? 'Command preview' : 'Could not build the command'}
|
|
||||||
</Caption1>
|
|
||||||
{previewResult.ok && (
|
|
||||||
<Button size="small" icon={<CopyRegular />} onClick={copyPreview}>
|
|
||||||
Copy
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
appearance="subtle"
|
|
||||||
icon={<DismissRegular />}
|
|
||||||
onClick={() => setPreviewResult(null)}
|
|
||||||
aria-label="Dismiss preview"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{previewResult.ok ? (
|
|
||||||
<Text className={styles.previewCommandText}>{previewResult.command}</Text>
|
|
||||||
) : (
|
|
||||||
<Caption1 className={mergeClasses(styles.previewMeta, styles.errorRow)}>
|
|
||||||
{previewResult.error}
|
|
||||||
</Caption1>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.folder}>
|
|
||||||
<FolderRegular />
|
|
||||||
<Caption1 className={styles.folderPath} title={folder}>
|
|
||||||
Saving to {folder}
|
|
||||||
</Caption1>
|
|
||||||
<Hint label="Change download folder" placement="top" align="start">
|
|
||||||
<Button size="small" appearance="transparent" onClick={chooseOutputDir}>
|
|
||||||
Change
|
|
||||||
</Button>
|
|
||||||
</Hint>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,17 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Format sorting (advanced)"
|
||||||
|
hint="Raw yt-dlp -S string to rank formats by priority, e.g. res:1080,vcodec:av01,size. Overrides the preferred-codec tiebreaker. Leave empty unless you know yt-dlp's -S syntax."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={value.formatSort}
|
||||||
|
placeholder="res,fps,vcodec:av01"
|
||||||
|
onChange={(_, d) => setOpt('formatSort', d.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
|
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.embedSubtitles}
|
checked={value.embedSubtitles}
|
||||||
@@ -204,6 +215,16 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
label={value.embedChapters ? 'On' : 'Off'}
|
label={value.embedChapters ? 'On' : 'Off'}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Split by chapters"
|
||||||
|
hint="Also save each chapter as its own file. The full-length file is kept too."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={value.splitChapters}
|
||||||
|
onChange={(_, d) => setOpt('splitChapters', d.checked)}
|
||||||
|
label={value.splitChapters ? 'On' : 'Off'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
|
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.embedMetadata}
|
checked={value.embedMetadata}
|
||||||
@@ -230,6 +251,29 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
</Caption1>
|
</Caption1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Sidecar files"
|
||||||
|
hint="Write separate metadata/poster/description files next to each download — handy for Jellyfin, Plex or Kodi libraries."
|
||||||
|
>
|
||||||
|
<div className={styles.subGroup}>
|
||||||
|
<Checkbox
|
||||||
|
checked={value.writeInfoJson}
|
||||||
|
onChange={(_, d) => setOpt('writeInfoJson', !!d.checked)}
|
||||||
|
label="Metadata (.info.json)"
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={value.writeThumbnailFile}
|
||||||
|
onChange={(_, d) => setOpt('writeThumbnailFile', !!d.checked)}
|
||||||
|
label="Thumbnail image file"
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={value.writeDescription}
|
||||||
|
onChange={(_, d) => setOpt('writeDescription', !!d.checked)}
|
||||||
|
label="Description (.description)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,61 @@
|
|||||||
import {
|
import {
|
||||||
Subtitle2,
|
Subtitle2,
|
||||||
Body1,
|
Body1,
|
||||||
|
Caption1,
|
||||||
Button,
|
Button,
|
||||||
|
ProgressBar,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
tokens
|
tokens,
|
||||||
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
import { ArrowDownloadRegular } from '@fluentui/react-icons'
|
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
|
||||||
import { useDownloads } from '../store/downloads'
|
import { useDownloads } from '../store/downloads'
|
||||||
|
import { summarizeQueue } from '../store/queueStats'
|
||||||
import { DownloadBar } from './DownloadBar'
|
import { DownloadBar } from './DownloadBar'
|
||||||
import { QueueItem } from './QueueItem'
|
import { QueueItem } from './QueueItem'
|
||||||
|
import { VirtualList } from './VirtualList'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
|
// Fill the scroll area so the queue list below can flex to the remaining
|
||||||
|
// height and scroll internally (it's virtualized), instead of the whole page
|
||||||
|
// growing to thousands of rows.
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '20px'
|
gap: '20px',
|
||||||
|
height: '100%'
|
||||||
},
|
},
|
||||||
queueHeader: {
|
queueHeader: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between'
|
justifyContent: 'space-between'
|
||||||
},
|
},
|
||||||
list: {
|
headerActions: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
gap: '8px'
|
||||||
gap: '10px'
|
},
|
||||||
|
summary: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '10px 14px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||||
|
},
|
||||||
|
summaryBar: {
|
||||||
|
flexGrow: 1
|
||||||
|
},
|
||||||
|
summaryText: {
|
||||||
|
flexShrink: 0,
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
},
|
||||||
|
listScroll: {
|
||||||
|
flexGrow: 1,
|
||||||
|
// min-height:0 lets this flex child shrink below its content height so it,
|
||||||
|
// not the page, owns the scrolling.
|
||||||
|
minHeight: 0
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -41,7 +72,9 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const items = useDownloads((s) => s.items)
|
const items = useDownloads((s) => s.items)
|
||||||
const clearFinished = useDownloads((s) => s.clearFinished)
|
const clearFinished = useDownloads((s) => s.clearFinished)
|
||||||
|
const retryAll = useDownloads((s) => s.retryAll)
|
||||||
|
|
||||||
|
const summary = summarizeQueue(items)
|
||||||
const hasFinished = items.some(
|
const hasFinished = items.some(
|
||||||
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
||||||
)
|
)
|
||||||
@@ -50,14 +83,38 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<DownloadBar />
|
<DownloadBar />
|
||||||
|
|
||||||
|
{summary.active && (
|
||||||
|
<div className={styles.summary}>
|
||||||
|
<ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" />
|
||||||
|
<Caption1 className={styles.summaryText}>
|
||||||
|
{summary.downloading} downloading
|
||||||
|
{summary.queued ? `, ${summary.queued} queued` : ''}
|
||||||
|
{summary.speedLabel ? ` • ${summary.speedLabel}` : ''}
|
||||||
|
{summary.etaLabel ? ` • ~${summary.etaLabel} left` : ''}
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.queueHeader}>
|
<div className={styles.queueHeader}>
|
||||||
<Subtitle2>Queue ({items.length})</Subtitle2>
|
<Subtitle2>Queue ({items.length})</Subtitle2>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
{summary.failed > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<ArrowClockwiseRegular />}
|
||||||
|
onClick={retryAll}
|
||||||
|
>
|
||||||
|
Retry all failed ({summary.failed})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{hasFinished && (
|
{hasFinished && (
|
||||||
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
||||||
Clear finished
|
Clear finished
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
@@ -65,11 +122,15 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
|
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.list}>
|
<VirtualList
|
||||||
{items.map((item) => (
|
items={items}
|
||||||
<QueueItem key={item.id} item={item} />
|
className={styles.listScroll}
|
||||||
))}
|
gap={10}
|
||||||
</div>
|
overscan={6}
|
||||||
|
estimateSize={() => 100}
|
||||||
|
getKey={(item) => item.id}
|
||||||
|
renderItem={(item) => <QueueItem item={item} />}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,13 +30,15 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
top: { bottom: 'calc(100% + 6px)' },
|
top: { bottom: 'calc(100% + 6px)' },
|
||||||
bottom: { top: 'calc(100% + 6px)' },
|
bottom: { top: 'calc(100% + 6px)' },
|
||||||
|
right: { left: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' },
|
||||||
|
left: { right: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' },
|
||||||
alignStart: { left: 0 },
|
alignStart: { left: 0 },
|
||||||
alignEnd: { right: 0 }
|
alignEnd: { right: 0 }
|
||||||
})
|
})
|
||||||
|
|
||||||
interface HintProps {
|
interface HintProps {
|
||||||
label: string
|
label: string
|
||||||
placement?: 'top' | 'bottom'
|
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||||
align?: 'start' | 'end'
|
align?: 'start' | 'end'
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
@@ -56,8 +58,16 @@ export function Hint({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
className={mergeClasses(
|
className={mergeClasses(
|
||||||
styles.bubble,
|
styles.bubble,
|
||||||
placement === 'bottom' ? styles.bottom : styles.top,
|
placement === 'bottom'
|
||||||
align === 'end' ? styles.alignEnd : styles.alignStart
|
? styles.bottom
|
||||||
|
: placement === 'right'
|
||||||
|
? styles.right
|
||||||
|
: placement === 'left'
|
||||||
|
? styles.left
|
||||||
|
: styles.top,
|
||||||
|
// start/end alignment only applies to vertical (top/bottom) placements
|
||||||
|
(placement === 'top' || placement === 'bottom') &&
|
||||||
|
(align === 'end' ? styles.alignEnd : styles.alignStart)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import {
|
|||||||
OpenRegular,
|
OpenRegular,
|
||||||
FolderRegular,
|
FolderRegular,
|
||||||
DeleteRegular,
|
DeleteRegular,
|
||||||
VideoClipRegular,
|
|
||||||
MusicNote2Regular,
|
|
||||||
HistoryRegular,
|
HistoryRegular,
|
||||||
SearchRegular,
|
SearchRegular,
|
||||||
ArrowClockwiseRegular,
|
ArrowClockwiseRegular,
|
||||||
@@ -24,9 +22,11 @@ import {
|
|||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import type { HistoryEntry, MediaKind } from '@shared/ipc'
|
import type { HistoryEntry, MediaKind } from '@shared/ipc'
|
||||||
import { useHistory } from '../store/history'
|
import { useHistory } from '../store/history'
|
||||||
import { useSettings } from '../store/settings'
|
import { useResolvedDark } from '../store/systemTheme'
|
||||||
import { useDownloads } from '../store/downloads'
|
import { useDownloads } from '../store/downloads'
|
||||||
import { thumbColors } from '../theme'
|
import { thumbColors } from '../theme'
|
||||||
|
import { thumbUrl } from '../thumb'
|
||||||
|
import { MediaThumb } from './MediaThumb'
|
||||||
import { Hint } from './Hint'
|
import { Hint } from './Hint'
|
||||||
import { Select } from './Select'
|
import { Select } from './Select'
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ function formatWhen(ts: number): string {
|
|||||||
|
|
||||||
export function HistoryView(): React.JSX.Element {
|
export function HistoryView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const isDark = useSettings((s) => s.theme === 'dark')
|
const isDark = useResolvedDark()
|
||||||
const tc = thumbColors[isDark ? 'dark' : 'light']
|
const tc = thumbColors[isDark ? 'dark' : 'light']
|
||||||
const entries = useHistory((s) => s.entries)
|
const entries = useHistory((s) => s.entries)
|
||||||
const openFile = useHistory((s) => s.openFile)
|
const openFile = useHistory((s) => s.openFile)
|
||||||
@@ -282,7 +282,6 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
) : (
|
) : (
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{filtered.map((h: HistoryEntry) => {
|
{filtered.map((h: HistoryEntry) => {
|
||||||
const t = h.kind === 'audio' ? tc.audio : tc.video
|
|
||||||
return (
|
return (
|
||||||
<div key={h.id} className={styles.row}>
|
<div key={h.id} className={styles.row}>
|
||||||
{selectMode && (
|
{selectMode && (
|
||||||
@@ -292,13 +291,12 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
aria-label={`Select ${h.title}`}
|
aria-label={`Select ${h.title}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={styles.thumb} style={{ backgroundColor: t.bg, color: t.fg }}>
|
<MediaThumb
|
||||||
{h.kind === 'audio' ? (
|
className={styles.thumb}
|
||||||
<MusicNote2Regular fontSize={22} />
|
src={thumbUrl({ thumbnail: h.thumbnail, url: h.url })}
|
||||||
) : (
|
kind={h.kind}
|
||||||
<VideoClipRegular fontSize={22} />
|
iconSize={22}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<Text className={styles.title}>{h.title}</Text>
|
<Text className={styles.title}>{h.title}</Text>
|
||||||
<Caption1 className={styles.meta}>
|
<Caption1 className={styles.meta}>
|
||||||
|
|||||||
@@ -0,0 +1,719 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Subtitle2,
|
||||||
|
Body1,
|
||||||
|
Caption1,
|
||||||
|
Text,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Switch,
|
||||||
|
Spinner,
|
||||||
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
|
tokens,
|
||||||
|
shorthands
|
||||||
|
} from '@fluentui/react-components'
|
||||||
|
import {
|
||||||
|
SearchRegular,
|
||||||
|
ArrowSyncRegular,
|
||||||
|
ArrowClockwiseRegular,
|
||||||
|
DeleteRegular,
|
||||||
|
ArrowDownloadRegular,
|
||||||
|
ChevronDownRegular,
|
||||||
|
ChevronRightRegular,
|
||||||
|
AppsListRegular,
|
||||||
|
VideoClipMultipleRegular,
|
||||||
|
AlertRegular,
|
||||||
|
LibraryRegular
|
||||||
|
} from '@fluentui/react-icons'
|
||||||
|
import type { MediaItem, Source } from '@shared/ipc'
|
||||||
|
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
|
||||||
|
|
||||||
|
/** 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
root: { display: 'flex', flexDirection: 'column', gap: '18px' },
|
||||||
|
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
|
||||||
|
sub: { color: tokens.colorNeutralForeground3 },
|
||||||
|
addRow: { display: 'flex', gap: '8px' },
|
||||||
|
addInput: { flexGrow: 1 },
|
||||||
|
toolbar: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '14px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
paddingTop: '2px'
|
||||||
|
},
|
||||||
|
toolbarSpacer: { flexGrow: 1 },
|
||||||
|
switchRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
color: tokens.colorNeutralForeground2
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
color: tokens.colorNeutralForeground2,
|
||||||
|
fontSize: tokens.fontSizeBase200
|
||||||
|
},
|
||||||
|
error: { color: tokens.colorPaletteRedForeground1 },
|
||||||
|
empty: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '56px 16px',
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
textAlign: 'center'
|
||||||
|
},
|
||||||
|
list: { display: 'flex', flexDirection: 'column', gap: '10px' },
|
||||||
|
card: {
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
overflow: 'hidden'
|
||||||
|
},
|
||||||
|
cardHead: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '12px 14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
|
||||||
|
},
|
||||||
|
srcIcon: {
|
||||||
|
width: '34px',
|
||||||
|
height: '34px',
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
|
backgroundColor: tokens.colorBrandBackground2,
|
||||||
|
color: tokens.colorBrandForeground2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '18px'
|
||||||
|
},
|
||||||
|
srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
|
||||||
|
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
|
||||||
|
srcTitle: { fontWeight: tokens.fontWeightSemibold, color: tokens.colorNeutralForeground1 },
|
||||||
|
watchBadge: {
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: tokens.fontSizeBase100,
|
||||||
|
padding: '0 7px',
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusCircular),
|
||||||
|
backgroundColor: tokens.colorBrandBackground2,
|
||||||
|
color: tokens.colorBrandForeground2
|
||||||
|
},
|
||||||
|
srcSub: { color: tokens.colorNeutralForeground3 },
|
||||||
|
detail: {
|
||||||
|
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
|
padding: '12px 14px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px'
|
||||||
|
},
|
||||||
|
actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
|
||||||
|
actionSpacer: { flexGrow: 1 },
|
||||||
|
groupHead: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '6px 4px',
|
||||||
|
color: tokens.colorNeutralForeground2,
|
||||||
|
cursor: 'pointer',
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||||
|
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
|
||||||
|
},
|
||||||
|
groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
|
||||||
|
row: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '5px 4px 5px 18px'
|
||||||
|
},
|
||||||
|
plainList: { display: 'flex', flexDirection: 'column' },
|
||||||
|
rowThumb: {
|
||||||
|
width: '60px',
|
||||||
|
height: '34px',
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusSmall)
|
||||||
|
},
|
||||||
|
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
|
||||||
|
rowTitle: {
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
color: tokens.colorNeutralForeground1
|
||||||
|
},
|
||||||
|
rowMeta: { color: tokens.colorNeutralForeground3 },
|
||||||
|
pill: {
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
padding: '1px 8px',
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusCircular),
|
||||||
|
backgroundColor: tokens.colorNeutralBackground3,
|
||||||
|
color: tokens.colorNeutralForeground3
|
||||||
|
},
|
||||||
|
pillDownloading: {
|
||||||
|
backgroundColor: tokens.colorBrandBackground2,
|
||||||
|
color: tokens.colorBrandForeground2
|
||||||
|
},
|
||||||
|
pillCompleted: {
|
||||||
|
backgroundColor: tokens.colorPaletteGreenBackground2,
|
||||||
|
color: tokens.colorPaletteGreenForeground2
|
||||||
|
},
|
||||||
|
pillError: {
|
||||||
|
backgroundColor: tokens.colorPaletteRedBackground2,
|
||||||
|
color: tokens.colorPaletteRedForeground2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
|
||||||
|
function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[] }[] {
|
||||||
|
const map = new Map<string, MediaItem[]>()
|
||||||
|
for (const it of items) {
|
||||||
|
const arr = map.get(it.playlistTitle) ?? []
|
||||||
|
arr.push(it)
|
||||||
|
map.set(it.playlistTitle, arr)
|
||||||
|
}
|
||||||
|
const groups = [...map.entries()].map(([title, its]) => ({
|
||||||
|
title,
|
||||||
|
items: [...its].sort((a, b) => a.playlistIndex - b.playlistIndex)
|
||||||
|
}))
|
||||||
|
groups.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.title === 'Uploads' ? 1 : 0) - (b.title === 'Uploads' ? 1 : 0) ||
|
||||||
|
a.title.localeCompare(b.title)
|
||||||
|
)
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
function relTime(ms?: number): string {
|
||||||
|
if (!ms) return 'never'
|
||||||
|
const mins = Math.round((Date.now() - ms) / 60000)
|
||||||
|
if (mins < 1) return 'just now'
|
||||||
|
if (mins < 60) return `${mins} min ago`
|
||||||
|
const hrs = Math.round(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs} h ago`
|
||||||
|
return `${Math.round(hrs / 24)} d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryView(): React.JSX.Element {
|
||||||
|
const styles = useStyles()
|
||||||
|
const sources = useSources((s) => s.sources)
|
||||||
|
const itemsBySource = useSources((s) => s.itemsBySource)
|
||||||
|
const selectedSourceId = useSources((s) => s.selectedSourceId)
|
||||||
|
const indexing = useSources((s) => s.indexing)
|
||||||
|
const selectSource = useSources((s) => s.selectSource)
|
||||||
|
const indexSource = useSources((s) => s.indexSource)
|
||||||
|
const reindexSource = useSources((s) => s.reindexSource)
|
||||||
|
const removeSource = useSources((s) => s.removeSource)
|
||||||
|
const enqueueItems = useSources((s) => s.enqueueItems)
|
||||||
|
const setWatched = useSources((s) => s.setWatched)
|
||||||
|
const syncWatched = useSources((s) => s.syncWatched)
|
||||||
|
const syncing = useSources((s) => s.syncing)
|
||||||
|
const downloadItems = useDownloads((s) => s.items)
|
||||||
|
const autoDownloadNew = useSettings((s) => s.autoDownloadNew)
|
||||||
|
const updateSettings = useSettings((s) => s.update)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Load the current scheduled-sync (Task Scheduler) state once.
|
||||||
|
useEffect(() => {
|
||||||
|
if (PREVIEW) return
|
||||||
|
window.api.getScheduledSync().then((s) => setScheduled(s.enabled)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function onCheckNew(): Promise<void> {
|
||||||
|
setSyncNote(null)
|
||||||
|
const n = await syncWatched()
|
||||||
|
setSyncNote(n > 0 ? `Found ${n} new video${n === 1 ? '' : 's'}.` : 'No new videos.')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleScheduled(next: boolean): Promise<void> {
|
||||||
|
setScheduled(next) // optimistic
|
||||||
|
if (PREVIEW) return
|
||||||
|
const res = await window.api.setScheduledSync(next)
|
||||||
|
setScheduled(res.enabled)
|
||||||
|
if (res.error) setError(res.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
|
const m = new Map<string, DownloadStatus>()
|
||||||
|
for (const d of downloadItems) m.set(d.url, d.status)
|
||||||
|
return m
|
||||||
|
}, [downloadItems])
|
||||||
|
|
||||||
|
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()
|
||||||
|
if (!u || indexing.active) return
|
||||||
|
const res = await indexSource(u)
|
||||||
|
if (res.ok) setUrl('')
|
||||||
|
else setError(res.error ?? 'Could not index that link.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(id: string, on: boolean): void {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (on) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
for (const it of groupItems) {
|
||||||
|
if (on) next.add(it.id)
|
||||||
|
else next.delete(it.id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 || 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
|
||||||
|
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 {
|
||||||
|
if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading)
|
||||||
|
if (status === 'completed') return mergeClasses(styles.pill, styles.pillCompleted)
|
||||||
|
if (status === 'error') return mergeClasses(styles.pill, styles.pillError)
|
||||||
|
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}>
|
||||||
|
<Subtitle2>Library</Subtitle2>
|
||||||
|
<Caption1 className={styles.sub}>
|
||||||
|
Index a channel or playlist once, then download it into organized folders.
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.addRow}>
|
||||||
|
<Input
|
||||||
|
className={styles.addInput}
|
||||||
|
value={url}
|
||||||
|
onChange={(_, d) => setUrl(d.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
|
||||||
|
placeholder="Paste a channel or playlist URL…"
|
||||||
|
size="large"
|
||||||
|
contentBefore={<LibraryRegular />}
|
||||||
|
disabled={indexing.active}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
appearance="primary"
|
||||||
|
icon={indexing.active ? <Spinner size="tiny" /> : <SearchRegular />}
|
||||||
|
onClick={onIndex}
|
||||||
|
disabled={!url.trim() || indexing.active}
|
||||||
|
>
|
||||||
|
Index
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="secondary"
|
||||||
|
icon={syncing ? <Spinner size="tiny" /> : <ArrowClockwiseRegular />}
|
||||||
|
onClick={onCheckNew}
|
||||||
|
disabled={syncing || watchedCount === 0}
|
||||||
|
>
|
||||||
|
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
|
||||||
|
</Button>
|
||||||
|
{syncNote && <Caption1 className={styles.sub}>{syncNote}</Caption1>}
|
||||||
|
<div className={styles.toolbarSpacer} />
|
||||||
|
<span className={styles.switchRow}>
|
||||||
|
<Caption1>Auto-download new</Caption1>
|
||||||
|
<Switch
|
||||||
|
checked={autoDownloadNew}
|
||||||
|
onChange={(_, d) => updateSettings({ autoDownloadNew: d.checked })}
|
||||||
|
aria-label="Auto-download new uploads"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className={styles.switchRow}>
|
||||||
|
<Caption1>Daily sync</Caption1>
|
||||||
|
<Switch
|
||||||
|
checked={scheduled}
|
||||||
|
onChange={(_, d) => toggleScheduled(d.checked)}
|
||||||
|
aria-label="Daily background sync"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{indexing.active && (
|
||||||
|
<div className={styles.progress}>
|
||||||
|
<Spinner size="tiny" />
|
||||||
|
<Text>
|
||||||
|
{indexing.message ?? 'Indexing…'}
|
||||||
|
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <Caption1 className={styles.error}>{error}</Caption1>}
|
||||||
|
|
||||||
|
{sources.length === 0 && !indexing.active ? (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
<LibraryRegular fontSize={40} />
|
||||||
|
<Body1>No channels or playlists yet. Paste one above to index it.</Body1>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{sources.map((src) => (
|
||||||
|
<SourceCard
|
||||||
|
key={src.id}
|
||||||
|
styles={styles}
|
||||||
|
source={src}
|
||||||
|
expanded={selectedSourceId === src.id}
|
||||||
|
onToggleExpand={() =>
|
||||||
|
selectSource(selectedSourceId === src.id ? null : src.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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)}
|
||||||
|
</Caption1>
|
||||||
|
<div className={styles.actionSpacer} />
|
||||||
|
{selected.size > 0 ? (
|
||||||
|
<>
|
||||||
|
<Button size="small" appearance="subtle" onClick={() => setSelected(new Set())}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="primary"
|
||||||
|
icon={<ArrowDownloadRegular />}
|
||||||
|
onClick={downloadSelected}
|
||||||
|
disabled={selectedActionable.length === 0}
|
||||||
|
>
|
||||||
|
Download {selectedActionable.length} selected
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="primary"
|
||||||
|
icon={<ArrowDownloadRegular />}
|
||||||
|
onClick={downloadPending}
|
||||||
|
disabled={pendingItems.length === 0}
|
||||||
|
>
|
||||||
|
Download {pendingItems.length} pending
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span className={styles.switchRow}>
|
||||||
|
<Caption1>Watch</Caption1>
|
||||||
|
<Switch
|
||||||
|
checked={!!src.watched}
|
||||||
|
onChange={(_, d) => setWatched(src.id, !!d.checked)}
|
||||||
|
aria-label={`Watch ${src.title} for new uploads`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<ArrowSyncRegular />}
|
||||||
|
onClick={() => reindexSource(src.id)}
|
||||||
|
disabled={indexing.active}
|
||||||
|
>
|
||||||
|
Re-index
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={() => removeSource(src.id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One collapsible source card (header always shown; children render when expanded). */
|
||||||
|
function SourceCard({
|
||||||
|
styles,
|
||||||
|
source,
|
||||||
|
expanded,
|
||||||
|
onToggleExpand,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
styles: ReturnType<typeof useStyles>
|
||||||
|
source: Source
|
||||||
|
expanded: boolean
|
||||||
|
onToggleExpand: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div
|
||||||
|
className={styles.cardHead}
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggleExpand()}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||||
|
<div className={styles.srcIcon}>
|
||||||
|
<VideoClipMultipleRegular />
|
||||||
|
</div>
|
||||||
|
<div className={styles.srcMeta}>
|
||||||
|
<span className={styles.srcTitleRow}>
|
||||||
|
<span className={styles.srcTitle}>{source.title}</span>
|
||||||
|
{source.watched && (
|
||||||
|
<span className={styles.watchBadge}>
|
||||||
|
<AlertRegular fontSize={11} /> Watching
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Caption1 className={styles.srcSub}>
|
||||||
|
{source.kind === 'channel' ? 'Channel' : 'Playlist'} · {source.itemCount} videos
|
||||||
|
{source.channel && source.channel !== source.title ? ` · ${source.channel}` : ''}
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expanded && children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { makeStyles, mergeClasses } from '@fluentui/react-components'
|
||||||
|
import { VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
|
||||||
|
import type { MediaKind } from '@shared/ipc'
|
||||||
|
import { useResolvedDark } from '../store/systemTheme'
|
||||||
|
import { thumbColors } from '../theme'
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
box: {
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden'
|
||||||
|
},
|
||||||
|
img: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A media preview thumbnail: shows the image at `src` when one is available and
|
||||||
|
* loads, otherwise a kind icon on a quiet neutral tint (the same palette the
|
||||||
|
* placeholders used before). The load failure is tracked per-src, so a thumbnail
|
||||||
|
* that arrives later (e.g. resolved after a probe) is retried rather than left on
|
||||||
|
* the fallback. The caller sizes the box via `className` (width/height/radius).
|
||||||
|
*/
|
||||||
|
export function MediaThumb({
|
||||||
|
src,
|
||||||
|
kind,
|
||||||
|
iconSize = 24,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
src?: string
|
||||||
|
kind: MediaKind
|
||||||
|
iconSize?: number
|
||||||
|
className?: string
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const styles = useStyles()
|
||||||
|
const isDark = useResolvedDark()
|
||||||
|
const colors = thumbColors[isDark ? 'dark' : 'light'][kind === 'audio' ? 'audio' : 'video']
|
||||||
|
const [failedSrc, setFailedSrc] = useState<string | null>(null)
|
||||||
|
const showImg = !!src && failedSrc !== src
|
||||||
|
|
||||||
|
// The neutral tint always backs the box, so there's a placeholder behind the
|
||||||
|
// image while it loads (and the kind icon stays legible when there's no image).
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={mergeClasses(styles.box, className)}
|
||||||
|
style={{ backgroundColor: colors.bg, color: colors.fg }}
|
||||||
|
>
|
||||||
|
{showImg ? (
|
||||||
|
<img
|
||||||
|
className={styles.img}
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setFailedSrc(src ?? null)}
|
||||||
|
/>
|
||||||
|
) : kind === 'audio' ? (
|
||||||
|
<MusicNote2Regular fontSize={iconSize} />
|
||||||
|
) : (
|
||||||
|
<VideoClipRegular fontSize={iconSize} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Body1,
|
Body1,
|
||||||
Caption1,
|
Caption1,
|
||||||
Field,
|
Field,
|
||||||
Input,
|
|
||||||
Button,
|
Button,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -12,7 +11,6 @@ import {
|
|||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
import {
|
import {
|
||||||
ArrowDownloadFilled,
|
ArrowDownloadFilled,
|
||||||
FolderRegular,
|
|
||||||
ClipboardPasteRegular,
|
ClipboardPasteRegular,
|
||||||
HistoryRegular,
|
HistoryRegular,
|
||||||
OptionsRegular,
|
OptionsRegular,
|
||||||
@@ -56,13 +54,8 @@ const useStyles = makeStyles({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '26px'
|
fontSize: '26px'
|
||||||
},
|
},
|
||||||
folderRow: {
|
folderNote: {
|
||||||
display: 'flex',
|
color: tokens.colorNeutralForeground3
|
||||||
gap: '8px',
|
|
||||||
alignItems: 'flex-end'
|
|
||||||
},
|
|
||||||
folderInput: {
|
|
||||||
flexGrow: 1
|
|
||||||
},
|
},
|
||||||
tips: {
|
tips: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -99,8 +92,6 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
|
|||||||
|
|
||||||
export function Onboarding(): React.JSX.Element {
|
export function Onboarding(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const outputDir = useSettings((s) => s.outputDir)
|
|
||||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -118,18 +109,12 @@ export function Onboarding(): React.JSX.Element {
|
|||||||
audio. yt-dlp and ffmpeg are bundled, so there's nothing else to install.
|
audio. yt-dlp and ffmpeg are bundled, so there's nothing else to install.
|
||||||
</Body1>
|
</Body1>
|
||||||
|
|
||||||
<Field label="Download folder">
|
<Field label="Where downloads go">
|
||||||
<div className={styles.folderRow}>
|
<Caption1 className={styles.folderNote}>
|
||||||
<Input
|
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
|
||||||
readOnly
|
<strong>Documents\Audio</strong>. You can point each to a different folder any time
|
||||||
className={styles.folderInput}
|
in Settings.
|
||||||
value={outputDir}
|
</Caption1>
|
||||||
contentBefore={<FolderRegular />}
|
|
||||||
/>
|
|
||||||
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
|
||||||
Browse
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className={styles.tips}>
|
<div className={styles.tips}>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
Caption1,
|
Caption1,
|
||||||
@@ -15,15 +16,18 @@ import {
|
|||||||
OpenRegular,
|
OpenRegular,
|
||||||
FolderRegular,
|
FolderRegular,
|
||||||
ArrowClockwiseRegular,
|
ArrowClockwiseRegular,
|
||||||
VideoClipRegular,
|
ArrowDownloadRegular,
|
||||||
MusicNote2Regular,
|
ArrowUpRegular,
|
||||||
|
BookmarkRegular,
|
||||||
|
PauseRegular,
|
||||||
|
PlayRegular,
|
||||||
CheckmarkCircleFilled,
|
CheckmarkCircleFilled,
|
||||||
ErrorCircleFilled,
|
ErrorCircleFilled,
|
||||||
EyeOffRegular
|
EyeOffRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
|
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
|
||||||
import { useSettings } from '../store/settings'
|
import { thumbUrl } from '../thumb'
|
||||||
import { thumbColors } from '../theme'
|
import { MediaThumb } from './MediaThumb'
|
||||||
import { Hint } from './Hint'
|
import { Hint } from './Hint'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
@@ -92,6 +96,8 @@ const useStyles = makeStyles({
|
|||||||
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
|
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
|
||||||
queued: { label: 'Queued', color: 'subtle' },
|
queued: { label: 'Queued', color: 'subtle' },
|
||||||
downloading: { label: 'Downloading', color: 'brand' },
|
downloading: { label: 'Downloading', color: 'brand' },
|
||||||
|
paused: { label: 'Paused', color: 'warning' },
|
||||||
|
saved: { label: 'Saved', color: 'subtle' },
|
||||||
completed: { label: 'Completed', color: 'success' },
|
completed: { label: 'Completed', color: 'success' },
|
||||||
error: { label: 'Failed', color: 'danger' },
|
error: { label: 'Failed', color: 'danger' },
|
||||||
canceled: { label: 'Canceled', color: 'warning' }
|
canceled: { label: 'Canceled', color: 'warning' }
|
||||||
@@ -101,11 +107,26 @@ function pct(progress: number): string {
|
|||||||
return `${Math.round(progress * 100)}%`
|
return `${Math.round(progress * 100)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
|
function fmtSchedule(ms: number): string {
|
||||||
|
try {
|
||||||
|
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueueItem = memo(function QueueItem({
|
||||||
|
item
|
||||||
|
}: {
|
||||||
|
item: DownloadItem
|
||||||
|
}): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const isDark = useSettings((s) => s.theme === 'dark')
|
|
||||||
const thumb = thumbColors[isDark ? 'dark' : 'light'][item.kind === 'audio' ? 'audio' : 'video']
|
|
||||||
const cancel = useDownloads((s) => s.cancel)
|
const cancel = useDownloads((s) => s.cancel)
|
||||||
|
const pause = useDownloads((s) => s.pause)
|
||||||
|
const resume = useDownloads((s) => s.resume)
|
||||||
|
const prioritize = useDownloads((s) => s.prioritize)
|
||||||
|
const saveForLater = useDownloads((s) => s.saveForLater)
|
||||||
|
const queueNow = useDownloads((s) => s.queueNow)
|
||||||
const remove = useDownloads((s) => s.remove)
|
const remove = useDownloads((s) => s.remove)
|
||||||
const retry = useDownloads((s) => s.retry)
|
const retry = useDownloads((s) => s.retry)
|
||||||
const openFile = useDownloads((s) => s.openFile)
|
const openFile = useDownloads((s) => s.openFile)
|
||||||
@@ -124,13 +145,12 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<div className={styles.thumb} style={{ backgroundColor: thumb.bg, color: thumb.fg }}>
|
<MediaThumb
|
||||||
{item.kind === 'audio' ? (
|
className={styles.thumb}
|
||||||
<MusicNote2Regular fontSize={28} />
|
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
|
||||||
) : (
|
kind={item.kind}
|
||||||
<VideoClipRegular fontSize={28} />
|
iconSize={28}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<div className={styles.titleRow}>
|
<div className={styles.titleRow}>
|
||||||
@@ -177,12 +197,79 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{item.status === 'paused' && (
|
||||||
|
<div className={styles.progressRow}>
|
||||||
|
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
|
||||||
|
<Caption1 className={styles.stats}>Paused • {pct(item.progress)}</Caption1>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.status === 'saved' && (
|
||||||
|
<Caption1 className={styles.stats}>
|
||||||
|
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'}
|
||||||
|
</Caption1>
|
||||||
|
)}
|
||||||
|
|
||||||
{item.status === 'error' && item.error && (
|
{item.status === 'error' && item.error && (
|
||||||
<Caption1 className={styles.error}>{item.error}</Caption1>
|
<Caption1 className={styles.error}>{item.error}</Caption1>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
|
{item.status === 'downloading' && (
|
||||||
|
<Hint label="Pause" placement="top" align="end">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<PauseRegular />}
|
||||||
|
onClick={() => pause(item.id)}
|
||||||
|
aria-label="Pause"
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.status === 'queued' && (
|
||||||
|
<>
|
||||||
|
<Hint label="Download next" placement="top" align="end">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<ArrowUpRegular />}
|
||||||
|
onClick={() => prioritize(item.id)}
|
||||||
|
aria-label="Download next"
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
<Hint label="Save for later" placement="top" align="end">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<BookmarkRegular />}
|
||||||
|
onClick={() => saveForLater(item.id)}
|
||||||
|
aria-label="Save for later"
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.status === 'paused' && (
|
||||||
|
<Hint label="Resume" placement="top" align="end">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<PlayRegular />}
|
||||||
|
onClick={() => resume(item.id)}
|
||||||
|
aria-label="Resume"
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.status === 'saved' && (
|
||||||
|
<Hint label="Add to queue" placement="top" align="end">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<ArrowDownloadRegular />}
|
||||||
|
onClick={() => queueNow(item.id)}
|
||||||
|
aria-label="Add to queue"
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
)}
|
||||||
|
|
||||||
{active && (
|
{active && (
|
||||||
<Hint label="Cancel" placement="top" align="end">
|
<Hint label="Cancel" placement="top" align="end">
|
||||||
<Button
|
<Button
|
||||||
@@ -239,4 +326,4 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
Input,
|
Input,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Caption1,
|
Caption1,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
|
ProgressBar,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
mergeClasses,
|
mergeClasses,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -31,13 +32,18 @@ import {
|
|||||||
CopyRegular,
|
CopyRegular,
|
||||||
DeleteRegular,
|
DeleteRegular,
|
||||||
PaintBucketRegular,
|
PaintBucketRegular,
|
||||||
AccessibilityRegular
|
AccessibilityRegular,
|
||||||
|
ArrowSyncRegular,
|
||||||
|
OpenRegular,
|
||||||
|
SearchRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import {
|
import {
|
||||||
COOKIE_BROWSERS,
|
COOKIE_BROWSERS,
|
||||||
type YtdlpVersionResult,
|
type YtdlpVersionResult,
|
||||||
type YtdlpUpdateChannel,
|
type YtdlpUpdateChannel,
|
||||||
type YtdlpUpdateResult,
|
type YtdlpUpdateResult,
|
||||||
|
type FfmpegVersionResult,
|
||||||
|
type AppUpdateInfo,
|
||||||
type MediaKind,
|
type MediaKind,
|
||||||
type CookieSource,
|
type CookieSource,
|
||||||
type CookieBrowser,
|
type CookieBrowser,
|
||||||
@@ -71,6 +77,17 @@ const useStyles = makeStyles({
|
|||||||
padding: '20px',
|
padding: '20px',
|
||||||
...shorthands.borderRadius(tokens.borderRadiusXLarge)
|
...shorthands.borderRadius(tokens.borderRadiusXLarge)
|
||||||
},
|
},
|
||||||
|
notesBox: {
|
||||||
|
maxHeight: '180px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '10px 12px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
lineHeight: tokens.lineHeightBase300
|
||||||
|
},
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -170,8 +187,32 @@ const UPDATE_CHANNEL_OPTIONS = [
|
|||||||
export function SettingsView(): React.JSX.Element {
|
export function SettingsView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
|
||||||
const outputDir = useSettings((s) => s.outputDir)
|
// Settings search (Phase O). Rather than thread a query through all ~11 cards,
|
||||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
// 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)
|
||||||
|
const clearDir = useSettings((s) => s.clearDir)
|
||||||
const defaultKind = useSettings((s) => s.defaultKind)
|
const defaultKind = useSettings((s) => s.defaultKind)
|
||||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||||
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||||
@@ -185,6 +226,8 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
const proxy = useSettings((s) => s.proxy)
|
const proxy = useSettings((s) => s.proxy)
|
||||||
const rateLimit = useSettings((s) => s.rateLimit)
|
const rateLimit = useSettings((s) => s.rateLimit)
|
||||||
const useAria2c = useSettings((s) => s.useAria2c)
|
const useAria2c = useSettings((s) => s.useAria2c)
|
||||||
|
const youtubePlayerClient = useSettings((s) => s.youtubePlayerClient)
|
||||||
|
const youtubePoToken = useSettings((s) => s.youtubePoToken)
|
||||||
const cookieSource = useSettings((s) => s.cookieSource)
|
const cookieSource = useSettings((s) => s.cookieSource)
|
||||||
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
|
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
|
||||||
const restrictFilenames = useSettings((s) => s.restrictFilenames)
|
const restrictFilenames = useSettings((s) => s.restrictFilenames)
|
||||||
@@ -192,6 +235,10 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
||||||
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
||||||
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
|
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
|
||||||
|
const minimizeToTray = useSettings((s) => s.minimizeToTray)
|
||||||
|
const autoUpdateYtdlp = useSettings((s) => s.autoUpdateYtdlp)
|
||||||
|
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
|
||||||
|
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
const templates = useTemplates((s) => s.templates)
|
const templates = useTemplates((s) => s.templates)
|
||||||
const errorEntries = useErrorLog((s) => s.entries)
|
const errorEntries = useErrorLog((s) => s.entries)
|
||||||
@@ -202,11 +249,19 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
|
|
||||||
const [checking, setChecking] = useState(false)
|
const [checking, setChecking] = useState(false)
|
||||||
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
|
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
|
||||||
|
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
|
||||||
|
|
||||||
const [updateChannel, setUpdateChannel] = useState<YtdlpUpdateChannel>('stable')
|
|
||||||
const [updating, setUpdating] = useState(false)
|
const [updating, setUpdating] = useState(false)
|
||||||
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
|
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
|
||||||
|
|
||||||
|
// --- AeroFetch app self-update ---
|
||||||
|
const [appVersion, setAppVersion] = useState('')
|
||||||
|
const [appUpd, setAppUpd] = useState<AppUpdateInfo | null>(null)
|
||||||
|
const [appChecking, setAppChecking] = useState(false)
|
||||||
|
const [appDownloading, setAppDownloading] = useState(false)
|
||||||
|
const [appFraction, setAppFraction] = useState<number | undefined>(undefined)
|
||||||
|
const [appUpdError, setAppUpdError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
|
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
|
||||||
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
|
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
|
||||||
const [signingIn, setSigningIn] = useState(false)
|
const [signingIn, setSigningIn] = useState(false)
|
||||||
@@ -221,6 +276,71 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
window.api.cookiesStatus().then(setCookiesStatus)
|
window.api.cookiesStatus().then(setCookiesStatus)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.api.getAppVersion().then(setAppVersion).catch(() => {})
|
||||||
|
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)
|
||||||
|
setAppUpdError(null)
|
||||||
|
try {
|
||||||
|
const info = await window.api.checkForAppUpdate()
|
||||||
|
// Funnel a failed check into the single error slot rather than storing a
|
||||||
|
// second not-ok AppUpdateInfo, so only one error message can ever render.
|
||||||
|
if (info.ok) setAppUpd(info)
|
||||||
|
else setAppUpdError(info.error ?? 'Update check failed.')
|
||||||
|
} catch (e) {
|
||||||
|
setAppUpdError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setAppChecking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installAppUpdate(): Promise<void> {
|
||||||
|
if (!appUpd?.downloadUrl) return
|
||||||
|
setAppDownloading(true)
|
||||||
|
setAppFraction(undefined)
|
||||||
|
setAppUpdError(null)
|
||||||
|
try {
|
||||||
|
const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl)
|
||||||
|
if (!dl.ok || !dl.filePath) {
|
||||||
|
setAppUpdError(dl.error ?? 'Download failed.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const run = await window.api.runAppUpdate(dl.filePath)
|
||||||
|
// On success the app quits as the installer launches; only errors return here.
|
||||||
|
if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.')
|
||||||
|
} catch (e) {
|
||||||
|
setAppUpdError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setAppDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function exportBackup(): Promise<void> {
|
async function exportBackup(): Promise<void> {
|
||||||
setExporting(true)
|
setExporting(true)
|
||||||
setExportResult(null)
|
setExportResult(null)
|
||||||
@@ -310,7 +430,7 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
setUpdating(true)
|
setUpdating(true)
|
||||||
setUpdateResult(null)
|
setUpdateResult(null)
|
||||||
try {
|
try {
|
||||||
const result = await window.api.updateYtdlp(updateChannel)
|
const result = await window.api.updateYtdlp(ytdlpChannel)
|
||||||
setUpdateResult(result)
|
setUpdateResult(result)
|
||||||
if (result.ok) setVersion(null) // stale — prompt a re-check rather than show a wrong version
|
if (result.ok) setVersion(null) // stale — prompt a re-check rather than show a wrong version
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -321,24 +441,71 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root} ref={rootRef}>
|
||||||
|
<div data-settings-search>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(_, d) => setSearch(d.value)}
|
||||||
|
placeholder="Search settings…"
|
||||||
|
contentBefore={<SearchRegular />}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
{noResults && (
|
||||||
|
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}>
|
||||||
|
No settings match “{search}”.
|
||||||
|
</Caption1>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<ArrowDownloadRegular className={styles.sectionIcon} />
|
<ArrowDownloadRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>Downloads</Subtitle2>
|
<Subtitle2>Downloads</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Download folder">
|
<Field
|
||||||
|
label="Video folder"
|
||||||
|
hint="Where video downloads are saved. Leave blank to use Documents\Video."
|
||||||
|
>
|
||||||
<div className={styles.folderRow}>
|
<div className={styles.folderRow}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
className={styles.folderInput}
|
className={styles.folderInput}
|
||||||
value={outputDir}
|
value={videoDir}
|
||||||
|
placeholder="Documents\Video (default)"
|
||||||
contentBefore={<FolderRegular />}
|
contentBefore={<FolderRegular />}
|
||||||
/>
|
/>
|
||||||
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
|
||||||
Browse
|
Browse
|
||||||
</Button>
|
</Button>
|
||||||
|
{videoDir && (
|
||||||
|
<Button appearance="subtle" onClick={() => clearDir('videoDir')}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Audio folder"
|
||||||
|
hint="Where audio downloads are saved. Leave blank to use Documents\Audio."
|
||||||
|
>
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
className={styles.folderInput}
|
||||||
|
value={audioDir}
|
||||||
|
placeholder="Documents\Audio (default)"
|
||||||
|
contentBefore={<FolderRegular />}
|
||||||
|
/>
|
||||||
|
<Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
{audioDir && (
|
||||||
|
<Button appearance="subtle" onClick={() => clearDir('audioDir')}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -391,6 +558,17 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
label={notifyOnComplete ? 'On' : 'Off'}
|
label={notifyOnComplete ? 'On' : 'Off'}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Keep running in the tray"
|
||||||
|
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting. Use the tray icon to reopen or quit."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={minimizeToTray}
|
||||||
|
onChange={(_, d) => update({ minimizeToTray: d.checked })}
|
||||||
|
label={minimizeToTray ? 'On' : 'Off'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
@@ -499,6 +677,28 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
label={useAria2c ? 'On' : 'Off'}
|
label={useAria2c ? 'On' : 'Off'}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="YouTube client (advanced)"
|
||||||
|
hint="Override the YouTube extraction client when downloads start failing the bot check. Try web_safari, tv, or mweb. Leave blank for yt-dlp's default."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={youtubePlayerClient}
|
||||||
|
placeholder="web_safari"
|
||||||
|
onChange={(_, d) => update({ youtubePlayerClient: d.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="YouTube PO token (advanced)"
|
||||||
|
hint="A manually-obtained Proof-of-Origin token for YouTube's bot check, sent via --extractor-args. Usually cookies (above) are the easier fix; automatic minting is planned."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={youtubePoToken}
|
||||||
|
placeholder="(none)"
|
||||||
|
onChange={(_, d) => update({ youtubePoToken: d.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
@@ -731,20 +931,90 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<ArrowSyncRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2>Software update</Subtitle2>
|
||||||
|
</div>
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
{appVersion
|
||||||
|
? `You're running AeroFetch v${appVersion}. Updates are fetched from the AeroFetch release repo.`
|
||||||
|
: 'Checking your AeroFetch version…'}
|
||||||
|
</Caption1>
|
||||||
|
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowSyncRegular />}
|
||||||
|
onClick={checkAppUpdate}
|
||||||
|
disabled={appChecking || appDownloading}
|
||||||
|
>
|
||||||
|
{appChecking ? 'Checking…' : 'Check for updates'}
|
||||||
|
</Button>
|
||||||
|
{appChecking && <Spinner size="tiny" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{appUpd?.ok && appUpd.available && (
|
||||||
|
<>
|
||||||
|
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
|
||||||
|
Version {appUpd.latestVersion} is available — here's what changed:
|
||||||
|
</Text>
|
||||||
|
{appUpd.notes && <div className={styles.notesBox}>{appUpd.notes}</div>}
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
icon={<ArrowDownloadRegular />}
|
||||||
|
onClick={installAppUpdate}
|
||||||
|
disabled={appDownloading || !appUpd.downloadUrl}
|
||||||
|
>
|
||||||
|
{appDownloading ? 'Downloading…' : 'Update now'}
|
||||||
|
</Button>
|
||||||
|
{appUpd.htmlUrl && (
|
||||||
|
<Button icon={<OpenRegular />} onClick={() => window.open(appUpd.htmlUrl, '_blank')}>
|
||||||
|
View release
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!appUpd.downloadUrl && (
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
This release has no installer attached — use “View release” to download it manually.
|
||||||
|
</Caption1>
|
||||||
|
)}
|
||||||
|
{appDownloading && <ProgressBar value={appFraction} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appUpd?.ok && !appUpd.available && (
|
||||||
|
<Text>You're up to date — v{appUpd.currentVersion} is the latest.</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appUpdError && (
|
||||||
|
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{appUpdError}
|
||||||
|
</Caption1>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<InfoRegular className={styles.sectionIcon} />
|
<InfoRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2>About</Subtitle2>
|
<Subtitle2>About</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
AeroFetch is a generic frontend for yt-dlp. It bundles yt-dlp and ffmpeg.
|
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its
|
||||||
|
own copy of yt-dlp, keeping it up to date automatically.
|
||||||
</Caption1>
|
</Caption1>
|
||||||
<div className={styles.folderRow}>
|
|
||||||
<Button onClick={checkVersion} disabled={checking}>
|
<Field
|
||||||
Check yt-dlp version
|
label="Keep yt-dlp updated automatically"
|
||||||
</Button>
|
hint="Checks once a day on launch and updates yt-dlp in the background, so YouTube changes don't start breaking downloads with 403 errors."
|
||||||
{checking && <Spinner size="tiny" />}
|
>
|
||||||
</div>
|
<Switch
|
||||||
|
checked={autoUpdateYtdlp}
|
||||||
|
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
|
||||||
|
label={autoUpdateYtdlp ? 'On' : 'Off'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
{version?.ok && (
|
{version?.ok && (
|
||||||
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
|
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
|
||||||
)}
|
)}
|
||||||
@@ -753,13 +1023,37 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
{version.error}
|
{version.error}
|
||||||
</Caption1>
|
</Caption1>
|
||||||
)}
|
)}
|
||||||
|
{ffmpeg && (
|
||||||
|
<>
|
||||||
|
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
|
||||||
|
ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
|
||||||
|
ffprobe {ffmpeg.ffprobe ?? 'not found'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{ytdlpLastUpdateCheck > 0 && (
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
|
||||||
|
</Caption1>
|
||||||
|
)}
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Button onClick={checkVersion} disabled={checking}>
|
||||||
|
Check yt-dlp version
|
||||||
|
</Button>
|
||||||
|
{checking && <Spinner size="tiny" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Field label="Update channel" hint="Nightly tracks yt-dlp's daily build; stable is the tagged release.">
|
<Field
|
||||||
|
label="Update channel"
|
||||||
|
hint="Nightly tracks yt-dlp's daily build; stable is the tagged release. Nightly follows YouTube changes fastest."
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
aria-label="Update channel"
|
aria-label="Update channel"
|
||||||
value={updateChannel}
|
value={ytdlpChannel}
|
||||||
options={UPDATE_CHANNEL_OPTIONS}
|
options={UPDATE_CHANNEL_OPTIONS}
|
||||||
onChange={(v) => setUpdateChannel(v as YtdlpUpdateChannel)}
|
onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<div className={styles.folderRow}>
|
<div className={styles.folderRow}>
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import {
|
import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||||
Caption1,
|
|
||||||
Switch,
|
|
||||||
makeStyles,
|
|
||||||
mergeClasses,
|
|
||||||
tokens,
|
|
||||||
shorthands
|
|
||||||
} from '@fluentui/react-components'
|
|
||||||
import {
|
import {
|
||||||
ArrowDownloadFilled,
|
ArrowDownloadFilled,
|
||||||
ArrowDownloadRegular,
|
ArrowDownloadRegular,
|
||||||
HistoryRegular,
|
HistoryRegular,
|
||||||
|
LibraryRegular,
|
||||||
|
WindowConsoleRegular,
|
||||||
SettingsRegular,
|
SettingsRegular,
|
||||||
WeatherMoonRegular,
|
WeatherMoonRegular,
|
||||||
WeatherSunnyRegular
|
WeatherSunnyRegular,
|
||||||
|
DesktopRegular,
|
||||||
|
PanelLeftContractRegular,
|
||||||
|
PanelLeftExpandRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
|
import type { ThemeMode } from '@shared/ipc'
|
||||||
|
import { Hint } from './Hint'
|
||||||
|
|
||||||
export type TabValue = 'downloads' | 'history' | 'settings'
|
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
@@ -26,13 +26,48 @@ const useStyles = makeStyles({
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '16px 12px',
|
padding: '16px 12px',
|
||||||
backgroundColor: tokens.colorNeutralBackground1,
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
borderRight: `1px solid ${tokens.colorNeutralStroke2}`
|
borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
|
transition: 'width 0.15s ease'
|
||||||
|
},
|
||||||
|
rootCollapsed: {
|
||||||
|
width: '60px',
|
||||||
|
alignItems: 'center'
|
||||||
|
},
|
||||||
|
topBar: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
paddingBottom: '2px'
|
||||||
|
},
|
||||||
|
topBarCollapsed: {
|
||||||
|
justifyContent: 'center'
|
||||||
|
},
|
||||||
|
iconBtn: {
|
||||||
|
appearance: 'none',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
fontSize: '18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||||
|
color: tokens.colorNeutralForeground2
|
||||||
|
}
|
||||||
},
|
},
|
||||||
brand: {
|
brand: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '11px',
|
gap: '11px',
|
||||||
padding: '6px 10px 14px'
|
padding: '0 10px 14px'
|
||||||
|
},
|
||||||
|
brandCollapsed: {
|
||||||
|
padding: '0 0 12px',
|
||||||
|
justifyContent: 'center'
|
||||||
},
|
},
|
||||||
mark: {
|
mark: {
|
||||||
width: '36px',
|
width: '36px',
|
||||||
@@ -63,7 +98,8 @@ const useStyles = makeStyles({
|
|||||||
nav: {
|
nav: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '3px'
|
gap: '3px',
|
||||||
|
alignSelf: 'stretch'
|
||||||
},
|
},
|
||||||
navItem: {
|
navItem: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -83,6 +119,10 @@ const useStyles = makeStyles({
|
|||||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
navItemCollapsed: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '9px 0'
|
||||||
|
},
|
||||||
navItemActive: {
|
navItemActive: {
|
||||||
backgroundColor: tokens.colorBrandBackground2,
|
backgroundColor: tokens.colorBrandBackground2,
|
||||||
color: tokens.colorBrandForeground2,
|
color: tokens.colorBrandForeground2,
|
||||||
@@ -93,92 +133,200 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
navIcon: {
|
navIcon: {
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
flexShrink: 0
|
flexShrink: 0,
|
||||||
|
display: 'flex'
|
||||||
},
|
},
|
||||||
spacer: {
|
spacer: {
|
||||||
flexGrow: 1
|
flexGrow: 1
|
||||||
},
|
},
|
||||||
themeRow: {
|
// --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
|
||||||
|
themeGroup: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
width: '100%',
|
||||||
justifyContent: 'space-between',
|
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||||
padding: '8px 12px',
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||||
color: tokens.colorNeutralForeground3
|
overflow: 'hidden'
|
||||||
},
|
},
|
||||||
themeLabel: {
|
themeSeg: {
|
||||||
|
flex: 1,
|
||||||
|
appearance: 'none',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: tokens.colorNeutralForeground2,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '9px'
|
justifyContent: 'center',
|
||||||
|
gap: '5px',
|
||||||
|
padding: '7px 4px',
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
fontFamily: tokens.fontFamilyBase,
|
||||||
|
cursor: 'pointer',
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||||
|
}
|
||||||
|
},
|
||||||
|
themeSegActive: {
|
||||||
|
backgroundColor: tokens.colorBrandBackground,
|
||||||
|
color: tokens.colorNeutralForegroundOnBrand,
|
||||||
|
fontWeight: tokens.fontWeightSemibold,
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: tokens.colorBrandBackgroundHover
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
|
const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
|
||||||
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
|
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
|
||||||
|
{ value: 'library', label: 'Library', icon: <LibraryRegular /> },
|
||||||
{ value: 'history', label: 'History', icon: <HistoryRegular /> },
|
{ value: 'history', label: 'History', icon: <HistoryRegular /> },
|
||||||
|
{ value: 'terminal', label: 'Terminal', icon: <WindowConsoleRegular /> },
|
||||||
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
|
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const THEMES: { value: ThemeMode; label: string; icon: React.JSX.Element }[] = [
|
||||||
|
{ value: 'light', label: 'Light', icon: <WeatherSunnyRegular /> },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: <WeatherMoonRegular /> },
|
||||||
|
{ value: 'system', label: 'Auto', icon: <DesktopRegular /> }
|
||||||
|
]
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
tab: TabValue
|
tab: TabValue
|
||||||
onTabChange: (t: TabValue) => void
|
onTabChange: (t: TabValue) => void
|
||||||
|
/** the current theme preference: explicit light/dark, or 'system' to follow the OS */
|
||||||
|
theme: ThemeMode
|
||||||
|
/** whether the resolved theme is currently dark (drives the collapsed toggle icon) */
|
||||||
isDark: boolean
|
isDark: boolean
|
||||||
/** true when theme is 'system' — the quick toggle still works (it sets an explicit mode) */
|
onSetTheme: (mode: ThemeMode) => void
|
||||||
followingSystem: boolean
|
/** AeroFetch version string, e.g. '0.3.2' ('' until loaded) */
|
||||||
onToggleTheme: () => void
|
version: string
|
||||||
|
collapsed: boolean
|
||||||
|
onToggleCollapsed: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
tab,
|
tab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
|
theme,
|
||||||
isDark,
|
isDark,
|
||||||
followingSystem,
|
onSetTheme,
|
||||||
onToggleTheme
|
version,
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapsed
|
||||||
}: SidebarProps): React.JSX.Element {
|
}: SidebarProps): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
|
||||||
|
// Collapsed view shows one button that cycles Light → Dark → Auto.
|
||||||
|
const order: ThemeMode[] = ['light', 'dark', 'system']
|
||||||
|
function cycleTheme(): void {
|
||||||
|
onSetTheme(order[(order.indexOf(theme) + 1) % order.length])
|
||||||
|
}
|
||||||
|
const themeIcon =
|
||||||
|
theme === 'system' ? (
|
||||||
|
<DesktopRegular fontSize={18} />
|
||||||
|
) : isDark ? (
|
||||||
|
<WeatherMoonRegular fontSize={18} />
|
||||||
|
) : (
|
||||||
|
<WeatherSunnyRegular fontSize={18} />
|
||||||
|
)
|
||||||
|
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={styles.root}>
|
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
|
||||||
<div className={styles.brand}>
|
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
|
||||||
|
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
onClick={onToggleCollapsed}
|
||||||
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
aria-pressed={collapsed}
|
||||||
|
>
|
||||||
|
{collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
|
||||||
|
</button>
|
||||||
|
</Hint>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={mergeClasses(styles.brand, collapsed && styles.brandCollapsed)}>
|
||||||
<div className={styles.mark}>
|
<div className={styles.mark}>
|
||||||
<ArrowDownloadFilled />
|
<ArrowDownloadFilled />
|
||||||
</div>
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
<div className={styles.brandText}>
|
<div className={styles.brandText}>
|
||||||
<span className={styles.brandName}>AeroFetch</span>
|
<span className={styles.brandName}>AeroFetch</span>
|
||||||
<Caption1 className={styles.caption}>yt-dlp frontend</Caption1>
|
<Caption1 className={styles.caption}>{version ? `v${version}` : 'yt-dlp frontend'}</Caption1>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.nav}>
|
<div className={styles.nav}>
|
||||||
{NAV.map((n) => {
|
{NAV.map((n) => {
|
||||||
const active = tab === n.value
|
const active = tab === n.value
|
||||||
return (
|
const btn = (
|
||||||
<button
|
<button
|
||||||
key={n.value}
|
key={n.value}
|
||||||
type="button"
|
type="button"
|
||||||
className={mergeClasses(styles.navItem, active && styles.navItemActive)}
|
className={mergeClasses(
|
||||||
|
styles.navItem,
|
||||||
|
collapsed && styles.navItemCollapsed,
|
||||||
|
active && styles.navItemActive
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
active
|
active && !collapsed
|
||||||
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
|
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClick={() => onTabChange(n.value)}
|
onClick={() => onTabChange(n.value)}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
|
aria-label={n.label}
|
||||||
>
|
>
|
||||||
<span className={styles.navIcon}>{n.icon}</span>
|
<span className={styles.navIcon}>{n.icon}</span>
|
||||||
{n.label}
|
{!collapsed && n.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
return collapsed ? (
|
||||||
|
<Hint key={n.value} label={n.label} placement="right">
|
||||||
|
{btn}
|
||||||
|
</Hint>
|
||||||
|
) : (
|
||||||
|
btn
|
||||||
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.spacer} />
|
<div className={styles.spacer} />
|
||||||
|
|
||||||
<div className={styles.themeRow}>
|
{collapsed ? (
|
||||||
<span className={styles.themeLabel}>
|
<Hint label={`Theme: ${themeLabel}`} placement="right">
|
||||||
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />}
|
<button
|
||||||
{(isDark ? 'Dark' : 'Light') + (followingSystem ? ' · Auto' : '')}
|
type="button"
|
||||||
</span>
|
className={styles.iconBtn}
|
||||||
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" />
|
onClick={cycleTheme}
|
||||||
|
aria-label={`Theme: ${themeLabel}. Click to change.`}
|
||||||
|
>
|
||||||
|
{themeIcon}
|
||||||
|
</button>
|
||||||
|
</Hint>
|
||||||
|
) : (
|
||||||
|
<div className={styles.themeGroup} role="radiogroup" aria-label="Theme">
|
||||||
|
{THEMES.map((t) => {
|
||||||
|
const on = theme === t.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={on}
|
||||||
|
className={mergeClasses(styles.themeSeg, on && styles.themeSegActive)}
|
||||||
|
onClick={() => onSetTheme(t.value)}
|
||||||
|
>
|
||||||
|
<span className={styles.navIcon}>{t.icon}</span>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,8 +74,10 @@ interface Draft {
|
|||||||
id: string | null
|
id: string | null
|
||||||
name: string
|
name: string
|
||||||
args: string
|
args: string
|
||||||
|
/** optional regex; auto-applies the template to matching URLs */
|
||||||
|
urlPattern: string
|
||||||
}
|
}
|
||||||
const BLANK_DRAFT: Draft = { id: null, name: '', args: '' }
|
const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
|
||||||
|
|
||||||
function newId(): string {
|
function newId(): string {
|
||||||
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
|
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
|
||||||
@@ -95,14 +97,20 @@ export function TemplateManager(): React.JSX.Element {
|
|||||||
const [draft, setDraft] = useState<Draft | null>(null)
|
const [draft, setDraft] = useState<Draft | null>(null)
|
||||||
|
|
||||||
function startEdit(t: CommandTemplate): void {
|
function startEdit(t: CommandTemplate): void {
|
||||||
setDraft({ id: t.id, name: t.name, args: t.args })
|
setDraft({ id: t.id, name: t.name, args: t.args, urlPattern: t.urlPattern ?? '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function commit(): void {
|
function commit(): void {
|
||||||
if (!draft) return
|
if (!draft) return
|
||||||
const name = draft.name.trim()
|
const name = draft.name.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
save({ id: draft.id ?? newId(), name, args: draft.args.trim() })
|
const urlPattern = draft.urlPattern.trim()
|
||||||
|
save({
|
||||||
|
id: draft.id ?? newId(),
|
||||||
|
name,
|
||||||
|
args: draft.args.trim(),
|
||||||
|
...(urlPattern ? { urlPattern } : {})
|
||||||
|
})
|
||||||
setDraft(null)
|
setDraft(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +162,11 @@ export function TemplateManager(): React.JSX.Element {
|
|||||||
resize="vertical"
|
resize="vertical"
|
||||||
onChange={(_, d) => setDraft({ ...draft, args: d.value })}
|
onChange={(_, d) => setDraft({ ...draft, args: d.value })}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
value={draft.urlPattern}
|
||||||
|
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com"
|
||||||
|
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })}
|
||||||
|
/>
|
||||||
<div className={styles.formActions}>
|
<div className={styles.formActions}>
|
||||||
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
|
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Textarea,
|
||||||
|
Subtitle2,
|
||||||
|
Body1,
|
||||||
|
Caption1,
|
||||||
|
makeStyles,
|
||||||
|
tokens,
|
||||||
|
shorthands
|
||||||
|
} from '@fluentui/react-components'
|
||||||
|
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
|
||||||
|
import { useSettings } from '../store/settings'
|
||||||
|
|
||||||
|
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
|
||||||
|
interface Line {
|
||||||
|
text: string
|
||||||
|
kind: LineKind
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
|
||||||
|
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
|
||||||
|
sub: { color: tokens.colorNeutralForeground3 },
|
||||||
|
gate: {
|
||||||
|
padding: '12px 14px',
|
||||||
|
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||||
|
color: tokens.colorStatusWarningForeground1,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||||
|
},
|
||||||
|
inputRow: { display: 'flex', gap: '8px', alignItems: 'flex-end' },
|
||||||
|
inputCol: { flexGrow: 1, display: 'flex', flexDirection: 'column', gap: '4px' },
|
||||||
|
prefix: { color: tokens.colorNeutralForeground3, fontFamily: tokens.fontFamilyMonospace },
|
||||||
|
buttons: { display: 'flex', gap: '8px' },
|
||||||
|
log: {
|
||||||
|
flexGrow: 1,
|
||||||
|
minHeight: '160px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
margin: 0,
|
||||||
|
padding: '12px 14px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
color: tokens.colorNeutralForeground1,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
|
fontFamily: tokens.fontFamilyMonospace,
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
lineHeight: tokens.lineHeightBase300,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
},
|
||||||
|
cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
|
||||||
|
stderr: { color: tokens.colorPaletteRedForeground1 },
|
||||||
|
sys: { color: tokens.colorNeutralForeground3 },
|
||||||
|
empty: { color: tokens.colorNeutralForeground3 }
|
||||||
|
})
|
||||||
|
|
||||||
|
function newId(): string {
|
||||||
|
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `t-${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
|
||||||
|
* binary, and watch its output stream. Gated on the customCommandEnabled consent
|
||||||
|
* flag (enforced again in main — see src/main/terminal.ts).
|
||||||
|
*/
|
||||||
|
export function TerminalView(): React.JSX.Element {
|
||||||
|
const styles = useStyles()
|
||||||
|
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
||||||
|
|
||||||
|
const [args, setArgs] = useState('')
|
||||||
|
const [lines, setLines] = useState<Line[]>([])
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
const runId = useRef<string | null>(null)
|
||||||
|
const logRef = useRef<HTMLPreElement>(null)
|
||||||
|
|
||||||
|
// Stream terminal output for the current run into the log.
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
window.api.onTerminalOutput((ev) => {
|
||||||
|
if (ev.id !== runId.current) return
|
||||||
|
if (ev.type === 'output') {
|
||||||
|
setLines((ls) => [...ls, { text: ev.line, kind: ev.stream }])
|
||||||
|
} else if (ev.type === 'error') {
|
||||||
|
setLines((ls) => [...ls, { text: ev.error, kind: 'stderr' }])
|
||||||
|
setRunning(false)
|
||||||
|
runId.current = null
|
||||||
|
} else {
|
||||||
|
setLines((ls) => [...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' }])
|
||||||
|
setRunning(false)
|
||||||
|
runId.current = null
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep the log pinned to the latest output.
|
||||||
|
useEffect(() => {
|
||||||
|
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
||||||
|
}, [lines])
|
||||||
|
|
||||||
|
function run(): void {
|
||||||
|
const a = args.trim()
|
||||||
|
if (!a || running) return
|
||||||
|
const id = newId()
|
||||||
|
runId.current = id
|
||||||
|
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
|
||||||
|
setRunning(true)
|
||||||
|
window.api
|
||||||
|
.runTerminal(id, a)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
setLines((ls) => [...ls, { text: res.error ?? 'Failed to start.', kind: 'stderr' }])
|
||||||
|
setRunning(false)
|
||||||
|
runId.current = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }])
|
||||||
|
setRunning(false)
|
||||||
|
runId.current = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(): void {
|
||||||
|
if (runId.current) window.api.cancelTerminal(runId.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineClass = (kind: LineKind): string | undefined =>
|
||||||
|
kind === 'cmd' ? styles.cmd : kind === 'stderr' ? styles.stderr : kind === 'sys' ? styles.sys : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Subtitle2>Terminal</Subtitle2>
|
||||||
|
<Caption1 className={styles.sub}>
|
||||||
|
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
|
||||||
|
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!customCommandEnabled && (
|
||||||
|
<Body1 className={styles.gate}>
|
||||||
|
The terminal is part of custom commands. Turn on “Run custom commands” in Settings →
|
||||||
|
Custom commands to use it.
|
||||||
|
</Body1>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.inputRow}>
|
||||||
|
<div className={styles.inputCol}>
|
||||||
|
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
|
||||||
|
<Textarea
|
||||||
|
value={args}
|
||||||
|
onChange={(_, d) => setArgs(d.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="--version (Ctrl+Enter to run)"
|
||||||
|
resize="vertical"
|
||||||
|
disabled={!customCommandEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
{running ? (
|
||||||
|
<Button appearance="secondary" icon={<DismissRegular />} onClick={stop}>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
icon={<PlayRegular />}
|
||||||
|
onClick={run}
|
||||||
|
disabled={!customCommandEnabled || !args.trim()}
|
||||||
|
>
|
||||||
|
Run
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={() => setLines([])}
|
||||||
|
disabled={lines.length === 0}
|
||||||
|
aria-label="Clear output"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre className={styles.log} ref={logRef}>
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<span className={styles.empty}>Output will appear here.</span>
|
||||||
|
) : (
|
||||||
|
lines.map((l, i) => (
|
||||||
|
<div key={i} className={lineClass(l.kind)}>
|
||||||
|
{l.text}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useRef, type CSSProperties, type ReactNode } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windowed list: renders only the rows near the viewport, so a list thousands of
|
||||||
|
* items long stays light — one DOM subtree per visible row, not per item. This
|
||||||
|
* keeps the Downloads queue and a big channel's Library list responsive when a
|
||||||
|
* source has 1000s of videos.
|
||||||
|
*
|
||||||
|
* It owns its own scroll container, so size that container via `style`/`className`
|
||||||
|
* (e.g. `flex: 1` + `minHeight: 0`, or a `maxHeight`). Owning the scroller means
|
||||||
|
* the virtualizer anchors at scroll offset 0 and never has to track where the
|
||||||
|
* list sits within the page — robust even though the app shares one outer
|
||||||
|
* scroll area with content above each list.
|
||||||
|
*
|
||||||
|
* Row heights are measured (measureElement), so variable-height rows are fine;
|
||||||
|
* `estimateSize` only needs to be a rough first guess before measurement.
|
||||||
|
*/
|
||||||
|
export function VirtualList<T>({
|
||||||
|
items,
|
||||||
|
estimateSize,
|
||||||
|
getKey,
|
||||||
|
renderItem,
|
||||||
|
overscan = 8,
|
||||||
|
gap = 0,
|
||||||
|
className,
|
||||||
|
style
|
||||||
|
}: {
|
||||||
|
items: T[]
|
||||||
|
estimateSize: (index: number) => number
|
||||||
|
getKey: (item: T, index: number) => string
|
||||||
|
renderItem: (item: T, index: number) => ReactNode
|
||||||
|
overscan?: number
|
||||||
|
gap?: number
|
||||||
|
className?: string
|
||||||
|
style?: CSSProperties
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: items.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize,
|
||||||
|
overscan,
|
||||||
|
gap,
|
||||||
|
getItemKey: (index) => getKey(items[index], index)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}>
|
||||||
|
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
||||||
|
{virtualizer.getVirtualItems().map((vrow) => (
|
||||||
|
<div
|
||||||
|
key={vrow.key}
|
||||||
|
data-index={vrow.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${vrow.start}px)`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderItem(items[vrow.index], vrow.index)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ import App from './App'
|
|||||||
// In the real Electron app this branch is skipped — preload provides window.api.
|
// In the real Electron app this branch is skipped — preload provides window.api.
|
||||||
if (import.meta.env.DEV && !window.api) {
|
if (import.meta.env.DEV && !window.api) {
|
||||||
const MOCK_SETTINGS: Settings = {
|
const MOCK_SETTINGS: Settings = {
|
||||||
outputDir: 'C:\\Users\\you\\Downloads',
|
videoDir: 'C:\\Users\\you\\Documents\\Video',
|
||||||
|
audioDir: 'C:\\Users\\you\\Documents\\Audio',
|
||||||
defaultKind: 'video',
|
defaultKind: 'video',
|
||||||
defaultVideoQuality: 'Best available',
|
defaultVideoQuality: 'Best available',
|
||||||
defaultAudioQuality: 'Best (MP3)',
|
defaultAudioQuality: 'Best (MP3)',
|
||||||
@@ -26,12 +27,19 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
useAria2c: false,
|
useAria2c: false,
|
||||||
cookieSource: 'none',
|
cookieSource: 'none',
|
||||||
cookiesBrowser: 'chrome',
|
cookiesBrowser: 'chrome',
|
||||||
|
youtubePlayerClient: '',
|
||||||
|
youtubePoToken: '',
|
||||||
restrictFilenames: false,
|
restrictFilenames: false,
|
||||||
downloadArchive: false,
|
downloadArchive: false,
|
||||||
|
autoUpdateYtdlp: true,
|
||||||
|
ytdlpChannel: 'nightly',
|
||||||
|
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
|
||||||
customCommandEnabled: false,
|
customCommandEnabled: false,
|
||||||
defaultTemplateId: null,
|
defaultTemplateId: null,
|
||||||
notifyOnComplete: true,
|
notifyOnComplete: true,
|
||||||
hasCompletedOnboarding: true
|
autoDownloadNew: true,
|
||||||
|
hasCompletedOnboarding: true,
|
||||||
|
minimizeToTray: false
|
||||||
}
|
}
|
||||||
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
|
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
|
||||||
// sign-in/clear flow be exercised in this browser-only preview.
|
// sign-in/clear flow be exercised in this browser-only preview.
|
||||||
@@ -43,7 +51,29 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
]
|
]
|
||||||
|
|
||||||
window.api = {
|
window.api = {
|
||||||
|
getAppVersion: async () => '0.4.0-preview',
|
||||||
|
checkForAppUpdate: async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 400))
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
available: true,
|
||||||
|
currentVersion: '0.4.0',
|
||||||
|
latestVersion: '0.5.0',
|
||||||
|
notes:
|
||||||
|
'### What’s new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes',
|
||||||
|
htmlUrl: 'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases',
|
||||||
|
downloadUrl: 'https://gitea.netbird.zimspace.uk:5938/example/AeroFetch-Setup-0.5.0.exe',
|
||||||
|
assetName: 'AeroFetch-Setup-0.5.0.exe'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
downloadAppUpdate: async () => ({
|
||||||
|
ok: false,
|
||||||
|
error: 'Downloading updates is disabled in the browser preview.'
|
||||||
|
}),
|
||||||
|
runAppUpdate: async () => ({ ok: true }),
|
||||||
|
onAppUpdateProgress: () => () => {},
|
||||||
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
||||||
|
getFfmpegVersions: async () => ({ ffmpeg: '7.1-full_build (UI preview mock)', ffprobe: '7.1-full_build (UI preview mock)' }),
|
||||||
probe: async (url: string) => {
|
probe: async (url: string) => {
|
||||||
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
||||||
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
|
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
|
||||||
@@ -86,6 +116,7 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
},
|
},
|
||||||
startDownload: async () => ({ ok: true }),
|
startDownload: async () => ({ ok: true }),
|
||||||
cancelDownload: async () => {},
|
cancelDownload: async () => {},
|
||||||
|
pauseDownload: async () => {},
|
||||||
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
|
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
|
||||||
chooseFolder: async () => null,
|
chooseFolder: async () => null,
|
||||||
openPath: async () => '',
|
openPath: async () => '',
|
||||||
@@ -120,17 +151,19 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
previewCommand: async (opts) => {
|
previewCommand: async (opts) => {
|
||||||
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
||||||
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
||||||
|
const dir = opts.kind === 'audio' ? MOCK_SETTINGS.audioDir : MOCK_SETTINGS.videoDir
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
command:
|
command:
|
||||||
`yt-dlp.exe -f "bv*+ba/b" -o "${MOCK_SETTINGS.outputDir}\\%(title)s.%(ext)s"` +
|
`yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
|
||||||
`${extra} -- ${opts.url}`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateYtdlp: async (channel) => {
|
updateYtdlp: async (channel) => {
|
||||||
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
|
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
|
||||||
return { ok: true, output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)` }
|
return { ok: true, output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)` }
|
||||||
},
|
},
|
||||||
|
// No background updater in the browser preview — hand back an inert unsubscribe.
|
||||||
|
onYtdlpAutoUpdateStatus: () => () => {},
|
||||||
listErrorLog: async () => [],
|
listErrorLog: async () => [],
|
||||||
clearErrorLog: async () => [],
|
clearErrorLog: async () => [],
|
||||||
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
|
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
|
||||||
@@ -139,7 +172,62 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
getSystemTheme: async () => ({ shouldUseDarkColors: false, shouldUseHighContrastColors: false }),
|
getSystemTheme: async () => ({ shouldUseDarkColors: false, shouldUseHighContrastColors: false }),
|
||||||
onSystemThemeUpdate: () => () => {},
|
onSystemThemeUpdate: () => () => {},
|
||||||
openHighContrastSettings: async () => {},
|
openHighContrastSettings: async () => {},
|
||||||
onExternalUrl: () => () => {}
|
onExternalUrl: () => () => {},
|
||||||
|
// Media-manager sources — a seeded channel so the (Phase H) Library view has
|
||||||
|
// demo data in this browser-only preview.
|
||||||
|
listSources: async () => [
|
||||||
|
{
|
||||||
|
id: 'src-demo',
|
||||||
|
url: 'https://www.youtube.com/@DevChannel',
|
||||||
|
kind: 'channel',
|
||||||
|
title: 'DevChannel',
|
||||||
|
channel: 'DevChannel',
|
||||||
|
addedAt: Date.now() - 86_400_000,
|
||||||
|
lastIndexedAt: Date.now() - 3_600_000,
|
||||||
|
itemCount: 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
indexSource: async (url) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 800)) // simulate the index walk
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
source: {
|
||||||
|
id: 'src-demo',
|
||||||
|
url,
|
||||||
|
kind: 'channel',
|
||||||
|
title: 'DevChannel',
|
||||||
|
channel: 'DevChannel',
|
||||||
|
addedAt: Date.now(),
|
||||||
|
lastIndexedAt: Date.now(),
|
||||||
|
itemCount: 7
|
||||||
|
},
|
||||||
|
itemCount: 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reindexSource: async () => ({ ok: true, itemCount: 7, newCount: 0 }),
|
||||||
|
removeSource: async () => [],
|
||||||
|
markSourceItemDownloaded: async () => [],
|
||||||
|
setSourceWatched: async () => [],
|
||||||
|
syncSources: async () => ({ ok: true, newItems: [] }),
|
||||||
|
getScheduledSync: async () => ({ enabled: false }),
|
||||||
|
setScheduledSync: async (enabled: boolean) => ({ enabled }),
|
||||||
|
listSourceItems: async (sourceId) =>
|
||||||
|
Array.from({ length: 7 }, (_, i) => ({
|
||||||
|
id: `${sourceId}:vid${i + 1}`,
|
||||||
|
sourceId,
|
||||||
|
videoId: `vid${i + 1}`,
|
||||||
|
title: `Episode ${i + 1}`,
|
||||||
|
url: `https://youtube.com/watch?v=vid${i + 1}`,
|
||||||
|
playlistTitle: i < 5 ? 'Full Electron Course' : 'Uploads',
|
||||||
|
playlistIndex: i < 5 ? i + 1 : i - 4,
|
||||||
|
durationLabel: `${10 + i}:0${i}`,
|
||||||
|
downloaded: i < 2
|
||||||
|
})),
|
||||||
|
onIndexProgress: () => () => {},
|
||||||
|
runTerminal: async () => ({ ok: true }),
|
||||||
|
cancelTerminal: async () => {},
|
||||||
|
onTerminalOutput: () => () => {},
|
||||||
|
setTaskbarProgress: () => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { DownloadEvent, DownloadMeta, DownloadOptions } from '@shared/ipc'
|
import type { DownloadEvent, DownloadMeta, DownloadOptions, CollectionContext } from '@shared/ipc'
|
||||||
import { useSettings } from './settings'
|
import { useSettings } from './settings'
|
||||||
import { useHistory } from './history'
|
import { useHistory } from './history'
|
||||||
|
import { useSources } from './sources'
|
||||||
|
|
||||||
export type MediaKind = 'video' | 'audio'
|
export type MediaKind = 'video' | 'audio'
|
||||||
|
|
||||||
export type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'error' | 'canceled'
|
export type DownloadStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'downloading'
|
||||||
|
| 'paused'
|
||||||
|
| 'saved'
|
||||||
|
| 'completed'
|
||||||
|
| 'error'
|
||||||
|
| 'canceled'
|
||||||
|
|
||||||
/** An exact probed format chosen for a download (omitted for the preset path). */
|
/** An exact probed format chosen for a download (omitted for the preset path). */
|
||||||
export interface ChosenFormat {
|
export interface ChosenFormat {
|
||||||
@@ -38,10 +46,18 @@ export interface DownloadItem {
|
|||||||
options?: DownloadOptions
|
options?: DownloadOptions
|
||||||
/** per-download custom-command override (omitted = use the persisted default template) */
|
/** per-download custom-command override (omitted = use the persisted default template) */
|
||||||
extraArgs?: string
|
extraArgs?: string
|
||||||
|
/** raw trim spec — time ranges to keep (parsed to --download-sections in main) */
|
||||||
|
trim?: string
|
||||||
|
/** epoch ms to auto-start a 'saved' item; undefined = parked indefinitely (Phase M) */
|
||||||
|
scheduledFor?: number
|
||||||
/** private download — completion is never recorded to history */
|
/** private download — completion is never recorded to history */
|
||||||
incognito?: boolean
|
incognito?: boolean
|
||||||
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
|
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
|
||||||
probedMeta?: DownloadMeta
|
probedMeta?: DownloadMeta
|
||||||
|
/** media-manager folder context — files this into <channel>/<playlist>/<NNN> - <title> */
|
||||||
|
collection?: CollectionContext
|
||||||
|
/** when set, the source MediaItem id this download came from — marked downloaded on completion */
|
||||||
|
mediaItemId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
|
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
|
||||||
@@ -49,28 +65,59 @@ export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
|
|||||||
audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps']
|
audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Options accepted when enqueuing a URL (shared by addFromUrl + addMany). */
|
||||||
|
export interface AddOptions {
|
||||||
|
format?: ChosenFormat
|
||||||
|
title?: string
|
||||||
|
channel?: string
|
||||||
|
durationLabel?: string
|
||||||
|
thumbnail?: string
|
||||||
|
options?: DownloadOptions
|
||||||
|
extraArgs?: string
|
||||||
|
trim?: string
|
||||||
|
/** when set and in the future, the item starts 'saved' and auto-queues at this time */
|
||||||
|
scheduledFor?: number
|
||||||
|
incognito?: boolean
|
||||||
|
collection?: CollectionContext
|
||||||
|
mediaItemId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One URL to enqueue in a single addMany batch. */
|
||||||
|
export interface AddEntry {
|
||||||
|
url: string
|
||||||
|
kind: MediaKind
|
||||||
|
quality: string
|
||||||
|
opts?: AddOptions
|
||||||
|
}
|
||||||
|
|
||||||
// True when running in the standalone browser preview (no Electron preload).
|
// True when running in the standalone browser preview (no Electron preload).
|
||||||
// The real preload injects window.electron; the browser stub does not.
|
// The real preload injects window.electron; the browser stub does not.
|
||||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||||
|
|
||||||
interface DownloadState {
|
interface DownloadState {
|
||||||
items: DownloadItem[]
|
items: DownloadItem[]
|
||||||
addFromUrl: (
|
addFromUrl: (url: string, kind: MediaKind, quality: string, opts?: AddOptions) => void
|
||||||
url: string,
|
/**
|
||||||
kind: MediaKind,
|
* Enqueue many URLs at once with a single state update and a single pump, so
|
||||||
quality: string,
|
* queuing an entire channel (1000s of items) doesn't churn the store O(n²) the
|
||||||
opts?: {
|
* way looping addFromUrl would.
|
||||||
format?: ChosenFormat
|
*/
|
||||||
title?: string
|
addMany: (entries: AddEntry[]) => void
|
||||||
channel?: string
|
|
||||||
durationLabel?: string
|
|
||||||
thumbnail?: string
|
|
||||||
options?: DownloadOptions
|
|
||||||
extraArgs?: string
|
|
||||||
incognito?: boolean
|
|
||||||
}
|
|
||||||
) => void
|
|
||||||
retry: (id: string) => void
|
retry: (id: string) => void
|
||||||
|
/** re-queue every failed item at once (Phase M) */
|
||||||
|
retryAll: () => void
|
||||||
|
/** stop a running download but keep its partial file for a later resume (Phase M) */
|
||||||
|
pause: (id: string) => void
|
||||||
|
/** re-queue a paused download; yt-dlp continues its partial file on relaunch */
|
||||||
|
resume: (id: string) => void
|
||||||
|
/** move a queued item to the front of the promotion order ("download next", Phase M) */
|
||||||
|
prioritize: (id: string) => void
|
||||||
|
/** park a queued item indefinitely as 'saved' (Phase M) */
|
||||||
|
saveForLater: (id: string) => void
|
||||||
|
/** un-park a 'saved' item back into the queue now (clears any schedule) */
|
||||||
|
queueNow: (id: string) => void
|
||||||
|
/** internal: promote any 'saved' item whose scheduledFor time has arrived */
|
||||||
|
promoteDueScheduled: () => void
|
||||||
cancel: (id: string) => void
|
cancel: (id: string) => void
|
||||||
remove: (id: string) => void
|
remove: (id: string) => void
|
||||||
clearFinished: () => void
|
clearFinished: () => void
|
||||||
@@ -88,6 +135,44 @@ function newId(): string {
|
|||||||
return `item-${++idCounter}`
|
return `item-${++idCounter}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fresh queued DownloadItem from a URL + options. Pure (no store access)
|
||||||
|
* so addFromUrl and addMany share it. If the caller already probed the URL, its
|
||||||
|
* metadata is carried as probedMeta so main can skip a redundant probe (audit P2).
|
||||||
|
*/
|
||||||
|
function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOptions): DownloadItem {
|
||||||
|
const probedMeta: DownloadMeta | undefined =
|
||||||
|
opts?.title || opts?.channel || opts?.durationLabel
|
||||||
|
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
|
||||||
|
: undefined
|
||||||
|
// A future scheduled time parks the item as 'saved' until its moment arrives;
|
||||||
|
// a past/absent time starts it queued as usual.
|
||||||
|
const scheduledFor =
|
||||||
|
opts?.scheduledFor && opts.scheduledFor > Date.now() ? opts.scheduledFor : undefined
|
||||||
|
return {
|
||||||
|
id: newId(),
|
||||||
|
url,
|
||||||
|
title: opts?.title ?? titleFromUrl(url),
|
||||||
|
channel: opts?.channel ?? 'Resolving…',
|
||||||
|
durationLabel: opts?.durationLabel,
|
||||||
|
thumbnail: opts?.thumbnail,
|
||||||
|
kind,
|
||||||
|
quality,
|
||||||
|
status: scheduledFor ? 'saved' : 'queued',
|
||||||
|
progress: 0,
|
||||||
|
formatId: opts?.format?.id,
|
||||||
|
formatHasAudio: opts?.format?.hasAudio,
|
||||||
|
options: opts?.options,
|
||||||
|
extraArgs: opts?.extraArgs,
|
||||||
|
trim: opts?.trim,
|
||||||
|
scheduledFor,
|
||||||
|
incognito: opts?.incognito,
|
||||||
|
collection: opts?.collection,
|
||||||
|
mediaItemId: opts?.mediaItemId,
|
||||||
|
probedMeta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function titleFromUrl(url: string): string {
|
function titleFromUrl(url: string): string {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url)
|
const u = new URL(url)
|
||||||
@@ -210,6 +295,11 @@ function startFakeTicker(
|
|||||||
completedAt: Date.now()
|
completedAt: Date.now()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Mirror the real applyEvent path so collection completions mark their
|
||||||
|
// source item downloaded in the preview too.
|
||||||
|
if (finished?.mediaItemId) {
|
||||||
|
useSources.getState().markDownloaded(finished.mediaItemId, finished.filePath)
|
||||||
|
}
|
||||||
get().pump() // a slot just freed — promote the next queued item
|
get().pump() // a slot just freed — promote the next queued item
|
||||||
}
|
}
|
||||||
}, 650)
|
}, 650)
|
||||||
@@ -235,12 +325,15 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
url: item.url,
|
url: item.url,
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
quality: item.quality,
|
quality: item.quality,
|
||||||
outputDir: useSettings.getState().outputDir || undefined,
|
// No outputDir here: main routes each download into the user's per-kind
|
||||||
|
// folder (Settings → Video/Audio folder), or the Documents\… default.
|
||||||
formatId: item.formatId,
|
formatId: item.formatId,
|
||||||
formatHasAudio: item.formatHasAudio,
|
formatHasAudio: item.formatHasAudio,
|
||||||
options: item.options,
|
options: item.options,
|
||||||
extraArgs: item.extraArgs,
|
extraArgs: item.extraArgs,
|
||||||
meta: item.probedMeta
|
trim: item.trim,
|
||||||
|
meta: item.probedMeta,
|
||||||
|
collection: item.collection
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) markError(item.id, res.error)
|
if (!res.ok) markError(item.id, res.error)
|
||||||
@@ -277,33 +370,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
pump,
|
pump,
|
||||||
|
|
||||||
addFromUrl: (url, kind, quality, opts) => {
|
addFromUrl: (url, kind, quality, opts) => {
|
||||||
const id = newId()
|
set((s) => ({ items: [buildItem(url, kind, quality, opts), ...s.items] }))
|
||||||
// If the caller already probed the URL, carry that metadata so main can skip
|
pump()
|
||||||
// its own redundant probe (audit P2). Only set it when something real was
|
},
|
||||||
// provided — a bare paste with no probe leaves it undefined.
|
|
||||||
const probedMeta: DownloadMeta | undefined =
|
addMany: (entries) => {
|
||||||
opts?.title || opts?.channel || opts?.durationLabel
|
if (entries.length === 0) return
|
||||||
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
|
const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts))
|
||||||
: undefined
|
set((s) => ({ items: [...built, ...s.items] }))
|
||||||
const item: DownloadItem = {
|
|
||||||
id,
|
|
||||||
url,
|
|
||||||
title: opts?.title ?? titleFromUrl(url),
|
|
||||||
channel: opts?.channel ?? 'Resolving…',
|
|
||||||
durationLabel: opts?.durationLabel,
|
|
||||||
thumbnail: opts?.thumbnail,
|
|
||||||
kind,
|
|
||||||
quality,
|
|
||||||
status: 'queued',
|
|
||||||
progress: 0,
|
|
||||||
formatId: opts?.format?.id,
|
|
||||||
formatHasAudio: opts?.format?.hasAudio,
|
|
||||||
options: opts?.options,
|
|
||||||
extraArgs: opts?.extraArgs,
|
|
||||||
incognito: opts?.incognito,
|
|
||||||
probedMeta
|
|
||||||
}
|
|
||||||
set((s) => ({ items: [item, ...s.items] }))
|
|
||||||
pump()
|
pump()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -318,6 +392,93 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
pump()
|
pump()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
retryAll: () => {
|
||||||
|
set((s) => ({
|
||||||
|
items: s.items.map((i) =>
|
||||||
|
i.status === 'error'
|
||||||
|
? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined }
|
||||||
|
: i
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
pump()
|
||||||
|
},
|
||||||
|
|
||||||
|
pause: (id) => {
|
||||||
|
const cur = get().items.find((i) => i.id === id)
|
||||||
|
if (!cur) return
|
||||||
|
// Only a running download has a live process to stop; a queued item is
|
||||||
|
// just parked in place. Either way it becomes 'paused' (pump ignores it).
|
||||||
|
if (!PREVIEW && cur.status === 'downloading') window.api.pauseDownload(id).catch(() => {})
|
||||||
|
set((s) => ({
|
||||||
|
items: s.items.map((i) =>
|
||||||
|
i.id === id && (i.status === 'downloading' || i.status === 'queued')
|
||||||
|
? { ...i, status: 'paused', speed: undefined, eta: undefined }
|
||||||
|
: i
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
pump() // pausing a running item frees a slot
|
||||||
|
},
|
||||||
|
|
||||||
|
resume: (id) => {
|
||||||
|
// Re-queue WITHOUT resetting progress (unlike retry): pump() promotes it and
|
||||||
|
// launchItem re-spawns yt-dlp, which continues the partial file by default.
|
||||||
|
set((s) => ({
|
||||||
|
items: s.items.map((i) =>
|
||||||
|
i.id === id && i.status === 'paused' ? { ...i, status: 'queued' } : i
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
pump()
|
||||||
|
},
|
||||||
|
|
||||||
|
prioritize: (id) => {
|
||||||
|
// Reposition the item so pump() — which promotes the highest-index queued
|
||||||
|
// item first (oldest-first over a newest-first array) — picks it next.
|
||||||
|
set((s) => {
|
||||||
|
const idx = s.items.findIndex((i) => i.id === id)
|
||||||
|
if (idx < 0 || s.items[idx].status !== 'queued') return {}
|
||||||
|
const items = s.items.slice()
|
||||||
|
const [it] = items.splice(idx, 1)
|
||||||
|
let after = -1
|
||||||
|
for (let k = 0; k < items.length; k++) if (items[k].status === 'queued') after = k
|
||||||
|
items.splice(after + 1, 0, it)
|
||||||
|
return { items }
|
||||||
|
})
|
||||||
|
pump()
|
||||||
|
},
|
||||||
|
|
||||||
|
saveForLater: (id) => {
|
||||||
|
set((s) => ({
|
||||||
|
items: s.items.map((i) =>
|
||||||
|
i.id === id && i.status === 'queued'
|
||||||
|
? { ...i, status: 'saved', scheduledFor: undefined }
|
||||||
|
: i
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
queueNow: (id) => {
|
||||||
|
set((s) => ({
|
||||||
|
items: s.items.map((i) =>
|
||||||
|
i.id === id && i.status === 'saved'
|
||||||
|
? { ...i, status: 'queued', scheduledFor: undefined }
|
||||||
|
: i
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
pump()
|
||||||
|
},
|
||||||
|
|
||||||
|
promoteDueScheduled: () => {
|
||||||
|
const now = Date.now()
|
||||||
|
set((s) => ({
|
||||||
|
items: s.items.map((i) =>
|
||||||
|
i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
|
||||||
|
? { ...i, status: 'queued', scheduledFor: undefined }
|
||||||
|
: i
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
pump()
|
||||||
|
},
|
||||||
|
|
||||||
cancel: (id) => {
|
cancel: (id) => {
|
||||||
const cur = get().items.find((i) => i.id === id)
|
const cur = get().items.find((i) => i.id === id)
|
||||||
if (!cur) return
|
if (!cur) return
|
||||||
@@ -340,7 +501,13 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
|
|
||||||
clearFinished: () =>
|
clearFinished: () =>
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
items: s.items.filter((i) => i.status === 'downloading' || i.status === 'queued')
|
items: s.items.filter(
|
||||||
|
(i) =>
|
||||||
|
i.status === 'downloading' ||
|
||||||
|
i.status === 'queued' ||
|
||||||
|
i.status === 'paused' ||
|
||||||
|
i.status === 'saved'
|
||||||
|
)
|
||||||
})),
|
})),
|
||||||
|
|
||||||
openFile: (id) => {
|
openFile: (id) => {
|
||||||
@@ -411,6 +578,10 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
completedAt: Date.now()
|
completedAt: Date.now()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Persist media-manager completion so an incremental re-sync skips it.
|
||||||
|
if (item?.mediaItemId) {
|
||||||
|
useSources.getState().markDownloaded(item.mediaItemId, item.filePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// A finished item frees a slot — promote whatever is queued next.
|
// A finished item frees a slot — promote whatever is queued next.
|
||||||
if (ev.type === 'done' || ev.type === 'error') pump()
|
if (ev.type === 'done' || ev.type === 'error') pump()
|
||||||
@@ -418,6 +589,17 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
|
||||||
|
// Session-only: the queue isn't persisted, so a schedule only fires while the app
|
||||||
|
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
|
||||||
|
setInterval(() => {
|
||||||
|
const st = useDownloads.getState()
|
||||||
|
const now = Date.now()
|
||||||
|
if (st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)) {
|
||||||
|
st.promoteDueScheduled()
|
||||||
|
}
|
||||||
|
}, 15_000)
|
||||||
|
|
||||||
// Wire main → renderer push events. (Output folder + cap live in the settings store.)
|
// Wire main → renderer push events. (Output folder + cap live in the settings store.)
|
||||||
if (!PREVIEW) {
|
if (!PREVIEW) {
|
||||||
window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev))
|
window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
|
|||||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||||
|
|
||||||
const FALLBACK: Settings = {
|
const FALLBACK: Settings = {
|
||||||
outputDir: PREVIEW ? 'C:\\Users\\you\\Downloads' : '',
|
videoDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Video' : '',
|
||||||
|
audioDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Audio' : '',
|
||||||
defaultKind: 'video',
|
defaultKind: 'video',
|
||||||
defaultVideoQuality: 'Best available',
|
defaultVideoQuality: 'Best available',
|
||||||
defaultAudioQuality: 'Best (MP3)',
|
defaultAudioQuality: 'Best (MP3)',
|
||||||
@@ -20,21 +21,31 @@ const FALLBACK: Settings = {
|
|||||||
useAria2c: false,
|
useAria2c: false,
|
||||||
cookieSource: 'none',
|
cookieSource: 'none',
|
||||||
cookiesBrowser: 'chrome',
|
cookiesBrowser: 'chrome',
|
||||||
|
youtubePlayerClient: '',
|
||||||
|
youtubePoToken: '',
|
||||||
restrictFilenames: false,
|
restrictFilenames: false,
|
||||||
downloadArchive: false,
|
downloadArchive: false,
|
||||||
|
autoUpdateYtdlp: true,
|
||||||
|
ytdlpChannel: 'nightly',
|
||||||
|
ytdlpLastUpdateCheck: 0,
|
||||||
customCommandEnabled: false,
|
customCommandEnabled: false,
|
||||||
defaultTemplateId: null,
|
defaultTemplateId: null,
|
||||||
notifyOnComplete: true,
|
notifyOnComplete: true,
|
||||||
|
autoDownloadNew: true,
|
||||||
// True in preview so design work isn't blocked behind the welcome screen;
|
// True in preview so design work isn't blocked behind the welcome screen;
|
||||||
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
|
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
|
||||||
hasCompletedOnboarding: PREVIEW
|
hasCompletedOnboarding: PREVIEW,
|
||||||
|
minimizeToTray: false
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsState extends Settings {
|
interface SettingsState extends Settings {
|
||||||
/** true once persisted settings have loaded (always true in preview) */
|
/** true once persisted settings have loaded (always true in preview) */
|
||||||
loaded: boolean
|
loaded: boolean
|
||||||
update: (partial: Partial<Settings>) => void
|
update: (partial: Partial<Settings>) => void
|
||||||
chooseOutputDir: () => void
|
/** open the OS folder picker and store the result as the video or audio folder */
|
||||||
|
chooseDir: (target: 'videoDir' | 'audioDir') => void
|
||||||
|
/** clear a per-kind folder override, restoring its Documents\… default */
|
||||||
|
clearDir: (target: 'videoDir' | 'audioDir') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettings = create<SettingsState>((set, get) => ({
|
export const useSettings = create<SettingsState>((set, get) => ({
|
||||||
@@ -46,12 +57,14 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
|||||||
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
|
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
|
||||||
},
|
},
|
||||||
|
|
||||||
chooseOutputDir: () => {
|
chooseDir: (target) => {
|
||||||
if (PREVIEW) return
|
if (PREVIEW) return
|
||||||
window.api.chooseFolder().then((dir) => {
|
window.api.chooseFolder().then((dir) => {
|
||||||
if (dir) get().update({ outputDir: dir })
|
if (dir) get().update({ [target]: dir })
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
|
|
||||||
|
clearDir: (target) => get().update({ [target]: '' })
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Load persisted settings on startup.
|
// Load persisted settings on startup.
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { Source, MediaItem, IndexProgress } from '@shared/ipc'
|
||||||
|
import { useDownloads } from './downloads'
|
||||||
|
import { useSettings } from './settings'
|
||||||
|
|
||||||
|
// True in the standalone browser preview (no Electron preload).
|
||||||
|
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||||
|
|
||||||
|
// --- Preview seed data ------------------------------------------------------
|
||||||
|
|
||||||
|
function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
id: `${sourceId}:${playlist}-${i + 1}`,
|
||||||
|
sourceId,
|
||||||
|
videoId: `${playlist}-${i + 1}`,
|
||||||
|
title: `${playlist} — part ${i + 1}`,
|
||||||
|
url: `https://youtube.com/watch?v=${sourceId}${i + 1}`,
|
||||||
|
playlistTitle: playlist,
|
||||||
|
playlistIndex: i + 1,
|
||||||
|
durationLabel: `${10 + i}:${String(i * 7).padStart(2, '0')}`,
|
||||||
|
downloaded: i < downloadedUpTo
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedSources: Source[] = PREVIEW
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'src-dev',
|
||||||
|
url: 'https://www.youtube.com/@DevChannel',
|
||||||
|
kind: 'channel',
|
||||||
|
title: 'DevChannel',
|
||||||
|
channel: 'DevChannel',
|
||||||
|
addedAt: Date.now() - 86_400_000,
|
||||||
|
lastIndexedAt: Date.now() - 3_600_000,
|
||||||
|
itemCount: 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'src-mix',
|
||||||
|
url: 'https://www.youtube.com/playlist?list=PLmix',
|
||||||
|
kind: 'playlist',
|
||||||
|
title: 'Lo-fi Coding Mixes',
|
||||||
|
channel: 'ChillStudio',
|
||||||
|
addedAt: Date.now() - 2 * 86_400_000,
|
||||||
|
lastIndexedAt: Date.now() - 7_200_000,
|
||||||
|
itemCount: 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const seedItemsBySource: Record<string, MediaItem[]> = PREVIEW
|
||||||
|
? {
|
||||||
|
'src-dev': [
|
||||||
|
...seedItems('src-dev', 'Full Electron Course', 6, 2),
|
||||||
|
...seedItems('src-dev', 'Uploads', 3)
|
||||||
|
],
|
||||||
|
'src-mix': seedItems('src-mix', 'Lo-fi Coding Mixes', 5, 1)
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
|
||||||
|
// --- Store ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Live indexing status for the add-source bar. */
|
||||||
|
interface IndexingState {
|
||||||
|
active: boolean
|
||||||
|
url?: string
|
||||||
|
message?: string
|
||||||
|
current?: number
|
||||||
|
total?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourcesState {
|
||||||
|
sources: Source[]
|
||||||
|
/** media items per source, lazily loaded when a source is expanded */
|
||||||
|
itemsBySource: Record<string, MediaItem[]>
|
||||||
|
/** which source is expanded in the library view (null = none) */
|
||||||
|
selectedSourceId: string | null
|
||||||
|
indexing: IndexingState
|
||||||
|
loadSources: () => void
|
||||||
|
selectSource: (id: string | null) => void
|
||||||
|
/** index a pasted channel/playlist URL; resolves with ok + an error message */
|
||||||
|
indexSource: (url: string) => Promise<{ ok: boolean; error?: string }>
|
||||||
|
reindexSource: (id: string) => Promise<void>
|
||||||
|
removeSource: (id: string) => void
|
||||||
|
/**
|
||||||
|
* 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) */
|
||||||
|
markDownloaded: (itemId: string, filePath?: string) => void
|
||||||
|
/** toggle a source's watched-for-new-uploads flag (Phase J) */
|
||||||
|
setWatched: (id: string, watched: boolean) => void
|
||||||
|
/** whether a sync is currently running (the "Check for new" button's busy state) */
|
||||||
|
syncing: boolean
|
||||||
|
/**
|
||||||
|
* Re-index all watched sources and (when autoDownloadNew is on) enqueue the new
|
||||||
|
* videos. Resolves with how many new videos were found.
|
||||||
|
*/
|
||||||
|
syncWatched: () => Promise<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSources = create<SourcesState>((set, get) => ({
|
||||||
|
sources: seedSources,
|
||||||
|
itemsBySource: seedItemsBySource,
|
||||||
|
selectedSourceId: null,
|
||||||
|
indexing: { active: false },
|
||||||
|
syncing: false,
|
||||||
|
|
||||||
|
loadSources: () => {
|
||||||
|
if (PREVIEW) return
|
||||||
|
window.api
|
||||||
|
.listSources()
|
||||||
|
.then((sources) => set({ sources }))
|
||||||
|
.catch(() => {})
|
||||||
|
},
|
||||||
|
|
||||||
|
selectSource: (id) => {
|
||||||
|
set({ selectedSourceId: id })
|
||||||
|
// Lazily fetch a source's items the first time it's expanded.
|
||||||
|
if (id && !get().itemsBySource[id] && !PREVIEW) {
|
||||||
|
window.api
|
||||||
|
.listSourceItems(id)
|
||||||
|
.then((items) => set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } })))
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
indexSource: async (url) => {
|
||||||
|
set({ indexing: { active: true, url, message: 'Reading source…' } })
|
||||||
|
if (PREVIEW) {
|
||||||
|
await new Promise((r) => setTimeout(r, 900)) // simulate the index walk
|
||||||
|
set({ indexing: { active: false } })
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await window.api.indexSource(url)
|
||||||
|
set({ indexing: { active: false } })
|
||||||
|
if (res.ok) {
|
||||||
|
get().loadSources()
|
||||||
|
if (res.source) get().selectSource(res.source.id)
|
||||||
|
}
|
||||||
|
return { ok: res.ok, error: res.error }
|
||||||
|
} catch (e) {
|
||||||
|
set({ indexing: { active: false } })
|
||||||
|
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reindexSource: async (id) => {
|
||||||
|
const src = get().sources.find((s) => s.id === id)
|
||||||
|
set({ indexing: { active: true, url: src?.url, message: 'Re-indexing…' } })
|
||||||
|
if (PREVIEW) {
|
||||||
|
await new Promise((r) => setTimeout(r, 700))
|
||||||
|
set({ indexing: { active: false } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await window.api.reindexSource(id)
|
||||||
|
set({ indexing: { active: false } })
|
||||||
|
if (res.ok) {
|
||||||
|
get().loadSources()
|
||||||
|
const items = await window.api.listSourceItems(id)
|
||||||
|
set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } }))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
set({ indexing: { active: false } })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSource: (id) => {
|
||||||
|
set((s) => ({
|
||||||
|
sources: s.sources.filter((x) => x.id !== id),
|
||||||
|
selectedSourceId: s.selectedSourceId === id ? null : s.selectedSourceId
|
||||||
|
}))
|
||||||
|
if (!PREVIEW) window.api.removeSource(id).catch(() => {})
|
||||||
|
},
|
||||||
|
|
||||||
|
enqueueItems: (sourceId, items) => {
|
||||||
|
const src = get().sources.find((s) => s.id === sourceId)
|
||||||
|
const settings = useSettings.getState()
|
||||||
|
const kind = settings.defaultKind
|
||||||
|
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
|
||||||
|
// 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) => {
|
||||||
|
set((s) => {
|
||||||
|
const next: Record<string, MediaItem[]> = {}
|
||||||
|
let changed = false
|
||||||
|
for (const [sid, list] of Object.entries(s.itemsBySource)) {
|
||||||
|
if (list.some((m) => m.id === itemId)) {
|
||||||
|
changed = true
|
||||||
|
next[sid] = list.map((m) =>
|
||||||
|
m.id === itemId ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
next[sid] = list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? { itemsBySource: next } : {}
|
||||||
|
})
|
||||||
|
if (!PREVIEW) window.api.markSourceItemDownloaded(itemId, filePath).catch(() => {})
|
||||||
|
},
|
||||||
|
|
||||||
|
setWatched: (id, watched) => {
|
||||||
|
set((s) => ({ sources: s.sources.map((x) => (x.id === id ? { ...x, watched } : x)) }))
|
||||||
|
if (!PREVIEW) window.api.setSourceWatched(id, watched).catch(() => {})
|
||||||
|
},
|
||||||
|
|
||||||
|
syncWatched: async () => {
|
||||||
|
if (get().syncing) return 0
|
||||||
|
set({ syncing: true })
|
||||||
|
try {
|
||||||
|
if (PREVIEW) {
|
||||||
|
await new Promise((r) => setTimeout(r, 700))
|
||||||
|
return 0 // preview has no real feed to poll
|
||||||
|
}
|
||||||
|
const res = await window.api.syncSources()
|
||||||
|
if (!res.ok) return 0
|
||||||
|
get().loadSources()
|
||||||
|
// Refresh any expanded source's items so new videos appear in the tree.
|
||||||
|
const sel = get().selectedSourceId
|
||||||
|
if (sel) {
|
||||||
|
const items = await window.api.listSourceItems(sel)
|
||||||
|
set((s) => ({ itemsBySource: { ...s.itemsBySource, [sel]: items } }))
|
||||||
|
}
|
||||||
|
// Auto-enqueue the new videos, grouped by source, when the setting is on.
|
||||||
|
if (useSettings.getState().autoDownloadNew && res.newItems.length > 0) {
|
||||||
|
const bySource = new Map<string, MediaItem[]>()
|
||||||
|
for (const it of res.newItems) {
|
||||||
|
const arr = bySource.get(it.sourceId) ?? []
|
||||||
|
arr.push(it)
|
||||||
|
bySource.set(it.sourceId, arr)
|
||||||
|
}
|
||||||
|
for (const [sid, items] of bySource) get().enqueueItems(sid, items)
|
||||||
|
}
|
||||||
|
return res.newItems.length
|
||||||
|
} finally {
|
||||||
|
set({ syncing: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Load persisted sources on startup, and subscribe to live indexing progress.
|
||||||
|
if (!PREVIEW) {
|
||||||
|
window.api
|
||||||
|
.listSources()
|
||||||
|
.then((sources) => {
|
||||||
|
useSources.setState({ sources })
|
||||||
|
// If any source is watched, kick off a sync shortly after launch (covers the
|
||||||
|
// scheduled `--sync` launch and a normal launch alike).
|
||||||
|
if (sources.some((s) => s.watched)) {
|
||||||
|
setTimeout(() => useSources.getState().syncWatched(), 1500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
window.api.onIndexProgress((p: IndexProgress) => {
|
||||||
|
useSources.setState({
|
||||||
|
indexing: {
|
||||||
|
active: p.phase !== 'done' && p.phase !== 'error',
|
||||||
|
url: p.url,
|
||||||
|
message: p.message,
|
||||||
|
current: p.current,
|
||||||
|
total: p.total
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { SystemThemeInfo } from '@shared/ipc'
|
import type { SystemThemeInfo } from '@shared/ipc'
|
||||||
|
import { useSettings } from './settings'
|
||||||
|
|
||||||
// True in the standalone browser preview (no Electron preload).
|
// True in the standalone browser preview (no Electron preload).
|
||||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||||
@@ -24,3 +25,16 @@ if (!PREVIEW) {
|
|||||||
useSystemTheme.setState({ shouldUseDarkColors: e.matches })
|
useSystemTheme.setState({ shouldUseDarkColors: e.matches })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app is currently rendering in dark mode. Resolves the 'system'
|
||||||
|
* preference against the live OS signal, so a component never has to special-case
|
||||||
|
* it (the bug where a raw `theme === 'dark'` check left thumbnails light under
|
||||||
|
* Auto + a dark OS). The single source of truth for light/dark — used by App for
|
||||||
|
* the FluentProvider and by anything that needs theme-aware colors (thumbColors).
|
||||||
|
*/
|
||||||
|
export function useResolvedDark(): boolean {
|
||||||
|
const theme = useSettings((s) => s.theme)
|
||||||
|
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
|
||||||
|
return theme === 'system' ? systemPrefersDark : theme === 'dark'
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Thumbnail helpers. A media "preview" thumbnail is either the explicit image URL
|
||||||
|
* yt-dlp handed us at probe time, or — for a YouTube video — one derived from the
|
||||||
|
* video id with no extra network probe. Anything non-YouTube without a probed
|
||||||
|
* thumbnail returns undefined, and the UI falls back to a kind icon.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the video id out of any YouTube watch / shorts / embed / live / youtu.be
|
||||||
|
* URL. Returns null for non-YouTube URLs or anything unparseable, so callers can
|
||||||
|
* cheaply ask "is there a thumbnail derivable from this link?".
|
||||||
|
*/
|
||||||
|
export function youtubeId(url: string | undefined): string | null {
|
||||||
|
if (!url) return null
|
||||||
|
let u: URL
|
||||||
|
try {
|
||||||
|
u = new URL(url)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
|
||||||
|
if (host === 'youtu.be') return u.pathname.slice(1).split('/')[0] || null
|
||||||
|
if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
|
||||||
|
const v = u.searchParams.get('v')
|
||||||
|
if (v) return v
|
||||||
|
const m = u.pathname.match(/^\/(?:embed|shorts|v|live)\/([^/?#]+)/i)
|
||||||
|
if (m) return m[1]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a preview thumbnail URL for a media item. Prefers an explicit thumbnail
|
||||||
|
* (from a probe), then a known videoId, then a YouTube URL it can derive an id
|
||||||
|
* from. Returns undefined when nothing usable is available.
|
||||||
|
*/
|
||||||
|
export function thumbUrl(opts: {
|
||||||
|
thumbnail?: string
|
||||||
|
videoId?: string
|
||||||
|
url?: string
|
||||||
|
}): string | undefined {
|
||||||
|
if (opts.thumbnail) return opts.thumbnail
|
||||||
|
const id = opts.videoId || youtubeId(opts.url)
|
||||||
|
// mqdefault is 320×180 (16:9, no letterbox bars) and exists for every public
|
||||||
|
// video — a better fit for our 16:9 thumb boxes than the 4:3 hqdefault.
|
||||||
|
return id ? `https://i.ytimg.com/vi/${encodeURIComponent(id)}/mqdefault.jpg` : undefined
|
||||||
|
}
|
||||||
+328
-6
@@ -4,10 +4,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const IpcChannels = {
|
export const IpcChannels = {
|
||||||
|
/** the AeroFetch app version (package.json / app.getVersion) */
|
||||||
|
appVersion: 'app:version',
|
||||||
|
/** check the configured Gitea repo for a newer AeroFetch release */
|
||||||
|
appUpdateCheck: 'app:update-check',
|
||||||
|
/** download the latest release's installer to a temp file */
|
||||||
|
appUpdateDownload: 'app:update-download',
|
||||||
|
/** launch a downloaded installer and quit so it can replace the app */
|
||||||
|
appUpdateRun: 'app:update-run',
|
||||||
|
/** main → renderer push channel for installer download progress */
|
||||||
|
appUpdateProgress: 'app:update-progress',
|
||||||
ytdlpVersion: 'ytdlp:version',
|
ytdlpVersion: 'ytdlp:version',
|
||||||
|
ffmpegVersion: 'ffmpeg:version',
|
||||||
probe: 'media:probe',
|
probe: 'media:probe',
|
||||||
downloadStart: 'download:start',
|
downloadStart: 'download:start',
|
||||||
downloadCancel: 'download:cancel',
|
downloadCancel: 'download:cancel',
|
||||||
|
downloadPause: 'download:pause',
|
||||||
defaultFolder: 'download:default-folder',
|
defaultFolder: 'download:default-folder',
|
||||||
chooseFolder: 'download:choose-folder',
|
chooseFolder: 'download:choose-folder',
|
||||||
openPath: 'shell:open-path',
|
openPath: 'shell:open-path',
|
||||||
@@ -30,6 +42,8 @@ export const IpcChannels = {
|
|||||||
templatesRemove: 'templates:remove',
|
templatesRemove: 'templates:remove',
|
||||||
commandPreview: 'command:preview',
|
commandPreview: 'command:preview',
|
||||||
ytdlpUpdate: 'ytdlp:update',
|
ytdlpUpdate: 'ytdlp:update',
|
||||||
|
/** main → renderer push channel for background yt-dlp auto-update status */
|
||||||
|
ytdlpAutoUpdateStatus: 'ytdlp:auto-update-status',
|
||||||
errorLogList: 'errorlog:list',
|
errorLogList: 'errorlog:list',
|
||||||
errorLogClear: 'errorlog:clear',
|
errorLogClear: 'errorlog:clear',
|
||||||
backupExport: 'backup:export',
|
backupExport: 'backup:export',
|
||||||
@@ -40,7 +54,31 @@ export const IpcChannels = {
|
|||||||
openHighContrastSettings: 'shell:open-high-contrast-settings',
|
openHighContrastSettings: 'shell:open-high-contrast-settings',
|
||||||
/** main → renderer push channel: a URL handed to AeroFetch from outside the app
|
/** main → renderer push channel: a URL handed to AeroFetch from outside the app
|
||||||
* (the aerofetch:// protocol, or a .url file via Explorer's "Send to" menu) */
|
* (the aerofetch:// protocol, or a .url file via Explorer's "Send to" menu) */
|
||||||
externalUrl: 'external-url'
|
externalUrl: 'external-url',
|
||||||
|
// --- Sources & media-manager indexing (Pinchflat-style, see ROADMAP-PINCHFLAT.md) ---
|
||||||
|
sourcesList: 'sources:list',
|
||||||
|
sourceIndex: 'sources:index',
|
||||||
|
sourceReindex: 'sources:reindex',
|
||||||
|
sourceRemove: 'sources:remove',
|
||||||
|
sourceItems: 'sources:items',
|
||||||
|
/** mark a media item downloaded once its collection download completes */
|
||||||
|
sourceItemDownloaded: 'sources:item-downloaded',
|
||||||
|
/** toggle whether a source is watched for new uploads (Phase J) */
|
||||||
|
sourceSetWatched: 'sources:set-watched',
|
||||||
|
/** re-index all watched sources and return the videos that are new */
|
||||||
|
sourcesSync: 'sources:sync',
|
||||||
|
/** get / set the Windows scheduled-sync task (Task Scheduler) */
|
||||||
|
scheduledSyncGet: 'sources:scheduled-sync-get',
|
||||||
|
scheduledSyncSet: 'sources:scheduled-sync-set',
|
||||||
|
/** main → renderer push channel for live indexing progress */
|
||||||
|
indexProgress: 'sources:index-progress',
|
||||||
|
// --- Built-in yt-dlp terminal (Phase N) ---
|
||||||
|
terminalRun: 'terminal:run',
|
||||||
|
terminalCancel: 'terminal:cancel',
|
||||||
|
/** main → renderer push channel for live terminal output lines */
|
||||||
|
terminalOutput: 'terminal:output',
|
||||||
|
/** renderer → main: reflect overall queue progress on the Windows taskbar (Phase O) */
|
||||||
|
taskbarProgress: 'taskbar:progress'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
|
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
|
||||||
@@ -116,6 +154,13 @@ export interface DownloadOptions {
|
|||||||
videoContainer: VideoContainer
|
videoContainer: VideoContainer
|
||||||
/** preferred video codec (sort tiebreaker, not a hard filter) */
|
/** preferred video codec (sort tiebreaker, not a hard filter) */
|
||||||
preferredVideoCodec: VideoCodecPref
|
preferredVideoCodec: VideoCodecPref
|
||||||
|
/**
|
||||||
|
* Advanced: a raw yt-dlp `-S` format-sort string (e.g. 'res:1080,vcodec:av01,size').
|
||||||
|
* When non-empty it replaces the codec-derived sort, letting power users rank
|
||||||
|
* resolution / codec / container / size by weighted priority. Empty = use the
|
||||||
|
* preferredVideoCodec tiebreaker.
|
||||||
|
*/
|
||||||
|
formatSort: string
|
||||||
/** download + embed subtitles into video */
|
/** download + embed subtitles into video */
|
||||||
embedSubtitles: boolean
|
embedSubtitles: boolean
|
||||||
/** comma-separated yt-dlp sub-lang selectors, e.g. 'en' or 'en.*,es' */
|
/** comma-separated yt-dlp sub-lang selectors, e.g. 'en' or 'en.*,es' */
|
||||||
@@ -128,18 +173,27 @@ export interface DownloadOptions {
|
|||||||
sponsorBlockCategories: SponsorBlockCategory[]
|
sponsorBlockCategories: SponsorBlockCategory[]
|
||||||
/** embed chapter markers */
|
/** embed chapter markers */
|
||||||
embedChapters: boolean
|
embedChapters: boolean
|
||||||
|
/** split the download into one file per chapter (--split-chapters) */
|
||||||
|
splitChapters: boolean
|
||||||
/** embed metadata (title/artist/etc.) */
|
/** embed metadata (title/artist/etc.) */
|
||||||
embedMetadata: boolean
|
embedMetadata: boolean
|
||||||
/** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */
|
/** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */
|
||||||
embedThumbnail: boolean
|
embedThumbnail: boolean
|
||||||
/** crop embedded audio artwork to a centred square (Seal's "crop artwork") */
|
/** crop embedded audio artwork to a centred square (Seal's "crop artwork") */
|
||||||
cropThumbnail: boolean
|
cropThumbnail: boolean
|
||||||
|
/** write a .info.json metadata sidecar (Jellyfin/Plex/Kodi ingest — Phase K) */
|
||||||
|
writeInfoJson: boolean
|
||||||
|
/** write the thumbnail as a separate image file alongside the video */
|
||||||
|
writeThumbnailFile: boolean
|
||||||
|
/** write the video description to a .description sidecar file */
|
||||||
|
writeDescription: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
|
export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
|
||||||
audioFormat: 'mp3',
|
audioFormat: 'mp3',
|
||||||
videoContainer: 'mp4',
|
videoContainer: 'mp4',
|
||||||
preferredVideoCodec: 'any',
|
preferredVideoCodec: 'any',
|
||||||
|
formatSort: '',
|
||||||
embedSubtitles: false,
|
embedSubtitles: false,
|
||||||
subtitleLanguages: 'en',
|
subtitleLanguages: 'en',
|
||||||
autoSubtitles: false,
|
autoSubtitles: false,
|
||||||
@@ -147,9 +201,13 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
|
|||||||
sponsorBlockMode: 'remove',
|
sponsorBlockMode: 'remove',
|
||||||
sponsorBlockCategories: ['sponsor'],
|
sponsorBlockCategories: ['sponsor'],
|
||||||
embedChapters: false,
|
embedChapters: false,
|
||||||
|
splitChapters: false,
|
||||||
embedMetadata: true,
|
embedMetadata: true,
|
||||||
embedThumbnail: true,
|
embedThumbnail: true,
|
||||||
cropThumbnail: false
|
cropThumbnail: false,
|
||||||
|
writeInfoJson: false,
|
||||||
|
writeThumbnailFile: false,
|
||||||
|
writeDescription: false
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YtdlpVersionResult {
|
export interface YtdlpVersionResult {
|
||||||
@@ -160,8 +218,74 @@ export interface YtdlpVersionResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** yt-dlp's self-update release channel (`--update-to <channel>`). */
|
/**
|
||||||
export type YtdlpUpdateChannel = 'stable' | 'nightly'
|
* 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
|
||||||
|
/** true when latestVersion is newer than the running app */
|
||||||
|
available: boolean
|
||||||
|
/** the running app's version, e.g. '0.4.0' */
|
||||||
|
currentVersion: string
|
||||||
|
/** the latest release's version (tag without a leading 'v'), when ok */
|
||||||
|
latestVersion?: string
|
||||||
|
/** the release notes / changelog body (Markdown), shown before updating */
|
||||||
|
notes?: string
|
||||||
|
/** the release's web page on Gitea (for "View release") */
|
||||||
|
htmlUrl?: string
|
||||||
|
/** direct download URL of the Windows installer asset, when the release has one */
|
||||||
|
downloadUrl?: string
|
||||||
|
/** the installer asset's file name */
|
||||||
|
assetName?: string
|
||||||
|
/** human-readable error, when ok is false */
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of downloading the update installer to a temp file. */
|
||||||
|
export interface AppUpdateDownload {
|
||||||
|
ok: boolean
|
||||||
|
/** absolute path to the downloaded installer, when ok */
|
||||||
|
filePath?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live progress of the installer download (main → renderer). */
|
||||||
|
export interface AppUpdateProgress {
|
||||||
|
/** bytes downloaded so far */
|
||||||
|
received: number
|
||||||
|
/** total bytes, when the server reports Content-Length */
|
||||||
|
total?: number
|
||||||
|
/** 0..1 fraction, when total is known */
|
||||||
|
fraction?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* yt-dlp's self-update release channels (`--update-to <channel>`).
|
||||||
|
*
|
||||||
|
* SECURITY (audit F1): `--update-to` also accepts an `OWNER/REPO@TAG` spec, which
|
||||||
|
* makes yt-dlp download a release binary from an ARBITRARY GitHub repo and
|
||||||
|
* overwrite the running yt-dlp.exe — i.e. arbitrary, persistent code execution.
|
||||||
|
* The TypeScript type is erased at runtime and is no defence over IPC, so the
|
||||||
|
* value must be checked against this allowlist before it ever reaches the flag
|
||||||
|
* (see isYtdlpUpdateChannel; enforced in src/main/ytdlp.ts).
|
||||||
|
*/
|
||||||
|
export const YTDLP_UPDATE_CHANNELS = ['stable', 'nightly'] as const
|
||||||
|
export type YtdlpUpdateChannel = (typeof YTDLP_UPDATE_CHANNELS)[number]
|
||||||
|
|
||||||
|
/** Runtime guard for an untrusted update-channel value crossing the IPC boundary. */
|
||||||
|
export function isYtdlpUpdateChannel(v: unknown): v is YtdlpUpdateChannel {
|
||||||
|
return typeof v === 'string' && (YTDLP_UPDATE_CHANNELS as readonly string[]).includes(v)
|
||||||
|
}
|
||||||
|
|
||||||
export interface YtdlpUpdateResult {
|
export interface YtdlpUpdateResult {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -170,6 +294,24 @@ export interface YtdlpUpdateResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of a background (startup) yt-dlp auto-update, pushed main → renderer on
|
||||||
|
* IpcChannels.ytdlpAutoUpdateStatus so the Settings UI can reflect a check that
|
||||||
|
* ran on its own — no button click needed.
|
||||||
|
*/
|
||||||
|
export interface YtdlpAutoUpdateStatus {
|
||||||
|
/** 'checking' when the update starts; the rest are terminal */
|
||||||
|
phase: 'checking' | 'updated' | 'current' | 'error'
|
||||||
|
/** which channel was checked */
|
||||||
|
channel: YtdlpUpdateChannel
|
||||||
|
/** yt-dlp version after the run, when known (phases 'updated' | 'current') */
|
||||||
|
version?: string
|
||||||
|
/** epoch ms the check finished (terminal phases) — feeds the "last checked" line */
|
||||||
|
checkedAt?: number
|
||||||
|
/** error text when phase === 'error' */
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
/** A single selectable output format, derived from `yt-dlp -J`. */
|
/** A single selectable output format, derived from `yt-dlp -J`. */
|
||||||
export interface FormatOption {
|
export interface FormatOption {
|
||||||
/** yt-dlp format_id, or BEST_FORMAT_ID for the auto-best option */
|
/** yt-dlp format_id, or BEST_FORMAT_ID for the auto-best option */
|
||||||
@@ -225,6 +367,24 @@ export interface ProbeResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media-manager folder context for a collection download (see ROADMAP-PINCHFLAT.md
|
||||||
|
* Phase G). When present on a StartDownloadOptions, the file is filed into
|
||||||
|
* `<outputDir>/<channel>/<playlist>/<NNN> - <title>.<ext>` instead of the flat
|
||||||
|
* filenameTemplate. The directory segments are sanitized for Windows at argv-build
|
||||||
|
* time, and `index` (the 1-based playlist position from the persisted MediaItem)
|
||||||
|
* drives the NNN prefix — not yt-dlp's %(playlist_index)s, which is empty under
|
||||||
|
* the per-video `--no-playlist` path these downloads still take.
|
||||||
|
*/
|
||||||
|
export interface CollectionContext {
|
||||||
|
/** channel / uploader name — the top folder level */
|
||||||
|
channel: string
|
||||||
|
/** playlist title — the second folder level ('Uploads' for the catch-all) */
|
||||||
|
playlist: string
|
||||||
|
/** 1-based position within the playlist, for the NNN filename prefix */
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
/** Sent renderer → main to kick off a download. The renderer owns the id. */
|
/** Sent renderer → main to kick off a download. The renderer owns the id. */
|
||||||
export interface StartDownloadOptions {
|
export interface StartDownloadOptions {
|
||||||
id: string
|
id: string
|
||||||
@@ -248,6 +408,13 @@ export interface StartDownloadOptions {
|
|||||||
* download even when the settings default is enabled.
|
* download even when the settings default is enabled.
|
||||||
*/
|
*/
|
||||||
extraArgs?: string
|
extraArgs?: string
|
||||||
|
/**
|
||||||
|
* Optional trim: keep only these time ranges. Raw user text — ranges like
|
||||||
|
* '1:30-2:00', comma- or newline-separated — normalised to yt-dlp
|
||||||
|
* `--download-sections` specs in buildArgs (parseTrimSections). Empty or
|
||||||
|
* undefined downloads the whole media.
|
||||||
|
*/
|
||||||
|
trim?: string
|
||||||
/**
|
/**
|
||||||
* Metadata the renderer already fetched when probing the URL (title/channel/
|
* Metadata the renderer already fetched when probing the URL (title/channel/
|
||||||
* duration). When present, main uses it directly instead of spawning a second
|
* duration). When present, main uses it directly instead of spawning a second
|
||||||
@@ -255,6 +422,12 @@ export interface StartDownloadOptions {
|
|||||||
* race where the late probe overwrites a good title the renderer supplied.
|
* race where the late probe overwrites a good title the renderer supplied.
|
||||||
*/
|
*/
|
||||||
meta?: DownloadMeta
|
meta?: DownloadMeta
|
||||||
|
/**
|
||||||
|
* When set, this is a media-manager (collection) download — file it into
|
||||||
|
* `<channel>/<playlist>/<NNN> - <title>` folders instead of the flat
|
||||||
|
* filenameTemplate. See CollectionContext.
|
||||||
|
*/
|
||||||
|
collection?: CollectionContext
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartDownloadResult {
|
export interface StartDownloadResult {
|
||||||
@@ -290,8 +463,10 @@ export type DownloadEvent =
|
|||||||
|
|
||||||
/** Persisted user settings (electron-store). */
|
/** Persisted user settings (electron-store). */
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
/** absolute output directory (empty string resolves to the OS Downloads folder) */
|
/** where video downloads are saved; empty string = the default Documents\Video folder */
|
||||||
outputDir: string
|
videoDir: string
|
||||||
|
/** where audio downloads are saved; empty string = the default Documents\Audio folder */
|
||||||
|
audioDir: string
|
||||||
defaultKind: MediaKind
|
defaultKind: MediaKind
|
||||||
defaultVideoQuality: string
|
defaultVideoQuality: string
|
||||||
defaultAudioQuality: string
|
defaultAudioQuality: string
|
||||||
@@ -317,18 +492,39 @@ export interface Settings {
|
|||||||
cookieSource: CookieSource
|
cookieSource: CookieSource
|
||||||
/** which browser to read cookies from when cookieSource is 'browser' */
|
/** which browser to read cookies from when cookieSource is 'browser' */
|
||||||
cookiesBrowser: CookieBrowser
|
cookiesBrowser: CookieBrowser
|
||||||
|
/**
|
||||||
|
* YouTube reliability (Phase P). When set, emitted as
|
||||||
|
* `--extractor-args "youtube:player_client=…;po_token=…"`:
|
||||||
|
* - playerClient: an alternate extraction client (e.g. 'web_safari', 'tv',
|
||||||
|
* 'mweb') — a common workaround for YouTube throttling/extraction breakage.
|
||||||
|
* - poToken: a manually-supplied Proof-of-Origin token for the bot check.
|
||||||
|
* Both empty = yt-dlp's defaults. (Automatic WebView token minting is deferred;
|
||||||
|
* this is the argv plumbing it would feed.)
|
||||||
|
*/
|
||||||
|
youtubePlayerClient: string
|
||||||
|
youtubePoToken: string
|
||||||
/** sanitize output filenames to a portable ASCII-only subset (--restrict-filenames) */
|
/** sanitize output filenames to a portable ASCII-only subset (--restrict-filenames) */
|
||||||
restrictFilenames: boolean
|
restrictFilenames: boolean
|
||||||
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */
|
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */
|
||||||
downloadArchive: boolean
|
downloadArchive: boolean
|
||||||
|
/** keep the managed yt-dlp.exe auto-updated in the background on launch */
|
||||||
|
autoUpdateYtdlp: boolean
|
||||||
|
/** channel the auto-updater (and the manual "Update yt-dlp" button) updates to */
|
||||||
|
ytdlpChannel: YtdlpUpdateChannel
|
||||||
|
/** epoch ms of the last automatic yt-dlp update check; 0 = never run */
|
||||||
|
ytdlpLastUpdateCheck: number
|
||||||
/** apply a named custom-command template's extra args to every new download */
|
/** apply a named custom-command template's extra args to every new download */
|
||||||
customCommandEnabled: boolean
|
customCommandEnabled: boolean
|
||||||
/** id of the CommandTemplate applied when customCommandEnabled is on; null = none picked yet */
|
/** id of the CommandTemplate applied when customCommandEnabled is on; null = none picked yet */
|
||||||
defaultTemplateId: string | null
|
defaultTemplateId: string | null
|
||||||
/** show a native OS notification when a download finishes or fails */
|
/** show a native OS notification when a download finishes or fails */
|
||||||
notifyOnComplete: boolean
|
notifyOnComplete: boolean
|
||||||
|
/** auto-enqueue newly-found videos from watched sources during a sync (Phase J) */
|
||||||
|
autoDownloadNew: boolean
|
||||||
/** false until the first-run welcome screen has been dismissed */
|
/** false until the first-run welcome screen has been dismissed */
|
||||||
hasCompletedOnboarding: boolean
|
hasCompletedOnboarding: boolean
|
||||||
|
/** keep AeroFetch running in the system tray when its window is closed (Phase O) */
|
||||||
|
minimizeToTray: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -341,6 +537,13 @@ export interface CommandTemplate {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
args: string
|
args: string
|
||||||
|
/**
|
||||||
|
* Optional regex (matched case-insensitively against the download URL). When
|
||||||
|
* set and custom commands are enabled, this template auto-applies to any URL it
|
||||||
|
* matches — taking precedence over the global defaultTemplateId. Empty/undefined
|
||||||
|
* means the template is only applied when explicitly chosen as the default.
|
||||||
|
*/
|
||||||
|
urlPattern?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of building the exact yt-dlp command line for the current form state, without running it. */
|
/** Result of building the exact yt-dlp command line for the current form state, without running it. */
|
||||||
@@ -410,3 +613,122 @@ export interface BackupImportResult {
|
|||||||
ok: boolean
|
ok: boolean
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sources & media-manager indexing (Pinchflat-style) ---------------------
|
||||||
|
// See ROADMAP-PINCHFLAT.md. A Source (channel/playlist) is indexed once into a
|
||||||
|
// persisted list of MediaItem records; the live download queue then pulls from
|
||||||
|
// that list a batch at a time, so "download an entire channel" never has to hold
|
||||||
|
// the whole collection in the queue at once.
|
||||||
|
|
||||||
|
/** Whether a Source is a whole channel or a single playlist. */
|
||||||
|
export type SourceKind = 'channel' | 'playlist'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A monitored channel or playlist that AeroFetch has indexed. Its full video
|
||||||
|
* list lives as MediaItem records (one JSON store), keyed back by `id`.
|
||||||
|
*/
|
||||||
|
export interface Source {
|
||||||
|
id: string
|
||||||
|
/** the URL the user added (used for re-indexing) */
|
||||||
|
url: string
|
||||||
|
kind: SourceKind
|
||||||
|
title: string
|
||||||
|
/** channel / uploader name, when known */
|
||||||
|
channel?: string
|
||||||
|
/** epoch ms the source was first added */
|
||||||
|
addedAt: number
|
||||||
|
/** epoch ms of the last successful (re)index; undefined if never indexed */
|
||||||
|
lastIndexedAt?: number
|
||||||
|
/** number of MediaItems at the last index (cached for list display) */
|
||||||
|
itemCount: number
|
||||||
|
/** watched for new uploads — included in scheduled / startup sync (Phase J) */
|
||||||
|
watched?: boolean
|
||||||
|
/** YouTube RSS feed URL for cheap "anything new?" checks; undefined if unknown */
|
||||||
|
feedUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One video discovered while indexing a Source. `playlistTitle`/`playlistIndex`
|
||||||
|
* drive the on-disk folder layout (<Channel>/<Playlist>/<NNN> - <Title>); the
|
||||||
|
* `downloaded` flag + `filePath` let the library view show per-item state and
|
||||||
|
* let re-syncs skip what's already on disk.
|
||||||
|
*/
|
||||||
|
export interface MediaItem {
|
||||||
|
/** globally unique, formed as `${sourceId}:${videoId}` */
|
||||||
|
id: string
|
||||||
|
sourceId: string
|
||||||
|
/** the yt-dlp video id — the dedup key within a source */
|
||||||
|
videoId: string
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
/** the playlist this item is filed under; 'Uploads' for videos in no playlist */
|
||||||
|
playlistTitle: string
|
||||||
|
/** 1-based position within its playlist, for NNN numbering */
|
||||||
|
playlistIndex: number
|
||||||
|
durationLabel?: string
|
||||||
|
downloaded: boolean
|
||||||
|
downloadedAt?: number
|
||||||
|
filePath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live progress pushed while a Source is being indexed (main → renderer). */
|
||||||
|
export interface IndexProgress {
|
||||||
|
/** the Source URL being indexed (correlates events back to the request) */
|
||||||
|
url: string
|
||||||
|
phase: 'start' | 'playlists' | 'playlist' | 'uploads' | 'done' | 'error'
|
||||||
|
/** human-readable status line */
|
||||||
|
message: string
|
||||||
|
/** for the per-playlist phase: playlists processed so far / total */
|
||||||
|
current?: number
|
||||||
|
total?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of indexing (or re-indexing) a Source. */
|
||||||
|
export interface IndexSourceResult {
|
||||||
|
ok: boolean
|
||||||
|
source?: Source
|
||||||
|
itemCount?: number
|
||||||
|
/** how many videos were new since the previous index (all of them on first index) */
|
||||||
|
newCount?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of syncing all watched sources: the videos found new across them. */
|
||||||
|
export interface SyncResult {
|
||||||
|
ok: boolean
|
||||||
|
/** newly-discovered media items across all watched sources (empty if nothing new) */
|
||||||
|
newItems: MediaItem[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the Windows scheduled-sync task is currently registered. */
|
||||||
|
export interface ScheduledSyncStatus {
|
||||||
|
enabled: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Built-in yt-dlp terminal (Phase N) -------------------------------------
|
||||||
|
// A power-user console that runs the bundled yt-dlp with raw, user-typed args
|
||||||
|
// and streams its output. The binary is fixed to yt-dlp (never arbitrary exes),
|
||||||
|
// and the feature is gated on the same customCommandEnabled consent flag as the
|
||||||
|
// per-download extra args (extra args can run code via --exec — audit F2).
|
||||||
|
|
||||||
|
/** Live output pushed on IpcChannels.terminalOutput while a terminal command runs. */
|
||||||
|
export type TerminalEvent =
|
||||||
|
| { type: 'output'; id: string; line: string; stream: 'stdout' | 'stderr' }
|
||||||
|
| { type: 'done'; id: string; code: number | null }
|
||||||
|
| { type: 'error'; id: string; error: string }
|
||||||
|
|
||||||
|
/** Result of starting a terminal command (pre-spawn validation only). */
|
||||||
|
export interface TerminalRunResult {
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overall queue state for the Windows taskbar progress bar (Phase O). */
|
||||||
|
export interface TaskbarProgress {
|
||||||
|
/** 0..1 overall fraction; ignored when mode is 'none' */
|
||||||
|
fraction: number
|
||||||
|
/** taskbar bar mode: hidden, normal, or red (some failed) */
|
||||||
|
mode: 'none' | 'normal' | 'error'
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
buildArgs,
|
buildArgs,
|
||||||
parseExtraArgs,
|
parseExtraArgs,
|
||||||
|
parseTrimSections,
|
||||||
|
selectExtraArgs,
|
||||||
formatCommandLine,
|
formatCommandLine,
|
||||||
|
sanitizeDirSegment,
|
||||||
|
collectionOutputTemplate,
|
||||||
CROP_SQUARE_PPA,
|
CROP_SQUARE_PPA,
|
||||||
ARIA2C_ARGS,
|
ARIA2C_ARGS,
|
||||||
type AccessOptions
|
type AccessOptions
|
||||||
@@ -326,6 +330,80 @@ describe('parseExtraArgs', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
|
||||||
|
const templates = [
|
||||||
|
{ id: 'thumb', args: '--write-thumbnail' },
|
||||||
|
{ id: 'danger', args: '--exec "calc.exe"' }
|
||||||
|
]
|
||||||
|
|
||||||
|
it('returns [] when custom commands are disabled, even with a per-download override', () => {
|
||||||
|
// The core fix: a renderer-supplied extraArgs must NOT run while the gate is off.
|
||||||
|
expect(
|
||||||
|
selectExtraArgs({
|
||||||
|
customCommandEnabled: false,
|
||||||
|
perDownloadExtraArgs: '--exec "calc.exe"',
|
||||||
|
defaultTemplateId: 'danger',
|
||||||
|
templates
|
||||||
|
})
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns [] when disabled even if a default template id is set', () => {
|
||||||
|
expect(
|
||||||
|
selectExtraArgs({
|
||||||
|
customCommandEnabled: false,
|
||||||
|
perDownloadExtraArgs: undefined,
|
||||||
|
defaultTemplateId: 'thumb',
|
||||||
|
templates
|
||||||
|
})
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses a per-download override when enabled', () => {
|
||||||
|
expect(
|
||||||
|
selectExtraArgs({
|
||||||
|
customCommandEnabled: true,
|
||||||
|
perDownloadExtraArgs: '--write-thumbnail --no-mtime',
|
||||||
|
defaultTemplateId: null,
|
||||||
|
templates
|
||||||
|
})
|
||||||
|
).toEqual(['--write-thumbnail', '--no-mtime'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an explicit empty override yields [] even with a default template set', () => {
|
||||||
|
expect(
|
||||||
|
selectExtraArgs({
|
||||||
|
customCommandEnabled: true,
|
||||||
|
perDownloadExtraArgs: '',
|
||||||
|
defaultTemplateId: 'thumb',
|
||||||
|
templates
|
||||||
|
})
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the default template when no per-download override is given', () => {
|
||||||
|
expect(
|
||||||
|
selectExtraArgs({
|
||||||
|
customCommandEnabled: true,
|
||||||
|
perDownloadExtraArgs: undefined,
|
||||||
|
defaultTemplateId: 'thumb',
|
||||||
|
templates
|
||||||
|
})
|
||||||
|
).toEqual(['--write-thumbnail'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns [] when the default template id matches nothing', () => {
|
||||||
|
expect(
|
||||||
|
selectExtraArgs({
|
||||||
|
customCommandEnabled: true,
|
||||||
|
perDownloadExtraArgs: undefined,
|
||||||
|
defaultTemplateId: 'missing',
|
||||||
|
templates
|
||||||
|
})
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('formatCommandLine', () => {
|
describe('formatCommandLine', () => {
|
||||||
it('joins the exe and args with spaces when nothing needs quoting', () => {
|
it('joins the exe and args with spaces when nothing needs quoting', () => {
|
||||||
expect(formatCommandLine('yt-dlp.exe', ['-f', 'best', '--', 'https://x.test/v'])).toBe(
|
expect(formatCommandLine('yt-dlp.exe', ['-f', 'best', '--', 'https://x.test/v'])).toBe(
|
||||||
@@ -360,3 +438,240 @@ describe('buildArgs extraArgs', () => {
|
|||||||
expect(withEmpty).toEqual(withoutParam)
|
expect(withEmpty).toEqual(withoutParam)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Media-server sidecar files (Phase K) -----------------------------------
|
||||||
|
|
||||||
|
describe('sidecar files', () => {
|
||||||
|
it('emits --write-info-json / --write-thumbnail / --write-description when enabled', () => {
|
||||||
|
const argv = build(
|
||||||
|
opts(),
|
||||||
|
dlo({ writeInfoJson: true, writeThumbnailFile: true, writeDescription: true })
|
||||||
|
)
|
||||||
|
expect(argv).toContain('--write-info-json')
|
||||||
|
expect(argv).toContain('--write-thumbnail')
|
||||||
|
expect(argv).toContain('--write-description')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits none of them by default', () => {
|
||||||
|
const argv = build(opts(), dlo())
|
||||||
|
expect(argv).not.toContain('--write-info-json')
|
||||||
|
expect(argv).not.toContain('--write-description')
|
||||||
|
// (--write-thumbnail is sidecar-only here; embedThumbnail uses --embed-thumbnail)
|
||||||
|
expect(argv).not.toContain('--write-thumbnail')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
it('keeps an ordinary name (incl. spaces and hyphens) intact', () => {
|
||||||
|
expect(sanitizeDirSegment('Linus Tech Tips')).toBe('Linus Tech Tips')
|
||||||
|
expect(sanitizeDirSegment('Two-Minute Papers')).toBe('Two-Minute Papers')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces Windows-illegal characters with a space and collapses runs', () => {
|
||||||
|
expect(sanitizeDirSegment('A/B\\C:D*E?F')).toBe('A B C D E F')
|
||||||
|
expect(sanitizeDirSegment('Best of 2024 <Live>')).toBe('Best of 2024 Live')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips leading/trailing dots and spaces, neutralising `..` traversal', () => {
|
||||||
|
expect(sanitizeDirSegment('..')).toBe('Untitled')
|
||||||
|
expect(sanitizeDirSegment('../../etc')).toBe('etc')
|
||||||
|
expect(sanitizeDirSegment(' trailing. ')).toBe('trailing')
|
||||||
|
expect(sanitizeDirSegment('My Playlist.')).toBe('My Playlist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefixes reserved Windows device names', () => {
|
||||||
|
expect(sanitizeDirSegment('CON')).toBe('_CON')
|
||||||
|
expect(sanitizeDirSegment('com1')).toBe('_com1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefixes a reserved device name even when it carries an extension (audit T3)', () => {
|
||||||
|
expect(sanitizeDirSegment('CON.txt')).toBe('_CON.txt')
|
||||||
|
expect(sanitizeDirSegment('nul.mp4')).toBe('_nul.mp4')
|
||||||
|
expect(sanitizeDirSegment('LPT1.foo.bar')).toBe('_LPT1.foo.bar')
|
||||||
|
// a non-reserved name that merely starts with similar letters is left alone
|
||||||
|
expect(sanitizeDirSegment('console.log')).toBe('console.log')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to Untitled for empty / all-illegal input', () => {
|
||||||
|
expect(sanitizeDirSegment('')).toBe('Untitled')
|
||||||
|
expect(sanitizeDirSegment('???')).toBe('Untitled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps length to keep the overall path bounded', () => {
|
||||||
|
expect(sanitizeDirSegment('x'.repeat(200)).length).toBe(80)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('collectionOutputTemplate', () => {
|
||||||
|
const ctx = { channel: 'DevChannel', playlist: 'Full Course', index: 3 }
|
||||||
|
|
||||||
|
it('files into <out>/<channel>/<playlist>/<NNN> - <filename>', () => {
|
||||||
|
const t = collectionOutputTemplate('C:/out', ctx, '%(title)s.%(ext)s')
|
||||||
|
// normalise separators so the assertion is OS-independent
|
||||||
|
expect(t.replace(/\\/g, '/')).toBe('C:/out/DevChannel/Full Course/003 - %(title)s.%(ext)s')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zero-pads the index to three digits and clamps bad values to 001', () => {
|
||||||
|
expect(collectionOutputTemplate('C:/o', { ...ctx, index: 42 }, 'f').replace(/\\/g, '/')).toContain(
|
||||||
|
'/042 - f'
|
||||||
|
)
|
||||||
|
expect(collectionOutputTemplate('C:/o', { ...ctx, index: 0 }, 'f').replace(/\\/g, '/')).toContain(
|
||||||
|
'/001 - f'
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
collectionOutputTemplate('C:/o', { ...ctx, index: NaN }, 'f').replace(/\\/g, '/')
|
||||||
|
).toContain('/001 - f')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizes the channel and playlist folder names', () => {
|
||||||
|
const t = collectionOutputTemplate(
|
||||||
|
'C:/out',
|
||||||
|
{ channel: 'A/B', playlist: '..', index: 1 },
|
||||||
|
'%(title)s.%(ext)s'
|
||||||
|
)
|
||||||
|
expect(t.replace(/\\/g, '/')).toBe('C:/out/A B/Untitled/001 - %(title)s.%(ext)s')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ describe('extractIncomingUrl — aerofetch:// protocol', () => {
|
|||||||
it('returns null when there is no url= param', () => {
|
it('returns null when there is no url= param', () => {
|
||||||
expect(extractIncomingUrl(['AeroFetch.exe', 'aerofetch://download'])).toBeNull()
|
expect(extractIncomingUrl(['AeroFetch.exe', 'aerofetch://download'])).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('normalises the target, stripping embedded control chars (audit T3 / F5)', () => {
|
||||||
|
// A tab spliced into the inner URL must not survive to the renderer banner.
|
||||||
|
const raw = 'https://www.youtube.com/watch?v=abc\tdef'
|
||||||
|
const arg = `aerofetch://download?url=${encodeURIComponent(raw)}`
|
||||||
|
expect(extractIncomingUrl(['AeroFetch.exe', arg])).toBe(
|
||||||
|
'https://www.youtube.com/watch?v=abcdef'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches the aerofetch:// scheme case-insensitively (audit T5)', () => {
|
||||||
|
const arg = `AEROFETCH://download?url=${encodeURIComponent(TARGET)}`
|
||||||
|
expect(extractIncomingUrl(['AeroFetch.exe', arg])).toBe(TARGET)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('extractIncomingUrl — .url Internet Shortcut (Explorer "Send to")', () => {
|
describe('extractIncomingUrl — .url Internet Shortcut (Explorer "Send to")', () => {
|
||||||
@@ -62,6 +76,18 @@ describe('extractIncomingUrl — .url Internet Shortcut (Explorer "Send to")', (
|
|||||||
it('ignores files that merely end in .url-like text but are not real paths', () => {
|
it('ignores files that merely end in .url-like text but are not real paths', () => {
|
||||||
expect(extractIncomingUrl(['AeroFetch.exe', 'not-a-real-path.url'])).toBeNull()
|
expect(extractIncomingUrl(['AeroFetch.exe', 'not-a-real-path.url'])).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reads a URL= line that falls within the 64 KB size cap (audit T5)', () => {
|
||||||
|
const junk = '; padding\r\n'.repeat(100) // ~1 KB of leading content
|
||||||
|
const path = urlFile('Small.url', `[InternetShortcut]\r\n${junk}URL=${TARGET}\r\n`)
|
||||||
|
expect(extractIncomingUrl(['AeroFetch.exe', path])).toBe(TARGET)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a URL= line that falls past the 64 KB size cap (audit T5)', () => {
|
||||||
|
const junk = '; padding\r\n'.repeat(8000) // ~88 KB, beyond the read window
|
||||||
|
const path = urlFile('Huge.url', `[InternetShortcut]\r\n${junk}URL=${TARGET}\r\n`)
|
||||||
|
expect(extractIncomingUrl(['AeroFetch.exe', path])).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('extractIncomingUrl — no match', () => {
|
describe('extractIncomingUrl — no match', () => {
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
classifySource,
|
||||||
|
buildMediaItems,
|
||||||
|
mergeItemsPreservingState,
|
||||||
|
buildFeedUrl,
|
||||||
|
isYouTubeFeedUrl,
|
||||||
|
parseRssVideoIds,
|
||||||
|
entryUrl,
|
||||||
|
fmtDuration,
|
||||||
|
stripTabSuffix,
|
||||||
|
stableSourceId,
|
||||||
|
type RawEntry
|
||||||
|
} from '../src/main/indexerCore'
|
||||||
|
import type { MediaItem } from '@shared/ipc'
|
||||||
|
|
||||||
|
describe('classifySource', () => {
|
||||||
|
it('classifies channel handle / id / c / user forms and strips the tab', () => {
|
||||||
|
expect(classifySource('https://www.youtube.com/@LinusTechTips')).toEqual({
|
||||||
|
kind: 'channel',
|
||||||
|
base: 'https://www.youtube.com/@LinusTechTips'
|
||||||
|
})
|
||||||
|
expect(classifySource('https://youtube.com/@Foo/videos')).toEqual({
|
||||||
|
kind: 'channel',
|
||||||
|
base: 'https://www.youtube.com/@Foo'
|
||||||
|
})
|
||||||
|
expect(classifySource('https://www.youtube.com/channel/UC123/playlists')?.base).toBe(
|
||||||
|
'https://www.youtube.com/channel/UC123'
|
||||||
|
)
|
||||||
|
expect(classifySource('https://www.youtube.com/c/SomeName')?.kind).toBe('channel')
|
||||||
|
expect(classifySource('https://www.youtube.com/user/Legacy')?.kind).toBe('channel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies a playlist by its list= param and canonicalises it', () => {
|
||||||
|
expect(classifySource('https://www.youtube.com/playlist?list=PL_abc')).toEqual({
|
||||||
|
kind: 'playlist',
|
||||||
|
base: 'https://www.youtube.com/playlist?list=PL_abc'
|
||||||
|
})
|
||||||
|
// a watch URL that also carries list= is treated as the playlist
|
||||||
|
expect(classifySource('https://www.youtube.com/watch?v=xyz&list=PLfoo')?.kind).toBe('playlist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for a lone video, a bad URL, or a non-YouTube host', () => {
|
||||||
|
expect(classifySource('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBeNull()
|
||||||
|
expect(classifySource('not a url')).toBeNull()
|
||||||
|
expect(classifySource('ftp://example.com/@chan')).toBeNull()
|
||||||
|
expect(classifySource('https://vimeo.com/channels/staffpicks')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('entryUrl', () => {
|
||||||
|
it('prefers an explicit http(s) url, else builds a watch url from the id', () => {
|
||||||
|
expect(entryUrl({ url: 'https://youtu.be/abc' })).toBe('https://youtu.be/abc')
|
||||||
|
expect(entryUrl({ webpage_url: 'https://x/y' })).toBe('https://x/y')
|
||||||
|
expect(entryUrl({ id: 'vid123' })).toBe('https://www.youtube.com/watch?v=vid123')
|
||||||
|
})
|
||||||
|
it('rejects a non-http url and returns null when nothing usable is present', () => {
|
||||||
|
expect(entryUrl({ url: 'javascript:alert(1)' })).toBeNull()
|
||||||
|
expect(entryUrl({})).toBeNull()
|
||||||
|
})
|
||||||
|
it('percent-encodes an untrusted id rather than splicing it raw (audit T2)', () => {
|
||||||
|
expect(entryUrl({ id: 'ab cd&x=1' })).toBe('https://www.youtube.com/watch?v=ab%20cd%26x%3D1')
|
||||||
|
expect(entryUrl({ id: 'vid123' })).toBe('https://www.youtube.com/watch?v=vid123') // unchanged
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fmtDuration', () => {
|
||||||
|
it('formats seconds as M:SS or H:MM:SS', () => {
|
||||||
|
expect(fmtDuration(0)).toBe('0:00')
|
||||||
|
expect(fmtDuration(75)).toBe('1:15')
|
||||||
|
expect(fmtDuration(3661)).toBe('1:01:01')
|
||||||
|
expect(fmtDuration(undefined)).toBeUndefined()
|
||||||
|
expect(fmtDuration(NaN)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('stripTabSuffix', () => {
|
||||||
|
it('strips a trailing " - <Tab>" suffix only', () => {
|
||||||
|
expect(stripTabSuffix('Creator - Videos')).toBe('Creator')
|
||||||
|
expect(stripTabSuffix('Creator - Playlists')).toBe('Creator')
|
||||||
|
expect(stripTabSuffix('Just A Name')).toBe('Just A Name')
|
||||||
|
expect(stripTabSuffix('Tech - Tips')).toBe('Tech - Tips') // 'Tips' isn't a tab
|
||||||
|
expect(stripTabSuffix(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('stableSourceId', () => {
|
||||||
|
it('is deterministic and 8 hex chars', () => {
|
||||||
|
const a = stableSourceId('https://www.youtube.com/@Foo')
|
||||||
|
expect(a).toMatch(/^[0-9a-f]{8}$/)
|
||||||
|
expect(stableSourceId('https://www.youtube.com/@Foo')).toBe(a)
|
||||||
|
expect(stableSourceId('https://www.youtube.com/@Bar')).not.toBe(a)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildMediaItems', () => {
|
||||||
|
const vids = (...ids: string[]): RawEntry[] => ids.map((id) => ({ id, title: `T-${id}` }))
|
||||||
|
|
||||||
|
it('files videos under their playlist with 1-based per-playlist index', () => {
|
||||||
|
const items = buildMediaItems('src1', [{ title: 'Series A', entries: vids('a', 'b') }], [])
|
||||||
|
expect(items).toHaveLength(2)
|
||||||
|
expect(items[0]).toMatchObject({
|
||||||
|
id: 'src1:a',
|
||||||
|
sourceId: 'src1',
|
||||||
|
videoId: 'a',
|
||||||
|
playlistTitle: 'Series A',
|
||||||
|
playlistIndex: 1,
|
||||||
|
downloaded: false
|
||||||
|
})
|
||||||
|
expect(items[1]).toMatchObject({ videoId: 'b', playlistIndex: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedups across playlists — the first playlist a video appears in wins', () => {
|
||||||
|
const items = buildMediaItems(
|
||||||
|
'src1',
|
||||||
|
[
|
||||||
|
{ title: 'First', entries: vids('x', 'y') },
|
||||||
|
{ title: 'Second', entries: vids('y', 'z') }
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
expect(items.map((i) => i.videoId)).toEqual(['x', 'y', 'z'])
|
||||||
|
expect(items.find((i) => i.videoId === 'y')?.playlistTitle).toBe('First')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the synthetic Uploads folder for videos in no playlist', () => {
|
||||||
|
const items = buildMediaItems('src1', [{ title: 'P', entries: vids('a') }], vids('a', 'b'))
|
||||||
|
// 'a' already filed under P; only 'b' becomes an Uploads item
|
||||||
|
expect(items).toHaveLength(2)
|
||||||
|
expect(items.find((i) => i.videoId === 'a')?.playlistTitle).toBe('P')
|
||||||
|
const b = items.find((i) => i.videoId === 'b')
|
||||||
|
expect(b).toMatchObject({ playlistTitle: 'Uploads', playlistIndex: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips entries with no id or no resolvable URL', () => {
|
||||||
|
const items = buildMediaItems('src1', [{ title: 'P', entries: [{ title: 'no id' }] }], [])
|
||||||
|
expect(items).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeItemsPreservingState', () => {
|
||||||
|
const item = (videoId: string, over: Partial<MediaItem> = {}): MediaItem => ({
|
||||||
|
id: `s:${videoId}`,
|
||||||
|
sourceId: 's',
|
||||||
|
videoId,
|
||||||
|
title: videoId,
|
||||||
|
url: `https://youtube.com/watch?v=${videoId}`,
|
||||||
|
playlistTitle: 'P',
|
||||||
|
playlistIndex: 1,
|
||||||
|
downloaded: false,
|
||||||
|
...over
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries downloaded state forward for videos already on disk', () => {
|
||||||
|
const existing = [item('a', { downloaded: true, downloadedAt: 111, filePath: 'C:/a.mp4' })]
|
||||||
|
const fresh = [item('a'), item('b')]
|
||||||
|
const { items, newCount } = mergeItemsPreservingState(existing, fresh)
|
||||||
|
expect(newCount).toBe(1) // only 'b' is new
|
||||||
|
expect(items.find((i) => i.videoId === 'a')).toMatchObject({
|
||||||
|
downloaded: true,
|
||||||
|
downloadedAt: 111,
|
||||||
|
filePath: 'C:/a.mp4'
|
||||||
|
})
|
||||||
|
expect(items.find((i) => i.videoId === 'b')?.downloaded).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adopts the fresh membership/order and drops vanished videos', () => {
|
||||||
|
const existing = [item('a', { downloaded: true }), item('gone', { downloaded: true })]
|
||||||
|
const fresh = [item('a', { playlistTitle: 'Moved', playlistIndex: 5 })]
|
||||||
|
const { items, newCount } = mergeItemsPreservingState(existing, fresh)
|
||||||
|
expect(items).toHaveLength(1) // 'gone' dropped
|
||||||
|
expect(newCount).toBe(0)
|
||||||
|
// fresh playlist assignment wins, but downloaded state is preserved
|
||||||
|
expect(items[0]).toMatchObject({ playlistTitle: 'Moved', playlistIndex: 5, downloaded: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts every video as new on a first index (empty existing)', () => {
|
||||||
|
const { newCount } = mergeItemsPreservingState([], [item('a'), item('b'), item('c')])
|
||||||
|
expect(newCount).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildFeedUrl', () => {
|
||||||
|
it('builds a channel feed by channel_id and a playlist feed by playlist_id', () => {
|
||||||
|
expect(buildFeedUrl('channel', 'UCabc')).toBe(
|
||||||
|
'https://www.youtube.com/feeds/videos.xml?channel_id=UCabc'
|
||||||
|
)
|
||||||
|
expect(buildFeedUrl('playlist', 'PLxyz')).toBe(
|
||||||
|
'https://www.youtube.com/feeds/videos.xml?playlist_id=PLxyz'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('returns undefined when the id is missing', () => {
|
||||||
|
expect(buildFeedUrl('channel', undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isYouTubeFeedUrl (audit T7 — SSRF guard)', () => {
|
||||||
|
it('accepts a youtube feeds URL (www optional) and what buildFeedUrl produces', () => {
|
||||||
|
expect(isYouTubeFeedUrl('https://www.youtube.com/feeds/videos.xml?channel_id=UCabc')).toBe(true)
|
||||||
|
expect(isYouTubeFeedUrl('https://youtube.com/feeds/videos.xml?playlist_id=PLxyz')).toBe(true)
|
||||||
|
expect(isYouTubeFeedUrl(buildFeedUrl('channel', 'UCabc')!)).toBe(true)
|
||||||
|
expect(isYouTubeFeedUrl(buildFeedUrl('playlist', 'PLxyz')!)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects internal/arbitrary hosts, wrong scheme, and wrong path (SSRF vectors)', () => {
|
||||||
|
expect(isYouTubeFeedUrl('http://169.254.169.254/latest/meta-data/')).toBe(false) // cloud metadata
|
||||||
|
expect(isYouTubeFeedUrl('http://localhost:8080/admin')).toBe(false)
|
||||||
|
expect(isYouTubeFeedUrl('https://evil.com/feeds/videos.xml')).toBe(false)
|
||||||
|
expect(isYouTubeFeedUrl('https://notyoutube.com.evil.com/feeds/videos.xml')).toBe(false)
|
||||||
|
expect(isYouTubeFeedUrl('http://www.youtube.com/feeds/videos.xml')).toBe(false) // not https
|
||||||
|
expect(isYouTubeFeedUrl('https://www.youtube.com/watch?v=x')).toBe(false) // wrong path
|
||||||
|
expect(isYouTubeFeedUrl('file:///etc/passwd')).toBe(false)
|
||||||
|
expect(isYouTubeFeedUrl('not a url')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseRssVideoIds', () => {
|
||||||
|
it('extracts every <yt:videoId> from a feed body, in order', () => {
|
||||||
|
const xml = `
|
||||||
|
<feed><entry><yt:videoId>aaa111</yt:videoId></entry>
|
||||||
|
<entry><yt:videoId> bbb222 </yt:videoId></entry></feed>`
|
||||||
|
expect(parseRssVideoIds(xml)).toEqual(['aaa111', 'bbb222'])
|
||||||
|
})
|
||||||
|
it('returns an empty array for a feed with no entries', () => {
|
||||||
|
expect(parseRssVideoIds('<feed></feed>')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { summarizeQueue, sameVideo } from '../src/renderer/src/store/queueStats'
|
||||||
|
import type { DownloadItem } from '../src/renderer/src/store/downloads'
|
||||||
|
|
||||||
|
// Minimal item factory — only the fields summarizeQueue reads.
|
||||||
|
function item(overrides: Partial<DownloadItem>): DownloadItem {
|
||||||
|
return {
|
||||||
|
id: Math.random().toString(36).slice(2),
|
||||||
|
url: 'https://youtube.com/watch?v=x',
|
||||||
|
title: 'x',
|
||||||
|
kind: 'video',
|
||||||
|
quality: '1080p',
|
||||||
|
status: 'queued',
|
||||||
|
progress: 0,
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('summarizeQueue', () => {
|
||||||
|
it('counts statuses and flags active', () => {
|
||||||
|
const s = summarizeQueue([
|
||||||
|
item({ status: 'downloading', progress: 0.5 }),
|
||||||
|
item({ status: 'queued' }),
|
||||||
|
item({ status: 'error' }),
|
||||||
|
item({ status: 'completed', progress: 1 }),
|
||||||
|
item({ status: 'canceled' })
|
||||||
|
])
|
||||||
|
expect(s.downloading).toBe(1)
|
||||||
|
expect(s.queued).toBe(1)
|
||||||
|
expect(s.failed).toBe(1)
|
||||||
|
expect(s.active).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is inactive and zeroed when nothing is downloading or queued', () => {
|
||||||
|
const s = summarizeQueue([item({ status: 'completed', progress: 1 })])
|
||||||
|
expect(s.active).toBe(false)
|
||||||
|
expect(s.progress).toBe(0)
|
||||||
|
expect(s.speedLabel).toBe('')
|
||||||
|
expect(s.etaLabel).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('averages progress over downloading + queued (queued counts as 0%)', () => {
|
||||||
|
// one at 80%, one queued at 0% → 40%
|
||||||
|
const s = summarizeQueue([
|
||||||
|
item({ status: 'downloading', progress: 0.8 }),
|
||||||
|
item({ status: 'queued' })
|
||||||
|
])
|
||||||
|
expect(s.progress).toBeCloseTo(0.4, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sums download speeds across MB/s and MiB/s strings', () => {
|
||||||
|
const s = summarizeQueue([
|
||||||
|
item({ status: 'downloading', progress: 0.1, speed: '4.0 MB/s' }),
|
||||||
|
item({ status: 'downloading', progress: 0.1, speed: '2000 KiB/s' })
|
||||||
|
])
|
||||||
|
// 4.0 MB/s + ~2.0 MB/s ≈ 6.0 MB/s
|
||||||
|
expect(s.speedLabel).toBe('6.0 MB/s')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports the longest active ETA as the time left', () => {
|
||||||
|
const s = summarizeQueue([
|
||||||
|
item({ status: 'downloading', progress: 0.5, eta: '0:30' }),
|
||||||
|
item({ status: 'downloading', progress: 0.2, eta: '2:10' })
|
||||||
|
])
|
||||||
|
expect(s.etaLabel).toBe('2:10')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sameVideo', () => {
|
||||||
|
it('matches identical URLs', () => {
|
||||||
|
expect(sameVideo('https://x.com/a', 'https://x.com/a')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches YouTube links by video id across watch/youtu.be/extra params', () => {
|
||||||
|
expect(
|
||||||
|
sameVideo(
|
||||||
|
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
'https://youtu.be/dQw4w9WgXcQ'
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
sameVideo(
|
||||||
|
'https://youtube.com/watch?v=dQw4w9WgXcQ&list=PL123',
|
||||||
|
'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not match different videos', () => {
|
||||||
|
expect(
|
||||||
|
sameVideo(
|
||||||
|
'https://youtube.com/watch?v=aaaaaaaaaaa',
|
||||||
|
'https://youtube.com/watch?v=bbbbbbbbbbb'
|
||||||
|
)
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalises www. and trailing slash for non-YouTube URLs', () => {
|
||||||
|
expect(sameVideo('https://www.vimeo.com/123/', 'https://vimeo.com/123')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { assertHttpUrl } from '../src/main/url'
|
||||||
|
import { isAllowedLoginUrl } from '../src/main/cookies'
|
||||||
|
import { isTrustedDownloadUrl, extractSha256, compareVersions } from '../src/main/updater'
|
||||||
|
import { isYtdlpUpdateChannel } from '@shared/ipc'
|
||||||
|
|
||||||
|
// --- F5: assertHttpUrl — argument-injection guard + normalisation -----------
|
||||||
|
|
||||||
|
describe('assertHttpUrl (audit F5)', () => {
|
||||||
|
it('accepts http(s) URLs and returns the normalised href', () => {
|
||||||
|
expect(assertHttpUrl('https://www.youtube.com/watch?v=abc')).toBe(
|
||||||
|
'https://www.youtube.com/watch?v=abc'
|
||||||
|
)
|
||||||
|
// http with a bare host normalises to a trailing slash
|
||||||
|
expect(assertHttpUrl('http://example.com')).toBe('http://example.com/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims surrounding whitespace', () => {
|
||||||
|
expect(assertHttpUrl(' https://example.com/v ')).toBe('https://example.com/v')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips interior tabs/newlines the URL parser tolerates (normalised output)', () => {
|
||||||
|
// Returning the raw input would leak these through to argv / loadURL.
|
||||||
|
expect(assertHttpUrl('https://exa\nmple.com/v')).toBe('https://example.com/v')
|
||||||
|
const out = assertHttpUrl('https://example.com/a\tb')
|
||||||
|
expect(out).not.toContain('\t')
|
||||||
|
expect(out).not.toContain('\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-http(s) protocols', () => {
|
||||||
|
expect(() => assertHttpUrl('file:///C:/Windows/system32')).toThrow()
|
||||||
|
expect(() => assertHttpUrl('javascript:alert(1)')).toThrow()
|
||||||
|
expect(() => assertHttpUrl('ftp://example.com/x')).toThrow()
|
||||||
|
expect(() => assertHttpUrl('aerofetch://download?url=x')).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unparseable input', () => {
|
||||||
|
expect(() => assertHttpUrl('not a url')).toThrow()
|
||||||
|
expect(() => assertHttpUrl('')).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can never return a value that begins with "-" (would read as a yt-dlp flag)', () => {
|
||||||
|
// A leading '-' cannot start a valid URL scheme, so it always throws —
|
||||||
|
// the returned value therefore never opens with a dash.
|
||||||
|
expect(() => assertHttpUrl('-https://example.com')).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- T4: isAllowedLoginUrl — sign-in window navigation confinement ----------
|
||||||
|
|
||||||
|
describe('isAllowedLoginUrl (audit T4)', () => {
|
||||||
|
it('allows http(s) and about:blank', () => {
|
||||||
|
expect(isAllowedLoginUrl('https://accounts.google.com/signin')).toBe(true)
|
||||||
|
expect(isAllowedLoginUrl('http://example.com/login')).toBe(true)
|
||||||
|
expect(isAllowedLoginUrl('about:blank')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks file://, the app protocol, and other external URI schemes', () => {
|
||||||
|
expect(isAllowedLoginUrl('file:///C:/Windows/System32/calc.exe')).toBe(false)
|
||||||
|
expect(isAllowedLoginUrl('aerofetch://download?url=https://evil.test')).toBe(false)
|
||||||
|
expect(isAllowedLoginUrl('ms-settings:')).toBe(false)
|
||||||
|
expect(isAllowedLoginUrl('mailto:x@y.z')).toBe(false)
|
||||||
|
expect(isAllowedLoginUrl('javascript:alert(1)')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks unparseable input', () => {
|
||||||
|
expect(isAllowedLoginUrl('not a url')).toBe(false)
|
||||||
|
expect(isAllowedLoginUrl('')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- app-updater: isTrustedDownloadUrl — host pin across redirect hops -------
|
||||||
|
|
||||||
|
describe('isTrustedDownloadUrl (app-updater host pin)', () => {
|
||||||
|
const onHost =
|
||||||
|
'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases/download/v1/AeroFetch-Setup.exe'
|
||||||
|
|
||||||
|
it('accepts https URLs on the exact update host (incl. port)', () => {
|
||||||
|
expect(isTrustedDownloadUrl(onHost)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a different host — the redirect/MITM bounce vector', () => {
|
||||||
|
expect(isTrustedDownloadUrl('https://evil.example/AeroFetch-Setup.exe')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects the right hostname on the wrong port', () => {
|
||||||
|
// host comparison includes the port, so :443 (default) is not the same origin
|
||||||
|
expect(isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk/AeroFetch-Setup.exe')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects plain http even on the update host', () => {
|
||||||
|
expect(isTrustedDownloadUrl('http://gitea.netbird.zimspace.uk:5938/x.exe')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not fooled by the trusted host placed in the userinfo', () => {
|
||||||
|
expect(
|
||||||
|
isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk:5938@evil.example/x.exe')
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unparseable input', () => {
|
||||||
|
expect(isTrustedDownloadUrl('not a url')).toBe(false)
|
||||||
|
expect(isTrustedDownloadUrl('')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- app-updater: extractSha256 — checksum-file parsing ----------------------
|
||||||
|
|
||||||
|
describe('extractSha256 (app-updater checksum parsing)', () => {
|
||||||
|
const hash = 'a'.repeat(64)
|
||||||
|
|
||||||
|
it('reads a bare lowercase digest', () => {
|
||||||
|
expect(extractSha256(hash)).toBe(hash)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads sha256sum format (`<hash> filename`)', () => {
|
||||||
|
expect(extractSha256(`${hash} AeroFetch-Setup-0.5.0.exe\n`)).toBe(hash)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lowercases a PowerShell Get-FileHash (uppercase) digest', () => {
|
||||||
|
expect(extractSha256('A'.repeat(64))).toBe(hash)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when there is no standalone 64-char hex token', () => {
|
||||||
|
expect(extractSha256('')).toBeNull()
|
||||||
|
expect(extractSha256('not a checksum')).toBeNull()
|
||||||
|
expect(extractSha256('deadbeef')).toBeNull() // too short
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not slice a 64-char window out of a longer hex run (e.g. sha512)', () => {
|
||||||
|
expect(extractSha256('a'.repeat(128))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- app-updater: compareVersions — prerelease never outranks its release ----
|
||||||
|
|
||||||
|
describe('compareVersions (app-updater version ordering)', () => {
|
||||||
|
it('orders by numeric components', () => {
|
||||||
|
expect(compareVersions('0.5.0', '0.4.0')).toBe(1)
|
||||||
|
expect(compareVersions('0.4.0', '0.5.0')).toBe(-1)
|
||||||
|
expect(compareVersions('1.2.3', '1.2.3')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a leading v as cosmetic', () => {
|
||||||
|
expect(compareVersions('v0.5.0', '0.5.0')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never sorts a prerelease above its final release', () => {
|
||||||
|
// 0.5.0-rc.1 must not be offered as an "upgrade" over a running 0.5.0
|
||||||
|
expect(compareVersions('0.5.0-rc.1', '0.5.0')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- F1: isYtdlpUpdateChannel — --update-to allowlist -----------------------
|
||||||
|
|
||||||
|
describe('isYtdlpUpdateChannel (audit F1)', () => {
|
||||||
|
it('accepts the two supported channels', () => {
|
||||||
|
expect(isYtdlpUpdateChannel('stable')).toBe(true)
|
||||||
|
expect(isYtdlpUpdateChannel('nightly')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an arbitrary repository spec (the --update-to RCE vector)', () => {
|
||||||
|
expect(isYtdlpUpdateChannel('evil/yt-dlp@latest')).toBe(false)
|
||||||
|
expect(isYtdlpUpdateChannel('owner/repo')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects other yt-dlp channels not on AeroFetch’s allowlist', () => {
|
||||||
|
expect(isYtdlpUpdateChannel('master')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-string / empty values', () => {
|
||||||
|
expect(isYtdlpUpdateChannel('')).toBe(false)
|
||||||
|
expect(isYtdlpUpdateChannel(undefined)).toBe(false)
|
||||||
|
expect(isYtdlpUpdateChannel(null)).toBe(false)
|
||||||
|
expect(isYtdlpUpdateChannel(42)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
+66
-2
@@ -4,9 +4,10 @@ import {
|
|||||||
isSafeOutputDir,
|
isSafeOutputDir,
|
||||||
isValidHistoryEntry,
|
isValidHistoryEntry,
|
||||||
isValidErrorLogEntry,
|
isValidErrorLogEntry,
|
||||||
isTemplateLike
|
isTemplateLike,
|
||||||
|
isValidSource
|
||||||
} from '../src/main/validation'
|
} from '../src/main/validation'
|
||||||
import type { HistoryEntry, ErrorLogEntry } from '@shared/ipc'
|
import type { HistoryEntry, ErrorLogEntry, Source } from '@shared/ipc'
|
||||||
|
|
||||||
// --- S4: filename template path-traversal -----------------------------------
|
// --- S4: filename template path-traversal -----------------------------------
|
||||||
|
|
||||||
@@ -40,6 +41,24 @@ describe('isSafeFilenameTemplate', () => {
|
|||||||
it('allows .. only when embedded in a longer segment (not a real traversal)', () => {
|
it('allows .. only when embedded in a longer segment (not a real traversal)', () => {
|
||||||
// '..foo' / 'foo..bar' are filenames, not parent-dir references.
|
// '..foo' / 'foo..bar' are filenames, not parent-dir references.
|
||||||
expect(isSafeFilenameTemplate('my..video.%(ext)s')).toBe(true)
|
expect(isSafeFilenameTemplate('my..video.%(ext)s')).toBe(true)
|
||||||
|
expect(isSafeFilenameTemplate('.%(title)s.%(ext)s')).toBe(true) // leading dot = hidden file
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- T1: Windows-specific bypasses --------------------------------------
|
||||||
|
|
||||||
|
it('rejects a drive-relative prefix that path.isAbsolute misses', () => {
|
||||||
|
// 'C:foo' is NOT absolute per Node, but still escapes the output dir.
|
||||||
|
expect(isSafeFilenameTemplate('C:foo\\%(title)s.%(ext)s')).toBe(false)
|
||||||
|
expect(isSafeFilenameTemplate('c:%(title)s')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a .. segment dressed up with trailing/leading dots or spaces', () => {
|
||||||
|
// Windows trims trailing dots/spaces, so these all resolve to '..'.
|
||||||
|
expect(isSafeFilenameTemplate('%(title)s/.. /x')).toBe(false)
|
||||||
|
expect(isSafeFilenameTemplate('%(title)s/.. ./x')).toBe(false)
|
||||||
|
expect(isSafeFilenameTemplate('%(title)s/ ../x')).toBe(false)
|
||||||
|
expect(isSafeFilenameTemplate('%(title)s\\.. \\x')).toBe(false)
|
||||||
|
expect(isSafeFilenameTemplate('...')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -144,3 +163,48 @@ describe('isTemplateLike', () => {
|
|||||||
expect(isTemplateLike('str')).toBe(false)
|
expect(isTemplateLike('str')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- T7: source row validation (feedUrl drives a network fetch) --------------
|
||||||
|
|
||||||
|
const validSource: Source = {
|
||||||
|
id: 's1',
|
||||||
|
url: 'https://www.youtube.com/@x',
|
||||||
|
kind: 'channel',
|
||||||
|
title: 'X',
|
||||||
|
addedAt: 1700000000000,
|
||||||
|
itemCount: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isValidSource', () => {
|
||||||
|
it('accepts a well-formed source and its optional fields', () => {
|
||||||
|
expect(isValidSource(validSource)).toBe(true)
|
||||||
|
expect(
|
||||||
|
isValidSource({
|
||||||
|
...validSource,
|
||||||
|
channel: 'X',
|
||||||
|
watched: true,
|
||||||
|
feedUrl: 'https://www.youtube.com/feeds/videos.xml?channel_id=UC',
|
||||||
|
lastIndexedAt: 1700000000001
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-string feedUrl (audit T7)', () => {
|
||||||
|
expect(isValidSource({ ...validSource, feedUrl: 123 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-boolean watched (audit T7)', () => {
|
||||||
|
expect(isValidSource({ ...validSource, watched: 'yes' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a wrong kind or a missing required field', () => {
|
||||||
|
expect(isValidSource({ ...validSource, kind: 'video' })).toBe(false)
|
||||||
|
const { id: _id, ...noId } = validSource
|
||||||
|
expect(isValidSource(noId)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-objects', () => {
|
||||||
|
expect(isValidSource(null)).toBe(false)
|
||||||
|
expect(isValidSource('nope')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { shouldAutoCheckYtdlp, AUTO_UPDATE_INTERVAL_MS } from '../src/main/ytdlpPolicy'
|
||||||
|
|
||||||
|
const NOW = 1_782_435_469_486 // arbitrary fixed "now"
|
||||||
|
|
||||||
|
describe('shouldAutoCheckYtdlp', () => {
|
||||||
|
it('never checks when auto-update is disabled', () => {
|
||||||
|
expect(shouldAutoCheckYtdlp(false, 0, NOW)).toBe(false)
|
||||||
|
// …even if a full interval has elapsed.
|
||||||
|
expect(shouldAutoCheckYtdlp(false, NOW - AUTO_UPDATE_INTERVAL_MS * 2, NOW)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks on first run (lastCheck is 0 / never)', () => {
|
||||||
|
expect(shouldAutoCheckYtdlp(true, 0, NOW)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips while inside the throttle window', () => {
|
||||||
|
// Checked one minute ago → far inside the 24h window.
|
||||||
|
expect(shouldAutoCheckYtdlp(true, NOW - 60_000, NOW)).toBe(false)
|
||||||
|
// Exactly one ms short of the interval still skips.
|
||||||
|
expect(shouldAutoCheckYtdlp(true, NOW - (AUTO_UPDATE_INTERVAL_MS - 1), NOW)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks once the interval has elapsed (boundary inclusive)', () => {
|
||||||
|
expect(shouldAutoCheckYtdlp(true, NOW - AUTO_UPDATE_INTERVAL_MS, NOW)).toBe(true)
|
||||||
|
expect(shouldAutoCheckYtdlp(true, NOW - AUTO_UPDATE_INTERVAL_MS * 3, NOW)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours a custom interval', () => {
|
||||||
|
const hour = 60 * 60 * 1000
|
||||||
|
expect(shouldAutoCheckYtdlp(true, NOW - 30 * 60_000, NOW, hour)).toBe(false)
|
||||||
|
expect(shouldAutoCheckYtdlp(true, NOW - hour, NOW, hour)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user