domain models: schedule, library, config_snapshot, activity, provider_config
This commit is contained in:
117
crates/domain/src/models/activity.rs
Normal file
117
crates/domain/src/models/activity.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ActivityEvent
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// An in-app activity event stored in the database for the admin log view.
|
||||||
|
///
|
||||||
|
/// Captures user and system actions (channel created, schedule generated, etc.)
|
||||||
|
/// for the admin dashboard's activity feed.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ActivityEvent {
|
||||||
|
id: Uuid,
|
||||||
|
timestamp: DateTime<Utc>,
|
||||||
|
event_type: String,
|
||||||
|
detail: String,
|
||||||
|
channel_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityEvent {
|
||||||
|
/// Create a new activity event (generates ID and timestamp).
|
||||||
|
pub fn new(
|
||||||
|
event_type: impl Into<String>,
|
||||||
|
detail: impl Into<String>,
|
||||||
|
channel_id: Option<Uuid>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
event_type: event_type.into(),
|
||||||
|
detail: detail.into(),
|
||||||
|
channel_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hydrate from persistence -- no validation, accepts all fields.
|
||||||
|
pub fn from_persistence(
|
||||||
|
id: Uuid,
|
||||||
|
timestamp: DateTime<Utc>,
|
||||||
|
event_type: String,
|
||||||
|
detail: String,
|
||||||
|
channel_id: Option<Uuid>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
timestamp,
|
||||||
|
event_type,
|
||||||
|
detail,
|
||||||
|
channel_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Getters --
|
||||||
|
|
||||||
|
pub fn id(&self) -> Uuid {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn timestamp(&self) -> DateTime<Utc> {
|
||||||
|
self.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn event_type(&self) -> &str {
|
||||||
|
&self.event_type
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
&self.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn channel_id(&self) -> Option<Uuid> {
|
||||||
|
self.channel_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_generates_id_and_timestamp() {
|
||||||
|
let event = ActivityEvent::new("channel_created", "Channel 'CNN' created", None);
|
||||||
|
assert_eq!(event.event_type(), "channel_created");
|
||||||
|
assert_eq!(event.detail(), "Channel 'CNN' created");
|
||||||
|
assert!(event.channel_id().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_with_channel_id() {
|
||||||
|
let ch_id = Uuid::new_v4();
|
||||||
|
let event = ActivityEvent::new("schedule_generated", "Gen #5", Some(ch_id));
|
||||||
|
assert_eq!(event.channel_id(), Some(ch_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_persistence_round_trip() {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let ch_id = Uuid::new_v4();
|
||||||
|
let now = Utc::now();
|
||||||
|
let event = ActivityEvent::from_persistence(
|
||||||
|
id,
|
||||||
|
now,
|
||||||
|
"sync_complete".into(),
|
||||||
|
"150 items synced".into(),
|
||||||
|
Some(ch_id),
|
||||||
|
);
|
||||||
|
assert_eq!(event.id(), id);
|
||||||
|
assert_eq!(event.timestamp(), now);
|
||||||
|
assert_eq!(event.event_type(), "sync_complete");
|
||||||
|
assert_eq!(event.channel_id(), Some(ch_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
136
crates/domain/src/models/config_snapshot.rs
Normal file
136
crates/domain/src/models/config_snapshot.rs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::value_objects::ChannelId;
|
||||||
|
|
||||||
|
use super::ScheduleConfig;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ChannelConfigSnapshot
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A point-in-time snapshot of a channel's `ScheduleConfig`.
|
||||||
|
///
|
||||||
|
/// Auto-created on every config save; users can pin with a label.
|
||||||
|
/// Enables config history browsing and rollback.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelConfigSnapshot {
|
||||||
|
id: Uuid,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
config: ScheduleConfig,
|
||||||
|
version_num: i64,
|
||||||
|
label: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelConfigSnapshot {
|
||||||
|
/// Create a new snapshot (generates ID and timestamp).
|
||||||
|
pub fn new(
|
||||||
|
channel_id: ChannelId,
|
||||||
|
config: ScheduleConfig,
|
||||||
|
version_num: i64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
channel_id,
|
||||||
|
config,
|
||||||
|
version_num,
|
||||||
|
label: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hydrate from persistence -- no validation, accepts all fields.
|
||||||
|
pub fn from_persistence(
|
||||||
|
id: Uuid,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
config: ScheduleConfig,
|
||||||
|
version_num: i64,
|
||||||
|
label: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
channel_id,
|
||||||
|
config,
|
||||||
|
version_num,
|
||||||
|
label,
|
||||||
|
created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Getters --
|
||||||
|
|
||||||
|
pub fn id(&self) -> Uuid {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn channel_id(&self) -> ChannelId {
|
||||||
|
self.channel_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(&self) -> &ScheduleConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn version_num(&self) -> i64 {
|
||||||
|
self.version_num
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> Option<&str> {
|
||||||
|
self.label.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn created_at(&self) -> DateTime<Utc> {
|
||||||
|
self.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_generates_id_and_timestamp() {
|
||||||
|
let ch_id = ChannelId::generate();
|
||||||
|
let snap = ChannelConfigSnapshot::new(ch_id, ScheduleConfig::default(), 1);
|
||||||
|
assert_eq!(snap.channel_id(), ch_id);
|
||||||
|
assert_eq!(snap.version_num(), 1);
|
||||||
|
assert!(snap.label().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_persistence_round_trip() {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let ch_id = ChannelId::generate();
|
||||||
|
let now = Utc::now();
|
||||||
|
let snap = ChannelConfigSnapshot::from_persistence(
|
||||||
|
id,
|
||||||
|
ch_id,
|
||||||
|
ScheduleConfig::default(),
|
||||||
|
42,
|
||||||
|
Some("release-v1".into()),
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
assert_eq!(snap.id(), id);
|
||||||
|
assert_eq!(snap.version_num(), 42);
|
||||||
|
assert_eq!(snap.label(), Some("release-v1"));
|
||||||
|
assert_eq!(snap.created_at(), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_accessor() {
|
||||||
|
let snap = ChannelConfigSnapshot::new(
|
||||||
|
ChannelId::generate(),
|
||||||
|
ScheduleConfig::default(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// Default config has empty day_blocks
|
||||||
|
assert!(snap.config().day_blocks().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
624
crates/domain/src/models/library.rs
Normal file
624
crates/domain/src/models/library.rs
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
use crate::value_objects::ContentType;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LibraryItem
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A media item stored in the local library cache, synced from a provider.
|
||||||
|
///
|
||||||
|
/// The `id` format is `"{provider_id}::{external_id}"` -- this composite key
|
||||||
|
/// allows items from multiple providers to coexist in the same table.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LibraryItem {
|
||||||
|
id: String,
|
||||||
|
provider_id: String,
|
||||||
|
external_id: String,
|
||||||
|
title: String,
|
||||||
|
content_type: ContentType,
|
||||||
|
duration_secs: u32,
|
||||||
|
series_name: Option<String>,
|
||||||
|
season_number: Option<u32>,
|
||||||
|
episode_number: Option<u32>,
|
||||||
|
year: Option<u16>,
|
||||||
|
genres: Vec<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
collection_id: Option<String>,
|
||||||
|
collection_name: Option<String>,
|
||||||
|
collection_type: Option<String>,
|
||||||
|
thumbnail_url: Option<String>,
|
||||||
|
synced_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibraryItem {
|
||||||
|
/// Create a new library item with required fields; optional fields default to None/empty.
|
||||||
|
pub fn new(
|
||||||
|
provider_id: impl Into<String>,
|
||||||
|
external_id: impl Into<String>,
|
||||||
|
title: impl Into<String>,
|
||||||
|
content_type: ContentType,
|
||||||
|
duration_secs: u32,
|
||||||
|
synced_at: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
let provider_id = provider_id.into();
|
||||||
|
let external_id = external_id.into();
|
||||||
|
let id = format!("{}::{}", provider_id, external_id);
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
provider_id,
|
||||||
|
external_id,
|
||||||
|
title: title.into(),
|
||||||
|
content_type,
|
||||||
|
duration_secs,
|
||||||
|
series_name: None,
|
||||||
|
season_number: None,
|
||||||
|
episode_number: None,
|
||||||
|
year: None,
|
||||||
|
genres: Vec::new(),
|
||||||
|
tags: Vec::new(),
|
||||||
|
collection_id: None,
|
||||||
|
collection_name: None,
|
||||||
|
collection_type: None,
|
||||||
|
thumbnail_url: None,
|
||||||
|
synced_at: synced_at.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hydrate from persistence -- no validation, accepts all fields.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn from_persistence(
|
||||||
|
id: String,
|
||||||
|
provider_id: String,
|
||||||
|
external_id: String,
|
||||||
|
title: String,
|
||||||
|
content_type: ContentType,
|
||||||
|
duration_secs: u32,
|
||||||
|
series_name: Option<String>,
|
||||||
|
season_number: Option<u32>,
|
||||||
|
episode_number: Option<u32>,
|
||||||
|
year: Option<u16>,
|
||||||
|
genres: Vec<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
collection_id: Option<String>,
|
||||||
|
collection_name: Option<String>,
|
||||||
|
collection_type: Option<String>,
|
||||||
|
thumbnail_url: Option<String>,
|
||||||
|
synced_at: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Getters --
|
||||||
|
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provider_id(&self) -> &str {
|
||||||
|
&self.provider_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn external_id(&self) -> &str {
|
||||||
|
&self.external_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn content_type(&self) -> &ContentType {
|
||||||
|
&self.content_type
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn duration_secs(&self) -> u32 {
|
||||||
|
self.duration_secs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn series_name(&self) -> Option<&str> {
|
||||||
|
self.series_name.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn season_number(&self) -> Option<u32> {
|
||||||
|
self.season_number
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn episode_number(&self) -> Option<u32> {
|
||||||
|
self.episode_number
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn year(&self) -> Option<u16> {
|
||||||
|
self.year
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn genres(&self) -> &[String] {
|
||||||
|
&self.genres
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tags(&self) -> &[String] {
|
||||||
|
&self.tags
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collection_id(&self) -> Option<&str> {
|
||||||
|
self.collection_id.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collection_name(&self) -> Option<&str> {
|
||||||
|
self.collection_name.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collection_type(&self) -> Option<&str> {
|
||||||
|
self.collection_type.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn thumbnail_url(&self) -> Option<&str> {
|
||||||
|
self.thumbnail_url.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn synced_at(&self) -> &str {
|
||||||
|
&self.synced_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LibraryCollection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A collection summary derived from synced library items.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LibraryCollection {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
collection_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibraryCollection {
|
||||||
|
pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
name: name.into(),
|
||||||
|
collection_type: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_persistence(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
collection_type: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
collection_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Getters --
|
||||||
|
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collection_type(&self) -> Option<&str> {
|
||||||
|
self.collection_type.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LibrarySyncResult
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Result of a single provider sync run.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LibrarySyncResult {
|
||||||
|
provider_id: String,
|
||||||
|
items_found: u32,
|
||||||
|
duration_ms: u64,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibrarySyncResult {
|
||||||
|
pub fn new(
|
||||||
|
provider_id: impl Into<String>,
|
||||||
|
items_found: u32,
|
||||||
|
duration_ms: u64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
provider_id: provider_id.into(),
|
||||||
|
items_found,
|
||||||
|
duration_ms,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_error(
|
||||||
|
provider_id: impl Into<String>,
|
||||||
|
duration_ms: u64,
|
||||||
|
error: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
provider_id: provider_id.into(),
|
||||||
|
items_found: 0,
|
||||||
|
duration_ms,
|
||||||
|
error: Some(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_persistence(
|
||||||
|
provider_id: String,
|
||||||
|
items_found: u32,
|
||||||
|
duration_ms: u64,
|
||||||
|
error: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
provider_id,
|
||||||
|
items_found,
|
||||||
|
duration_ms,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Getters --
|
||||||
|
|
||||||
|
pub fn provider_id(&self) -> &str {
|
||||||
|
&self.provider_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn items_found(&self) -> u32 {
|
||||||
|
self.items_found
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn duration_ms(&self) -> u64 {
|
||||||
|
self.duration_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(&self) -> Option<&str> {
|
||||||
|
self.error.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LibrarySyncLogEntry
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Log entry from the library_sync_log table.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LibrarySyncLogEntry {
|
||||||
|
id: i64,
|
||||||
|
provider_id: String,
|
||||||
|
started_at: String,
|
||||||
|
finished_at: Option<String>,
|
||||||
|
items_found: u32,
|
||||||
|
status: String,
|
||||||
|
error_msg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibrarySyncLogEntry {
|
||||||
|
pub fn new(id: i64, provider_id: impl Into<String>, started_at: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
provider_id: provider_id.into(),
|
||||||
|
started_at: started_at.into(),
|
||||||
|
finished_at: None,
|
||||||
|
items_found: 0,
|
||||||
|
status: "running".to_string(),
|
||||||
|
error_msg: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_persistence(
|
||||||
|
id: i64,
|
||||||
|
provider_id: String,
|
||||||
|
started_at: String,
|
||||||
|
finished_at: Option<String>,
|
||||||
|
items_found: u32,
|
||||||
|
status: String,
|
||||||
|
error_msg: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
provider_id,
|
||||||
|
started_at,
|
||||||
|
finished_at,
|
||||||
|
items_found,
|
||||||
|
status,
|
||||||
|
error_msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Getters --
|
||||||
|
|
||||||
|
pub fn id(&self) -> i64 {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provider_id(&self) -> &str {
|
||||||
|
&self.provider_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn started_at(&self) -> &str {
|
||||||
|
&self.started_at
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finished_at(&self) -> Option<&str> {
|
||||||
|
self.finished_at.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn items_found(&self) -> u32 {
|
||||||
|
self.items_found
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> &str {
|
||||||
|
&self.status
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error_msg(&self) -> Option<&str> {
|
||||||
|
self.error_msg.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ShowSummary
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Aggregated summary of a TV show derived from synced episodes.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ShowSummary {
|
||||||
|
series_name: String,
|
||||||
|
episode_count: u32,
|
||||||
|
season_count: u32,
|
||||||
|
thumbnail_url: Option<String>,
|
||||||
|
genres: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShowSummary {
|
||||||
|
pub fn new(
|
||||||
|
series_name: impl Into<String>,
|
||||||
|
episode_count: u32,
|
||||||
|
season_count: u32,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
series_name: series_name.into(),
|
||||||
|
episode_count,
|
||||||
|
season_count,
|
||||||
|
thumbnail_url: None,
|
||||||
|
genres: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_persistence(
|
||||||
|
series_name: String,
|
||||||
|
episode_count: u32,
|
||||||
|
season_count: u32,
|
||||||
|
thumbnail_url: Option<String>,
|
||||||
|
genres: Vec<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
series_name,
|
||||||
|
episode_count,
|
||||||
|
season_count,
|
||||||
|
thumbnail_url,
|
||||||
|
genres,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Getters --
|
||||||
|
|
||||||
|
pub fn series_name(&self) -> &str {
|
||||||
|
&self.series_name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn episode_count(&self) -> u32 {
|
||||||
|
self.episode_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn season_count(&self) -> u32 {
|
||||||
|
self.season_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn thumbnail_url(&self) -> Option<&str> {
|
||||||
|
self.thumbnail_url.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn genres(&self) -> &[String] {
|
||||||
|
&self.genres
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SeasonSummary
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Aggregated summary of one season of a TV show.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SeasonSummary {
|
||||||
|
season_number: u32,
|
||||||
|
episode_count: u32,
|
||||||
|
thumbnail_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SeasonSummary {
|
||||||
|
pub fn new(season_number: u32, episode_count: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
season_number,
|
||||||
|
episode_count,
|
||||||
|
thumbnail_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_persistence(
|
||||||
|
season_number: u32,
|
||||||
|
episode_count: u32,
|
||||||
|
thumbnail_url: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
season_number,
|
||||||
|
episode_count,
|
||||||
|
thumbnail_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Getters --
|
||||||
|
|
||||||
|
pub fn season_number(&self) -> u32 {
|
||||||
|
self.season_number
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn episode_count(&self) -> u32 {
|
||||||
|
self.episode_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn thumbnail_url(&self) -> Option<&str> {
|
||||||
|
self.thumbnail_url.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_item_new_generates_composite_id() {
|
||||||
|
let item = LibraryItem::new("jellyfin", "abc123", "Test Movie", ContentType::Movie, 7200, "2026-03-19T00:00:00Z");
|
||||||
|
assert_eq!(item.id(), "jellyfin::abc123");
|
||||||
|
assert_eq!(item.provider_id(), "jellyfin");
|
||||||
|
assert_eq!(item.external_id(), "abc123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_item_new_defaults_optional_fields() {
|
||||||
|
let item = LibraryItem::new("jf", "1", "Movie", ContentType::Movie, 3600, "2026-01-01");
|
||||||
|
assert!(item.series_name().is_none());
|
||||||
|
assert!(item.season_number().is_none());
|
||||||
|
assert!(item.genres().is_empty());
|
||||||
|
assert!(item.tags().is_empty());
|
||||||
|
assert!(item.collection_id().is_none());
|
||||||
|
assert!(item.thumbnail_url().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_item_from_persistence_all_fields() {
|
||||||
|
let item = LibraryItem::from_persistence(
|
||||||
|
"jf::abc".into(),
|
||||||
|
"jf".into(),
|
||||||
|
"abc".into(),
|
||||||
|
"Breaking Bad S01E01".into(),
|
||||||
|
ContentType::Episode,
|
||||||
|
2700,
|
||||||
|
Some("Breaking Bad".into()),
|
||||||
|
Some(1),
|
||||||
|
Some(1),
|
||||||
|
Some(2008),
|
||||||
|
vec!["Drama".into()],
|
||||||
|
vec!["tv".into()],
|
||||||
|
Some("col-1".into()),
|
||||||
|
Some("TV Shows".into()),
|
||||||
|
Some("tvshows".into()),
|
||||||
|
Some("http://thumb.jpg".into()),
|
||||||
|
"2026-03-19T00:00:00Z".into(),
|
||||||
|
);
|
||||||
|
assert_eq!(item.series_name(), Some("Breaking Bad"));
|
||||||
|
assert_eq!(item.season_number(), Some(1));
|
||||||
|
assert_eq!(item.year(), Some(2008));
|
||||||
|
assert_eq!(item.collection_name(), Some("TV Shows"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_collection_new_and_getters() {
|
||||||
|
let col = LibraryCollection::new("col-1", "Movies");
|
||||||
|
assert_eq!(col.id(), "col-1");
|
||||||
|
assert_eq!(col.name(), "Movies");
|
||||||
|
assert!(col.collection_type().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_collection_from_persistence() {
|
||||||
|
let col = LibraryCollection::from_persistence(
|
||||||
|
"col-2".into(),
|
||||||
|
"TV Shows".into(),
|
||||||
|
Some("tvshows".into()),
|
||||||
|
);
|
||||||
|
assert_eq!(col.collection_type(), Some("tvshows"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_result_success() {
|
||||||
|
let r = LibrarySyncResult::new("jellyfin", 150, 1200);
|
||||||
|
assert_eq!(r.provider_id(), "jellyfin");
|
||||||
|
assert_eq!(r.items_found(), 150);
|
||||||
|
assert_eq!(r.duration_ms(), 1200);
|
||||||
|
assert!(r.error().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_result_with_error() {
|
||||||
|
let r = LibrarySyncResult::with_error("jellyfin", 500, "connection refused");
|
||||||
|
assert_eq!(r.items_found(), 0);
|
||||||
|
assert_eq!(r.error(), Some("connection refused"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_log_entry_new_defaults() {
|
||||||
|
let entry = LibrarySyncLogEntry::new(1, "jellyfin", "2026-03-19T00:00:00Z");
|
||||||
|
assert_eq!(entry.id(), 1);
|
||||||
|
assert_eq!(entry.status(), "running");
|
||||||
|
assert_eq!(entry.items_found(), 0);
|
||||||
|
assert!(entry.finished_at().is_none());
|
||||||
|
assert!(entry.error_msg().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_summary_getters() {
|
||||||
|
let show = ShowSummary::from_persistence(
|
||||||
|
"Breaking Bad".into(),
|
||||||
|
62,
|
||||||
|
5,
|
||||||
|
Some("http://thumb.jpg".into()),
|
||||||
|
vec!["Drama".into(), "Crime".into()],
|
||||||
|
);
|
||||||
|
assert_eq!(show.series_name(), "Breaking Bad");
|
||||||
|
assert_eq!(show.episode_count(), 62);
|
||||||
|
assert_eq!(show.season_count(), 5);
|
||||||
|
assert_eq!(show.genres().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn season_summary_getters() {
|
||||||
|
let season = SeasonSummary::from_persistence(1, 7, Some("http://s1.jpg".into()));
|
||||||
|
assert_eq!(season.season_number(), 1);
|
||||||
|
assert_eq!(season.episode_count(), 7);
|
||||||
|
assert_eq!(season.thumbnail_url(), Some("http://s1.jpg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn season_summary_new_defaults() {
|
||||||
|
let season = SeasonSummary::new(3, 13);
|
||||||
|
assert_eq!(season.season_number(), 3);
|
||||||
|
assert_eq!(season.episode_count(), 13);
|
||||||
|
assert!(season.thumbnail_url().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,25 @@
|
|||||||
|
mod activity;
|
||||||
mod channel;
|
mod channel;
|
||||||
mod collections;
|
mod collections;
|
||||||
|
mod config_snapshot;
|
||||||
|
mod library;
|
||||||
mod media;
|
mod media;
|
||||||
|
mod provider_config;
|
||||||
|
mod schedule;
|
||||||
mod user;
|
mod user;
|
||||||
|
|
||||||
|
pub use activity::ActivityEvent;
|
||||||
pub use channel::{
|
pub use channel::{
|
||||||
BlockContent, Channel, OldScheduleConfig, ProgrammingBlock, ScheduleConfig,
|
BlockContent, Channel, OldScheduleConfig, ProgrammingBlock, ScheduleConfig,
|
||||||
ScheduleConfigCompat,
|
ScheduleConfigCompat,
|
||||||
};
|
};
|
||||||
pub use collections::{PageParams, Paginated};
|
pub use collections::{PageParams, Paginated};
|
||||||
|
pub use config_snapshot::ChannelConfigSnapshot;
|
||||||
|
pub use library::{
|
||||||
|
LibraryCollection, LibraryItem, LibrarySyncLogEntry, LibrarySyncResult, SeasonSummary,
|
||||||
|
ShowSummary,
|
||||||
|
};
|
||||||
pub use media::{MediaItem, PlaybackRecord};
|
pub use media::{MediaItem, PlaybackRecord};
|
||||||
|
pub use provider_config::ProviderConfigRow;
|
||||||
|
pub use schedule::{CurrentBroadcast, GeneratedSchedule, ScheduledSlot};
|
||||||
pub use user::User;
|
pub use user::User;
|
||||||
|
|||||||
112
crates/domain/src/models/provider_config.rs
Normal file
112
crates/domain/src/models/provider_config.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// ProviderConfigRow
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A row from the provider_configs table.
|
||||||
|
///
|
||||||
|
/// Stores the JSON configuration blob for a registered media provider
|
||||||
|
/// (e.g. Jellyfin URL + API key). The `provider_type` discriminates which
|
||||||
|
/// adapter to instantiate at runtime.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProviderConfigRow {
|
||||||
|
id: String,
|
||||||
|
provider_type: String,
|
||||||
|
config_json: String,
|
||||||
|
enabled: bool,
|
||||||
|
updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderConfigRow {
|
||||||
|
/// Create a new provider config row.
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<String>,
|
||||||
|
provider_type: impl Into<String>,
|
||||||
|
config_json: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
provider_type: provider_type.into(),
|
||||||
|
config_json: config_json.into(),
|
||||||
|
enabled: true,
|
||||||
|
updated_at: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hydrate from persistence -- no validation, accepts all fields.
|
||||||
|
pub fn from_persistence(
|
||||||
|
id: String,
|
||||||
|
provider_type: String,
|
||||||
|
config_json: String,
|
||||||
|
enabled: bool,
|
||||||
|
updated_at: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
provider_type,
|
||||||
|
config_json,
|
||||||
|
enabled,
|
||||||
|
updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Getters --
|
||||||
|
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provider_type(&self) -> &str {
|
||||||
|
&self.provider_type
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_json(&self) -> &str {
|
||||||
|
&self.config_json
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
self.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn updated_at(&self) -> &str {
|
||||||
|
&self.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_defaults_enabled() {
|
||||||
|
let row = ProviderConfigRow::new("jf-1", "jellyfin", r#"{"url":"http://localhost:8096"}"#);
|
||||||
|
assert_eq!(row.id(), "jf-1");
|
||||||
|
assert_eq!(row.provider_type(), "jellyfin");
|
||||||
|
assert!(row.enabled());
|
||||||
|
assert!(row.updated_at().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_persistence_round_trip() {
|
||||||
|
let row = ProviderConfigRow::from_persistence(
|
||||||
|
"local-1".into(),
|
||||||
|
"local_files".into(),
|
||||||
|
r#"{"path":"/media"}"#.into(),
|
||||||
|
false,
|
||||||
|
"2026-03-19T00:00:00Z".into(),
|
||||||
|
);
|
||||||
|
assert_eq!(row.id(), "local-1");
|
||||||
|
assert_eq!(row.provider_type(), "local_files");
|
||||||
|
assert!(!row.enabled());
|
||||||
|
assert_eq!(row.updated_at(), "2026-03-19T00:00:00Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_json_accessor() {
|
||||||
|
let row = ProviderConfigRow::new("test", "jellyfin", r#"{"api_key":"secret"}"#);
|
||||||
|
assert!(row.config_json().contains("api_key"));
|
||||||
|
}
|
||||||
|
}
|
||||||
316
crates/domain/src/models/schedule.rs
Normal file
316
crates/domain/src/models/schedule.rs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::value_objects::{BlockId, ChannelId, ScheduleId, SlotId};
|
||||||
|
|
||||||
|
use super::MediaItem;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GeneratedSchedule
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A fully resolved broadcast program for one channel.
|
||||||
|
///
|
||||||
|
/// Contains concrete time slots derived from the channel's `ScheduleConfig`.
|
||||||
|
/// The `generation` counter is monotonically increasing per channel and drives
|
||||||
|
/// `RecyclePolicy` cooldown calculations.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GeneratedSchedule {
|
||||||
|
id: ScheduleId,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
valid_from: DateTime<Utc>,
|
||||||
|
valid_until: DateTime<Utc>,
|
||||||
|
/// Monotonically increasing counter per channel, used by `RecyclePolicy`.
|
||||||
|
generation: u32,
|
||||||
|
/// Resolved slots, sorted ascending by `start_at`.
|
||||||
|
slots: Vec<ScheduledSlot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeneratedSchedule {
|
||||||
|
/// Create a new generated schedule.
|
||||||
|
pub fn new(
|
||||||
|
channel_id: ChannelId,
|
||||||
|
valid_from: DateTime<Utc>,
|
||||||
|
valid_until: DateTime<Utc>,
|
||||||
|
generation: u32,
|
||||||
|
slots: Vec<ScheduledSlot>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: ScheduleId::generate(),
|
||||||
|
channel_id,
|
||||||
|
valid_from,
|
||||||
|
valid_until,
|
||||||
|
generation,
|
||||||
|
slots,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hydrate from persistence -- no validation, accepts all fields.
|
||||||
|
pub fn from_persistence(
|
||||||
|
id: ScheduleId,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
valid_from: DateTime<Utc>,
|
||||||
|
valid_until: DateTime<Utc>,
|
||||||
|
generation: u32,
|
||||||
|
slots: Vec<ScheduledSlot>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
channel_id,
|
||||||
|
valid_from,
|
||||||
|
valid_until,
|
||||||
|
generation,
|
||||||
|
slots,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `time` falls within this schedule's validity window `[valid_from, valid_until)`.
|
||||||
|
pub fn is_active_at(&self, time: DateTime<Utc>) -> bool {
|
||||||
|
time >= self.valid_from && time < self.valid_until
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Getters --
|
||||||
|
|
||||||
|
pub fn id(&self) -> ScheduleId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn channel_id(&self) -> ChannelId {
|
||||||
|
self.channel_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn valid_from(&self) -> DateTime<Utc> {
|
||||||
|
self.valid_from
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn valid_until(&self) -> DateTime<Utc> {
|
||||||
|
self.valid_until
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generation(&self) -> u32 {
|
||||||
|
self.generation
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn slots(&self) -> &[ScheduledSlot] {
|
||||||
|
&self.slots
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_slots(self) -> Vec<ScheduledSlot> {
|
||||||
|
self.slots
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ScheduledSlot
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A single resolved broadcast moment within a `GeneratedSchedule`.
|
||||||
|
///
|
||||||
|
/// Contains a snapshot of the media item's metadata captured at schedule-generation
|
||||||
|
/// time. Stream URLs are fetched on-demand at tune-in, not stored here.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ScheduledSlot {
|
||||||
|
id: SlotId,
|
||||||
|
start_at: DateTime<Utc>,
|
||||||
|
end_at: DateTime<Utc>,
|
||||||
|
/// Metadata snapshot captured at schedule-generation time.
|
||||||
|
item: MediaItem,
|
||||||
|
/// Which `ProgrammingBlock` rule produced this slot.
|
||||||
|
source_block_id: BlockId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScheduledSlot {
|
||||||
|
/// Create a new scheduled slot.
|
||||||
|
pub fn new(
|
||||||
|
start_at: DateTime<Utc>,
|
||||||
|
end_at: DateTime<Utc>,
|
||||||
|
item: MediaItem,
|
||||||
|
source_block_id: BlockId,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: SlotId::generate(),
|
||||||
|
start_at,
|
||||||
|
end_at,
|
||||||
|
item,
|
||||||
|
source_block_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hydrate from persistence -- no validation, accepts all fields.
|
||||||
|
pub fn from_persistence(
|
||||||
|
id: SlotId,
|
||||||
|
start_at: DateTime<Utc>,
|
||||||
|
end_at: DateTime<Utc>,
|
||||||
|
item: MediaItem,
|
||||||
|
source_block_id: BlockId,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
start_at,
|
||||||
|
end_at,
|
||||||
|
item,
|
||||||
|
source_block_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Getters --
|
||||||
|
|
||||||
|
pub fn id(&self) -> SlotId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_at(&self) -> DateTime<Utc> {
|
||||||
|
self.start_at
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end_at(&self) -> DateTime<Utc> {
|
||||||
|
self.end_at
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn item(&self) -> &MediaItem {
|
||||||
|
&self.item
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn source_block_id(&self) -> BlockId {
|
||||||
|
self.source_block_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CurrentBroadcast
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// What is currently broadcasting on a channel -- derived from `GeneratedSchedule`
|
||||||
|
/// and the wall clock. Never stored, never serialized. `None` means no block is
|
||||||
|
/// scheduled right now (dead air / no-signal).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CurrentBroadcast {
|
||||||
|
slot: ScheduledSlot,
|
||||||
|
/// Seconds elapsed since the start of the current item.
|
||||||
|
offset_secs: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CurrentBroadcast {
|
||||||
|
/// Create a new current broadcast snapshot.
|
||||||
|
pub fn new(slot: ScheduledSlot, offset_secs: u32) -> Self {
|
||||||
|
Self { slot, offset_secs }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Getters --
|
||||||
|
|
||||||
|
pub fn slot(&self) -> &ScheduledSlot {
|
||||||
|
&self.slot
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_slot(self) -> ScheduledSlot {
|
||||||
|
self.slot
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn offset_secs(&self) -> u32 {
|
||||||
|
self.offset_secs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::value_objects::{ContentType, MediaItemId};
|
||||||
|
use chrono::Duration;
|
||||||
|
|
||||||
|
fn make_item() -> MediaItem {
|
||||||
|
MediaItem::new(
|
||||||
|
MediaItemId::new("test::1"),
|
||||||
|
"Test Movie",
|
||||||
|
ContentType::Movie,
|
||||||
|
7200,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_slot(start: DateTime<Utc>, dur_secs: i64) -> ScheduledSlot {
|
||||||
|
ScheduledSlot::new(
|
||||||
|
start,
|
||||||
|
start + Duration::seconds(dur_secs),
|
||||||
|
make_item(),
|
||||||
|
BlockId::generate(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_schedule_is_active_at() {
|
||||||
|
let now = Utc::now();
|
||||||
|
let from = now - Duration::hours(1);
|
||||||
|
let until = now + Duration::hours(1);
|
||||||
|
let sched = GeneratedSchedule::new(
|
||||||
|
ChannelId::generate(),
|
||||||
|
from,
|
||||||
|
until,
|
||||||
|
1,
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
assert!(sched.is_active_at(now));
|
||||||
|
assert!(sched.is_active_at(from));
|
||||||
|
assert!(!sched.is_active_at(until)); // exclusive upper bound
|
||||||
|
assert!(!sched.is_active_at(from - Duration::seconds(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_schedule_getters() {
|
||||||
|
let ch_id = ChannelId::generate();
|
||||||
|
let now = Utc::now();
|
||||||
|
let slot = make_slot(now, 3600);
|
||||||
|
let sched = GeneratedSchedule::new(ch_id, now, now + Duration::hours(24), 5, vec![slot]);
|
||||||
|
assert_eq!(sched.channel_id(), ch_id);
|
||||||
|
assert_eq!(sched.generation(), 5);
|
||||||
|
assert_eq!(sched.slots().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduled_slot_getters() {
|
||||||
|
let now = Utc::now();
|
||||||
|
let block_id = BlockId::generate();
|
||||||
|
let slot = ScheduledSlot::new(now, now + Duration::hours(2), make_item(), block_id);
|
||||||
|
assert_eq!(slot.start_at(), now);
|
||||||
|
assert_eq!(slot.source_block_id(), block_id);
|
||||||
|
assert_eq!(slot.item().title(), "Test Movie");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_broadcast_getters() {
|
||||||
|
let now = Utc::now();
|
||||||
|
let slot = make_slot(now, 3600);
|
||||||
|
let bc = CurrentBroadcast::new(slot, 120);
|
||||||
|
assert_eq!(bc.offset_secs(), 120);
|
||||||
|
assert_eq!(bc.slot().item().title(), "Test Movie");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_persistence_round_trip() {
|
||||||
|
let id = ScheduleId::generate();
|
||||||
|
let ch_id = ChannelId::generate();
|
||||||
|
let now = Utc::now();
|
||||||
|
let until = now + Duration::hours(48);
|
||||||
|
let sched = GeneratedSchedule::from_persistence(id, ch_id, now, until, 3, vec![]);
|
||||||
|
assert_eq!(sched.id(), id);
|
||||||
|
assert_eq!(sched.valid_from(), now);
|
||||||
|
assert_eq!(sched.valid_until(), until);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn into_slots_consumes() {
|
||||||
|
let now = Utc::now();
|
||||||
|
let slot = make_slot(now, 3600);
|
||||||
|
let sched = GeneratedSchedule::new(
|
||||||
|
ChannelId::generate(),
|
||||||
|
now,
|
||||||
|
now + Duration::hours(24),
|
||||||
|
1,
|
||||||
|
vec![slot],
|
||||||
|
);
|
||||||
|
let slots = sched.into_slots();
|
||||||
|
assert_eq!(slots.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user