medias 0.3.3

TV media player on bare DRM/KMS: Vulkan UI plane over libmpv video, for local files, UPnP/DLNA servers and YouTube, controlled by HDMI-CEC remote, evdev or terminal
//! YouTube Data API v3 response models and pure parsers.
//!
//! Field layout follows the Data API v3 `snippet`/`contentDetails`/`statistics`
//! parts. Parsers take response text so they run offline under test; the
//! transport lives in `api`.

use serde::Deserialize;

/// Thumbnail URLs offered for one item (empty string when a size is absent).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Thumbs {
    pub default: String,
    pub medium: String,
    pub high: String,
}

impl Thumbs {
    /// Thumbnail for the 480x270 atlas slot: medium (channels 240x240, playlists
    /// 320x180), then high, then default.
    pub fn list(&self) -> &str {
        [&self.medium, &self.high, &self.default]
            .into_iter()
            .find(|u| !u.is_empty())
            .map(String::as_str)
            .unwrap_or("")
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Video {
    pub id: String,
    pub title: String,
    pub channel: String,
    pub duration: u32, // seconds; 0 when unknown (e.g. live)
    pub thumbs: Thumbs,
}

impl Video {
    pub fn watch_url(&self) -> String {
        format!("https://www.youtube.com/watch?v={}", self.id)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Channel {
    pub id: String,
    pub title: String,
    pub thumbs: Thumbs,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Playlist {
    pub id: String,
    pub title: String,
    pub count: u32,
    pub thumbs: Thumbs,
}

/// Own-channel identity plus the auto-generated uploads playlist id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mine {
    pub id: String,
    pub title: String,
    pub uploads: String,
}

/// One page of results: items plus the token for the following page, if any.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Page<T> {
    pub items: Vec<T>,
    pub next: Option<String>,
}

// ---- raw deserialization shapes -------------------------------------------

#[derive(Deserialize, Default)]
struct RawThumb {
    #[serde(default)]
    url: String,
}

#[derive(Deserialize, Default)]
struct RawThumbs {
    #[serde(default)]
    default: RawThumb,
    #[serde(default)]
    medium: RawThumb,
    #[serde(default)]
    high: RawThumb,
}

impl From<RawThumbs> for Thumbs {
    fn from(r: RawThumbs) -> Self {
        Thumbs { default: r.default.url, medium: r.medium.url, high: r.high.url }
    }
}

#[derive(Deserialize, Default)]
struct Snippet {
    #[serde(default)]
    title: String,
    #[serde(default, rename = "channelTitle")]
    channel_title: String,
    #[serde(default, rename = "videoOwnerChannelTitle")]
    owner_channel_title: String,
    #[serde(default)]
    thumbnails: RawThumbs,
    #[serde(default, rename = "resourceId")]
    resource_id: Option<ResourceId>,
}

#[derive(Deserialize, Default)]
struct ResourceId {
    #[serde(default, rename = "channelId")]
    channel_id: String,
}

#[derive(Deserialize, Default)]
struct ContentDetails {
    #[serde(default)]
    duration: String,
    #[serde(default, rename = "videoId")]
    video_id: String,
    #[serde(default, rename = "itemCount")]
    item_count: u32,
    #[serde(default, rename = "relatedPlaylists")]
    related: Option<RelatedPlaylists>,
}

#[derive(Deserialize, Default)]
struct RelatedPlaylists {
    #[serde(default)]
    uploads: String,
}

/// A search.list `id` is an object; videos.list/playlists.list `id` is a string.
#[derive(Deserialize)]
#[serde(untagged)]
enum Id {
    String(String),
    Object {
        #[serde(default, rename = "videoId")]
        video_id: String,
        #[serde(default, rename = "channelId")]
        channel_id: String,
        #[serde(default, rename = "playlistId")]
        playlist_id: String,
    },
}

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

#[derive(Deserialize, Default)]
struct Item {
    #[serde(default)]
    id: Id,
    #[serde(default)]
    snippet: Snippet,
    #[serde(default, rename = "contentDetails")]
    content_details: ContentDetails,
}

#[derive(Deserialize, Default)]
struct ListResponse {
    #[serde(default)]
    items: Vec<Item>,
    #[serde(default, rename = "nextPageToken")]
    next_page_token: Option<String>,
    #[serde(default)]
    error: Option<ApiError>,
}

#[derive(Deserialize, Default)]
struct ApiError {
    #[serde(default)]
    code: u32,
    #[serde(default)]
    message: String,
}

fn decode(body: &str) -> Result<ListResponse, String> {
    let resp: ListResponse = serde_json::from_str(body).map_err(|e| format!("json: {e}"))?;
    if let Some(err) = &resp.error {
        return Err(format!("api {}: {}", err.code, err.message));
    }
    Ok(resp)
}

// ---- public parsers --------------------------------------------------------

/// Parse search.list (type=video) or any list whose items carry a video id.
pub fn videos(body: &str) -> Result<Page<Video>, String> {
    let resp = decode(body)?;
    let items = resp
        .items
        .into_iter()
        .filter_map(video_from_item)
        .collect();
    Ok(Page { items, next: resp.next_page_token })
}

fn video_from_item(item: Item) -> Option<Video> {
    // Video id appears in different places across endpoints: the search `id`
    // object, playlistItems `contentDetails`, or the videos.list `id` string.
    let id = match &item.id {
        Id::Object { video_id, .. } if !video_id.is_empty() => video_id.clone(),
        Id::String(s) if !s.is_empty() && item.content_details.video_id.is_empty() => s.clone(),
        _ => item.content_details.video_id.clone(),
    };
    if id.is_empty() {
        return None;
    }
    let channel = if item.snippet.channel_title.is_empty() {
        item.snippet.owner_channel_title
    } else {
        item.snippet.channel_title
    };
    Some(Video {
        id,
        title: item.snippet.title,
        channel,
        duration: parse_duration(&item.content_details.duration),
        thumbs: item.snippet.thumbnails.into(),
    })
}

/// Parse subscriptions.list into channels.
pub fn subscriptions(body: &str) -> Result<Page<Channel>, String> {
    let resp = decode(body)?;
    let items = resp
        .items
        .into_iter()
        .filter_map(|item| {
            let id = item.snippet.resource_id.map(|r| r.channel_id).unwrap_or_default();
            (!id.is_empty()).then(|| Channel {
                id,
                title: item.snippet.title,
                thumbs: item.snippet.thumbnails.into(),
            })
        })
        .collect();
    Ok(Page { items, next: resp.next_page_token })
}

/// Parse playlists.list.
pub fn playlists(body: &str) -> Result<Page<Playlist>, String> {
    let resp = decode(body)?;
    let items = resp
        .items
        .into_iter()
        .filter_map(|item| {
            let id = match &item.id {
                Id::String(s) => s.clone(),
                Id::Object { playlist_id, .. } => playlist_id.clone(),
            };
            (!id.is_empty()).then(|| Playlist {
                id,
                title: item.snippet.title,
                count: item.content_details.item_count,
                thumbs: item.snippet.thumbnails.into(),
            })
        })
        .collect();
    Ok(Page { items, next: resp.next_page_token })
}

/// Parse channels.list?mine=true (identity + uploads playlist).
pub fn mine(body: &str) -> Result<Mine, String> {
    let resp = decode(body)?;
    let item = resp.items.into_iter().next().ok_or("no channel for account")?;
    let uploads = item
        .content_details
        .related
        .map(|r| r.uploads)
        .unwrap_or_default();
    let id = match item.id {
        Id::String(s) => s,
        Id::Object { channel_id, .. } => channel_id,
    };
    Ok(Mine { id, title: item.snippet.title, uploads })
}

/// Parse an ISO-8601 duration ("PT1H2M3S") into whole seconds. Unknown or
/// non-time ("P0D") forms yield 0.
pub fn parse_duration(iso: &str) -> u32 {
    let Some(rest) = iso.strip_prefix("PT") else {
        return 0;
    };
    let mut seconds = 0u32;
    let mut value = 0u32;
    for ch in rest.chars() {
        match ch {
            '0'..='9' => value = value.saturating_mul(10).saturating_add(ch as u32 - '0' as u32),
            'H' => {
                seconds = seconds.saturating_add(value.saturating_mul(3600));
                value = 0;
            }
            'M' => {
                seconds = seconds.saturating_add(value.saturating_mul(60));
                value = 0;
            }
            'S' => {
                seconds = seconds.saturating_add(value);
                value = 0;
            }
            _ => return 0,
        }
    }
    seconds
}

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

    #[test]
    fn durations_cover_hours_minutes_seconds() {
        assert_eq!(parse_duration("PT1H2M3S"), 3723);
        assert_eq!(parse_duration("PT4M13S"), 253);
        assert_eq!(parse_duration("PT45S"), 45);
        assert_eq!(parse_duration("PT2H"), 7200);
        assert_eq!(parse_duration("P0D"), 0);
        assert_eq!(parse_duration(""), 0);
    }

    #[test]
    fn search_items_become_videos_with_watch_urls() {
        let body = r#"{"items":[
            {"id":{"kind":"youtube#video","videoId":"abc123"},
             "snippet":{"title":"First","channelTitle":"Chan",
               "thumbnails":{"medium":{"url":"https://i/mq.jpg"},"high":{"url":"https://i/hq.jpg"}}}},
            {"id":{"kind":"youtube#channel","channelId":"UCxxxx"},
             "snippet":{"title":"a channel"}}
        ],"nextPageToken":"NEXT"}"#;
        let page = videos(body).unwrap();
        assert_eq!(page.items.len(), 1); // channel result dropped (no videoId)
        assert_eq!(page.items[0].id, "abc123");
        assert_eq!(page.items[0].title, "First");
        assert_eq!(page.items[0].channel, "Chan");
        assert_eq!(page.items[0].watch_url(), "https://www.youtube.com/watch?v=abc123");
        assert_eq!(page.items[0].thumbs.list(), "https://i/mq.jpg");
        assert_eq!(page.next.as_deref(), Some("NEXT"));
    }

