From 25b33b6a0e668bfc05f15aea99f5144f74cc1eca Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 04:21:21 +0200 Subject: [PATCH] cleanup: strip comments, extract constants, DRY shared helpers across adapters + infra-wiring - strip all comments except WHY workaround notes (3 remain) - remove all #[allow(dead_code)]; fix via _prefix rename - extract named constants: JWT time units, token types, default config values, jellyfin fallback bitrate - DRY: move serialize_enum_as_string, content_type_str, parse_content_type, parse_genres_blob to adapter-common - sqlite+postgres library.rs use shared helpers instead of local copies - sqlite+postgres channel.rs use shared serialize_enum_as_string - remove dead `let _ = ext` in scanner.rs --- crates/adapters/adapter-common/src/lib.rs | 112 +++++++++++------- crates/adapters/auth/src/jwt.rs | 76 ++---------- crates/adapters/auth/src/lib.rs | 2 - crates/adapters/auth/src/oidc.rs | 44 +------ crates/adapters/auth/src/password.rs | 4 - crates/adapters/event-publisher/src/lib.rs | 5 +- crates/adapters/jellyfin/src/config.rs | 4 - crates/adapters/jellyfin/src/lib.rs | 6 - crates/adapters/jellyfin/src/mapping.rs | 7 +- crates/adapters/jellyfin/src/models.rs | 10 -- crates/adapters/jellyfin/src/provider.rs | 27 +---- crates/adapters/local-files/src/config.rs | 5 - crates/adapters/local-files/src/index.rs | 17 --- crates/adapters/local-files/src/lib.rs | 11 -- crates/adapters/local-files/src/provider.rs | 58 ++++----- crates/adapters/local-files/src/scanner.rs | 47 +++----- crates/adapters/local-files/src/transcoder.rs | 66 ++++------- crates/adapters/postgres/src/activity.rs | 3 - crates/adapters/postgres/src/channel.rs | 19 +-- crates/adapters/postgres/src/lib.rs | 3 - crates/adapters/postgres/src/library.rs | 50 +------- .../adapters/postgres/src/provider_config.rs | 2 - crates/adapters/postgres/src/schedule.rs | 17 +-- crates/adapters/postgres/src/settings.rs | 2 - crates/adapters/postgres/src/transcode.rs | 2 - crates/adapters/postgres/src/user.rs | 8 -- crates/adapters/postgres/src/wire.rs | 8 -- crates/adapters/sqlite/src/activity.rs | 3 - crates/adapters/sqlite/src/channel.rs | 19 +-- crates/adapters/sqlite/src/lib.rs | 3 - crates/adapters/sqlite/src/library.rs | 51 +------- crates/adapters/sqlite/src/provider_config.rs | 2 - crates/adapters/sqlite/src/schedule.rs | 16 +-- crates/adapters/sqlite/src/settings.rs | 2 - crates/adapters/sqlite/src/transcode.rs | 2 - crates/adapters/sqlite/src/user.rs | 8 -- crates/adapters/sqlite/src/wire.rs | 8 -- crates/infra-wiring/src/lib.rs | 89 ++++---------- 38 files changed, 188 insertions(+), 630 deletions(-) diff --git a/crates/adapters/adapter-common/src/lib.rs b/crates/adapters/adapter-common/src/lib.rs index 0a01f07..d2ec3d9 100644 --- a/crates/adapters/adapter-common/src/lib.rs +++ b/crates/adapters/adapter-common/src/lib.rs @@ -1,31 +1,13 @@ -//! Shared helpers for database adapter crates (SQLite, PostgreSQL). -//! -//! Provides error mapping, datetime parsing, UUID parsing, and JSON -//! deserialization helpers that are identical across database backends. - use chrono::{DateTime, Utc}; use domain::{DomainError, RecyclePolicy, ScheduleConfig, ScheduleConfigCompat}; use serde::de::DeserializeOwned; use uuid::Uuid; -// ============================================================================ -// Error mapping -// ============================================================================ - -/// Map a [`sqlx::Error`] into a [`DomainError::RepositoryError`]. pub fn map_sqlx_error(err: sqlx::Error) -> DomainError { tracing::error!(error = %err, "database error"); DomainError::RepositoryError(err.to_string()) } -// ============================================================================ -// Datetime parsing -// ============================================================================ - -/// Parse a datetime string stored in the database. -/// -/// Tries RFC 3339 first (e.g. `2026-03-19T00:00:00Z`), then falls back to -/// the bare SQLite format `%Y-%m-%d %H:%M:%S` (no timezone, assumed UTC). pub fn parse_dt(s: &str) -> Result, DomainError> { DateTime::parse_from_rfc3339(s) .map(|dt| dt.with_timezone(&Utc)) @@ -35,54 +17,73 @@ pub fn parse_dt(s: &str) -> Result, DomainError> { .map_err(|e| DomainError::RepositoryError(format!("Invalid datetime '{}': {}", s, e))) } -// ============================================================================ -// UUID parsing -// ============================================================================ - -/// Parse a UUID string from the database, wrapping errors in [`DomainError::RepositoryError`]. -/// -/// The `context` parameter is included in the error message for diagnostics -/// (e.g. `"channel id"`, `"slot id"`). pub fn parse_uuid(s: &str, context: &str) -> Result { Uuid::parse_str(s) .map_err(|e| DomainError::RepositoryError(format!("Invalid {} UUID '{}': {}", context, s, e))) } -// ============================================================================ -// JSON deserialization helpers -// ============================================================================ - -/// Deserialize a JSON string from the database into `T`. -/// -/// The `context` parameter is included in the error message for diagnostics -/// (e.g. `"schedule_config"`, `"slot item"`). pub fn parse_json(json: &str, context: &str) -> Result { serde_json::from_str(json) .map_err(|e| DomainError::RepositoryError(format!("Invalid {} JSON: {}", context, e))) } -/// Parse a `schedule_config` JSON column, handling V1/V2 compat migration. pub fn parse_schedule_config(json: &str) -> Result { let compat: ScheduleConfigCompat = parse_json(json, "schedule_config")?; Ok(ScheduleConfig::from(compat)) } -/// Parse a `recycle_policy` JSON column. pub fn parse_recycle_policy(json: &str) -> Result { parse_json(json, "recycle_policy") } -/// Deserialize a string-encoded enum, returning `T::default()` on failure. -/// -/// Used for columns like `access_mode` and `logo_position` that are stored as -/// bare strings (e.g. `"public"`, `"top_left"`) and deserialized via serde. pub fn parse_enum_or_default(value: String) -> T { serde_json::from_value(serde_json::Value::String(value)).unwrap_or_default() } -// ============================================================================ -// Tests -// ============================================================================ +pub fn serialize_enum_as_string(v: &T, fallback: &str) -> String { + serde_json::to_value(v) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_else(|| fallback.to_owned()) +} + +pub fn content_type_str(ct: &domain::ContentType) -> &'static str { + match ct { + domain::ContentType::Movie => "movie", + domain::ContentType::Episode => "episode", + domain::ContentType::Short => "short", + } +} + +pub fn parse_content_type(s: &str) -> domain::ContentType { + match s { + "episode" => domain::ContentType::Episode, + "short" => domain::ContentType::Short, + _ => domain::ContentType::Movie, + } +} + +pub fn parse_genres_blob(blob: &str) -> Vec { + use std::collections::HashSet; + blob.split("],[") + .flat_map(|chunk| { + let cleaned = chunk.trim_start_matches('[').trim_end_matches(']'); + cleaned + .split(',') + .filter_map(|s| { + let s = s.trim().trim_matches('"'); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } + }) + .collect::>() + }) + .collect::>() + .into_iter() + .collect() +} #[cfg(test)] mod tests { @@ -146,7 +147,6 @@ mod tests { fn parse_schedule_config_v1_compat() { let json = r#"{"blocks":[]}"#; let cfg = parse_schedule_config(json).unwrap(); - // V1 promotes blocks to all 7 days assert_eq!(cfg.day_blocks().len(), 7); } @@ -168,7 +168,6 @@ mod tests { fn parse_enum_or_default_fallback() { use domain::AccessMode; let mode: AccessMode = parse_enum_or_default("garbage".to_string()); - // Should return default (Public) assert!(matches!(mode, AccessMode::Public)); } @@ -178,4 +177,27 @@ mod tests { let domain_err = map_sqlx_error(sqlx_err); assert!(matches!(domain_err, DomainError::RepositoryError(_))); } + + #[test] + fn serialize_enum_as_string_valid() { + use domain::AccessMode; + let result = serialize_enum_as_string(&AccessMode::Public, "fallback"); + assert_eq!(result, "public"); + } + + #[test] + fn content_type_roundtrip() { + use domain::ContentType; + assert_eq!(parse_content_type(content_type_str(&ContentType::Movie)), ContentType::Movie); + assert_eq!(parse_content_type(content_type_str(&ContentType::Episode)), ContentType::Episode); + assert_eq!(parse_content_type(content_type_str(&ContentType::Short)), ContentType::Short); + } + + #[test] + fn parse_genres_blob_basic() { + let genres = parse_genres_blob(r#"["Action","Comedy"],["Drama","Action"]"#); + assert!(genres.contains(&"Action".to_string())); + assert!(genres.contains(&"Comedy".to_string())); + assert!(genres.contains(&"Drama".to_string())); + } } diff --git a/crates/adapters/auth/src/jwt.rs b/crates/adapters/auth/src/jwt.rs index d600d3a..a4e9516 100644 --- a/crates/adapters/auth/src/jwt.rs +++ b/crates/adapters/auth/src/jwt.rs @@ -1,40 +1,24 @@ -//! JWT token generation and validation (HS256). -//! -//! This does NOT implement a domain port — it is used directly by the -//! presentation layer's auth extractors. - use domain::User; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; -/// Minimum secret length for production (256 bits = 32 bytes). const MIN_SECRET_LENGTH: usize = 32; +const SECS_PER_HOUR: usize = 3600; +const SECS_PER_DAY: usize = 86400; +const TOKEN_TYPE_ACCESS: &str = "access"; +const TOKEN_TYPE_REFRESH: &str = "refresh"; -// --------------------------------------------------------------------------- -// Config -// --------------------------------------------------------------------------- - -/// JWT configuration. #[derive(Debug, Clone)] pub struct JwtConfig { - /// Secret key for HS256 signing/verification. pub secret: String, - /// Expected issuer (for validation). pub issuer: Option, - /// Expected audience (for validation). pub audience: Option, - /// Access token expiry in hours (default: 24). pub expiry_hours: u64, - /// Refresh token expiry in days (default: 30). pub refresh_expiry_days: u64, } impl JwtConfig { - /// Create a new JWT config with validation. - /// - /// In production mode, this rejects secrets shorter than - /// [`MIN_SECRET_LENGTH`] bytes. pub fn new( secret: String, issuer: Option, @@ -59,7 +43,6 @@ impl JwtConfig { }) } - /// Create config without validation (for testing). pub fn new_unchecked(secret: String) -> Self { Self { secret, @@ -71,42 +54,24 @@ impl JwtConfig { } } -// --------------------------------------------------------------------------- -// Claims -// --------------------------------------------------------------------------- - fn default_token_type() -> String { - "access".to_string() + TOKEN_TYPE_ACCESS.to_string() } -/// JWT claims structure. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct JwtClaims { - /// Subject — the user's unique identifier (user ID as string). pub sub: String, - /// User's email address. pub email: String, - /// Expiry timestamp (seconds since UNIX epoch). pub exp: usize, - /// Issued-at timestamp (seconds since UNIX epoch). pub iat: usize, - /// Issuer. #[serde(skip_serializing_if = "Option::is_none")] pub iss: Option, - /// Audience. #[serde(skip_serializing_if = "Option::is_none")] pub aud: Option, - /// Token type: `"access"` or `"refresh"`. Defaults to `"access"` for - /// backward compatibility. #[serde(default = "default_token_type")] pub token_type: String, } -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- - -/// JWT-related errors. #[derive(Debug, thiserror::Error)] pub enum JwtError { #[error("JWT secret too weak: minimum {min_length} bytes, got {actual_length}")] @@ -131,11 +96,6 @@ pub enum JwtError { MissingConfig, } -// --------------------------------------------------------------------------- -// Validator / generator -// --------------------------------------------------------------------------- - -/// JWT token validator and generator. #[derive(Clone)] pub struct JwtValidator { config: JwtConfig, @@ -145,7 +105,6 @@ pub struct JwtValidator { } impl JwtValidator { - /// Create a new JWT validator with the given configuration. pub fn new(config: JwtConfig) -> Self { let encoding_key = EncodingKey::from_secret(config.secret.as_bytes()); let decoding_key = DecodingKey::from_secret(config.secret.as_bytes()); @@ -167,10 +126,9 @@ impl JwtValidator { } } - /// Create an access JWT token for the given user. pub fn create_token(&self, user: &User) -> Result { let now = now_secs(); - let expiry = now + (self.config.expiry_hours as usize * 3600); + let expiry = now + (self.config.expiry_hours as usize * SECS_PER_HOUR); let claims = JwtClaims { sub: user.id().to_string(), @@ -179,17 +137,16 @@ impl JwtValidator { iat: now, iss: self.config.issuer.clone(), aud: self.config.audience.clone(), - token_type: "access".to_string(), + token_type: TOKEN_TYPE_ACCESS.to_string(), }; encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key) .map_err(JwtError::CreationFailed) } - /// Create a refresh JWT token for the given user (longer-lived). pub fn create_refresh_token(&self, user: &User) -> Result { let now = now_secs(); - let expiry = now + (self.config.refresh_expiry_days as usize * 86400); + let expiry = now + (self.config.refresh_expiry_days as usize * SECS_PER_DAY); let claims = JwtClaims { sub: user.id().to_string(), @@ -198,14 +155,13 @@ impl JwtValidator { iat: now, iss: self.config.issuer.clone(), aud: self.config.audience.clone(), - token_type: "refresh".to_string(), + token_type: TOKEN_TYPE_REFRESH.to_string(), }; encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key) .map_err(JwtError::CreationFailed) } - /// Validate a JWT token and return the claims. pub fn validate_token(&self, token: &str) -> Result { let token_data = decode::(token, &self.decoding_key, &self.validation).map_err(|e| { @@ -219,10 +175,9 @@ impl JwtValidator { Ok(token_data.claims) } - /// Validate an access token — rejects refresh tokens. pub fn validate_access_token(&self, token: &str) -> Result { let claims = self.validate_token(token)?; - if claims.token_type != "access" { + if claims.token_type != TOKEN_TYPE_ACCESS { return Err(JwtError::ValidationFailed( "Not an access token".to_string(), )); @@ -230,10 +185,9 @@ impl JwtValidator { Ok(claims) } - /// Validate a refresh token — rejects access tokens. pub fn validate_refresh_token(&self, token: &str) -> Result { let claims = self.validate_token(token)?; - if claims.token_type != "refresh" { + if claims.token_type != TOKEN_TYPE_REFRESH { return Err(JwtError::ValidationFailed( "Not a refresh token".to_string(), )); @@ -241,9 +195,6 @@ impl JwtValidator { Ok(claims) } - /// Get the user ID (subject) from a token without full validation. - /// - /// Useful for logging/debugging — should not be trusted for auth decisions. pub fn decode_unverified(&self, token: &str) -> Result { let mut insecure = Validation::new(Algorithm::HS256); insecure.insecure_disable_signature_validation(); @@ -273,10 +224,6 @@ fn now_secs() -> usize { .as_secs() as usize } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; @@ -311,7 +258,6 @@ mod tests { let claims = validator.validate_refresh_token(&token).unwrap(); assert_eq!(claims.token_type, "refresh"); - // Access-only validation rejects it assert!(validator.validate_access_token(&token).is_err()); } diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs index a83e0ca..5404ec4 100644 --- a/crates/adapters/auth/src/lib.rs +++ b/crates/adapters/auth/src/lib.rs @@ -1,5 +1,3 @@ -//! Auth adapter crate — JWT, OIDC, and password hashing. - pub mod password; #[cfg(feature = "jwt")] diff --git a/crates/adapters/auth/src/oidc.rs b/crates/adapters/auth/src/oidc.rs index 1d2d9b5..3d12133 100644 --- a/crates/adapters/auth/src/oidc.rs +++ b/crates/adapters/auth/src/oidc.rs @@ -1,5 +1,3 @@ -//! OIDC (OpenID Connect) authorization flow adapter. - use domain::{ AuthorizationCode, AuthorizationUrlData, ClientId, ClientSecret, CsrfToken, IssuerUrl, OidcNonce, PkceVerifier, RedirectUrl, ResourceId, @@ -18,10 +16,6 @@ use openidconnect::{ }; use serde::{Deserialize, Serialize}; -// --------------------------------------------------------------------------- -// Type aliases -// --------------------------------------------------------------------------- - pub type OidcClient = Client< EmptyAdditionalClaims, CoreAuthDisplay, @@ -34,19 +28,14 @@ pub type OidcClient = Client< CoreTokenIntrospectionResponse, CoreRevocableToken, CoreRevocationErrorResponse, - EndpointSet, // HasAuthUrl - EndpointNotSet, // HasDeviceAuthUrl - EndpointNotSet, // HasIntrospectionUrl - EndpointNotSet, // HasRevocationUrl - EndpointMaybeSet, // HasTokenUrl - EndpointMaybeSet, // HasUserInfoUrl + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, >; -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- - -/// OIDC-specific errors. #[derive(Debug, thiserror::Error)] pub enum OidcError { #[error("OIDC discovery failed: {0}")] @@ -71,11 +60,6 @@ pub enum OidcError { Http(String), } -// --------------------------------------------------------------------------- -// State / types -// --------------------------------------------------------------------------- - -/// Serializable OIDC state stored in an encrypted cookie during the auth flow. #[derive(Debug, Serialize, Deserialize)] pub struct OidcState { pub csrf_token: CsrfToken, @@ -83,18 +67,12 @@ pub struct OidcState { pub pkce_verifier: PkceVerifier, } -/// Resolved OIDC user info. #[derive(Debug)] pub struct OidcUser { pub subject: String, pub email: String, } -// --------------------------------------------------------------------------- -// Service -// --------------------------------------------------------------------------- - -/// OIDC authorization flow service. #[derive(Clone)] pub struct OidcService { client: OidcClient, @@ -103,7 +81,6 @@ pub struct OidcService { } impl OidcService { - /// Create a new OIDC service — performs provider discovery. pub async fn new( issuer: IssuerUrl, client_id: ClientId, @@ -157,11 +134,6 @@ impl OidcService { }) } - /// Build the authorization URL and associated state for OIDC login. - /// - /// Returns `(AuthorizationUrlData, OidcState)` — the state should be - /// serialized and stored in an encrypted cookie for the duration of the - /// flow. pub fn get_authorization_url(&self) -> (AuthorizationUrlData, OidcState) { let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); @@ -193,8 +165,6 @@ impl OidcService { (auth_data, oidc_state) } - /// Resolve the OIDC callback — exchange code for tokens, verify ID token, - /// and return the authenticated user. pub async fn resolve_callback( &self, code: AuthorizationCode, @@ -232,7 +202,6 @@ impl OidcService { .claims(&id_token_verifier, &oidc_nonce) .map_err(|e| OidcError::IdTokenVerification(e.to_string()))?; - // Verify access token hash if present if let Some(expected_hash) = claims.access_token_hash() { let actual_hash = AccessTokenHash::from_token( token_response.access_token(), @@ -250,7 +219,6 @@ impl OidcService { } } - // Get email from ID token or fall back to UserInfo endpoint let email = if let Some(email) = claims.email() { Some(email.as_str().to_string()) } else { diff --git a/crates/adapters/auth/src/password.rs b/crates/adapters/auth/src/password.rs index 3f93dfa..7b8a6e3 100644 --- a/crates/adapters/auth/src/password.rs +++ b/crates/adapters/auth/src/password.rs @@ -1,10 +1,6 @@ -//! Password hashing adapter using the `password-auth` crate. - use domain::errors::DomainResult; use domain::ports::AuthService; -/// Concrete `AuthService` implementation backed by `password-auth` -/// (Argon2id by default). pub struct PasswordAuthService; impl AuthService for PasswordAuthService { diff --git a/crates/adapters/event-publisher/src/lib.rs b/crates/adapters/event-publisher/src/lib.rs index aefcb43..d745c11 100644 --- a/crates/adapters/event-publisher/src/lib.rs +++ b/crates/adapters/event-publisher/src/lib.rs @@ -26,7 +26,7 @@ impl ChannelEventBus { #[async_trait] impl EventPublisher for ChannelEventBus { async fn publish(&self, event: DomainEvent) -> DomainResult<()> { - let _ = self.tx.send(event); // Ok to drop if no receivers + let _ = self.tx.send(event); Ok(()) } } @@ -34,9 +34,6 @@ impl EventPublisher for ChannelEventBus { #[async_trait] impl EventConsumer for ChannelEventBus { async fn recv(&self) -> DomainResult { - // Note: This creates a new subscriber each call — for real use, - // the presentation layer should hold a receiver from subscriber() - // This impl exists to satisfy the port trait let mut rx = self.tx.subscribe(); rx.recv() .await diff --git a/crates/adapters/jellyfin/src/config.rs b/crates/adapters/jellyfin/src/config.rs index 1033c3d..1cdb35a 100644 --- a/crates/adapters/jellyfin/src/config.rs +++ b/crates/adapters/jellyfin/src/config.rs @@ -1,10 +1,6 @@ -/// Connection details for a single Jellyfin instance. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct JellyfinConfig { - /// e.g. `"http://192.168.1.10:8096"` -- no trailing slash. pub base_url: String, - /// Jellyfin API key (Settings -> API Keys). pub api_key: String, - /// The Jellyfin user ID used for library browsing. pub user_id: String, } diff --git a/crates/adapters/jellyfin/src/lib.rs b/crates/adapters/jellyfin/src/lib.rs index fa80242..207e4d0 100644 --- a/crates/adapters/jellyfin/src/lib.rs +++ b/crates/adapters/jellyfin/src/lib.rs @@ -1,9 +1,3 @@ -//! Jellyfin media provider adapter. -//! -//! Implements [`domain::ports::IMediaProvider`] by talking to the Jellyfin HTTP API. -//! The domain never sees Jellyfin-specific types -- this module translates -//! between Jellyfin's API model and the domain's abstract `MediaItem`/`MediaFilter`. - mod config; mod mapping; mod models; diff --git a/crates/adapters/jellyfin/src/mapping.rs b/crates/adapters/jellyfin/src/mapping.rs index 46cc083..6ac5fa0 100644 --- a/crates/adapters/jellyfin/src/mapping.rs +++ b/crates/adapters/jellyfin/src/mapping.rs @@ -2,11 +2,8 @@ use domain::{ContentType, MediaItem, MediaItemId}; use crate::models::JellyfinItem; -/// Ticks are Jellyfin's time unit: 1 tick = 100 nanoseconds -> 10,000,000 ticks/sec. pub(crate) const TICKS_PER_SEC: i64 = 10_000_000; -/// Map a raw Jellyfin item to a domain `MediaItem`. Returns `None` for unknown -/// item types (e.g. Season, Series, Folder) so they are silently skipped. pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option { let content_type = match item.item_type.as_str() { "Movie" => ContentType::Movie, @@ -31,7 +28,7 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option { item.series_name, item.parent_index_number, item.index_number, - None, // thumbnail_url - None, // collection_id + None, + None, )) } diff --git a/crates/adapters/jellyfin/src/models.rs b/crates/adapters/jellyfin/src/models.rs index eb64d6d..d5cc016 100644 --- a/crates/adapters/jellyfin/src/models.rs +++ b/crates/adapters/jellyfin/src/models.rs @@ -1,10 +1,6 @@ use domain::ContentType; use serde::Deserialize; -// ============================================================================ -// Jellyfin API response types -// ============================================================================ - #[derive(Debug, Deserialize)] pub(crate) struct JellyfinItemsResponse { #[serde(rename = "Items")] @@ -29,19 +25,14 @@ pub(crate) struct JellyfinItem { pub production_year: Option, #[serde(rename = "Tags")] pub tags: Option>, - /// TV show name (episodes only). #[serde(rename = "SeriesName")] pub series_name: Option, - /// Season number (episodes only). #[serde(rename = "ParentIndexNumber")] pub parent_index_number: Option, - /// Episode number within the season (episodes only). #[serde(rename = "IndexNumber")] pub index_number: Option, - /// Collection type for virtual library folders (e.g. "movies", "tvshows"). #[serde(rename = "CollectionType")] pub collection_type: Option, - /// Total number of child items (used for Series to count episodes). #[serde(rename = "RecursiveItemCount")] pub recursive_item_count: Option, } @@ -64,7 +55,6 @@ pub(crate) fn jellyfin_item_type(ct: &ContentType) -> &'static str { match ct { ContentType::Movie => "Movie", ContentType::Episode => "Episode", - // Jellyfin has no native "Short" type; short films are filed as Movies. ContentType::Short => "Movie", } } diff --git a/crates/adapters/jellyfin/src/provider.rs b/crates/adapters/jellyfin/src/provider.rs index f6a503d..68a4ec5 100644 --- a/crates/adapters/jellyfin/src/provider.rs +++ b/crates/adapters/jellyfin/src/provider.rs @@ -12,6 +12,8 @@ use crate::models::{ jellyfin_item_type, JellyfinItemsResponse, JellyfinPlaybackInfoResponse, }; +const FALLBACK_HLS_BITRATE: u32 = 8_000_000; + pub struct JellyfinMediaProvider { client: reqwest::Client, config: JellyfinConfig, @@ -28,7 +30,6 @@ impl JellyfinMediaProvider { } } - /// Inner fetch: applies all filter fields plus an optional series name override. async fn fetch_items_for_series( &self, filter: &MediaFilter, @@ -72,19 +73,13 @@ impl JellyfinMediaProvider { } if let Some(name) = series_name { - // Series-level targeting: skip ParentId so the show is found regardless - // of which library it lives in. SeriesName is already precise enough. params.push(("SeriesName", name.to_string())); - // Return episodes in chronological order when a specific series is - // requested -- season first, then episode within the season. params.push(("SortBy", "ParentIndexNumber,IndexNumber".into())); params.push(("SortOrder", "Ascending".into())); - // Prevent Jellyfin from returning Season/Series container items. if filter.content_type.is_none() { params.push(("IncludeItemTypes", "Episode".into())); } } else { - // No series filter -- scope to the collection (library) if one is set. if let Some(parent_id) = filter.collections.first() { params.push(("ParentId", parent_id.clone())); } @@ -116,9 +111,8 @@ impl JellyfinMediaProvider { DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}")) })?; - // Jellyfin's SeriesName query param is not a strict filter -- it can - // bleed items from other shows. Post-filter in Rust to guarantee that - // only the requested series is returned. + // WHY: Jellyfin's SeriesName query param is a fuzzy match that can return + // items from other shows; post-filter to guarantee correctness. let items = body.items.into_iter().filter_map(map_jellyfin_item); let items: Vec = if let Some(name) = series_name { items @@ -163,11 +157,6 @@ impl IMediaProvider for JellyfinMediaProvider { } } - /// Fetch items matching `filter` from the Jellyfin library. - /// - /// When `series_names` has more than one entry the results from each series - /// are fetched sequentially and concatenated (Jellyfin only supports one - /// `SeriesName` param per request). async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult> { match filter.series_names.len() { 0 | 1 => { @@ -175,7 +164,6 @@ impl IMediaProvider for JellyfinMediaProvider { self.fetch_items_for_series(filter, series).await } _ => { - // Fetch each series independently, then interleave round-robin. let mut per_series: Vec> = Vec::new(); for series_name in &filter.series_names { let items = self @@ -199,7 +187,6 @@ impl IMediaProvider for JellyfinMediaProvider { } } - /// Fetch a single item by its opaque ID. async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult> { let url = format!( "{}/Users/{}/Items", @@ -231,7 +218,6 @@ impl IMediaProvider for JellyfinMediaProvider { Ok(body.items.into_iter().next().and_then(map_jellyfin_item)) } - /// List top-level virtual libraries available to the configured user. async fn list_collections(&self) -> DomainResult> { let url = format!( "{}/Users/{}/Views", @@ -270,7 +256,6 @@ impl IMediaProvider for JellyfinMediaProvider { .collect()) } - /// List all Series items, optionally scoped to a collection (ParentId). async fn list_series(&self, collection_id: Option<&str>) -> DomainResult> { let url = format!( "{}/Users/{}/Items", @@ -327,7 +312,6 @@ impl IMediaProvider for JellyfinMediaProvider { .collect()) } - /// List available genres from the Jellyfin `/Genres` endpoint. async fn list_genres(&self, content_type: Option<&ContentType>) -> DomainResult> { let url = format!("{}/Genres", self.config.base_url); @@ -409,8 +393,7 @@ impl IMediaProvider for JellyfinMediaProvider { )); } } - // Fallback: HLS at 8 Mbps - Ok(self.hls_url(item_id, 8_000_000)) + Ok(self.hls_url(item_id, FALLBACK_HLS_BITRATE)) } StreamQuality::Transcode(bps) => Ok(self.hls_url(item_id, *bps)), } diff --git a/crates/adapters/local-files/src/config.rs b/crates/adapters/local-files/src/config.rs index f34e746..90c4546 100644 --- a/crates/adapters/local-files/src/config.rs +++ b/crates/adapters/local-files/src/config.rs @@ -1,13 +1,8 @@ use std::path::PathBuf; -/// Configuration for the local files media provider. pub struct LocalFilesConfig { - /// Root directory containing video files. All files are served relative to this. pub root_dir: PathBuf, - /// Public base URL of this API server, used to build stream URLs. pub base_url: String, - /// Directory for FFmpeg HLS transcode cache. `None` disables transcoding. pub transcode_dir: Option, - /// How long (hours) to keep transcode cache entries. Passed to TranscodeManager. pub cleanup_ttl_hours: u32, } diff --git a/crates/adapters/local-files/src/index.rs b/crates/adapters/local-files/src/index.rs index 0b3616e..e79fa07 100644 --- a/crates/adapters/local-files/src/index.rs +++ b/crates/adapters/local-files/src/index.rs @@ -11,7 +11,6 @@ use domain::MediaItemId; use crate::config::LocalFilesConfig; use crate::scanner::{scan_dir, LocalFileItem}; -/// Encode a rel-path string into a URL-safe, padding-free base64 MediaItemId. pub fn encode_id(rel_path: &str) -> MediaItemId { use base64::Engine as _; MediaItemId::new( @@ -19,7 +18,6 @@ pub fn encode_id(rel_path: &str) -> MediaItemId { ) } -/// Decode a MediaItemId back to a relative path string. pub fn decode_id(id: &MediaItemId) -> Option { use base64::Engine as _; let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD @@ -28,11 +26,6 @@ pub fn decode_id(id: &MediaItemId) -> Option { String::from_utf8(bytes).ok() } -/// In-memory (+ SQLite-backed) index of local video files. -/// -/// On startup the index is populated from the SQLite cache so the provider can -/// serve requests immediately. A background task calls `rescan()` to pick up -/// any changes on disk and write them back to the cache. pub struct LocalIndex { items: Arc>>, pub root_dir: PathBuf, @@ -41,7 +34,6 @@ pub struct LocalIndex { } impl LocalIndex { - /// Create the index, immediately loading persisted entries from SQLite. pub async fn new( config: &LocalFilesConfig, pool: sqlx::SqlitePool, @@ -57,7 +49,6 @@ impl LocalIndex { idx } - /// Load previously scanned items from SQLite (instant on startup). async fn load_from_db(&self) { #[derive(sqlx::FromRow)] struct Row { @@ -107,10 +98,6 @@ impl LocalIndex { } } - /// Scan the filesystem for video files and rebuild the index. - /// - /// Returns the number of items found. Called on startup (background task) - /// and via `POST /files/rescan`. pub async fn rescan(&self) -> u32 { info!( "Local files [{}]: scanning {:?}", @@ -119,7 +106,6 @@ impl LocalIndex { let new_items = scan_dir(&self.root_dir).await; let count = new_items.len() as u32; - // Swap in-memory map. { let mut map = self.items.write().await; map.clear(); @@ -129,7 +115,6 @@ impl LocalIndex { } } - // Persist to SQLite. if let Err(e) = self.save_to_db(&new_items).await { error!("Failed to persist local files index: {}", e); } @@ -142,7 +127,6 @@ impl LocalIndex { } async fn save_to_db(&self, items: &[LocalFileItem]) -> Result<(), sqlx::Error> { - // Rebuild the table in one transaction, scoped to this provider. let mut tx = self.pool.begin().await?; sqlx::query("DELETE FROM local_files_index WHERE provider_id = ?") @@ -189,7 +173,6 @@ impl LocalIndex { .collect() } - /// Return unique top-level directories as collection names. pub async fn collections(&self) -> Vec { let map = self.items.read().await; let mut seen = std::collections::HashSet::new(); diff --git a/crates/adapters/local-files/src/lib.rs b/crates/adapters/local-files/src/lib.rs index bf9d426..dc0f7e8 100644 --- a/crates/adapters/local-files/src/lib.rs +++ b/crates/adapters/local-files/src/lib.rs @@ -1,9 +1,3 @@ -//! Local-files media provider adapter. -//! -//! Implements [`domain::ports::IMediaProvider`] by scanning a local filesystem -//! directory for video files. Optional FFmpeg HLS transcoding via -//! [`TranscodeManager`]. - pub mod config; pub mod index; pub mod provider; @@ -17,7 +11,6 @@ pub use transcoder::TranscodeManager; use std::sync::Arc; -/// Bundle of all local-files components, constructed once at startup. pub struct LocalFilesBundle { pub provider: LocalFilesProvider, pub local_index: Arc, @@ -25,10 +18,6 @@ pub struct LocalFilesBundle { } impl LocalFilesBundle { - /// Build the bundle from config and a SQLite pool. - /// - /// If `config.transcode_dir` is `Some`, a `TranscodeManager` is created - /// with its background cleanup task. pub async fn build( config: LocalFilesConfig, pool: sqlx::SqlitePool, diff --git a/crates/adapters/local-files/src/provider.rs b/crates/adapters/local-files/src/provider.rs index 1734b56..72bb15e 100644 --- a/crates/adapters/local-files/src/provider.rs +++ b/crates/adapters/local-files/src/provider.rs @@ -17,7 +17,8 @@ pub struct LocalFilesProvider { transcode_manager: Option>, } -const SHORT_DURATION_SECS: u32 = 1200; // 20 minutes +const SHORT_DURATION_SECS: u32 = 1200; +const DECADE_SPAN: u16 = 9; impl LocalFilesProvider { pub fn new( @@ -44,15 +45,15 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem { item.title.clone(), content_type, item.duration_secs, - None, // description - vec![], // genres + None, + vec![], item.year, item.tags.clone(), - None, // series_name - None, // season_number - None, // episode_number - None, // thumbnail_url - None, // collection_id + None, + None, + None, + None, + None, ) } @@ -82,26 +83,23 @@ impl IMediaProvider for LocalFilesProvider { let results = all .into_iter() .filter_map(|(id, item)| { - // content_type: derive heuristically, then filter let content_type = if item.duration_secs < SHORT_DURATION_SECS { ContentType::Short } else { ContentType::Movie }; - if let Some(ref ct) = filter.content_type { - if &content_type != ct { - return None; - } + if let Some(ref ct) = filter.content_type + && &content_type != ct + { + return None; } - // collections: match against top_dir if !filter.collections.is_empty() && !filter.collections.contains(&item.top_dir) { return None; } - // tags: OR -- item must have at least one matching tag if !filter.tags.is_empty() { let has = filter .tags @@ -112,31 +110,28 @@ impl IMediaProvider for LocalFilesProvider { } } - // decade: year in [decade, decade+9] if let Some(decade) = filter.decade { match item.year { - Some(y) if y >= decade && y <= decade + 9 => {} + Some(y) if y >= decade && y <= decade + DECADE_SPAN => {} _ => return None, } } - // duration bounds - if let Some(min) = filter.min_duration_secs { - if item.duration_secs < min { - return None; - } + if let Some(min) = filter.min_duration_secs + && item.duration_secs < min + { + return None; } - if let Some(max) = filter.max_duration_secs { - if item.duration_secs > max { - return None; - } + if let Some(max) = filter.max_duration_secs + && item.duration_secs > max + { + return None; } - // search_term: case-insensitive substring in title - if let Some(ref q) = filter.search_term { - if !item.title.to_lowercase().contains(&q.to_lowercase()) { - return None; - } + if let Some(ref q) = filter.search_term + && !item.title.to_lowercase().contains(&q.to_lowercase()) + { + return None; } Some(to_media_item(id, &item)) @@ -194,7 +189,6 @@ impl IMediaProvider for LocalFilesProvider { } } -/// Decode an encoded ID from a URL path segment to its relative path string. pub fn decode_stream_id(encoded: &str) -> Option { decode_id(&MediaItemId::new(encoded)) } diff --git a/crates/adapters/local-files/src/scanner.rs b/crates/adapters/local-files/src/scanner.rs index b8fb73f..105a09f 100644 --- a/crates/adapters/local-files/src/scanner.rs +++ b/crates/adapters/local-files/src/scanner.rs @@ -2,25 +2,21 @@ use std::path::Path; use tokio::process::Command; const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mkv", "avi", "mov", "webm", "m4v"]; +const ROOT_COLLECTION_NAME: &str = "__root__"; +const YEAR_DIGITS: usize = 4; +const MIN_YEAR: u16 = 1900; +const MAX_YEAR: u16 = 2099; -/// In-memory representation of a scanned local video file. #[derive(Debug, Clone)] pub struct LocalFileItem { - /// Relative path from root, with forward slashes (used as the stable ID source). pub rel_path: String, pub title: String, pub duration_secs: u32, pub year: Option, - /// Ancestor directory names between root and file (excluding root itself). pub tags: Vec, - /// First path component under root (used as collection id/name). pub top_dir: String, } -/// Walk `root` and return all recognised video files with metadata. -/// -/// ffprobe is called for each file to determine duration. Files that cannot be -/// probed are included with `duration_secs = 0` so they still appear in the index. pub async fn scan_dir(root: &Path) -> Vec { let mut items = Vec::new(); @@ -34,33 +30,29 @@ pub async fn scan_dir(root: &Path) -> Vec { .extension() .and_then(|e| e.to_str()) .map(|e| e.to_lowercase()); - let ext = match ext { - Some(ref e) if VIDEO_EXTENSIONS.contains(&e.as_str()) => e.clone(), + match ext { + Some(ref e) if VIDEO_EXTENSIONS.contains(&e.as_str()) => {} _ => continue, }; - let _ = ext; // extension validated, not needed further let rel = match path.strip_prefix(root) { Ok(r) => r, Err(_) => continue, }; - // Normalise to forward-slash string for cross-platform stability. let rel_path: String = rel .components() .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect::>() .join("/"); - // Top-level directory under root. let top_dir = rel .components() .next() - .filter(|_| rel.components().count() > 1) // skip if file is at root level + .filter(|_| rel.components().count() > 1) .map(|c| c.as_os_str().to_string_lossy().into_owned()) - .unwrap_or_else(|| "__root__".to_string()); + .unwrap_or_else(|| ROOT_COLLECTION_NAME.to_string()); - // Title: stem with separator chars replaced by spaces. let stem = path .file_stem() .and_then(|s| s.to_str()) @@ -69,7 +61,6 @@ pub async fn scan_dir(root: &Path) -> Vec { let title = stem.replace(['_', '-', '.'], " "); let title = title.trim().to_string(); - // Year: first 4-digit number starting with 19xx or 20xx in filename or parent dirs. let search_str = format!( "{} {}", stem, @@ -79,7 +70,6 @@ pub async fn scan_dir(root: &Path) -> Vec { ); let year = extract_year(&search_str); - // Tags: ancestor directory components between root and the file. let tags: Vec = rel .parent() .into_iter() @@ -103,27 +93,23 @@ pub async fn scan_dir(root: &Path) -> Vec { items } -/// Extract the first plausible 4-digit year (1900-2099) from `s`. fn extract_year(s: &str) -> Option { let chars: Vec = s.chars().collect(); let n = chars.len(); - if n < 4 { + if n < YEAR_DIGITS { return None; } - for i in 0..=(n - 4) { - // All four chars must be ASCII digits. - if !chars[i..i + 4].iter().all(|c| c.is_ascii_digit()) { + for i in 0..=(n - YEAR_DIGITS) { + if !chars[i..i + YEAR_DIGITS].iter().all(|c| c.is_ascii_digit()) { continue; } - // Parse and range-check. - let s4: String = chars[i..i + 4].iter().collect(); + let s4: String = chars[i..i + YEAR_DIGITS].iter().collect(); let num: u16 = s4.parse().ok()?; - if !(1900..=2099).contains(&num) { + if !(MIN_YEAR..=MAX_YEAR).contains(&num) { continue; } - // Word-boundary: char before and after must not be digits. let before_ok = i == 0 || !chars[i - 1].is_ascii_digit(); - let after_ok = i + 4 >= n || !chars[i + 4].is_ascii_digit(); + let after_ok = i + YEAR_DIGITS >= n || !chars[i + YEAR_DIGITS].is_ascii_digit(); if before_ok && after_ok { return Some(num); } @@ -131,7 +117,6 @@ fn extract_year(s: &str) -> Option { None } -/// Run ffprobe to get the duration of `path` in whole seconds. async fn get_duration(path: &Path) -> Option { #[derive(serde::Deserialize)] struct Fmt { @@ -169,8 +154,8 @@ mod tests { assert_eq!(extract_year("Movie 2024 HD"), Some(2024)); assert_eq!(extract_year("1999_classic"), Some(1999)); assert_eq!(extract_year("no year here"), None); - assert_eq!(extract_year("12345"), None); // 5-digit number - assert_eq!(extract_year("2100"), None); // out of range + assert_eq!(extract_year("12345"), None); + assert_eq!(extract_year("2100"), None); assert_eq!(extract_year("1900"), Some(1900)); assert_eq!(extract_year("2099"), Some(2099)); } diff --git a/crates/adapters/local-files/src/transcoder.rs b/crates/adapters/local-files/src/transcoder.rs index ca2bf57..b10abdc 100644 --- a/crates/adapters/local-files/src/transcoder.rs +++ b/crates/adapters/local-files/src/transcoder.rs @@ -1,11 +1,3 @@ -//! FFmpeg HLS transcoder for local video files. -//! -//! `TranscodeManager` orchestrates on-demand transcoding: the first request for -//! an item spawns an ffmpeg process and returns once the initial HLS playlist -//! appears. Concurrent requests for the same item subscribe to a watch channel -//! and wait without spawning duplicate processes. Transcoded segments are cached -//! in `transcode_dir/{item_id}/` and cleaned up by a background task. - use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{ @@ -19,9 +11,13 @@ use tracing::{error, info, warn}; use domain::{DomainError, DomainResult}; -// ============================================================================ -// Types -// ============================================================================ +const SECS_PER_HOUR: u64 = 3600; +const CLEANUP_INTERVAL: Duration = Duration::from_secs(SECS_PER_HOUR); +const TRANSCODE_TIMEOUT: Duration = Duration::from_secs(60); +const TRANSCODE_POLL_INTERVAL: Duration = Duration::from_millis(100); +const FFMPEG_CRF: &str = "23"; +const FFMPEG_AUDIO_BITRATE: &str = "128k"; +const HLS_SEGMENT_SECS: &str = "6"; #[derive(Clone, Debug)] pub enum TranscodeStatus { @@ -29,10 +25,6 @@ pub enum TranscodeStatus { Failed(String), } -// ============================================================================ -// Manager -// ============================================================================ - pub struct TranscodeManager { pub transcode_dir: PathBuf, cleanup_ttl_hours: Arc, @@ -46,10 +38,10 @@ impl TranscodeManager { cleanup_ttl_hours: Arc::new(AtomicU32::new(cleanup_ttl_hours)), active: Arc::new(Mutex::new(HashMap::new())), }); - // Background cleanup task -- uses Weak to avoid keeping manager alive. + // uses Weak to avoid keeping manager alive let weak = Arc::downgrade(&mgr); tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(3600)); + let mut interval = tokio::time::interval(CLEANUP_INTERVAL); loop { interval.tick().await; match weak.upgrade() { @@ -61,7 +53,6 @@ impl TranscodeManager { mgr } - /// Update the cleanup TTL (also persisted to DB by the route handler). pub fn set_cleanup_ttl(&self, hours: u32) { self.cleanup_ttl_hours.store(hours, Ordering::Relaxed); } @@ -70,8 +61,6 @@ impl TranscodeManager { self.cleanup_ttl_hours.load(Ordering::Relaxed) } - /// Ensure `item_id` has been transcoded to HLS. Blocks until the initial - /// playlist appears or an error occurs. Concurrent callers share the result. pub async fn ensure_transcoded(&self, item_id: &str, src_path: &Path) -> DomainResult<()> { let out_dir = self.transcode_dir.join(item_id); let playlist = out_dir.join("playlist.m3u8"); @@ -111,7 +100,6 @@ impl TranscodeManager { } }; - // Wait for Ready or Failed. loop { rx.changed().await.map_err(|_| { DomainError::InfrastructureError( @@ -129,7 +117,6 @@ impl TranscodeManager { } } - /// Remove all cached transcode directories. pub async fn clear_cache(&self) -> std::io::Result<()> { if self.transcode_dir.exists() { tokio::fs::remove_dir_all(&self.transcode_dir).await?; @@ -137,7 +124,6 @@ impl TranscodeManager { tokio::fs::create_dir_all(&self.transcode_dir).await } - /// Return `(total_bytes, item_count)` for the cache directory. pub async fn cache_stats(&self) -> (u64, usize) { let mut total_bytes = 0u64; let mut item_count = 0usize; @@ -162,7 +148,7 @@ impl TranscodeManager { async fn run_cleanup(&self) { let ttl_hours = self.cleanup_ttl_hours.load(Ordering::Relaxed) as u64; - let ttl = Duration::from_secs(ttl_hours * 3600); + let ttl = Duration::from_secs(ttl_hours * SECS_PER_HOUR); let now = std::time::SystemTime::now(); let Ok(mut entries) = tokio::fs::read_dir(&self.transcode_dir).await else { @@ -174,24 +160,18 @@ impl TranscodeManager { continue; } let playlist = path.join("playlist.m3u8"); - if let Ok(meta) = tokio::fs::metadata(&playlist).await { - if let Ok(modified) = meta.modified() { - if let Ok(age) = now.duration_since(modified) { - if age > ttl { - warn!("cleanup: removing stale transcode {:?}", path); - let _ = tokio::fs::remove_dir_all(&path).await; - } - } - } + if let Ok(meta) = tokio::fs::metadata(&playlist).await + && let Ok(modified) = meta.modified() + && let Ok(age) = now.duration_since(modified) + && age > ttl + { + warn!("cleanup: removing stale transcode {:?}", path); + let _ = tokio::fs::remove_dir_all(&path).await; } } } } -// ============================================================================ -// FFmpeg helper -// ============================================================================ - async fn do_transcode(src: &Path, out_dir: &Path, playlist: &Path) -> TranscodeStatus { let segment_pattern = out_dir.join("seg%05d.ts"); @@ -204,13 +184,13 @@ async fn do_transcode(src: &Path, out_dir: &Path, playlist: &Path) -> TranscodeS "-preset", "fast", "-crf", - "23", + FFMPEG_CRF, "-c:a", "aac", "-b:a", - "128k", + FFMPEG_AUDIO_BITRATE, "-hls_time", - "6", + HLS_SEGMENT_SECS, "-hls_list_size", "0", "-hls_flags", @@ -227,10 +207,8 @@ async fn do_transcode(src: &Path, out_dir: &Path, playlist: &Path) -> TranscodeS Err(e) => return TranscodeStatus::Failed(format!("ffmpeg spawn error: {}", e)), }; - // Poll for playlist.m3u8 -- it appears after the first segment is written, - // allowing the client to start playback before transcoding is complete. let start = Instant::now(); - let timeout = Duration::from_secs(60); + let timeout = TRANSCODE_TIMEOUT; loop { if playlist.exists() { return TranscodeStatus::Ready; @@ -258,6 +236,6 @@ async fn do_transcode(src: &Path, out_dir: &Path, playlist: &Path) -> TranscodeS Err(e) => return TranscodeStatus::Failed(e.to_string()), Ok(None) => {} } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(TRANSCODE_POLL_INTERVAL).await; } } diff --git a/crates/adapters/postgres/src/activity.rs b/crates/adapters/postgres/src/activity.rs index e3fee6e..b4f7a3b 100644 --- a/crates/adapters/postgres/src/activity.rs +++ b/crates/adapters/postgres/src/activity.rs @@ -1,5 +1,3 @@ -//! PostgreSQL adapter for activity log (ActivityLogCommand + ActivityLogQuery). - use async_trait::async_trait; use chrono::Utc; use sqlx::PgPool; @@ -62,7 +60,6 @@ impl ActivityLogQuery for PgActivityLog { let mut events = Vec::with_capacity(rows.len()); for (id_str, ts_str, event_type, detail, channel_id_str) in rows { - // Silently skip rows with bad UUIDs/timestamps (mirrors old behaviour) let Ok(id) = parse_uuid(&id_str, "activity id") else { continue; }; diff --git a/crates/adapters/postgres/src/channel.rs b/crates/adapters/postgres/src/channel.rs index 08e4789..3b6b68f 100644 --- a/crates/adapters/postgres/src/channel.rs +++ b/crates/adapters/postgres/src/channel.rs @@ -1,5 +1,3 @@ -//! PostgreSQL adapter for channel persistence (ChannelCommand + ChannelQuery). - use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row}; @@ -7,7 +5,7 @@ use uuid::Uuid; use adapter_common::{ map_sqlx_error, parse_dt, parse_enum_or_default, parse_recycle_policy, parse_schedule_config, - parse_uuid, + parse_uuid, serialize_enum_as_string, }; use domain::{ ports::channel::{ChannelCommand, ChannelQuery}, @@ -25,8 +23,6 @@ impl PgChannelRepository { } } -// -- Row type ---------------------------------------------------------------- - const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, created_at, updated_at"; #[derive(Debug, sqlx::FromRow)] @@ -85,15 +81,6 @@ impl ChannelRow { } } -// -- Helpers ------------------------------------------------------------------ - -fn serialize_enum_as_string(v: &T, fallback: &str) -> String { - serde_json::to_value(v) - .ok() - .and_then(|v| v.as_str().map(str::to_owned)) - .unwrap_or_else(|| fallback.to_owned()) -} - fn map_snapshot_row( row: &sqlx::postgres::PgRow, channel_id: ChannelId, @@ -117,8 +104,6 @@ fn map_snapshot_row( )) } -// -- Command ----------------------------------------------------------------- - #[async_trait] impl ChannelCommand for PgChannelRepository { async fn save(&self, channel: &Channel) -> DomainResult<()> { @@ -261,8 +246,6 @@ impl ChannelCommand for PgChannelRepository { } } -// -- Query ------------------------------------------------------------------- - #[async_trait] impl ChannelQuery for PgChannelRepository { async fn find_by_id(&self, id: ChannelId) -> DomainResult> { diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index 36fcf0b..8de27de 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -1,6 +1,3 @@ -//! PostgreSQL adapter crate — implements all CQRS-split repository port traits -//! for PostgreSQL via sqlx. - pub mod activity; pub mod channel; pub mod library; diff --git a/crates/adapters/postgres/src/library.rs b/crates/adapters/postgres/src/library.rs index b0058d2..4ccc637 100644 --- a/crates/adapters/postgres/src/library.rs +++ b/crates/adapters/postgres/src/library.rs @@ -1,10 +1,7 @@ -//! PostgreSQL adapter for library persistence (LibraryCommand + LibraryQuery). - -use std::collections::HashSet; - use async_trait::async_trait; use sqlx::PgPool; +use adapter_common::{content_type_str, parse_content_type, parse_genres_blob}; use domain::{ ports::library::{LibraryCommand, LibraryQuery}, ContentType, DomainError, DomainResult, LibraryCollection, LibraryItem, LibrarySearchFilter, @@ -21,26 +18,6 @@ impl PgLibraryRepository { } } -// -- Helpers ----------------------------------------------------------------- - -fn content_type_str(ct: &ContentType) -> &'static str { - match ct { - ContentType::Movie => "movie", - ContentType::Episode => "episode", - ContentType::Short => "short", - } -} - -fn parse_content_type(s: &str) -> ContentType { - match s { - "episode" => ContentType::Episode, - "short" => ContentType::Short, - _ => ContentType::Movie, - } -} - -// -- Row types --------------------------------------------------------------- - #[derive(sqlx::FromRow)] struct LibraryItemRow { id: String, @@ -113,8 +90,6 @@ struct SeasonSummaryRow { thumbnail_url: Option, } -// -- Command ----------------------------------------------------------------- - #[async_trait] impl LibraryCommand for PgLibraryRepository { async fn upsert_items(&self, _provider_id: &str, items: Vec) -> DomainResult<()> { @@ -223,8 +198,6 @@ impl LibraryCommand for PgLibraryRepository { } } -// -- Query ------------------------------------------------------------------- - #[async_trait] impl LibraryQuery for PgLibraryRepository { async fn search( @@ -505,26 +478,7 @@ impl LibraryQuery for PgLibraryRepository { Ok(rows .into_iter() .map(|r| { - let genres: Vec = r - .genres_blob - .split("],[") - .flat_map(|chunk| { - let cleaned = chunk.trim_start_matches('[').trim_end_matches(']'); - cleaned - .split(',') - .filter_map(|s| { - let s = s.trim().trim_matches('"'); - if s.is_empty() { - None - } else { - Some(s.to_string()) - } - }) - .collect::>() - }) - .collect::>() - .into_iter() - .collect(); + let genres = parse_genres_blob(&r.genres_blob); ShowSummary::from_persistence( r.series_name, r.episode_count as u32, diff --git a/crates/adapters/postgres/src/provider_config.rs b/crates/adapters/postgres/src/provider_config.rs index 856867c..6b1e737 100644 --- a/crates/adapters/postgres/src/provider_config.rs +++ b/crates/adapters/postgres/src/provider_config.rs @@ -1,5 +1,3 @@ -//! PostgreSQL adapter for provider config (ProviderConfigCommand + ProviderConfigQuery). - use async_trait::async_trait; use sqlx::PgPool; diff --git a/crates/adapters/postgres/src/schedule.rs b/crates/adapters/postgres/src/schedule.rs index e1b016c..28534b9 100644 --- a/crates/adapters/postgres/src/schedule.rs +++ b/crates/adapters/postgres/src/schedule.rs @@ -1,5 +1,3 @@ -//! PostgreSQL adapter for schedule persistence (ScheduleCommand + ScheduleQuery). - use std::collections::HashMap; use async_trait::async_trait; @@ -22,8 +20,6 @@ impl PgScheduleRepository { } } -// -- Row types --------------------------------------------------------------- - #[derive(Debug, sqlx::FromRow)] struct ScheduleRow { id: String, @@ -36,8 +32,8 @@ struct ScheduleRow { #[derive(Debug, sqlx::FromRow)] struct SlotRow { id: String, - #[allow(dead_code)] - schedule_id: String, + #[sqlx(rename = "schedule_id")] + _schedule_id: String, start_at: String, end_at: String, item: String, @@ -59,8 +55,6 @@ struct PlaybackRecordRow { generation: i64, } -// -- Mapping ----------------------------------------------------------------- - fn map_slot_row(row: SlotRow) -> DomainResult { let id = SlotId::from_uuid(parse_uuid(&row.id, "slot id")?); let source_block_id = BlockId::from_uuid(parse_uuid(&row.source_block_id, "block id")?); @@ -103,8 +97,6 @@ fn map_playback_row(row: PlaybackRecordRow) -> DomainResult { )) } -// -- Internal helpers -------------------------------------------------------- - impl PgScheduleRepository { async fn fetch_slots(&self, schedule_id: &str) -> DomainResult> { sqlx::query_as( @@ -118,8 +110,6 @@ impl PgScheduleRepository { } } -// -- Command ----------------------------------------------------------------- - #[async_trait] impl ScheduleCommand for PgScheduleRepository { async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> { @@ -142,7 +132,6 @@ impl ScheduleCommand for PgScheduleRepository { .await .map_err(map_sqlx_error)?; - // Delete-then-insert all slots sqlx::query("DELETE FROM scheduled_slots WHERE schedule_id = $1") .bind(schedule.id().value().to_string()) .execute(&self.pool) @@ -216,8 +205,6 @@ impl ScheduleCommand for PgScheduleRepository { } } -// -- Query ------------------------------------------------------------------- - #[async_trait] impl ScheduleQuery for PgScheduleRepository { async fn find_active( diff --git a/crates/adapters/postgres/src/settings.rs b/crates/adapters/postgres/src/settings.rs index 259c050..e8b02d7 100644 --- a/crates/adapters/postgres/src/settings.rs +++ b/crates/adapters/postgres/src/settings.rs @@ -1,5 +1,3 @@ -//! PostgreSQL adapter for app settings (AppSettingsRepository). - use async_trait::async_trait; use sqlx::PgPool; diff --git a/crates/adapters/postgres/src/transcode.rs b/crates/adapters/postgres/src/transcode.rs index 60863a8..403ac0b 100644 --- a/crates/adapters/postgres/src/transcode.rs +++ b/crates/adapters/postgres/src/transcode.rs @@ -1,5 +1,3 @@ -//! PostgreSQL adapter for transcode settings (TranscodeSettingsRepository). - use async_trait::async_trait; use sqlx::PgPool; diff --git a/crates/adapters/postgres/src/user.rs b/crates/adapters/postgres/src/user.rs index e312110..c4c7cee 100644 --- a/crates/adapters/postgres/src/user.rs +++ b/crates/adapters/postgres/src/user.rs @@ -1,5 +1,3 @@ -//! PostgreSQL adapter for user persistence (UserCommand + UserQuery). - use async_trait::async_trait; use sqlx::PgPool; @@ -19,8 +17,6 @@ impl PgUserRepository { } } -// -- Row type for query_as -------------------------------------------------- - #[derive(Debug, sqlx::FromRow)] struct UserRow { id: String, @@ -49,8 +45,6 @@ impl UserRow { } } -// -- Command ----------------------------------------------------------------- - #[async_trait] impl UserCommand for PgUserRepository { async fn save(&self, user: &User) -> DomainResult<()> { @@ -98,8 +92,6 @@ impl UserCommand for PgUserRepository { } } -// -- Query ------------------------------------------------------------------- - #[async_trait] impl UserQuery for PgUserRepository { async fn find_by_id(&self, id: UserId) -> DomainResult> { diff --git a/crates/adapters/postgres/src/wire.rs b/crates/adapters/postgres/src/wire.rs index e891aa0..bc22657 100644 --- a/crates/adapters/postgres/src/wire.rs +++ b/crates/adapters/postgres/src/wire.rs @@ -1,6 +1,3 @@ -//! Wiring function that instantiates all PostgreSQL repositories and returns them -//! as trait-object Arcs. - use std::sync::Arc; use sqlx::PgPool; @@ -27,7 +24,6 @@ use crate::{ user::PgUserRepository, }; -/// All PostgreSQL adapter outputs, ready to be injected into the application layer. pub struct PostgresWireOutput { pub user_command: Arc, pub user_query: Arc, @@ -45,10 +41,6 @@ pub struct PostgresWireOutput { pub transcode_settings: Arc, } -/// Create all PostgreSQL repository implementations from a single pool. -/// -/// Each struct wraps a clone of the same pool. Repositories that implement -/// both Command and Query traits share a single `Arc` via `.clone()`. pub fn wire(pool: PgPool) -> PostgresWireOutput { let user = Arc::new(PgUserRepository::new(pool.clone())); let channel = Arc::new(PgChannelRepository::new(pool.clone())); diff --git a/crates/adapters/sqlite/src/activity.rs b/crates/adapters/sqlite/src/activity.rs index e1a1b34..82ce074 100644 --- a/crates/adapters/sqlite/src/activity.rs +++ b/crates/adapters/sqlite/src/activity.rs @@ -1,5 +1,3 @@ -//! SQLite adapter for activity log (ActivityLogCommand + ActivityLogQuery). - use async_trait::async_trait; use chrono::Utc; use sqlx::SqlitePool; @@ -62,7 +60,6 @@ impl ActivityLogQuery for SqliteActivityLog { let mut events = Vec::with_capacity(rows.len()); for (id_str, ts_str, event_type, detail, channel_id_str) in rows { - // Silently skip rows with bad UUIDs/timestamps (mirrors old behaviour) let Ok(id) = parse_uuid(&id_str, "activity id") else { continue; }; diff --git a/crates/adapters/sqlite/src/channel.rs b/crates/adapters/sqlite/src/channel.rs index adc2a41..880c917 100644 --- a/crates/adapters/sqlite/src/channel.rs +++ b/crates/adapters/sqlite/src/channel.rs @@ -1,5 +1,3 @@ -//! SQLite adapter for channel persistence (ChannelCommand + ChannelQuery). - use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::{Row, SqlitePool}; @@ -7,7 +5,7 @@ use uuid::Uuid; use adapter_common::{ map_sqlx_error, parse_dt, parse_enum_or_default, parse_recycle_policy, parse_schedule_config, - parse_uuid, + parse_uuid, serialize_enum_as_string, }; use domain::{ ports::channel::{ChannelCommand, ChannelQuery}, @@ -25,8 +23,6 @@ impl SqliteChannelRepository { } } -// -- Row type ---------------------------------------------------------------- - const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, created_at, updated_at"; #[derive(Debug, sqlx::FromRow)] @@ -85,15 +81,6 @@ impl ChannelRow { } } -// -- Helpers ------------------------------------------------------------------ - -fn serialize_enum_as_string(v: &T, fallback: &str) -> String { - serde_json::to_value(v) - .ok() - .and_then(|v| v.as_str().map(str::to_owned)) - .unwrap_or_else(|| fallback.to_owned()) -} - fn map_snapshot_row( row: &sqlx::sqlite::SqliteRow, channel_id: ChannelId, @@ -117,8 +104,6 @@ fn map_snapshot_row( )) } -// -- Command ----------------------------------------------------------------- - #[async_trait] impl ChannelCommand for SqliteChannelRepository { async fn save(&self, channel: &Channel) -> DomainResult<()> { @@ -261,8 +246,6 @@ impl ChannelCommand for SqliteChannelRepository { } } -// -- Query ------------------------------------------------------------------- - #[async_trait] impl ChannelQuery for SqliteChannelRepository { async fn find_by_id(&self, id: ChannelId) -> DomainResult> { diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 1a9d207..29cdb65 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -1,6 +1,3 @@ -//! SQLite adapter crate — implements all CQRS-split repository port traits -//! for SQLite via sqlx. - pub mod activity; pub mod channel; pub mod library; diff --git a/crates/adapters/sqlite/src/library.rs b/crates/adapters/sqlite/src/library.rs index 2b4e56f..d1a9eb4 100644 --- a/crates/adapters/sqlite/src/library.rs +++ b/crates/adapters/sqlite/src/library.rs @@ -1,10 +1,7 @@ -//! SQLite adapter for library persistence (LibraryCommand + LibraryQuery). - -use std::collections::HashSet; - use async_trait::async_trait; use sqlx::SqlitePool; +use adapter_common::{content_type_str, parse_content_type, parse_genres_blob}; use domain::{ ports::library::{LibraryCommand, LibraryQuery}, ContentType, DomainError, DomainResult, LibraryCollection, LibraryItem, LibrarySearchFilter, @@ -21,26 +18,6 @@ impl SqliteLibraryRepository { } } -// -- Helpers ----------------------------------------------------------------- - -fn content_type_str(ct: &ContentType) -> &'static str { - match ct { - ContentType::Movie => "movie", - ContentType::Episode => "episode", - ContentType::Short => "short", - } -} - -fn parse_content_type(s: &str) -> ContentType { - match s { - "episode" => ContentType::Episode, - "short" => ContentType::Short, - _ => ContentType::Movie, - } -} - -// -- Row types --------------------------------------------------------------- - #[derive(sqlx::FromRow)] struct LibraryItemRow { id: String, @@ -113,8 +90,6 @@ struct SeasonSummaryRow { thumbnail_url: Option, } -// -- Command ----------------------------------------------------------------- - #[async_trait] impl LibraryCommand for SqliteLibraryRepository { async fn upsert_items(&self, _provider_id: &str, items: Vec) -> DomainResult<()> { @@ -206,8 +181,6 @@ impl LibraryCommand for SqliteLibraryRepository { } } -// -- Query ------------------------------------------------------------------- - #[async_trait] impl LibraryQuery for SqliteLibraryRepository { async fn search( @@ -481,32 +454,12 @@ impl LibraryQuery for SqliteLibraryRepository { Ok(rows .into_iter() .map(|r| { - let genres: Vec = r - .genres_blob - .split("],[") - .flat_map(|chunk| { - let cleaned = chunk.trim_start_matches('[').trim_end_matches(']'); - cleaned - .split(',') - .filter_map(|s| { - let s = s.trim().trim_matches('"'); - if s.is_empty() { - None - } else { - Some(s.to_string()) - } - }) - .collect::>() - }) - .collect::>() - .into_iter() - .collect(); ShowSummary::from_persistence( r.series_name, r.episode_count as u32, r.season_count as u32, r.thumbnail_url, - genres, + parse_genres_blob(&r.genres_blob), ) }) .collect()) diff --git a/crates/adapters/sqlite/src/provider_config.rs b/crates/adapters/sqlite/src/provider_config.rs index 6aeeff2..a58e45f 100644 --- a/crates/adapters/sqlite/src/provider_config.rs +++ b/crates/adapters/sqlite/src/provider_config.rs @@ -1,5 +1,3 @@ -//! SQLite adapter for provider config (ProviderConfigCommand + ProviderConfigQuery). - use async_trait::async_trait; use sqlx::SqlitePool; diff --git a/crates/adapters/sqlite/src/schedule.rs b/crates/adapters/sqlite/src/schedule.rs index f96bd70..07aab55 100644 --- a/crates/adapters/sqlite/src/schedule.rs +++ b/crates/adapters/sqlite/src/schedule.rs @@ -1,5 +1,3 @@ -//! SQLite adapter for schedule persistence (ScheduleCommand + ScheduleQuery). - use std::collections::HashMap; use async_trait::async_trait; @@ -22,8 +20,6 @@ impl SqliteScheduleRepository { } } -// -- Row types --------------------------------------------------------------- - #[derive(Debug, sqlx::FromRow)] struct ScheduleRow { id: String, @@ -36,8 +32,7 @@ struct ScheduleRow { #[derive(Debug, sqlx::FromRow)] struct SlotRow { id: String, - #[allow(dead_code)] - schedule_id: String, + _schedule_id: String, start_at: String, end_at: String, item: String, @@ -59,8 +54,6 @@ struct PlaybackRecordRow { generation: i64, } -// -- Mapping ----------------------------------------------------------------- - fn map_slot_row(row: SlotRow) -> DomainResult { let id = SlotId::from_uuid(parse_uuid(&row.id, "slot id")?); let source_block_id = BlockId::from_uuid(parse_uuid(&row.source_block_id, "block id")?); @@ -103,8 +96,6 @@ fn map_playback_row(row: PlaybackRecordRow) -> DomainResult { )) } -// -- Internal helpers -------------------------------------------------------- - impl SqliteScheduleRepository { async fn fetch_slots(&self, schedule_id: &str) -> DomainResult> { sqlx::query_as( @@ -118,8 +109,6 @@ impl SqliteScheduleRepository { } } -// -- Command ----------------------------------------------------------------- - #[async_trait] impl ScheduleCommand for SqliteScheduleRepository { async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> { @@ -142,7 +131,6 @@ impl ScheduleCommand for SqliteScheduleRepository { .await .map_err(map_sqlx_error)?; - // Delete-then-insert all slots sqlx::query("DELETE FROM scheduled_slots WHERE schedule_id = ?") .bind(schedule.id().value().to_string()) .execute(&self.pool) @@ -216,8 +204,6 @@ impl ScheduleCommand for SqliteScheduleRepository { } } -// -- Query ------------------------------------------------------------------- - #[async_trait] impl ScheduleQuery for SqliteScheduleRepository { async fn find_active( diff --git a/crates/adapters/sqlite/src/settings.rs b/crates/adapters/sqlite/src/settings.rs index 6807f2a..fef96f5 100644 --- a/crates/adapters/sqlite/src/settings.rs +++ b/crates/adapters/sqlite/src/settings.rs @@ -1,5 +1,3 @@ -//! SQLite adapter for app settings (AppSettingsRepository). - use async_trait::async_trait; use sqlx::SqlitePool; diff --git a/crates/adapters/sqlite/src/transcode.rs b/crates/adapters/sqlite/src/transcode.rs index ea1e64c..354b078 100644 --- a/crates/adapters/sqlite/src/transcode.rs +++ b/crates/adapters/sqlite/src/transcode.rs @@ -1,5 +1,3 @@ -//! SQLite adapter for transcode settings (TranscodeSettingsRepository). - use async_trait::async_trait; use sqlx::SqlitePool; diff --git a/crates/adapters/sqlite/src/user.rs b/crates/adapters/sqlite/src/user.rs index ca58b24..7e8ef54 100644 --- a/crates/adapters/sqlite/src/user.rs +++ b/crates/adapters/sqlite/src/user.rs @@ -1,5 +1,3 @@ -//! SQLite adapter for user persistence (UserCommand + UserQuery). - use async_trait::async_trait; use sqlx::SqlitePool; @@ -19,8 +17,6 @@ impl SqliteUserRepository { } } -// -- Row type for query_as -------------------------------------------------- - #[derive(Debug, sqlx::FromRow)] struct UserRow { id: String, @@ -49,8 +45,6 @@ impl UserRow { } } -// -- Command ----------------------------------------------------------------- - #[async_trait] impl UserCommand for SqliteUserRepository { async fn save(&self, user: &User) -> DomainResult<()> { @@ -98,8 +92,6 @@ impl UserCommand for SqliteUserRepository { } } -// -- Query ------------------------------------------------------------------- - #[async_trait] impl UserQuery for SqliteUserRepository { async fn find_by_id(&self, id: UserId) -> DomainResult> { diff --git a/crates/adapters/sqlite/src/wire.rs b/crates/adapters/sqlite/src/wire.rs index 73d70d9..586eb29 100644 --- a/crates/adapters/sqlite/src/wire.rs +++ b/crates/adapters/sqlite/src/wire.rs @@ -1,6 +1,3 @@ -//! Wiring function that instantiates all SQLite repositories and returns them -//! as trait-object Arcs. - use std::sync::Arc; use sqlx::SqlitePool; @@ -27,7 +24,6 @@ use crate::{ user::SqliteUserRepository, }; -/// All SQLite adapter outputs, ready to be injected into the application layer. pub struct SqliteWireOutput { pub user_command: Arc, pub user_query: Arc, @@ -45,10 +41,6 @@ pub struct SqliteWireOutput { pub transcode_settings: Arc, } -/// Create all SQLite repository implementations from a single pool. -/// -/// Each struct wraps a clone of the same pool. Repositories that implement -/// both Command and Query traits share a single `Arc` via `.clone()`. pub fn wire(pool: SqlitePool) -> SqliteWireOutput { let user = Arc::new(SqliteUserRepository::new(pool.clone())); let channel = Arc::new(SqliteChannelRepository::new(pool.clone())); diff --git a/crates/infra-wiring/src/lib.rs b/crates/infra-wiring/src/lib.rs index 29a0fb5..8e3d57a 100644 --- a/crates/infra-wiring/src/lib.rs +++ b/crates/infra-wiring/src/lib.rs @@ -1,16 +1,18 @@ -//! Infra-wiring crate — shared `DbPool` enum and `Config` struct. -//! -//! Breaks dependency cycles between adapter crates and the presentation layer -//! by owning pool creation, migration running, and env-var config loading. - use std::env; use std::path::PathBuf; -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- +const DEFAULT_HOST: &str = "0.0.0.0"; +const DEFAULT_PORT: u16 = 3000; +const DEFAULT_DATABASE_URL: &str = "sqlite:data.db?mode=rwc"; +const DEFAULT_LOG_LEVEL: &str = "info"; +const DEFAULT_COOKIE_SECRET: &str = "k-template-cookie-secret-key-must-be-at-least-64-bytes-long!!"; +const DEFAULT_CORS_ORIGIN: &str = "http://localhost:5173"; +const DEFAULT_MAX_CONNECTIONS: u32 = 5; +const DEFAULT_MIN_CONNECTIONS: u32 = 1; +const DEFAULT_JWT_EXPIRY_HOURS: u64 = 24; +const DEFAULT_JWT_REFRESH_EXPIRY_DAYS: u64 = 30; +const DEFAULT_TRANSCODE_CLEANUP_TTL_HOURS: u32 = 24; -/// Errors that can occur when building a [`Config`]. #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("missing required env var: {0}")] @@ -23,7 +25,6 @@ pub enum ConfigError { }, } -/// Errors from pool creation or migration. #[derive(Debug, thiserror::Error)] pub enum DbError { #[error("unsupported database URL scheme: {0}")] @@ -36,11 +37,6 @@ pub enum DbError { Migrate(#[from] sqlx::migrate::MigrateError), } -// --------------------------------------------------------------------------- -// DbPool -// --------------------------------------------------------------------------- - -/// Feature-gated database pool — one variant per supported backend. #[derive(Debug, Clone)] pub enum DbPool { #[cfg(feature = "sqlite")] @@ -51,10 +47,6 @@ pub enum DbPool { } impl DbPool { - /// Create a pool by auto-detecting the scheme of `database_url`. - /// - /// * URLs starting with `sqlite:` → `SqlitePool` - /// * URLs starting with `postgres:` / `postgresql:` → `PgPool` pub async fn connect(database_url: &str) -> Result { let scheme = database_url .split(':') @@ -77,7 +69,6 @@ impl DbPool { } } - /// Run the embedded migrations for the detected backend. pub async fn run_migrations(&self) -> Result<(), DbError> { match self { #[cfg(feature = "sqlite")] @@ -88,7 +79,6 @@ impl DbPool { } #[cfg(feature = "postgres")] Self::Postgres(_pool) => { - // TODO: add postgres migrations directory and enable tracing::warn!("postgres migrations not yet available"); } } @@ -96,93 +86,66 @@ impl DbPool { } } -// --------------------------------------------------------------------------- -// Config -// --------------------------------------------------------------------------- - -/// Source of dynamic config values (env-only vs database-backed overrides). #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConfigSource { Env, Db, } -/// Application configuration loaded from environment variables. #[derive(Debug, Clone)] pub struct Config { pub config_source: ConfigSource, - - // Core pub database_url: String, pub host: String, pub port: u16, pub log_level: String, pub is_production: bool, pub base_url: String, - - // HTTP pub cors_origins: Vec, pub cookie_secret: String, pub secure_cookie: bool, - - // Connection pool pub db_max_connections: u32, pub db_min_connections: u32, - - // Auth – JWT pub jwt_secret: Option, pub jwt_issuer: Option, pub jwt_audience: Option, pub jwt_expiry_hours: u64, pub jwt_refresh_expiry_days: u64, pub allow_registration: bool, - - // Auth – OIDC pub oidc_issuer_url: Option, pub oidc_client_id: Option, pub oidc_client_secret: Option, pub oidc_redirect_url: Option, pub oidc_resource_id: Option, - - // Jellyfin media provider pub jellyfin_url: Option, pub jellyfin_api_key: Option, pub jellyfin_user_id: Option, - - // Local-files provider pub local_files_dir: Option, - - // Transcoding pub transcode_dir: Option, pub transcode_cleanup_ttl_hours: u32, } impl Config { - /// Build config from environment variables. - /// - /// Uses sensible defaults where possible; returns `ConfigError` only when - /// a required variable is missing and has no default. pub fn from_env() -> Result { - let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); + let host = env::var("HOST").unwrap_or_else(|_| DEFAULT_HOST.to_string()); let port: u16 = env::var("PORT") .ok() .and_then(|p| p.parse().ok()) - .unwrap_or(3000); + .unwrap_or(DEFAULT_PORT); let database_url = - env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:data.db?mode=rwc".to_string()); + env::var("DATABASE_URL").unwrap_or_else(|_| DEFAULT_DATABASE_URL.to_string()); let log_level = env::var("LOG_LEVEL") .or_else(|_| env::var("RUST_LOG")) - .unwrap_or_else(|_| "info".to_string()); + .unwrap_or_else(|_| DEFAULT_LOG_LEVEL.to_string()); - let cookie_secret = env::var("COOKIE_SECRET").unwrap_or_else(|_| { - "k-template-cookie-secret-key-must-be-at-least-64-bytes-long!!".to_string() - }); + let cookie_secret = env::var("COOKIE_SECRET") + .unwrap_or_else(|_| DEFAULT_COOKIE_SECRET.to_string()); let cors_origins: Vec = env::var("CORS_ALLOWED_ORIGINS") - .unwrap_or_else(|_| "http://localhost:5173".to_string()) + .unwrap_or_else(|_| DEFAULT_CORS_ORIGIN.to_string()) .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) @@ -196,27 +159,25 @@ impl Config { let db_max_connections = env::var("DB_MAX_CONNECTIONS") .ok() .and_then(|s| s.parse().ok()) - .unwrap_or(5); + .unwrap_or(DEFAULT_MAX_CONNECTIONS); let db_min_connections = env::var("DB_MIN_CONNECTIONS") .ok() .and_then(|s| s.parse().ok()) - .unwrap_or(1); + .unwrap_or(DEFAULT_MIN_CONNECTIONS); - // JWT let jwt_secret = env::var("JWT_SECRET").ok(); let jwt_issuer = env::var("JWT_ISSUER").ok(); let jwt_audience = env::var("JWT_AUDIENCE").ok(); let jwt_expiry_hours = env::var("JWT_EXPIRY_HOURS") .ok() .and_then(|s| s.parse().ok()) - .unwrap_or(24); + .unwrap_or(DEFAULT_JWT_EXPIRY_HOURS); let jwt_refresh_expiry_days = env::var("JWT_REFRESH_EXPIRY_DAYS") .ok() .and_then(|s| s.parse().ok()) - .unwrap_or(30); + .unwrap_or(DEFAULT_JWT_REFRESH_EXPIRY_DAYS); - // OIDC let oidc_issuer_url = env::var("OIDC_ISSUER").ok(); let oidc_client_id = env::var("OIDC_CLIENT_ID").ok(); let oidc_client_secret = env::var("OIDC_CLIENT_SECRET").ok(); @@ -235,20 +196,16 @@ impl Config { .map(|v| !(v == "false" || v == "0")) .unwrap_or(true); - // Jellyfin let jellyfin_url = env::var("JELLYFIN_BASE_URL").ok(); let jellyfin_api_key = env::var("JELLYFIN_API_KEY").ok(); let jellyfin_user_id = env::var("JELLYFIN_USER_ID").ok(); - // Local files let local_files_dir = env::var("LOCAL_FILES_DIR").ok().map(PathBuf::from); - - // Transcoding let transcode_dir = env::var("TRANSCODE_DIR").ok().map(PathBuf::from); let transcode_cleanup_ttl_hours = env::var("TRANSCODE_CLEANUP_TTL_HOURS") .ok() .and_then(|s| s.parse().ok()) - .unwrap_or(24); + .unwrap_or(DEFAULT_TRANSCODE_CLEANUP_TTL_HOURS); let base_url = env::var("BASE_URL").unwrap_or_else(|_| format!("http://localhost:{}", port));