domain crate code quality cleanup

strip all comments, extract tests to tests/ dirs,
remove #[allow(clippy::...)], extract magic numbers to
constants, refactor schedule engine private methods to
use param structs, add Default impls, clippy.toml for
persistence constructors
This commit is contained in:
2026-07-12 04:02:12 +02:00
parent d650e2ba07
commit 98a54245b1
54 changed files with 1190 additions and 1942 deletions

View File

@@ -7,22 +7,16 @@ use crate::value_objects::{
RecyclePolicy, UserId, Weekday,
};
// ============================================================================
// Channel
// ============================================================================
const SECONDS_IN_DAY: u32 = 86_400;
const DEFAULT_LOGO_OPACITY: f32 = 1.0;
const DEFAULT_WEBHOOK_POLL_INTERVAL_SECS: u32 = 5;
/// A broadcast channel owned by a user.
///
/// Holds the user-designed `ScheduleConfig` (the template) and `RecyclePolicy`.
/// The engine consumes these to produce a concrete `GeneratedSchedule`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Channel {
id: ChannelId,
owner_id: UserId,
name: String,
description: Option<String>,
/// IANA timezone string, e.g. `"America/New_York"`. All `start_time` fields
/// inside `ScheduleConfig` are interpreted in this timezone.
timezone: String,
schedule_config: ScheduleConfig,
recycle_policy: RecyclePolicy,
@@ -41,7 +35,6 @@ pub struct Channel {
}
impl Channel {
/// Create a new channel with sensible defaults.
pub fn new(
owner_id: UserId,
name: impl Into<String>,
@@ -61,9 +54,9 @@ impl Channel {
access_password_hash: None,
logo: None,
logo_position: LogoPosition::default(),
logo_opacity: 1.0,
logo_opacity: DEFAULT_LOGO_OPACITY,
webhook_url: None,
webhook_poll_interval_secs: 5,
webhook_poll_interval_secs: DEFAULT_WEBHOOK_POLL_INTERVAL_SECS,
webhook_body_template: None,
webhook_headers: None,
created_at: now,
@@ -71,8 +64,6 @@ impl Channel {
}
}
/// Hydrate from persistence — no validation, accepts all fields.
#[allow(clippy::too_many_arguments)]
pub fn from_persistence(
id: ChannelId,
owner_id: UserId,
@@ -117,8 +108,6 @@ impl Channel {
}
}
// -- Getters --
pub fn id(&self) -> ChannelId {
self.id
}
@@ -195,57 +184,38 @@ impl Channel {
self.updated_at
}
// -- Setters --
/// Update the channel name and touch `updated_at`.
pub fn set_name(&mut self, name: impl Into<String>) {
self.name = name.into();
self.updated_at = Utc::now();
}
/// Update the description and touch `updated_at`.
pub fn set_description(&mut self, description: Option<String>) {
self.description = description;
self.updated_at = Utc::now();
}
/// Update the timezone and touch `updated_at`.
pub fn set_timezone(&mut self, timezone: impl Into<String>) {
self.timezone = timezone.into();
self.updated_at = Utc::now();
}
/// Replace the schedule config and touch `updated_at`.
pub fn set_schedule_config(&mut self, config: ScheduleConfig) {
self.schedule_config = config;
self.updated_at = Utc::now();
}
/// Replace the recycle policy and touch `updated_at`.
pub fn set_recycle_policy(&mut self, policy: RecyclePolicy) {
self.recycle_policy = policy;
self.updated_at = Utc::now();
}
/// Toggle auto-schedule and touch `updated_at`.
pub fn set_auto_schedule(&mut self, enabled: bool) {
self.auto_schedule = enabled;
self.updated_at = Utc::now();
}
}
// ============================================================================
// ScheduleConfig
// ============================================================================
/// The user-designed programming template (V2: day-keyed weekly grid).
///
/// Each day of the week has its own independent list of `ProgrammingBlock`s.
/// A day with an empty vec (or absent key) produces no slots — valid, not an error.
/// A channel does not need to cover all 24 hours — gaps render as no-signal.
///
/// `deny_unknown_fields` is required so the `#[serde(untagged)]` compat enum
/// correctly rejects V1 `{"blocks":[...]}` payloads and falls through to `OldScheduleConfig`.
// 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 {
@@ -253,36 +223,31 @@ pub struct ScheduleConfig {
}
impl ScheduleConfig {
/// Create a new empty schedule config.
pub fn new() -> Self {
Self::default()
}
/// Create from a pre-built day_blocks map (e.g. from compat migration).
pub fn from_day_blocks(day_blocks: HashMap<Weekday, Vec<ProgrammingBlock>>) -> Self {
Self { day_blocks }
}
/// Blocks for a given day. Returns empty slice if the day has no blocks.
pub fn blocks_for(&self, day: Weekday) -> &[ProgrammingBlock] {
self.day_blocks.get(&day).map(|v| v.as_slice()).unwrap_or(&[])
}
/// The block whose window contains `time` on `day`, if any.
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 <= 86_400 {
if end <= SECONDS_IN_DAY {
secs >= start && secs < end
} else {
secs >= start || secs < (end % 86_400)
secs >= start || secs < (end % SECONDS_IN_DAY)
}
})
}
/// The start time of the next block beginning strictly after `time` on `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)
@@ -293,8 +258,6 @@ impl ScheduleConfig {
.and_then(|s| NaiveTime::from_num_seconds_from_midnight_opt(s, 0))
}
/// Earliest block start time across ALL days (used by background scheduler).
/// Returns `None` if every day is empty.
pub fn earliest_block_start(&self) -> Option<NaiveTime> {
self.day_blocks
.values()
@@ -303,29 +266,19 @@ impl ScheduleConfig {
.min()
}
/// Iterator over all blocks across all days (for block-ID lookups that are day-agnostic).
pub fn all_blocks(&self) -> impl Iterator<Item = &ProgrammingBlock> {
self.day_blocks.values().flatten()
}
/// Get the underlying day_blocks map.
pub fn day_blocks(&self) -> &HashMap<Weekday, Vec<ProgrammingBlock>> {
&self.day_blocks
}
/// Insert blocks for a specific day (used in tests and config building).
pub fn insert_day(&mut self, day: Weekday, blocks: Vec<ProgrammingBlock>) {
self.day_blocks.insert(day, blocks);
}
}
// ============================================================================
// OldScheduleConfig + ScheduleConfigCompat
// ============================================================================
/// V1 on-disk shape — kept for transparent migration only.
/// Never construct directly; use `ScheduleConfigCompat` for deserialization.
/// `deny_unknown_fields` ensures V2 payloads don't accidentally match here.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OldScheduleConfig {
@@ -338,8 +291,6 @@ impl OldScheduleConfig {
}
}
/// Deserializes either V2 (`day_blocks`) or V1 (`blocks`) from the DB.
/// V1 is automatically promoted: all blocks are copied to all 7 days.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ScheduleConfigCompat {
@@ -362,44 +313,32 @@ impl From<ScheduleConfigCompat> for ScheduleConfig {
}
}
// ============================================================================
// ProgrammingBlock
// ============================================================================
fn default_true() -> bool {
true
}
/// A single programming rule within a `ScheduleConfig`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgrammingBlock {
id: BlockId,
name: String,
/// Local time of day (in the channel's timezone) when this block starts.
start_time: NaiveTime,
/// Target duration in minutes.
duration_mins: u32,
content: BlockContent,
/// Sequential only: loop back to episode 1 after the last episode. Default: true.
#[serde(default = "default_true")]
loop_on_finish: bool,
/// When true, skip the channel-level recycle policy for this block.
#[serde(default)]
ignore_recycle_policy: bool,
/// Who can watch the stream during this block.
#[serde(default)]
access_mode: AccessMode,
/// Bcrypt/argon2 hash of the block password (when access_mode = PasswordProtected).
#[serde(default, skip_serializing_if = "Option::is_none")]
access_password_hash: Option<String>,
}
impl ProgrammingBlock {
/// Create a new algorithmic programming block.
pub fn new_algorithmic(
name: impl Into<String>,
start_time: NaiveTime,
@@ -424,7 +363,6 @@ impl ProgrammingBlock {
}
}
/// Create a new manual programming block with hand-picked items.
pub fn new_manual(
name: impl Into<String>,
start_time: NaiveTime,
@@ -447,8 +385,6 @@ impl ProgrammingBlock {
}
}
// -- Getters --
pub fn id(&self) -> BlockId {
self.id
}
@@ -486,146 +422,22 @@ impl ProgrammingBlock {
}
}
// ============================================================================
// BlockContent
// ============================================================================
/// How the content of a `ProgrammingBlock` is determined.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BlockContent {
/// The user hand-picked specific items in a specific order.
Manual {
items: Vec<MediaItemId>,
/// Registry key of the provider these items come from. Empty string = primary.
#[serde(default)]
provider_id: String,
},
/// The engine selects items from the provider using the given filter and strategy.
Algorithmic {
filter: MediaFilter,
strategy: FillStrategy,
/// Registry key of the provider to query. Empty string = primary.
#[serde(default)]
provider_id: String,
},
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
fn t(h: u32, m: u32) -> NaiveTime {
NaiveTime::from_hms_opt(h, m, 0).unwrap()
}
fn make_block(start: NaiveTime, duration_mins: u32) -> ProgrammingBlock {
ProgrammingBlock::new_algorithmic(
"test",
start,
duration_mins,
Default::default(),
FillStrategy::Random,
)
}
fn cfg_with_monday_block(start: NaiveTime, dur: u32) -> ScheduleConfig {
let mut cfg = ScheduleConfig::default();
cfg.insert_day(Weekday::Monday, vec![make_block(start, dur)]);
cfg
}
#[test]
fn find_block_at_finds_active_block() {
let cfg = cfg_with_monday_block(t(8, 0), 60);
assert!(cfg.find_block_at(Weekday::Monday, t(8, 30)).is_some());
assert!(cfg.find_block_at(Weekday::Monday, t(9, 0)).is_none());
}
#[test]
fn find_block_at_wrong_day_returns_none() {
let cfg = cfg_with_monday_block(t(8, 0), 60);
assert!(cfg.find_block_at(Weekday::Tuesday, t(8, 30)).is_none());
}
#[test]
fn v1_compat_copies_blocks_to_all_days() {
let json = r#"{"blocks": []}"#;
let compat: ScheduleConfigCompat = serde_json::from_str(json).unwrap();
let cfg: ScheduleConfig = compat.into();
assert_eq!(cfg.day_blocks().len(), 7);
}
#[test]
fn v2_payload_with_unknown_blocks_key_fails() {
let json = r#"{"blocks": [], "day_blocks": {}}"#;
let result: Result<ScheduleConfigCompat, _> = serde_json::from_str(json);
match result {
Ok(ScheduleConfigCompat::V2(cfg)) => {
let _ = cfg;
}
Ok(ScheduleConfigCompat::V1(_)) => { /* acceptable */ }
Err(_) => { /* acceptable — ambiguous payload rejected */ }
}
}
#[test]
fn earliest_block_start_across_days() {
let mut cfg = ScheduleConfig::default();
cfg.insert_day(Weekday::Monday, vec![make_block(t(10, 0), 60)]);
cfg.insert_day(Weekday::Friday, vec![make_block(t(7, 0), 60)]);
assert_eq!(cfg.earliest_block_start(), Some(t(7, 0)));
}
#[test]
fn empty_config_earliest_block_start_is_none() {
let cfg = ScheduleConfig::default();
assert!(cfg.earliest_block_start().is_none());
}
#[test]
fn channel_new_defaults() {
let owner = UserId::generate();
let ch = Channel::new(owner, "Test Channel", "America/New_York");
assert_eq!(ch.name(), "Test Channel");
assert_eq!(ch.timezone(), "America/New_York");
assert_eq!(ch.owner_id(), owner);
assert!(!ch.auto_schedule());
assert!(ch.description().is_none());
assert_eq!(ch.logo_opacity(), 1.0);
assert_eq!(ch.webhook_poll_interval_secs(), 5);
}
#[test]
fn programming_block_getters() {
let block = ProgrammingBlock::new_algorithmic(
"Morning",
t(8, 0),
120,
Default::default(),
FillStrategy::BestFit,
);
assert_eq!(block.name(), "Morning");
assert_eq!(block.start_time(), t(8, 0));
assert_eq!(block.duration_mins(), 120);
assert!(block.loop_on_finish());
assert!(!block.ignore_recycle_policy());
}
#[test]
fn manual_block_creation() {
let items = vec![MediaItemId::new("item1"), MediaItemId::new("item2")];
let block = ProgrammingBlock::new_manual("Manual Block", t(20, 0), 60, items);
match block.content() {
BlockContent::Manual { items, provider_id } => {
assert_eq!(items.len(), 2);
assert!(provider_id.is_empty());
}
_ => panic!("Expected Manual content"),
}
}
}
#[path = "tests/channel.rs"]
mod tests;