domain ports: CQRS-split traits for all bounded contexts
This commit is contained in:
@@ -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};
|
||||||
|
|||||||
28
crates/domain/src/ports/activity.rs
Normal file
28
crates/domain/src/ports/activity.rs
Normal 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>>;
|
||||||
|
}
|
||||||
19
crates/domain/src/ports/auth.rs
Normal file
19
crates/domain/src/ports/auth.rs
Normal 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>;
|
||||||
|
}
|
||||||
66
crates/domain/src/ports/channel.rs
Normal file
66
crates/domain/src/ports/channel.rs
Normal 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>>;
|
||||||
|
}
|
||||||
54
crates/domain/src/ports/events.rs
Normal file
54
crates/domain/src/ports/events.rs
Normal 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<()>;
|
||||||
|
}
|
||||||
91
crates/domain/src/ports/library.rs
Normal file
91
crates/domain/src/ports/library.rs
Normal 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;
|
||||||
|
}
|
||||||
219
crates/domain/src/ports/media.rs
Normal file
219
crates/domain/src/ports/media.rs
Normal 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>>;
|
||||||
|
}
|
||||||
38
crates/domain/src/ports/mod.rs
Normal file
38
crates/domain/src/ports/mod.rs
Normal 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};
|
||||||
29
crates/domain/src/ports/provider_config.rs
Normal file
29
crates/domain/src/ports/provider_config.rs
Normal 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>>;
|
||||||
|
}
|
||||||
76
crates/domain/src/ports/schedule.rs
Normal file
76
crates/domain/src/ports/schedule.rs
Normal 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>>;
|
||||||
|
}
|
||||||
21
crates/domain/src/ports/settings.rs
Normal file
21
crates/domain/src/ports/settings.rs
Normal 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)>>;
|
||||||
|
}
|
||||||
18
crates/domain/src/ports/transcode.rs
Normal file
18
crates/domain/src/ports/transcode.rs
Normal 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<()>;
|
||||||
|
}
|
||||||
33
crates/domain/src/ports/user.rs
Normal file
33
crates/domain/src/ports/user.rs
Normal 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>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user