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.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
target/
|
||||
.DS_Store
|
||||
*.db
|
||||
@@ -0,0 +1,178 @@
|
||||
# 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
|
||||
|
||||
```sql
|
||||
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:
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user