feat(domain): ScheduleConfig V2 day-keyed weekly grid with V1 compat
This commit is contained in:
@@ -16,3 +16,4 @@ uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["rt", "macros"] }
|
tokio = { version = "1", features = ["rt", "macros"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
pub use crate::value_objects::{Email, UserId};
|
pub use crate::value_objects::{Email, UserId};
|
||||||
use chrono::{DateTime, NaiveTime, Timelike, Utc};
|
use chrono::{DateTime, NaiveTime, Timelike, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::value_objects::{
|
use crate::value_objects::{
|
||||||
AccessMode, BlockId, ChannelId, ContentType, FillStrategy, LogoPosition, MediaFilter,
|
AccessMode, BlockId, ChannelId, ContentType, FillStrategy, LogoPosition, MediaFilter,
|
||||||
MediaItemId, RecyclePolicy, SlotId,
|
MediaItemId, RecyclePolicy, SlotId, Weekday,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A user in the system.
|
/// A user in the system.
|
||||||
@@ -132,40 +133,77 @@ impl Channel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user-designed programming template.
|
/// The user-designed programming template (V2: day-keyed weekly grid).
|
||||||
///
|
///
|
||||||
/// This is the shareable/exportable part of a channel. It contains an ordered
|
/// Each day of the week has its own independent list of `ProgrammingBlock`s.
|
||||||
/// list of `ProgrammingBlock`s but makes no assumptions about the media source.
|
/// 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 are valid and render
|
/// A channel does not need to cover all 24 hours — gaps render as no-signal.
|
||||||
/// as a no-signal state on the client.
|
///
|
||||||
|
/// `deny_unknown_fields` is required so the `#[serde(untagged)]` compat enum
|
||||||
|
/// correctly rejects V1 `{"blocks":[...]}` payloads and falls through to `OldScheduleConfig`.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ScheduleConfig {
|
pub struct ScheduleConfig {
|
||||||
|
pub day_blocks: HashMap<Weekday, Vec<ProgrammingBlock>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
pub blocks: Vec<ProgrammingBlock>,
|
pub blocks: Vec<ProgrammingBlock>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ScheduleConfig {
|
impl ScheduleConfig {
|
||||||
/// Return the block whose time window contains `time`, if any.
|
/// Blocks for a given day. Returns empty slice if the day has no blocks.
|
||||||
///
|
pub fn blocks_for(&self, day: Weekday) -> &[ProgrammingBlock] {
|
||||||
/// Handles blocks that span midnight (e.g. start 23:00, duration 180 min).
|
self.day_blocks.get(&day).map(|v| v.as_slice()).unwrap_or(&[])
|
||||||
pub fn find_block_at(&self, time: NaiveTime) -> Option<&ProgrammingBlock> {
|
}
|
||||||
|
|
||||||
|
/// 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();
|
let secs = time.num_seconds_from_midnight();
|
||||||
self.blocks.iter().find(|block| {
|
self.blocks_for(day).iter().find(|block| {
|
||||||
let start = block.start_time.num_seconds_from_midnight();
|
let start = block.start_time.num_seconds_from_midnight();
|
||||||
let end = start + block.duration_mins * 60;
|
let end = start + block.duration_mins * 60;
|
||||||
if end <= 86_400 {
|
if end <= 86_400 {
|
||||||
secs >= start && secs < end
|
secs >= start && secs < end
|
||||||
} else {
|
} else {
|
||||||
// Block crosses midnight: active from `start` to `end % 86400` next day
|
|
||||||
secs >= start || secs < (end % 86_400)
|
secs >= start || secs < (end % 86_400)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the start time of the next block that begins strictly after `time`,
|
/// The start time of the next block beginning strictly after `time` on `day`.
|
||||||
/// within the same calendar day.
|
pub fn next_block_start_after(&self, day: Weekday, time: NaiveTime) -> Option<NaiveTime> {
|
||||||
pub fn next_block_start_after(&self, time: NaiveTime) -> Option<NaiveTime> {
|
|
||||||
let secs = time.num_seconds_from_midnight();
|
let secs = time.num_seconds_from_midnight();
|
||||||
self.blocks
|
self.blocks_for(day)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|b| b.start_time.num_seconds_from_midnight())
|
.map(|b| b.start_time.num_seconds_from_midnight())
|
||||||
.filter(|&s| s > secs)
|
.filter(|&s| s > secs)
|
||||||
@@ -173,9 +211,15 @@ impl ScheduleConfig {
|
|||||||
.and_then(|s| NaiveTime::from_num_seconds_from_midnight_opt(s, 0))
|
.and_then(|s| NaiveTime::from_num_seconds_from_midnight_opt(s, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The earliest block start time across all blocks (used for next-day rollover).
|
/// 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> {
|
pub fn earliest_block_start(&self) -> Option<NaiveTime> {
|
||||||
self.blocks.iter().map(|b| b.start_time).min()
|
self.day_blocks.values().flatten().map(|b| b.start_time).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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,3 +411,74 @@ impl PlaybackRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod schedule_config_tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::NaiveTime;
|
||||||
|
|
||||||
|
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.day_blocks.insert(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.day_blocks.insert(Weekday::Monday, vec![make_block(t(10, 0), 60)]);
|
||||||
|
cfg.day_blocks.insert(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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::{DateTime, Duration, TimeZone, Utc};
|
use chrono::{DateTime, Datelike, Duration, TimeZone, Utc};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ impl ScheduleEngineService {
|
|||||||
let mut current_date = start_date;
|
let mut current_date = start_date;
|
||||||
|
|
||||||
while current_date <= end_date {
|
while current_date <= end_date {
|
||||||
for block in &channel.schedule_config.blocks {
|
for block in channel.schedule_config.blocks_for(chrono::Weekday::from(current_date.weekday()).into()) {
|
||||||
let naive_start = current_date.and_time(block.start_time);
|
let naive_start = current_date.and_time(block.start_time);
|
||||||
|
|
||||||
// `earliest()` handles DST gaps — if the local time doesn't exist
|
// `earliest()` handles DST gaps — if the local time doesn't exist
|
||||||
|
|||||||
Reference in New Issue
Block a user