domain ports: CQRS-split traits for all bounded contexts

This commit is contained in:
2026-07-12 01:23:38 +02:00
parent 616c60e213
commit 0166e829c1
13 changed files with 693 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
pub mod errors; pub mod errors;
pub mod models; pub mod models;
pub mod ports;
pub mod value_objects; pub mod value_objects;
pub use errors::{DomainError, DomainResult}; pub use errors::{DomainError, DomainResult};

View File

@@ -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<Uuid>,
) -> 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<Vec<ActivityEvent>>;
}

View File

@@ -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<String>;
/// Verify a plaintext password against an encoded hash.
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool>;
}

View File

@@ -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<String>,
) -> DomainResult<ChannelConfigSnapshot>;
/// Update the label on an existing config snapshot.
async fn patch_config_snapshot_label(
&self,
channel_id: ChannelId,
snapshot_id: Uuid,
label: Option<String>,
) -> DomainResult<Option<ChannelConfigSnapshot>>;
}
/// 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<Option<Channel>>;
/// Find all channels owned by a user.
async fn find_by_owner(&self, owner_id: UserId) -> DomainResult<Vec<Channel>>;
/// List all channels.
async fn find_all(&self) -> DomainResult<Vec<Channel>>;
/// Find channels with auto-schedule enabled.
async fn find_auto_schedule_enabled(&self) -> DomainResult<Vec<Channel>>;
/// List all config snapshots for a channel, newest first.
async fn list_config_snapshots(
&self,
channel_id: ChannelId,
) -> DomainResult<Vec<ChannelConfigSnapshot>>;
/// Get a specific config snapshot by channel and snapshot ID.
async fn get_config_snapshot(
&self,
channel_id: ChannelId,
snapshot_id: Uuid,
) -> DomainResult<Option<ChannelConfigSnapshot>>;
}

View File

@@ -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<()>;
}

View File

@@ -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<LibraryItem>) -> 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<i64>;
/// 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<LibraryItem>, u32)>;
/// Get a single library item by its composite ID.
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>>;
/// List all collections, optionally filtered by provider.
async fn list_collections(
&self,
provider_id: Option<&str>,
) -> DomainResult<Vec<LibraryCollection>>;
/// List all unique series names, optionally filtered by provider.
async fn list_series(&self, provider_id: Option<&str>) -> DomainResult<Vec<String>>;
/// List all genres, optionally filtered by content type and provider.
async fn list_genres(
&self,
content_type: Option<&ContentType>,
provider_id: Option<&str>,
) -> DomainResult<Vec<String>>;
/// Get the latest sync log entries (one per provider).
async fn latest_sync_status(&self) -> DomainResult<Vec<LibrarySyncLogEntry>>;
/// Check whether a sync is currently running for a provider.
async fn is_sync_running(&self, provider_id: &str) -> DomainResult<bool>;
/// 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<Vec<ShowSummary>>;
/// List season summaries for a specific series.
async fn list_seasons(
&self,
series_name: &str,
provider_id: Option<&str>,
) -> DomainResult<Vec<SeasonSummary>>;
}
/// 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;
}

View File

