medias 0.5.0

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
//! `medias metadata FILE|URL...`: container and per-track stream parameters.
//! Values come from libmpv's demuxer (`track-list/N/*`); colorimetry
//! (`video-params/*`) needs one decoded frame and is omitted on timeout.

use std::time::{Duration, Instant};

use crate::mpv::{Event, Mpv};

const LOAD_TIMEOUT: Duration = Duration::from_secs(15);
const FRAME_TIMEOUT: Duration = Duration::from_secs(5);

pub fn run(sources: &[String]) -> Result<(), String> {
    if sources.is_empty() {
        return Err("usage: medias metadata FILE|URL...".into());
    }
    let mut failed = 0;
    for (i, source) in sources.iter().enumerate() {
        if i > 0 {
            println!();
        }
        match probe(source) {
            Ok(lines) => {
                println!("{source}");
                for line in lines {
                    println!("  {line}");
                }
            }
            Err(error) => {
                eprintln!("{source}: {error}");
                failed += 1;
            }
        }
    }
    match failed {
        0 => Ok(()),
        n => Err(format!("{n} of {} failed", sources.len())),
    }
}

fn probe(source: &str) -> Result<Vec<String>, String> {
    let m = Mpv::new()?;
    for (name, value) in [
        ("vo", "null"), ("ao", "null"), ("pause", "yes"), ("sid", "no"), ("hwdec", "no"),
        ("idle", "no"), ("load-scripts", "no"), ("ytdl", "no"), ("msg-level", "all=no"),
    ] {
        m.set_option(name, value)?;
    }
    m.init()?;
    m.loadfile(source)?;
    wait_for(&m, LOAD_TIMEOUT, |e| matches!(e, Event::FileLoaded))?;
    let has_video = m.get_string("current-tracks/video/id").is_some();
    // Paused playback still decodes the first frame (PLAYBACK_RESTART).
    let decoded = has_video && wait_for(&m, FRAME_TIMEOUT, |e| matches!(e, Event::PlaybackRestart)).is_ok();

    let duration = m.get_f64("duration").filter(|d| *d > 0.0);
    let size = m.get_string("file-size").and_then(|s| s.parse::<u64>().ok());
    let total_bps = match (size, duration) {
        (Some(s), Some(d)) => Some(s as f64 * 8.0 / d),
        _ => None,
    };

    let tracks: Vec<Track> = (0..m.get_f64("track-list/count").unwrap_or(0.0) as usize)
        .map(|n| Track::read(&m, n))
        .collect();

    let mut container = vec![m.get_string("file-format").unwrap_or_else(|| "?".into())];
    container.extend(duration.map(hms));
    container.extend(size.map(bytes));
    container.extend(total_bps.map(rate));
    let mut lines = vec![format!("{:<9} {}", "container", container.join(", "))];

    // Video bitrate when the container gives none: total minus known audio,
    // valid only for one non-image video track and no subtitle/unknown tracks.
    let videos = tracks.iter().filter(|t| t.kind == "video" && !t.image).count();
    let audio_bps: Option<f64> = tracks.iter().filter(|t| t.kind == "audio").map(|t| t.bps).sum();
    let only_av = tracks.iter().all(|t| matches!(t.kind.as_str(), "video" | "audio"));
    let video_estimate = match (videos, total_bps, audio_bps, only_av) {
        (1, Some(total), Some(audio), true) if total > audio => Some(total - audio),
        _ => None,
    };

    for t in &tracks {
        let mut f = vec![t.codec.clone()];
        match t.kind.as_str() {
            "video" => {
                if let (Some(w), Some(h)) = (t.w, t.h) {
                    f.push(format!("{w}x{h}"));
                }
                if t.par.is_some_and(|p| (p - 1.0).abs() > 1e-3) {
                    f.push(format!("par {:.3}", t.par.unwrap()));
                }
                if t.rotation.is_some_and(|r| r != 0) {
                    f.push(format!("rotated {}°", t.rotation.unwrap()));
                }
                if t.image {
                    f.push(if t.albumart { "cover art" } else { "still image" }.into());
                } else if let Some(fps) = t.fps {
                    f.push(format!("{} fps", fps_str(fps)));
                }
                let pix = (t.selected && decoded).then(|| m.get_string("video-params/pixelformat")).flatten()
                    .or_else(|| t.format.clone());
                if let Some(pix) = pix {
                    match bit_depth(&pix) {
                        Some(d) => f.push(format!("{d} bit ({pix})")),
                        None => f.push(pix),
                    }
                }
                if t.selected && decoded && !t.image {
                    f.extend(colorimetry(&m, t.dovi));
                }
                match (t.bps, video_estimate) {
                    (Some(b), _) => f.push(rate(b)),
                    (None, Some(b)) if !t.image => f.push(format!("~{}", rate(b))),
                    _ => {}
                }
            }
            "audio" => {
                match (&t.channels, t.channel_count) {
                    (Some(l), Some(c)) if l != &c.to_string() => f.push(format!("{l} ({c} ch)")),
                    (_, Some(c)) => f.push(format!("{c} ch")),
                    (Some(l), None) => f.push(l.clone()),
                    _ => {}
                }
                f.extend(t.samplerate.map(|r| format!("{r} Hz")));
                f.extend(t.format.clone());
                f.extend(t.bps.map(rate));
            }
            _ => {}
        }
        f.extend(t.lang.as_ref().map(|l| format!("[{l}]")));
        f.extend(t.title.as_ref().map(|s| format!("\"{s}\"")));
        if t.default {
            f.push("default".into());
        }
        if t.external {
            f.push("external".into());
        }
        lines.push(format!("{:<9} {}", format!("{} {}", t.kind, t.id), f.join(", ")));
    }
    Ok(lines)
}

