libytdlp 0.1.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;

use libytdlp::Client;
use libytdlp::ClientBuilder;
use libytdlp::Kind;
use libytdlp::MediaInfo;
use libytdlp::PlayerClient;
use libytdlp::abi_version;
use libytdlp::player_client_options;
use libytdlp::runtime_info;

fn assert_send_sync<T>()
where
    T: Send + Sync,
{
}

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();
    }
}

#[test]
fn handles_are_send_and_sync() {
    assert_send_sync::<Client>();
    assert_send_sync::<MediaInfo>();
}

#[test]
fn bad_options_json_fails_as_invalid_argument() {
    let client = Client::new("{not json").expect("options are stored verbatim");

    let error = client
        .extract("ly-bogus://example")
        .expect_err("bad options must fail");

    assert_eq!(error.kind(), Kind::InvalidArgument);
    assert!(!error.message().is_empty());
    assert!(!error.is_retryable());
}

#[test]
fn empty_url_fails() {
    let client = Client::new("{}").expect("default options must be accepted");

    for url in ["", "   ".trim()] {
        let error = client.extract(url).expect_err("empty url must fail");

        assert_eq!(error.kind(), Kind::InvalidArgument);
        assert!(!error.message().is_empty());
        assert!(!error.is_retryable());
    }
}

#[test]
fn nul_byte_url_fails_without_crashing() {
    let client = Client::new("{}").expect("default options must be accepted");

    let error = client
        .extract("https://example.org/watch?v=abc\0def")
        .expect_err("NUL byte url must fail");

    assert_eq!(error.kind(), Kind::InvalidArgument);
    assert!(!error.message().is_empty());
}

#[test]
fn create_drop_loop() {
    for _ in 0..200 {
        let client = Client::new("{}").expect("context creation must succeed");

        std::hint::black_box(&client);
    }
}

#[test]
fn runtime_info_reports_diagnostics() {
    let info = runtime_info().expect("runtime info must succeed");

    assert_eq!(info.abi(), abi_version());
    assert_eq!(info.backend(), "embedded");
    assert!(!info.profile().is_empty());
    assert!(!info.python().is_empty());
    assert!(!info.ytdlp_version().is_empty());
    assert!(!info.ytdlp_commit().is_empty());
    assert!(!info.raw_json().is_empty());
    assert!(info.value().is_object());

    let ffmpeg_some = info.ffmpeg().is_some();
    let ffprobe_some = info.ffprobe().is_some();

    assert_eq!(
        ffmpeg_some,
        info.value()
            .get("ffmpeg")
            .and_then(serde_json::Value::as_str)
            .is_some()
    );
    assert_eq!(
        ffprobe_some,
        info.value()
            .get("ffprobe")
            .and_then(serde_json::Value::as_str)
            .is_some()
    );

    if let Some(path) = info.ffmpeg() {
        assert!(!path.is_empty());
    }

    if let Some(path) = info.ffprobe() {
        assert!(!path.is_empty());
    }
}

#[test]
fn error_display_reports_kind_and_message() {
    let client = Client::new("{not json").expect("options are stored verbatim");

    let error = client
        .extract("ly-bogus://example")
        .expect_err("bad options must fail");

    let shown = error.to_string();

    assert!(shown.contains("invalid argument"), "display was: {shown}");
    assert!(shown.contains(error.message()), "display was: {shown}");

    let debug = format!("{error:?}");

    assert!(debug.contains("InvalidArgument"), "debug was: {debug}");
}