@@ -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 `<video>` element.
DirectFile,
}
/// Feature matrix for a media provider.
///
/// The API and frontend use this to gate calls and hide UI controls that
/// the active provider does not support.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderCapabilities {
pub collections: bool,
pub series: bool,
pub genres: bool,
pub tags: bool,
pub decade: bool,
pub search: bool,
pub streaming_protocol: StreamingProtocol,
/// Whether `POST /files/rescan` is available.
pub rescan: bool,
/// Whether on-demand FFmpeg transcoding to HLS is available.
pub transcode: bool,
}
// ============================================================================
// Library browsing types
// ============================================================================
/// A top-level media collection / library exposed by a provider.
///
/// In Jellyfin this maps to a virtual library (Movies, TV Shows, ...).
/// In Plex it maps to a section. The `id` is provider-specific and is used
/// as the value for `MediaFilter::collections`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Collection {
pub id: String,
pub name: String,
/// Provider-specific type hint, e.g. "movies", "tvshows". `None` when the
/// provider does not expose this information.
pub collection_type: Option<String>,
}
/// Lightweight summary of a TV series available in the provider's library.
/// Returned by `IMediaProvider::list_series` for the dashboard browser.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeriesSummary {
/// Provider-specific series ID (opaque -- used for ParentId filtering).
pub id: String,
pub name: String,
/// Total number of episodes across all seasons, if the provider exposes it.
pub episode_count: u32,
pub genres: Vec<String>,
pub year: Option<u16>,
}
// ============================================================================
// IMediaProvider
// ============================================================================
/// Port for reading media content from an external provider.
///
/// Implementations live in the infra layer. One adapter per provider type
/// (e.g. `JellyfinMediaProvider`, `PlexMediaProvider`, `LocalFileProvider`).
///
/// The three browsing methods (`list_collections`, `list_series`, `list_genres`)
/// have default implementations that return an `InfrastructureError`. Adapters
/// that support library browsing override them; those that don't (e.g. the
/// `NoopMediaProvider`) inherit the default and return a clear error.
#[async_trait]
pub trait IMediaProvider: Send + Sync {
/// Declare what features this provider supports.
///
/// Called at request time (not cached) so the response always reflects the
/// active provider. Implementations return a plain struct -- no I/O needed.
fn capabilities(&self) -> ProviderCapabilities;
/// Fetch metadata for all items matching `filter` from this provider.
///
/// The provider interprets each field of `MediaFilter` in terms of its own
/// API (e.g. Jellyfin libraries, Plex sections, filesystem paths).
/// Returns an empty vec -- not an error -- when nothing matches.
async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>>;
/// Fetch metadata for a single item by its opaque ID.
///
/// Used by the scheduler when resolving `BlockContent::Manual` blocks, where
/// the user has hand-picked specific items. Returns `None` if the item no
/// longer exists in the provider (deleted, unavailable, etc.).
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>>;
/// Get a playback URL for an item, called on-demand at tune-in time.
///
/// URLs are intentionally *not* stored in the schedule because they may be
/// short-lived (signed URLs, session tokens) or depend on client context.
async fn get_stream_url(
&self,
item_id: &MediaItemId,
quality: &StreamQuality,
) -> DomainResult<String>;
/// List top-level collections (libraries/sections) available in this provider.
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
Err(DomainError::InfrastructureError(
"list_collections is not supported by this provider".into(),
))
}
/// List TV series available in an optional collection.
async fn list_series(&self, collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>> {
let _ = collection_id;
Err(DomainError::InfrastructureError(
"list_series is not supported by this provider".into(),
))
}
/// List all genres available for a given content type.
async fn list_genres(
&self,
content_type: Option<&ContentType>,
) -> DomainResult<Vec<String>> {
let _ = content_type;
Err(DomainError::InfrastructureError(
"list_genres is not supported by this provider".into(),
))
}
}
// ============================================================================
// IProviderRegistry
// ============================================================================
/// Port for routing media operations across multiple named providers.
///
/// The registry holds all configured providers (Jellyfin, local files, ...)
/// and dispatches each call to the right one. Item IDs are prefixed with the
/// provider key (e.g. `"jellyfin::abc123"`, `"local::base64path"`) so every
/// fetch and stream call is self-routing.
#[async_trait]
pub trait IProviderRegistry: Send + Sync {
/// Fetch items from a named provider (used by Algorithmic blocks).
/// Empty `provider_id` uses the primary provider.
async fn fetch_items(
&self,
provider_id: &str,
filter: &MediaFilter,
) -> DomainResult<Vec<MediaItem>>;
/// Fetch a single item by its (possibly prefixed) ID.
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>>;
/// Get a playback URL. Routes via prefix in `item_id`.
async fn get_stream_url(
&self,
item_id: &MediaItemId,
quality: &StreamQuality,
) -> DomainResult<String>;
/// List all registered provider keys in registration order.
fn provider_ids(&self) -> Vec<String>;
/// Key of the primary (first-registered) provider.
fn primary_id(&self) -> &str;
/// Capability matrix for a specific provider. Returns `None` if the key is unknown.
fn capabilities(&self, provider_id: &str) -> Option<ProviderCapabilities>;
/// List collections for a provider. Empty `provider_id` = primary.
async fn list_collections(&self, provider_id: &str) -> DomainResult<Vec<Collection>>;
/// List series for a provider. Empty `provider_id` = primary.
async fn list_series(
&self,
provider_id: &str,
collection_id: Option<&str>,
) -> DomainResult<Vec<SeriesSummary>>;
/// List genres for a provider. Empty `provider_id` = primary.
async fn list_genres(
&self,
provider_id: &str,
content_type: Option<&ContentType>,
) -> DomainResult<Vec<String>>;
}

