wire gap_filler into schedule engine, API, MCP
This commit is contained in:
@@ -9,7 +9,8 @@ use crate::models::{
|
||||
ScheduledSlot,
|
||||
};
|
||||
use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery};
|
||||
use crate::value_objects::{BlockId, ChannelId, FillStrategy, InterstitialRule, LibrarySearchFilter, MediaItemId, MediaRole, RotationPolicy, Weekday};
|
||||
use crate::models::MediaItem;
|
||||
use crate::value_objects::{BlockId, ChannelId, FillStrategy, InterstitialRule, LibrarySearchFilter, MediaFilter, MediaItemId, MediaRole, RotationPolicy, Weekday};
|
||||
|
||||
mod fill;
|
||||
mod rotation;
|
||||
@@ -153,6 +154,13 @@ impl ScheduleEngineService {
|
||||
|
||||
slots.sort_by_key(|s| s.start_at());
|
||||
|
||||
if let Some(gap_filter) = channel.gap_filler() {
|
||||
let filler_items = self.query_gap_fillers(gap_filter).await?;
|
||||
if !filler_items.is_empty() {
|
||||
Self::fill_gaps(&mut slots, &filler_items, valid_from, valid_until);
|
||||
}
|
||||
}
|
||||
|
||||
let schedule = GeneratedSchedule::new(
|
||||
channel_id,
|
||||
valid_from,
|
||||
@@ -415,6 +423,71 @@ impl ScheduleEngineService {
|
||||
|
||||
Ok(slots)
|
||||
}
|
||||
|
||||
async fn query_gap_fillers(&self, gap_filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
|
||||
let filter =
|
||||
media_filter_to_library_search(gap_filter).with_role(MediaRole::Interstitial);
|
||||
let (items, _) = self.library_query.search(&filter).await?;
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn fill_gaps(
|
||||
slots: &mut Vec<ScheduledSlot>,
|
||||
fillers: &[MediaItem],
|
||||
valid_from: DateTime<Utc>,
|
||||
valid_until: DateTime<Utc>,
|
||||
) {
|
||||
if fillers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let gap_block_id = BlockId::generate();
|
||||
let mut gap_slots: Vec<ScheduledSlot> = Vec::new();
|
||||
let mut filler_idx = 0usize;
|
||||
|
||||
let mut boundaries: Vec<(DateTime<Utc>, DateTime<Utc>)> = Vec::new();
|
||||
|
||||
if slots.is_empty() {
|
||||
boundaries.push((valid_from, valid_until));
|
||||
} else {
|
||||
if slots[0].start_at() > valid_from {
|
||||
boundaries.push((valid_from, slots[0].start_at()));
|
||||
}
|
||||
for pair in slots.windows(2) {
|
||||
if pair[1].start_at() > pair[0].end_at() {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (gap_start, gap_end) in boundaries {
|
||||
let mut cursor = gap_start;
|
||||
while cursor < gap_end {
|
||||
let filler = &fillers[filler_idx % fillers.len()];
|
||||
let filler_end =
|
||||
(cursor + Duration::seconds(filler.duration_secs() as i64)).min(gap_end);
|
||||
if filler_end <= cursor {
|
||||
break;
|
||||
}
|
||||
gap_slots.push(ScheduledSlot::new(
|
||||
cursor,
|
||||
filler_end,
|
||||
filler.clone(),
|
||||
gap_block_id,
|
||||
));
|
||||
cursor = filler_end;
|
||||
filler_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
slots.append(&mut gap_slots);
|
||||
slots.sort_by_key(|s| s.start_at());
|
||||
}
|
||||
}
|
||||
|
||||
fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) -> LibrarySearchFilter {
|
||||
|
||||
@@ -420,3 +420,138 @@ async fn interstitial_min_gap_respected() {
|
||||
"no interstitials should be inserted when programs are shorter than min_gap_secs"
|
||||
);
|
||||
}
|
||||
|
||||
impl TestHarness {
|
||||
async fn create_channel_with_blocks_and_gap_filler(
|
||||
&self,
|
||||
blocks: Vec<ProgrammingBlock>,
|
||||
gap_filler: Option<MediaFilter>,
|
||||
) -> Channel {
|
||||
let mut channel = Channel::new(
|
||||
crate::value_objects::UserId::generate(),
|
||||
"test-channel",
|
||||
"UTC",
|
||||
);
|
||||
let today = Utc::now()
|
||||
.with_timezone(&chrono_tz::UTC)
|
||||
.date_naive();
|
||||
let weekday = Weekday::from(today.weekday());
|
||||
let mut config = ScheduleConfig::new();
|
||||
config.insert_day(weekday, blocks);
|
||||
channel.set_schedule_config(config);
|
||||
channel.set_gap_filler(gap_filler);
|
||||
self.channel_repo
|
||||
.channels
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(channel.id(), channel.clone());
|
||||
channel
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gap_filler_fills_between_blocks() {
|
||||
let h = TestHarness::new();
|
||||
h.seed_items(vec![
|
||||
movie("prog1", 1800),
|
||||
movie("prog2", 1800),
|
||||
interstitial("filler1", 60),
|
||||
interstitial("filler2", 60),
|
||||
]);
|
||||
|
||||
let block1 = ProgrammingBlock::new_algorithmic(
|
||||
"morning",
|
||||
NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
|
||||
30,
|
||||
MediaFilter::default(),
|
||||
FillStrategy::BestFit,
|
||||
);
|
||||
let block2 = ProgrammingBlock::new_algorithmic(
|
||||
"afternoon",
|
||||
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
|
||||
30,
|
||||
MediaFilter::default(),
|
||||
FillStrategy::BestFit,
|
||||
);
|
||||
|
||||
let channel = h
|
||||
.create_channel_with_blocks_and_gap_filler(
|
||||
vec![block1, block2],
|
||||
Some(MediaFilter::default()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let schedule = h
|
||||
.engine
|
||||
.generate_schedule(channel.id(), Utc::now())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let filler_count = schedule
|
||||
.slots()
|
||||
.iter()
|
||||
.filter(|s| *s.item().role() == MediaRole::Interstitial)
|
||||
.count();
|
||||
|
||||
assert!(
|
||||
filler_count > 0,
|
||||
"gap filler should produce interstitial slots between blocks"
|
||||
);
|
||||
|
||||
for pair in schedule.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 no_gap_filler_leaves_gaps_empty() {
|
||||
let h = TestHarness::new();
|
||||
h.seed_items(vec![
|
||||
movie("prog1", 1800),
|
||||
movie("prog2", 1800),
|
||||
interstitial("filler1", 60),
|
||||
]);
|
||||
|
||||
let block1 = ProgrammingBlock::new_algorithmic(
|
||||
"morning",
|
||||
NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
|
||||
30,
|
||||
MediaFilter::default(),
|
||||
FillStrategy::BestFit,
|
||||
);
|
||||
let block2 = ProgrammingBlock::new_algorithmic(
|
||||
"afternoon",
|
||||
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
|
||||
30,
|
||||
MediaFilter::default(),
|
||||
FillStrategy::BestFit,
|
||||
);
|
||||
|
||||
let channel = h
|
||||
.create_channel_with_blocks_and_gap_filler(vec![block1, block2], None)
|
||||
.await;
|
||||
|
||||
let schedule = h
|
||||
.engine
|
||||
.generate_schedule(channel.id(), Utc::now())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let filler_count = schedule
|
||||
.slots()
|
||||
.iter()
|
||||
.filter(|s| *s.item().role() == MediaRole::Interstitial)
|
||||
.count();
|
||||
|
||||
assert_eq!(
|
||||
filler_count, 0,
|
||||
"no gap filler configured, should have no interstitial slots"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user