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