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

@@ -36,5 +36,6 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
collection_type: None, collection_type: None,
synced_at: None, synced_at: None,
role: MediaRole::default(), role: MediaRole::default(),
chapters: Vec::new(),
})) }))
} }

View File

@@ -47,6 +47,7 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem {
collection_type: None, collection_type: None,
synced_at: None, synced_at: None,
role: MediaRole::default(), role: MediaRole::default(),
chapters: Vec::new(),
}) })
} }

View File

@@ -10,7 +10,7 @@ use adapter_common::{
use domain::{ use domain::{
ports::channel::{ChannelCommand, ChannelQuery}, ports::channel::{ChannelCommand, ChannelQuery},
AccessMode, Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow, AccessMode, Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow,
DomainError, DomainResult, LogoPosition, ScheduleConfig, SnapshotId, UserId, DomainError, DomainResult, LogoPosition, MediaFilter, ScheduleConfig, SnapshotId, UserId,
}; };
pub struct SqliteChannelRepository { pub struct SqliteChannelRepository {
@@ -23,7 +23,7 @@ impl SqliteChannelRepository {
} }
} }
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy AS rotation_policy, auto_schedule, access_mode, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, created_at, updated_at"; const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy AS rotation_policy, auto_schedule, access_mode, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, gap_filler, created_at, updated_at";
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
struct ChannelRow { struct ChannelRow {
@@ -43,6 +43,7 @@ struct ChannelRow {
webhook_poll_interval_secs: i64, webhook_poll_interval_secs: i64,
webhook_body_template: Option<String>, webhook_body_template: Option<String>,
webhook_headers: Option<String>, webhook_headers: Option<String>,
gap_filler: Option<String>,
created_at: String, created_at: String,
updated_at: String, updated_at: String,
} }
@@ -56,6 +57,11 @@ impl ChannelRow {
let access_mode: AccessMode = parse_enum_or_default(self.access_mode); let access_mode: AccessMode = parse_enum_or_default(self.access_mode);
let logo_position: LogoPosition = parse_enum_or_default(self.logo_position); let logo_position: LogoPosition = parse_enum_or_default(self.logo_position);
let gap_filler: Option<MediaFilter> = self
.gap_filler
.as_deref()
.and_then(|s| serde_json::from_str(s).ok());
Ok(Channel::from_persistence(DomainChannelRow { Ok(Channel::from_persistence(DomainChannelRow {
id, id,
owner_id, owner_id,
@@ -73,6 +79,7 @@ impl ChannelRow {
webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32, webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32,
webhook_body_template: self.webhook_body_template, webhook_body_template: self.webhook_body_template,
webhook_headers: self.webhook_headers, webhook_headers: self.webhook_headers,
gap_filler,
created_at: parse_dt(&self.created_at)?, created_at: parse_dt(&self.created_at)?,
updated_at: parse_dt(&self.updated_at)?, updated_at: parse_dt(&self.updated_at)?,
})) }))
@@ -112,14 +119,18 @@ impl ChannelCommand for SqliteChannelRepository {
let access_mode = serialize_enum_as_string(channel.access_mode(), "public"); let access_mode = serialize_enum_as_string(channel.access_mode(), "public");
let logo_position = serialize_enum_as_string(channel.logo_position(), "top_right"); let logo_position = serialize_enum_as_string(channel.logo_position(), "top_right");
let gap_filler_json = channel
.gap_filler()
.map(|f| serde_json::to_string(f).unwrap_or_default());
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO channels INSERT INTO channels
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, (id, owner_id, name, description, timezone, schedule_config, recycle_policy,
auto_schedule, access_mode, logo, logo_position, auto_schedule, access_mode, logo, logo_position,
logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template,
webhook_headers, created_at, updated_at) webhook_headers, gap_filler, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
description = excluded.description, description = excluded.description,
@@ -135,6 +146,7 @@ impl ChannelCommand for SqliteChannelRepository {
webhook_poll_interval_secs = excluded.webhook_poll_interval_secs, webhook_poll_interval_secs = excluded.webhook_poll_interval_secs,
webhook_body_template = excluded.webhook_body_template, webhook_body_template = excluded.webhook_body_template,
webhook_headers = excluded.webhook_headers, webhook_headers = excluded.webhook_headers,
gap_filler = excluded.gap_filler,
updated_at = excluded.updated_at updated_at = excluded.updated_at
"#, "#,
) )
@@ -154,6 +166,7 @@ impl ChannelCommand for SqliteChannelRepository {
.bind(channel.webhook_poll_interval_secs() as i64) .bind(channel.webhook_poll_interval_secs() as i64)
.bind(channel.webhook_body_template()) .bind(channel.webhook_body_template())
.bind(channel.webhook_headers()) .bind(channel.webhook_headers())
.bind(&gap_filler_json)
.bind(channel.created_at().to_rfc3339()) .bind(channel.created_at().to_rfc3339())
.bind(channel.updated_at().to_rfc3339()) .bind(channel.updated_at().to_rfc3339())
.execute(&self.pool) .execute(&self.pool)

View File

@@ -39,6 +39,7 @@ struct LibraryItemRow {
collection_type: Option<String>, collection_type: Option<String>,
thumbnail_url: Option<String>, thumbnail_url: Option<String>,
synced_at: String, synced_at: String,
chapters: Option<String>,
} }
impl LibraryItemRow { impl LibraryItemRow {
@@ -63,6 +64,11 @@ impl LibraryItemRow {
thumbnail_url: self.thumbnail_url, thumbnail_url: self.thumbnail_url,
synced_at: Some(self.synced_at), synced_at: Some(self.synced_at),
role: MediaRole::default(), role: MediaRole::default(),
chapters: self
.chapters
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default(),
}) })
} }
} }
@@ -104,12 +110,18 @@ impl LibraryCommand for SqliteLibraryRepository {
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
for item in items { for item in items {
let chapters_json = if item.chapters().is_empty() {
None
} else {
Some(serde_json::to_string(item.chapters()).unwrap_or_default())
};
sqlx::query( sqlx::query(
"INSERT OR REPLACE INTO library_items "INSERT OR REPLACE INTO library_items
(id, provider_id, external_id, title, content_type, duration_secs, (id, provider_id, external_id, title, content_type, duration_secs,
series_name, season_number, episode_number, year, genres, tags, series_name, season_number, episode_number, year, genres, tags,
collection_id, collection_name, collection_type, thumbnail_url, synced_at) collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
) )
.bind(item.id().value()) .bind(item.id().value())
.bind(item.provider_id()) .bind(item.provider_id())
@@ -128,6 +140,7 @@ impl LibraryCommand for SqliteLibraryRepository {
.bind(item.collection_type()) .bind(item.collection_type())
.bind(item.thumbnail_url()) .bind(item.thumbnail_url())
.bind(item.synced_at().unwrap_or("")) .bind(item.synced_at().unwrap_or(""))
.bind(&chapters_json)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;

View File

@@ -42,6 +42,7 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
thumbnail_url: None, thumbnail_url: None,
synced_at: Some("2026-01-01".into()), synced_at: Some("2026-01-01".into()),
role: MediaRole::default(), role: MediaRole::default(),
chapters: Vec::new(),
}); });
let scifi = MediaItem::from_persistence(MediaItemRow { let scifi = MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new("test::m2"), id: MediaItemId::new("test::m2"),
@@ -63,6 +64,7 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
thumbnail_url: None, thumbnail_url: None,
synced_at: Some("2026-01-01".into()), synced_at: Some("2026-01-01".into()),
role: MediaRole::default(), role: MediaRole::default(),
chapters: Vec::new(),
}); });
let comedy = MediaItem::from_persistence(MediaItemRow { let comedy = MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new("test::m3"), id: MediaItemId::new("test::m3"),
@@ -84,6 +86,7 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
thumbnail_url: None, thumbnail_url: None,
synced_at: Some("2026-01-01".into()), synced_at: Some("2026-01-01".into()),
role: MediaRole::default(), role: MediaRole::default(),
chapters: Vec::new(),
}); });
store.insert(action.id().value().to_string(), action); store.insert(action.id().value().to_string(), action);

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,15 @@ pub(super) fn fill_block<'a>(
} }
result 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 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)] #[cfg(test)]
#[path = "tests/fill.rs"] #[path = "tests/fill.rs"]
mod tests; mod tests;

