perf: run yt-dlp from a zipapp on a portable Python

Replaces the official yt-dlp_macos sidecar. That build is PyInstaller
one-file and unpacks 37MB on every invocation, costing ~8s per call on
this machine — ruled out signing, xattrs and thinning the universal
binary as causes, and it is 8% CPU over 8s, so it is the unpack itself.
Every stream resolve paid it.

The 3MB zipapp on a trimmed portable CPython starts in ~0.45s, a 17x
improvement; a real stream resolve goes from ~10s to ~2.4s. macOS ships
only Python 3.9 and yt-dlp needs 3.10+, hence bundling an interpreter.

yt-dlp is now resolved as an argv prefix rather than a path, so a system
install (a plain script, instant) still takes precedence.

Bundle grows 153MB -> 219MB, which buys back the responsiveness.
This commit is contained in:
vincent
2026-08-29 12:07:09 +02:00
parent 50ad908f1e
commit 9e9f71b31b
5 changed files with 130 additions and 45 deletions
+63 -18
View File
@@ -43,6 +43,18 @@ pub struct AppState {
/// Resolved stream URLs, keyed by video and quality cap. YouTube's signed
/// URLs last hours, so replaying a video should not pay for yt-dlp again.
pub streams: Arc<Mutex<HashMap<(String, Option<u32>), (String, std::time::Instant)>>>,
/// argv prefix that runs yt-dlp: either a system binary, or the bundled
/// Python interpreter followed by the zipapp.
pub yt_dlp: Vec<String>,
}
impl AppState {
/// A ready-to-configure yt-dlp process.
fn yt_dlp(&self) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new(&self.yt_dlp[0]);
cmd.args(&self.yt_dlp[1..]);
cmd
}
}
/// Comfortably inside the ~6h lifetime of YouTube's signed URLs.
@@ -107,6 +119,35 @@ fn bin(name: &str) -> String {
name.to_string()
}
/// Works out how to run yt-dlp.
///
/// A system install wins when present — it is a plain Python script, starts in
/// milliseconds, and is easier to keep current than a bundled copy. Otherwise
/// the app runs its own interpreter against the yt-dlp zipapp. The 3MB zipapp
/// plus a portable Python starts in about half a second; the official
/// PyInstaller binary took eight, because it unpacks 37MB on every call.
fn resolve_yt_dlp(app: &AppHandle) -> Vec<String> {
for prefix in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
let candidate = format!("{prefix}/yt-dlp");
if std::path::Path::new(&candidate).exists() {
return vec![candidate];
}
}
if let Ok(res) = app.path().resource_dir() {
// python3 and python are symlinks; name the real file so the bundle
// does not depend on symlinks surviving the copy.
let python = res.join("python/bin/python3.12");
let zipapp = res.join("yt-dlp.pyz");
if python.exists() && zipapp.exists() {
return vec![
python.to_string_lossy().to_string(),
zipapp.to_string_lossy().to_string(),
];
}
}
vec!["yt-dlp".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()
@@ -115,18 +156,18 @@ fn is_bundled(name: &str) -> bool {
.unwrap_or(false)
}
/// Whether yt-dlp is the app's own copy rather than one found on the system.
fn yt_dlp_is_bundled(argv: &[String]) -> bool {
argv.len() > 1
}
/// `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.
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()?;
async fn version_of(argv: &[String], flag: &str) -> Option<String> {
let mut cmd = tokio::process::Command::new(&argv[0]);
cmd.args(&argv[1..]);
let out = cmd.arg(flag).output().await.ok()?;
if !out.status.success() {
return None;
}
@@ -159,8 +200,11 @@ pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String
}
let fresh = Prereqs {
yt_dlp: version_of("yt-dlp", "--version").await.map(|v| label(&v, "yt-dlp")),
ffmpeg: version_of("ffmpeg", "-version").await.map(|v| {
yt_dlp: version_of(&state.yt_dlp, "--version").await.map(|v| {
let origin = if yt_dlp_is_bundled(&state.yt_dlp) { "bundled" } else { "system" };
format!("{v} ({origin})")
}),
ffmpeg: version_of(&[bin("ffmpeg")], "-version").await.map(|v| {
// ffmpeg's first line is long; keep the useful head of it.
let head = v.split_whitespace().take(3).collect::<Vec<_>>().join(" ");
label(&head, "ffmpeg")
@@ -260,6 +304,7 @@ pub async fn resolve_stream(
// The HLS master playlist. Every m3u8 format shares the same manifest_url,
// so any one of them yields the master.
if let Some(master) = yt_dlp_print(
&state,
&["-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", &url],
)
.await
@@ -287,7 +332,7 @@ pub async fn resolve_stream(
}
// Rare fallback: an old-style progressive muxed MP4.
if let Some(u) = yt_dlp_print(&[
if let Some(u) = yt_dlp_print(&state, &[
"-f",
"b[ext=mp4][acodec!=none][vcodec!=none]",
"--print",
@@ -366,8 +411,8 @@ fn resolution_height(stream_inf: &str) -> Option<u32> {
}
/// Runs yt-dlp and returns its first non-empty stdout line, or None.
async fn yt_dlp_print(args: &[&str]) -> Option<String> {
let mut cmd = tokio::process::Command::new(bin("yt-dlp"));
async fn yt_dlp_print(state: &State<'_, AppState>, args: &[&str]) -> Option<String> {
let mut cmd = state.yt_dlp();
cmd.args(["--no-warnings", "--no-playlist", "--simulate"]);
cmd.args(args);
let out = cmd.output().await.ok()?;
@@ -545,14 +590,13 @@ pub async fn download_video(
args.push("--ffmpeg-location".into());
args.push(bin("ffmpeg"));
let mut child = tokio::process::Command::new(bin("yt-dlp"))
let mut child = state
.yt_dlp()
.args(&args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| {
format!("Could not start yt-dlp: {e}. Install it with: brew install yt-dlp ffmpeg")
})?;
.map_err(|e| format!("Could not start yt-dlp: {e}"))?;
let stdout = child.stdout.take().ok_or("yt-dlp produced no stdout")?;
let stderr = child.stderr.take().ok_or("yt-dlp produced no stderr")?;
@@ -810,6 +854,7 @@ pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
playlists: PlaylistServer::start()?,
prereqs: Arc::new(Mutex::new(None)),
streams: Arc::new(Mutex::new(HashMap::new())),
yt_dlp: resolve_yt_dlp(app),
})
}
+5 -2
View File
@@ -45,9 +45,12 @@
"icons/icon.ico"
],
"externalBin": [
"binaries/yt-dlp",
"binaries/ffmpeg",
"binaries/ffprobe"
]
],
"resources": {
"resources/python": "python",
"resources/yt-dlp.pyz": "yt-dlp.pyz"
}
}
}