wire mid-roll breaks into schedule engine with chapter-aware splitting
This commit is contained in:
@@ -10,7 +10,7 @@ use crate::models::{
|
||||
};
|
||||
use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery};
|
||||
use crate::models::MediaItem;
|
||||
use crate::value_objects::{BlockId, ChannelId, FillStrategy, InterstitialRule, LibrarySearchFilter, MediaFilter, MediaItemId, MediaRole, RotationPolicy, Weekday};
|
||||
use crate::value_objects::{BlockId, ChannelId, FillStrategy, InterstitialRule, LibrarySearchFilter, MediaFilter, MediaItemId, MediaRole, MidRollRule, RotationPolicy, Weekday};
|
||||
|
||||
mod fill;
|
||||
mod rotation;
|
||||
@@ -283,11 +283,18 @@ impl ScheduleEngineService {
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(rule) = block.interstitial_rule() {
|
||||
let with_interstitials = if let Some(rule) = block.interstitial_rule() {
|
||||
self.insert_interstitials(program_slots, rule, block.id(), window.end)
|
||||
.await?
|
||||
} else {
|
||||
program_slots
|
||||
};
|
||||
|
||||
if let Some(rule) = block.mid_roll_rule() {
|
||||
self.apply_mid_rolls(with_interstitials, rule, block.id(), window.end)
|
||||
.await
|
||||
} else {
|
||||
Ok(program_slots)
|
||||
Ok(with_interstitials)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +361,132 @@ impl ScheduleEngineService {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn apply_mid_rolls(
|
||||
&self,
|
||||
slots: Vec<ScheduledSlot>,
|
||||
rule: &MidRollRule,
|
||||
block_id: BlockId,
|
||||
block_end: DateTime<Utc>,
|
||||
) -> DomainResult<Vec<ScheduledSlot>> {
|
||||
let interval_secs = rule.fallback_interval_mins() as u64 * 60;
|
||||
if interval_secs == 0 {
|
||||
return Ok(slots);
|
||||
}
|
||||
|
||||
let mut filter = media_filter_to_library_search(rule.pool_filter());
|
||||
filter = filter.with_role(MediaRole::Interstitial);
|
||||
let (break_items, _) = self.library_query.search(&filter).await?;
|
||||
|
||||
if break_items.is_empty() {
|
||||
return Ok(slots);
|
||||
}
|
||||
|
||||
let mut result: Vec<ScheduledSlot> = Vec::new();
|
||||
let mut break_idx = 0usize;
|
||||
|
||||
for slot in &slots {
|
||||
let item_duration = slot.item().duration_secs() as u64;
|
||||
|
||||
if item_duration < interval_secs {
|
||||
result.push(slot.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
let break_points = Self::compute_break_points(
|
||||
slot.item(),
|
||||
rule.prefer_chapters(),
|
||||
interval_secs,
|
||||
);
|
||||
|
||||
if break_points.is_empty() {
|
||||
result.push(slot.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut cursor = slot.start_at();
|
||||
let mut prev_offset = 0u64;
|
||||
|
||||
for bp in &break_points {
|
||||
if cursor >= block_end {
|
||||
break;
|
||||
}
|
||||
|
||||
let segment_duration = bp - prev_offset;
|
||||
let segment_end =
|
||||
(cursor + Duration::seconds(segment_duration as i64)).min(block_end);
|
||||
|
||||
result.push(ScheduledSlot::new(
|
||||
cursor,
|
||||
segment_end,
|
||||
slot.item().clone(),
|
||||
block_id,
|
||||
));
|
||||
cursor = segment_end;
|
||||
prev_offset = *bp;
|
||||
|
||||
if cursor < block_end {
|
||||
let break_item = &break_items[break_idx % break_items.len()];
|
||||
let break_end_secs = rule.break_duration_secs().min(break_item.duration_secs());
|
||||
let break_end =
|
||||
(cursor + Duration::seconds(break_end_secs as i64)).min(block_end);
|
||||
if break_end > cursor {
|
||||
result.push(ScheduledSlot::new(
|
||||
cursor,
|
||||
break_end,
|
||||
break_item.clone(),
|
||||
block_id,
|
||||
));
|
||||
cursor = break_end;
|
||||
break_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let remaining = item_duration - prev_offset;
|
||||
if remaining > 0 && cursor < block_end {
|
||||
let tail_end =
|
||||
(cursor + Duration::seconds(remaining as i64)).min(block_end);
|
||||
result.push(ScheduledSlot::new(
|
||||
cursor,
|
||||
tail_end,
|
||||
slot.item().clone(),
|
||||
block_id,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn compute_break_points(
|
||||
item: &MediaItem,
|
||||
prefer_chapters: bool,
|
||||
interval_secs: u64,
|
||||
) -> Vec<u64> {
|
||||
if prefer_chapters && !item.chapters().is_empty() {
|
||||
item.chapters()
|
||||
.iter()
|
||||
.filter_map(|ch| {
|
||||
let end = ch.end_secs() as u64;
|
||||
if end > 0 && end < item.duration_secs() as u64 {
|
||||
Some(end)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
let duration = item.duration_secs() as u64;
|
||||
let mut points = Vec::new();
|
||||
let mut offset = interval_secs;
|
||||
while offset < duration {
|
||||
points.push(offset);
|
||||
offset += interval_secs;
|
||||
}
|
||||
points
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_manual(
|
||||
&self,
|
||||
item_ids: &[MediaItemId],
|
||||
@@ -458,10 +591,10 @@ impl ScheduleEngineService {
|
||||
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));
|
||||
}
|
||||
if let Some(last) = slots.last()
|
||||
&& last.end_at() < valid_until
|
||||
{
|
||||
boundaries.push((last.end_at(), valid_until));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,10 +642,8 @@ fn media_filter_to_library_search(filter: &crate::value_objects::MediaFilter) ->
|
||||
if let Some(max) = filter.max_duration_secs {
|
||||
lsf = lsf.with_max_duration_secs(max);
|
||||
}
|
||||
if !filter.collections.is_empty() {
|
||||
if let Some(first) = filter.collections.first() {
|
||||
lsf = lsf.with_collection_id(first.clone());
|
||||
}
|
||||
if let Some(first) = filter.collections.first() {
|
||||
lsf = lsf.with_collection_id(first.clone());
|
||||
}
|
||||
if !filter.series_names.is_empty() {
|
||||
lsf = lsf.with_series_names(filter.series_names.clone());
|
||||
|
||||
Reference in New Issue
Block a user