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:
@@ -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)
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"externalBin": [
|
||||
"binaries/yt-dlp",
|
||||
"binaries/ffmpeg",
|
||||
"binaries/ffprobe"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user