nab 0.7.1

Token-optimized HTTP client for LLMs — fetches any URL as clean markdown
Documentation
//! DR (Danish) streaming provider

use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;

use super::common::{last_path_segment_without_query, segment_after};
use crate::stream::provider::{EpisodeInfo, SeriesInfo, StreamInfo, StreamProvider};

const DR_MU_API_BASE: &str = "https://www.dr.dk/mu-online/api/1.4";
const DR_TOKEN_API: &str = "https://www.dr.dk/mu-online/api/1.4/bar";

pub struct DrProvider {
    client: Client,
}

impl DrProvider {
    pub fn new() -> Result<Self> {
        let client = Client::builder().user_agent("nab/1.0").build()?;
        Ok(Self { client })
    }

    /// Extract program URN from URL or return as-is if already an ID
    /// URLs: <https://www.dr.dk/drtv/episode/gintberg-til-gaes_363891>
    /// URLs: <https://www.dr.dk/drtv/se/gintberg-til-gaes_363891>
    fn extract_program_id(url_or_id: &str) -> String {
        if url_or_id.starts_with("http") {
            // Extract the ID with underscore (slug_id format)
            // Return the full slug_id for MU API lookup
            last_path_segment_without_query(url_or_id)
                .unwrap_or(url_or_id)
                .to_string()
        } else {
            url_or_id.to_string()
        }
    }

    /// Extract series slug from URL
    fn extract_series_slug(url_or_id: &str) -> String {
        if url_or_id.starts_with("http") {
            // For series URLs: /drtv/serie/gintberg-til-gaes_123456
            segment_after(url_or_id, "serie")
                .or_else(|| last_path_segment_without_query(url_or_id))
                .unwrap_or(url_or_id)
                .to_string()
        } else {
            url_or_id.to_string()
        }
    }

    /// Get the numeric ID from a `slug_id` format
    fn get_numeric_id(slug_id: &str) -> String {
        // Format: title-slug_12345
        if let Some(pos) = slug_id.rfind('_') {
            slug_id[pos + 1..].to_string()
        } else {
            slug_id.to_string()
        }
    }

    async fn fetch_program_card(&self, product_number: &str) -> Result<DrProgramCard> {
        let url = format!("{DR_MU_API_BASE}/programcard/{product_number}");

        let resp = self
            .client
            .get(&url)
            .header("Accept", "application/json")
            .send()
            .await
            .with_context(|| format!("DR MU API request failed for {product_number}"))?;

        if !resp.status().is_success() {
            return Err(anyhow!(
                "DR MU API error: {} for program {}",
                resp.status(),
                product_number
            ));
        }

        resp.json()
            .await
            .context("Failed to parse DR program card response")
    }

    async fn fetch_manifest(&self, program_id: &str) -> Result<DrManifestResponse> {
        // First get an anonymous token (best-effort, not required)
        let token_resp = self
            .client
            .get(DR_TOKEN_API)
            .send()
            .await
            .context("DR token API request failed")?;

        let _token: Option<String> = if token_resp.status().is_success() {
            token_resp.json().await.ok()
        } else {
            None
        };

        // Fetch the manifest
        let url = format!("{DR_MU_API_BASE}/programcard/{program_id}/manifest");

        let resp = self
            .client
            .get(&url)
            .header("Accept", "application/json")
            .send()
            .await
            .with_context(|| format!("DR manifest API request failed for {program_id}"))?;

        if !resp.status().is_success() {
            return Err(anyhow!(
                "DR Manifest API error: {} for program {}",
                resp.status(),
                program_id
            ));
        }

        resp.json()
            .await
            .context("Failed to parse DR manifest response")
    }

    async fn fetch_series(&self, series_slug: &str) -> Result<DrSeriesResponse> {
        let numeric_id = Self::get_numeric_id(series_slug);
        let url = format!("{DR_MU_API_BASE}/series/{numeric_id}");

        let resp = self
            .client
            .get(&url)
            .header("Accept", "application/json")
            .send()
            .await
            .with_context(|| format!("DR series API request failed for {series_slug}"))?;

        if !resp.status().is_success() {
            return Err(anyhow!(
                "DR Series API error: {} for series {}",
                resp.status(),
                series_slug
            ));
        }

        resp.json()
            .await
            .context("Failed to parse DR series response")
    }
}

