155 lines
3.9 KiB
Rust
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);
|
|
}
|
|
}
|