# FlightTube Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** A Tauri desktop app that merges the user's YouTube subscriptions into one newest-first feed, downloads videos locally, and shows only downloaded videos when offline.

**Architecture:** Rust owns all I/O — Atom feed fetching, SQLite, the `yt-dlp` subprocess — and streams progress to the frontend over Tauri events. React renders and dispatches commands, holding no business logic. Pure parsing functions (CSV, XML, progress lines) are separated from I/O so they are unit-testable without a network or a subprocess.

**Tech Stack:** Tauri 2.11 · Rust 1.98 · React 19.2 · TypeScript · Vite 8.2 · Tailwind CSS 4.3 (`@tailwindcss/vite`, no config file) · rusqlite 0.40 (bundled) · quick-xml 0.42 · reqwest 0.13 · tokio 1.53 · csv 1.4 · chrono 0.4

**Spec:** `docs/superpowers/specs/2026-08-29-flighttube-design.md`

## Global Constraints

- **Download format is fixed:** `-f "bv*[vcodec^=avc1]+ba[acodec^=mp4a]/bv*+ba/b" --merge-output-format mp4`. Validated live; resolves to `1920x1080 avc1.640028 + mp4a.40.2`. Never widen this — non-H.264 will not play in WKWebView, and in-app playback is a hard requirement.
- **No OAuth, no API keys, no secrets on disk.** Metadata comes only from the public Atom endpoint `https://www.youtube.com/feeds/videos.xml?channel_id=<id>`.
- **Atom feeds return ~15 entries per channel.** No backfill exists. Never write code that assumes deeper history.
- **Refresh must upsert,** never delete-and-reinsert: download state lives alongside video rows and must survive a refresh.
- **Thumbnails must be cached to disk** on refresh, or the offline feed renders broken images.
- **Feed concurrency 8; download concurrency 2.**
- **Library path:** `~/Movies/FlightTube`, user-configurable.
- **Timestamps** are stored as Unix seconds (`INTEGER`) so sorting is an indexed `ORDER BY`.
- Verified toolchain: `yt-dlp 2026.08.19`, `ffmpeg 9.0.1`, `rustc 1.98.0`, Node 22.22.2.

## File Structure

```
src-tauri/src/
  main.rs          entry, builder, state registration
  db.rs            schema, migrations, all SQL
  models.rs        Channel, Video, DownloadState, FeedFilter — shared types
  takeout.rs       subscriptions.csv -> Vec<Channel>          (pure + tested)
  feed.rs          Atom XML -> Vec<Video>, plus fetch          (parse is pure + tested)
  thumbs.rs        thumbnail download + disk cache
  downloader.rs    yt-dlp spawn, progress parsing              (parse is pure + tested)
  net.rs           connectivity probe
  commands.rs      Tauri command surface — delegates only
  fixtures/ltt_feed.xml   captured real Atom response for tests

src/
  main.tsx         React root
  App.tsx          layout, routing between feed and player
  api.ts           typed wrappers over invoke() + event listeners
  types.ts         TS mirrors of models.rs
  hooks/useFeed.ts        feed data + filters
  hooks/useDownloads.ts   live download progress map
  hooks/useConnectivity.ts
  components/Sidebar.tsx
  components/TopBar.tsx
  components/VideoRow.tsx
  components/DownloadButton.tsx
  components/Player.tsx
  components/Settings.tsx
  index.css        @import "tailwindcss" + theme tokens
```

---

### Task 1: Scaffold and boot

**Files:**
- Create: `package.json`, `vite.config.ts`, `tsconfig.json`, `index.html`, `src/main.tsx`, `src/App.tsx`, `src/index.css`
- Create: `src-tauri/Cargo.toml`, `src-tauri/tauri.conf.json`, `src-tauri/src/main.rs`, `src-tauri/capabilities/default.json`

**Interfaces:**
- Consumes: nothing
- Produces: a running Tauri window rendering Tailwind-styled React

