feat: bundle yt-dlp and ffmpeg as sidecars

The app is now self-contained: yt-dlp, ffmpeg and ffprobe ship inside
the bundle and are resolved beside the executable, so a fresh Mac needs
nothing installed. A system copy still wins when present, which is how
to run a newer yt-dlp than the bundled one. yt-dlp is pointed at the
bundled ffmpeg explicitly, since a bundled app has no reason to have one
on PATH.

The binaries stay out of git (~140MB); scripts/fetch-binaries.sh pulls
them. Homebrew's ffmpeg cannot be used — it links a dozen dylibs and
does not relocate — so the static arm64 build is used instead.

Version checks moved to the async Command. yt-dlp is a PyInstaller
bundle that unpacks ~37MB on first run, and the blocking call was
stalling a runtime worker for roughly twenty seconds.

Bundle size goes from 19MB to 153MB.
This commit is contained in:
vincent
2026-08-29 11:41:14 +02:00
parent 9bb7b71225
commit 3296b5436a
6 changed files with 114 additions and 18 deletions
+3
View File
@@ -27,3 +27,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Bundled helper binaries — ~140MB, fetched by scripts/fetch-binaries.sh
src-tauri/binaries/
+15 -6
View File
@@ -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 915px 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
+35
View File
@@ -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
+47 -8
View File
@@ -71,10 +71,24 @@ pub struct RefreshSummary {
pub failures: Vec<String>,
}
/// 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<String> {
let out = std::process::Command::new(bin(name))
async fn version_of(name: &str, flag: &str) -> Option<String> {
// 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<String> {
(!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<Prereqs, String> {
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::<Vec<_>>().join(" ")
let head = v.split_whitespace().take(3).collect::<Vec<_>>().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)
+5
View File
@@ -43,6 +43,11 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"externalBin": [
"binaries/yt-dlp",
"binaries/ffmpeg",
"binaries/ffprobe"
]
}
}
+9 -4
View File
@@ -216,14 +216,19 @@ export default function Settings({
</div>
</div>
<p className={`mt-2 ${HELP}`}>
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.
</p>
{missing && (
<div className="mt-2 rounded-lg border border-red-500/30 bg-red-500/5 p-3">
<p className="text-[11px] leading-snug text-red-700 dark:text-red-300">
Downloads need both tools. Install them with:
A bundled tool is missing, which should not happen. Reinstalling the app
will restore it; meanwhile <code>brew install yt-dlp ffmpeg</code> also
works.
</p>
<code className="mt-1.5 block rounded bg-slate-100 px-2 py-1 text-[11px] dark:bg-slate-800">
brew install yt-dlp ffmpeg
</code>
</div>
)}
</section>