feat: initialize k-tv-frontend with Next.js and Tailwind CSS
- Added package.json with dependencies and scripts for development, build, and linting. - Created postcss.config.mjs for Tailwind CSS integration. - Added SVG assets for UI components including file, globe, next, vercel, and window icons. - Configured TypeScript with tsconfig.json for strict type checking and module resolution.
This commit is contained in:
296
k-tv-backend/domain/src/entities.rs
Normal file
296
k-tv-backend/domain/src/entities.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
//! Domain entities
|
||||
//!
|
||||
//! This module contains pure domain types with no I/O dependencies.
|
||||
//! These represent the core business concepts of the application.
|
||||
|
||||
pub use crate::value_objects::{Email, UserId};
|
||||
use chrono::{DateTime, NaiveTime, Timelike, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::value_objects::{
|
||||
BlockId, ChannelId, ContentType, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy, SlotId,
|
||||
};
|
||||
|
||||
/// A user in the system.
|
||||
///
|
||||
/// Designed to be OIDC-ready: the `subject` field stores the OIDC subject claim
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub subject: String,
|
||||
pub email: Email,
|
||||
pub password_hash: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn new(subject: impl Into<String>, email: Email) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
subject: subject.into(),
|
||||
email,
|
||||
password_hash: None,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_id(
|
||||
id: Uuid,
|
||||
subject: impl Into<String>,
|
||||
email: Email,
|
||||
password_hash: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
subject: subject.into(),
|
||||
email,
|
||||
password_hash,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_local(email: Email, password_hash: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
subject: format!("local|{}", Uuid::new_v4()),
|
||||
email,
|
||||
password_hash: Some(password_hash.into()),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel
|
||||
// ============================================================================
|
||||
|
||||
/// 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 {
|
||||
pub id: ChannelId,
|
||||
pub owner_id: UserId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// IANA timezone string, e.g. `"America/New_York"`. All `start_time` fields
|
||||
/// inside `ScheduleConfig` are interpreted in this timezone.
|
||||
pub timezone: String,
|
||||
pub schedule_config: ScheduleConfig,
|
||||
pub recycle_policy: RecyclePolicy,
|
||||
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: Uuid::new_v4(),
|
||||
owner_id,
|
||||
name: name.into(),
|
||||
description: None,
|
||||
timezone: timezone.into(),
|
||||
schedule_config: ScheduleConfig::default(),
|
||||
recycle_policy: RecyclePolicy::default(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The user-designed programming template.
|
||||
///
|
||||
/// This is the shareable/exportable part of a channel. It contains an ordered
|
||||
/// list of `ProgrammingBlock`s but makes no assumptions about the media source.
|
||||
/// A channel does not need to cover all 24 hours — gaps are valid and render
|
||||
/// as a no-signal state on the client.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ScheduleConfig {
|
||||
pub blocks: Vec<ProgrammingBlock>,
|
||||
}
|
||||
|
||||
impl ScheduleConfig {
|
||||
/// Return the block whose time window contains `time`, if any.
|
||||
///
|
||||
/// Handles blocks that span midnight (e.g. start 23:00, duration 180 min).
|
||||
pub fn find_block_at(&self, time: NaiveTime) -> Option<&ProgrammingBlock> {
|
||||
let secs = time.num_seconds_from_midnight();
|
||||
self.blocks.iter().find(|block| {
|
||||
let start = block.start_time.num_seconds_from_midnight();
|
||||
let end = start + block.duration_mins * 60;
|
||||
if end <= 86_400 {
|
||||
secs >= start && secs < end
|
||||
} else {
|
||||
// Block crosses midnight: active from `start` to `end % 86400` next day
|
||||
secs >= start || secs < (end % 86_400)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the start time of the next block that begins strictly after `time`,
|
||||
/// within the same calendar day.
|
||||
pub fn next_block_start_after(&self, time: NaiveTime) -> Option<NaiveTime> {
|
||||
let secs = time.num_seconds_from_midnight();
|
||||
self.blocks
|
||||
.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))
|
||||
}
|
||||
|
||||
/// The earliest block start time across all blocks (used for next-day rollover).
|
||||
pub fn earliest_block_start(&self) -> Option<NaiveTime> {
|
||||
self.blocks.iter().map(|b| b.start_time).min()
|
||||
}
|
||||
}
|
||||
|
||||
/// A single programming rule within a `ScheduleConfig`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProgrammingBlock {
|
||||
pub id: BlockId,
|
||||
pub name: String,
|
||||
/// Local time of day (in the channel's timezone) when this block starts.
|
||||
pub start_time: NaiveTime,
|
||||
/// Target duration in minutes. The engine fills this window as closely as
|
||||
/// possible; remaining time at the end becomes dead air (no-signal).
|
||||
pub duration_mins: u32,
|
||||
pub content: BlockContent,
|
||||
}
|
||||
|
||||
impl ProgrammingBlock {
|
||||
pub fn new_algorithmic(
|
||||
name: impl Into<String>,
|
||||
start_time: NaiveTime,
|
||||
duration_mins: u32,
|
||||
filter: MediaFilter,
|
||||
strategy: FillStrategy,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.into(),
|
||||
start_time,
|
||||
duration_mins,
|
||||
content: BlockContent::Algorithmic { filter, strategy },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_manual(
|
||||
name: impl Into<String>,
|
||||
start_time: NaiveTime,
|
||||
duration_mins: u32,
|
||||
items: Vec<MediaItemId>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.into(),
|
||||
start_time,
|
||||
duration_mins,
|
||||
content: BlockContent::Manual { items },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> },
|
||||
/// The engine selects items from the provider using the given filter and strategy.
|
||||
Algorithmic {
|
||||
filter: MediaFilter,
|
||||
strategy: FillStrategy,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Media / Schedule resolution types
|
||||
// ============================================================================
|
||||
|
||||
/// A snapshot of a media item's metadata at schedule-generation time.
|
||||
///
|
||||
/// Stream URLs are intentionally absent — they are fetched on-demand from the
|
||||
/// provider at tune-in time so they stay fresh and provider-agnostic.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaItem {
|
||||
pub id: MediaItemId,
|
||||
pub title: String,
|
||||
pub content_type: ContentType,
|
||||
pub duration_secs: u32,
|
||||
pub genres: Vec<String>,
|
||||
pub year: Option<u16>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// A fully resolved 48-hour broadcast program for one channel.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneratedSchedule {
|
||||
pub id: Uuid,
|
||||
pub channel_id: ChannelId,
|
||||
pub valid_from: DateTime<Utc>,
|
||||
pub valid_until: DateTime<Utc>,
|
||||
/// Monotonically increasing counter per channel, used by `RecyclePolicy`.
|
||||
pub generation: u32,
|
||||
/// Resolved slots, sorted ascending by `start_at`.
|
||||
pub slots: Vec<ScheduledSlot>,
|
||||
}
|
||||
|
||||
impl GeneratedSchedule {
|
||||
pub fn is_active_at(&self, time: DateTime<Utc>) -> bool {
|
||||
time >= self.valid_from && time < self.valid_until
|
||||
}
|
||||
}
|
||||
|
||||
/// A single resolved broadcast moment within a `GeneratedSchedule`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScheduledSlot {
|
||||
pub id: SlotId,
|
||||
pub start_at: DateTime<Utc>,
|
||||
pub end_at: DateTime<Utc>,
|
||||
/// Metadata snapshot captured at schedule-generation time.
|
||||
pub item: MediaItem,
|
||||
/// Which `ProgrammingBlock` rule produced this slot.
|
||||
pub source_block_id: BlockId,
|
||||
}
|
||||
|
||||
/// What is currently broadcasting on a channel — derived from `GeneratedSchedule`
|
||||
/// and the wall clock. Never stored. `None` means no block is scheduled right now
|
||||
/// (dead air / no-signal).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CurrentBroadcast {
|
||||
pub slot: ScheduledSlot,
|
||||
/// Seconds elapsed since the start of the current item.
|
||||
pub offset_secs: u32,
|
||||
}
|
||||
|
||||
/// Records that an item was aired on a channel. Persisted to drive `RecyclePolicy`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlaybackRecord {
|
||||
pub id: Uuid,
|
||||
pub channel_id: ChannelId,
|
||||
pub item_id: MediaItemId,
|
||||
pub played_at: DateTime<Utc>,
|
||||
/// The generation of the schedule that scheduled this play.
|
||||
pub generation: u32,
|
||||
}
|
||||
|
||||
impl PlaybackRecord {
|
||||
pub fn new(channel_id: ChannelId, item_id: MediaItemId, generation: u32) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
channel_id,
|
||||
item_id,
|
||||
played_at: Utc::now(),
|
||||
generation,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user