# 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.
