medias 0.3.2

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 transport over ureq (rustls).
//!
//! Account calls (`mine`, `subscriptions`, `playlists`, own uploads) need an
//! OAuth bearer token; public calls (`search`, `trending`) accept an API key.
//! Without either the Data API is unavailable and the caller falls back to
//! yt-dlp (see `youtube::resolve`). Responses are disk-cached with a TTL to stay
//! under the daily quota (10k units; search costs 100).

use std::path::PathBuf;
use std::time::Duration;

use crate::youtube::model::{self, Channel, Mine, Playlist, Video};

const BASE: &str = "https://www.googleapis.com/youtube/v3";
pub const PAGE_SIZE: u32 = 25;

pub struct Client {
    agent: ureq::Agent,
    token: Option<String>,
    key: Option<String>,
}

impl Client {
    pub fn new(token: Option<String>, key: Option<String>) -> Client {
        // Disable ureq's status-as-error so non-2xx bodies (which carry the API
        // error JSON) are parsed by `model::decode` instead of being discarded.
        let agent = ureq::Agent::config_builder()
            .http_status_as_error(false)
            .timeout_global(Some(Duration::from_secs(20)))
            .build()
            .into();
        Client { agent, token, key }
    }

    /// True when Data API calls are possible (signed in or an API key is set).
    pub fn usable(&self) -> bool {
        self.token.is_some() || self.key.is_some()
    }

    pub fn search(&self, query: &str) -> Result<Vec<Video>, String> {
        let n = PAGE_SIZE.to_string();
        let body = self.get_cached(
            "search",
            &[("part", "snippet"), ("type", "video"), ("maxResults", &n), ("q", query)],
            Duration::from_secs(600),
        )?;
        Ok(model::videos(&body)?.items)
    }

    pub fn trending(&self) -> Result<Vec<Video>, String> {
        let n = PAGE_SIZE.to_string();
        let body = self.get_cached(
            "videos",
            &[
                ("part", "snippet,contentDetails"),
                ("chart", "mostPopular"),
                ("maxResults", &n),
            ],
            Duration::from_secs(1800),
        )?;
        Ok(model::videos(&body)?.items)
    }

    /// Latest uploads for a channel via its auto-generated uploads playlist.
    pub fn channel_uploads(&self, channel_id: &str) -> Result<Vec<Video>, String> {
        self.playlist_videos(&uploads_playlist_id(channel_id))
    }

    pub fn playlist_videos(&self, playlist_id: &str) -> Result<Vec<Video>, String> {
        let n = PAGE_SIZE.to_string();
        let body = self.get_cached(
            "playlistItems",
            &[
                ("part", "snippet,contentDetails"),
                ("playlistId", playlist_id),
                ("maxResults", &n),
            ],
            Duration::from_secs(900),
        )?;
        Ok(model::videos(&body)?.items)
    }

    pub fn subscriptions(&self) -> Result<Vec<Channel>, String> {
        let n = PAGE_SIZE.to_string();
        let body = self.get_cached(
            "subscriptions",
            &[
                ("part", "snippet"),
                ("mine", "true"),
                ("order", "alphabetical"),
                ("maxResults", &n),
            ],
            Duration::from_secs(1800),
        )?;
        Ok(model::subscriptions(&body)?.items)
    }

    pub fn playlists(&self) -> Result<Vec<Playlist>, String> {
        let n = PAGE_SIZE.to_string();
        let body = self.get_cached(
            "playlists",
            &[("part", "snippet,contentDetails"), ("mine", "true"), ("maxResults", &n)],
            Duration::from_secs(1800),
        )?;
        Ok(model::playlists(&body)?.items)
    }

    /// Own channel identity and uploads playlist; also the liked-videos playlist
    /// id (`LL`) is a fixed constant, so callers use that directly.
    pub fn mine(&self) -> Result<Mine, String> {
        let body = self.get_cached(
            "channels",
            &[("part", "snippet,contentDetails"), ("mine", "true")],
            Duration::from_secs(3600),
        )?;
        model::mine(&body)
    }

    fn get_cached(&self, endpoint: &str, params: &[(&str, &str)], ttl: Duration) -> Result<String, String> {
        let cache = cache_path(endpoint, params);
        if let Some(body) = cache.as_ref().and_then(|p| read_fresh(p, ttl)) {
            return Ok(body);
        }
        let body = self.get(endpoint, params)?;
        if let Some(path) = &cache {
            write_cache(path, &body);
        }
        Ok(body)
    }

