add domain concepts: FillStrategy variants, Chapter, InterstitialRule, MidRollRule, gap_filler

- FillStrategy: +Alternating (round-robin series), +Weighted (fresh-first), +Marathon (always ep1, loops)
- Chapter value object on MediaItem (JSON column in library_items)
- InterstitialRule + MidRollRule on ProgrammingBlock (data model only)
- gap_filler: Option<MediaFilter> on Channel (persisted as JSON column)
- migration: 20260712000002_add_chapters_and_gap_filler.sql
This commit is contained in:
2026-07-12 07:41:57 +02:00
parent 39f5f99bfd
commit 60524c56f7
16 changed files with 547 additions and 10 deletions

View File

@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::value_objects::{
AccessMode, BlockId, ChannelId, FillStrategy, LogoPosition, MediaFilter, MediaItemId,
RotationPolicy, UserId, Weekday,
AccessMode, BlockId, ChannelId, FillStrategy, InterstitialRule, LogoPosition, MediaFilter,
MediaItemId, MidRollRule, RotationPolicy, UserId, Weekday,
};
const SECONDS_IN_DAY: u32 = 86_400;
@@ -29,6 +29,8 @@ pub struct Channel {
webhook_poll_interval_secs: u32,
webhook_body_template: Option<String>,
webhook_headers: Option<String>,
#[serde(default)]
gap_filler: Option<MediaFilter>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
@@ -50,6 +52,7 @@ pub struct ChannelRow {
pub webhook_poll_interval_secs: u32,
pub webhook_body_template: Option<String>,
pub webhook_headers: Option<String>,
pub gap_filler: Option<MediaFilter>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -78,6 +81,7 @@ impl Channel {
webhook_poll_interval_secs: DEFAULT_WEBHOOK_POLL_INTERVAL_SECS,
webhook_body_template: None,
webhook_headers: None,
gap_filler: None,
created_at: now,
updated_at: now,
}
@@ -101,6 +105,7 @@ impl Channel {
webhook_poll_interval_secs: row.webhook_poll_interval_secs,
webhook_body_template: row.webhook_body_template,
webhook_headers: row.webhook_headers,
gap_filler: row.gap_filler,
created_at: row.created_at,
updated_at: row.updated_at,
}
@@ -207,6 +212,15 @@ impl Channel {
self.auto_schedule = enabled;
self.updated_at = Utc::now();
}
pub fn gap_filler(&self) -> Option<&MediaFilter> {
self.gap_filler.as_ref()
}
pub fn set_gap_filler(&mut self, gap_filler: Option<MediaFilter>) {
self.gap_filler = gap_filler;
self.updated_at = Utc::now();
}
}
// deny_unknown_fields required so #[serde(untagged)] compat enum correctly rejects V1 payloads
@@ -324,6 +338,12 @@ pub struct ProgrammingBlock {
#[serde(default)]
ignore_rotation_policy: bool,
#[serde(default)]
interstitial_rule: Option<InterstitialRule>,
#[serde(default)]
mid_roll_rule: Option<MidRollRule>,
}
impl ProgrammingBlock {
@@ -345,6 +365,8 @@ impl ProgrammingBlock {
},
loop_on_finish: true,
ignore_rotation_policy: false,
interstitial_rule: None,
mid_roll_rule: None,
}
}
@@ -364,6 +386,8 @@ impl ProgrammingBlock {
},
loop_on_finish: true,
ignore_rotation_policy: false,
interstitial_rule: None,
mid_roll_rule: None,
}
}
@@ -394,6 +418,14 @@ impl ProgrammingBlock {
pub fn ignore_rotation_policy(&self) -> bool {
self.ignore_rotation_policy
}
pub fn interstitial_rule(&self) -> Option<&InterstitialRule> {
self.interstitial_rule.as_ref()
}
pub fn mid_roll_rule(&self) -> Option<&MidRollRule> {
self.mid_roll_rule.as_ref()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -1,7 +1,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::value_objects::{ChannelId, ContentType, MediaItemId, MediaRole, PlaybackRecordId};
use crate::value_objects::{Chapter, ChannelId, ContentType, MediaItemId, MediaRole, PlaybackRecordId};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaItem {
@@ -29,6 +29,8 @@ pub struct MediaItem {
synced_at: Option<String>,
#[serde(default)]
role: MediaRole,
#[serde(default)]
chapters: Vec<Chapter>,
}
pub struct MediaItemRow {
@@ -51,6 +53,7 @@ pub struct MediaItemRow {
pub collection_type: Option<String>,
pub synced_at: Option<String>,
pub role: MediaRole,
pub chapters: Vec<Chapter>,
}
impl MediaItem {
@@ -80,6 +83,7 @@ impl MediaItem {
collection_type: None,
synced_at: None,
role: MediaRole::default(),
chapters: Vec::new(),
}
}
@@ -114,6 +118,7 @@ impl MediaItem {
collection_type: None,
synced_at: Some(synced_at.into()),
role: MediaRole::default(),
chapters: Vec::new(),
}
}
@@ -138,6 +143,7 @@ impl MediaItem {
collection_type: row.collection_type,
synced_at: row.synced_at,
role: row.role,
chapters: row.chapters,
}
}
@@ -216,6 +222,10 @@ impl MediaItem {
pub fn role(&self) -> &MediaRole {
&self.role
}
pub fn chapters(&self) -> &[Chapter] {
&self.chapters
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -1,5 +1,5 @@
use super::*;
use crate::value_objects::PlaybackRecordId;
use crate::value_objects::{Chapter, PlaybackRecordId};
#[test]
fn media_item_new_defaults() {
@@ -19,6 +19,7 @@ fn media_item_new_defaults() {
assert_eq!(item.external_id(), "");
assert!(item.synced_at().is_none());
assert_eq!(item.role(), &MediaRole::Program);
assert!(item.chapters().is_empty());
}
#[test]
@@ -65,6 +66,7 @@ fn media_item_from_persistence_round_trip() {
collection_type: Some("tvshows".into()),
synced_at: Some("2026-03-19T00:00:00Z".into()),
role: MediaRole::Program,
chapters: vec![Chapter::new(Some("Intro".into()), 0.0, 30.0)],
});
assert_eq!(item.title(), "Breaking Bad S01E01");
assert_eq!(item.series_name(), Some("Breaking Bad"));

View File

@@ -34,6 +34,15 @@ pub(super) fn fill_block<'a>(
}
result
}
FillStrategy::Alternating => {
fill_alternating(candidates, pool, target_secs)
}
FillStrategy::Weighted => {
fill_weighted(candidates, pool, target_secs)
}
FillStrategy::Marathon => {
fill_marathon(candidates, pool, target_secs, loop_on_finish)
}
}
}
@@ -118,6 +127,199 @@ pub(super) fn fill_sequential<'a>(
result
}
pub(super) fn fill_alternating<'a>(
candidates: &'a [MediaItem],
pool: &'a [MediaItem],
target_secs: u32,
) -> Vec<&'a MediaItem> {
if pool.is_empty() {
return vec![];
}
let available: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect();
let mut groups: Vec<Vec<&MediaItem>> = Vec::new();
let mut seen_series: HashSet<Option<&str>> = HashSet::new();
for item in candidates {
if !available.contains(item.id()) {
continue;
}
let key = item.series_name();
if !seen_series.contains(&key) {
seen_series.insert(key);
groups.push(Vec::new());
}
let group_idx = groups.len() - 1;
// Find the group for this series
let idx = groups
.iter()
.position(|g| {
g.first()
.map(|f| f.series_name() == item.series_name())
.unwrap_or(true)
})
.unwrap_or(group_idx);
groups[idx].push(item);
}
if groups.is_empty() {
return vec![];
}
let mut cursors: Vec<usize> = vec![0; groups.len()];
let mut remaining = target_secs;
let mut result = Vec::new();
let mut stale_count = 0;
loop {
if stale_count >= groups.len() {
break;
}
for (gi, group) in groups.iter().enumerate() {
if remaining == 0 {
return result;
}
if cursors[gi] >= group.len() {
stale_count += 1;
continue;
}
let item = group[cursors[gi]];
if item.duration_secs() <= remaining {
remaining -= item.duration_secs();
result.push(item);
cursors[gi] += 1;
stale_count = 0;
} else {
cursors[gi] += 1;
}
}
}
result
}
pub(super) fn fill_weighted<'a>(
candidates: &'a [MediaItem],
pool: &'a [MediaItem],
target_secs: u32,
) -> Vec<&'a MediaItem> {
if pool.is_empty() {
return vec![];
}
let pool_ids: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect();
let candidate_ids: HashSet<&MediaItemId> = candidates.iter().map(|i| i.id()).collect();
let mut fresh: Vec<&MediaItem> = pool
.iter()
.filter(|i| !candidate_ids.contains(i.id()) || pool_ids.contains(i.id()))
.collect();
let all_in_pool: Vec<&MediaItem> = pool.iter().collect();
let mut rng = StdRng::from_entropy();
fresh.shuffle(&mut rng);
let mut remaining = target_secs;
let mut result = Vec::new();
let mut used: HashSet<&MediaItemId> = HashSet::new();
for item in &fresh {
if remaining == 0 {
break;
}
if used.contains(item.id()) {
continue;
}
if item.duration_secs() <= remaining {
remaining -= item.duration_secs();
used.insert(item.id());
result.push(*item);
}
}
if remaining > 0 {
let mut rest: Vec<&MediaItem> = all_in_pool
.iter()
.filter(|i| !used.contains(i.id()))
.copied()
.collect();
rest.shuffle(&mut rng);
for item in rest {
if remaining == 0 {
break;
}
if item.duration_secs() <= remaining {
remaining -= item.duration_secs();
result.push(item);
}
}
}
result
}
pub(super) fn fill_marathon<'a>(
candidates: &'a [MediaItem],
pool: &'a [MediaItem],
target_secs: u32,
loop_on_finish: bool,
) -> Vec<&'a MediaItem> {
if pool.is_empty() {
return vec![];
}
let available: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect();
let ordered: Vec<&MediaItem> = candidates
.iter()
.filter(|item| available.contains(item.id()))
.collect();
if ordered.is_empty() {
return vec![];
}
let mut remaining = target_secs;
let mut result = Vec::new();
if loop_on_finish {
let mut idx = 0;
while remaining > 0 {
let item = ordered[idx % ordered.len()];
if item.duration_secs() <= remaining {
remaining -= item.duration_secs();
result.push(item);
} else {
break;
}
idx += 1;
if idx > ordered.len() * 100 {
break;
}
}
} else {
for item in &ordered {
if item.duration_secs() <= remaining {
remaining -= item.duration_secs();
result.push(*item);
} else {
break;
}
}
}
if result.is_empty() {
if let Some(&first) = ordered.first() {
result.push(first);
}
}
result
}
#[cfg(test)]
#[path = "tests/fill.rs"]
mod tests;

