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();
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(", "))];
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>,
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"),
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())),
}
}
}
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()),
_ => {}
}
}
}
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 {
_ 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)) => {
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");
}
}