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:
+63
-18
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user