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:
+3
-1
@@ -28,5 +28,7 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# Bundled helper binaries — ~140MB, fetched by scripts/fetch-binaries.sh
|
# Bundled helper tools — ~130MB, fetched by scripts/fetch-binaries.sh
|
||||||
src-tauri/binaries/
|
src-tauri/binaries/
|
||||||
|
src-tauri/resources/python/
|
||||||
|
src-tauri/resources/yt-dlp.pyz
|
||||||
|
|||||||
@@ -50,9 +50,17 @@ The binaries are not in git (~140 MB together). Fetch them once before building:
|
|||||||
./scripts/fetch-binaries.sh
|
./scripts/fetch-binaries.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
That pulls yt-dlp from its official GitHub release and static arm64 ffmpeg/ffprobe from
|
That pulls static arm64 ffmpeg/ffprobe from osxexperts.net (Homebrew's ffmpeg links a
|
||||||
osxexperts.net — Homebrew's ffmpeg links a dozen dylibs and cannot be relocated into an
|
dozen dylibs and cannot be relocated into a bundle), the yt-dlp **zipapp**, and a portable
|
||||||
app bundle. Note that static ffmpeg builds are GPL, which matters if you redistribute.
|
CPython from astral's python-build-standalone.
|
||||||
|
|
||||||
|
The interpreter is bundled deliberately. The official `yt-dlp_macos` binary is a
|
||||||
|
PyInstaller one-file build that unpacks 37 MB on *every* invocation — about eight seconds
|
||||||
|
per call, which every stream resolve would pay. The 3 MB zipapp on a portable interpreter
|
||||||
|
starts in under half a second. macOS ships only Python 3.9 and yt-dlp requires 3.10+,
|
||||||
|
which is why the interpreter has to come too.
|
||||||
|
|
||||||
|
Static ffmpeg builds are GPL, which matters if you redistribute.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
@@ -88,6 +96,9 @@ system by default; Settings offers System / Light / Dark.
|
|||||||
- The bundled `yt-dlp` is frozen at build time and YouTube changes often. Re-run
|
- 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 —
|
`./scripts/fetch-binaries.sh` and rebuild, or install a newer one on your system —
|
||||||
the app prefers a system copy when it finds one.
|
the app prefers a system copy when it finds one.
|
||||||
|
- yt-dlp warns that extraction without a JavaScript runtime (deno) is deprecated. Format
|
||||||
|
lists are currently identical either way, but that may not hold; installing deno, or a
|
||||||
|
system yt-dlp that depends on it, is the escape hatch.
|
||||||
- Downloading videos is contrary to YouTube's Terms of Service.
|
- Downloading videos is contrary to YouTube's Terms of Service.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|||||||
+45
-21
@@ -1,35 +1,59 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Fetches the helper binaries FlightTube ships as Tauri sidecars.
|
# Fetches the helper tools FlightTube ships inside its bundle.
|
||||||
#
|
#
|
||||||
# They are not in git — together they are about 140MB. Run this once before
|
# They are not in git (~130MB together). Run this once before building.
|
||||||
# building, or after changing the target architecture.
|
#
|
||||||
|
# ffmpeg / ffprobe static arm64 binaries, shipped as Tauri sidecars
|
||||||
|
# python + yt-dlp a portable interpreter plus the yt-dlp zipapp
|
||||||
|
#
|
||||||
|
# yt-dlp is deliberately NOT the official yt-dlp_macos build: that is a
|
||||||
|
# PyInstaller one-file binary which unpacks ~37MB on every invocation, costing
|
||||||
|
# about eight seconds per call. The 3MB zipapp on a portable interpreter starts
|
||||||
|
# in under half a second. macOS ships only Python 3.9 and yt-dlp needs 3.10+,
|
||||||
|
# which is why the interpreter has to come along.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
TRIPLE="${1:-aarch64-apple-darwin}"
|
TRIPLE="${1:-aarch64-apple-darwin}"
|
||||||
DEST="$(cd "$(dirname "$0")/.." && pwd)/src-tauri/binaries"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
BIN="$ROOT/src-tauri/binaries"
|
||||||
|
RES="$ROOT/src-tauri/resources"
|
||||||
TMP="$(mktemp -d)"
|
TMP="$(mktemp -d)"
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
mkdir -p "$DEST"
|
mkdir -p "$BIN" "$RES"
|
||||||
|
|
||||||
echo "Fetching yt-dlp (official GitHub release)…"
|
echo "Fetching ffmpeg and ffprobe (static arm64)…"
|
||||||
curl -fsSL -o "$TMP/yt-dlp" \
|
# Homebrew's ffmpeg links a dozen dylibs and cannot be relocated into a bundle.
|
||||||
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
|
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"
|
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"
|
unzip -o -q "$TMP/$tool.zip" -d "$TMP"
|
||||||
|
install -m 0755 "$TMP/$tool" "$BIN/$tool-$TRIPLE"
|
||||||
done
|
done
|
||||||
|
|
||||||
for tool in yt-dlp ffmpeg ffprobe; do
|
echo "Fetching the yt-dlp zipapp…"
|
||||||
install -m 0755 "$TMP/$tool" "$DEST/$tool-$TRIPLE"
|
curl -fsSL -o "$RES/yt-dlp.pyz" \
|
||||||
echo " -> $DEST/$tool-$TRIPLE"
|
https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp
|
||||||
done
|
|
||||||
|
|
||||||
echo "Done. Verifying they run:"
|
echo "Fetching a portable Python…"
|
||||||
for tool in yt-dlp ffmpeg ffprobe; do
|
PY_TAG="$(curl -fsSL -H 'User-Agent: flighttube-build' \
|
||||||
"$DEST/$tool-$TRIPLE" -version 2>/dev/null | head -1 || \
|
https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest \
|
||||||
"$DEST/$tool-$TRIPLE" --version 2>/dev/null | head -1
|
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')"
|
||||||
done
|
PY_ASSET="cpython-3.12.14+${PY_TAG}-${TRIPLE}-install_only_stripped.tar.gz"
|
||||||
|
curl -fsSL -o "$TMP/python.tar.gz" \
|
||||||
|
"https://github.com/astral-sh/python-build-standalone/releases/download/${PY_TAG}/${PY_ASSET// /%20}"
|
||||||
|
rm -rf "$RES/python"
|
||||||
|
tar -xzf "$TMP/python.tar.gz" -C "$TMP"
|
||||||
|
mv "$TMP/python" "$RES/python"
|
||||||
|
|
||||||
|
echo "Trimming the interpreter to what yt-dlp needs…"
|
||||||
|
P="$RES/python"
|
||||||
|
rm -rf "$P/include" "$P/share" "$P/lib/pkgconfig" \
|
||||||
|
"$P/lib/python3.12/test" "$P/lib/python3.12/idlelib" \
|
||||||
|
"$P/lib/python3.12/tkinter" "$P/lib/python3.12/lib2to3" \
|
||||||
|
"$P/lib/python3.12/ensurepip" "$P/lib/python3.12/turtledemo" \
|
||||||
|
"$P/lib/python3.12/config-3.12-darwin"
|
||||||
|
find "$P" -name "libpython*.a" -delete
|
||||||
|
find "$P" -name "__pycache__" -type d -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Verifying:"
|
||||||
|
"$BIN/ffmpeg-$TRIPLE" -version | head -1
|
||||||
|
"$P/bin/python3.12" "$RES/yt-dlp.pyz" --version
|
||||||
|
|||||||
+63
-18
@@ -43,6 +43,18 @@ pub struct AppState {
|
|||||||
/// Resolved stream URLs, keyed by video and quality cap. YouTube's signed
|
/// 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.
|
/// 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)>>>,
|
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.
|
/// Comfortably inside the ~6h lifetime of YouTube's signed URLs.
|
||||||
@@ -107,6 +119,35 @@ fn bin(name: &str) -> String {
|
|||||||
name.to_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.
|
/// True when the binary we resolved is the one inside the app bundle.
|
||||||
fn is_bundled(name: &str) -> bool {
|
fn is_bundled(name: &str) -> bool {
|
||||||
std::env::current_exe()
|
std::env::current_exe()
|
||||||
@@ -115,18 +156,18 @@ fn is_bundled(name: &str) -> bool {
|
|||||||
.unwrap_or(false)
|
.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
|
/// `flag` differs per tool: yt-dlp takes `--version`, ffmpeg only accepts
|
||||||
/// `-version` (it exits non-zero on `--version` and writes to stderr), so the
|
/// `-version` (it exits non-zero on `--version` and writes to stderr), so the
|
||||||
/// flag is passed in and both streams are consulted.
|
/// flag is passed in and both streams are consulted.
|
||||||
async fn version_of(name: &str, flag: &str) -> Option<String> {
|
async fn version_of(argv: &[String], flag: &str) -> Option<String> {
|
||||||
// Must be the async Command: yt-dlp is a PyInstaller bundle that unpacks
|
let mut cmd = tokio::process::Command::new(&argv[0]);
|
||||||
// ~37MB on its first run, so a blocking call here would stall a runtime
|
cmd.args(&argv[1..]);
|
||||||
// worker for the better part of a minute.
|
let out = cmd.arg(flag).output().await.ok()?;
|
||||||
let out = tokio::process::Command::new(bin(name))
|
|
||||||
.arg(flag)
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.ok()?;
|
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -159,8 +200,11 @@ pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String
|
|||||||
}
|
}
|
||||||
|
|
||||||
let fresh = Prereqs {
|
let fresh = Prereqs {
|
||||||
yt_dlp: version_of("yt-dlp", "--version").await.map(|v| label(&v, "yt-dlp")),
|
yt_dlp: version_of(&state.yt_dlp, "--version").await.map(|v| {
|
||||||
ffmpeg: version_of("ffmpeg", "-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.
|
// ffmpeg's first line is long; keep the useful head of it.
|
||||||
let head = v.split_whitespace().take(3).collect::<Vec<_>>().join(" ");
|
let head = v.split_whitespace().take(3).collect::<Vec<_>>().join(" ");
|
||||||
label(&head, "ffmpeg")
|
label(&head, "ffmpeg")
|
||||||
@@ -260,6 +304,7 @@ pub async fn resolve_stream(
|
|||||||
// The HLS master playlist. Every m3u8 format shares the same manifest_url,
|
// The HLS master playlist. Every m3u8 format shares the same manifest_url,
|
||||||
// so any one of them yields the master.
|
// so any one of them yields the master.
|
||||||
if let Some(master) = yt_dlp_print(
|
if let Some(master) = yt_dlp_print(
|
||||||
|
&state,
|
||||||
&["-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", &url],
|
&["-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", &url],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -287,7 +332,7 @@ pub async fn resolve_stream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rare fallback: an old-style progressive muxed MP4.
|
// Rare fallback: an old-style progressive muxed MP4.
|
||||||
if let Some(u) = yt_dlp_print(&[
|
if let Some(u) = yt_dlp_print(&state, &[
|
||||||
"-f",
|
"-f",
|
||||||
"b[ext=mp4][acodec!=none][vcodec!=none]",
|
"b[ext=mp4][acodec!=none][vcodec!=none]",
|
||||||
"--print",
|
"--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.
|
/// Runs yt-dlp and returns its first non-empty stdout line, or None.
|
||||||
async fn yt_dlp_print(args: &[&str]) -> Option<String> {
|
async fn yt_dlp_print(state: &State<'_, AppState>, args: &[&str]) -> Option<String> {
|
||||||
let mut cmd = tokio::process::Command::new(bin("yt-dlp"));
|
let mut cmd = state.yt_dlp();
|
||||||
cmd.args(["--no-warnings", "--no-playlist", "--simulate"]);
|
cmd.args(["--no-warnings", "--no-playlist", "--simulate"]);
|
||||||
cmd.args(args);
|
cmd.args(args);
|
||||||
let out = cmd.output().await.ok()?;
|
let out = cmd.output().await.ok()?;
|
||||||
@@ -545,14 +590,13 @@ pub async fn download_video(
|
|||||||
args.push("--ffmpeg-location".into());
|
args.push("--ffmpeg-location".into());
|
||||||
args.push(bin("ffmpeg"));
|
args.push(bin("ffmpeg"));
|
||||||
|
|
||||||
let mut child = tokio::process::Command::new(bin("yt-dlp"))
|
let mut child = state
|
||||||
|
.yt_dlp()
|
||||||
.args(&args)
|
.args(&args)
|
||||||
.stdout(std::process::Stdio::piped())
|
.stdout(std::process::Stdio::piped())
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| {
|
.map_err(|e| format!("Could not start yt-dlp: {e}"))?;
|
||||||
format!("Could not start yt-dlp: {e}. Install it with: brew install yt-dlp ffmpeg")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let stdout = child.stdout.take().ok_or("yt-dlp produced no stdout")?;
|
let stdout = child.stdout.take().ok_or("yt-dlp produced no stdout")?;
|
||||||
let stderr = child.stderr.take().ok_or("yt-dlp produced no stderr")?;
|
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()?,
|
playlists: PlaylistServer::start()?,
|
||||||
prereqs: Arc::new(Mutex::new(None)),
|
prereqs: Arc::new(Mutex::new(None)),
|
||||||
streams: Arc::new(Mutex::new(HashMap::new())),
|
streams: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
yt_dlp: resolve_yt_dlp(app),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,9 +45,12 @@
|
|||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"externalBin": [
|
"externalBin": [
|
||||||
"binaries/yt-dlp",
|
|
||||||
"binaries/ffmpeg",
|
"binaries/ffmpeg",
|
||||||
"binaries/ffprobe"
|
"binaries/ffprobe"
|
||||||
]
|
],
|
||||||
|
"resources": {
|
||||||
|
"resources/python": "python",
|
||||||
|
"resources/yt-dlp.pyz": "yt-dlp.pyz"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user