Files
FlightTube/docs/superpowers/plans/2026-08-29-flighttube.md
T

534 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 25
- 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.