- delete clippy.toml (too-many-arguments-threshold=20 hack) - ChannelRow/MediaItemRow/LibraryItemRow structs for from_persistence - SnapshotId/ActivityEventId/PlaybackRecordId newtypes - DomainError variants use ChannelId/UserId instead of Uuid - ActivityEvent.channel_id: Option<ChannelId> not Option<Uuid> - InMemory repos key on newtype IDs - AlgorithmicParams struct for schedule engine - update all adapters/application/presentation callers
446 lines
12 KiB
Rust
446 lines
12 KiB
Rust
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<String>,
|
|
timezone: String,
|
|
schedule_config: ScheduleConfig,
|
|
recycle_policy: RecyclePolicy,
|
|
auto_schedule: bool,
|
|
access_mode: AccessMode,
|
|
access_password_hash: Option<String>,
|
|
logo: Option<String>,
|
|
logo_position: LogoPosition,
|
|
logo_opacity: f32,
|
|
webhook_url: Option<String>,
|
|
webhook_poll_interval_secs: u32,
|
|
webhook_body_template: Option<String>,
|
|
webhook_headers: Option<String>,
|
|
created_at: DateTime<Utc>,
|
|
updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
pub struct ChannelRow {
|
|
pub id: ChannelId,
|
|
pub owner_id: UserId,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub timezone: String,
|
|
pub schedule_config: ScheduleConfig,
|
|
pub recycle_policy: RecyclePolicy,
|
|
pub auto_schedule: bool,
|
|
pub access_mode: AccessMode,
|
|
pub access_password_hash: Option<String>,
|
|
pub logo: Option<String>,
|
|
pub logo_position: LogoPosition,
|
|
pub logo_opacity: f32,
|
|
pub webhook_url: Option<String>,
|
|
pub webhook_poll_interval_secs: u32,
|
|
pub webhook_body_template: Option<String>,
|
|
pub webhook_headers: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl Channel {
|
|
pub fn new(
|
|
owner_id: UserId,
|
|
name: impl Into<String>,
|
|
timezone: impl Into<String>,
|
|
) -> 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<Utc> {
|
|
self.created_at
|
|
}
|
|
|
|
pub fn updated_at(&self) -> DateTime<Utc> {
|
|
self.updated_at
|
|
}
|
|
|
|
pub fn set_name(&mut self, name: impl Into<String>) {
|
|
self.name = name.into();
|
|
self.updated_at = Utc::now();
|
|
}
|
|
|
|
pub fn set_description(&mut self, description: Option<String>) {
|
|
self.description = description;
|
|
self.updated_at = Utc::now();
|
|
}
|
|
|
|
pub fn set_timezone(&mut self, timezone: impl Into<String>) {
|
|
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<Weekday, Vec<ProgrammingBlock>>,
|
|
}
|
|
|
|
impl ScheduleConfig {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn from_day_blocks(day_blocks: HashMap<Weekday, Vec<ProgrammingBlock>>) -> 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<NaiveTime> {
|
|
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<NaiveTime> {
|
|
self.day_blocks
|
|
.values()
|
|
.flatten()
|
|
.map(|b| b.start_time())
|
|
.min()
|
|
}
|
|
|
|
pub fn all_blocks(&self) -> impl Iterator<Item = &ProgrammingBlock> {
|
|
self.day_blocks.values().flatten()
|
|
}
|
|
|
|
pub fn day_blocks(&self) -> &HashMap<Weekday, Vec<ProgrammingBlock>> {
|
|
&self.day_blocks
|
|
}
|
|
|
|
pub fn insert_day(&mut self, day: Weekday, blocks: Vec<ProgrammingBlock>) {
|
|
self.day_blocks.insert(day, blocks);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct OldScheduleConfig {
|
|
blocks: Vec<ProgrammingBlock>,
|
|
}
|
|
|
|
impl OldScheduleConfig {
|
|
pub fn blocks(&self) -> &[ProgrammingBlock] {
|
|
&self.blocks
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum ScheduleConfigCompat {
|
|
V2(ScheduleConfig),
|
|
V1(OldScheduleConfig),
|
|
}
|
|
|
|
impl From<ScheduleConfigCompat> 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<String>,
|
|
}
|
|
|
|
impl ProgrammingBlock {
|
|
pub fn new_algorithmic(
|
|
name: impl Into<String>,
|
|
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<String>,
|
|
start_time: NaiveTime,
|
|
duration_mins: u32,
|
|
items: Vec<MediaItemId>,
|
|
) -> 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<MediaItemId>,
|
|
#[serde(default)]
|
|
provider_id: String,
|
|
},
|
|
Algorithmic {
|
|
filter: MediaFilter,
|
|
strategy: FillStrategy,
|
|
#[serde(default)]
|
|
provider_id: String,
|
|
},
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "tests/channel.rs"]
|
|
mod tests;
|