Files
mov-renamarr/src/media.rs
2025-12-30 10:52:59 -05:00

155 lines
3.9 KiB
Rust

use std::path::Path;
use std::process::Command;
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use crate::config::QualityTags;
#[derive(Debug, Clone)]
pub struct MediaInfo {
pub duration_seconds: Option<f64>,
pub height: Option<u32>,
pub codec: Option<String>,
}
#[derive(Debug, Deserialize)]
struct FfprobeOutput {
format: Option<FfprobeFormat>,
streams: Option<Vec<FfprobeStream>>,
}
#[derive(Debug, Deserialize)]
struct FfprobeFormat {
duration: Option<String>,
}
#[derive(Debug, Deserialize)]
struct FfprobeStream {
codec_type: Option<String>,
codec_name: Option<String>,
height: Option<u32>,
}
pub fn probe(path: &Path) -> Result<MediaInfo> {
let output = Command::new("ffprobe")
.arg("-v")
.arg("error")
.arg("-print_format")
.arg("json")
.arg("-show_format")
.arg("-show_streams")
.arg(path)
.output()
.with_context(|| format!("failed to run ffprobe on {}", path.display()))?;
if !output.status.success() {
return Err(anyhow!(
"ffprobe failed for {}: {}",
path.display(),
String::from_utf8_lossy(&output.stderr)
));
}
let parsed: FfprobeOutput = serde_json::from_slice(&output.stdout)
.with_context(|| "failed to parse ffprobe JSON")?;
let duration_seconds = parsed
.format
.and_then(|fmt| fmt.duration)
.and_then(|dur| dur.parse::<f64>().ok());
let video_stream = parsed
.streams
.unwrap_or_default()
.into_iter()
.find(|stream| stream.codec_type.as_deref() == Some("video"));
let (height, codec) = if let Some(stream) = video_stream {
(stream.height, stream.codec_name)
} else {
(None, None)
};
Ok(MediaInfo {
duration_seconds,
height,
codec,
})
}
pub fn quality_tag(info: &MediaInfo, tags: &QualityTags) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if tags.resolution {
if let Some(res) = resolution_tag(info.height) {
parts.push(res);
}
}
if tags.codec {
if let Some(codec) = codec_tag(info.codec.as_deref()) {
parts.push(codec);
}
}
if tags.source {
// Source tagging not implemented yet; placeholder for future expansion.
}
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
pub fn resolution_tag(height: Option<u32>) -> Option<String> {
let height = height?;
let tag = if height >= 2160 {
"2160p"
} else if height >= 1080 {
"1080p"
} else if height >= 720 {
"720p"
} else if height >= 480 {
"480p"
} else {
"360p"
};
Some(tag.to_string())
}
pub fn codec_tag(codec: Option<&str>) -> Option<String> {
let codec = codec?.to_ascii_lowercase();
let tag = if codec.contains("hevc") || codec.contains("h265") || codec.contains("x265") {
"x265"
} else if codec.contains("h264") || codec.contains("x264") {
"x264"
} else if codec.contains("av1") {
"av1"
} else {
return None;
};
Some(tag.to_string())
}
#[cfg(test)]
mod tests {
use super::{codec_tag, resolution_tag};
#[test]
fn resolution_tags() {
assert_eq!(resolution_tag(Some(2160)).as_deref(), Some("2160p"));
assert_eq!(resolution_tag(Some(1080)).as_deref(), Some("1080p"));
assert_eq!(resolution_tag(Some(720)).as_deref(), Some("720p"));
assert_eq!(resolution_tag(Some(480)).as_deref(), Some("480p"));
assert_eq!(resolution_tag(Some(360)).as_deref(), Some("360p"));
}
#[test]
fn codec_tags() {
assert_eq!(codec_tag(Some("h264")).as_deref(), Some("x264"));
assert_eq!(codec_tag(Some("hevc")).as_deref(), Some("x265"));
assert_eq!(codec_tag(Some("av1")).as_deref(), Some("av1"));
assert_eq!(codec_tag(Some("vp9")), None);
}
}