From 6dbf7ab98c3864306834e580839c4d0d4f24925e Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 14:33:22 +0200 Subject: [PATCH] SCTE-35 markers + overlay metadata in playout (#10) --- crates/playout/src/config.rs | 55 +++++--- crates/playout/src/lib.rs | 2 + crates/playout/src/metadata.rs | 47 +++++++ crates/playout/src/scte35.rs | 230 +++++++++++++++++++++++++++++++++ 4 files changed, 316 insertions(+), 18 deletions(-) create mode 100644 crates/playout/src/metadata.rs create mode 100644 crates/playout/src/scte35.rs diff --git a/crates/playout/src/config.rs b/crates/playout/src/config.rs index cfb8f50..2d9da98 100644 --- a/crates/playout/src/config.rs +++ b/crates/playout/src/config.rs @@ -1,34 +1,53 @@ use std::path::PathBuf; +use std::time::Duration; #[derive(Debug, Clone)] pub struct PlayoutConfig { pub listen_addr: String, pub segment_duration_secs: u32, + pub target_duration_secs: u32, pub window_size: usize, pub storage_path: PathBuf, pub tick_interval_ms: u64, + pub overlay_trigger_offset: Duration, +} + +impl Default for PlayoutConfig { + fn default() -> Self { + Self { + listen_addr: "0.0.0.0:9090".into(), + segment_duration_secs: 6, + target_duration_secs: 6, + window_size: 10, + storage_path: PathBuf::from("/tmp/k-tv-playout"), + tick_interval_ms: 1000, + overlay_trigger_offset: Duration::from_secs(30), + } + } } impl PlayoutConfig { pub fn from_env() -> Self { - Self { - listen_addr: std::env::var("PLAYOUT_LISTEN_ADDR") - .unwrap_or_else(|_| "0.0.0.0:9090".into()), - segment_duration_secs: std::env::var("PLAYOUT_SEGMENT_DURATION") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(6), - window_size: std::env::var("PLAYOUT_WINDOW_SIZE") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10), - storage_path: std::env::var("PLAYOUT_STORAGE_PATH") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp/k-tv-playout")), - tick_interval_ms: std::env::var("PLAYOUT_TICK_INTERVAL_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1000), + let mut config = Self::default(); + if let Ok(v) = std::env::var("PLAYOUT_LISTEN_ADDR") { + config.listen_addr = v; } + if let Some(v) = std::env::var("PLAYOUT_SEGMENT_DURATION").ok().and_then(|v| v.parse().ok()) { + config.segment_duration_secs = v; + config.target_duration_secs = v; + } + if let Some(v) = std::env::var("PLAYOUT_WINDOW_SIZE").ok().and_then(|v| v.parse().ok()) { + config.window_size = v; + } + if let Ok(v) = std::env::var("PLAYOUT_STORAGE_PATH") { + config.storage_path = PathBuf::from(v); + } + if let Some(v) = std::env::var("PLAYOUT_TICK_INTERVAL_MS").ok().and_then(|v| v.parse().ok()) { + config.tick_interval_ms = v; + } + if let Some(v) = std::env::var("PLAYOUT_OVERLAY_OFFSET_SECS").ok().and_then(|v| v.parse::().ok()) { + config.overlay_trigger_offset = Duration::from_secs(v); + } + config } } diff --git a/crates/playout/src/lib.rs b/crates/playout/src/lib.rs index 189d1af..4d426c2 100644 --- a/crates/playout/src/lib.rs +++ b/crates/playout/src/lib.rs @@ -2,5 +2,7 @@ pub mod config; pub mod engine; pub mod ffmpeg; pub mod http; +pub mod metadata; pub mod playlist; +pub mod scte35; pub mod segment_store; diff --git a/crates/playout/src/metadata.rs b/crates/playout/src/metadata.rs new file mode 100644 index 0000000..9547fcc --- /dev/null +++ b/crates/playout/src/metadata.rs @@ -0,0 +1,47 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OverlayPayload { + pub title: String, + pub thumbnail_url: Option, +} + +#[derive(Debug, Clone)] +pub struct TimedMetadata { + pub trigger_at: DateTime, + pub payload: OverlayPayload, +} + +impl TimedMetadata { + pub fn to_id3_json(&self) -> String { + serde_json::to_string(&self.payload).expect("OverlayPayload is always serializable") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_serializes_to_json() { + let payload = OverlayPayload { + title: "Next: The Matrix".into(), + thumbnail_url: Some("https://example.com/matrix.jpg".into()), + }; + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains("Next: The Matrix")); + assert!(json.contains("matrix.jpg")); + } + + #[test] + fn payload_without_thumbnail() { + let payload = OverlayPayload { + title: "Coming up: News".into(), + thumbnail_url: None, + }; + let json = serde_json::to_string(&payload).unwrap(); + let roundtrip: OverlayPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(roundtrip, payload); + } +} diff --git a/crates/playout/src/scte35.rs b/crates/playout/src/scte35.rs new file mode 100644 index 0000000..37a6258 --- /dev/null +++ b/crates/playout/src/scte35.rs @@ -0,0 +1,230 @@ +use chrono::{DateTime, Duration, Utc}; +use domain::{MediaRole, ScheduledSlot}; + +use crate::config::PlayoutConfig; +use crate::metadata::{OverlayPayload, TimedMetadata}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Scte35Event { + SpliceOut { + id: String, + start: DateTime, + duration_secs: u32, + }, + SpliceIn { + id: String, + time: DateTime, + }, +} + +#[derive(Debug, Clone)] +pub struct PlayoutPlan { + pub slots: Vec, + pub scte35_events: Vec, + pub timed_metadata: Vec, +} + +pub fn build_playout_plan(slots: &[ScheduledSlot], config: &PlayoutConfig) -> PlayoutPlan { + let scte35_events = detect_midroll_breaks(slots); + let timed_metadata = generate_overlay_triggers(slots, config); + + PlayoutPlan { + slots: slots.to_vec(), + scte35_events, + timed_metadata, + } +} + +fn detect_midroll_breaks(slots: &[ScheduledSlot]) -> Vec { + let mut events = Vec::new(); + + for window in slots.windows(3) { + let before = &window[0]; + let break_slot = &window[1]; + let after = &window[2]; + + let is_break = *break_slot.item().role() == MediaRole::Interstitial; + let same_source = before.source_block_id() == after.source_block_id() + && before.source_block_id() == break_slot.source_block_id(); + + if is_break && same_source { + let break_duration = (break_slot.end_at() - break_slot.start_at()) + .num_seconds() + .max(0) as u32; + + let event_id = format!("midroll-{}", break_slot.id()); + + events.push(Scte35Event::SpliceOut { + id: event_id.clone(), + start: break_slot.start_at(), + duration_secs: break_duration, + }); + + events.push(Scte35Event::SpliceIn { + id: event_id, + time: break_slot.end_at(), + }); + } + } + + events +} + +fn generate_overlay_triggers( + slots: &[ScheduledSlot], + config: &PlayoutConfig, +) -> Vec { + let mut triggers = Vec::new(); + let offset = + Duration::from_std(config.overlay_trigger_offset).unwrap_or(Duration::seconds(30)); + + for i in 0..slots.len().saturating_sub(1) { + let current = &slots[i]; + let next = &slots[i + 1]; + + if *next.item().role() == MediaRole::Interstitial { + continue; + } + + let trigger_at = current.end_at() - offset; + if trigger_at <= current.start_at() { + continue; + } + + triggers.push(TimedMetadata { + trigger_at, + payload: OverlayPayload { + title: format!("Coming up next: {}", next.item().title()), + thumbnail_url: next.item().thumbnail_url().map(String::from), + }, + }); + } + + triggers +} + +pub fn render_scte35_daterange(event: &Scte35Event) -> String { + match event { + Scte35Event::SpliceOut { + id, + start, + duration_secs, + } => { + format!( + "#EXT-X-DATERANGE:ID=\"{id}\",START-DATE=\"{}\",PLANNED-DURATION={duration_secs},SCTE35-OUT=0xFC30", + start.format("%Y-%m-%dT%H:%M:%S%.3fZ") + ) + } + Scte35Event::SpliceIn { id, time } => { + format!( + "#EXT-X-DATERANGE:ID=\"{id}-in\",START-DATE=\"{}\",SCTE35-IN=0xFC30", + time.format("%Y-%m-%dT%H:%M:%S%.3fZ") + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use domain::{BlockId, ContentType, MediaItem, MediaItemId, MediaItemRow}; + + fn make_item(title: &str, duration_secs: u32, role: MediaRole) -> MediaItem { + MediaItem::from_persistence(MediaItemRow { + id: MediaItemId::new(format!("item-{title}")), + title: title.into(), + content_type: ContentType::Movie, + duration_secs, + description: None, + genres: vec![], + year: None, + tags: vec![], + series_name: None, + season_number: None, + episode_number: None, + thumbnail_url: None, + collection_id: None, + provider_id: String::new(), + external_id: String::new(), + collection_name: None, + collection_type: None, + synced_at: None, + role, + chapters: vec![], + }) + } + + fn make_slot( + start_min: i64, + end_min: i64, + title: &str, + role: MediaRole, + block_id: BlockId, + ) -> ScheduledSlot { + let base = Utc.with_ymd_and_hms(2026, 7, 12, 20, 0, 0).unwrap(); + let duration = ((end_min - start_min) * 60) as u32; + let item = make_item(title, duration, role); + ScheduledSlot::new( + base + Duration::minutes(start_min), + base + Duration::minutes(end_min), + item, + block_id, + ) + } + + #[test] + fn detects_midroll_break_between_program_segments() { + let block_id = BlockId::generate(); + let slots = vec![ + make_slot(0, 30, "Movie Part 1", MediaRole::Program, block_id), + make_slot(30, 32, "Ad Break", MediaRole::Interstitial, block_id), + make_slot(32, 62, "Movie Part 2", MediaRole::Program, block_id), + ]; + let events = detect_midroll_breaks(&slots); + assert_eq!(events.len(), 2); + assert!(matches!(&events[0], Scte35Event::SpliceOut { duration_secs: 120, .. })); + assert!(matches!(&events[1], Scte35Event::SpliceIn { .. })); + } + + #[test] + fn no_midroll_for_different_blocks() { + let block_a = BlockId::generate(); + let block_b = BlockId::generate(); + let slots = vec![ + make_slot(0, 30, "Show A", MediaRole::Program, block_a), + make_slot(30, 32, "Bumper", MediaRole::Interstitial, block_b), + make_slot(32, 62, "Show B", MediaRole::Program, block_a), + ]; + assert!(detect_midroll_breaks(&slots).is_empty()); + } + + #[test] + fn overlay_trigger_fires_before_slot_end() { + let block_id = BlockId::generate(); + let slots = vec![ + make_slot(0, 60, "Current Movie", MediaRole::Program, block_id), + make_slot(60, 120, "Next Movie", MediaRole::Program, block_id), + ]; + let config = PlayoutConfig { + overlay_trigger_offset: std::time::Duration::from_secs(30), + ..Default::default() + }; + let triggers = generate_overlay_triggers(&slots, &config); + assert_eq!(triggers.len(), 1); + assert!(triggers[0].payload.title.contains("Next Movie")); + } + + #[test] + fn no_overlay_before_interstitial() { + let block_id = BlockId::generate(); + let slots = vec![ + make_slot(0, 30, "Movie Part 1", MediaRole::Program, block_id), + make_slot(30, 32, "Ad Break", MediaRole::Interstitial, block_id), + make_slot(32, 62, "Movie Part 2", MediaRole::Program, block_id), + ]; + let triggers = generate_overlay_triggers(&slots, &PlayoutConfig::default()); + assert_eq!(triggers.len(), 1); + assert!(triggers[0].payload.title.contains("Movie Part 2")); + } +}