- [ ] **Step 1: Scaffold**

```bash
npm create tauri-app@latest . -- --template react-ts --manager npm --yes
npm install
npm install -D @tailwindcss/vite tailwindcss
```

- [ ] **Step 2: Wire Tailwind v4 into Vite**

`vite.config.ts` — add the plugin (v4 needs no `tailwind.config.js`):

```ts
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({ plugins: [react(), tailwindcss()], /* keep tauri server block */ });
```

`src/index.css` — replace entire contents:

```css
@import "tailwindcss";
@theme {
  --color-ink: #0f0f0f;
  --color-surface: #181818;
  --color-edge: #303030;
}
```

- [ ] **Step 3: Prove Tailwind renders**

`src/App.tsx`:

```tsx
export default function App() {
  return <div className="min-h-screen bg-ink text-white grid place-items-center">
    <h1 className="text-3xl font-semibold">FlightTube</h1>
  </div>;
}
```

- [ ] **Step 4: Run and verify**

Run: `npm run tauri dev`
Expected: a native window, dark `#0f0f0f` background, centered white "FlightTube". If the background is white, Tailwind is not wired — fix before continuing.

- [ ] **Step 5: Commit**

```bash
git add -A && git commit -m "feat: scaffold Tauri + React + Tailwind v4"
```

---

### Task 2: Takeout CSV parsing

**Files:**
- Create: `src-tauri/src/models.rs`, `src-tauri/src/takeout.rs`
- Modify: `src-tauri/Cargo.toml` (add `csv`, `serde`)

**Interfaces:**
- Consumes: nothing
- Produces: `Channel { id: String, title: String, url: String }` and `takeout::parse_csv(input: &str) -> Result<Vec<Channel>, String>`

Real Takeout header is `Channel Id,Channel Url,Channel Title`. Parse **by header name**, not position — column order has changed across Takeout versions and locales.

- [ ] **Step 1: Write failing tests**

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_standard_export() {
        let csv = "Channel Id,Channel Url,Channel Title\n\
                   UCabc,http://www.youtube.com/channel/UCabc,Linus Tech Tips\n";
        let out = parse_csv(csv).unwrap();
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].id, "UCabc");
        assert_eq!(out[0].title, "Linus Tech Tips");
    }

    #[test]
    fn handles_commas_and_unicode_in_titles() {
        let csv = "Channel Id,Channel Url,Channel Title\n\
                   UCx,http://y.com/UCx,\"Kurzgesagt – In a Nutshell, Ltd\"\n";
        let out = parse_csv(csv).unwrap();
        assert_eq!(out[0].title, "Kurzgesagt – In a Nutshell, Ltd");
    }

    #[test]
    fn tolerates_reordered_columns() {
        let csv = "Channel Title,Channel Id,Channel Url\nVeritasium,UCz,http://y.com/UCz\n";
        let out = parse_csv(csv).unwrap();
        assert_eq!(out[0].id, "UCz");
        assert_eq!(out[0].title, "Veritasium");
    }

    #[test]
    fn skips_blank_lines_and_empty_file() {
        assert!(parse_csv("Channel Id,Channel Url,Channel Title\n\n").unwrap().is_empty());
        assert!(parse_csv("").is_err());
    }

    #[test]
    fn errors_on_missing_required_column() {
        assert!(parse_csv("Foo,Bar\n1,2\n").is_err());
    }
}
```

- [ ] **Step 2: Verify they fail**

Run: `cd src-tauri && cargo test takeout`
Expected: FAIL — `parse_csv` not found.

- [ ] **Step 3: Implement**

Use `csv::ReaderBuilder::new().flexible(true).from_reader(input.as_bytes())`, read `headers()`, build a name→index map, error if `Channel Id` or `Channel Title` is absent, skip records whose id is empty, and derive `url` from the id when the URL column is missing.

- [ ] **Step 4: Verify they pass**

Run: `cd src-tauri && cargo test takeout`
Expected: 5 passed.

- [ ] **Step 5: Commit**

```bash
git add -A && git commit -m "feat: parse Takeout subscriptions.csv"
```

---

### Task 3: Atom feed parsing

**Files:**
- Create: `src-tauri/src/feed.rs`, `src-tauri/src/fixtures/ltt_feed.xml`
- Modify: `src-tauri/Cargo.toml` (add `quick-xml` with `serde` feature, `reqwest` with `rustls-tls`, `tokio`, `chrono`)

**Interfaces:**
- Consumes: nothing
- Produces:
  - `Video { id, channel_id, title, description, published: i64, thumb_url, views: i64, is_short: bool }`
  - `feed::parse_atom(xml: &str) -> Result<Vec<Video>, String>`
  - `async feed::fetch_channel(client: &reqwest::Client, channel_id: &str) -> Result<Vec<Video>, String>`

A real response is already captured at `/private/tmp/.../scratchpad/feed.xml` — copy it to `src-tauri/src/fixtures/ltt_feed.xml`. Testing against a real payload rather than a hand-written one is the point.

Shorts are detected by the alternate link containing `/shorts/`. `published` is RFC 3339 → Unix seconds via `chrono::DateTime::parse_from_rfc3339`.

- [ ] **Step 1: Write failing tests**

```rust
#[cfg(test)]
mod tests {
    use super::*;
    const FIXTURE: &str = include_str!("fixtures/ltt_feed.xml");

