use chrono::{DateTime, NaiveTime, Timelike, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use crate::value_objects::{ AccessMode, BlockId, ChannelId, FillStrategy, LogoPosition, MediaFilter, MediaItemId, RecyclePolicy, UserId, Weekday, }; const SECONDS_IN_DAY: u32 = 86_400; const DEFAULT_LOGO_OPACITY: f32 = 1.0; const DEFAULT_WEBHOOK_POLL_INTERVAL_SECS: u32 = 5; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Channel { id: ChannelId, owner_id: UserId, name: String, description: Option, timezone: String, schedule_config: ScheduleConfig, recycle_policy: RecyclePolicy, auto_schedule: bool, access_mode: AccessMode, access_password_hash: Option, logo: Option, logo_position: LogoPosition, logo_opacity: f32, webhook_url: Option, webhook_poll_interval_secs: u32, webhook_body_template: Option, webhook_headers: Option, created_at: DateTime, updated_at: DateTime, } pub struct ChannelRow { pub id: ChannelId, pub owner_id: UserId, pub name: String, pub description: Option, pub timezone: String, pub schedule_config: ScheduleConfig, pub recycle_policy: RecyclePolicy, pub auto_schedule: bool, pub access_mode: AccessMode, pub access_password_hash: Option, pub logo: Option, pub logo_position: LogoPosition, pub logo_opacity: f32, pub webhook_url: Option, pub webhook_poll_interval_secs: u32, pub webhook_body_template: Option, pub webhook_headers: Option, pub created_at: DateTime, pub updated_at: DateTime, } impl Channel { pub fn new( owner_id: UserId, name: impl Into, timezone: impl Into, ) -> Self { let now = Utc::now(); Self { id: ChannelId::generate(), owner_id, name: name.into(), description: None, timezone: timezone.into(), schedule_config: ScheduleConfig::default(), recycle_policy: RecyclePolicy::default(), auto_schedule: false, access_mode: AccessMode::default(), access_password_hash: None, logo: None, logo_position: LogoPosition::default(), logo_opacity: DEFAULT_LOGO_OPACITY, webhook_url: None, webhook_poll_interval_secs: DEFAULT_WEBHOOK_POLL_INTERVAL_SECS, webhook_body_template: None, webhook_headers: None, created_at: now, updated_at: now, } } pub fn from_persistence(row: ChannelRow) -> Self { Self { id: row.id, owner_id: row.owner_id, name: row.name, description: row.description, timezone: row.timezone, schedule_config: row.schedule_config, recycle_policy: row.recycle_policy, auto_schedule: row.auto_schedule, access_mode: row.access_mode, access_password_hash: row.access_password_hash, logo: row.logo, logo_position: row.logo_position, logo_opacity: row.logo_opacity, webhook_url: row.webhook_url, webhook_poll_interval_secs: row.webhook_poll_interval_secs, webhook_body_template: row.webhook_body_template, webhook_headers: row.webhook_headers, created_at: row.created_at, updated_at: row.updated_at, } } pub fn id(&self) -> ChannelId { self.id } pub fn owner_id(&self) -> UserId { self.owner_id } pub fn name(&self) -> &str { &self.name } pub fn description(&self) -> Option<&str> { self.description.as_deref() } pub fn timezone(&self) -> &str { &self.timezone } pub fn schedule_config(&self) -> &ScheduleConfig { &self.schedule_config } pub fn recycle_policy(&self) -> &RecyclePolicy { &self.recycle_policy } pub fn auto_schedule(&self) -> bool { self.auto_schedule } pub fn access_mode(&self) -> &AccessMode { &self.access_mode } pub fn access_password_hash(&self) -> Option<&str> { self.access_password_hash.as_deref() } pub fn logo(&self) -> Option<&str> { self.logo.as_deref() } pub fn logo_position(&self) -> &LogoPosition { &self.logo_position } pub fn logo_opacity(&self) -> f32 { self.logo_opacity } pub fn webhook_url(&self) -> Option<&str> { self.webhook_url.as_deref() } pub fn webhook_poll_interval_secs(&self) -> u32 { self.webhook_poll_interval_secs } pub fn webhook_body_template(&self) -> Option<&str> { self.webhook_body_template.as_deref() } pub fn webhook_headers(&self) -> Option<&str> { self.webhook_headers.as_deref() } pub fn created_at(&self) -> DateTime { self.created_at } pub fn updated_at(&self) -> DateTime { self.updated_at } pub fn set_name(&mut self, name: impl Into) { self.name = name.into(); self.updated_at = Utc::now(); } pub fn set_description(&mut self, description: Option) { self.description = description; self.updated_at = Utc::now(); } pub fn set_timezone(&mut self, timezone: impl Into) { self.timezone = timezone.into(); self.updated_at = Utc::now(); } pub fn set_schedule_config(&mut self, config: ScheduleConfig) { self.schedule_config = config; self.updated_at = Utc::now(); } pub fn set_recycle_policy(&mut self, policy: RecyclePolicy) { self.recycle_policy = policy; self.updated_at = Utc::now(); } pub fn set_auto_schedule(&mut self, enabled: bool) { self.auto_schedule = enabled; self.updated_at = Utc::now(); } } // deny_unknown_fields required so #[serde(untagged)] compat enum correctly rejects V1 payloads #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ScheduleConfig { day_blocks: HashMap>, } impl ScheduleConfig { pub fn new() -> Self { Self::default() } pub fn from_day_blocks(day_blocks: HashMap>) -> Self { Self { day_blocks } } pub fn blocks_for(&self, day: Weekday) -> &[ProgrammingBlock] { self.day_blocks.get(&day).map(|v| v.as_slice()).unwrap_or(&[]) } pub fn find_block_at(&self, day: Weekday, time: NaiveTime) -> Option<&ProgrammingBlock> { let secs = time.num_seconds_from_midnight(); self.blocks_for(day).iter().find(|block| { let start = block.start_time().num_seconds_from_midnight(); let end = start + block.duration_mins() * 60; if end <= SECONDS_IN_DAY { secs >= start && secs < end } else { secs >= start || secs < (end % SECONDS_IN_DAY) } }) } pub fn next_block_start_after(&self, day: Weekday, time: NaiveTime) -> Option { let secs = time.num_seconds_from_midnight(); self.blocks_for(day) .iter() .map(|b| b.start_time().num_seconds_from_midnight()) .filter(|&s| s > secs) .min() .and_then(|s| NaiveTime::from_num_seconds_from_midnight_opt(s, 0)) } pub fn earliest_block_start(&self) -> Option { self.day_blocks .values() .flatten() .map(|b| b.start_time()) .min() } pub fn all_blocks(&self) -> impl Iterator { self.day_blocks.values().flatten() } pub fn day_blocks(&self) -> &HashMap> { &self.day_blocks } pub fn insert_day(&mut self, day: Weekday, blocks: Vec) { self.day_blocks.insert(day, blocks); } } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct OldScheduleConfig { blocks: Vec, } impl OldScheduleConfig { pub fn blocks(&self) -> &[ProgrammingBlock] { &self.blocks } } #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum ScheduleConfigCompat { V2(ScheduleConfig), V1(OldScheduleConfig), } impl From for ScheduleConfig { fn from(c: ScheduleConfigCompat) -> Self { match c { ScheduleConfigCompat::V2(cfg) => cfg, ScheduleConfigCompat::V1(old) => { let day_blocks = Weekday::all() .into_iter() .map(|d| (d, old.blocks.clone())) .collect(); ScheduleConfig { day_blocks } } } } } fn default_true() -> bool { true } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProgrammingBlock { id: BlockId, name: String, start_time: NaiveTime, duration_mins: u32, content: BlockContent, #[serde(default = "default_true")] loop_on_finish: bool, #[serde(default)] ignore_recycle_policy: bool, #[serde(default)] access_mode: AccessMode, #[serde(default, skip_serializing_if = "Option::is_none")] access_password_hash: Option, } impl ProgrammingBlock { pub fn new_algorithmic( name: impl Into, start_time: NaiveTime, duration_mins: u32, filter: MediaFilter, strategy: FillStrategy, ) -> Self { Self { id: BlockId::generate(), name: name.into(), start_time, duration_mins, content: BlockContent::Algorithmic { filter, strategy, provider_id: String::new(), }, loop_on_finish: true, ignore_recycle_policy: false, access_mode: AccessMode::default(), access_password_hash: None, } } pub fn new_manual( name: impl Into, start_time: NaiveTime, duration_mins: u32, items: Vec, ) -> Self { Self { id: BlockId::generate(), name: name.into(), start_time, duration_mins, content: BlockContent::Manual { items, provider_id: String::new(), }, loop_on_finish: true, ignore_recycle_policy: false, access_mode: AccessMode::default(), access_password_hash: None, } } pub fn id(&self) -> BlockId { self.id } pub fn name(&self) -> &str { &self.name } pub fn start_time(&self) -> NaiveTime { self.start_time } pub fn duration_mins(&self) -> u32 { self.duration_mins } pub fn content(&self) -> &BlockContent { &self.content } pub fn loop_on_finish(&self) -> bool { self.loop_on_finish } pub fn ignore_recycle_policy(&self) -> bool { self.ignore_recycle_policy } pub fn access_mode(&self) -> &AccessMode { &self.access_mode } pub fn access_password_hash(&self) -> Option<&str> { self.access_password_hash.as_deref() } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum BlockContent { Manual { items: Vec, #[serde(default)] provider_id: String, }, Algorithmic { filter: MediaFilter, strategy: FillStrategy, #[serde(default)] provider_id: String, }, } #[cfg(test)] #[path = "tests/channel.rs"] mod tests;