libytdlp 0.6.0

Safe Rust wrapper around the libytdlp C ABI
use std::io::BufRead;
use std::io::BufReader;
use std::path::PathBuf;
use std::process::Child;
use std::process::Command;
use std::process::Stdio;

struct FixtureServer {
    child: Child,
    port: u16,
}

impl FixtureServer {
    fn start(bytes: u64, rate: u64) -> Self {
        let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("../../../tests/fixtures/slow_server.py");

        let python = std::env::var("LIBYTDLP_TEST_PYTHON").unwrap_or_else(|_| "python3".to_owned());

        let mut child = Command::new(python)
            .arg(fixture)
            .arg("0")
            .arg(bytes.to_string())
            .arg(rate.to_string())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .expect("fixture server must start");

        let stdout = child
            .stdout
            .take()
            .expect("fixture server must pipe its port");
        let mut line = String::new();
        BufReader::new(stdout)
            .read_line(&mut line)
            .expect("fixture server must report its port");

        let port: u16 = line
            .trim()
            .strip_prefix("PORT ")
            .expect("fixture server must print PORT")
            .parse()
            .expect("fixture port must be numeric");

        Self { child, port }
    }

    fn url(&self, path: &str) -> String {
        format!("http://127.0.0.1:{}{}", self.port, path)
    }
}

impl Drop for FixtureServer {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

fn copy_dir(source: &std::path::Path, target: &std::path::Path) {
    std::fs::create_dir_all(target).expect("copy target must be created");

    for entry in std::fs::read_dir(source).expect("copy source must list") {
        let entry = entry.expect("copy entry must read");
        let name = entry.file_name();
        let from = entry.path();
        let to = target.join(&name);

        if from.is_dir() {
            copy_dir(&from, &to);
        } else {
            std::fs::copy(&from, &to).expect("copy file must succeed");
        }
    }
}

#[test]
fn extract_with_embedded_bridge_and_alt_ytdlp() {
    let server = FixtureServer::start(65536, 8_000_000);

    let alt = std::env::temp_dir().join(format!("libytdlp-alt-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&alt);

    copy_dir(
        &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../third_party/yt-dlp/yt_dlp"),
        &alt.join("yt_dlp"),
    );

    unsafe {
        std::env::set_var("LIBYTDLP_BRIDGE_DIR", "");
        std::env::set_var("LIBYTDLP_YTDLP_DIR", "");
        std::env::set_var("PYTHONPATH", &alt);
    }

    let client = libytdlp::Client::new("{}").expect("client must build");

    let info = client
        .extract(&server.url("/file.bin"))
        .expect("embedded bridge extract must succeed");

    assert!(info.value().is_object());

    match info
        .value()
        .get("formats")
        .and_then(|formats| formats.as_array())
    {
        Some(formats) => assert!(!formats.is_empty()),
        None => assert!(
            info.value()
                .get("url")
                .and_then(|url| url.as_str())
                .is_some_and(|url| url.contains(&server.port.to_string())),
            "direct media URL must point at the fixture"
        ),
    }

    assert!(
        info.webpage_url()
            .is_some_and(|url| url.contains(&server.port.to_string())),
        "extracted URL must point at the fixture"
    );

    let versions = libytdlp::runtime_info().expect("runtime info must succeed");

    assert_eq!(
        versions.value().get("backend").and_then(|v| v.as_str()),
        Some("embedded")
    );

    let _ = std::fs::remove_dir_all(&alt);
}