    #[test]
    fn parses_all_entries_from_real_feed() {
        let v = parse_atom(FIXTURE).unwrap();
        assert_eq!(v.len(), 15);
    }

    #[test]
    fn extracts_core_fields() {
        let v = parse_atom(FIXTURE).unwrap();
        let first = &v[0];
        assert_eq!(first.id, "tklAv8hcG9s");
        assert_eq!(first.channel_id, "UCXuqSBlHAE6Xw-yeJA0Tunw");
        assert!(first.title.contains("Linus"));
        assert!(first.published > 1_700_000_000);
        assert!(first.thumb_url.contains("tklAv8hcG9s"));
        assert!(first.views > 0);
    }

    #[test]
    fn detects_shorts() {
        let v = parse_atom(FIXTURE).unwrap();
        assert!(v[0].is_short, "first fixture entry is a /shorts/ link");
    }

    #[test]
    fn tolerates_missing_optional_fields() {
        let xml = r#"<feed xmlns="http://www.w3.org/2005/Atom"
          xmlns:yt="http://www.youtube.com/xml/schemas/2015"
          xmlns:media="http://search.yahoo.com/mrss/">
          <entry><yt:videoId>abc</yt:videoId><yt:channelId>UCq</yt:channelId>
            <title>Bare</title>
            <link rel="alternate" href="https://www.youtube.com/watch?v=abc"/>
            <published>2026-01-01T00:00:00+00:00</published>
          </entry></feed>"#;
        let v = parse_atom(xml).unwrap();
        assert_eq!(v[0].views, 0);
        assert_eq!(v[0].description, "");
        assert!(!v[0].is_short);
    }

    #[test]
    fn errors_on_malformed_xml() {
        assert!(parse_atom("<feed><entry>").is_err());
    }

