chapter extraction via ffprobe during library sync (#5)
ffprobe module in adapter-common: parse JSON output into Vec<Chapter>, should_probe_chapters gate (movie OR >45min), graceful failure. Add set_chapters/set_role setters to MediaItem.
This commit is contained in:
@@ -11,3 +11,4 @@ uuid = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
180
crates/adapters/adapter-common/src/ffprobe.rs
Normal file
180
crates/adapters/adapter-common/src/ffprobe.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
use domain::{Chapter, ContentType, SourceUri};
|
||||
use serde::Deserialize;
|
||||
|
||||
const CHAPTER_PROBE_MIN_DURATION_SECS: u32 = 2700;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FfprobeOutput {
|
||||
#[serde(default)]
|
||||
chapters: Vec<FfprobeChapter>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FfprobeChapter {
|
||||
#[serde(default)]
|
||||
start_time: String,
|
||||
#[serde(default)]
|
||||
end_time: String,
|
||||
#[serde(default)]
|
||||
tags: Option<FfprobeChapterTags>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FfprobeChapterTags {
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_chapters_json(json: &str) -> Vec<Chapter> {
|
||||
let output: FfprobeOutput = match serde_json::from_str(json) {
|
||||
Ok(o) => o,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
output
|
||||
.chapters
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let title = c.tags.and_then(|t| t.title);
|
||||
let start_secs = c.start_time.parse::<f64>().unwrap_or(0.0);
|
||||
let end_secs = c.end_time.parse::<f64>().unwrap_or(0.0);
|
||||
Chapter::new(title, start_secs, end_secs)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn should_probe_chapters(content_type: &ContentType, duration_secs: u32) -> bool {
|
||||
matches!(content_type, ContentType::Movie) || duration_secs > CHAPTER_PROBE_MIN_DURATION_SECS
|
||||
}
|
||||
|
||||
pub async fn extract_chapters(source_uri: &SourceUri) -> Vec<Chapter> {
|
||||
let path = match source_uri {
|
||||
SourceUri::FilePath { path } => path.clone(),
|
||||
SourceUri::NetworkUrl { url } => url.clone(),
|
||||
};
|
||||
|
||||
let result = tokio::process::Command::new("ffprobe")
|
||||
.args([
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_chapters",
|
||||
&path,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(output) if output.status.success() => {
|
||||
let json = String::from_utf8_lossy(&output.stdout);
|
||||
parse_chapters_json(&json)
|
||||
}
|
||||
Ok(output) => {
|
||||
tracing::warn!(
|
||||
path = %path,
|
||||
stderr = %String::from_utf8_lossy(&output.stderr),
|
||||
"ffprobe exited with non-zero status"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "ffprobe not available or failed to execute");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_ffprobe_json_with_chapters() {
|
||||
let json = r#"{
|
||||
"chapters": [
|
||||
{
|
||||
"id": 0,
|
||||
"time_base": "1/1000",
|
||||
"start": 0,
|
||||
"start_time": "0.000000",
|
||||
"end": 300000,
|
||||
"end_time": "300.000000",
|
||||
"tags": { "title": "Opening" }
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"time_base": "1/1000",
|
||||
"start": 300000,
|
||||
"start_time": "300.000000",
|
||||
"end": 1800000,
|
||||
"end_time": "1800.000000",
|
||||
"tags": { "title": "Main Feature" }
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"time_base": "1/1000",
|
||||
"start": 1800000,
|
||||
"start_time": "1800.000000",
|
||||
"end": 2100000,
|
||||
"end_time": "2100.000000"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let chapters = parse_chapters_json(json);
|
||||
assert_eq!(chapters.len(), 3);
|
||||
|
||||
assert_eq!(chapters[0].title(), Some("Opening"));
|
||||
assert!((chapters[0].start_secs() - 0.0).abs() < f64::EPSILON);
|
||||
assert!((chapters[0].end_secs() - 300.0).abs() < f64::EPSILON);
|
||||
|
||||
assert_eq!(chapters[1].title(), Some("Main Feature"));
|
||||
assert!((chapters[1].start_secs() - 300.0).abs() < f64::EPSILON);
|
||||
assert!((chapters[1].end_secs() - 1800.0).abs() < f64::EPSILON);
|
||||
|
||||
assert_eq!(chapters[2].title(), None);
|
||||
assert!((chapters[2].start_secs() - 1800.0).abs() < f64::EPSILON);
|
||||
assert!((chapters[2].end_secs() - 2100.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ffprobe_json_no_chapters() {
|
||||
let json = r#"{ "chapters": [] }"#;
|
||||
let chapters = parse_chapters_json(json);
|
||||
assert!(chapters.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ffprobe_json_missing_chapters_key() {
|
||||
let json = r#"{}"#;
|
||||
let chapters = parse_chapters_json(json);
|
||||
assert!(chapters.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ffprobe_json_invalid() {
|
||||
let chapters = parse_chapters_json("not json");
|
||||
assert!(chapters.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_items_skip_probe() {
|
||||
assert!(!should_probe_chapters(&ContentType::Episode, 1800));
|
||||
assert!(!should_probe_chapters(&ContentType::Short, 300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movie_always_probed() {
|
||||
assert!(should_probe_chapters(&ContentType::Movie, 600));
|
||||
assert!(should_probe_chapters(&ContentType::Movie, 7200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_episode_probed() {
|
||||
assert!(should_probe_chapters(&ContentType::Episode, 3600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn episode_under_threshold_skipped() {
|
||||
assert!(!should_probe_chapters(&ContentType::Episode, 2700));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user