impl Default for DrProvider {
    fn default() -> Self {
        Self::new().expect("Failed to create DrProvider")
    }
}

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

    fn matches(&self, url: &str) -> bool {
        url.contains("dr.dk/drtv") || url.contains("dr.dk/tv")
    }

    async fn get_stream_info(&self, id: &str) -> Result<StreamInfo> {
        let slug_id = Self::extract_program_id(id);
        let product_number = Self::get_numeric_id(&slug_id);

        // Fetch program card for metadata
        let program = self.fetch_program_card(&product_number).await?;

        // Fetch manifest for streaming URL
        let manifest = self.fetch_manifest(&product_number).await?;

        // Find HLS manifest URL
        let manifest_url = manifest
            .links
            .iter()
            .find(|l| l.target == "HLS")
            .map(|l| l.uri.clone())
            .ok_or_else(|| anyhow!("No HLS manifest found"))?;

        let duration = program
            .primary_asset
            .as_ref()
            .and_then(|a| a.duration_in_milliseconds)
            .map(|ms| ms / 1000);

        let thumbnail_url = program.primary_image_uri.map(|uri| {
            // DR image URLs need resolution suffix
            format!("{}/{}x{}", uri, 960, 540)
        });

        let is_live = program
            .primary_asset
            .as_ref()
            .is_some_and(|a| a.kind == "VideoLive");

        Ok(StreamInfo {
            id: product_number,
            title: program.title,
            description: program.description,
            duration_seconds: duration,
            manifest_url,
            is_live,
            qualities: vec![],
            thumbnail_url,
        })
    }

    async fn list_series(&self, series_id: &str) -> Result<SeriesInfo> {
        let slug = Self::extract_series_slug(series_id);
        let series = self.fetch_series(&slug).await?;

        let episodes = series
            .episodes
            .unwrap_or_default()
            .into_iter()
            .map(|ep| {
                let duration = ep
                    .primary_asset
                    .as_ref()
                    .and_then(|a| a.duration_in_milliseconds)
                    .map(|ms| ms / 1000);

                EpisodeInfo {
                    id: ep.product_number,
                    title: ep.title,
                    // Sign loss acceptable: episode/season numbers are non-negative
                    #[allow(clippy::cast_sign_loss)]
                    episode_number: ep.episode_number.map(|n| n as u32),
                    #[allow(clippy::cast_sign_loss)]
                    season_number: ep.season_number.map(|n| n as u32),
                    duration_seconds: duration,
                    publish_date: ep.primary_broadcast_date,
                }
            })
            .collect();

        Ok(SeriesInfo {
            id: slug,
            title: series.title,
            episodes,
        })
    }
}

// Serde structures for DR API responses

#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DrProgramCard {
    #[allow(dead_code)]
    product_number: String,
    title: String,
    description: Option<String>,
    primary_asset: Option<DrAsset>,
    primary_image_uri: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DrAsset {
    #[allow(dead_code)]
    kind: String,
    duration_in_milliseconds: Option<u64>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DrManifestResponse {
    links: Vec<DrLink>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DrLink {
    uri: String,
    target: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DrSeriesResponse {
    title: String,
    episodes: Option<Vec<DrEpisode>>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DrEpisode {
    product_number: String,
    title: String,
    episode_number: Option<i32>,
    season_number: Option<i32>,
    primary_asset: Option<DrAsset>,
    primary_broadcast_date: Option<String>,
}

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

    #[test]
    fn test_extract_program_id() {
        assert_eq!(DrProvider::extract_program_id("363891"), "363891");
        assert_eq!(
            DrProvider::extract_program_id(
                "https://www.dr.dk/drtv/episode/gintberg-til-gaes_363891"
            ),
            "gintberg-til-gaes_363891"
        );
        assert_eq!(
            DrProvider::extract_program_id("https://www.dr.dk/drtv/se/gintberg-til-gaes_363891"),
            "gintberg-til-gaes_363891"
        );
    }

    #[test]
    fn test_get_numeric_id() {
        assert_eq!(
            DrProvider::get_numeric_id("gintberg-til-gaes_363891"),
            "363891"
        );
        assert_eq!(DrProvider::get_numeric_id("363891"), "363891");
    }

    #[test]
    fn test_extract_series_slug() {
        assert_eq!(
            DrProvider::extract_series_slug(
                "https://www.dr.dk/drtv/serie/gintberg-til-gaes_123456"
            ),
            "gintberg-til-gaes_123456"
        );
        assert_eq!(
            DrProvider::extract_series_slug(
                "https://www.dr.dk/drtv/serie/gintberg-til-gaes_123456?foo=bar"
            ),
            "gintberg-til-gaes_123456"
        );
        assert_eq!(
            DrProvider::extract_series_slug(
                "https://www.dr.dk/drtv/gintberg-til-gaes_123456?foo=bar"
            ),
            "gintberg-til-gaes_123456"
        );
    }

    #[test]
    fn test_matches() {
        let provider = DrProvider::default();
        assert!(provider.matches("https://www.dr.dk/drtv/episode/test_123"));
        assert!(provider.matches("https://dr.dk/tv/live"));
        assert!(!provider.matches("https://example.com"));
    }
}