struct Track {
    kind: String,
    id: i64,
    codec: String,
    format: Option<String>,
    lang: Option<String>,
    title: Option<String>,
    default: bool,
    external: bool,
    selected: bool,
    image: bool,
    albumart: bool,
    w: Option<i64>,
    h: Option<i64>,
    par: Option<f64>,
    rotation: Option<i64>,
    fps: Option<f64>,
    dovi: Option<i64>,
    channels: Option<String>,
    channel_count: Option<i64>,
    samplerate: Option<i64>,
    /// Container bitrate: demux-bitrate (audio), else the Matroska statistics tag BPS.
    bps: Option<f64>,
}

impl Track {
    fn read(m: &Mpv, n: usize) -> Track {
        let s = |key: &str| m.get_string(&format!("track-list/{n}/{key}")).filter(|v| !v.is_empty());
        let f = |key: &str| m.get_f64(&format!("track-list/{n}/{key}"));
        let i = |key: &str| f(key).map(|v| v as i64);
        let b = |key: &str| s(key).as_deref() == Some("yes");
        let codec = match (s("codec"), s("codec-profile")) {
            (Some(c), Some(p)) => format!("{c} ({p})"),
            (Some(c), None) => c,
            (None, _) => "?".into(),
        };
        Track {
            kind: s("type").unwrap_or_else(|| "?".into()),
            id: i("id").unwrap_or(0),
            codec,
            format: s("format-name"),
            lang: s("lang"),
            title: s("title"),
            default: b("default"),
            external: b("external"),
            selected: b("selected"),
            image: b("image"),
            albumart: b("albumart"),
            w: i("demux-w"),
            h: i("demux-h"),
            par: f("demux-par"),
            rotation: i("demux-rotation"),
            fps: f("demux-fps"),
            dovi: i("dolby-vision-profile"),
            // mpv reports "unknownN" when the container has no layout.
            channels: s("demux-channels").filter(|l| !l.starts_with("unknown")),
            channel_count: i("demux-channel-count"),
            samplerate: i("demux-samplerate"),
            bps: f("demux-bitrate").filter(|b| *b > 0.0)
                .or_else(|| s("metadata/by-key/BPS").and_then(|v| v.parse().ok())),
        }
    }
}

/// Primaries, transfer and HDR class of the decoded frame.
fn colorimetry(m: &Mpv, dovi: Option<i64>) -> Vec<String> {
    let mut out = Vec::new();
    let primaries = m.get_string("video-params/primaries");
    let gamma = m.get_string("video-params/gamma");
    if let (Some(p), Some(g)) = (&primaries, &gamma) {
        out.push(format!("{p}/{g}"));
    }
    match (dovi, gamma.as_deref()) {
        (Some(p), _) => out.push(format!("Dolby Vision profile {p}")),
        (None, Some("pq")) => out.push("HDR10".into()),
        (None, Some("hlg")) => out.push("HLG".into()),
        _ => {}
    }
    out
}

