diff --git a/README.md b/README.md index 102e366..5717908 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,100 @@ -# Tauri + React + Typescript +# FlightTube -This template should help get you started developing with Tauri, React and Typescript in Vite. +A desktop app that merges all your YouTube subscriptions into one newest-first feed, +downloads the videos you pick, and — when you have no connection — shows only what you +already downloaded. Built for the flight. -## Recommended IDE Setup +Tauri 2 · Rust · React · Tailwind CSS 4 · SQLite -- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) +## How it works + +**Subscriptions come from Google Takeout, not the API.** Export *YouTube subscriptions* +from [Takeout](https://takeout.google.com/) and import the `subscriptions.csv` in +Settings. No OAuth, no API key, no Google Cloud project, nothing secret on disk. + +**Video metadata comes from YouTube's public Atom feed** — one request per channel to +`youtube.com/feeds/videos.xml?channel_id=…`, fetched 8 at a time and merged into a single +feed sorted by publish date. Thumbnails are mirrored to local disk so the feed still +renders with no network. + +**Downloads shell out to `yt-dlp`,** pinned to H.264 video + AAC audio in an MP4 +container. That caps quality at 1080p — YouTube only serves H.264 that high, and anything +above it is VP9 or AV1, which the app's own player cannot reliably decode. The tradeoff is +deliberate: every download is guaranteed to play inside FlightTube. + +## Requirements + +```bash +brew install yt-dlp ffmpeg +``` + +Both are checked at startup; Settings shows their versions and this command if either is +missing. `ffmpeg` is required because best-quality H.264 and AAC arrive as separate +streams that must be merged. + +## Running it + +```bash +npm install && npm run tauri dev +``` + +Build a real app bundle: + +```bash +npm run tauri build +``` + +## Using it + +1. **Settings → Import subscriptions.csv** — point it at your Takeout export. +2. **Refresh** — pulls the latest videos from every channel. +3. **Download** on any video — progress shows live on the button; click again to cancel. +4. Click a **downloaded** video to play it in the app. Click an undownloaded one to open + it on YouTube. +5. **Offline** — the app detects a lost connection and collapses the feed to your + downloads. The connectivity pill also toggles a forced offline mode for testing. + +Videos land in `~/Movies/FlightTube` (changeable in Settings). + +## Limitations + +- The Atom feed returns only the **~15 most recent videos per channel**. There is no + backfill and no pagination — this is a rolling recent window, not an archive. +- Quality tops out at 1080p, by the deliberate choice described above. +- `yt-dlp` needs occasional updating (`brew upgrade yt-dlp`) as YouTube changes. +- Downloading videos is contrary to YouTube's Terms of Service. + +## Tests + +```bash +cd src-tauri && cargo test +``` + +37 unit tests cover the three places malformed input actually bites: Takeout CSV parsing, +Atom feed parsing (against a captured real response), and `yt-dlp` progress-line parsing — +plus the database rule that a refresh must never clobber download state. + +Two network-dependent tests are excluded by default: + +```bash +cargo test --test pipeline -- --ignored --nocapture # full pipeline vs. live feeds +cargo test --test seed_real_db -- --ignored --nocapture # populate the installed app's db +``` + +## Layout + +``` +src-tauri/src/ + takeout.rs subscriptions.csv -> channels (pure, tested) + feed.rs Atom XML -> videos, plus fetching (parse is pure, tested) + downloader.rs yt-dlp arguments and progress lines (pure, tested) + db.rs schema and every SQL statement + thumbs.rs local thumbnail cache + net.rs reachability probe + commands.rs Tauri command surface — delegates only +src/ + components/ Sidebar, TopBar, VideoRow, DownloadButton, Player, Settings + hooks/ useFeed, useDownloads, useConnectivity +``` + +Design notes and the implementation plan are in `docs/superpowers/`. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a581ed4..7ce9807 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -80,29 +80,32 @@ fn bin(name: &str) -> String { name.to_string() } -fn version_of(name: &str) -> Option { - std::process::Command::new(bin(name)) - .arg("--version") +/// `flag` differs per tool: yt-dlp takes `--version`, ffmpeg only accepts +/// `-version` (it exits non-zero on `--version` and writes to stderr), so the +/// flag is passed in and both streams are consulted. +fn version_of(name: &str, flag: &str) -> Option { + let out = std::process::Command::new(bin(name)) + .arg(flag) .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| { - String::from_utf8_lossy(&o.stdout) - .lines() - .next() - .unwrap_or("") - .trim() - .to_string() - }) - .filter(|s| !s.is_empty()) + .ok()?; + if !out.status.success() { + return None; + } + let text = if out.stdout.is_empty() { + String::from_utf8_lossy(&out.stderr).to_string() + } else { + String::from_utf8_lossy(&out.stdout).to_string() + }; + let first = text.lines().next().unwrap_or("").trim().to_string(); + (!first.is_empty()).then_some(first) } #[tauri::command] pub async fn check_prereqs(state: State<'_, AppState>) -> Result { let library = state.library.lock().await.clone(); Ok(Prereqs { - yt_dlp: version_of("yt-dlp"), - ffmpeg: version_of("ffmpeg").map(|v| { + yt_dlp: version_of("yt-dlp", "--version"), + ffmpeg: version_of("ffmpeg", "-version").map(|v| { // ffmpeg's first line is long; keep the useful head of it. v.split_whitespace().take(3).collect::>().join(" ") }), diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8490ca9..770ca1a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,10 +13,11 @@ "windows": [ { "title": "FlightTube", - "width": 1280, - "height": 840, - "minWidth": 940, - "minHeight": 600 + "width": 1180, + "height": 780, + "minWidth": 900, + "minHeight": 560, + "center": true } ], "security": { diff --git a/src-tauri/tests/seed_real_db.rs b/src-tauri/tests/seed_real_db.rs new file mode 100644 index 0000000..fbf25a3 --- /dev/null +++ b/src-tauri/tests/seed_real_db.rs @@ -0,0 +1,56 @@ +//! Populates the installed app's database using the app's own code paths, so +//! the UI can be verified against real data without driving the file dialog. +//! Run with: cargo test --test seed_real_db -- --ignored --nocapture + +use flighttube_lib::{db::Db, feed, models::FeedFilter, thumbs}; +use std::path::PathBuf; + +fn app_data() -> PathBuf { + PathBuf::from(std::env::var("HOME").unwrap()) + .join("Library/Application Support/com.vincent.flighttube") +} + +#[tokio::test] +#[ignore] +async fn seed() { + let dir = app_data(); + let mut db = Db::open(&dir.join("flighttube.db")).expect("open real db"); + + let channels = db.channel_ids().unwrap(); + println!("channels in db: {}", channels.len()); + assert!(!channels.is_empty(), "seed channels first"); + + let http = reqwest::Client::builder() + .user_agent("FlightTube/0.1 (+desktop)") + .timeout(std::time::Duration::from_secs(20)) + .build() + .unwrap(); + + let mut total = 0; + for cid in &channels { + if let Ok(v) = feed::fetch_channel(&http, cid).await { + total += v.len(); + db.upsert_videos(&v).unwrap(); + } + } + println!("videos upserted: {total}"); + + // Cache thumbnails so the feed renders with no network. + let pending = db.videos_missing_thumbs(400).unwrap(); + println!("thumbnails to cache: {}", pending.len()); + let tdir = thumbs::cache_dir(&dir); + let mut ok = 0; + for (id, url) in pending { + if let Ok(p) = thumbs::cache_one(&http, &id, &url, &tdir).await { + db.set_thumb_path(&id, &p.to_string_lossy()).unwrap(); + ok += 1; + } + } + println!("thumbnails cached: {ok}"); + + let feed_rows = db.list_feed(&FeedFilter::default()).unwrap(); + println!("feed rows now: {}", feed_rows.len()); + for i in feed_rows.iter().take(3) { + println!(" [{}] {}", i.channel_title, i.title); + } +} diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 4f74c41..b52c6b6 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -29,9 +29,11 @@ export default function Player({ item, path, onClose, onDelete }: Props) { -
+ {/* Absolute fill + object-contain, so portrait Shorts and landscape + videos are both letterboxed to the pane instead of overflowing it. */} +