View File

@@ -68,3 +68,148 @@ fn random_fill_respects_budget() {
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
assert!(total <= 200);
}
fn episode(id: &str, series: &str, secs: u32) -> MediaItem {
let mut i = item(id, secs);
// Use serde to set series_name since fields are private
let mut val = serde_json::to_value(&i).unwrap();
val["series_name"] = serde_json::Value::String(series.into());
serde_json::from_value(val).unwrap()
}
#[test]
fn alternating_cycles_through_series() {
let candidates = vec![
episode("s1e1", "Show A", 60),
episode("s1e2", "Show A", 60),
episode("s2e1", "Show B", 60),
episode("s2e2", "Show B", 60),
];
let pool = candidates.clone();
let result = fill_alternating(&candidates, &pool, 240);
assert_eq!(result.len(), 4);
assert_eq!(result[0].series_name(), Some("Show A"));
assert_eq!(result[1].series_name(), Some("Show B"));
assert_eq!(result[2].series_name(), Some("Show A"));
assert_eq!(result[3].series_name(), Some("Show B"));
}
#[test]
fn alternating_respects_budget() {
let candidates = vec![
episode("s1e1", "Show A", 100),
episode("s2e1", "Show B", 100),
];
let pool = candidates.clone();
let result = fill_alternating(&candidates, &pool, 150);
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
assert!(total <= 150);
}
#[test]
fn alternating_empty_pool() {
let candidates: Vec<MediaItem> = vec![];
let pool: Vec<MediaItem> = vec![];
let result = fill_alternating(&candidates, &pool, 300);
assert!(result.is_empty());
}
#[test]
fn weighted_respects_budget() {
let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
let candidates = pool.clone();
let result = fill_weighted(&candidates, &pool, 200);
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
assert!(total <= 200);
}
#[test]
fn weighted_no_duplicates() {
let pool = vec![item("a", 50), item("b", 50), item("c", 50)];
let candidates = pool.clone();
let result = fill_weighted(&candidates, &pool, 150);
let ids: Vec<&str> = result.iter().map(|i| i.id().value()).collect();
let unique: HashSet<&str> = ids.iter().copied().collect();
assert_eq!(ids.len(), unique.len());
}
#[test]
fn weighted_empty_pool() {
let candidates: Vec<MediaItem> = vec![];
let pool: Vec<MediaItem> = vec![];
let result = fill_weighted(&candidates, &pool, 300);
assert!(result.is_empty());
}
#[test]
fn marathon_starts_from_beginning() {
let candidates = vec![item("ep1", 60), item("ep2", 60), item("ep3", 60)];
let pool = candidates.clone();
let result = fill_marathon(&candidates, &pool, 180, false);
assert_eq!(result.len(), 3);
assert_eq!(result[0].id().value(), "ep1");
assert_eq!(result[1].id().value(), "ep2");
assert_eq!(result[2].id().value(), "ep3");
}
#[test]
fn marathon_loops_when_enabled() {
let candidates = vec![item("ep1", 60), item("ep2", 60)];
let pool = candidates.clone();
let result = fill_marathon(&candidates, &pool, 240, true);
assert_eq!(result.len(), 4);
assert_eq!(result[0].id().value(), "ep1");
assert_eq!(result[1].id().value(), "ep2");
assert_eq!(result[2].id().value(), "ep1");
assert_eq!(result[3].id().value(), "ep2");
}
#[test]
fn marathon_stops_without_loop() {
let candidates = vec![item("ep1", 60), item("ep2", 60)];
let pool = candidates.clone();
let result = fill_marathon(&candidates, &pool, 300, false);
assert_eq!(result.len(), 2);
}
#[test]
fn marathon_empty_pool() {
let candidates: Vec<MediaItem> = vec![];
let pool: Vec<MediaItem> = vec![];
let result = fill_marathon(&candidates, &pool, 300, true);
assert!(result.is_empty());
}
#[test]
fn marathon_oversize_single_item() {
let candidates = vec![item("ep1", 9999)];
let pool = candidates.clone();
let result = fill_marathon(&candidates, &pool, 60, true);
assert_eq!(result.len(), 1);
}
#[test]
fn fill_block_dispatches_alternating() {
let candidates = vec![item("a", 100), item("b", 100)];
let pool = candidates.clone();
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Alternating, None, true);
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
assert!(total <= 200);
}
#[test]
fn fill_block_dispatches_weighted() {
let candidates = vec![item("a", 100), item("b", 100)];
let pool = candidates.clone();
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Weighted, None, true);
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
assert!(total <= 200);
}
#[test]
fn fill_block_dispatches_marathon() {
let candidates = vec![item("a", 100), item("b", 100)];
let pool = candidates.clone();
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Marathon, None, true);
assert_eq!(result[0].id().value(), "a");
}