View File

@@ -68,3 +68,148 @@ fn random_fill_respects_budget() {
let total: u32 = result.iter().map(|i| i.duration_secs()).sum(); let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
assert!(total <= 200); 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, BestFit,
Sequential, Sequential,
Random, Random,
Alternating,
Weighted,
Marathon,
} }
const DEFAULT_COOLDOWN_DAYS: u32 = 30; const DEFAULT_COOLDOWN_DAYS: u32 = 30;
@@ -99,6 +102,75 @@ pub enum MediaRole {
Interstitial, 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)] #[cfg(test)]
#[path = "tests/scheduling.rs"] #[path = "tests/scheduling.rs"]
mod tests; mod tests;

View File

@@ -6,3 +6,40 @@ pub enum SourceUri {
NetworkUrl { url: String }, NetworkUrl { url: String },
FilePath { path: 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
}
}

View File

@@ -459,6 +459,7 @@ fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) ->
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()), thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
synced_at: Some(now), synced_at: Some(now),
role: domain::MediaRole::default(), role: domain::MediaRole::default(),
chapters: item.chapters().to_vec(),
}) })
} }

View File

@@ -365,6 +365,7 @@ fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) ->
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()), thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
synced_at: Some(now), synced_at: Some(now),
role: domain::MediaRole::default(), role: domain::MediaRole::default(),
chapters: item.chapters().to_vec(),
}) })
} }

View File

@@ -0,0 +1,2 @@
ALTER TABLE library_items ADD COLUMN chapters TEXT;
ALTER TABLE channels ADD COLUMN gap_filler TEXT;

View File

@@ -0,0 +1,2 @@
ALTER TABLE library_items ADD COLUMN chapters TEXT;
ALTER TABLE channels ADD COLUMN gap_filler TEXT;