nab 0.7.1

Token-optimized HTTP client for LLMs — fetches any URL as clean markdown
Documentation
//! Generic HLS/DASH provider for direct manifest URLs.
//!
//! This provider matches any URL ending in `.m3u8` or `.mpd` and
//! returns it verbatim as the manifest URL. It does not perform any
//! API calls -- the manifest URL is used directly by the backends.

use anyhow::{Result, anyhow};
use async_trait::async_trait;

use super::common::strip_query;
use crate::stream::provider::{SeriesInfo, StreamInfo, StreamProvider};

/// Provider for direct HLS `.m3u8` and DASH `.mpd` URLs.
pub struct GenericHlsProvider;

impl GenericHlsProvider {
    #[must_use]
    pub fn new() -> Self {
        Self
    }
}

impl Default for GenericHlsProvider {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl StreamProvider for GenericHlsProvider {
    fn name(&self) -> &'static str {
        "generic"
    }

    fn matches(&self, url: &str) -> bool {
        // Strip query params for extension check
        let path = strip_query(url);
        std::path::Path::new(path)
            .extension()
            .is_some_and(|e| e.eq_ignore_ascii_case("m3u8") || e.eq_ignore_ascii_case("mpd"))
    }

    async fn get_stream_info(&self, url: &str) -> Result<StreamInfo> {
        Ok(StreamInfo {
            id: url.to_string(),
            title: "Direct Stream".to_string(),
            description: None,
            duration_seconds: None,
            manifest_url: url.to_string(),
            is_live: false, // Could detect from manifest
            qualities: vec![],
            thumbnail_url: None,
        })
    }

    async fn list_series(&self, _series_id: &str) -> Result<SeriesInfo> {
        Err(anyhow!("Generic provider does not support series listing"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_matches_m3u8() {
        let provider = GenericHlsProvider::new();
        assert!(provider.matches("https://example.com/stream.m3u8"));
        assert!(!provider.matches("https://example.com/stream.mp4"));
    }

    #[test]
    fn test_matches_mpd() {
        let provider = GenericHlsProvider::new();
        assert!(provider.matches("https://example.com/stream.mpd"));
        assert!(!provider.matches("https://example.com"));
    }

    #[test]
    fn test_does_not_match_ordinary_urls() {
        let provider = GenericHlsProvider::new();
        assert!(!provider.matches("https://example.com/page.html"));
        assert!(!provider.matches("https://example.com/video.ts"));
        assert!(!provider.matches("https://example.com"));
    }

    #[test]
    fn test_name() {
        let provider = GenericHlsProvider::new();
        assert_eq!(provider.name(), "generic");
    }

    #[tokio::test]
    async fn test_get_stream_info() {
        let provider = GenericHlsProvider::new();
        let info = provider
            .get_stream_info("https://example.com/stream.m3u8")
            .await
            .unwrap();
        assert_eq!(info.manifest_url, "https://example.com/stream.m3u8");
        assert_eq!(info.title, "Direct Stream");
        assert!(!info.is_live);
        assert!(info.qualities.is_empty());
    }

    #[tokio::test]
    async fn test_get_stream_info_uses_url_as_id() {
        let provider = GenericHlsProvider::new();
        let info = provider
            .get_stream_info("https://cdn.example.com/live.mpd")
            .await
            .unwrap();
        assert_eq!(info.id, "https://cdn.example.com/live.mpd");
    }

    #[tokio::test]
    async fn test_list_series_unsupported() {
        let provider = GenericHlsProvider::new();
        let result = provider.list_series("test").await;
        assert!(result.is_err());
    }

    #[test]
    fn test_default() {
        let provider = GenericHlsProvider;
        assert_eq!(provider.name(), "generic");
    }
}