feat(schedule): add loop and recycle policy options to programming blocks

This commit is contained in:
2026-03-13 01:53:02 +01:00
parent eeb4e2cb41
commit 6a4eb099cb
5 changed files with 111 additions and 22 deletions

View File

@@ -164,6 +164,20 @@ pub struct ProgrammingBlock {
/// possible; remaining time at the end becomes dead air (no-signal). /// possible; remaining time at the end becomes dead air (no-signal).
pub duration_mins: u32, pub duration_mins: u32,
pub content: BlockContent, 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 { impl ProgrammingBlock {
@@ -180,6 +194,8 @@ impl ProgrammingBlock {
start_time, start_time,
duration_mins, duration_mins,
content: BlockContent::Algorithmic { filter, strategy }, content: BlockContent::Algorithmic { filter, strategy },
loop_on_finish: true,
ignore_recycle_policy: false,
} }
} }
@@ -195,6 +211,8 @@ impl ProgrammingBlock {
start_time, start_time,
duration_mins, duration_mins,
content: BlockContent::Manual { items }, content: BlockContent::Manual { items },
loop_on_finish: true,
ignore_recycle_policy: false,
} }
} }
} }

View File

@@ -11,10 +11,13 @@ pub(super) fn fill_block<'a>(
target_secs: u32, target_secs: u32,
strategy: &FillStrategy, strategy: &FillStrategy,
last_item_id: Option<&MediaItemId>, last_item_id: Option<&MediaItemId>,
loop_on_finish: bool,
) -> Vec<&'a MediaItem> { ) -> Vec<&'a MediaItem> {
match strategy { match strategy {
FillStrategy::BestFit => fill_best_fit(pool, target_secs), 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 => { FillStrategy::Random => {
let mut indices: Vec<usize> = (0..pool.len()).collect(); let mut indices: Vec<usize> = (0..pool.len()).collect();
indices.shuffle(&mut rand::thread_rng()); indices.shuffle(&mut rand::thread_rng());
@@ -84,6 +87,7 @@ pub(super) fn fill_sequential<'a>(
pool: &'a [MediaItem], pool: &'a [MediaItem],
target_secs: u32, target_secs: u32,
last_item_id: Option<&MediaItemId>, last_item_id: Option<&MediaItemId>,
loop_on_finish: bool,
) -> Vec<&'a MediaItem> { ) -> Vec<&'a MediaItem> {
if pool.is_empty() { if pool.is_empty() {
return vec![]; return vec![];
@@ -92,19 +96,35 @@ pub(super) fn fill_sequential<'a>(
// Set of item IDs currently eligible to air. // Set of item IDs currently eligible to air.
let available: HashSet<&MediaItemId> = pool.iter().map(|i| &i.id).collect(); let available: HashSet<&MediaItemId> = pool.iter().map(|i| &i.id).collect();
// Find where in the full ordered list to resume. let ordered: Vec<&MediaItem> = if loop_on_finish {
// Falls back to index 0 if last_item_id is absent or was removed from the library. // Find where in the full ordered list to resume, wrapping around.
let start_idx = last_item_id // Falls back to index 0 if last_item_id is absent or was removed from the library.
.and_then(|id| candidates.iter().position(|c| &c.id == id)) let start_idx = last_item_id
.map(|pos| (pos + 1) % candidates.len()) .and_then(|id| candidates.iter().position(|c| &c.id == id))
.unwrap_or(0); .map(|pos| (pos + 1) % candidates.len())
.unwrap_or(0);
// Walk candidates in order from start_idx, wrapping around once, (0..candidates.len())
// skipping any that are on cooldown (not in `available`). .map(|i| &candidates[(start_idx + i) % candidates.len()])
let ordered: Vec<&MediaItem> = (0..candidates.len()) .filter(|item| available.contains(&item.id))
.map(|i| &candidates[(start_idx + i) % candidates.len()]) .collect()
.filter(|item| available.contains(&item.id)) } else {
.collect(); // 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. // Greedily fill the block's time budget in episode order.
let mut remaining = target_secs; let mut remaining = target_secs;

View File

@@ -255,6 +255,8 @@ impl ScheduleEngineService {
self.resolve_algorithmic( self.resolve_algorithmic(
filter, strategy, start, end, history, policy, generation, filter, strategy, start, end, history, policy, generation,
block.id, last_item_id, block.id, last_item_id,
block.loop_on_finish,
block.ignore_recycle_policy,
) )
.await .await
} }
@@ -311,6 +313,8 @@ impl ScheduleEngineService {
generation: u32, generation: u32,
block_id: BlockId, block_id: BlockId,
last_item_id: Option<&MediaItemId>, last_item_id: Option<&MediaItemId>,
loop_on_finish: bool,
ignore_recycle_policy: bool,
) -> DomainResult<Vec<ScheduledSlot>> { ) -> DomainResult<Vec<ScheduledSlot>> {
// `candidates` — all items matching the filter, in provider order. // `candidates` — all items matching the filter, in provider order.
// Kept separate from `pool` so Sequential can rotate through the full // Kept separate from `pool` so Sequential can rotate through the full
@@ -321,9 +325,14 @@ impl ScheduleEngineService {
return Ok(vec![]); 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 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 slots = Vec::new();
let mut cursor = start; let mut cursor = start;

View File

@@ -58,6 +58,8 @@ const blockSchema = z.object({
items: z.array(z.string()), items: z.array(z.string()),
}), }),
]), ]),
loop_on_finish: z.boolean().optional(),
ignore_recycle_policy: z.boolean().optional(),
}); });
const channelFormSchema = z.object({ const channelFormSchema = z.object({
@@ -211,6 +213,8 @@ function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock
start_time: minsToTime(startMins), start_time: minsToTime(startMins),
duration_mins: durationMins, duration_mins: durationMins,
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" }, 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
</div> </div>
{content.type === "algorithmic" && ( {content.type === "algorithmic" && (
<AlgorithmicFilterEditor <>
content={content} <AlgorithmicFilterEditor
pfx={pfx} content={content}
errors={errors} pfx={pfx}
setFilter={setFilter} errors={errors}
setStrategy={setStrategy} setFilter={setFilter}
/> setStrategy={setStrategy}
/>
{content.strategy === "sequential" && (
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Sequential options
</p>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={block.loop_on_finish ?? true}
onChange={(e) => onChange({ ...block, loop_on_finish: e.target.checked })}
className="accent-zinc-400"
/>
<span className="text-sm text-zinc-300">Loop series</span>
<span className="text-[11px] text-zinc-600">
Restart from episode 1 after the final episode
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={block.ignore_recycle_policy ?? false}
onChange={(e) => onChange({ ...block, ignore_recycle_policy: e.target.checked })}
className="accent-zinc-400"
/>
<span className="text-sm text-zinc-300">Independent scheduling</span>
<span className="text-[11px] text-zinc-600">
Play episodes in order even if they aired in another block today
</span>
</label>
</div>
)}
</>
)} )}
{content.type === "manual" && ( {content.type === "manual" && (

View File

@@ -63,6 +63,10 @@ export interface ProgrammingBlock {
start_time: string; start_time: string;
duration_mins: number; duration_mins: number;
content: BlockContent; 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 { export interface ScheduleConfig {