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:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -25,6 +25,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -1795,6 +1796,7 @@ name = "presentation"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"adapter-auth",
|
"adapter-auth",
|
||||||
|
"adapter-common",
|
||||||
"adapter-event-publisher",
|
"adapter-event-publisher",
|
||||||
"adapter-jellyfin",
|
"adapter-jellyfin",
|
||||||
"adapter-local-files",
|
"adapter-local-files",
|
||||||
@@ -3372,6 +3374,7 @@ name = "worker"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"adapter-auth",
|
"adapter-auth",
|
||||||
|
"adapter-common",
|
||||||
"adapter-event-publisher",
|
"adapter-event-publisher",
|
||||||
"adapter-jellyfin",
|
"adapter-jellyfin",
|
||||||
"adapter-local-files",
|
"adapter-local-files",
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ uuid = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tracing = { 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,6 +226,14 @@ impl MediaItem {
|
|||||||
pub fn chapters(&self) -> &[Chapter] {
|
pub fn chapters(&self) -> &[Chapter] {
|
||||||
&self.chapters
|
&self.chapters
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_chapters(&mut self, chapters: Vec<Chapter>) {
|
||||||
|
self.chapters = chapters;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_role(&mut self, role: MediaRole) {
|
||||||
|
self.role = role;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
Reference in New Issue
Block a user