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:
@@ -27,3 +27,6 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Bundled helper binaries — ~140MB, fetched by scripts/fetch-binaries.sh
|
||||||
|
src-tauri/binaries/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ A desktop app that merges all your YouTube subscriptions into one newest-first f
|
|||||||
downloads the videos you pick, and — when you have no connection — shows only what you
|
downloads the videos you pick, and — when you have no connection — shows only what you
|
||||||
already downloaded. Built for the flight.
|
already downloaded. Built for the flight.
|
||||||
|
|
||||||
Tauri 2 · Rust · React · Tailwind CSS 4 · SQLite
|
Tauri 2 · Rust · React · Tailwind CSS 4 · SQLite · self-contained (bundles yt-dlp + ffmpeg)
|
||||||
|
|
||||||
Styled to `DESIGN-SYSTEM.md`: slate and sky, a 9–15px type ladder, outline-first
|
Styled to `DESIGN-SYSTEM.md`: slate and sky, a 9–15px type ladder, outline-first
|
||||||
controls, borders for separation and shadows only for elevation, with light and dark
|
controls, borders for separation and shadows only for elevation, with light and dark
|
||||||
@@ -39,13 +39,20 @@ deliberate: every download is guaranteed to play inside FlightTube.
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
None at runtime — `yt-dlp`, `ffmpeg` and `ffprobe` ship inside the app as Tauri sidecars,
|
||||||
|
so a fresh Mac needs nothing installed. Settings shows each tool's version and whether it
|
||||||
|
came from the bundle or the system; a copy on your machine takes precedence, which is how
|
||||||
|
you run a newer yt-dlp than the bundled one.
|
||||||
|
|
||||||
|
The binaries are not in git (~140 MB together). Fetch them once before building:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install yt-dlp ffmpeg
|
./scripts/fetch-binaries.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Both are checked at startup; Settings shows their versions and this command if either is
|
That pulls yt-dlp from its official GitHub release and static arm64 ffmpeg/ffprobe from
|
||||||
missing. `ffmpeg` is required because best-quality H.264 and AAC arrive as separate
|
osxexperts.net — Homebrew's ffmpeg links a dozen dylibs and cannot be relocated into an
|
||||||
streams that must be merged.
|
app bundle. Note that static ffmpeg builds are GPL, which matters if you redistribute.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
@@ -78,7 +85,9 @@ system by default; Settings offers System / Light / Dark.
|
|||||||
- The Atom feed returns only the **~15 most recent videos per channel**. There is no
|
- The Atom feed returns only the **~15 most recent videos per channel**. There is no
|
||||||
backfill and no pagination — this is a rolling recent window, not an archive.
|
backfill and no pagination — this is a rolling recent window, not an archive.
|
||||||
- Quality tops out at 1080p, by the deliberate choice described above.
|
- Quality tops out at 1080p, by the deliberate choice described above.
|
||||||
- `yt-dlp` needs occasional updating (`brew upgrade yt-dlp`) as YouTube changes.
|
- 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 —
|
||||||
|
the app prefers a system copy when it finds one.
|
||||||
- Downloading videos is contrary to YouTube's Terms of Service.
|
- Downloading videos is contrary to YouTube's Terms of Service.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fetches the helper binaries FlightTube ships as Tauri sidecars.
|
||||||
|
#
|
||||||
|
# They are not in git — together they are about 140MB. Run this once before
|
||||||
|
# building, or after changing the target architecture.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TRIPLE="${1:-aarch64-apple-darwin}"
|
||||||
|
DEST="$(cd "$(dirname "$0")/.." && pwd)/src-tauri/binaries"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
|
||||||
|
echo "Fetching yt-dlp (official GitHub release)…"
|
||||||
|
curl -fsSL -o "$TMP/yt-dlp" \
|
||||||
|
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
|
||||||
|
echo "Fetching $tool (static, osxexperts.net)…"
|
||||||
|
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"
|
||||||
|
done
|
||||||
|
|
||||||
|
for tool in yt-dlp ffmpeg ffprobe; do
|
||||||
|
install -m 0755 "$TMP/$tool" "$DEST/$tool-$TRIPLE"
|
||||||
|
echo " -> $DEST/$tool-$TRIPLE"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Done. Verifying they run:"
|
||||||
|
for tool in yt-dlp ffmpeg ffprobe; do
|
||||||
|
"$DEST/$tool-$TRIPLE" -version 2>/dev/null | head -1 || \
|
||||||
|
"$DEST/$tool-$TRIPLE" --version 2>/dev/null | head -1
|
||||||
|
done
|
||||||
@@ -71,10 +71,24 @@ pub struct RefreshSummary {
|
|||||||
pub failures: Vec<String>,
|
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 {
|
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
|
// 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
|
// Homebrew's bin dir is invisible to them; name the paths explicitly.
|
||||||
// can find one, and fall back to the bare name for PATH resolution.
|
|
||||||
for prefix in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
|
for prefix in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
|
||||||
let candidate = format!("{prefix}/{name}");
|
let candidate = format!("{prefix}/{name}");
|
||||||
if std::path::Path::new(&candidate).exists() {
|
if std::path::Path::new(&candidate).exists() {
|
||||||
@@ -84,13 +98,25 @@ fn bin(name: &str) -> String {
|
|||||||
name.to_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
|
/// `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.
|
||||||
fn version_of(name: &str, flag: &str) -> Option<String> {
|
async fn version_of(name: &str, flag: &str) -> Option<String> {
|
||||||
let out = std::process::Command::new(bin(name))
|
// 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)
|
.arg(flag)
|
||||||
.output()
|
.output()
|
||||||
|
.await
|
||||||
.ok()?;
|
.ok()?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
return None;
|
return None;
|
||||||
@@ -104,14 +130,23 @@ fn version_of(name: &str, flag: &str) -> Option<String> {
|
|||||||
(!first.is_empty()).then_some(first)
|
(!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]
|
#[tauri::command]
|
||||||
pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String> {
|
pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String> {
|
||||||
let library = state.library.lock().await.clone();
|
let library = state.library.lock().await.clone();
|
||||||
Ok(Prereqs {
|
Ok(Prereqs {
|
||||||
yt_dlp: version_of("yt-dlp", "--version"),
|
yt_dlp: version_of("yt-dlp", "--version").await.map(|v| label(&v, "yt-dlp")),
|
||||||
ffmpeg: version_of("ffmpeg", "-version").map(|v| {
|
ffmpeg: version_of("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.
|
||||||
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(),
|
library_path: library.to_string_lossy().to_string(),
|
||||||
})
|
})
|
||||||
@@ -462,7 +497,11 @@ pub async fn download_video(
|
|||||||
.join(downloader::OUTPUT_TEMPLATE)
|
.join(downloader::OUTPUT_TEMPLATE)
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.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"))
|
let mut child = tokio::process::Command::new(bin("yt-dlp"))
|
||||||
.args(&args)
|
.args(&args)
|
||||||
|
|||||||
@@ -43,6 +43,11 @@
|
|||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/yt-dlp",
|
||||||
|
"binaries/ffmpeg",
|
||||||
|
"binaries/ffprobe"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,14 +216,19 @@ export default function Settings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className={`mt-2 ${HELP}`}>
|
||||||
|
yt-dlp and ffmpeg ship inside the app, so nothing needs installing. A copy
|
||||||
|
on your system is used instead if one is present, which is how you can run
|
||||||
|
a newer yt-dlp than the bundled one.
|
||||||
|
</p>
|
||||||
|
|
||||||
{missing && (
|
{missing && (
|
||||||
<div className="mt-2 rounded-lg border border-red-500/30 bg-red-500/5 p-3">
|
<div className="mt-2 rounded-lg border border-red-500/30 bg-red-500/5 p-3">
|
||||||
<p className="text-[11px] leading-snug text-red-700 dark:text-red-300">
|
<p className="text-[11px] leading-snug text-red-700 dark:text-red-300">
|
||||||
Downloads need both tools. Install them with:
|
A bundled tool is missing, which should not happen. Reinstalling the app
|
||||||
|
will restore it; meanwhile <code>brew install yt-dlp ffmpeg</code> also
|
||||||
|
works.
|
||||||
</p>
|
</p>
|
||||||
<code className="mt-1.5 block rounded bg-slate-100 px-2 py-1 text-[11px] dark:bg-slate-800">
|
|
||||||
brew install yt-dlp ffmpeg
|
|
||||||
</code>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user