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> {
|
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)]
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user