wire interstitial insertion into schedule engine resolve_block
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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<Vec<ScheduledSlot>> {
|
||||
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<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(
|
||||
@@ -304,7 +377,8 @@ impl ScheduleEngineService {
|
||||
window: BlockTimeWindow,
|
||||
rotation: RotationContext<'_>,
|
||||
) -> 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?;
|
||||
|
||||
if candidates.is_empty() {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user