#[test]
fn builder_assembles_typed_options() {
    let client = ClientBuilder::new()
        .options_json(r#"{"quiet": true, "retries": 1}"#)
        .expect("object options must merge")
        .options_json(r#"{"retries": 3}"#)
        .expect("later keys must win")
        .option("socket_timeout", serde_json::json!(5))
        .extractor_args("generic", serde_json::json!({"force": [true]}))
        .extractor_args("other", serde_json::json!({"x": [1]}))
        .build()
        .expect("builder must produce a client");

    let error = client
        .extract("ly-bogus://no-such-scheme-xyz")
        .expect_err("bogus url must fail");

    assert!(!error.message().is_empty());
}

#[test]
fn builder_rejects_non_object_options() {
    let failed = ClientBuilder::new().options_json("[1, 2]");

    assert!(failed.is_err());

    let failed = ClientBuilder::new().options_json("{not json}");

    assert!(failed.is_err());
}

#[test]
fn builder_default_matches_empty_options() {
    let _ = ClientBuilder::default()
        .build()
        .expect("default builder must succeed");
}

#[test]
fn player_client_retry_order_starts_with_default() {
    assert_eq!(PlayerClient::RETRY_ORDER.len(), 5);
    assert_eq!(PlayerClient::RETRY_ORDER[0], PlayerClient::Default);
    assert_eq!(
        PlayerClient::RETRY_ORDER,
        [
            PlayerClient::Default,
            PlayerClient::Android,
            PlayerClient::TvEmbedded,
            PlayerClient::Ios,
            PlayerClient::Web,
        ]
    );
}

#[test]
fn player_client_wire_names_match_upstream() {
    assert_eq!(PlayerClient::Default.as_str(), "default");
    assert_eq!(PlayerClient::Android.as_str(), "android");
    assert_eq!(PlayerClient::TvEmbedded.as_str(), "web_embedded");
    assert_eq!(PlayerClient::Ios.as_str(), "ios");
    assert_eq!(PlayerClient::Web.as_str(), "web");
}

#[test]
fn player_client_options_serialize_per_entry() {
    assert_eq!(player_client_options([PlayerClient::Default]), ["{}"]);

    let options = player_client_options([PlayerClient::Ios]);

    assert_eq!(options.len(), 1);
    assert!(
        options[0].contains("\"ios\""),
        "options were: {}",
        options[0]
    );

    let parsed: serde_json::Value =
        serde_json::from_str(&options[0]).expect("options must be JSON");

    assert_eq!(
        parsed,
        serde_json::json!({
            "extractor_args": {
                "youtube": {
                    "player_client": ["ios"],
                },
            },
        })
    );

    let ordered = player_client_options(PlayerClient::RETRY_ORDER);

    assert_eq!(ordered.len(), 5);
    assert_eq!(ordered[0], "{}");
    assert!(
        ordered[3].contains("\"ios\""),
        "options were: {}",
        ordered[3]
    );
}

#[test]
fn youtube_player_client_reaches_youtube_dl() {
    let server = FixtureServer::start(65536, 8_000_000);
    let url = server.url("/file.bin");

    let client = Client::builder()
        .youtube_player_client(PlayerClient::Ios)
        .build()
        .expect("builder must succeed");

    let info = client
        .extract(&url)
        .expect("ios override in context must keep generic extract working");

    assert_eq!(info.id(), Some("file"));

    let plain = Client::new("{}").expect("default options must be accepted");
    let per_call = player_client_options([PlayerClient::Ios]);

    assert_eq!(per_call.len(), 1);

    let info = plain
        .extract_with_options(&url, &per_call[0])
        .expect("ios override per call must keep generic extract working");

    assert_eq!(info.id(), Some("file"));
}

#[test]
fn extract_with_options_rejects_nul_bytes() {
    let client = Client::new("{}").expect("default options must be accepted");

    let error = client
        .extract_with_options("https://example.org/x\0y", "{}")
        .expect_err("NUL url must fail");

    assert_eq!(error.kind(), Kind::InvalidArgument);

    let error = client
        .extract_with_options("ly-bogus://example", "{}\0")
        .expect_err("NUL options must fail");

    assert_eq!(error.kind(), Kind::InvalidArgument);
}

#[test]
fn extract_with_options_rejects_non_object_call_options() {
    let client = Client::new("{}").expect("default options must be accepted");

    let error = client
        .extract_with_options("ly-bogus://example", "[1, 2]")
        .expect_err("non-object call options must fail");

    assert_eq!(error.kind(), Kind::InvalidArgument);
}

#[test]
fn extract_with_options_merges_over_context() {
    let server = FixtureServer::start(65536, 8_000_000);
    let url = server.url("/file.bin");

    let client = ClientBuilder::new()
        .options_json(r#"{"skip_download": true}"#)
        .expect("context options must merge")
        .build()
        .expect("builder must succeed");

    let info = client
        .extract_with_options(&url, "{}")
        .expect("empty call options must keep context");

    assert_eq!(info.id(), Some("file"));

    let info = client
        .extract_with_options(
            &url,
            r#"{"extractor_args": {"generic": {"force": [true]}}}"#,
        )
        .expect("call extractor args must reach YoutubeDL");

    assert_eq!(info.id(), Some("file"));

    let plain = Client::new("{}").expect("default options must be accepted");
    let info = plain.extract(&url).expect("context extract must work");

    assert_eq!(info.id(), Some("file"));
    assert!(info.entries().is_empty());
    assert!(info.formats().iter().all(|format| format.url().is_some()));
}