wire mid-roll breaks into schedule engine with chapter-aware splitting
This commit is contained in:
@@ -311,10 +311,10 @@ pub(super) fn fill_marathon<'a>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.is_empty() {
|
if result.is_empty()
|
||||||
if let Some(&first) = ordered.first() {
|
&& let Some(&first) = ordered.first()
|
||||||
result.push(first);
|
{
|
||||||
}
|
result.push(first);
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::models::{
|
|||||||
};
|
};
|
||||||
use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery};
|
use crate::ports::{ChannelQuery, LibraryQuery, ScheduleCommand, ScheduleQuery};
|
||||||
use crate::models::MediaItem;
|
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 fill;
|
||||||
mod rotation;
|
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)
|
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
|
.await
|
||||||
} else {
|
} else {
|
||||||
Ok(program_slots)
|
Ok(with_interstitials)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +361,132 @@ impl ScheduleEngineService {
|
|||||||
Ok(result)
|
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(
|
async fn resolve_manual(
|
||||||
&self,
|
&self,
|
||||||
item_ids: &[MediaItemId],
|
item_ids: &[MediaItemId],
|
||||||
@@ -458,10 +591,10 @@ impl ScheduleEngineService {
|
|||||||
boundaries.push((pair[0].end_at(), pair[1].start_at()));
|
boundaries.push((pair[0].end_at(), pair[1].start_at()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(last) = slots.last() {
|
if let Some(last) = slots.last()
|
||||||
if last.end_at() < valid_until {
|
&& last.end_at() < valid_until
|
||||||
boundaries.push((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 {
|
if let Some(max) = filter.max_duration_secs {
|
||||||
lsf = lsf.with_max_duration_secs(max);
|
lsf = lsf.with_max_duration_secs(max);
|
||||||
}
|
}
|
||||||
if !filter.collections.is_empty() {
|
if let Some(first) = filter.collections.first() {
|
||||||
if let Some(first) = filter.collections.first() {
|
lsf = lsf.with_collection_id(first.clone());
|
||||||
lsf = lsf.with_collection_id(first.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if !filter.series_names.is_empty() {
|
if !filter.series_names.is_empty() {
|
||||||
lsf = lsf.with_series_names(filter.series_names.clone());
|
lsf = lsf.with_series_names(filter.series_names.clone());
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use crate::testing::{
|
|||||||
InMemoryChannelRepository, InMemoryLibraryRepository, InMemoryScheduleRepository,
|
InMemoryChannelRepository, InMemoryLibraryRepository, InMemoryScheduleRepository,
|
||||||
};
|
};
|
||||||
use crate::value_objects::{
|
use crate::value_objects::{
|
||||||
ContentType, FillStrategy, InterstitialRule, MediaFilter, MediaItemId, MediaRole, Weekday,
|
Chapter, ContentType, FillStrategy, InterstitialRule, MediaFilter, MediaItemId, MediaRole,
|
||||||
|
MidRollRule, Weekday,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn episode(id: &str, series: &str, ep: u32, secs: u32) -> MediaItem {
|
fn episode(id: &str, series: &str, ep: u32, secs: u32) -> MediaItem {
|
||||||
@@ -555,3 +556,161 @@ async fn no_gap_filler_leaves_gaps_empty() {
|
|||||||
"no gap filler configured, should have no interstitial slots"
|
"no gap filler configured, should have no interstitial slots"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn movie_with_chapters(id: &str, secs: u32, chapters: Vec<Chapter>) -> MediaItem {
|
||||||
|
let item = MediaItem::new(MediaItemId::new(id), id, ContentType::Movie, secs);
|
||||||
|
let mut val = serde_json::to_value(&item).unwrap();
|
||||||
|
val["chapters"] = serde_json::to_value(&chapters).unwrap();
|
||||||
|
serde_json::from_value(val).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mid_roll_splits_long_movie_at_intervals() {
|
||||||
|
let h = TestHarness::new();
|
||||||
|
h.seed_items(vec![
|
||||||
|
movie("long-movie", 7200),
|
||||||
|
interstitial("ad1", 120),
|
||||||
|
interstitial("ad2", 120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let block = ProgrammingBlock::new_algorithmic(
|
||||||
|
"movie-block",
|
||||||
|
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
|
||||||
|
180,
|
||||||
|
MediaFilter::default(),
|
||||||
|
FillStrategy::BestFit,
|
||||||
|
)
|
||||||
|
.with_mid_roll_rule(MidRollRule::new(
|
||||||
|
false,
|
||||||
|
30,
|
||||||
|
120,
|
||||||
|
MediaFilter::default(),
|
||||||
|
));
|
||||||
|
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(20).collect();
|
||||||
|
|
||||||
|
let movie_segments = today_slots
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.item().id().value() == "long-movie")
|
||||||
|
.count();
|
||||||
|
let break_count = today_slots
|
||||||
|
.iter()
|
||||||
|
.filter(|s| *s.item().role() == MediaRole::Interstitial)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
movie_segments >= 2,
|
||||||
|
"2h movie with 30min interval should produce >= 2 segments, got {movie_segments}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
break_count >= 1,
|
||||||
|
"should have at least 1 break slot, got {break_count}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mid_roll_prefers_chapter_boundaries() {
|
||||||
|
let h = TestHarness::new();
|
||||||
|
h.seed_items(vec![
|
||||||
|
movie_with_chapters(
|
||||||
|
"chaptered-movie",
|
||||||
|
7200,
|
||||||
|
vec![
|
||||||
|
Chapter::new(Some("Act 1".into()), 0.0, 2400.0),
|
||||||
|
Chapter::new(Some("Act 2".into()), 2400.0, 4800.0),
|
||||||
|
Chapter::new(Some("Act 3".into()), 4800.0, 7200.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
interstitial("ad1", 120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let block = ProgrammingBlock::new_algorithmic(
|
||||||
|
"chapter-block",
|
||||||
|
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
|
||||||
|
180,
|
||||||
|
MediaFilter::default(),
|
||||||
|
FillStrategy::BestFit,
|
||||||
|
)
|
||||||
|
.with_mid_roll_rule(MidRollRule::new(
|
||||||
|
true,
|
||||||
|
30,
|
||||||
|
120,
|
||||||
|
MediaFilter::default(),
|
||||||
|
));
|
||||||
|
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(20).collect();
|
||||||
|
|
||||||
|
let movie_segments = today_slots
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.item().id().value() == "chaptered-movie")
|
||||||
|
.count();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
movie_segments >= 3,
|
||||||
|
"chaptered movie should split at chapter boundaries, got {movie_segments} segments"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mid_roll_short_item_not_split() {
|
||||||
|
let h = TestHarness::new();
|
||||||
|
h.seed_items(vec![
|
||||||
|
movie("short-movie", 1200),
|
||||||
|
interstitial("ad1", 120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let block = ProgrammingBlock::new_algorithmic(
|
||||||
|
"short-block",
|
||||||
|
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
|
||||||
|
60,
|
||||||
|
MediaFilter::default(),
|
||||||
|
FillStrategy::BestFit,
|
||||||
|
)
|
||||||
|
.with_mid_roll_rule(MidRollRule::new(
|
||||||
|
false,
|
||||||
|
30,
|
||||||
|
120,
|
||||||
|
MediaFilter::default(),
|
||||||
|
));
|
||||||
|
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 movie_segments = today_slots
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.item().id().value() == "short-movie")
|
||||||
|
.count();
|
||||||
|
let break_count = today_slots
|
||||||
|
.iter()
|
||||||
|
.filter(|s| *s.item().role() == MediaRole::Interstitial)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
movie_segments, 1,
|
||||||
|
"20min movie under 30min interval should not be split"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
break_count, 0,
|
||||||
|
"no breaks for short items"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,13 +128,15 @@ impl KTvMcpServer {
|
|||||||
};
|
};
|
||||||
channels::update_channel(
|
channels::update_channel(
|
||||||
&self.channel_cmd_deps,
|
&self.channel_cmd_deps,
|
||||||
id,
|
channels::UpdateChannelArgs {
|
||||||
self.owner_id,
|
channel_id: id,
|
||||||
p.name,
|
owner_id: self.owner_id,
|
||||||
p.timezone,
|
name: p.name,
|
||||||
p.description,
|
timezone: p.timezone,
|
||||||
schedule_config,
|
description: p.description,
|
||||||
gap_filler,
|
schedule_config,
|
||||||
|
gap_filler,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,26 +45,30 @@ pub async fn create_channel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct UpdateChannelArgs {
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub owner_id: Uuid,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub timezone: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub schedule_config: Option<domain::ScheduleConfig>,
|
||||||
|
pub gap_filler: Option<Option<domain::MediaFilter>>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn update_channel(
|
pub async fn update_channel(
|
||||||
cmd_deps: &Arc<ChannelCommandDeps>,
|
cmd_deps: &Arc<ChannelCommandDeps>,
|
||||||
channel_id: Uuid,
|
args: UpdateChannelArgs,
|
||||||
owner_id: Uuid,
|
|
||||||
name: Option<String>,
|
|
||||||
timezone: Option<String>,
|
|
||||||
description: Option<String>,
|
|
||||||
schedule_config: Option<domain::ScheduleConfig>,
|
|
||||||
gap_filler: Option<Option<domain::MediaFilter>>,
|
|
||||||
) -> String {
|
) -> String {
|
||||||
let cmd = UpdateChannelCommand {
|
let cmd = UpdateChannelCommand {
|
||||||
channel_id: channel_id.into(),
|
channel_id: args.channel_id.into(),
|
||||||
owner_id: owner_id.into(),
|
owner_id: args.owner_id.into(),
|
||||||
name,
|
name: args.name,
|
||||||
description: description.map(Some),
|
description: args.description.map(Some),
|
||||||
timezone,
|
timezone: args.timezone,
|
||||||
schedule_config,
|
schedule_config: args.schedule_config,
|
||||||
rotation_policy: None,
|
rotation_policy: None,
|
||||||
auto_schedule: None,
|
auto_schedule: None,
|
||||||
gap_filler,
|
gap_filler: args.gap_filler,
|
||||||
};
|
};
|
||||||
match application::channels::update::execute(cmd_deps, cmd).await {
|
match application::channels::update::execute(cmd_deps, cmd).await {
|
||||||
Ok(channel) => ok_json(&channel),
|
Ok(channel) => ok_json(&channel),
|
||||||
|
|||||||
Reference in New Issue
Block a user