202 lines
7.1 KiB
Rust
202 lines
7.1 KiB
Rust
//! Minimal `yt-dlp` sidecar for **stream URLs only**
|
|
//! (see `architecture/youtube-rustypipe.md` D2-revised).
|
|
//!
|
|
//! All metadata comes from the pure-Rust rustypipe extractor; this
|
|
//! module exists because YouTube currently rejects sustained tokenless
|
|
//! stream fetching (every URL serves exactly its leading ~1 MiB), and
|
|
//! the token-capable Innertube clients need signature deciphering that
|
|
//! is broken in rustypipe upstream right now. `yt-dlp` solves the
|
|
//! cipher challenges and its URLs stream fully (throttled, but well
|
|
//! above audio bitrate). When upstream recovers, this sidecar can be
|
|
//! dropped without touching the provider: it is only consulted by
|
|
//! `get_urls_for_track`.
|
|
//!
|
|
//! Subprocess discipline: argv-only (never a shell), one bounded call
|
|
//! (`kill_on_drop`), stdout capped, stderr summarized to a short
|
|
//! suffix. Errors carry the video id and exit status — never stream
|
|
//! URLs (they may embed tokens).
|
|
|
|
use std::path::PathBuf;
|
|
use std::process::Stdio;
|
|
use std::time::Duration;
|
|
|
|
use tracing::debug;
|
|
|
|
/// Cap on captured stdout; `-g` prints a handful of URLs.
|
|
const MAX_STDOUT_BYTES: usize = 512 * 1024;
|
|
|
|
/// Errors from the sidecar.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum YtDlpError {
|
|
#[error("cannot run yt-dlp: {0}")]
|
|
Spawn(String),
|
|
#[error("yt-dlp timed out after {0:?}")]
|
|
Timeout(Duration),
|
|
#[error("yt-dlp failed ({status}): {stderr}")]
|
|
Failed { status: String, stderr: String },
|
|
#[error("yt-dlp printed no stream url")]
|
|
NoUrl,
|
|
}
|
|
|
|
/// A configured `yt-dlp` binary used for stream URL resolution.
|
|
#[derive(Clone, Debug)]
|
|
pub struct YtDlp {
|
|
binary: PathBuf,
|
|
timeout: Duration,
|
|
}
|
|
|
|
impl YtDlp {
|
|
pub fn new(binary: PathBuf, timeout: Duration) -> Self {
|
|
Self { binary, timeout }
|
|
}
|
|
|
|
/// Probes `--version`; used at init to decide whether the sidecar
|
|
/// is available (absence degrades to rustypipe URLs, never fails
|
|
/// the provider).
|
|
pub async fn probe(&self) -> Result<String, YtDlpError> {
|
|
let stdout = self.run(&["--version"]).await?;
|
|
Ok(stdout.trim().to_string())
|
|
}
|
|
|
|
/// The audio stream URL for a watch URL: `audio/mp4` (AAC)
|
|
/// preferred — the format the local player decodes.
|
|
pub async fn stream_url(&self, watch_url: &str) -> Result<String, YtDlpError> {
|
|
let stdout = self
|
|
.run(&[
|
|
"-f",
|
|
"bestaudio[ext=m4a]/bestaudio/best",
|
|
"-g",
|
|
"--no-playlist",
|
|
watch_url,
|
|
])
|
|
.await?;
|
|
stdout
|
|
.lines()
|
|
.map(str::trim)
|
|
.find(|line| !line.is_empty())
|
|
.map(str::to_string)
|
|
.ok_or(YtDlpError::NoUrl)
|
|
}
|
|
|
|
/// Runs one bounded call and returns its stdout.
|
|
async fn run(&self, args: &[&str]) -> Result<String, YtDlpError> {
|
|
debug!(binary = %self.binary.display(), ?args, "running yt-dlp");
|
|
let mut command = tokio::process::Command::new(&self.binary);
|
|
command
|
|
.args(["--no-warnings"])
|
|
.args(args)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.kill_on_drop(true);
|
|
let child = command
|
|
.spawn()
|
|
.map_err(|err| YtDlpError::Spawn(err.to_string()))?;
|
|
let output = tokio::time::timeout(self.timeout, child.wait_with_output())
|
|
.await
|
|
.map_err(|_| YtDlpError::Timeout(self.timeout))?
|
|
.map_err(|err| YtDlpError::Spawn(err.to_string()))?;
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let summary: String = stderr.trim().chars().take(200).collect();
|
|
return Err(YtDlpError::Failed {
|
|
status: output.status.to_string(),
|
|
stderr: summary,
|
|
});
|
|
}
|
|
let mut stdout = output.stdout;
|
|
stdout.truncate(MAX_STDOUT_BYTES);
|
|
Ok(String::from_utf8_lossy(&stdout).into_owned())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::Path;
|
|
|
|
/// A fake yt-dlp: a script dispatching on its argv.
|
|
fn fake_binary(dir: &Path, body: &str) -> PathBuf {
|
|
let path = dir.join("fake-yt-dlp");
|
|
fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write fake binary");
|
|
let mut perms = fs::metadata(&path).expect("metadata").permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&path, perms).expect("chmod");
|
|
path
|
|
}
|
|
|
|
/// Retries a call while exec races a concurrent fork (ETXTBSY under
|
|
/// parallel test load).
|
|
async fn retry_spawn<T, F, Fut>(mut call: F) -> Result<T, YtDlpError>
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: std::future::Future<Output = Result<T, YtDlpError>>,
|
|
{
|
|
let mut last = call().await;
|
|
for _ in 0..4 {
|
|
if !matches!(last, Err(YtDlpError::Spawn(_))) {
|
|
break;
|
|
}
|
|
last = call().await;
|
|
}
|
|
last
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolves_stream_urls_and_probes() {
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let binary = fake_binary(
|
|
dir.path(),
|
|
r#"
|
|
case "$*" in
|
|
*--version*) echo "2026.07.04"; exit 0 ;;
|
|
*watch?v=vid1*) printf '%s\n' "https://example.test/a.m4a"; exit 0 ;;
|
|
*) echo boom >&2; exit 1 ;;
|
|
esac"#,
|
|
);
|
|
let ytdlp = YtDlp::new(binary, Duration::from_secs(30));
|
|
assert_eq!(
|
|
retry_spawn(|| ytdlp.probe()).await.expect("probe"),
|
|
"2026.07.04"
|
|
);
|
|
let url = retry_spawn(|| ytdlp.stream_url("https://www.youtube.com/watch?v=vid1"))
|
|
.await
|
|
.expect("url");
|
|
assert_eq!(url, "https://example.test/a.m4a");
|
|
// Failures are typed and carry a bounded stderr summary.
|
|
let err = ytdlp
|
|
.stream_url("https://www.youtube.com/watch?v=nope")
|
|
.await
|
|
.expect_err("fails");
|
|
assert!(err.to_string().contains("boom"), "{err}");
|
|
// A missing binary is a typed spawn error.
|
|
let gone = YtDlp::new(dir.path().join("gone"), Duration::from_secs(5));
|
|
assert!(matches!(gone.probe().await, Err(YtDlpError::Spawn(_))));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hung_calls_hit_the_timeout() {
|
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
|
let binary = fake_binary(dir.path(), "sleep 30");
|
|
let ytdlp = YtDlp::new(binary, Duration::from_millis(200));
|
|
let started = std::time::Instant::now();
|
|
let mut saw_timeout = false;
|
|
for _ in 0..5 {
|
|
match ytdlp.probe().await {
|
|
Err(YtDlpError::Timeout(_)) => {
|
|
saw_timeout = true;
|
|
break;
|
|
}
|
|
// Under parallel test load, exec of a just-written
|
|
// script can race a concurrent fork (ETXTBSY) — retry.
|
|
Err(YtDlpError::Spawn(_)) => continue,
|
|
other => panic!("expected a timeout, got {other:?}"),
|
|
}
|
|
}
|
|
assert!(saw_timeout, "no timeout within 5 attempts");
|
|
assert!(started.elapsed() < Duration::from_secs(10));
|
|
}
|
|
}
|