From 874de68fb5d1a9fc3aa786bb5cc8a3c9a52c3578 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 14:12:59 +0200 Subject: [PATCH] wire mid-roll breaks into schedule engine with chapter-aware splitting --- crates/domain/src/services/schedule/fill.rs | 8 +- crates/domain/src/services/schedule/mod.rs | 153 +++++++++++++++-- .../services/schedule/tests/integration.rs | 161 +++++++++++++++++- crates/mcp/src/server.rs | 16 +- crates/mcp/src/tools/channels.rs | 32 ++-- 5 files changed, 333 insertions(+), 37 deletions(-) diff --git a/crates/domain/src/services/schedule/fill.rs b/crates/domain/src/services/schedule/fill.rs index b027940..f4b71d4 100644 --- a/crates/domain/src/services/schedule/fill.rs +++ b/crates/domain/src/services/schedule/fill.rs @@ -311,10 +311,10 @@ pub(super) fn fill_marathon<'a>( } } - if result.is_empty() { - if let Some(&first) = ordered.first() { - result.push(first); - } + if result.is_empty() + && let Some(&first) = ordered.first() + { + result.push(first); } result diff --git a/crates/domain/src/services/schedule/mod.rs b/crates/domain/src/services/schedule/mod.rs index 17f54b2..defe15f 100644 --- a/crates/domain/src/services/schedule/mod.rs +++ b/crates/domain/src/services/schedule/mod.rs @@ -10,7 +10,7 @@ use crate::models::{ }; use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery}; use crate::models::MediaItem; -use crate::value_objects::{BlockId, ChannelId, FillStrategy, InterstitialRule, LibrarySearchFilter, MediaFilter, MediaItemId, MediaRole, RotationPolicy, Weekday}; +use crate::value_objects::{BlockId, ChannelId, FillStrategy, InterstitialRule, LibrarySearchFilter, MediaFilter, MediaItemId, MediaRole, MidRollRule, RotationPolicy, Weekday}; mod fill; mod rotation; @@ -283,11 +283,18 @@ impl ScheduleEngineService { } }; - if let Some(rule) = block.interstitial_rule() { + let with_interstitials = if let Some(rule) = block.interstitial_rule() { self.insert_interstitials(program_slots, rule, block.id(), window.end) + .await? + } else { + program_slots + }; + + if let Some(rule) = block.mid_roll_rule() { + self.apply_mid_rolls(with_interstitials, rule, block.id(), window.end) .await } else { - Ok(program_slots) + Ok(with_interstitials) } } @@ -354,6 +361,132 @@ impl ScheduleEngineService { Ok(result) } + async fn apply_mid_rolls( + &self, + slots: Vec, + rule: &MidRollRule, + block_id: BlockId, + block_end: DateTime, + ) -> DomainResult> { + let interval_secs = rule.fallback_interval_mins() as u64 * 60; + if interval_secs == 0 { + return Ok(slots); + } + + let mut filter = media_filter_to_library_search(rule.pool_filter()); + filter = filter.with_role(MediaRole::Interstitial); + let (break_items, _) = self.library_query.search(&filter).await?; + + if break_items.is_empty() { + return Ok(slots); + } + + let mut result: Vec = Vec::new(); + let mut break_idx = 0usize; + + for slot in &slots { + let item_duration = slot.item().duration_secs() as u64; + + if item_duration < interval_secs { + result.push(slot.clone()); + continue; + } + + let break_points = Self::compute_break_points( + slot.item(), + rule.prefer_chapters(), + interval_secs, + ); + + if break_points.is_empty() { + result.push(slot.clone()); + continue; + } + + let mut cursor = slot.start_at(); + let mut prev_offset = 0u64; + + for bp in &break_points { + if cursor >= block_end { + break; + } + + let segment_duration = bp - prev_offset; + let segment_end = + (cursor + Duration::seconds(segment_duration as i64)).min(block_end); + + result.push(ScheduledSlot::new( + cursor, + segment_end, + slot.item().clone(), + block_id, + )); + cursor = segment_end; + prev_offset = *bp; + + if cursor < block_end { + let break_item = &break_items[break_idx % break_items.len()]; + let break_end_secs = rule.break_duration_secs().min(break_item.duration_secs()); + let break_end = + (cursor + Duration::seconds(break_end_secs as i64)).min(block_end); + if break_end > cursor { + result.push(ScheduledSlot::new( + cursor, + break_end, + break_item.clone(), + block_id, + )); + cursor = break_end; + break_idx += 1; + } + } + } + + let remaining = item_duration - prev_offset; + if remaining > 0 && cursor < block_end { + let tail_end = + (cursor + Duration::seconds(remaining as i64)).min(block_end); + result.push(ScheduledSlot::new( + cursor, + tail_end, + slot.item().clone(), + block_id, + )); + } + } + + Ok(result) + } + + fn compute_break_points( + item: &MediaItem, + prefer_chapters: bool, + interval_secs: u64, + ) -> Vec { + if prefer_chapters && !item.chapters().is_empty() { + item.chapters() + .iter() + .filter_map(|ch| { + let end = ch.end_secs() as u64; + if end > 0 && end < item.duration_secs() as u64 { + Some(end) + } else { + None + } + }) + .collect() + } else { + let duration = item.duration_secs() as u64; + let mut points = Vec::new(); + let mut offset = interval_secs; + while offset < duration { + points.push(offset); + offset += interval_secs; + } + points + } + } + async fn resolve_manual( &self, item_ids: &[MediaItemId], @@ -458,10 +591,10 @@ impl ScheduleEngineService { boundaries.push((pair[0].end_at(), pair[1].start_at())); } } - if let Some(last) = slots.last() { - if last.end_at() < valid_until { - boundaries.push((last.end_at(), valid_until)); - } + if let Some(last) = slots.last() + && last.end_at() < valid_until + { + boundaries.push((last.end_at(), valid_until)); } } @@ -509,10 +642,8 @@ fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) -> if let Some(max) = filter.max_duration_secs { lsf = lsf.with_max_duration_secs(max); } - if !filter.collections.is_empty() { - if let Some(first) = filter.collections.first() { - lsf = lsf.with_collection_id(first.clone()); - } + if let Some(first) = filter.collections.first() { + lsf = lsf.with_collection_id(first.clone()); } if !filter.series_names.is_empty() { lsf = lsf.with_series_names(filter.series_names.clone()); diff --git a/crates/domain/src/services/schedule/tests/integration.rs b/crates/domain/src/services/schedule/tests/integration.rs index a606d05..9d7393f 100644 --- a/crates/domain/src/services/schedule/tests/integration.rs +++ b/crates/domain/src/services/schedule/tests/integration.rs @@ -8,7 +8,8 @@ use crate::testing::{ InMemoryChannelRepository, InMemoryLibraryRepository, InMemoryScheduleRepository, }; use crate::value_objects::{ - ContentType, FillStrategy, InterstitialRule, MediaFilter, MediaItemId, MediaRole, Weekday, + Chapter, ContentType, FillStrategy, InterstitialRule, MediaFilter, MediaItemId, MediaRole, + MidRollRule, Weekday, }; fn episode(id: &str, series: &str, ep: u32, secs: u32) -> MediaItem { @@ -555,3 +556,161 @@ async fn no_gap_filler_leaves_gaps_empty() { "no gap filler configured, should have no interstitial slots" ); } + +fn movie_with_chapters(id: &str, secs: u32, chapters: Vec) -> MediaItem { + let item = MediaItem::new(MediaItemId::new(id), id, ContentType::Movie, secs); + let mut val = serde_json::to_value(&item).unwrap(); + val["chapters"] = serde_json::to_value(&chapters).unwrap(); + serde_json::from_value(val).unwrap() +} + +#[tokio::test] +async fn mid_roll_splits_long_movie_at_intervals() { + let h = TestHarness::new(); + h.seed_items(vec![ + movie("long-movie", 7200), + interstitial("ad1", 120), + interstitial("ad2", 120), + ]); + + let block = ProgrammingBlock::new_algorithmic( + "movie-block", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 180, + MediaFilter::default(), + FillStrategy::BestFit, + ) + .with_mid_roll_rule(MidRollRule::new( + false, + 30, + 120, + MediaFilter::default(), + )); + let channel = h.create_channel_with_block(block).await; + + let schedule = h + .engine + .generate_schedule(channel.id(), Utc::now()) + .await + .unwrap(); + + let today_slots: Vec<_> = schedule.slots().iter().take(20).collect(); + + let movie_segments = today_slots + .iter() + .filter(|s| s.item().id().value() == "long-movie") + .count(); + let break_count = today_slots + .iter() + .filter(|s| *s.item().role() == MediaRole::Interstitial) + .count(); + + assert!( + movie_segments >= 2, + "2h movie with 30min interval should produce >= 2 segments, got {movie_segments}" + ); + assert!( + break_count >= 1, + "should have at least 1 break slot, got {break_count}" + ); +} + +#[tokio::test] +async fn mid_roll_prefers_chapter_boundaries() { + let h = TestHarness::new(); + h.seed_items(vec![ + movie_with_chapters( + "chaptered-movie", + 7200, + vec![ + Chapter::new(Some("Act 1".into()), 0.0, 2400.0), + Chapter::new(Some("Act 2".into()), 2400.0, 4800.0), + Chapter::new(Some("Act 3".into()), 4800.0, 7200.0), + ], + ), + interstitial("ad1", 120), + ]); + + let block = ProgrammingBlock::new_algorithmic( + "chapter-block", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 180, + MediaFilter::default(), + FillStrategy::BestFit, + ) + .with_mid_roll_rule(MidRollRule::new( + true, + 30, + 120, + MediaFilter::default(), + )); + let channel = h.create_channel_with_block(block).await; + + let schedule = h + .engine + .generate_schedule(channel.id(), Utc::now()) + .await + .unwrap(); + + let today_slots: Vec<_> = schedule.slots().iter().take(20).collect(); + + let movie_segments = today_slots + .iter() + .filter(|s| s.item().id().value() == "chaptered-movie") + .count(); + + assert!( + movie_segments >= 3, + "chaptered movie should split at chapter boundaries, got {movie_segments} segments" + ); +} + +#[tokio::test] +async fn mid_roll_short_item_not_split() { + let h = TestHarness::new(); + h.seed_items(vec![ + movie("short-movie", 1200), + interstitial("ad1", 120), + ]); + + let block = ProgrammingBlock::new_algorithmic( + "short-block", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 60, + MediaFilter::default(), + FillStrategy::BestFit, + ) + .with_mid_roll_rule(MidRollRule::new( + false, + 30, + 120, + MediaFilter::default(), + )); + let channel = h.create_channel_with_block(block).await; + + let schedule = h + .engine + .generate_schedule(channel.id(), Utc::now()) + .await + .unwrap(); + + let today_slots: Vec<_> = schedule.slots().iter().take(10).collect(); + + let movie_segments = today_slots + .iter() + .filter(|s| s.item().id().value() == "short-movie") + .count(); + let break_count = today_slots + .iter() + .filter(|s| *s.item().role() == MediaRole::Interstitial) + .count(); + + assert_eq!( + movie_segments, 1, + "20min movie under 30min interval should not be split" + ); + assert_eq!( + break_count, 0, + "no breaks for short items" + ); +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index de63493..fe1af8b 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -128,13 +128,15 @@ impl KTvMcpServer { }; channels::update_channel( &self.channel_cmd_deps, - id, - self.owner_id, - p.name, - p.timezone, - p.description, - schedule_config, - gap_filler, + channels::UpdateChannelArgs { + channel_id: id, + owner_id: self.owner_id, + name: p.name, + timezone: p.timezone, + description: p.description, + schedule_config, + gap_filler, + }, ) .await } diff --git a/crates/mcp/src/tools/channels.rs b/crates/mcp/src/tools/channels.rs index b1dfacf..1cd4d0e 100644 --- a/crates/mcp/src/tools/channels.rs +++ b/crates/mcp/src/tools/channels.rs @@ -45,26 +45,30 @@ pub async fn create_channel( } } +pub struct UpdateChannelArgs { + pub channel_id: Uuid, + pub owner_id: Uuid, + pub name: Option, + pub timezone: Option, + pub description: Option, + pub schedule_config: Option, + pub gap_filler: Option>, +} + pub async fn update_channel( cmd_deps: &Arc, - channel_id: Uuid, - owner_id: Uuid, - name: Option, - timezone: Option, - description: Option, - schedule_config: Option, - gap_filler: Option>, + args: UpdateChannelArgs, ) -> String { let cmd = UpdateChannelCommand { - channel_id: channel_id.into(), - owner_id: owner_id.into(), - name, - description: description.map(Some), - timezone, - schedule_config, + channel_id: args.channel_id.into(), + owner_id: args.owner_id.into(), + name: args.name, + description: args.description.map(Some), + timezone: args.timezone, + schedule_config: args.schedule_config, rotation_policy: None, auto_schedule: None, - gap_filler, + gap_filler: args.gap_filler, }; match application::channels::update::execute(cmd_deps, cmd).await { Ok(channel) => ok_json(&channel),