diff --git a/.gitignore b/.gitignore index 5aab465..5886379 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Bundled helper binaries — ~140MB, fetched by scripts/fetch-binaries.sh +src-tauri/binaries/ diff --git a/README.md b/README.md index 79a0834..88e560b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A desktop app that merges all your YouTube subscriptions into one newest-first f downloads the videos you pick, and — when you have no connection — shows only what you already downloaded. Built for the flight. -Tauri 2 · Rust · React · Tailwind CSS 4 · SQLite +Tauri 2 · Rust · React · Tailwind CSS 4 · SQLite · self-contained (bundles yt-dlp + ffmpeg) Styled to `DESIGN-SYSTEM.md`: slate and sky, a 9–15px type ladder, outline-first controls, borders for separation and shadows only for elevation, with light and dark @@ -39,13 +39,20 @@ deliberate: every download is guaranteed to play inside FlightTube. ## Requirements +None at runtime — `yt-dlp`, `ffmpeg` and `ffprobe` ship inside the app as Tauri sidecars, +so a fresh Mac needs nothing installed. Settings shows each tool's version and whether it +came from the bundle or the system; a copy on your machine takes precedence, which is how +you run a newer yt-dlp than the bundled one. + +The binaries are not in git (~140 MB together). Fetch them once before building: + ```bash -brew install yt-dlp ffmpeg +./scripts/fetch-binaries.sh ``` -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. +That pulls yt-dlp from its official GitHub release and static arm64 ffmpeg/ffprobe from +osxexperts.net — Homebrew's ffmpeg links a dozen dylibs and cannot be relocated into an +app bundle. Note that static ffmpeg builds are GPL, which matters if you redistribute. ## Running it @@ -78,7 +85,9 @@ system by default; Settings offers System / Light / Dark. - 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. +- The bundled `yt-dlp` is frozen at build time and YouTube changes often. Re-run + `./scripts/fetch-binaries.sh` and rebuild, or install a newer one on your system — + the app prefers a system copy when it finds one. - Downloading videos is contrary to YouTube's Terms of Service. ## Tests diff --git a/scripts/fetch-binaries.sh b/scripts/fetch-binaries.sh new file mode 100755 index 0000000..f9a9955 --- /dev/null +++ b/scripts/fetch-binaries.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Fetches the helper binaries FlightTube ships as Tauri sidecars. +# +# They are not in git — together they are about 140MB. Run this once before +# building, or after changing the target architecture. +set -euo pipefail + +TRIPLE="${1:-aarch64-apple-darwin}" +DEST="$(cd "$(dirname "$0")/.." && pwd)/src-tauri/binaries" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$DEST" + +echo "Fetching yt-dlp (official GitHub release)…" +curl -fsSL -o "$TMP/yt-dlp" \ + https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos + +# Static arm64 builds; Homebrew's ffmpeg links a dozen dylibs and cannot be +# relocated into an app bundle. +for tool in ffmpeg ffprobe; do + echo "Fetching $tool (static, osxexperts.net)…" + curl -fsSL -A "Mozilla/5.0" -o "$TMP/$tool.zip" "https://www.osxexperts.net/${tool}9arm.zip" + unzip -o -q "$TMP/$tool.zip" -d "$TMP" +done + +for tool in yt-dlp ffmpeg ffprobe; do + install -m 0755 "$TMP/$tool" "$DEST/$tool-$TRIPLE" + echo " -> $DEST/$tool-$TRIPLE" +done + +echo "Done. Verifying they run:" +for tool in yt-dlp ffmpeg ffprobe; do + "$DEST/$tool-$TRIPLE" -version 2>/dev/null | head -1 || \ + "$DEST/$tool-$TRIPLE" --version 2>/dev/null | head -1 +done diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4136d54..570c5d3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -71,10 +71,24 @@ pub struct RefreshSummary { pub failures: Vec, } +/// Resolves a helper binary. +/// +/// The app ships `yt-dlp`, `ffmpeg` and `ffprobe` as sidecars, so a fresh Mac +/// needs nothing installed. Tauri places them beside the executable inside +/// `Contents/MacOS/`, which is checked first. A copy on PATH still wins nothing +/// — but the Homebrew fallbacks remain for `cargo run` during development, +/// where there is no bundle. fn bin(name: &str) -> String { + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let bundled = dir.join(name); + if bundled.exists() { + return bundled.to_string_lossy().to_string(); + } + } + } // GUI apps launched from Finder don't inherit a login shell PATH, so - // Homebrew's bin dir is invisible to them. Prefer an absolute path when we - // can find one, and fall back to the bare name for PATH resolution. + // Homebrew's bin dir is invisible to them; name the paths explicitly. for prefix in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] { let candidate = format!("{prefix}/{name}"); if std::path::Path::new(&candidate).exists() { @@ -84,13 +98,25 @@ fn bin(name: &str) -> String { name.to_string() } +/// True when the binary we resolved is the one inside the app bundle. +fn is_bundled(name: &str) -> bool { + std::env::current_exe() + .ok() + .and_then(|e| e.parent().map(|d| d.join(name).exists())) + .unwrap_or(false) +} + /// `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)) +async fn version_of(name: &str, flag: &str) -> Option { + // Must be the async Command: yt-dlp is a PyInstaller bundle that unpacks + // ~37MB on its first run, so a blocking call here would stall a runtime + // worker for the better part of a minute. + let out = tokio::process::Command::new(bin(name)) .arg(flag) .output() + .await .ok()?; if !out.status.success() { return None; @@ -104,14 +130,23 @@ fn version_of(name: &str, flag: &str) -> Option { (!first.is_empty()).then_some(first) } +fn label(version: &str, name: &str) -> String { + if is_bundled(name) { + format!("{version} (bundled)") + } else { + format!("{version} (system)") + } +} + #[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", "--version"), - ffmpeg: version_of("ffmpeg", "-version").map(|v| { + yt_dlp: version_of("yt-dlp", "--version").await.map(|v| label(&v, "yt-dlp")), + ffmpeg: version_of("ffmpeg", "-version").await.map(|v| { // ffmpeg's first line is long; keep the useful head of it. - v.split_whitespace().take(3).collect::>().join(" ") + let head = v.split_whitespace().take(3).collect::>().join(" "); + label(&head, "ffmpeg") }), library_path: library.to_string_lossy().to_string(), }) @@ -462,7 +497,11 @@ pub async fn download_video( .join(downloader::OUTPUT_TEMPLATE) .to_string_lossy() .to_string(); - let args = downloader::build_args(&video_id, &out_template, &quality); + let mut args = downloader::build_args(&video_id, &out_template, &quality); + // Without this yt-dlp looks for ffmpeg on PATH, which a bundled app has no + // reason to have. Merging video and audio would fail on a clean machine. + args.push("--ffmpeg-location".into()); + args.push(bin("ffmpeg")); let mut child = tokio::process::Command::new(bin("yt-dlp")) .args(&args) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 72ed221..e275c90 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -43,6 +43,11 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" + ], + "externalBin": [ + "binaries/yt-dlp", + "binaries/ffmpeg", + "binaries/ffprobe" ] } } diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index a372cb4..77e360a 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -216,14 +216,19 @@ export default function Settings({ +

+ yt-dlp and ffmpeg ship inside the app, so nothing needs installing. A copy + on your system is used instead if one is present, which is how you can run + a newer yt-dlp than the bundled one. +

+ {missing && (

- Downloads need both tools. Install them with: + A bundled tool is missing, which should not happen. Reinstalling the app + will restore it; meanwhile brew install yt-dlp ffmpeg also + works.

- - brew install yt-dlp ffmpeg -
)}