View File

@@ -0,0 +1,38 @@
//! Domain ports (trait definitions).
//!
//! These traits define the abstract interfaces that infrastructure adapters
//! implement. The domain layer depends only on these traits, never on concrete
//! implementations.
//!
//! Repository traits follow a CQRS split: separate Command (write) and Query
//! (read) traits for each aggregate. Small or rarely-split repositories keep
//! a single trait when the split adds no value.
pub mod activity;
pub mod auth;
pub mod channel;
pub mod events;
pub mod library;
pub mod media;
pub mod provider_config;
pub mod schedule;
pub mod settings;
pub mod transcode;
pub mod user;
// -- Re-exports for convenience --
pub use activity::{ActivityLogCommand, ActivityLogQuery};
pub use auth::AuthService;
pub use channel::{ChannelCommand, ChannelQuery};
pub use events::{DomainEvent, EventHandler, EventPublisher};
pub use library::{LibraryCommand, LibraryQuery, LibrarySyncAdapter};
pub use media::{
Collection, IMediaProvider, IProviderRegistry, ProviderCapabilities, SeriesSummary,
StreamQuality, StreamingProtocol,
};
pub use provider_config::{ProviderConfigCommand, ProviderConfigQuery};
pub use schedule::{ScheduleCommand, ScheduleQuery};
pub use settings::AppSettingsRepository;
pub use transcode::TranscodeSettingsRepository;
pub use user::{UserCommand, UserQuery};

View File

@@ -0,0 +1,29 @@
//! Provider configuration port (CQRS split).
//!
//! Stores the JSON configuration blob for registered media providers
//! (e.g. Jellyfin URL + API key).
use async_trait::async_trait;
use crate::errors::DomainResult;
use crate::models::ProviderConfigRow;
/// Write-side port for provider configuration persistence.
#[async_trait]
pub trait ProviderConfigCommand: Send + Sync {
/// Insert or update a provider configuration.
async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()>;
/// Delete a provider configuration by ID.
async fn delete(&self, id: &str) -> DomainResult<()>;
}
/// Read-side port for provider configuration persistence.
#[async_trait]
pub trait ProviderConfigQuery: Send + Sync {
/// Get all provider configurations.
async fn get_all(&self) -> DomainResult<Vec<ProviderConfigRow>>;
/// Get a provider configuration by ID.
async fn get_by_id(&self, id: &str) -> DomainResult<Option<ProviderConfigRow>>;
}

View File

