diff --git a/crates/adapters/jellyfin/src/mapping.rs b/crates/adapters/jellyfin/src/mapping.rs index 5a9c089..b20ca0e 100644 --- a/crates/adapters/jellyfin/src/mapping.rs +++ b/crates/adapters/jellyfin/src/mapping.rs @@ -36,5 +36,6 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option { collection_type: None, synced_at: None, role: MediaRole::default(), + chapters: Vec::new(), })) } diff --git a/crates/adapters/local-files/src/provider.rs b/crates/adapters/local-files/src/provider.rs index 8a23006..4343da3 100644 --- a/crates/adapters/local-files/src/provider.rs +++ b/crates/adapters/local-files/src/provider.rs @@ -47,6 +47,7 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem { collection_type: None, synced_at: None, role: MediaRole::default(), + chapters: Vec::new(), }) } diff --git a/crates/adapters/sqlite/src/channel.rs b/crates/adapters/sqlite/src/channel.rs index 0599a7f..93d133b 100644 --- a/crates/adapters/sqlite/src/channel.rs +++ b/crates/adapters/sqlite/src/channel.rs @@ -10,7 +10,7 @@ use adapter_common::{ use domain::{ ports::channel::{ChannelCommand, ChannelQuery}, AccessMode, Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow, - DomainError, DomainResult, LogoPosition, ScheduleConfig, SnapshotId, UserId, + DomainError, DomainResult, LogoPosition, MediaFilter, ScheduleConfig, SnapshotId, UserId, }; 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)] struct ChannelRow { @@ -43,6 +43,7 @@ struct ChannelRow { webhook_poll_interval_secs: i64, webhook_body_template: Option, webhook_headers: Option, + gap_filler: Option, created_at: String, updated_at: String, } @@ -56,6 +57,11 @@ impl ChannelRow { let access_mode: AccessMode = parse_enum_or_default(self.access_mode); let logo_position: LogoPosition = parse_enum_or_default(self.logo_position); + let gap_filler: Option = self + .gap_filler + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()); + Ok(Channel::from_persistence(DomainChannelRow { id, owner_id, @@ -73,6 +79,7 @@ impl ChannelRow { webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32, webhook_body_template: self.webhook_body_template, webhook_headers: self.webhook_headers, + gap_filler, created_at: parse_dt(&self.created_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 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( r#" INSERT INTO channels (id, owner_id, name, description, timezone, schedule_config, recycle_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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + webhook_headers, gap_filler, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, @@ -135,6 +146,7 @@ impl ChannelCommand for SqliteChannelRepository { webhook_poll_interval_secs = excluded.webhook_poll_interval_secs, webhook_body_template = excluded.webhook_body_template, webhook_headers = excluded.webhook_headers, + gap_filler = excluded.gap_filler, updated_at = excluded.updated_at "#, ) @@ -154,6 +166,7 @@ impl ChannelCommand for SqliteChannelRepository { .bind(channel.webhook_poll_interval_secs() as i64) .bind(channel.webhook_body_template()) .bind(channel.webhook_headers()) + .bind(&gap_filler_json) .bind(channel.created_at().to_rfc3339()) .bind(channel.updated_at().to_rfc3339()) .execute(&self.pool) diff --git a/crates/adapters/sqlite/src/library.rs b/crates/adapters/sqlite/src/library.rs index ca204e6..2ef69b9 100644 --- a/crates/adapters/sqlite/src/library.rs +++ b/crates/adapters/sqlite/src/library.rs @@ -39,6 +39,7 @@ struct LibraryItemRow { collection_type: Option, thumbnail_url: Option, synced_at: String, + chapters: Option, } impl LibraryItemRow { @@ -63,6 +64,11 @@ impl LibraryItemRow { thumbnail_url: self.thumbnail_url, synced_at: Some(self.synced_at), 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()))?; 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( "INSERT OR REPLACE INTO library_items (id, provider_id, external_id, title, content_type, duration_secs, series_name, season_number, episode_number, year, genres, tags, - collection_id, collection_name, collection_type, thumbnail_url, synced_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ) .bind(item.id().value()) .bind(item.provider_id()) @@ -128,6 +140,7 @@ impl LibraryCommand for SqliteLibraryRepository { .bind(item.collection_type()) .bind(item.thumbnail_url()) .bind(item.synced_at().unwrap_or("")) + .bind(&chapters_json) .execute(&mut *tx) .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; diff --git a/crates/application/src/library/tests/search.rs b/crates/application/src/library/tests/search.rs index a63b75c..d10640f 100644 --- a/crates/application/src/library/tests/search.rs +++ b/crates/application/src/library/tests/search.rs @@ -42,6 +42,7 @@ fn seed_items_with_genres(repo: &std::sync::Arc, webhook_headers: Option, + #[serde(default)] + gap_filler: Option, created_at: DateTime, updated_at: DateTime, } @@ -50,6 +52,7 @@ pub struct ChannelRow { pub webhook_poll_interval_secs: u32, pub webhook_body_template: Option, pub webhook_headers: Option, + pub gap_filler: Option, pub created_at: DateTime, pub updated_at: DateTime, } @@ -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) { + 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, + + #[serde(default)] + mid_roll_rule: Option, } 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)] diff --git a/crates/domain/src/models/media.rs b/crates/domain/src/models/media.rs index ec7ce04..47e9498 100644 --- a/crates/domain/src/models/media.rs +++ b/crates/domain/src/models/media.rs @@ -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, #[serde(default)] role: MediaRole, + #[serde(default)] + chapters: Vec, } pub struct MediaItemRow { @@ -51,6 +53,7 @@ pub struct MediaItemRow { pub collection_type: Option, pub synced_at: Option, pub role: MediaRole, + pub chapters: Vec, } 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)] diff --git a/crates/domain/src/models/tests/media.rs b/crates/domain/src/models/tests/media.rs index f677eba..b038888 100644 --- a/crates/domain/src/models/tests/media.rs +++ b/crates/domain/src/models/tests/media.rs @@ -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")); diff --git a/crates/domain/src/services/schedule/fill.rs b/crates/domain/src/services/schedule/fill.rs index 7df1df7..b027940 100644 --- a/crates/domain/src/services/schedule/fill.rs +++ b/crates/domain/src/services/schedule/fill.rs @@ -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::new(); + let mut seen_series: HashSet> = 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 = 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; diff --git a/crates/domain/src/services/schedule/tests/fill.rs b/crates/domain/src/services/schedule/tests/fill.rs index ad40a22..9532045 100644 --- a/crates/domain/src/services/schedule/tests/fill.rs +++ b/crates/domain/src/services/schedule/tests/fill.rs @@ -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 = vec![]; + let pool: Vec = 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 = vec![]; + let pool: Vec = 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 = vec![]; + let pool: Vec = 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"); +} diff --git a/crates/domain/src/value_objects/scheduling.rs b/crates/domain/src/value_objects/scheduling.rs index 3955090..e11ba22 100644 --- a/crates/domain/src/value_objects/scheduling.rs +++ b/crates/domain/src/value_objects/scheduling.rs @@ -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; diff --git a/crates/domain/src/value_objects/streaming.rs b/crates/domain/src/value_objects/streaming.rs index aad31df..7e7c1e3 100644 --- a/crates/domain/src/value_objects/streaming.rs +++ b/crates/domain/src/value_objects/streaming.rs @@ -6,3 +6,40 @@ pub enum SourceUri { NetworkUrl { url: String }, FilePath { path: String }, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Chapter { + title: Option, + start_secs: f64, + end_secs: f64, +} + +impl Chapter { + pub fn new(title: Option, start_secs: f64, end_secs: f64) -> Self { + Self { + title, + start_secs, + end_secs, + } + } + + pub fn from_persistence(title: Option, 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 + } +} diff --git a/crates/presentation/src/factory.rs b/crates/presentation/src/factory.rs index 09c40a6..6d296a2 100644 --- a/crates/presentation/src/factory.rs +++ b/crates/presentation/src/factory.rs @@ -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()), synced_at: Some(now), role: domain::MediaRole::default(), + chapters: item.chapters().to_vec(), }) } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index bd62567..e629708 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -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()), synced_at: Some(now), role: domain::MediaRole::default(), + chapters: item.chapters().to_vec(), }) } diff --git a/k-tv-backend/migrations_sqlite/20260712000002_add_chapters_and_gap_filler.sql b/k-tv-backend/migrations_sqlite/20260712000002_add_chapters_and_gap_filler.sql new file mode 100644 index 0000000..1e9cf17 --- /dev/null +++ b/k-tv-backend/migrations_sqlite/20260712000002_add_chapters_and_gap_filler.sql @@ -0,0 +1,2 @@ +ALTER TABLE library_items ADD COLUMN chapters TEXT; +ALTER TABLE channels ADD COLUMN gap_filler TEXT; diff --git a/migrations_sqlite/20260712000002_add_chapters_and_gap_filler.sql b/migrations_sqlite/20260712000002_add_chapters_and_gap_filler.sql new file mode 100644 index 0000000..1e9cf17 --- /dev/null +++ b/migrations_sqlite/20260712000002_add_chapters_and_gap_filler.sql @@ -0,0 +1,2 @@ +ALTER TABLE library_items ADD COLUMN chapters TEXT; +ALTER TABLE channels ADD COLUMN gap_filler TEXT;