feat(schedule): add loop and recycle policy options to programming blocks
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// 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.
|
// Falls back to index 0 if last_item_id is absent or was removed from the library.
|
||||||
let start_idx = last_item_id
|
let start_idx = last_item_id
|
||||||
.and_then(|id| candidates.iter().position(|c| &c.id == id))
|
.and_then(|id| candidates.iter().position(|c| &c.id == id))
|
||||||
.map(|pos| (pos + 1) % candidates.len())
|
.map(|pos| (pos + 1) % candidates.len())
|
||||||
.unwrap_or(0);
|
.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`).
|
|
||||||
let ordered: Vec<&MediaItem> = (0..candidates.len())
|
|
||||||
.map(|i| &candidates[(start_idx + i) % candidates.len()])
|
.map(|i| &candidates[(start_idx + i) % candidates.len()])
|
||||||
.filter(|item| available.contains(&item.id))
|
.filter(|item| available.contains(&item.id))
|
||||||
.collect();
|
.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.
|
// Greedily fill the block's time budget in episode order.
|
||||||
let mut remaining = target_secs;
|
let mut remaining = target_secs;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +538,7 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{content.type === "algorithmic" && (
|
{content.type === "algorithmic" && (
|
||||||
|
<>
|
||||||
<AlgorithmicFilterEditor
|
<AlgorithmicFilterEditor
|
||||||
content={content}
|
content={content}
|
||||||
pfx={pfx}
|
pfx={pfx}
|
||||||
@@ -541,6 +546,39 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
|
|||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
setStrategy={setStrategy}
|
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" && (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user