fn wait_for(m: &Mpv, timeout: Duration, done: impl Fn(&Event) -> bool) -> Result<(), String> {
    let start = Instant::now();
    loop {
        let event = m.wait_event(0.1);
        if done(&event) {
            return Ok(());
        }
        match event {
            Event::PlaybackError => return Err("cannot open".into()),
            Event::EndFile(_) | Event::Shutdown => return Err("ended before load".into()),
            _ if start.elapsed() > timeout => return Err("timeout".into()),
            _ => {}
        }
    }
}

/// Bits per component of an ffmpeg pixel format name; None if unknown.
fn bit_depth(pix: &str) -> Option<u32> {
    let p = pix.strip_suffix("le").or_else(|| pix.strip_suffix("be")).unwrap_or(pix);
    if p.contains("f16") {
        return Some(16);
    }
    if p.contains("f32") {
        return Some(32);
    }
    match p {
        // semi-planar: p010/p210/p410 = 10 bit, p012 = 12, p016 = 16; nvXX = 8, nv20 = 10
        _ if p.len() == 4 && p.starts_with('p') && p[1..].bytes().all(|c| c.is_ascii_digit()) => p[2..].parse().ok(),
        "nv20" => Some(10),
        _ if p.starts_with("nv") => Some(8),
        "rgb24" | "bgr24" | "rgba" | "bgra" | "argb" | "abgr" | "rgb0" | "bgr0" | "0rgb" | "0bgr" | "pal8" => Some(8),
        "rgb48" | "bgr48" | "rgba64" | "bgra64" => Some(16),
        "x2rgb10" | "x2bgr10" | "y210" | "v30x" | "xv30" => Some(10),
        "y212" | "xv36" => Some(12),
        "yuyv422" | "uyvy422" | "yvyu422" | "vuyx" | "vuya" | "ayuv" => Some(8),
        _ if ["yuv", "yuvj", "yuva", "gbr", "gbrap", "gray", "ya"].iter().any(|s| p.starts_with(s)) => {
            // trailing digits after the planar "p" (yuv420p10, gbrp12) or "gray" (gray10)
            let tail: String = p.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
            let head = &p[..p.len() - tail.len()];
            if tail.is_empty() {
                Some(8)
            } else if head.ends_with('p') || head == "gray" || head == "ya" {
                tail.chars().rev().collect::<String>().parse().ok()
            } else {
                None
            }
        }
        _ => None,
    }
}

fn fps_str(fps: f64) -> String {
    let s = format!("{fps:.3}");
    s.trim_end_matches('0').trim_end_matches('.').to_string()
}

fn hms(seconds: f64) -> String {
    let t = seconds.round() as u64;
    format!("{}:{:02}:{:02}", t / 3600, t / 60 % 60, t % 60)
}

fn bytes(n: u64) -> String {
    match n {
        n if n >= 1 << 30 => format!("{:.2} GiB", n as f64 / (1u64 << 30) as f64),
        n if n >= 1 << 20 => format!("{:.1} MiB", n as f64 / (1u64 << 20) as f64),
        n if n >= 1 << 10 => format!("{:.1} KiB", n as f64 / 1024.0),
        n => format!("{n} B"),
    }
}

fn rate(bps: f64) -> String {
    if bps >= 1e6 { format!("{:.2} Mbit/s", bps / 1e6) } else { format!("{:.0} kbit/s", bps / 1e3) }
}

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

    #[test]
    fn bit_depth_of_pixel_formats() {
        for (pix, depth) in [
            ("yuv420p", Some(8)), ("yuvj420p", Some(8)), ("yuv420p10le", Some(10)), ("yuv444p12be", Some(12)),
            ("gbrp10le", Some(10)), ("gray", Some(8)), ("gray10le", Some(10)), ("nv12", Some(8)),
            ("p010le", Some(10)), ("p016le", Some(16)), ("rgb24", Some(8)), ("rgb48le", Some(16)),
            ("gbrpf32le", Some(32)), ("yuva420p", Some(8)), ("x2rgb10le", Some(10)), ("bayer_rggb8", None),
        ] {
            assert_eq!(bit_depth(pix), depth, "{pix}");
        }
    }

    #[test]
    fn formatting() {
        assert_eq!(fps_str(23.976023), "23.976");
        assert_eq!(fps_str(25.0), "25");
        assert_eq!(hms(3725.4), "1:02:05");
        assert_eq!(rate(9_840_000.0), "9.84 Mbit/s");
        assert_eq!(rate(192_000.0), "192 kbit/s");
    }
}