diff --git a/Cargo.lock b/Cargo.lock index 9a62af3..034166a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,6 +25,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "tokio", "tracing", "uuid", ] @@ -1795,6 +1796,7 @@ name = "presentation" version = "0.1.0" dependencies = [ "adapter-auth", + "adapter-common", "adapter-event-publisher", "adapter-jellyfin", "adapter-local-files", @@ -3372,6 +3374,7 @@ name = "worker" version = "0.1.0" dependencies = [ "adapter-auth", + "adapter-common", "adapter-event-publisher", "adapter-jellyfin", "adapter-local-files", diff --git a/crates/adapters/adapter-common/Cargo.toml b/crates/adapters/adapter-common/Cargo.toml index 0bc05a8..efa910b 100644 --- a/crates/adapters/adapter-common/Cargo.toml +++ b/crates/adapters/adapter-common/Cargo.toml @@ -11,3 +11,4 @@ uuid = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } +tokio = { workspace = true } diff --git a/crates/adapters/adapter-common/src/ffprobe.rs b/crates/adapters/adapter-common/src/ffprobe.rs new file mode 100644 index 0000000..d3b6068 --- /dev/null +++ b/crates/adapters/adapter-common/src/ffprobe.rs @@ -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, +} + +#[derive(Deserialize)] +struct FfprobeChapter { + #[serde(default)] + start_time: String, + #[serde(default)] + end_time: String, + #[serde(default)] + tags: Option, +} + +#[derive(Deserialize)] +struct FfprobeChapterTags { + title: Option, +} + +pub fn parse_chapters_json(json: &str) -> Vec { + 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::().unwrap_or(0.0); + let end_secs = c.end_time.parse::().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 { + 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)); + } +} diff --git a/crates/domain/src/models/media.rs b/crates/domain/src/models/media.rs index 47e9498..8746b39 100644 --- a/crates/domain/src/models/media.rs +++ b/crates/domain/src/models/media.rs @@ -226,6 +226,14 @@ impl MediaItem { pub fn chapters(&self) -> &[Chapter] { &self.chapters } + + pub fn set_chapters(&mut self, chapters: Vec) { + self.chapters = chapters; + } + + pub fn set_role(&mut self, role: MediaRole) { + self.role = role; + } } #[derive(Debug, Clone, Serialize, Deserialize)]