SCTE-35 markers + overlay metadata in playout (#10)

This commit is contained in:
2026-07-12 14:33:22 +02:00
parent e27e2ab6d1
commit 6dbf7ab98c
4 changed files with 316 additions and 18 deletions

View File

@@ -1,34 +1,53 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PlayoutConfig { pub struct PlayoutConfig {
pub listen_addr: String, pub listen_addr: String,
pub segment_duration_secs: u32, pub segment_duration_secs: u32,
pub target_duration_secs: u32,
pub window_size: usize, pub window_size: usize,
pub storage_path: PathBuf, pub storage_path: PathBuf,
pub tick_interval_ms: u64, 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 { impl PlayoutConfig {
pub fn from_env() -> Self { pub fn from_env() -> Self {
Self { let mut config = Self::default();
listen_addr: std::env::var("PLAYOUT_LISTEN_ADDR") if let Ok(v) = std::env::var("PLAYOUT_LISTEN_ADDR") {
.unwrap_or_else(|_| "0.0.0.0:9090".into()), config.listen_addr = v;
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),
} }
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::<u64>().ok()) {
config.overlay_trigger_offset = Duration::from_secs(v);
}
config
} }
} }

View File

@@ -2,5 +2,7 @@ pub mod config;
pub mod engine; pub mod engine;
pub mod ffmpeg; pub mod ffmpeg;
pub mod http; pub mod http;
pub mod metadata;
pub mod playlist; pub mod playlist;
pub mod scte35;
pub mod segment_store; pub mod segment_store;

View File

@@ -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<String>,
}
#[derive(Debug, Clone)]
pub struct TimedMetadata {
pub trigger_at: DateTime<Utc>,
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);
}
}

View File

@@ -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<Utc>,
duration_secs: u32,
},
SpliceIn {
id: String,
time: DateTime<Utc>,
},
}
#[derive(Debug, Clone)]
pub struct PlayoutPlan {
pub slots: Vec<ScheduledSlot>,
pub scte35_events: Vec<Scte35Event>,
pub timed_metadata: Vec<TimedMetadata>,
}
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<Scte35Event> {
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<TimedMetadata> {
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"));
}
}