From 6a4eb099cbd51208c417b8791675aa306ec243b4 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 13 Mar 2026 01:53:02 +0100 Subject: [PATCH] feat(schedule): add loop and recycle policy options to programming blocks --- k-tv-backend/domain/src/entities.rs | 18 +++++++ .../domain/src/services/schedule/fill.rs | 46 +++++++++++----- .../domain/src/services/schedule/mod.rs | 13 ++++- .../components/edit-channel-sheet.tsx | 52 ++++++++++++++++--- k-tv-frontend/lib/types.ts | 4 ++ 5 files changed, 111 insertions(+), 22 deletions(-) diff --git a/k-tv-backend/domain/src/entities.rs b/k-tv-backend/domain/src/entities.rs index 97c7ff1..85362e5 100644 --- a/k-tv-backend/domain/src/entities.rs +++ b/k-tv-backend/domain/src/entities.rs @@ -164,6 +164,20 @@ pub struct ProgrammingBlock { /// possible; remaining time at the end becomes dead air (no-signal). pub duration_mins: u32, pub content: BlockContent, + + /// Sequential only: loop back to episode 1 after the last episode. Default: true. + #[serde(default = "default_true")] + pub loop_on_finish: bool, + + /// When true, skip the channel-level recycle policy for this block. + /// Useful for dedicated sequential blocks that must always play in order + /// regardless of what other blocks aired. + #[serde(default)] + pub ignore_recycle_policy: bool, +} + +fn default_true() -> bool { + true } impl ProgrammingBlock { @@ -180,6 +194,8 @@ impl ProgrammingBlock { start_time, duration_mins, content: BlockContent::Algorithmic { filter, strategy }, + loop_on_finish: true, + ignore_recycle_policy: false, } } @@ -195,6 +211,8 @@ impl ProgrammingBlock { start_time, duration_mins, content: BlockContent::Manual { items }, + loop_on_finish: true, + ignore_recycle_policy: false, } } } diff --git a/k-tv-backend/domain/src/services/schedule/fill.rs b/k-tv-backend/domain/src/services/schedule/fill.rs index a3dfa52..a69a61d 100644 --- a/k-tv-backend/domain/src/services/schedule/fill.rs +++ b/k-tv-backend/domain/src/services/schedule/fill.rs @@ -11,10 +11,13 @@ pub(super) fn fill_block<'a>( target_secs: u32, strategy: &FillStrategy, last_item_id: Option<&MediaItemId>, + loop_on_finish: bool, ) -> Vec<&'a MediaItem> { match strategy { FillStrategy::BestFit => fill_best_fit(pool, target_secs), - FillStrategy::Sequential => fill_sequential(candidates, pool, target_secs, last_item_id), + FillStrategy::Sequential => { + fill_sequential(candidates, pool, target_secs, last_item_id, loop_on_finish) + } FillStrategy::Random => { let mut indices: Vec = (0..pool.len()).collect(); indices.shuffle(&mut rand::thread_rng()); @@ -84,6 +87,7 @@ pub(super) fn fill_sequential<'a>( pool: &'a [MediaItem], target_secs: u32, last_item_id: Option<&MediaItemId>, + loop_on_finish: bool, ) -> Vec<&'a MediaItem> { if pool.is_empty() { return vec![]; @@ -92,19 +96,35 @@ pub(super) fn fill_sequential<'a>( // Set of item IDs currently eligible to air. let available: HashSet<&MediaItemId> = pool.iter().map(|i| &i.id).collect(); - // Find where in the full ordered list to resume. - // Falls back to index 0 if last_item_id is absent or was removed from the library. - let start_idx = last_item_id - .and_then(|id| candidates.iter().position(|c| &c.id == id)) - .map(|pos| (pos + 1) % candidates.len()) - .unwrap_or(0); + let ordered: Vec<&MediaItem> = if loop_on_finish { + // Find where in the full ordered list to resume, wrapping around. + // Falls back to index 0 if last_item_id is absent or was removed from the library. + let start_idx = last_item_id + .and_then(|id| candidates.iter().position(|c| &c.id == id)) + .map(|pos| (pos + 1) % candidates.len()) + .unwrap_or(0); - // Walk candidates in order from start_idx, wrapping around once, - // skipping any that are on cooldown (not in `available`). - let ordered: Vec<&MediaItem> = (0..candidates.len()) - .map(|i| &candidates[(start_idx + i) % candidates.len()]) - .filter(|item| available.contains(&item.id)) - .collect(); + (0..candidates.len()) + .map(|i| &candidates[(start_idx + i) % candidates.len()]) + .filter(|item| available.contains(&item.id)) + .collect() + } else { + // No wrap: compute raw next position without modulo. + // If the series has finished (next_pos >= len), return dead air. + let next_pos = last_item_id + .and_then(|id| candidates.iter().position(|c| &c.id == id)) + .map(|pos| pos + 1) + .unwrap_or(0); + + if next_pos >= candidates.len() { + return vec![]; // series finished — dead air + } + + candidates[next_pos..] + .iter() + .filter(|item| available.contains(&item.id)) + .collect() + }; // Greedily fill the block's time budget in episode order. let mut remaining = target_secs; diff --git a/k-tv-backend/domain/src/services/schedule/mod.rs b/k-tv-backend/domain/src/services/schedule/mod.rs index d832f1e..a56e725 100644 --- a/k-tv-backend/domain/src/services/schedule/mod.rs +++ b/k-tv-backend/domain/src/services/schedule/mod.rs @@ -255,6 +255,8 @@ impl ScheduleEngineService { self.resolve_algorithmic( filter, strategy, start, end, history, policy, generation, block.id, last_item_id, + block.loop_on_finish, + block.ignore_recycle_policy, ) .await } @@ -311,6 +313,8 @@ impl ScheduleEngineService { generation: u32, block_id: BlockId, last_item_id: Option<&MediaItemId>, + loop_on_finish: bool, + ignore_recycle_policy: bool, ) -> DomainResult> { // `candidates` — all items matching the filter, in provider order. // Kept separate from `pool` so Sequential can rotate through the full @@ -321,9 +325,14 @@ impl ScheduleEngineService { return Ok(vec![]); } - let pool = recycle::apply_recycle_policy(&candidates, history, policy, generation); + let pool = if ignore_recycle_policy { + candidates.clone() + } else { + recycle::apply_recycle_policy(&candidates, history, policy, generation) + }; let target_secs = (end - start).num_seconds() as u32; - let selected = fill::fill_block(&candidates, &pool, target_secs, strategy, last_item_id); + let selected = + fill::fill_block(&candidates, &pool, target_secs, strategy, last_item_id, loop_on_finish); let mut slots = Vec::new(); let mut cursor = start; diff --git a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx index a2e30b0..cbeb864 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx @@ -58,6 +58,8 @@ const blockSchema = z.object({ items: z.array(z.string()), }), ]), + loop_on_finish: z.boolean().optional(), + ignore_recycle_policy: z.boolean().optional(), }); const channelFormSchema = z.object({ @@ -211,6 +213,8 @@ function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock start_time: minsToTime(startMins), duration_mins: durationMins, content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" }, + loop_on_finish: true, + ignore_recycle_policy: false, }; } @@ -534,13 +538,47 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo {content.type === "algorithmic" && ( - + <> + + + {content.strategy === "sequential" && ( +
+

+ Sequential options +

+ + +
+ )} + )} {content.type === "manual" && ( diff --git a/k-tv-frontend/lib/types.ts b/k-tv-frontend/lib/types.ts index cc19d55..edc8d68 100644 --- a/k-tv-frontend/lib/types.ts +++ b/k-tv-frontend/lib/types.ts @@ -63,6 +63,10 @@ export interface ProgrammingBlock { start_time: string; duration_mins: number; content: BlockContent; + /** Sequential only: loop back to episode 1 after the last episode. Default true on backend. */ + loop_on_finish?: boolean; + /** When true, skip the channel-level recycle policy for this block. Default false on backend. */ + ignore_recycle_policy?: boolean; } export interface ScheduleConfig {