docs: remove completed roadmap + release-gate docs
The ROADMAP files are fully shipped/retrospective and the smoke-test and signing guides are retired at the maintainer's request. resources/bin/README.md is kept (documents bundled-binary setup + license obligations). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,266 +0,0 @@
|
||||
# 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
|
||||
itemCount: number // cached MediaItem count for list display
|
||||
watched?: boolean // included in scheduled / startup sync (Phase J)
|
||||
feedUrl?: string // YouTube RSS feed for cheap "anything new?" checks
|
||||
}
|
||||
interface MediaItem {
|
||||
id: string // globally unique, `${sourceId}:${videoId}`
|
||||
sourceId: string
|
||||
videoId: string // yt-dlp video id — the dedup key within a source
|
||||
title: string
|
||||
url: string
|
||||
playlistTitle: string // for the folder path; 'Uploads' fallback
|
||||
playlistIndex: number // 1-based, for NNN numbering
|
||||
durationLabel?: string
|
||||
downloaded: boolean
|
||||
downloadedAt?: number
|
||||
filePath?: string
|
||||
}
|
||||
```
|
||||
*(This block reflects the shipped `src/shared/ipc.ts` shape; the original sketch
|
||||
predated the `videoId`/`itemCount`/`watched`/`feedUrl` fields.)*
|
||||
- [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.
|
||||
- [x] **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. *Implemented (Batch 26):
|
||||
`Settings.collectionOutputTemplate` — blank = the built-in `Channel/Playlist/NNN - Title`
|
||||
layout; a raw yt-dlp `-o` template overrides it for collection downloads.
|
||||
`collectionOutputTemplate()` (buildArgs) takes the optional custom template; the zod schema
|
||||
validates it as a safe relative template; surfaced as the "Collection folder layout" field in
|
||||
Settings → Downloads → Filenames. A Media Profile can override it per-source (Batch 27).*
|
||||
- [x] **"Media Profile" = named download preset.** Pinchflat bundles quality + subs + output
|
||||
template into a reusable Profile. Implemented: a `MediaProfile` = named bundle of
|
||||
`{ kind, quality, options, outputTemplate, extraArgs }` a Source points at via `Source.profileId`.
|
||||
Backend mirrors the `CommandTemplate` precedent — `src/main/profiles.ts` on `createJsonStore`
|
||||
(`profiles.json`, `PROFILES_MAX` cap, `isProfileLike` guard + `sanitize()`), CRUD IPC
|
||||
(`profiles:list/save/remove`) + preload + a `useProfiles` renderer store. Resolution is the pure
|
||||
`resolveProfile` in `@shared/profileResolve.ts` (profile → global default, field by field; unit-tested
|
||||
in `test/profileResolve.test.ts`), applied in the sources store's `enqueueItems` so a source's
|
||||
downloads follow its profile. UI: a `ProfileManager` (mirrors `TemplateManager`) in a new Settings →
|
||||
Advanced → **Media profiles** card, plus a per-source **Profile** picker on the Library source detail.
|
||||
Live-verified both surfaces render in the preview.
|
||||
|
||||
## 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.
|
||||
- [x] **Retention / quality-upgrade (stretch, from Pinchflat).** Optional re-download to upgrade
|
||||
quality/metadata, and optional pruning of old items. Implemented as an on-demand per-source
|
||||
maintenance panel (`SourceRetention` on the Library source detail), not a persisted schedule
|
||||
(matching the "niche for a desktop app" scope). **Prune:** keep the newest N downloaded files,
|
||||
delete the older ones' files (the download archive stops them silently re-downloading); shows a
|
||||
dry-run count and a two-step confirm before any deletion. **Upgrade:** re-enqueue items whose
|
||||
recorded `downloadedQuality` ranks below the source's current target (its profile's quality or
|
||||
the global default). The selection logic is pure + injected-now (`@shared/retention.ts`:
|
||||
`selectPruneCandidates`/`selectUpgradeCandidates`, `qualityRank` in `@shared/ipc`), unit-tested
|
||||
in `test/retention.test.ts`; the impure prune (`pruneMediaItems` in `src/main/sources.ts`)
|
||||
deletes files best-effort and clears `downloaded`. `MediaItem.downloadedQuality` is now recorded
|
||||
through the completion path. Live-verified the panel renders on an expanded source.
|
||||
|
||||
## 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.
|
||||
-459
@@ -1,459 +0,0 @@
|
||||
# AeroFetch Roadmap — feature parity with Seal
|
||||
|
||||
AeroFetch is the Windows/Electron counterpart to [Seal](https://github.com/JunkFood02/Seal),
|
||||
the Android Material-You frontend for [yt-dlp](https://github.com/yt-dlp/yt-dlp). The core
|
||||
loop (probe → format pick → queue → download → history) is already in place. This document
|
||||
tracks the remaining **gap**: everything Seal does that AeroFetch doesn't yet.
|
||||
|
||||
Sources: [Seal GitHub](https://github.com/JunkFood02/Seal) ·
|
||||
[Seal on F-Droid](https://f-droid.org/packages/com.junkfood.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 ✅
|
||||
|
||||
Video + audio download · audio → mp3 with embedded thumbnail/metadata · interactive format
|
||||
picker (probe) · quality presets · queue + concurrency cap + cancel · download history
|
||||
(open / show-in-folder) · clipboard auto-detect · light/dark theme · output folder +
|
||||
filename template · yt-dlp version check · NSIS installer + portable build.
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Core download power ✅ COMPLETE
|
||||
|
||||
A shared `DownloadOptions` model (`src/shared/ipc.ts`) carries the post-processing
|
||||
choices end-to-end. Persisted defaults are editable in **Settings → Format &
|
||||
post-processing** via a reusable `DownloadOptionsForm` component. (The per-download
|
||||
**Options** panel in the download bar is plumbed end-to-end — `StartDownloadOptions.options`/
|
||||
`extraArgs` — but not yet surfaced in the bar UI; see audit UX1/M5.) The main process emits
|
||||
the flags in `buildArgs` (`src/main/buildArgs.ts`). All items verified in the UI preview
|
||||
(typecheck clean). **Real-download smoke test ✅ (2026-06-23):** crop-to-square cover,
|
||||
audio re-encode (opus), video container + codec preference (mkv + vp9 merge), subtitle
|
||||
embed (→ mov_text), chapter embed, and SponsorBlock-remove (output duration shrinks by the
|
||||
cut segments) are now all verified against live yt-dlp + ffmpeg in
|
||||
[real-download.integration.test.ts](test/real-download.integration.test.ts). That pass
|
||||
found and fixed a real shipping bug — the bundled `ffprobe.exe` was missing (see CODE-AUDIT C1).
|
||||
|
||||
- [x] **Playlist downloads.** Probe now uses `yt-dlp -J --flat-playlist` and returns either
|
||||
a single video (with formats) or a flat playlist. The download bar shows a selection
|
||||
panel (scrollable checkbox list, select-all/none, live count) and enqueues each chosen
|
||||
entry as its own single-video download — reusing the existing queue/concurrency/history.
|
||||
- [x] **Subtitles.** Download + embed (`--write-subs`, `--write-auto-subs`, `--sub-langs`,
|
||||
`--embed-subs`, `--convert-subs srt`); language field + auto-caption toggle.
|
||||
- [x] **SponsorBlock.** `--sponsorblock-mark` / `--sponsorblock-remove` with a category
|
||||
multi-select (sponsor, intro, outro, selfpromo, music_offtopic…) + `--force-keyframes-at-cuts`.
|
||||
- [x] **More audio formats.** Was mp3-only. Now best / mp3 / m4a / opus / flac / wav / aac
|
||||
via `--audio-format`.
|
||||
- [x] **Video container + codec preference.** Container choice (mp4/mkv/webm) +
|
||||
preferred codec (`-S res,fps,vcodec:…`) to nudge toward av1 / vp9 / h264 without
|
||||
overriding the requested resolution.
|
||||
- [x] **Embed chapters.** `--embed-chapters` toggle. (`--split-chapters` shipped in Phase L.)
|
||||
- [x] **Embed thumbnail crop-to-square** for audio (Seal's "crop artwork") via thumbnail
|
||||
post-processor args. *Smoke-tested ✅: the embedded cover comes out a perfect square
|
||||
(360×360) — the `--ppa` crop recipe survives yt-dlp's shlex split + ffmpeg's filtergraph.*
|
||||
- [x] **Per-download overrides.** Collapsible Options panel in the download bar (shared
|
||||
`DownloadOptionsForm`) so one download — or one playlist batch — can deviate from the
|
||||
persisted defaults; "Reset to defaults" clears it. Threads `options` through
|
||||
`addFromUrl → DownloadItem → startDownload` and is carried on retry.
|
||||
|
||||
## Phase B — Access & networking ✅ COMPLETE
|
||||
|
||||
`buildArgs`'s former `NetworkOptions` param is now `AccessOptions` (still in
|
||||
`src/main/buildArgs.ts`) — it grew past pure networking once cookies and
|
||||
filename/archive flags joined proxy/rate-limit/aria2c. **Smoke-tested ✅ (2026-06-23):**
|
||||
`--restrict-filenames` (spaces → underscores) and `--download-archive` (a second run of
|
||||
the same URL is skipped with no fresh download) are verified end-to-end. *Still untested
|
||||
live: the sign-in window's cookie export — it needs an interactive login against a gated
|
||||
video, which can't run unattended, so it stays reasoning + typecheck only. aria2c
|
||||
(optional, binary not bundled) and proxy (needs a live proxy server) are likewise not
|
||||
smoke-tested.*
|
||||
|
||||
- [x] **Cookies.** Settings → Cookies has a source picker:
|
||||
- *From a browser's cookie store* → `--cookies-from-browser <name>` (chrome/edge/
|
||||
firefox/brave/opera/vivaldi — `COOKIE_BROWSERS` in `src/shared/ipc.ts`).
|
||||
- *Sign-in window (built into AeroFetch)* → opens an Electron `BrowserWindow` on a
|
||||
persisted session partition (`persist:aerofetch-login`, see `src/main/cookies.ts`);
|
||||
OAuth/SSO popups are allowed through onto the same partition. Closing the window
|
||||
exports its cookies to a Netscape-format file (`userData/cookies.txt`) which
|
||||
`buildArgs` passes via `--cookies`. Settings → Cookies shows last-saved time + a
|
||||
"Clear saved cookies" button (also clears the partition's storage).
|
||||
- [x] **aria2c external downloader.** Settings → Network has a "Use aria2c downloader"
|
||||
toggle; when on and `resources/bin/aria2c.exe` exists, `buildArgs` emits
|
||||
`--downloader <path> --downloader-args` (`ARIA2C_ARGS` in `src/main/buildArgs.ts`:
|
||||
`-x 16 -s 16 -k 1M`). Missing binary falls back silently to yt-dlp's own downloader
|
||||
(checked in `src/main/download.ts`). Binary is optional and not committed — see
|
||||
`resources/bin/README.md`.
|
||||
- [x] **Proxy** (`--proxy`) and **rate limit** (`--limit-rate`). Settings → Network fields
|
||||
(`Settings.proxy` / `Settings.rateLimit`), threaded through `buildArgs`'s
|
||||
`AccessOptions` param.
|
||||
- [x] **Restrict filenames** toggle (`--restrict-filenames`) + **download archive**
|
||||
(`--download-archive`, skip already-downloaded) — both in Settings → Filenames.
|
||||
The archive file lives at a fixed `userData/download-archive.txt`
|
||||
(`getDownloadArchivePath()` in `src/main/settings.ts`), not user-configurable.
|
||||
|
||||
## Phase C — Custom commands & yt-dlp management ✅ COMPLETE
|
||||
|
||||
Named `CommandTemplate`s (`src/shared/ipc.ts`) persist to `templates.json`
|
||||
(`src/main/templates.ts`, mirrors `history.ts`'s plain-JSON pattern) and are managed
|
||||
inline in **Settings → Custom commands** via `TemplateManager.tsx` — no modal, matching
|
||||
the rest of the app's no-portal-overlay convention (see the Hint/Select comments re:
|
||||
this dev machine's GPU/driver). A template's extra yt-dlp flags are shell-split
|
||||
(`parseExtraArgs`, `src/main/buildArgs.ts`) and appended to the generated argv
|
||||
immediately before the `--` URL terminator, so a template flag can override anything
|
||||
chosen earlier. `Settings.customCommandEnabled` + `defaultTemplateId` apply a template
|
||||
to every new download; the download bar's own **Custom command** panel lets a single
|
||||
download override that default or opt out entirely (`extraArgs` on
|
||||
`StartDownloadOptions`, mirroring the existing per-download `options` override).
|
||||
`buildCommand()` (`src/main/download.ts`) now centralises settings/access/
|
||||
options/extraArgs resolution, shared by both the real `startDownload` spawn and the new
|
||||
preview path. All items verified in the UI preview + `npm run typecheck` +
|
||||
`npm run test` (11 new unit tests covering `parseExtraArgs`, `formatCommandLine`, and
|
||||
extraArgs ordering). **Smoke-tested ✅ (2026-06-23):** `parseExtraArgs('--write-info-json
|
||||
--no-mtime')` splits into discrete tokens and the flags reach a live yt-dlp run — the
|
||||
`.info.json` sidecar is written, confirming custom-command args take effect end-to-end.
|
||||
|
||||
- [x] **Custom command templates.** Named, editable templates of extra yt-dlp args
|
||||
(`CommandTemplate`), CRUD'd inline via `TemplateManager.tsx`; persisted to
|
||||
`templates.json`. **Pick a default** via `Settings.defaultTemplateId` (a Select in
|
||||
the Custom commands card). The **"run custom command" mode** is
|
||||
`Settings.customCommandEnabled`, applied to every new download unless overridden
|
||||
per-download in the download bar's Custom command panel.
|
||||
- [x] **Command preview (surfaced).** `IpcChannels.commandPreview` → `previewCommand()`
|
||||
(`src/main/download.ts`) builds the exact argv via the same `buildCommand()` path a real
|
||||
download would take, then renders it with `formatCommandLine`. **Now surfaced:** the
|
||||
DownloadBar's **"Show command"** toggle (`useDownloadBar.toggleCommandPreview` →
|
||||
`window.api.previewCommand(...)`) renders the exact argv in an inline panel that reflects the
|
||||
current URL/format/options/trim/schedule. (Checkbox was stale — the button has shipped;
|
||||
re-verified in the browser preview: setting a URL + "Show command" renders the argv panel.)
|
||||
- [x] **In-app yt-dlp updater.** `updateYtdlp(channel)` (`src/main/ytdlp.ts`) runs
|
||||
yt-dlp's own `--update-to stable|nightly`. Surfaced in **Settings → About**, right
|
||||
next to the existing version check, with a channel picker + result output.
|
||||
|
||||
## Phase D — Library, UX & notifications ✅ COMPLETE
|
||||
|
||||
History stays plain JSON (`history.json`, 500-entry cap) — search/filter/select are done
|
||||
client-side over the already-small array, so the planned better-sqlite3 migration wasn't
|
||||
needed for this phase and was deliberately skipped to avoid a native-module build step for
|
||||
no functional gain at this scale. Failed downloads (pre-spawn rejections and in-flight
|
||||
yt-dlp failures alike) now persist to a new `errorlog.json` (`src/main/errorlog.ts`,
|
||||
200-entry cap) independent of the queue, so a report survives `Clear finished`. Native
|
||||
notifications and the backup file both reuse existing settings/templates plumbing
|
||||
(`getSettings`/`setSettings`, `listTemplates`/the new `replaceTemplates`) rather than adding
|
||||
parallel storage. All items verified in the UI preview (typecheck + `npm run test` clean).
|
||||
|
||||
- [x] **History search + multi-select + filter** (video/audio) + bulk delete + **re-download**.
|
||||
`HistoryView.tsx` gained a search box, an All/Video/Audio filter, a "Select" mode with
|
||||
per-row checkboxes + select-all + **Delete selected** (new `historyRemoveMany` IPC,
|
||||
`src/main/history.ts`), and a per-row re-download button that re-queues the entry's
|
||||
URL/kind/quality via the existing `addFromUrl`.
|
||||
- [x] **Native OS notifications** on completion / failure (Electron `Notification`,
|
||||
`src/main/download.ts`). Gated by a new **Notify when downloads finish** switch
|
||||
(`Settings.notifyOnComplete`, default on); clicking a notification refocuses the window.
|
||||
- [x] **Private / incognito mode (surfaced).** `DownloadItem.incognito` flows through `buildItem`
|
||||
→ history-skip → the QueueItem "Private" badge, and `store/downloads.ts` checks it before
|
||||
ever calling `useHistory().add(...)`. **Now surfaced:** the DownloadBar's Advanced options
|
||||
panel carries an **"Incognito mode (no logging, no history, no cookies)"** checkbox
|
||||
(`useDownloadBar.setIncognito`), one-shot per download. The main-process promise is also
|
||||
complete — L136 (Batch 8) threaded `incognito` through `StartDownloadOptions` so main skips
|
||||
the errorlog write, drops the notification detail, and withholds cookies for a private
|
||||
download, not just the renderer's history skip.
|
||||
- [x] **Backup / restore** settings + templates (export / import JSON). New
|
||||
`src/main/backup.ts` writes/reads `{ settings, templates }` via a save/open dialog;
|
||||
`replaceTemplates()` (`src/main/templates.ts`) does a full restore rather than a
|
||||
merge-by-id. Surfaced as **Settings → Backup & restore**.
|
||||
- [x] **Error log / copy error report** view (Seal's debug report). New
|
||||
`src/main/errorlog.ts` persists every failure (pre-spawn rejection, spawn error, or
|
||||
non-zero yt-dlp exit); **Settings → Diagnostics** lists recent entries with
|
||||
**Copy full report** and **Clear log**.
|
||||
|
||||
## Phase E — Theming & platform polish ✅ COMPLETE
|
||||
|
||||
Some items are Android-specific in Seal and adapted to Windows here.
|
||||
|
||||
- [x] **Theme presets + "follow system" and high-contrast.** Settings → Appearance has a
|
||||
Theme select (Light / Dark / **Follow system**) and four accent presets — Rose,
|
||||
Coral, Amber, Teal ("Sunset-to-sea", default Teal) — each a 16-stop `BrandVariants`
|
||||
ramp sharing one lightness curve at a different hue (`src/renderer/src/theme.ts`). "Follow
|
||||
system" reads Electron's `nativeTheme.shouldUseDarkColors` in the main process
|
||||
(`src/main/index.ts`'s `resolveBackgroundMode`/`registerSystemThemeBridge`, pushed to
|
||||
the renderer over a new `system-theme:update` channel into
|
||||
`src/renderer/src/store/systemTheme.ts`) so the native window background stays in sync
|
||||
too, not just the in-page theme. High contrast: `nativeTheme.shouldUseHighContrastColors`
|
||||
surfaces as a status row in Settings → Appearance with a shortcut to Windows' contrast
|
||||
settings (`ms-settings:easeofaccess-highcontrast`); actual color overrides are left to
|
||||
Chromium's native `forced-colors` handling since nothing in the app sets
|
||||
`forced-color-adjust: none`. The sidebar's quick toggle still works under "system" — it
|
||||
sets an explicit light/dark away from whatever's currently resolved, breaking out of
|
||||
auto. Verified in the UI preview (typecheck + `npm run test` clean): default Teal,
|
||||
switching to Rose/Coral/Amber, Light/Dark/Follow-system, and the sidebar
|
||||
toggle's break-out-of-system behavior all checked visually.
|
||||
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
|
||||
(non-MSIX) app can't register as an actual Share Target:
|
||||
- **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at
|
||||
install time via `electron-builder.yml`'s `protocols` field (NSIS), and at runtime via
|
||||
`app.setAsDefaultProtocolClient` in `src/main/index.ts` (covers the portable build and
|
||||
dev, which have no installer step to do it for them).
|
||||
- **Explorer "Send to AeroFetch"** — `src/main/deeplink.ts`'s `registerSendToShortcut()`
|
||||
writes a `.lnk` into `%APPDATA%\Microsoft\Windows\SendTo` via Electron's
|
||||
`shell.writeShortcutLink` (no native deps). Sending a `.url` Internet Shortcut file
|
||||
(what Explorer/browsers create from "Create shortcut"/dragging a tab to the desktop)
|
||||
passes its path as argv; `extractIncomingUrl()` parses the `URL=` line.
|
||||
|
||||
Both paths funnel through the same `extractIncomingUrl()` (only ever forwards http(s)
|
||||
targets — same restriction as the existing external-link window-open handler) and a new
|
||||
`external-url` IPC push channel. The renderer surfaces the incoming link via
|
||||
`DownloadBar.tsx`'s existing clipboard-suggestion banner (now labeled "Link received:"
|
||||
for this source instead of "Use copied link?") rather than a new UI element. Added
|
||||
`app.requestSingleInstanceLock()` + a `second-instance` handler so a second invocation
|
||||
(the OS relaunching us for the protocol/SendTo case) hands its link to the already-running
|
||||
window instead of opening a duplicate. `extractIncomingUrl()`'s parsing/validation logic
|
||||
has 10 new unit tests (`test/deeplink.test.ts`); typecheck and the full suite are clean.
|
||||
*Caveat: the OS-level wiring (protocol registration, second-instance routing, the SendTo
|
||||
shortcut) is main-process/Electron-only and can't be exercised in the Vite browser
|
||||
preview used elsewhere in this phase — worth a real install + a manual
|
||||
`aerofetch://download?url=...` / "Send to" smoke test, same spirit as Phases A–C's
|
||||
real-download caveats.*
|
||||
- [x] **Onboarding / welcome screen** on first run. `Settings.hasCompletedOnboarding`
|
||||
(default `false`, persisted) gates a full-screen `Onboarding.tsx` rendered in place of
|
||||
the sidebar + main content in `App.tsx` — not a Fluent `Dialog`, since this app avoids
|
||||
Fluent's portal-based overlays entirely (GPU/driver blank-overlay issue noted in
|
||||
`src/main/index.ts` and `Select.tsx`). Shows the brand mark, a one-line pitch, the
|
||||
download-folder picker (same field as Settings → Downloads), three tip bullets, and a
|
||||
"Get started" button that flips the flag. Gated on the settings store's `loaded` flag
|
||||
so a returning user's real settings can never get clobbered by a one-frame flash of the
|
||||
default-false state. Verified in the UI preview (typecheck + `npm run test` clean):
|
||||
screen renders, folder field/browse work, "Get started" dismisses into the normal app.
|
||||
|
||||
---
|
||||
|
||||
## Not portable from Seal (noted for completeness)
|
||||
|
||||
- SAF directory picker (Android storage) → already covered by the native folder dialog.
|
||||
- Android share-sheet / quick-download-on-share → mapped to Phase E's `aerofetch://` protocol
|
||||
+ Explorer "Send to" integration (a true Share Target needs an MSIX package, out of scope
|
||||
for this app's NSIS/portable distribution).
|
||||
- F-Droid distribution → N/A (AeroFetch ships NSIS + portable).
|
||||
|
||||
---
|
||||
|
||||
# Post-parity — editing, native polish & reliability
|
||||
|
||||
With both parity tracks complete — Seal (Phases A–E above) and the
|
||||
[Pinchflat media-manager roadmap](ROADMAP-PINCHFLAT.md) (Phases F–K ✅) — this final track
|
||||
collects what's left, from two sources:
|
||||
|
||||
1. A **feature study of [YTDLnis](https://github.com/deniscerri/ytdlnis)** (the other major
|
||||
Android yt-dlp frontend), keeping only the features AeroFetch doesn't already have.
|
||||
2. A **Windows-native + daily-use UX gap analysis** of AeroFetch itself (things neither Seal
|
||||
nor Pinchflat cover, but that a desktop app should do).
|
||||
|
||||
Nothing here is built yet — every box is unchecked. Phases continue the existing lettering
|
||||
(A–E Seal, F–K Pinchflat) from **L onward**, and each item carries the yt-dlp flag and the
|
||||
AeroFetch file it touches so it can be driven straight from the existing `buildArgs` /
|
||||
`DownloadOptions` / queue plumbing rather than new infrastructure.
|
||||
|
||||
Sources: [YTDLnis GitHub](https://github.com/deniscerri/ytdlnis) ·
|
||||
[YTDLnis docs](https://ytdlnis.org/) · [YTDLnis changelogs](https://ytdlnis.org/changelogs/) ·
|
||||
[F-Droid listing](https://f-droid.org/packages/com.deniscerri.ytdl/).
|
||||
|
||||
### Already at parity with YTDLnis (do **not** rebuild)
|
||||
|
||||
Format/quality/container picking · SponsorBlock (remove/mark + embed-as-chapters) · embed
|
||||
subtitles/metadata/chapters · cookies + login · custom command templates · backup/restore ·
|
||||
**Observe Sources** (= our Phase J watched sources + RSS) · incognito mode · history with
|
||||
filter + bulk re-download · filename templating · self-managing yt-dlp + ffmpeg binaries.
|
||||
AeroFetch is even with, or ahead of, YTDLnis on all of these.
|
||||
|
||||
## Phase L — Media editing (YTDLnis headline features)
|
||||
|
||||
YTDLnis's signature differentiator. The keyframe plumbing already exists in AeroFetch
|
||||
(`--force-keyframes-at-cuts` is emitted today for SponsorBlock-remove in
|
||||
`src/main/buildArgs.ts`), so this is cheaper than it looks.
|
||||
|
||||
- [x] **Trim / cut downloads.** Keep only chosen time ranges (multiple allowed). →
|
||||
`parseTrimSections` (`src/main/buildArgs.ts`) normalises raw user text into yt-dlp
|
||||
`--download-sections "*START-END"` specs (+ `--force-keyframes-at-cuts`, deduped against
|
||||
SponsorBlock-remove). Threaded as `StartDownloadOptions.trim` through
|
||||
`store/downloads.ts`, surfaced as a collapsible **Trim** panel in `DownloadBar.tsx`
|
||||
(single downloads only). *Implemented + unit-tested (`buildArgs.test.ts` — 7 new tests;
|
||||
typecheck + test green). Live smoke test pending, plus two follow-ups: timestamp-range
|
||||
only so far (chapter-based cuts via the probe `chapters[]` array, and a scrubber/preview
|
||||
player, still TODO).*
|
||||
- [x] **Split by chapters into separate files.** → `--split-chapters` (already flagged as a
|
||||
TODO in Phase A above). One toggle on `DownloadOptions` +
|
||||
`src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. yt-dlp
|
||||
keeps the full file and writes per-chapter files via its default `chapter:` template.
|
||||
*Implemented + unit-tested (`buildArgs.test.ts`, typecheck + test green); live smoke test
|
||||
still pending.*
|
||||
- [x] **Metadata editing before download.** Change title / author / artist pre-download
|
||||
(YTDLnis: "modify metadata such as title and author"). Uses `--replace-in-metadata FIELD ^.*$ VALUE`
|
||||
(not `--parse-metadata FROM:TO` which splits on colons). Only backslashes need escaping in the
|
||||
replacement string. Title, Artist, Album inputs in `DownloadOptionsForm.tsx`; setting any override
|
||||
implicitly enables `--embed-metadata`. Unit-tested in `buildArgs.test.ts`.
|
||||
|
||||
## 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*). The queue is now persisted across restarts (audit M4 — `queue.json` via
|
||||
`src/main/queue.ts`), so a parked/scheduled item survives a quit and its schedule fires on the
|
||||
next launch once due. (The app must still be running at the scheduled moment for an immediate
|
||||
fire; a schedule that came due while closed fires at the next launch.)*
|
||||
|
||||
## 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).*
|
||||
- [x] **Per-playlist exact-format-per-item.** *Done: each video row in the playlist panel has an
|
||||
**exact-format** button that lazily probes just that entry's URL (reusing the single-video
|
||||
`probe` IPC — no new main code) and reveals a native `<Select>` of that entry's real formats;
|
||||
the pick is carried as a `ChosenFormat` and flows through `addMany` → `formatId` per queue
|
||||
item. Probing is on-demand per row (never all up front — playlists can be huge / rate-limited).
|
||||
Flipping a row to audio drops its format. Pure mapping in `downloadBar/playlistEntries.ts`,
|
||||
unit-tested (`test/playlistEntries.test.ts`); verified end-to-end in the preview harness.*
|
||||
- [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.*
|
||||
- [x] **Taskbar overlay badge** (`win.setOverlayIcon`) showing active-download count / error state.
|
||||
Green dot when downloading, red dot on error, cleared when idle. Pure-JS PNG generator in
|
||||
`src/main/badge.ts` (Buffer + zlib, no new deps); badgeCount threaded through the existing
|
||||
`taskbarProgress` IPC channel from `summarizeQueue`.
|
||||
- [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.*
|
||||
**Automatic WebView token minting shipped:** `src/main/poToken.ts` opens a YouTube video
|
||||
page in a hardened BrowserWindow (shared login session), injects an async poll for
|
||||
`ytInitialPlayerResponse.serviceIntegrityDimensions.poToken`, saves the result encrypted
|
||||
via `safeStorage`. "Fetch" button in Settings → Network triggers it via `youtube:po-token-mint`
|
||||
IPC. Falls back gracefully (null) if YouTube's page structure changes.
|
||||
- [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 — non-goal.** *Decision: no certificate will be purchased, so builds ship
|
||||
unsigned and the SmartScreen first-run prompt is accepted. The build stays 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) — if that decision is ever reversed.*
|
||||
- [ ] **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.
|
||||
@@ -1,77 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,61 +0,0 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user