View File

@@ -28,6 +28,9 @@ pub enum FillStrategy {
BestFit,
Sequential,
Random,
Alternating,
Weighted,
Marathon,
}
const DEFAULT_COOLDOWN_DAYS: u32 = 30;
@@ -99,6 +102,75 @@ pub enum MediaRole {
Interstitial,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterstitialRule {
pool_filter: MediaFilter,
strategy: FillStrategy,
min_gap_secs: u32,
}
impl InterstitialRule {
pub fn new(pool_filter: MediaFilter, strategy: FillStrategy, min_gap_secs: u32) -> Self {
Self {
pool_filter,
strategy,
min_gap_secs,
}
}
pub fn pool_filter(&self) -> &MediaFilter {
&self.pool_filter
}
pub fn strategy(&self) -> &FillStrategy {
&self.strategy
}
pub fn min_gap_secs(&self) -> u32 {
self.min_gap_secs
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MidRollRule {
prefer_chapters: bool,
fallback_interval_mins: u32,
break_duration_secs: u32,
pool_filter: MediaFilter,
}
impl MidRollRule {
pub fn new(
prefer_chapters: bool,
fallback_interval_mins: u32,
break_duration_secs: u32,
pool_filter: MediaFilter,
) -> Self {
Self {
prefer_chapters,
fallback_interval_mins,
break_duration_secs,
pool_filter,
}
}
pub fn prefer_chapters(&self) -> bool {
self.prefer_chapters
}
pub fn fallback_interval_mins(&self) -> u32 {
self.fallback_interval_mins
}
pub fn break_duration_secs(&self) -> u32 {
self.break_duration_secs
}
pub fn pool_filter(&self) -> &MediaFilter {
&self.pool_filter
}
}
#[cfg(test)]
#[path = "tests/scheduling.rs"]
mod tests;

View File

@@ -6,3 +6,40 @@ pub enum SourceUri {
NetworkUrl { url: String },
FilePath { path: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Chapter {
title: Option<String>,
start_secs: f64,
end_secs: f64,
}
impl Chapter {
pub fn new(title: Option<String>, start_secs: f64, end_secs: f64) -> Self {
Self {
title,
start_secs,
end_secs,
}
}
pub fn from_persistence(title: Option<String>, start_secs: f64, end_secs: f64) -> Self {
Self {
title,
start_secs,
end_secs,
}
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn start_secs(&self) -> f64 {
self.start_secs
}
pub fn end_secs(&self) -> f64 {
self.end_secs
}
}