wire interstitial insertion into schedule engine resolve_block

This commit is contained in:
2026-07-12 14:05:33 +02:00
parent 8e4f724562
commit 8edd7a9c0b
3 changed files with 234 additions and 6 deletions

View File

@@ -426,6 +426,16 @@ impl ProgrammingBlock {
pub fn mid_roll_rule(&self) -> Option<&MidRollRule> { pub fn mid_roll_rule(&self) -> Option<&MidRollRule> {
self.mid_roll_rule.as_ref() 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)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -9,7 +9,7 @@ use crate::models::{
ScheduledSlot, ScheduledSlot,
}; };
use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery}; 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 fill;
mod rotation; mod rotation;
@@ -248,10 +248,10 @@ impl ScheduleEngineService {
window: BlockTimeWindow, window: BlockTimeWindow,
rotation: RotationContext<'_>, rotation: RotationContext<'_>,
) -> DomainResult<Vec<ScheduledSlot>> { ) -> DomainResult<Vec<ScheduledSlot>> {
match block.content() { let program_slots = match block.content() {
BlockContent::Manual { items } => { BlockContent::Manual { items } => {
self.resolve_manual(items, window.start, window.end, block.id()) self.resolve_manual(items, window.start, window.end, block.id())
.await .await?
} }
BlockContent::Algorithmic { BlockContent::Algorithmic {
filter, filter,
@@ -265,12 +265,85 @@ impl ScheduleEngineService {
loop_on_finish: block.loop_on_finish(), loop_on_finish: block.loop_on_finish(),
ignore_rotation_policy: block.ignore_rotation_policy(), ignore_rotation_policy: block.ignore_rotation_policy(),
}, },
window, BlockTimeWindow {
start: window.start,
end: window.end,
},
rotation, rotation,
) )
.await?
}
};
if let Some(rule) = block.interstitial_rule() {
self.insert_interstitials(program_slots, rule, block.id(), window.end)
.await .await
} else {
Ok(program_slots)
} }
} }
async fn insert_interstitials(
&self,
program_slots: Vec<ScheduledSlot>,
rule: &InterstitialRule,
block_id: BlockId,
block_end: DateTime<Utc>,
) -> DomainResult<Vec<ScheduledSlot>> {
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<ScheduledSlot> = 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( async fn resolve_manual(
@@ -304,7 +377,8 @@ impl ScheduleEngineService {
window: BlockTimeWindow, window: BlockTimeWindow,
rotation: RotationContext<'_>, rotation: RotationContext<'_>,
) -> DomainResult<Vec<ScheduledSlot>> { ) -> DomainResult<Vec<ScheduledSlot>> {
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?; let (candidates, _total) = self.library_query.search(&library_filter).await?;
if candidates.is_empty() { if candidates.is_empty() {

View File

@@ -8,7 +8,7 @@ use crate::testing::{
InMemoryChannelRepository, InMemoryLibraryRepository, InMemoryScheduleRepository, InMemoryChannelRepository, InMemoryLibraryRepository, InMemoryScheduleRepository,
}; };
use crate::value_objects::{ 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 { 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"); 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"
);
}