//! Request and Response DTOs //! //! Data Transfer Objects for the API. //! Uses domain newtypes for validation instead of the validator crate. use chrono::{DateTime, Utc}; use domain::{Email, Password}; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Login request with validated email and password newtypes #[derive(Debug, Deserialize)] pub struct LoginRequest { /// Email is validated on deserialization pub email: Email, /// Password is validated on deserialization (min 8 chars) pub password: Password, /// When true, a refresh token is also issued for persistent sessions #[serde(default)] pub remember_me: bool, } /// Refresh token request #[derive(Debug, Deserialize)] pub struct RefreshRequest { pub refresh_token: String, } /// Register request with validated email and password newtypes #[derive(Debug, Deserialize)] pub struct RegisterRequest { /// Email is validated on deserialization pub email: Email, /// Password is validated on deserialization (min 8 chars) pub password: Password, } /// User response DTO #[derive(Debug, Serialize)] pub struct UserResponse { pub id: Uuid, pub email: String, pub created_at: DateTime, pub is_admin: bool, } /// JWT token response #[derive(Debug, Serialize)] pub struct TokenResponse { pub access_token: String, pub token_type: String, pub expires_in: u64, /// Only present when remember_me was true at login, or on token refresh #[serde(skip_serializing_if = "Option::is_none")] pub refresh_token: Option, } /// Per-provider info returned by `GET /config`. #[derive(Debug, Serialize)] pub struct ProviderInfo { pub id: String, pub capabilities: domain::ProviderCapabilities, } /// System configuration response #[derive(Debug, Serialize)] pub struct ConfigResponse { pub allow_registration: bool, /// All registered providers with their capabilities. pub providers: Vec, /// Capabilities of the primary provider — kept for backward compatibility. pub provider_capabilities: domain::ProviderCapabilities, /// Provider type strings supported by this build (feature-gated). pub available_provider_types: Vec, } // ============================================================================ // Admin DTOs // ============================================================================ /// An activity log entry returned by GET /admin/activity. #[derive(Debug, Serialize)] pub struct ActivityEventResponse { pub id: Uuid, pub timestamp: DateTime, pub event_type: String, pub detail: String, pub channel_id: Option, } impl From for ActivityEventResponse { fn from(e: domain::ActivityEvent) -> Self { Self { id: e.id, timestamp: e.timestamp, event_type: e.event_type, detail: e.detail, channel_id: e.channel_id, } } } // ============================================================================ // Channel DTOs // ============================================================================ #[derive(Debug, Deserialize)] pub struct CreateChannelRequest { pub name: String, pub description: Option, /// IANA timezone, e.g. "UTC" or "America/New_York" pub timezone: String, pub access_mode: Option, /// Plain-text password; hashed before storage. pub access_password: Option, pub webhook_url: Option, pub webhook_poll_interval_secs: Option, pub webhook_body_template: Option, pub webhook_headers: Option, } /// All fields are optional — only provided fields are updated. #[derive(Debug, Deserialize)] pub struct UpdateChannelRequest { pub name: Option, pub description: Option, pub timezone: Option, /// Replace the entire schedule config (template import/edit) pub schedule_config: Option, pub recycle_policy: Option, pub auto_schedule: Option, pub access_mode: Option, /// Empty string clears the password; non-empty re-hashes. pub access_password: Option, /// `Some(None)` = clear logo, `Some(Some(url))` = set logo, `None` = unchanged. pub logo: Option>, pub logo_position: Option, pub logo_opacity: Option, /// `Some(None)` = clear, `Some(Some(url))` = set, `None` = unchanged. pub webhook_url: Option>, pub webhook_poll_interval_secs: Option, /// `Some(None)` = clear, `Some(Some(tmpl))` = set, `None` = unchanged. pub webhook_body_template: Option>, /// `Some(None)` = clear, `Some(Some(json))` = set, `None` = unchanged. pub webhook_headers: Option>, } #[derive(Debug, Serialize)] pub struct ChannelResponse { pub id: Uuid, pub owner_id: Uuid, pub name: String, pub description: Option, pub timezone: String, pub schedule_config: domain::ScheduleConfig, pub recycle_policy: domain::RecyclePolicy, pub auto_schedule: bool, pub access_mode: domain::AccessMode, pub logo: Option, pub logo_position: domain::LogoPosition, pub logo_opacity: f32, pub webhook_url: Option, pub webhook_poll_interval_secs: u32, pub webhook_body_template: Option, pub webhook_headers: Option, pub created_at: DateTime, pub updated_at: DateTime, } impl From for ChannelResponse { fn from(c: domain::Channel) -> Self { Self { id: c.id, owner_id: c.owner_id, name: c.name, description: c.description, timezone: c.timezone, schedule_config: c.schedule_config, recycle_policy: c.recycle_policy, auto_schedule: c.auto_schedule, access_mode: c.access_mode, logo: c.logo, logo_position: c.logo_position, logo_opacity: c.logo_opacity, webhook_url: c.webhook_url, webhook_poll_interval_secs: c.webhook_poll_interval_secs, webhook_body_template: c.webhook_body_template, webhook_headers: c.webhook_headers, created_at: c.created_at, updated_at: c.updated_at, } } } // ============================================================================ // Config history DTOs // ============================================================================ #[derive(Debug, Serialize)] pub struct ConfigSnapshotResponse { pub id: Uuid, pub version_num: i64, pub label: Option, pub created_at: DateTime, } impl From for ConfigSnapshotResponse { fn from(s: domain::ChannelConfigSnapshot) -> Self { Self { id: s.id, version_num: s.version_num, label: s.label, created_at: s.created_at, } } } #[derive(Debug, Deserialize)] pub struct PatchSnapshotRequest { pub label: Option, } // ============================================================================ // EPG / playback DTOs // ============================================================================ #[derive(Debug, Serialize)] pub struct MediaItemResponse { pub id: String, pub title: String, pub content_type: domain::ContentType, pub duration_secs: u32, pub description: Option, pub genres: Vec, pub year: Option, pub tags: Vec, pub series_name: Option, pub season_number: Option, pub episode_number: Option, } impl From for MediaItemResponse { fn from(i: domain::MediaItem) -> Self { Self { id: i.id.into_inner(), title: i.title, content_type: i.content_type, duration_secs: i.duration_secs, description: i.description, genres: i.genres, year: i.year, tags: i.tags, series_name: i.series_name, season_number: i.season_number, episode_number: i.episode_number, } } } #[derive(Debug, Serialize)] pub struct ScheduledSlotResponse { pub id: Uuid, pub start_at: DateTime, pub end_at: DateTime, pub item: MediaItemResponse, pub source_block_id: Uuid, #[serde(default)] pub block_access_mode: domain::AccessMode, } impl From for ScheduledSlotResponse { fn from(s: domain::ScheduledSlot) -> Self { Self { id: s.id, start_at: s.start_at, end_at: s.end_at, item: s.item.into(), source_block_id: s.source_block_id, block_access_mode: domain::AccessMode::default(), } } } impl ScheduledSlotResponse { pub fn with_block_access(slot: domain::ScheduledSlot, channel: &domain::Channel) -> Self { let block_access_mode = channel .schedule_config .all_blocks() .find(|b| b.id == slot.source_block_id) .map(|b| b.access_mode.clone()) .unwrap_or_default(); Self { id: slot.id, start_at: slot.start_at, end_at: slot.end_at, item: slot.item.into(), source_block_id: slot.source_block_id, block_access_mode, } } } /// What is currently playing on a channel. /// A 204 No Content response is returned instead when there is no active slot (no-signal). #[derive(Debug, Serialize)] pub struct CurrentBroadcastResponse { pub slot: ScheduledSlotResponse, /// Seconds elapsed since the start of the current item — use this as the /// initial seek position for the player. pub offset_secs: u32, /// Access mode of the block currently playing. The stream is gated by this. pub block_access_mode: domain::AccessMode, } #[derive(Debug, Serialize)] pub struct ScheduleResponse { pub id: Uuid, pub channel_id: Uuid, pub valid_from: DateTime, pub valid_until: DateTime, pub generation: u32, pub slots: Vec, } // ============================================================================ // Transcode DTOs // ============================================================================ #[cfg(feature = "local-files")] #[derive(Debug, Serialize)] pub struct TranscodeSettingsResponse { pub cleanup_ttl_hours: u32, } #[cfg(feature = "local-files")] #[derive(Debug, Deserialize)] pub struct UpdateTranscodeSettingsRequest { pub cleanup_ttl_hours: u32, } #[cfg(feature = "local-files")] #[derive(Debug, Serialize)] pub struct TranscodeStatsResponse { pub cache_size_bytes: u64, pub item_count: usize, } #[derive(Debug, Serialize)] pub struct ScheduleHistoryEntry { pub id: Uuid, pub generation: u32, pub valid_from: DateTime, pub valid_until: DateTime, pub slot_count: usize, } impl From for ScheduleHistoryEntry { fn from(s: domain::GeneratedSchedule) -> Self { Self { id: s.id, generation: s.generation, valid_from: s.valid_from, valid_until: s.valid_until, slot_count: s.slots.len(), } } } impl From for ScheduleResponse { fn from(s: domain::GeneratedSchedule) -> Self { Self { id: s.id, channel_id: s.channel_id, valid_from: s.valid_from, valid_until: s.valid_until, generation: s.generation, slots: s.slots.into_iter().map(Into::into).collect(), } } }