    #[test]
    fn returns_empty_for_feed_with_no_entries() {
        let xml = r#"<feed xmlns="http://www.w3.org/2005/Atom"><title>Empty</title></feed>"#;
        assert!(parse_atom(xml).unwrap().is_empty());
    }
}
```

- [ ] **Step 2: Verify they fail**

Run: `cd src-tauri && cargo test feed`

- [ ] **Step 3: Implement**

Event-based `quick_xml::Reader` walking `<entry>` elements. Track the current element name; capture text for `yt:videoId`, `yt:channelId`, `title`, `published`, `media:description`; read the `href` attribute of `link[rel=alternate]` and the `url` attribute of `media:thumbnail`; read `views` from `media:statistics`. Ignore unknown elements. Return `Err` on `quick_xml` errors so malformed XML fails loudly.

- [ ] **Step 4: Verify they pass**

Run: `cd src-tauri && cargo test feed`
Expected: 6 passed.

- [ ] **Step 5: Commit**

```bash
git add -A && git commit -m "feat: parse YouTube channel Atom feeds"
```

---

### Task 4: yt-dlp progress parsing

**Files:**
- Create: `src-tauri/src/downloader.rs`

**Interfaces:**
- Consumes: nothing
- Produces: `Progress { downloaded: u64, total: Option<u64>, speed: Option<f64>, eta: Option<u64> }` and `downloader::parse_progress_line(line: &str) -> Option<Progress>`

yt-dlp emits `NA` for unknown numeric fields, and interleaves ordinary log output with progress lines. The `FTPROG` sentinel disambiguates. A line that fails to parse must return `None`, never panic — a malformed progress line must not kill a download.

- [ ] **Step 1: Write failing tests**

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_complete_line() {
        let p = parse_progress_line("FTPROG 1048576 10485760 524288.0 18").unwrap();
        assert_eq!(p.downloaded, 1_048_576);
        assert_eq!(p.total, Some(10_485_760));
        assert_eq!(p.speed, Some(524_288.0));
        assert_eq!(p.eta, Some(18));
    }

    #[test]
    fn handles_na_fields_before_stream_resolves() {
        let p = parse_progress_line("FTPROG 4096 NA NA NA").unwrap();
        assert_eq!(p.downloaded, 4096);
        assert_eq!(p.total, None);
        assert_eq!(p.speed, None);
        assert_eq!(p.eta, None);
    }

    #[test]
    fn ignores_ordinary_yt_dlp_output() {
        assert!(parse_progress_line("[youtube] Extracting URL: https://...").is_none());
        assert!(parse_progress_line("[Merger] Merging formats into \"x.mp4\"").is_none());
        assert!(parse_progress_line("").is_none());
    }

    #[test]
    fn ignores_garbage_without_panicking() {
        assert!(parse_progress_line("FTPROG").is_none());
        assert!(parse_progress_line("FTPROG a b c d").is_none());
        assert!(parse_progress_line("FTPROG 1 2").is_none());
    }

    #[test]
    fn computes_percent() {
        let p = parse_progress_line("FTPROG 5000 10000 1.0 1").unwrap();
        assert_eq!(p.pct(), Some(50.0));
        let q = parse_progress_line("FTPROG 5000 NA NA NA").unwrap();
        assert_eq!(q.pct(), None);
    }
}
```

- [ ] **Step 2: Verify they fail**

Run: `cd src-tauri && cargo test downloader`

- [ ] **Step 3: Implement**

`parse_progress_line` splits on whitespace, requires the `FTPROG` prefix and exactly 5 tokens, and maps `"NA"` → `None`. `downloaded` must parse or the line is rejected. Add `Progress::pct()` returning `Option<f64>`.

- [ ] **Step 4: Verify they pass**

Run: `cd src-tauri && cargo test downloader`
Expected: 5 passed.

- [ ] **Step 5: Commit**

```bash
git add -A && git commit -m "feat: parse yt-dlp progress output"
```

---

### Task 5: Database layer

**Files:**
- Create: `src-tauri/src/db.rs`
- Modify: `src-tauri/Cargo.toml` (add `rusqlite` with `bundled` feature)

**Interfaces:**
- Consumes: `Channel` (Task 2), `Video` (Task 3)
- Produces: `Db` with `open(path) -> Result<Db>`, `upsert_channels(&[Channel]) -> Result<usize>`, `list_channels() -> Result<Vec<ChannelWithCount>>`, `upsert_videos(&[Video]) -> Result<usize>`, `list_feed(&FeedFilter) -> Result<Vec<FeedItem>>`, `set_download_state(...)`, `get_download(video_id)`

