From 0166e829c1f80b882644f84d1010a022f34b5fb3 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 01:23:38 +0200 Subject: [PATCH] domain ports: CQRS-split traits for all bounded contexts --- crates/domain/src/lib.rs | 1 + crates/domain/src/ports/activity.rs | 28 +++ crates/domain/src/ports/auth.rs | 19 ++ crates/domain/src/ports/channel.rs | 66 +++++++ crates/domain/src/ports/events.rs | 54 +++++ crates/domain/src/ports/library.rs | 91 +++++++++ crates/domain/src/ports/media.rs | 219 +++++++++++++++++++++ crates/domain/src/ports/mod.rs | 38 ++++ crates/domain/src/ports/provider_config.rs | 29 +++ crates/domain/src/ports/schedule.rs | 76 +++++++ crates/domain/src/ports/settings.rs | 21 ++ crates/domain/src/ports/transcode.rs | 18 ++ crates/domain/src/ports/user.rs | 33 ++++ 13 files changed, 693 insertions(+) create mode 100644 crates/domain/src/ports/activity.rs create mode 100644 crates/domain/src/ports/auth.rs create mode 100644 crates/domain/src/ports/channel.rs create mode 100644 crates/domain/src/ports/events.rs create mode 100644 crates/domain/src/ports/library.rs create mode 100644 crates/domain/src/ports/media.rs create mode 100644 crates/domain/src/ports/mod.rs create mode 100644 crates/domain/src/ports/provider_config.rs create mode 100644 crates/domain/src/ports/schedule.rs create mode 100644 crates/domain/src/ports/settings.rs create mode 100644 crates/domain/src/ports/transcode.rs create mode 100644 crates/domain/src/ports/user.rs diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 8e8fd67..46fdb40 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -1,5 +1,6 @@ pub mod errors; pub mod models; +pub mod ports; pub mod value_objects; pub use errors::{DomainError, DomainResult}; diff --git a/crates/domain/src/ports/activity.rs b/crates/domain/src/ports/activity.rs new file mode 100644 index 0000000..6b328f9 --- /dev/null +++ b/crates/domain/src/ports/activity.rs @@ -0,0 +1,28 @@ +//! Activity log port. +//! +//! Records user and system actions for the admin dashboard's activity feed. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::errors::DomainResult; +use crate::models::ActivityEvent; + +/// Port for activity log persistence. +#[async_trait] +pub trait ActivityLogCommand: Send + Sync { + /// Log a new activity event. + async fn log( + &self, + event_type: &str, + detail: &str, + channel_id: Option, + ) -> DomainResult<()>; +} + +/// Port for reading activity log entries. +#[async_trait] +pub trait ActivityLogQuery: Send + Sync { + /// Retrieve the most recent activity events. + async fn recent(&self, limit: u32) -> DomainResult>; +} diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs new file mode 100644 index 0000000..87fedc3 --- /dev/null +++ b/crates/domain/src/ports/auth.rs @@ -0,0 +1,19 @@ +//! Authentication port. +//! +//! Abstracts password hashing and verification so the domain layer +//! never depends on a specific hashing algorithm (bcrypt, argon2, etc.). + +use crate::errors::DomainResult; + +/// Port for password hashing and verification. +/// +/// Implementations live in the infra layer (e.g. `BcryptAuthService`). +/// These methods are intentionally synchronous — hashing libraries are CPU-bound +/// and should be spawned on a blocking thread pool by the caller if needed. +pub trait AuthService: Send + Sync { + /// Hash a plaintext password and return the encoded hash string. + fn hash_password(&self, password: &str) -> DomainResult; + + /// Verify a plaintext password against an encoded hash. + fn verify_password(&self, password: &str, hash: &str) -> DomainResult; +} diff --git a/crates/domain/src/ports/channel.rs b/crates/domain/src/ports/channel.rs new file mode 100644 index 0000000..72a11cf --- /dev/null +++ b/crates/domain/src/ports/channel.rs @@ -0,0 +1,66 @@ +//! Channel persistence ports (CQRS split). + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::errors::DomainResult; +use crate::models::{Channel, ChannelConfigSnapshot, ScheduleConfig}; +use crate::value_objects::{ChannelId, UserId}; + +/// Write-side port for channel persistence. +#[async_trait] +pub trait ChannelCommand: Send + Sync { + /// Insert or update a channel. + async fn save(&self, channel: &Channel) -> DomainResult<()>; + + /// Delete a channel by ID. + async fn delete(&self, id: ChannelId) -> DomainResult<()>; + + /// Snapshot the current config before saving a new one. + /// + /// `version_num` is computed by the infra layer as `MAX(version_num)+1` + /// inside a transaction. + async fn save_config_snapshot( + &self, + channel_id: ChannelId, + config: &ScheduleConfig, + label: Option, + ) -> DomainResult; + + /// Update the label on an existing config snapshot. + async fn patch_config_snapshot_label( + &self, + channel_id: ChannelId, + snapshot_id: Uuid, + label: Option, + ) -> DomainResult>; +} + +/// Read-side port for channel persistence. +#[async_trait] +pub trait ChannelQuery: Send + Sync { + /// Find a channel by its ID. + async fn find_by_id(&self, id: ChannelId) -> DomainResult>; + + /// Find all channels owned by a user. + async fn find_by_owner(&self, owner_id: UserId) -> DomainResult>; + + /// List all channels. + async fn find_all(&self) -> DomainResult>; + + /// Find channels with auto-schedule enabled. + async fn find_auto_schedule_enabled(&self) -> DomainResult>; + + /// List all config snapshots for a channel, newest first. + async fn list_config_snapshots( + &self, + channel_id: ChannelId, + ) -> DomainResult>; + + /// Get a specific config snapshot by channel and snapshot ID. + async fn get_config_snapshot( + &self, + channel_id: ChannelId, + snapshot_id: Uuid, + ) -> DomainResult>; +} diff --git a/crates/domain/src/ports/events.rs b/crates/domain/src/ports/events.rs new file mode 100644 index 0000000..bfed7fc --- /dev/null +++ b/crates/domain/src/ports/events.rs @@ -0,0 +1,54 @@ +//! Domain event ports. +//! +//! Minimal event infrastructure for publishing domain events. +//! The consumer/handler side is intentionally simple — no subscription +//! mechanism yet; handlers are registered at startup and dispatched +//! synchronously by the publisher. + +use async_trait::async_trait; + +use crate::errors::DomainResult; +use crate::value_objects::ChannelId; + +/// A domain event emitted by aggregate operations. +/// +/// New variants will be added as the system grows. Downstream consumers +/// pattern-match and ignore unknown variants. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum DomainEvent { + /// A new schedule was generated for a channel. + ScheduleGenerated { + channel_id: ChannelId, + generation: u32, + }, + /// A library sync completed for a provider. + LibrarySyncCompleted { + provider_id: String, + items_found: u32, + }, + /// A channel was created. + ChannelCreated { channel_id: ChannelId }, + /// A channel was deleted. + ChannelDeleted { channel_id: ChannelId }, +} + +/// Port for publishing domain events. +/// +/// Implementations may log, fan out to handlers, or push to a message bus. +#[async_trait] +pub trait EventPublisher: Send + Sync { + /// Publish a single domain event. + async fn publish(&self, event: DomainEvent) -> DomainResult<()>; +} + +/// Port for handling domain events. +/// +/// Each handler is responsible for one side-effect (e.g. logging, webhook +/// dispatch, cache invalidation). Handlers are registered at startup. +#[async_trait] +pub trait EventHandler: Send + Sync { + /// Handle a domain event. Errors are logged but do not abort the + /// originating operation. + async fn handle(&self, event: &DomainEvent) -> DomainResult<()>; +} diff --git a/crates/domain/src/ports/library.rs b/crates/domain/src/ports/library.rs new file mode 100644 index 0000000..676ff3d --- /dev/null +++ b/crates/domain/src/ports/library.rs @@ -0,0 +1,91 @@ +//! Library persistence ports (CQRS split) and sync adapter. + +use async_trait::async_trait; + +use crate::errors::DomainResult; +use crate::models::{ + LibraryCollection, LibraryItem, LibrarySyncLogEntry, LibrarySyncResult, + SeasonSummary, ShowSummary, +}; +use crate::value_objects::{ContentType, LibrarySearchFilter}; + +use super::media::IMediaProvider; + +/// Write-side port for library persistence. +#[async_trait] +pub trait LibraryCommand: Send + Sync { + /// Upsert a batch of library items for a given provider. + async fn upsert_items(&self, provider_id: &str, items: Vec) -> DomainResult<()>; + + /// Remove all items belonging to a provider (used before full re-sync). + async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>; + + /// Create a sync log entry marking the start of a sync run. + /// Returns the log entry ID for later completion. + async fn log_sync_start(&self, provider_id: &str) -> DomainResult; + + /// Mark a sync log entry as finished with the given result. + async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()>; +} + +/// Read-side port for library persistence. +#[async_trait] +pub trait LibraryQuery: Send + Sync { + /// Search the library with the given filter. Returns (items, total_count). + async fn search( + &self, + filter: &LibrarySearchFilter, + ) -> DomainResult<(Vec, u32)>; + + /// Get a single library item by its composite ID. + async fn get_by_id(&self, id: &str) -> DomainResult>; + + /// List all collections, optionally filtered by provider. + async fn list_collections( + &self, + provider_id: Option<&str>, + ) -> DomainResult>; + + /// List all unique series names, optionally filtered by provider. + async fn list_series(&self, provider_id: Option<&str>) -> DomainResult>; + + /// List all genres, optionally filtered by content type and provider. + async fn list_genres( + &self, + content_type: Option<&ContentType>, + provider_id: Option<&str>, + ) -> DomainResult>; + + /// Get the latest sync log entries (one per provider). + async fn latest_sync_status(&self) -> DomainResult>; + + /// Check whether a sync is currently running for a provider. + async fn is_sync_running(&self, provider_id: &str) -> DomainResult; + + /// List TV show summaries, optionally filtered by provider, search term, and genres. + async fn list_shows( + &self, + provider_id: Option<&str>, + search_term: Option<&str>, + genres: &[String], + ) -> DomainResult>; + + /// List season summaries for a specific series. + async fn list_seasons( + &self, + series_name: &str, + provider_id: Option<&str>, + ) -> DomainResult>; +} + +/// Port: sync one provider's items into the library. +/// +/// DB writes are handled entirely inside implementations — no pool in the trait. +#[async_trait] +pub trait LibrarySyncAdapter: Send + Sync { + async fn sync_provider( + &self, + provider: &dyn IMediaProvider, + provider_id: &str, + ) -> LibrarySyncResult; +} diff --git a/crates/domain/src/ports/media.rs b/crates/domain/src/ports/media.rs new file mode 100644 index 0000000..3f50efb --- /dev/null +++ b/crates/domain/src/ports/media.rs @@ -0,0 +1,219 @@ +//! Media provider ports and associated types. +//! +//! Abstract interfaces for fetching media from any source. +//! The domain never knows whether the backing provider is Jellyfin, Plex, +//! a local filesystem, or anything else — adapters in the infra crate implement +//! these traits for each concrete source. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::errors::{DomainError, DomainResult}; +use crate::models::MediaItem; +use crate::value_objects::{ContentType, MediaFilter, MediaItemId}; + +// ============================================================================ +// Stream quality +// ============================================================================ + +/// Requested stream quality for `get_stream_url`. +#[derive(Debug, Clone)] +pub enum StreamQuality { + /// Try direct stream via PlaybackInfo; fall back to HLS at 8 Mbps. + Direct, + /// Force HLS transcode at this bitrate (bits per second). + Transcode(u32), +} + +// ============================================================================ +// Provider capabilities +// ============================================================================ + +/// How a provider delivers video to the client. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StreamingProtocol { + /// HLS playlist (`.m3u8`). Requires hls.js on non-Safari browsers. + Hls, + /// Direct file URL with Range-header support. Native `