    #[test]
    fn playlist_items_use_content_details_video_id() {
        let body = r#"{"items":[
            {"snippet":{"title":"Song","videoOwnerChannelTitle":"Artist"},
             "contentDetails":{"videoId":"vid9","duration":"PT3M"}}
        ]}"#;
        let page = videos(body).unwrap();
        assert_eq!(page.items[0].id, "vid9");
        assert_eq!(page.items[0].channel, "Artist");
        assert_eq!(page.items[0].duration, 180);
        assert_eq!(page.next, None);
    }

    #[test]
    fn subscriptions_read_resource_channel_id() {
        let body = r#"{"items":[
            {"snippet":{"title":"Cool Channel","resourceId":{"channelId":"UC777"},
              "thumbnails":{"default":{"url":"https://i/ch.jpg"}}}},
            {"snippet":{"title":"no id"}}
        ]}"#;
        let page = subscriptions(body).unwrap();
        assert_eq!(page.items.len(), 1);
        assert_eq!(page.items[0].id, "UC777");
        assert_eq!(page.items[0].thumbs.list(), "https://i/ch.jpg");
    }

    #[test]
    fn playlists_carry_item_count() {
        let body = r#"{"items":[
            {"id":"PL1","snippet":{"title":"Faves"},"contentDetails":{"itemCount":12}}
        ]}"#;
        let page = playlists(body).unwrap();
        assert_eq!((page.items[0].id.as_str(), page.items[0].count), ("PL1", 12));
    }

    #[test]
    fn mine_extracts_uploads_playlist() {
        let body = r#"{"items":[
            {"id":"UCme","snippet":{"title":"My Account"},
             "contentDetails":{"relatedPlaylists":{"uploads":"UUme"}}}
        ]}"#;
        let m = mine(body).unwrap();
        assert_eq!((m.id.as_str(), m.title.as_str(), m.uploads.as_str()), ("UCme", "My Account", "UUme"));
    }

    #[test]
    fn api_error_body_is_surfaced() {
        let body = r#"{"error":{"code":403,"message":"quotaExceeded"}}"#;
        assert_eq!(videos(body).unwrap_err(), "api 403: quotaExceeded");
    }
}