Files
FlightTube/docs/superpowers/specs/2026-08-29-flighttube-design.md
T
vincent 69dab761f1 Add FlightTube design spec
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.
2026-08-29 02:16:47 +02:00

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-dlp requires 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:

  • rustc 1.98.0 (aarch64-apple-darwin)
  • Node v22.22.2, npm 10.9.7
  • yt-dlp 2026.08.19
  • ffmpeg 9.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 — parse subscriptions.csv into Channel records. 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/cancel yt-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.statequeued | 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:

  1. Takeout CSV parsing — real header, quoted titles with commas, UTF-8, blank lines, wrong column order, empty file.
  2. Atom feed parsing — against the captured live fixture; missing optional fields (views, description), entries with no thumbnail, malformed XML.
  3. yt-dlp progress parsing — well-formed lines, NA byte 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.