@@ -0,0 +1,76 @@
//! Schedule persistence ports (CQRS split).
use std::collections::HashMap;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::errors::DomainResult;
use crate::models::{GeneratedSchedule, PlaybackRecord};
use crate::value_objects::{BlockId, ChannelId, MediaItemId};
/// Write-side port for schedule and playback persistence.
#[async_trait]
pub trait ScheduleCommand: Send + Sync {
/// Insert or replace a generated schedule.
async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()>;
/// Persist a playback record (item was aired on a channel).
async fn save_playback_record(&self, record: &PlaybackRecord) -> DomainResult<()>;
/// Delete all schedules with generation > `target_generation` for this channel.
///
/// Also deletes matching playback_records (no DB cascade between those tables).
/// `scheduled_slots` cascade via FK from `generated_schedules`.
async fn delete_schedules_after(
&self,
channel_id: ChannelId,
target_generation: u32,
) -> DomainResult<()>;
}
/// Read-side port for schedule and playback persistence.
#[async_trait]
pub trait ScheduleQuery: Send + Sync {
/// Find the schedule whose `[valid_from, valid_until)` window contains `at`.
async fn find_active(
&self,
channel_id: ChannelId,
at: DateTime<Utc>,
) -> DomainResult<Option<GeneratedSchedule>>;
/// Find the most recently generated schedule for a channel.
/// Used to derive the next generation number.
async fn find_latest(
&self,
channel_id: ChannelId,
) -> DomainResult<Option<GeneratedSchedule>>;
/// All playback records for a channel, used by the recycle policy engine.
async fn find_playback_history(
&self,
channel_id: ChannelId,
) -> DomainResult<Vec<PlaybackRecord>>;
/// Return the most recent slot per block_id across ALL schedules for a channel.
///
/// Resilient to any single generation having empty slots for a block.
async fn find_last_slot_per_block(
&self,
channel_id: ChannelId,
) -> DomainResult<HashMap<BlockId, MediaItemId>>;
/// List all generated schedule headers for a channel, newest first.
async fn list_schedule_history(
&self,
channel_id: ChannelId,
) -> DomainResult<Vec<GeneratedSchedule>>;
/// Fetch a specific schedule with its slots, verifying channel ownership.
async fn get_schedule_by_id(
&self,
channel_id: ChannelId,
schedule_id: Uuid,
) -> DomainResult<Option<GeneratedSchedule>>;
}

View File

@@ -0,0 +1,21 @@
//! Application settings port.
//!
//! Key-value admin configuration (e.g. `library_sync_interval_hours`).
//! Small enough to keep as a single trait rather than CQRS split.
use async_trait::async_trait;
use crate::errors::DomainResult;
/// Port for general admin settings persistence (`app_settings` table).
#[async_trait]
pub trait AppSettingsRepository: Send + Sync {
/// Get a setting value by key. Returns `None` if not set.
async fn get(&self, key: &str) -> DomainResult<Option<String>>;
/// Set a setting value (upsert).
async fn set(&self, key: &str, value: &str) -> DomainResult<()>;
/// Get all settings as (key, value) pairs.
async fn get_all(&self) -> DomainResult<Vec<(String, String)>>;
}

View File

@@ -0,0 +1,18 @@
//! Transcode settings port.
//!
//! Persists FFmpeg transcoding configuration (cleanup TTL, etc.).
//! Small enough to keep as a single trait.
use async_trait::async_trait;
use crate::errors::DomainResult;
/// Port for transcode settings persistence.
#[async_trait]
pub trait TranscodeSettingsRepository: Send + Sync {
/// Load the persisted cleanup TTL. Returns `None` if no row exists yet.
async fn load_cleanup_ttl(&self) -> DomainResult<Option<u32>>;
/// Persist the cleanup TTL (upsert -- always row id=1).
async fn save_cleanup_ttl(&self, hours: u32) -> DomainResult<()>;
}

View File

@@ -0,0 +1,33 @@
//! User persistence ports (CQRS split).
use async_trait::async_trait;
use crate::errors::DomainResult;
use crate::models::User;
use crate::value_objects::UserId;
/// Write-side port for user persistence.
#[async_trait]
pub trait UserCommand: Send + Sync {
/// Insert or update a user.
async fn save(&self, user: &User) -> DomainResult<()>;
/// Delete a user by their internal ID.
async fn delete(&self, id: UserId) -> DomainResult<()>;
}
/// Read-side port for user persistence.
#[async_trait]
pub trait UserQuery: Send + Sync {
/// Find a user by their internal ID.
async fn find_by_id(&self, id: UserId) -> DomainResult<Option<User>>;
/// Find a user by their OIDC subject (used for authentication).
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>>;
/// Find a user by their email address.
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>>;
/// Count total number of users (used for first-user admin promotion).
async fn count_users(&self) -> DomainResult<u64>;
}