Files
AeroFetch/ROADMAP.md
T
debont80 1376c2dee8 Harden audit findings: correctness, type-safety, Windows conventions & polish
Audit-pass over CODE-AUDIT.md (~48 items closed this pass; all verified —
typecheck + 234 tests + eslint + prettier green).

Correctness / bugs:
- B3: match the release checksum to the asset's filename line (no wrong-hash verify)
- B4: newline-safe metadata probe (one --print with a unit-separator delimiter)
- B5 / L88: guard the meta event against canceled items; progress no longer promotes
  a queued item outside pump()
- B7: cookie-login promise always resolves (handles destroy-without-close)
- L146: trim parser rejects >2 colon-group times; M36: Library selection counts only
  actionable rows
- L11 / L50 / L156 / L57 / L159 / L15 / L3: live queue count, empty-cookie message,
  schedule picker min, dead-code/comment cleanup

Type safety:
- Enable noUncheckedIndexedAccess + noFallthroughCasesInSwitch (15 real edge cases fixed)

Resilience / Windows / metadata:
- R5: settings write failure handled (no unhandled IPC rejection; reconciles to truth)
- W1 / W5 / W6: min window size, seeded folder picker, parented sign-in window;
  L147 dead macOS branches removed
- CL1: shared stdout markers; package/builder metadata (license, homepage, repository,
  copyright, tsbuildinfo glob)

Copy / docs / tests:
- M37 / SR9 dev-jargon cleanup in hints; M8 / M25 / M26 / L66 / L80 / L81 reconciled
- New unit tests for L35 (isValidMediaItem) and L36 (compareVersions)

This commit also checkpoints the previously-uncommitted feat/tray-background-clipboard
work it builds on: background running + auto-download, library clipboard detection,
tray, binary management & library scale, credential encryption at rest, the shared
jsonStore and ui/ primitives, and the eslint/prettier tooling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:02:54 -04:00

449 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
- [ ] **Command preview (plumbed, not 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`. The **Preview command** button is NOT yet in the download bar —
only the main/preload/buildArgs chain exists. Wire it into the per-download Options
panel or remove the chain (audit M5/UX1).
- [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.
- [ ] **Private / incognito mode (plumbed, not surfaced).** `DownloadItem.incognito` flows
through `buildItem` → history-skip → the QueueItem "Private" badge, and
`store/downloads.ts` checks it before ever calling `useHistory().add(...)`. But no UI
control sets `incognito: true` yet — the download-bar toggle is not wired (audit M6/UX1).
Note `download.ts` would still log a failure / show a completion title for a private item,
so the "private" promise currently covers history only (audit L136).
- [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 AC'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 AE above) and the
[Pinchflat media-manager roadmap](ROADMAP-PINCHFLAT.md) (Phases FK ✅) — this final track
collects what's left, from two sources:
1. A **feature study of [YTDLnis](https://github.com/deniscerri/ytdlnis)** (the other major
Android yt-dlp frontend), keeping only the features AeroFetch doesn't already have.
2. A **Windows-native + daily-use UX gap analysis** of AeroFetch itself (things neither Seal
nor Pinchflat cover, but that a desktop app should do).
Nothing here is built yet — every box is unchecked. Phases continue the existing lettering
(AE Seal, FK Pinchflat) from **L onward**, and each item carries the yt-dlp flag and the
AeroFetch file it touches so it can be driven straight from the existing `buildArgs` /
`DownloadOptions` / queue plumbing rather than new infrastructure.
Sources: [YTDLnis GitHub](https://github.com/deniscerri/ytdlnis) ·
[YTDLnis docs](https://ytdlnis.org/) · [YTDLnis changelogs](https://ytdlnis.org/changelogs/) ·
[F-Droid listing](https://f-droid.org/packages/com.deniscerri.ytdl/).
### Already at parity with YTDLnis (do **not** rebuild)
Format/quality/container picking · SponsorBlock (remove/mark + embed-as-chapters) · embed
subtitles/metadata/chapters · cookies + login · custom command templates · backup/restore ·
**Observe Sources** (= our Phase J watched sources + RSS) · incognito mode · history with
filter + bulk re-download · filename templating · self-managing yt-dlp + ffmpeg binaries.
AeroFetch is even with, or ahead of, YTDLnis on all of these.
## Phase L — Media editing (YTDLnis headline features)
YTDLnis's signature differentiator. The keyframe plumbing already exists in AeroFetch
(`--force-keyframes-at-cuts` is emitted today for SponsorBlock-remove in
`src/main/buildArgs.ts`), so this is cheaper than it looks.
- [x] **Trim / cut downloads.** Keep only chosen time ranges (multiple allowed). →
`parseTrimSections` (`src/main/buildArgs.ts`) normalises raw user text into yt-dlp
`--download-sections "*START-END"` specs (+ `--force-keyframes-at-cuts`, deduped against
SponsorBlock-remove). Threaded as `StartDownloadOptions.trim` through
`store/downloads.ts`, surfaced as a collapsible **Trim** panel in `DownloadBar.tsx`
(single downloads only). *Implemented + unit-tested (`buildArgs.test.ts` — 7 new tests;
typecheck + test green). Live smoke test pending, plus two follow-ups: timestamp-range
only so far (chapter-based cuts via the probe `chapters[]` array, and a scrubber/preview
player, still TODO).*
- [x] **Split by chapters into separate files.**`--split-chapters` (already flagged as a
TODO in Phase A above). One toggle on `DownloadOptions` +
`src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. yt-dlp
keeps the full file and writes per-chapter files via its default `chapter:` template.
*Implemented + unit-tested (`buildArgs.test.ts`, typecheck + test green); live smoke test
still pending.*
- [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*). **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.*
- [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 AK were.