diff --git a/crates/domain/src/models/channel.rs b/crates/domain/src/models/channel.rs index c12ba71..7fd5ccc 100644 --- a/crates/domain/src/models/channel.rs +++ b/crates/domain/src/models/channel.rs @@ -426,6 +426,16 @@ impl ProgrammingBlock { pub fn mid_roll_rule(&self) -> Option<&MidRollRule> { self.mid_roll_rule.as_ref() } + + pub fn with_interstitial_rule(mut self, rule: InterstitialRule) -> Self { + self.interstitial_rule = Some(rule); + self + } + + pub fn with_mid_roll_rule(mut self, rule: MidRollRule) -> Self { + self.mid_roll_rule = Some(rule); + self + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/domain/src/services/schedule/mod.rs b/crates/domain/src/services/schedule/mod.rs index f5b4523..f0fbdfa 100644 --- a/crates/domain/src/services/schedule/mod.rs +++ b/crates/domain/src/services/schedule/mod.rs @@ -9,7 +9,7 @@ use crate::models::{ ScheduledSlot, }; use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery}; -use crate::value_objects::{BlockId, ChannelId, FillStrategy, LibrarySearchFilter, MediaItemId, RotationPolicy, Weekday}; +use crate::value_objects::{BlockId, ChannelId, FillStrategy, InterstitialRule, LibrarySearchFilter, MediaItemId, MediaRole, RotationPolicy, Weekday}; mod fill; mod rotation; @@ -248,10 +248,10 @@ impl ScheduleEngineService { window: BlockTimeWindow, rotation: RotationContext<'_>, ) -> DomainResult> { - match block.content() { + let program_slots = match block.content() { BlockContent::Manual { items } => { self.resolve_manual(items, window.start, window.end, block.id()) - .await + .await? } BlockContent::Algorithmic { filter, @@ -265,12 +265,85 @@ impl ScheduleEngineService { loop_on_finish: block.loop_on_finish(), ignore_rotation_policy: block.ignore_rotation_policy(), }, - window, + BlockTimeWindow { + start: window.start, + end: window.end, + }, rotation, ) + .await? + } + }; + + if let Some(rule) = block.interstitial_rule() { + self.insert_interstitials(program_slots, rule, block.id(), window.end) .await + } else { + Ok(program_slots) + } + } + + async fn insert_interstitials( + &self, + program_slots: Vec, + rule: &InterstitialRule, + block_id: BlockId, + block_end: DateTime, + ) -> DomainResult> { + if program_slots.len() < 2 { + return Ok(program_slots); + } + + let mut filter = media_filter_to_library_search(rule.pool_filter()); + filter = filter.with_role(MediaRole::Interstitial); + let (interstitials, _) = self.library_query.search(&filter).await?; + + if interstitials.is_empty() { + return Ok(program_slots); + } + + let mut result: Vec = Vec::new(); + let mut cursor = program_slots[0].start_at(); + let mut interstitial_idx = 0; + + for (i, slot) in program_slots.iter().enumerate() { + if cursor >= block_end { + break; + } + + let program_duration = slot.item().duration_secs(); + let program_end = (cursor + Duration::seconds(program_duration as i64)).min(block_end); + result.push(ScheduledSlot::new( + cursor, + program_end, + slot.item().clone(), + block_id, + )); + cursor = program_end; + + let should_insert = i + 1 < program_slots.len() + && program_duration >= rule.min_gap_secs() + && cursor < block_end; + + if should_insert { + let interstitial = &interstitials[interstitial_idx % interstitials.len()]; + let interstitial_end = + (cursor + Duration::seconds(interstitial.duration_secs() as i64)) + .min(block_end); + if interstitial_end > cursor { + result.push(ScheduledSlot::new( + cursor, + interstitial_end, + interstitial.clone(), + block_id, + )); + cursor = interstitial_end; + interstitial_idx += 1; + } } } + + Ok(result) } async fn resolve_manual( @@ -304,7 +377,8 @@ impl ScheduleEngineService { window: BlockTimeWindow, rotation: RotationContext<'_>, ) -> DomainResult> { - let library_filter = media_filter_to_library_search(params.filter); + let library_filter = + media_filter_to_library_search(params.filter).with_role(MediaRole::Program); let (candidates, _total) = self.library_query.search(&library_filter).await?; if candidates.is_empty() { diff --git a/crates/domain/src/services/schedule/tests/integration.rs b/crates/domain/src/services/schedule/tests/integration.rs index 7c74214..ce833ab 100644 --- a/crates/domain/src/services/schedule/tests/integration.rs +++ b/crates/domain/src/services/schedule/tests/integration.rs @@ -8,7 +8,7 @@ use crate::testing::{ InMemoryChannelRepository, InMemoryLibraryRepository, InMemoryScheduleRepository, }; use crate::value_objects::{ - ContentType, FillStrategy, MediaFilter, MediaItemId, Weekday, + ContentType, FillStrategy, InterstitialRule, MediaFilter, MediaItemId, MediaRole, Weekday, }; fn episode(id: &str, series: &str, ep: u32, secs: u32) -> MediaItem { @@ -276,3 +276,147 @@ async fn random_produces_schedule_within_budget() { assert!(!schedule.slots().is_empty(), "random strategy produced no slots"); } + +fn interstitial(id: &str, secs: u32) -> MediaItem { + let item = MediaItem::new(MediaItemId::new(id), id, ContentType::Short, secs); + let mut val = serde_json::to_value(&item).unwrap(); + val["role"] = serde_json::Value::String("interstitial".into()); + serde_json::from_value(val).unwrap() +} + +#[tokio::test] +async fn interstitial_inserted_between_programs() { + let h = TestHarness::new(); + h.seed_items(vec![ + movie("prog1", 600), + movie("prog2", 600), + movie("prog3", 600), + interstitial("bump1", 30), + interstitial("bump2", 30), + ]); + + let block = ProgrammingBlock::new_algorithmic( + "with-interstitials", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 60, + MediaFilter::default(), + FillStrategy::BestFit, + ) + .with_interstitial_rule(InterstitialRule::new( + MediaFilter::default(), + FillStrategy::Sequential, + 0, + )); + 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 program_count = today_slots + .iter() + .filter(|s| *s.item().role() == MediaRole::Program) + .count(); + let interstitial_count = today_slots + .iter() + .filter(|s| *s.item().role() == MediaRole::Interstitial) + .count(); + + assert!(program_count >= 3, "expected >= 3 programs, got {program_count}"); + assert!( + interstitial_count >= 2, + "expected >= 2 interstitials, got {interstitial_count}" + ); + + for pair in today_slots.windows(2) { + assert!( + pair[0].end_at() <= pair[1].start_at(), + "slots overlap: {} ends at {:?} but {} starts at {:?}", + pair[0].item().title(), + pair[0].end_at(), + pair[1].item().title(), + pair[1].start_at(), + ); + } +} + +#[tokio::test] +async fn interstitial_no_matching_items_still_generates() { + let h = TestHarness::new(); + h.seed_items(vec![ + movie("prog1", 600), + movie("prog2", 600), + ]); + + let block = ProgrammingBlock::new_algorithmic( + "no-interstitials-available", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 30, + MediaFilter::default(), + FillStrategy::BestFit, + ) + .with_interstitial_rule(InterstitialRule::new( + MediaFilter::default(), + FillStrategy::Sequential, + 0, + )); + let channel = h.create_channel_with_block(block).await; + + let schedule = h + .engine + .generate_schedule(channel.id(), Utc::now()) + .await + .unwrap(); + + assert!(!schedule.slots().is_empty(), "should still produce program slots"); + assert!( + schedule.slots().iter().all(|s| *s.item().role() == MediaRole::Program), + "all slots should be programs when no interstitials available" + ); +} + +#[tokio::test] +async fn interstitial_min_gap_respected() { + let h = TestHarness::new(); + h.seed_items(vec![ + movie("short1", 100), + movie("short2", 100), + movie("short3", 100), + interstitial("bump1", 30), + ]); + + let block = ProgrammingBlock::new_algorithmic( + "min-gap-test", + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + 30, + MediaFilter::default(), + FillStrategy::BestFit, + ) + .with_interstitial_rule(InterstitialRule::new( + MediaFilter::default(), + FillStrategy::Sequential, + 200, + )); + let channel = h.create_channel_with_block(block).await; + + let schedule = h + .engine + .generate_schedule(channel.id(), Utc::now()) + .await + .unwrap(); + + let interstitial_count = schedule + .slots() + .iter() + .filter(|s| *s.item().role() == MediaRole::Interstitial) + .count(); + + assert_eq!( + interstitial_count, 0, + "no interstitials should be inserted when programs are shorter than min_gap_secs" + ); +}