Schema exactly as the spec defines. Wrap the connection in `Mutex<Connection>` for Tauri state.

- [ ] **Step 1: Write failing tests** (all against `Db::open_in_memory()`)

```rust
#[test] fn upserting_same_channel_twice_yields_one_row() { /* insert twice, assert count 1 */ }
#[test] fn refresh_preserves_download_state() {
    // upsert video, mark downloaded, upsert same video again with new title,
    // assert title updated AND download state still "done"
}
#[test] fn feed_is_sorted_newest_first() { /* three videos, assert descending published */ }
#[test] fn downloaded_only_filter_excludes_undownloaded() {}
#[test] fn hide_shorts_filter_excludes_shorts() {}
#[test] fn channel_filter_scopes_to_one_channel() {}
#[test] fn search_matches_title_case_insensitively() {}
```

- [ ] **Step 2: Verify they fail**

Run: `cd src-tauri && cargo test db`

- [ ] **Step 3: Implement**

`upsert_videos` uses `INSERT … ON CONFLICT(id) DO UPDATE SET title=excluded.title, …` touching only metadata columns — never the `downloads` table. That is what makes the preservation test pass. `list_feed` is a `LEFT JOIN downloads` with `ORDER BY published DESC` and `WHERE` clauses built from the filter.

- [ ] **Step 4: Verify they pass**

Run: `cd src-tauri && cargo test db`
Expected: 7 passed.

- [ ] **Step 5: Commit**

```bash
git add -A && git commit -m "feat: SQLite schema and queries"
```

---

### Task 6: Command surface and background work

**Files:**
- Create: `src-tauri/src/thumbs.rs`, `src-tauri/src/net.rs`, `src-tauri/src/commands.rs`
- Modify: `src-tauri/src/main.rs`, `src-tauri/capabilities/default.json`, `src-tauri/tauri.conf.json`

**Interfaces:**
- Consumes: everything from Tasks 2–5
- Produces: the nine commands and four events named in the spec

- [ ] **Step 1: Implement `thumbs` and `net`**

`thumbs::cache(client, url, dir) -> Result<PathBuf>` — skip if the file already exists, name by video id, write bytes. `net::is_online()` — `HEAD https://www.youtube.com` with a 5s timeout.

- [ ] **Step 2: Implement `refresh_feeds` with bounded concurrency**

`futures::stream::iter(channels).map(fetch).buffer_unordered(8)`, upserting per channel as results land and emitting `refresh:progress {done,total,channel}`. A single channel's failure must not abort the run — collect errors and report a count.

- [ ] **Step 3: Implement `download_video`**

Spawn via `tokio::process::Command` with the fixed format selector and `--progress-template "FTPROG %(progress.downloaded_bytes)s %(progress.total_bytes)s %(progress.speed)s %(progress.eta)s"`. Read stdout lines, feed each to `parse_progress_line`, emit `download:progress` on `Some`. On exit code 0 mark `done` and record the real path; otherwise mark `failed` with captured stderr. Hold child handles in a `Mutex<HashMap<String, Child>>` so `cancel_download` can kill them. Gate concurrent downloads at 2 with a `tokio::sync::Semaphore`.

- [ ] **Step 4: Configure the asset protocol**

`tauri.conf.json` → `app.security.assetProtocol`: `{ "enable": true, "scope": ["$HOME/Movies/FlightTube/**"] }`, and add `core:asset:default` plus the opener and dialog permissions to `capabilities/default.json`. Without this the player shows a black rectangle and a CSP error.

- [ ] **Step 5: Verify the whole backend compiles and tests still pass**

Run: `cd src-tauri && cargo test && cargo build`
Expected: all tests pass, zero errors.

- [ ] **Step 6: Commit**

```bash
git add -A && git commit -m "feat: Tauri commands, concurrent refresh, download manager"
```

---

### Task 7: Feed UI