    fn get(&self, endpoint: &str, params: &[(&str, &str)]) -> Result<String, String> {
        let url = self.url(endpoint, params);
        let mut req = self.agent.get(&url);
        if let Some(token) = &self.token {
            req = req.header("Authorization", &format!("Bearer {token}"));
        }
        let mut resp = req.call().map_err(|e| format!("request: {e}"))?;
        resp.body_mut().read_to_string().map_err(|e| format!("read: {e}"))
    }

    fn url(&self, endpoint: &str, params: &[(&str, &str)]) -> String {
        // The API key authenticates public calls; OAuth calls add it harmlessly.
        let key = self.key.as_deref().map(|k| ("key", k));
        build_url(endpoint, params, key)
    }
}

/// The uploads playlist for a channel: UCxxxx -> UUxxxx (documented convention),
/// saving a channels.list quota unit. Non-UC ids pass through unchanged.
pub fn uploads_playlist_id(channel_id: &str) -> String {
    match channel_id.strip_prefix("UC") {
        Some(rest) => format!("UU{rest}"),
        None => channel_id.to_string(),
    }
}

/// Fixed liked-videos playlist id for the signed-in account.
pub const LIKED_PLAYLIST: &str = "LL";

fn build_url(endpoint: &str, params: &[(&str, &str)], extra: Option<(&str, &str)>) -> String {
    let mut url = format!("{BASE}/{endpoint}?");
    let mut first = true;
    let push = |url: &mut String, first: &mut bool, k: &str, v: &str| {
        if !*first {
            url.push('&');
        }
        *first = false;
        url.push_str(k);
        url.push('=');
        url.push_str(&encode(v));
    };
    for (k, v) in params {
        push(&mut url, &mut first, k, v);
    }
    if let Some((k, v)) = extra {
        push(&mut url, &mut first, k, v);
    }
    url
}

/// Percent-encode a query value (RFC 3986 unreserved stays literal).
pub(crate) fn encode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for &b in s.as_bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

fn cache_dir() -> Option<PathBuf> {
    let home = std::env::var("HOME").ok()?;
    Some(PathBuf::from(home).join("medias").join(".cache").join("api"))
}

fn cache_path(endpoint: &str, params: &[(&str, &str)]) -> Option<PathBuf> {
    let mut hash = 0xcbf29ce484222325u64; // FNV-1a over endpoint + params
    for bytes in std::iter::once(endpoint.as_bytes())
        .chain(params.iter().flat_map(|(k, v)| [k.as_bytes(), v.as_bytes()]))
    {
        for &b in bytes {
            hash ^= b as u64;
            hash = hash.wrapping_mul(0x100000001b3);
        }
    }
    Some(cache_dir()?.join(format!("{endpoint}-{hash:016x}.json")))
}

fn read_fresh(path: &PathBuf, ttl: Duration) -> Option<String> {
    let meta = std::fs::metadata(path).ok()?;
    let age = meta.modified().ok()?.elapsed().ok()?;
    (age < ttl).then(|| std::fs::read_to_string(path).ok()).flatten()
}

fn write_cache(path: &PathBuf, body: &str) {
    if let Some(dir) = path.parent() {
        let _ = std::fs::create_dir_all(dir);
    }
    let _ = std::fs::write(path, body);
}

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

    #[test]
    fn uploads_playlist_swaps_uc_prefix() {
        assert_eq!(uploads_playlist_id("UC1a2b3c"), "UU1a2b3c");
        assert_eq!(uploads_playlist_id("PLsomething"), "PLsomething");
    }

    #[test]
    fn query_values_are_percent_encoded() {
        assert_eq!(encode("lofi hip hop"), "lofi%20hip%20hop");
        assert_eq!(encode("a&b=c"), "a%26b%3Dc");
        assert_eq!(encode("plain-TEXT_1.0~"), "plain-TEXT_1.0~");
    }

    #[test]
    fn url_includes_params_and_optional_key() {
        let url = build_url("search", &[("q", "cats"), ("type", "video")], Some(("key", "K1")));
        assert_eq!(
            url,
            "https://www.googleapis.com/youtube/v3/search?q=cats&type=video&key=K1"
        );
        let url = build_url("videos", &[("chart", "mostPopular")], None);
        assert_eq!(url, "https://www.googleapis.com/youtube/v3/videos?chart=mostPopular");
    }
}