libytdlp 0.6.1

Safe Rust wrapper around the libytdlp C ABI
Documentation
use std::fmt;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;

use tracing::Level;
use tracing::Metadata;
use tracing::Subscriber;
use tracing::field::Field;
use tracing::field::Visit;
use tracing::span::Attributes;
use tracing::span::Id;
use tracing::span::Record;

struct RecordedEvent {
    level: Level,
    kind: Option<String>,
}

struct EventFields {
    kind: Option<String>,
}

impl Visit for EventFields {
    fn record_str(&mut self, field: &Field, value: &str) {
        if field.name() == "kind" {
            self.kind = Some(value.to_owned());
        }
    }

    fn record_debug(&mut self, _field: &Field, _value: &dyn fmt::Debug) {}
}

struct Inner {
    next_id: AtomicU64,
    events: Mutex<Vec<RecordedEvent>>,
}

#[derive(Clone)]
struct RecordingSubscriber {
    inner: Arc<Inner>,
}

impl RecordingSubscriber {
    fn install() -> Self {
        static CELL: OnceLock<RecordingSubscriber> = OnceLock::new();

        CELL.get_or_init(|| {
            let subscriber = RecordingSubscriber {
                inner: Arc::new(Inner {
                    next_id: AtomicU64::new(1),
                    events: Mutex::new(Vec::new()),
                }),
            };

            let _ = tracing::dispatcher::set_global_default(tracing::dispatcher::Dispatch::new(
                subscriber.clone(),
            ));

            subscriber
        })
        .clone()
    }

    fn clear(&self) {
        self.inner.events.lock().expect("events lock").clear();
    }

    fn kinds(&self) -> Vec<(Level, Option<String>)> {
        self.inner
            .events
            .lock()
            .expect("events lock")
            .iter()
            .map(|event| (event.level, event.kind.clone()))
            .collect()
    }
}

impl Subscriber for RecordingSubscriber {
    fn enabled(&self, metadata: &Metadata<'_>) -> bool {
        metadata.target().starts_with("libytdlp")
    }

    fn new_span(&self, _span: &Attributes<'_>) -> Id {
        Id::from_u64(self.inner.next_id.fetch_add(1, Ordering::Relaxed))
    }

    fn record(&self, _span: &Id, _values: &Record<'_>) {}

    fn record_follows_from(&self, _span: &Id, _follows: &Id) {}

    fn enter(&self, _span: &Id) {}

    fn exit(&self, _span: &Id) {}

    fn drop_span(&self, _id: Id) {}

    fn event(&self, event: &tracing::Event<'_>) {
        if !self.enabled(event.metadata()) {
            return;
        }

        let mut fields = EventFields { kind: None };
        event.record(&mut fields);

        self.inner
            .events
            .lock()
            .expect("events lock")
            .push(RecordedEvent {
                level: *event.metadata().level(),
                kind: fields.kind,
            });
    }

    unsafe fn downcast_raw(&self, id: std::any::TypeId) -> Option<*const ()> {
        if id == std::any::TypeId::of::<Self>() {
            Some(self as *const Self as *const ())
        } else {
            None
        }
    }
}

#[test]
fn failed_extract_emits_kind_only() {
    let subscriber = RecordingSubscriber::install();
    subscriber.clear();

    let client = libytdlp::Client::new("{}").expect("default options must be accepted");

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

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

    let recorded = subscriber.kinds();

    assert!(
        recorded
            .iter()
            .any(|(level, kind)| *level == Level::DEBUG && kind.as_deref() == Some("extract")),
        "no extract failure event recorded: {recorded:?}"
    );
}