Files
AeroFetch/ROADMAP-PINCHFLAT.md
T

18 KiB
Raw Blame History

AeroFetch Roadmap — media-manager mode (Pinchflat parity)

The Seal-parity roadmap took AeroFetch from "paste a URL → download" to a full-featured yt-dlp frontend (Phases AE, complete). This document is a second, parallel track: turning AeroFetch from a one-shot downloader into a media manager in the spirit of 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 · Pinchflat architecture (DeepWiki) · Pinchflat FAQ — indexing.

See also: ROADMAP.mdPost-parity — YTDLnis-derived editing features, Windows-native polish, and reliability work, Phases L onward.


Implementation status — Phases FK 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 MediaItems — 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.

  • 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.
  • 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.)
  • Dedup on index. A video appearing in multiple playlists collapses to one MediaItem (keyed by video id); first playlist seen wins the folder assignment (surface this choice in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in no real playlist.
  • Index job. Async indexing in main (not blocking the UI), pushing progress over a new 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.

  • 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.
  • Directory sanitizer. A small helper in buildArgs.ts that strips Windows-illegal directory chars (< > : " / \ | ? *, trailing dots/spaces, reserved names like CON). Critical because --restrict-filenames only sanitizes the filename yt-dlp generates, not the directory segments AeroFetch constructs from channel/playlist names.
  • Output-path setting. A new Settings → Downloads field for the folder layout, defaulting to Channel / Playlist / Title, with the raw yt-dlp -o template exposed for power users (parallels how Phase C exposed raw extra args). Keeps the existing flat filenameTemplate as the default for non-collection downloads. 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).
  • "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.

  • "Library" sidebar section (src/renderer/src/components/Sidebar.tsx) alongside Downloads / History / Settings. Lists added Sources.
  • Source detail — playlist/video tree. A collapsible Channel → Playlist → Video tree showing each MediaItem's state (indexed · pending · downloading · downloaded · error), reusing the checkbox-selection pattern already in DownloadBar.tsx's playlist panel. Per-playlist select-all, live counts.
  • "Download all pending" → existing queue, batched. Enqueue selected MediaItems via the existing addFromUrl path, a batch at a time (e.g. 50) so the queue/store never holds the entire channel at once — the live queue stays small while the Library view is the source of truth for the full list. Completion flips MediaItem.downloaded = true and records to history exactly as today.
  • New useSources store (src/renderer/src/store/sources.ts, mirrors store/downloads.ts / store/history.ts) plus its IPC bridge. Browser-preview seed data so the view is demoable without Electron (same convention as downloads.ts's PREVIEW).
  • Risk to watch: rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and, if JSON-store load times bite, revisit the Phase-D better-sqlite3 decision.

Phase I — Incremental sync & dedup COMPLETE

Re-running a channel should grab only what's new — the everyday media-manager loop.

  • Re-index = diff. Re-indexing a Source compares freshly enumerated video ids against the persisted MediaItems and marks only the new ones; existing records (and their downloaded state) are preserved. "X new since last sync" badge.
  • "Download new only." One action that enqueues just the un-downloaded MediaItems, backed by the existing --download-archive setting (Settings.downloadArchive, getDownloadArchivePath()) as a second, yt-dlp-level dedup guard so even a stale index can't re-download.
  • Retention / quality-upgrade (stretch, from Pinchflat). Optional re-download to upgrade quality/metadata, and optional pruning of old items. 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).

  • Watched sources. A Source.watched flag + an "auto-download new" toggle per source.
  • Fast indexing via RSS. YouTube exposes a per-channel Atom feed (https://www.youtube.com/feeds/videos.xml?channel_id=<id>) — a cheap check for new uploads without a full yt-dlp scan. Caveat: the feed only carries the latest ~15 videos, so RSS is for staying current, not initial indexing (which stays a full Phase-F scan). Pinchflat draws the same line ("fast indexing is not recommended for playlists / initial scans").
  • Scheduled headless run. A --sync CLI entry that indexes all watched sources and 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.

  • 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 FH are the high-value core; IK are incremental and each shippable on its own.