**Files:**
- Create: `src/types.ts`, `src/api.ts`, `src/hooks/useFeed.ts`, `src/hooks/useDownloads.ts`, `src/hooks/useConnectivity.ts`, `src/components/{Sidebar,TopBar,VideoRow,DownloadButton}.tsx`
- Modify: `src/App.tsx`

**Interfaces:**
- Consumes: commands and events from Task 6
- Produces: `App` rendering the working feed; `Player` mount point for Task 8

- [ ] **Step 1: Mirror Rust types in `types.ts`** — `Channel`, `FeedItem`, `DownloadState`, `FeedFilter`. Keep field names identical to the serde output (snake_case) to avoid a mapping layer.

- [ ] **Step 2: Write `api.ts`** — thin typed wrappers: `importTakeout()`, `listChannels()`, `refreshFeeds()`, `listFeed(filter)`, `downloadVideo(id)`, `cancelDownload(id)`, `deleteDownload(id)`, `checkPrereqs()`, plus `onDownloadProgress(cb)` / `onRefreshProgress(cb)` returning unlisten functions.

- [ ] **Step 3: Build the components** — `Sidebar` (channel list + counts + active filter), `TopBar` (refresh with progress, search, downloaded-only, hide-Shorts, connectivity pill, settings), `VideoRow` (cached thumbnail, title, channel, relative time, views, `DownloadButton`), `DownloadButton` (idle → ring showing live pct → done badge; cancel while running).

- [ ] **Step 4: Verify against real data**

Run: `npm run tauri dev`, import a real `subscriptions.csv`, refresh.
Expected: feed populates newest-first across channels, thumbnails render, download shows live progress and completes.

- [ ] **Step 5: Commit**

```bash
git add -A && git commit -m "feat: subscription feed UI"
```

---

### Task 8: Player, offline mode, settings

**Files:**
- Create: `src/components/Player.tsx`, `src/components/Settings.tsx`
- Modify: `src/App.tsx`, `src/hooks/useConnectivity.ts`

**Interfaces:**
- Consumes: Task 7 components, `convertFileSrc` from `@tauri-apps/api/core`
- Produces: complete app

- [ ] **Step 1: Player** — `<video controls autoPlay src={convertFileSrc(path)} />` plus title, channel, description, and delete-download. Clicking an undownloaded row opens YouTube via the opener plugin instead.

- [ ] **Step 2: Offline mode** — poll `get_connectivity` every 30s, listen to the webview's `online`/`offline` events, and add a manual override toggle. When offline (real or forced), force `downloadedOnly` on and show a banner.

- [ ] **Step 3: Settings** — Takeout import via the dialog plugin, library path display, prereq status with `yt-dlp` and `ffmpeg` versions, and the `brew install yt-dlp ffmpeg` hint when missing.

- [ ] **Step 4: Verify offline behavior**

Toggle Offline mode with downloads present.
Expected: feed collapses to downloaded videos only, banner appears, playback still works, thumbnails still render from the disk cache.

- [ ] **Step 5: Commit**

```bash
git add -A && git commit -m "feat: in-app player, offline mode, settings"
```

---

## Self-Review

**Spec coverage:** Takeout import → T2/T8. Atom feeds → T3/T6. Thumbnail caching → T6. SQLite schema → T5. All nine commands and four events → T6. Downloads with fixed format selector, cancel, concurrency cap → T4/T6. Offline detection and filtering → T6/T8. In-app playback and asset protocol scope → T6/T8. UI incl. hide-Shorts → T7/T8. Prereq check → T6/T8. Testing strategy → T2/T3/T4/T5. No uncovered requirement.

**Placeholders:** none — every code step carries real code or a precise description of the transformation.

**Type consistency:** `Channel`, `Video`, `Progress`, `FeedFilter`, `FeedItem`, `DownloadState` are defined once and referenced identically throughout. `parse_csv`, `parse_atom`, `parse_progress_line` keep the same names in their defining and consuming tasks.
