Offline-first YouTube subscription feed reader: Takeout CSV import, public Atom feeds for metadata, yt-dlp downloads constrained to H.264/AAC for in-app WKWebView playback.
8.0 KiB
FlightTube — Design Spec
Date: 2026-08-29 Status: Approved for implementation Type: Proof of concept
Purpose
A Tauri desktop app that presents every video from the user's YouTube subscriptions as a single feed sorted newest-first, downloads chosen videos to local disk, and — when the machine is offline — shows only what has been downloaded.
The driving use case is the app's name: load up before a flight, watch without a network.
Constraints and decisions
These were settled during brainstorming and are not open questions.
| Decision | Choice | Rationale |
|---|---|---|
| Subscription source | Google Takeout subscriptions.csv import |
No OAuth, no API key, no cloud console setup, no secrets on disk |
| Video metadata source | Public per-channel Atom feed youtube.com/feeds/videos.xml?channel_id=… |
Public, unauthenticated, gives title/date/thumbnail/views/description |
| Download engine | yt-dlp subprocess |
Only approach that survives YouTube's format changes |
| Quality | H.264 + AAC, 1080p ceiling | Must play in the app's own WKWebView player; no external player fallback |
| Playback | In-app <video> via Tauri asset protocol |
Explicit requirement: "it needs to work in the app… no vlc thingies" |
| Storage | SQLite via rusqlite (bundled) |
Sorting/filtering over ~3k rows for free; no system SQLite dependency |
| Styling | Tailwind CSS v4 via @tailwindcss/vite |
Requested framework; v4 needs no config file |
Known limitations, accepted
- The Atom feed returns only the ~15 most recent videos per channel. There is no backfill and no pagination. The feed is a rolling recent window, not an archive.
- Downloading videos is contrary to YouTube's Terms of Service. Accepted by the user.
yt-dlprequires periodic updating as YouTube changes. The app surfaces its version.- Quality tops out at 1080p because YouTube serves H.264 no higher; 4K exists only as VP9/AV1, which WKWebView cannot reliably play. Explicitly traded away for in-app playback.
Verified environment
Confirmed present on the target machine at spec time:
rustc1.98.0 (aarch64-apple-darwin)- Node v22.22.2, npm 10.9.7
yt-dlp2026.08.19ffmpeg9.0.1
The format selector was validated live against a real video and resolved to
1920x1080, avc1.640028 + mp4a.40.2, mp4:
-f "bv*[vcodec^=avc1]+ba[acodec^=mp4a]/bv*+ba/b" --merge-output-format mp4
Architecture
Rust owns all I/O and long-running work. React renders and dispatches. The boundary is Tauri's command/event bridge.
subscriptions.csv ──import──> channels
channels ──refresh (8 concurrent)──> videos.xml ──quick-xml──> videos + cached thumbnails
videos ──download──> yt-dlp ──> ~/Movies/FlightTube/<id>.mp4 ──> downloads
downloads ──asset protocol──> <video> player
Rust modules
Each module has one job and is testable without the others.
db— SQLite open, migrations, typed queries. The only module that touches SQL.takeout— parsesubscriptions.csvintoChannelrecords. Pure; string in, structs out.feed— fetch and parse channel Atom feeds. Parsing is pure and separable from fetching.thumbs— download and cache thumbnail JPEGs to disk.downloader— spawn/track/cancelyt-dlp, parse its progress lines, emit events.net— connectivity probe.commands— thin Tauri command layer. Delegates; holds no logic.
Schema
channels(id TEXT PK, title TEXT, url TEXT, added_at INTEGER)
videos(id TEXT PK, channel_id TEXT, title TEXT, description TEXT,
published INTEGER, thumb_url TEXT, thumb_path TEXT, views INTEGER,
is_short INTEGER, fetched_at INTEGER)
downloads(video_id TEXT PK, state TEXT, path TEXT, bytes_total INTEGER,
bytes_done INTEGER, pct REAL, speed TEXT, eta TEXT, error TEXT, completed_at INTEGER)
downloads.state ∈ queued | running | done | failed | cancelled.
Refresh upserts videos so existing rows and their download state survive. published
is a Unix timestamp so the feed sort is a plain indexed ORDER BY published DESC.
Commands
| Command | Behavior |
|---|---|
import_takeout_csv(path) |
Parse and upsert channels; returns count imported |
list_channels() |
Channels with per-channel video counts |
refresh_feeds() |
Fetch all channel feeds, 8 concurrent; upsert videos; cache thumbnails; emit refresh:progress |
list_feed(filter) |
Videos joined with download state, ORDER BY published DESC; filters: channel, search text, downloaded-only, hide-Shorts |
download_video(video_id) |
Spawn yt-dlp; emit download:progress; mark done/failed |
cancel_download(video_id) |
Kill the child process, mark cancelled, clean partial files |
delete_download(video_id) |
Remove file, reset row to not-downloaded |
check_prereqs() |
Report yt-dlp/ffmpeg presence and versions |
get_connectivity() |
Online/offline probe result |
Events
refresh:progress {done, total, channel} · download:progress {video_id, pct, speed, eta, bytes_done, bytes_total} · download:state {video_id, state, error?} · connectivity {online}
Downloads
Invocation:
yt-dlp -f "bv*[vcodec^=avc1]+ba[acodec^=mp4a]/bv*+ba/b" \
--merge-output-format mp4 --no-playlist --newline \
--progress-template "FTPROG %(progress.downloaded_bytes)s %(progress.total_bytes)s %(progress.speed)s %(progress.eta)s" \
-o "<library>/%(id)s.%(ext)s" "https://www.youtube.com/watch?v=<id>"
Progress lines are matched on the FTPROG sentinel; any line that fails to parse is
ignored rather than crashing the download. total_bytes may be NA before the stream is
resolved, so byte fields are parsed as optional.
Library path defaults to ~/Movies/FlightTube, configurable in settings.
Concurrency is capped at 2 simultaneous downloads; further requests queue.
Offline behavior
Connectivity is determined by a Rust-side reachability probe on refresh and every 30s,
supplemented by the webview's online/offline events. When offline the feed filters
itself to downloaded videos only and shows a banner. A manual Offline mode toggle
forces the same state for testing without touching wifi.
Thumbnails are cached to disk on refresh precisely so the offline feed still renders.
Playback
Downloaded files are served into a <video> element through Tauri's asset protocol via
convertFileSrc(). The capability's assetProtocol scope is restricted to the library
directory. Because every file is H.264/AAC in MP4, WKWebView plays it natively.
Clicking an undownloaded video opens it on YouTube in the default browser. Clicking a downloaded one opens the in-app player.
UI
Dark theme, YouTube-adjacent density.
- Sidebar — imported channels with video counts; click filters the feed; "All" resets.
- Top bar — refresh (with progress), search, downloaded-only toggle, hide-Shorts toggle, online/offline pill, settings.
- Feed — newest-first video rows: cached thumbnail, title, channel, relative time, view count, and a download control that moves through idle → progress ring → done badge.
- Player — full-pane
<video>with title, channel, description, and delete-download. - Settings — Takeout import, library path, prereq status with versions, yt-dlp version.
Testing
Unit tests in Rust covering the three places malformed input actually causes failures:
- Takeout CSV parsing — real header, quoted titles with commas, UTF-8, blank lines, wrong column order, empty file.
- Atom feed parsing — against the captured live fixture; missing optional fields (views, description), entries with no thumbnail, malformed XML.
- yt-dlp progress parsing — well-formed lines,
NAbyte totals, interleaved non-progress output, garbage.
Download and playback are verified manually; they depend on the network and a real binary.
Out of scope
Watch history, playlists, watch-later, comments, subscription management from within the app, background/scheduled refresh, video transcoding, and any OAuth-authenticated YouTube feature.