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
+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)