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
This commit is contained in:
2026-07-12 04:21:21 +02:00
parent eff14228af
commit 25b33b6a0e
38 changed files with 188 additions and 630 deletions

View File

@@ -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 chrono::{DateTime, Utc};
use domain::{DomainError, RecyclePolicy, ScheduleConfig, ScheduleConfigCompat}; use domain::{DomainError, RecyclePolicy, ScheduleConfig, ScheduleConfigCompat};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use uuid::Uuid; use uuid::Uuid;
// ============================================================================
// Error mapping
// ============================================================================
/// Map a [`sqlx::Error`] into a [`DomainError::RepositoryError`].
pub fn map_sqlx_error(err: sqlx::Error) -> DomainError { pub fn map_sqlx_error(err: sqlx::Error) -> DomainError {
tracing::error!(error = %err, "database error"); tracing::error!(error = %err, "database error");
DomainError::RepositoryError(err.to_string()) 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<DateTime<Utc>, DomainError> { pub fn parse_dt(s: &str) -> Result<DateTime<Utc>, DomainError> {
DateTime::parse_from_rfc3339(s) DateTime::parse_from_rfc3339(s)
.map(|dt| dt.with_timezone(&Utc)) .map(|dt| dt.with_timezone(&Utc))
@@ -35,54 +17,73 @@ pub fn parse_dt(s: &str) -> Result<DateTime<Utc>, DomainError> {
.map_err(|e| DomainError::RepositoryError(format!("Invalid datetime '{}': {}", s, e))) .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, DomainError> { pub fn parse_uuid(s: &str, context: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s) Uuid::parse_str(s)
.map_err(|e| DomainError::RepositoryError(format!("Invalid {} UUID '{}': {}", context, s, e))) .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<T: DeserializeOwned>(json: &str, context: &str) -> Result<T, DomainError> { pub fn parse_json<T: DeserializeOwned>(json: &str, context: &str) -> Result<T, DomainError> {
serde_json::from_str(json) serde_json::from_str(json)
.map_err(|e| DomainError::RepositoryError(format!("Invalid {} JSON: {}", context, e))) .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<ScheduleConfig, DomainError> { pub fn parse_schedule_config(json: &str) -> Result<ScheduleConfig, DomainError> {
let compat: ScheduleConfigCompat = parse_json(json, "schedule_config")?; let compat: ScheduleConfigCompat = parse_json(json, "schedule_config")?;
Ok(ScheduleConfig::from(compat)) Ok(ScheduleConfig::from(compat))
} }
/// Parse a `recycle_policy` JSON column.
pub fn parse_recycle_policy(json: &str) -> Result<RecyclePolicy, DomainError> { pub fn parse_recycle_policy(json: &str) -> Result<RecyclePolicy, DomainError> {
parse_json(json, "recycle_policy") 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<T: DeserializeOwned + Default>(value: String) -> T { pub fn parse_enum_or_default<T: DeserializeOwned + Default>(value: String) -> T {
serde_json::from_value(serde_json::Value::String(value)).unwrap_or_default() serde_json::from_value(serde_json::Value::String(value)).unwrap_or_default()
} }
// ============================================================================ pub fn serialize_enum_as_string<T: serde::Serialize>(v: &T, fallback: &str) -> String {
// Tests 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<String> {
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::<Vec<_>>()
})
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -146,7 +147,6 @@ mod tests {
fn parse_schedule_config_v1_compat() { fn parse_schedule_config_v1_compat() {
let json = r#"{"blocks":[]}"#; let json = r#"{"blocks":[]}"#;
let cfg = parse_schedule_config(json).unwrap(); let cfg = parse_schedule_config(json).unwrap();
// V1 promotes blocks to all 7 days
assert_eq!(cfg.day_blocks().len(), 7); assert_eq!(cfg.day_blocks().len(), 7);
} }
@@ -168,7 +168,6 @@ mod tests {
fn parse_enum_or_default_fallback() { fn parse_enum_or_default_fallback() {
use domain::AccessMode; use domain::AccessMode;
let mode: AccessMode = parse_enum_or_default("garbage".to_string()); let mode: AccessMode = parse_enum_or_default("garbage".to_string());
// Should return default (Public)
assert!(matches!(mode, AccessMode::Public)); assert!(matches!(mode, AccessMode::Public));
} }
@@ -178,4 +177,27 @@ mod tests {
let domain_err = map_sqlx_error(sqlx_err); let domain_err = map_sqlx_error(sqlx_err);
assert!(matches!(domain_err, DomainError::RepositoryError(_))); 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()));
}
} }

View File

@@ -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 domain::User;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
/// Minimum secret length for production (256 bits = 32 bytes).
const MIN_SECRET_LENGTH: usize = 32; 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)] #[derive(Debug, Clone)]
pub struct JwtConfig { pub struct JwtConfig {
/// Secret key for HS256 signing/verification.
pub secret: String, pub secret: String,
/// Expected issuer (for validation).
pub issuer: Option<String>, pub issuer: Option<String>,
/// Expected audience (for validation).
pub audience: Option<String>, pub audience: Option<String>,
/// Access token expiry in hours (default: 24).
pub expiry_hours: u64, pub expiry_hours: u64,
/// Refresh token expiry in days (default: 30).
pub refresh_expiry_days: u64, pub refresh_expiry_days: u64,
} }
impl JwtConfig { impl JwtConfig {
/// Create a new JWT config with validation.
///
/// In production mode, this rejects secrets shorter than
/// [`MIN_SECRET_LENGTH`] bytes.
pub fn new( pub fn new(
secret: String, secret: String,
issuer: Option<String>, issuer: Option<String>,
@@ -59,7 +43,6 @@ impl JwtConfig {
}) })
} }
/// Create config without validation (for testing).
pub fn new_unchecked(secret: String) -> Self { pub fn new_unchecked(secret: String) -> Self {
Self { Self {
secret, secret,
@@ -71,42 +54,24 @@ impl JwtConfig {
} }
} }
// ---------------------------------------------------------------------------
// Claims
// ---------------------------------------------------------------------------
fn default_token_type() -> String { fn default_token_type() -> String {
"access".to_string() TOKEN_TYPE_ACCESS.to_string()
} }
/// JWT claims structure.
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JwtClaims { pub struct JwtClaims {
/// Subject — the user's unique identifier (user ID as string).
pub sub: String, pub sub: String,
/// User's email address.
pub email: String, pub email: String,
/// Expiry timestamp (seconds since UNIX epoch).
pub exp: usize, pub exp: usize,
/// Issued-at timestamp (seconds since UNIX epoch).
pub iat: usize, pub iat: usize,
/// Issuer.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub iss: Option<String>, pub iss: Option<String>,
/// Audience.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>, pub aud: Option<String>,
/// Token type: `"access"` or `"refresh"`. Defaults to `"access"` for
/// backward compatibility.
#[serde(default = "default_token_type")] #[serde(default = "default_token_type")]
pub token_type: String, pub token_type: String,
} }
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// JWT-related errors.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum JwtError { pub enum JwtError {
#[error("JWT secret too weak: minimum {min_length} bytes, got {actual_length}")] #[error("JWT secret too weak: minimum {min_length} bytes, got {actual_length}")]
@@ -131,11 +96,6 @@ pub enum JwtError {
MissingConfig, MissingConfig,
} }
// ---------------------------------------------------------------------------
// Validator / generator
// ---------------------------------------------------------------------------
/// JWT token validator and generator.
#[derive(Clone)] #[derive(Clone)]
pub struct JwtValidator { pub struct JwtValidator {
config: JwtConfig, config: JwtConfig,
@@ -145,7 +105,6 @@ pub struct JwtValidator {
} }
impl JwtValidator { impl JwtValidator {
/// Create a new JWT validator with the given configuration.
pub fn new(config: JwtConfig) -> Self { pub fn new(config: JwtConfig) -> Self {
let encoding_key = EncodingKey::from_secret(config.secret.as_bytes()); let encoding_key = EncodingKey::from_secret(config.secret.as_bytes());
let decoding_key = DecodingKey::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<String, JwtError> { pub fn create_token(&self, user: &User) -> Result<String, JwtError> {
let now = now_secs(); 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 { let claims = JwtClaims {
sub: user.id().to_string(), sub: user.id().to_string(),
@@ -179,17 +137,16 @@ impl JwtValidator {
iat: now, iat: now,
iss: self.config.issuer.clone(), iss: self.config.issuer.clone(),
aud: self.config.audience.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) encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key)
.map_err(JwtError::CreationFailed) .map_err(JwtError::CreationFailed)
} }
/// Create a refresh JWT token for the given user (longer-lived).
pub fn create_refresh_token(&self, user: &User) -> Result<String, JwtError> { pub fn create_refresh_token(&self, user: &User) -> Result<String, JwtError> {
let now = now_secs(); 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 { let claims = JwtClaims {
sub: user.id().to_string(), sub: user.id().to_string(),
@@ -198,14 +155,13 @@ impl JwtValidator {
iat: now, iat: now,
iss: self.config.issuer.clone(), iss: self.config.issuer.clone(),
aud: self.config.audience.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) encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key)
.map_err(JwtError::CreationFailed) .map_err(JwtError::CreationFailed)
} }
/// Validate a JWT token and return the claims.
pub fn validate_token(&self, token: &str) -> Result<JwtClaims, JwtError> { pub fn validate_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
let token_data = let token_data =
decode::<JwtClaims>(token, &self.decoding_key, &self.validation).map_err(|e| { decode::<JwtClaims>(token, &self.decoding_key, &self.validation).map_err(|e| {
@@ -219,10 +175,9 @@ impl JwtValidator {
Ok(token_data.claims) Ok(token_data.claims)
} }
/// Validate an access token — rejects refresh tokens.
pub fn validate_access_token(&self, token: &str) -> Result<JwtClaims, JwtError> { pub fn validate_access_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
let claims = self.validate_token(token)?; let claims = self.validate_token(token)?;
if claims.token_type != "access" { if claims.token_type != TOKEN_TYPE_ACCESS {
return Err(JwtError::ValidationFailed( return Err(JwtError::ValidationFailed(
"Not an access token".to_string(), "Not an access token".to_string(),
)); ));
@@ -230,10 +185,9 @@ impl JwtValidator {
Ok(claims) Ok(claims)
} }
/// Validate a refresh token — rejects access tokens.
pub fn validate_refresh_token(&self, token: &str) -> Result<JwtClaims, JwtError> { pub fn validate_refresh_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
let claims = self.validate_token(token)?; let claims = self.validate_token(token)?;
if claims.token_type != "refresh" { if claims.token_type != TOKEN_TYPE_REFRESH {
return Err(JwtError::ValidationFailed( return Err(JwtError::ValidationFailed(
"Not a refresh token".to_string(), "Not a refresh token".to_string(),
)); ));
@@ -241,9 +195,6 @@ impl JwtValidator {
Ok(claims) 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<JwtClaims, JwtError> { pub fn decode_unverified(&self, token: &str) -> Result<JwtClaims, JwtError> {
let mut insecure = Validation::new(Algorithm::HS256); let mut insecure = Validation::new(Algorithm::HS256);
insecure.insecure_disable_signature_validation(); insecure.insecure_disable_signature_validation();
@@ -273,10 +224,6 @@ fn now_secs() -> usize {
.as_secs() as usize .as_secs() as usize
} }
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -311,7 +258,6 @@ mod tests {
let claims = validator.validate_refresh_token(&token).unwrap(); let claims = validator.validate_refresh_token(&token).unwrap();
assert_eq!(claims.token_type, "refresh"); assert_eq!(claims.token_type, "refresh");
// Access-only validation rejects it
assert!(validator.validate_access_token(&token).is_err()); assert!(validator.validate_access_token(&token).is_err());
} }

View File

@@ -1,5 +1,3 @@
//! Auth adapter crate — JWT, OIDC, and password hashing.
pub mod password; pub mod password;
#[cfg(feature = "jwt")] #[cfg(feature = "jwt")]

View File

@@ -1,5 +1,3 @@
//! OIDC (OpenID Connect) authorization flow adapter.
use domain::{ use domain::{
AuthorizationCode, AuthorizationUrlData, ClientId, ClientSecret, CsrfToken, IssuerUrl, AuthorizationCode, AuthorizationUrlData, ClientId, ClientSecret, CsrfToken, IssuerUrl,
OidcNonce, PkceVerifier, RedirectUrl, ResourceId, OidcNonce, PkceVerifier, RedirectUrl, ResourceId,
@@ -18,10 +16,6 @@ use openidconnect::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Type aliases
// ---------------------------------------------------------------------------
pub type OidcClient = Client< pub type OidcClient = Client<
EmptyAdditionalClaims, EmptyAdditionalClaims,
CoreAuthDisplay, CoreAuthDisplay,
@@ -34,19 +28,14 @@ pub type OidcClient = Client<
CoreTokenIntrospectionResponse, CoreTokenIntrospectionResponse,
CoreRevocableToken, CoreRevocableToken,
CoreRevocationErrorResponse, CoreRevocationErrorResponse,
EndpointSet, // HasAuthUrl EndpointSet,
EndpointNotSet, // HasDeviceAuthUrl EndpointNotSet,
EndpointNotSet, // HasIntrospectionUrl EndpointNotSet,
EndpointNotSet, // HasRevocationUrl EndpointNotSet,
EndpointMaybeSet, // HasTokenUrl EndpointMaybeSet,
EndpointMaybeSet, // HasUserInfoUrl EndpointMaybeSet,
>; >;
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// OIDC-specific errors.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum OidcError { pub enum OidcError {
#[error("OIDC discovery failed: {0}")] #[error("OIDC discovery failed: {0}")]
@@ -71,11 +60,6 @@ pub enum OidcError {
Http(String), Http(String),
} }
// ---------------------------------------------------------------------------
// State / types
// ---------------------------------------------------------------------------
/// Serializable OIDC state stored in an encrypted cookie during the auth flow.
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct OidcState { pub struct OidcState {
pub csrf_token: CsrfToken, pub csrf_token: CsrfToken,
@@ -83,18 +67,12 @@ pub struct OidcState {
pub pkce_verifier: PkceVerifier, pub pkce_verifier: PkceVerifier,
} }
/// Resolved OIDC user info.
#[derive(Debug)] #[derive(Debug)]
pub struct OidcUser { pub struct OidcUser {
pub subject: String, pub subject: String,
pub email: String, pub email: String,
} }
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
/// OIDC authorization flow service.
#[derive(Clone)] #[derive(Clone)]
pub struct OidcService { pub struct OidcService {
client: OidcClient, client: OidcClient,
@@ -103,7 +81,6 @@ pub struct OidcService {
} }
impl OidcService { impl OidcService {
/// Create a new OIDC service — performs provider discovery.
pub async fn new( pub async fn new(
issuer: IssuerUrl, issuer: IssuerUrl,
client_id: ClientId, 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) { pub fn get_authorization_url(&self) -> (AuthorizationUrlData, OidcState) {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
@@ -193,8 +165,6 @@ impl OidcService {
(auth_data, oidc_state) (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( pub async fn resolve_callback(
&self, &self,
code: AuthorizationCode, code: AuthorizationCode,
@@ -232,7 +202,6 @@ impl OidcService {
.claims(&id_token_verifier, &oidc_nonce) .claims(&id_token_verifier, &oidc_nonce)
.map_err(|e| OidcError::IdTokenVerification(e.to_string()))?; .map_err(|e| OidcError::IdTokenVerification(e.to_string()))?;
// Verify access token hash if present
if let Some(expected_hash) = claims.access_token_hash() { if let Some(expected_hash) = claims.access_token_hash() {
let actual_hash = AccessTokenHash::from_token( let actual_hash = AccessTokenHash::from_token(
token_response.access_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() { let email = if let Some(email) = claims.email() {
Some(email.as_str().to_string()) Some(email.as_str().to_string())
} else { } else {

View File

@@ -1,10 +1,6 @@
//! Password hashing adapter using the `password-auth` crate.
use domain::errors::DomainResult; use domain::errors::DomainResult;
use domain::ports::AuthService; use domain::ports::AuthService;
/// Concrete `AuthService` implementation backed by `password-auth`
/// (Argon2id by default).
pub struct PasswordAuthService; pub struct PasswordAuthService;
impl AuthService for PasswordAuthService { impl AuthService for PasswordAuthService {

View File

@@ -26,7 +26,7 @@ impl ChannelEventBus {
#[async_trait] #[async_trait]
impl EventPublisher for ChannelEventBus { impl EventPublisher for ChannelEventBus {
async fn publish(&self, event: DomainEvent) -> DomainResult<()> { async fn publish(&self, event: DomainEvent) -> DomainResult<()> {
let _ = self.tx.send(event); // Ok to drop if no receivers let _ = self.tx.send(event);
Ok(()) Ok(())
} }
} }
@@ -34,9 +34,6 @@ impl EventPublisher for ChannelEventBus {
#[async_trait] #[async_trait]
impl EventConsumer for ChannelEventBus { impl EventConsumer for ChannelEventBus {
async fn recv(&self) -> DomainResult<DomainEvent> { async fn recv(&self) -> DomainResult<DomainEvent> {
// 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(); let mut rx = self.tx.subscribe();
rx.recv() rx.recv()
.await .await

View File

@@ -1,10 +1,6 @@
/// Connection details for a single Jellyfin instance.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct JellyfinConfig { pub struct JellyfinConfig {
/// e.g. `"http://192.168.1.10:8096"` -- no trailing slash.
pub base_url: String, pub base_url: String,
/// Jellyfin API key (Settings -> API Keys).
pub api_key: String, pub api_key: String,
/// The Jellyfin user ID used for library browsing.
pub user_id: String, pub user_id: String,
} }

View File

@@ -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 config;
mod mapping; mod mapping;
mod models; mod models;

View File

@@ -2,11 +2,8 @@ use domain::{ContentType, MediaItem, MediaItemId};
use crate::models::JellyfinItem; 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; 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<MediaItem> { pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
let content_type = match item.item_type.as_str() { let content_type = match item.item_type.as_str() {
"Movie" => ContentType::Movie, "Movie" => ContentType::Movie,
@@ -31,7 +28,7 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
item.series_name, item.series_name,
item.parent_index_number, item.parent_index_number,
item.index_number, item.index_number,
None, // thumbnail_url None,
None, // collection_id None,
)) ))
} }

View File

@@ -1,10 +1,6 @@
use domain::ContentType; use domain::ContentType;
use serde::Deserialize; use serde::Deserialize;
// ============================================================================
// Jellyfin API response types
// ============================================================================
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct JellyfinItemsResponse { pub(crate) struct JellyfinItemsResponse {
#[serde(rename = "Items")] #[serde(rename = "Items")]
@@ -29,19 +25,14 @@ pub(crate) struct JellyfinItem {
pub production_year: Option<u16>, pub production_year: Option<u16>,
#[serde(rename = "Tags")] #[serde(rename = "Tags")]
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// TV show name (episodes only).
#[serde(rename = "SeriesName")] #[serde(rename = "SeriesName")]
pub series_name: Option<String>, pub series_name: Option<String>,
/// Season number (episodes only).
#[serde(rename = "ParentIndexNumber")] #[serde(rename = "ParentIndexNumber")]
pub parent_index_number: Option<u32>, pub parent_index_number: Option<u32>,
/// Episode number within the season (episodes only).
#[serde(rename = "IndexNumber")] #[serde(rename = "IndexNumber")]
pub index_number: Option<u32>, pub index_number: Option<u32>,
/// Collection type for virtual library folders (e.g. "movies", "tvshows").
#[serde(rename = "CollectionType")] #[serde(rename = "CollectionType")]
pub collection_type: Option<String>, pub collection_type: Option<String>,
/// Total number of child items (used for Series to count episodes).
#[serde(rename = "RecursiveItemCount")] #[serde(rename = "RecursiveItemCount")]
pub recursive_item_count: Option<u32>, pub recursive_item_count: Option<u32>,
} }
@@ -64,7 +55,6 @@ pub(crate) fn jellyfin_item_type(ct: &ContentType) -> &'static str {
match ct { match ct {
ContentType::Movie => "Movie", ContentType::Movie => "Movie",
ContentType::Episode => "Episode", ContentType::Episode => "Episode",
// Jellyfin has no native "Short" type; short films are filed as Movies.
ContentType::Short => "Movie", ContentType::Short => "Movie",
} }
} }

View File

@@ -12,6 +12,8 @@ use crate::models::{
jellyfin_item_type, JellyfinItemsResponse, JellyfinPlaybackInfoResponse, jellyfin_item_type, JellyfinItemsResponse, JellyfinPlaybackInfoResponse,
}; };
const FALLBACK_HLS_BITRATE: u32 = 8_000_000;
pub struct JellyfinMediaProvider { pub struct JellyfinMediaProvider {
client: reqwest::Client, client: reqwest::Client,
config: JellyfinConfig, 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( async fn fetch_items_for_series(
&self, &self,
filter: &MediaFilter, filter: &MediaFilter,
@@ -72,19 +73,13 @@ impl JellyfinMediaProvider {
} }
if let Some(name) = series_name { 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())); 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(("SortBy", "ParentIndexNumber,IndexNumber".into()));
params.push(("SortOrder", "Ascending".into())); params.push(("SortOrder", "Ascending".into()));
// Prevent Jellyfin from returning Season/Series container items.
if filter.content_type.is_none() { if filter.content_type.is_none() {
params.push(("IncludeItemTypes", "Episode".into())); params.push(("IncludeItemTypes", "Episode".into()));
} }
} else { } else {
// No series filter -- scope to the collection (library) if one is set.
if let Some(parent_id) = filter.collections.first() { if let Some(parent_id) = filter.collections.first() {
params.push(("ParentId", parent_id.clone())); params.push(("ParentId", parent_id.clone()));
} }
@@ -116,9 +111,8 @@ impl JellyfinMediaProvider {
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}")) DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
})?; })?;
// Jellyfin's SeriesName query param is not a strict filter -- it can // WHY: Jellyfin's SeriesName query param is a fuzzy match that can return
// bleed items from other shows. Post-filter in Rust to guarantee that // items from other shows; post-filter to guarantee correctness.
// only the requested series is returned.
let items = body.items.into_iter().filter_map(map_jellyfin_item); let items = body.items.into_iter().filter_map(map_jellyfin_item);
let items: Vec<MediaItem> = if let Some(name) = series_name { let items: Vec<MediaItem> = if let Some(name) = series_name {
items 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<Vec<MediaItem>> { async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
match filter.series_names.len() { match filter.series_names.len() {
0 | 1 => { 0 | 1 => {
@@ -175,7 +164,6 @@ impl IMediaProvider for JellyfinMediaProvider {
self.fetch_items_for_series(filter, series).await self.fetch_items_for_series(filter, series).await
} }
_ => { _ => {
// Fetch each series independently, then interleave round-robin.
let mut per_series: Vec<Vec<MediaItem>> = Vec::new(); let mut per_series: Vec<Vec<MediaItem>> = Vec::new();
for series_name in &filter.series_names { for series_name in &filter.series_names {
let items = self 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<Option<MediaItem>> { async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
let url = format!( let url = format!(
"{}/Users/{}/Items", "{}/Users/{}/Items",
@@ -231,7 +218,6 @@ impl IMediaProvider for JellyfinMediaProvider {
Ok(body.items.into_iter().next().and_then(map_jellyfin_item)) 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<Vec<Collection>> { async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
let url = format!( let url = format!(
"{}/Users/{}/Views", "{}/Users/{}/Views",
@@ -270,7 +256,6 @@ impl IMediaProvider for JellyfinMediaProvider {
.collect()) .collect())
} }
/// List all Series items, optionally scoped to a collection (ParentId).
async fn list_series(&self, collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>> { async fn list_series(&self, collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>> {
let url = format!( let url = format!(
"{}/Users/{}/Items", "{}/Users/{}/Items",
@@ -327,7 +312,6 @@ impl IMediaProvider for JellyfinMediaProvider {
.collect()) .collect())
} }
/// List available genres from the Jellyfin `/Genres` endpoint.
async fn list_genres(&self, content_type: Option<&ContentType>) -> DomainResult<Vec<String>> { async fn list_genres(&self, content_type: Option<&ContentType>) -> DomainResult<Vec<String>> {
let url = format!("{}/Genres", self.config.base_url); 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, FALLBACK_HLS_BITRATE))
Ok(self.hls_url(item_id, 8_000_000))
} }
StreamQuality::Transcode(bps) => Ok(self.hls_url(item_id, *bps)), StreamQuality::Transcode(bps) => Ok(self.hls_url(item_id, *bps)),
} }

View File

@@ -1,13 +1,8 @@
use std::path::PathBuf; use std::path::PathBuf;
/// Configuration for the local files media provider.
pub struct LocalFilesConfig { pub struct LocalFilesConfig {
/// Root directory containing video files. All files are served relative to this.
pub root_dir: PathBuf, pub root_dir: PathBuf,
/// Public base URL of this API server, used to build stream URLs.
pub base_url: String, pub base_url: String,
/// Directory for FFmpeg HLS transcode cache. `None` disables transcoding.
pub transcode_dir: Option<PathBuf>, pub transcode_dir: Option<PathBuf>,
/// How long (hours) to keep transcode cache entries. Passed to TranscodeManager.
pub cleanup_ttl_hours: u32, pub cleanup_ttl_hours: u32,
} }

View File

@@ -11,7 +11,6 @@ use domain::MediaItemId;
use crate::config::LocalFilesConfig; use crate::config::LocalFilesConfig;
use crate::scanner::{scan_dir, LocalFileItem}; 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 { pub fn encode_id(rel_path: &str) -> MediaItemId {
use base64::Engine as _; use base64::Engine as _;
MediaItemId::new( 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<String> { pub fn decode_id(id: &MediaItemId) -> Option<String> {
use base64::Engine as _; use base64::Engine as _;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
@@ -28,11 +26,6 @@ pub fn decode_id(id: &MediaItemId) -> Option<String> {
String::from_utf8(bytes).ok() 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 { pub struct LocalIndex {
items: Arc<RwLock<HashMap<MediaItemId, LocalFileItem>>>, items: Arc<RwLock<HashMap<MediaItemId, LocalFileItem>>>,
pub root_dir: PathBuf, pub root_dir: PathBuf,
@@ -41,7 +34,6 @@ pub struct LocalIndex {
} }
impl LocalIndex { impl LocalIndex {
/// Create the index, immediately loading persisted entries from SQLite.
pub async fn new( pub async fn new(
config: &LocalFilesConfig, config: &LocalFilesConfig,
pool: sqlx::SqlitePool, pool: sqlx::SqlitePool,
@@ -57,7 +49,6 @@ impl LocalIndex {
idx idx
} }
/// Load previously scanned items from SQLite (instant on startup).
async fn load_from_db(&self) { async fn load_from_db(&self) {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { 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 { pub async fn rescan(&self) -> u32 {
info!( info!(
"Local files [{}]: scanning {:?}", "Local files [{}]: scanning {:?}",
@@ -119,7 +106,6 @@ impl LocalIndex {
let new_items = scan_dir(&self.root_dir).await; let new_items = scan_dir(&self.root_dir).await;
let count = new_items.len() as u32; let count = new_items.len() as u32;
// Swap in-memory map.
{ {
let mut map = self.items.write().await; let mut map = self.items.write().await;
map.clear(); map.clear();
@@ -129,7 +115,6 @@ impl LocalIndex {
} }
} }
// Persist to SQLite.
if let Err(e) = self.save_to_db(&new_items).await { if let Err(e) = self.save_to_db(&new_items).await {
error!("Failed to persist local files index: {}", e); 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> { 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?; let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM local_files_index WHERE provider_id = ?") sqlx::query("DELETE FROM local_files_index WHERE provider_id = ?")
@@ -189,7 +173,6 @@ impl LocalIndex {
.collect() .collect()
} }
/// Return unique top-level directories as collection names.
pub async fn collections(&self) -> Vec<String> { pub async fn collections(&self) -> Vec<String> {
let map = self.items.read().await; let map = self.items.read().await;
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();

View File

@@ -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 config;
pub mod index; pub mod index;
pub mod provider; pub mod provider;
@@ -17,7 +11,6 @@ pub use transcoder::TranscodeManager;
use std::sync::Arc; use std::sync::Arc;
/// Bundle of all local-files components, constructed once at startup.
pub struct LocalFilesBundle { pub struct LocalFilesBundle {
pub provider: LocalFilesProvider, pub provider: LocalFilesProvider,
pub local_index: Arc<LocalIndex>, pub local_index: Arc<LocalIndex>,
@@ -25,10 +18,6 @@ pub struct LocalFilesBundle {
} }
impl 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( pub async fn build(
config: LocalFilesConfig, config: LocalFilesConfig,
pool: sqlx::SqlitePool, pool: sqlx::SqlitePool,

View File

@@ -17,7 +17,8 @@ pub struct LocalFilesProvider {
transcode_manager: Option<Arc<TranscodeManager>>, transcode_manager: Option<Arc<TranscodeManager>>,
} }
const SHORT_DURATION_SECS: u32 = 1200; // 20 minutes const SHORT_DURATION_SECS: u32 = 1200;
const DECADE_SPAN: u16 = 9;
impl LocalFilesProvider { impl LocalFilesProvider {
pub fn new( pub fn new(
@@ -44,15 +45,15 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem {
item.title.clone(), item.title.clone(),
content_type, content_type,
item.duration_secs, item.duration_secs,
None, // description None,
vec![], // genres vec![],
item.year, item.year,
item.tags.clone(), item.tags.clone(),
None, // series_name None,
None, // season_number None,
None, // episode_number None,
None, // thumbnail_url None,
None, // collection_id None,
) )
} }
@@ -82,26 +83,23 @@ impl IMediaProvider for LocalFilesProvider {
let results = all let results = all
.into_iter() .into_iter()
.filter_map(|(id, item)| { .filter_map(|(id, item)| {
// content_type: derive heuristically, then filter
let content_type = if item.duration_secs < SHORT_DURATION_SECS { let content_type = if item.duration_secs < SHORT_DURATION_SECS {
ContentType::Short ContentType::Short
} else { } else {
ContentType::Movie ContentType::Movie
}; };
if let Some(ref ct) = filter.content_type { if let Some(ref ct) = filter.content_type
if &content_type != ct { && &content_type != ct
{
return None; return None;
} }
}
// collections: match against top_dir
if !filter.collections.is_empty() if !filter.collections.is_empty()
&& !filter.collections.contains(&item.top_dir) && !filter.collections.contains(&item.top_dir)
{ {
return None; return None;
} }
// tags: OR -- item must have at least one matching tag
if !filter.tags.is_empty() { if !filter.tags.is_empty() {
let has = filter let has = filter
.tags .tags
@@ -112,32 +110,29 @@ impl IMediaProvider for LocalFilesProvider {
} }
} }
// decade: year in [decade, decade+9]
if let Some(decade) = filter.decade { if let Some(decade) = filter.decade {
match item.year { match item.year {
Some(y) if y >= decade && y <= decade + 9 => {} Some(y) if y >= decade && y <= decade + DECADE_SPAN => {}
_ => return None, _ => return None,
} }
} }
// duration bounds if let Some(min) = filter.min_duration_secs
if let Some(min) = filter.min_duration_secs { && item.duration_secs < min
if item.duration_secs < min { {
return None; return None;
} }
} if let Some(max) = filter.max_duration_secs
if let Some(max) = filter.max_duration_secs { && item.duration_secs > max
if item.duration_secs > max { {
return None; return None;
} }
}
// search_term: case-insensitive substring in title if let Some(ref q) = filter.search_term
if let Some(ref q) = filter.search_term { && !item.title.to_lowercase().contains(&q.to_lowercase())
if !item.title.to_lowercase().contains(&q.to_lowercase()) { {
return None; return None;
} }
}
Some(to_media_item(id, &item)) 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<String> { pub fn decode_stream_id(encoded: &str) -> Option<String> {
decode_id(&MediaItemId::new(encoded)) decode_id(&MediaItemId::new(encoded))
} }

View File

@@ -2,25 +2,21 @@ use std::path::Path;
use tokio::process::Command; use tokio::process::Command;
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mkv", "avi", "mov", "webm", "m4v"]; 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)] #[derive(Debug, Clone)]
pub struct LocalFileItem { pub struct LocalFileItem {
/// Relative path from root, with forward slashes (used as the stable ID source).
pub rel_path: String, pub rel_path: String,
pub title: String, pub title: String,
pub duration_secs: u32, pub duration_secs: u32,
pub year: Option<u16>, pub year: Option<u16>,
/// Ancestor directory names between root and file (excluding root itself).
pub tags: Vec<String>, pub tags: Vec<String>,
/// First path component under root (used as collection id/name).
pub top_dir: String, 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<LocalFileItem> { pub async fn scan_dir(root: &Path) -> Vec<LocalFileItem> {
let mut items = Vec::new(); let mut items = Vec::new();
@@ -34,33 +30,29 @@ pub async fn scan_dir(root: &Path) -> Vec<LocalFileItem> {
.extension() .extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
.map(|e| e.to_lowercase()); .map(|e| e.to_lowercase());
let ext = match ext { match ext {
Some(ref e) if VIDEO_EXTENSIONS.contains(&e.as_str()) => e.clone(), Some(ref e) if VIDEO_EXTENSIONS.contains(&e.as_str()) => {}
_ => continue, _ => continue,
}; };
let _ = ext; // extension validated, not needed further
let rel = match path.strip_prefix(root) { let rel = match path.strip_prefix(root) {
Ok(r) => r, Ok(r) => r,
Err(_) => continue, Err(_) => continue,
}; };
// Normalise to forward-slash string for cross-platform stability.
let rel_path: String = rel let rel_path: String = rel
.components() .components()
.map(|c| c.as_os_str().to_string_lossy().into_owned()) .map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("/"); .join("/");
// Top-level directory under root.
let top_dir = rel let top_dir = rel
.components() .components()
.next() .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()) .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 let stem = path
.file_stem() .file_stem()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
@@ -69,7 +61,6 @@ pub async fn scan_dir(root: &Path) -> Vec<LocalFileItem> {
let title = stem.replace(['_', '-', '.'], " "); let title = stem.replace(['_', '-', '.'], " ");
let title = title.trim().to_string(); 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!( let search_str = format!(
"{} {}", "{} {}",
stem, stem,
@@ -79,7 +70,6 @@ pub async fn scan_dir(root: &Path) -> Vec<LocalFileItem> {
); );
let year = extract_year(&search_str); let year = extract_year(&search_str);
// Tags: ancestor directory components between root and the file.
let tags: Vec<String> = rel let tags: Vec<String> = rel
.parent() .parent()
.into_iter() .into_iter()
@@ -103,27 +93,23 @@ pub async fn scan_dir(root: &Path) -> Vec<LocalFileItem> {
items items
} }
/// Extract the first plausible 4-digit year (1900-2099) from `s`.
fn extract_year(s: &str) -> Option<u16> { fn extract_year(s: &str) -> Option<u16> {
let chars: Vec<char> = s.chars().collect(); let chars: Vec<char> = s.chars().collect();
let n = chars.len(); let n = chars.len();
if n < 4 { if n < YEAR_DIGITS {
return None; return None;
} }
for i in 0..=(n - 4) { for i in 0..=(n - YEAR_DIGITS) {
// All four chars must be ASCII digits. if !chars[i..i + YEAR_DIGITS].iter().all(|c| c.is_ascii_digit()) {
if !chars[i..i + 4].iter().all(|c| c.is_ascii_digit()) {
continue; continue;
} }
// Parse and range-check. let s4: String = chars[i..i + YEAR_DIGITS].iter().collect();
let s4: String = chars[i..i + 4].iter().collect();
let num: u16 = s4.parse().ok()?; let num: u16 = s4.parse().ok()?;
if !(1900..=2099).contains(&num) { if !(MIN_YEAR..=MAX_YEAR).contains(&num) {
continue; continue;
} }
// Word-boundary: char before and after must not be digits.
let before_ok = i == 0 || !chars[i - 1].is_ascii_digit(); 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 { if before_ok && after_ok {
return Some(num); return Some(num);
} }
@@ -131,7 +117,6 @@ fn extract_year(s: &str) -> Option<u16> {
None None
} }
/// Run ffprobe to get the duration of `path` in whole seconds.
async fn get_duration(path: &Path) -> Option<u32> { async fn get_duration(path: &Path) -> Option<u32> {
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct Fmt { struct Fmt {
@@ -169,8 +154,8 @@ mod tests {
assert_eq!(extract_year("Movie 2024 HD"), Some(2024)); assert_eq!(extract_year("Movie 2024 HD"), Some(2024));
assert_eq!(extract_year("1999_classic"), Some(1999)); assert_eq!(extract_year("1999_classic"), Some(1999));
assert_eq!(extract_year("no year here"), None); assert_eq!(extract_year("no year here"), None);
assert_eq!(extract_year("12345"), None); // 5-digit number assert_eq!(extract_year("12345"), None);
assert_eq!(extract_year("2100"), None); // out of range assert_eq!(extract_year("2100"), None);
assert_eq!(extract_year("1900"), Some(1900)); assert_eq!(extract_year("1900"), Some(1900));
assert_eq!(extract_year("2099"), Some(2099)); assert_eq!(extract_year("2099"), Some(2099));
} }

View File

@@ -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::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{ use std::sync::{
@@ -19,9 +11,13 @@ use tracing::{error, info, warn};
use domain::{DomainError, DomainResult}; use domain::{DomainError, DomainResult};
// ============================================================================ const SECS_PER_HOUR: u64 = 3600;
// Types 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)] #[derive(Clone, Debug)]
pub enum TranscodeStatus { pub enum TranscodeStatus {
@@ -29,10 +25,6 @@ pub enum TranscodeStatus {
Failed(String), Failed(String),
} }
// ============================================================================
// Manager
// ============================================================================
pub struct TranscodeManager { pub struct TranscodeManager {
pub transcode_dir: PathBuf, pub transcode_dir: PathBuf,
cleanup_ttl_hours: Arc<AtomicU32>, cleanup_ttl_hours: Arc<AtomicU32>,
@@ -46,10 +38,10 @@ impl TranscodeManager {
cleanup_ttl_hours: Arc::new(AtomicU32::new(cleanup_ttl_hours)), cleanup_ttl_hours: Arc::new(AtomicU32::new(cleanup_ttl_hours)),
active: Arc::new(Mutex::new(HashMap::new())), 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); let weak = Arc::downgrade(&mgr);
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(3600)); let mut interval = tokio::time::interval(CLEANUP_INTERVAL);
loop { loop {
interval.tick().await; interval.tick().await;
match weak.upgrade() { match weak.upgrade() {
@@ -61,7 +53,6 @@ impl TranscodeManager {
mgr mgr
} }
/// Update the cleanup TTL (also persisted to DB by the route handler).
pub fn set_cleanup_ttl(&self, hours: u32) { pub fn set_cleanup_ttl(&self, hours: u32) {
self.cleanup_ttl_hours.store(hours, Ordering::Relaxed); self.cleanup_ttl_hours.store(hours, Ordering::Relaxed);
} }
@@ -70,8 +61,6 @@ impl TranscodeManager {
self.cleanup_ttl_hours.load(Ordering::Relaxed) 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<()> { pub async fn ensure_transcoded(&self, item_id: &str, src_path: &Path) -> DomainResult<()> {
let out_dir = self.transcode_dir.join(item_id); let out_dir = self.transcode_dir.join(item_id);
let playlist = out_dir.join("playlist.m3u8"); let playlist = out_dir.join("playlist.m3u8");
@@ -111,7 +100,6 @@ impl TranscodeManager {
} }
}; };
// Wait for Ready or Failed.
loop { loop {
rx.changed().await.map_err(|_| { rx.changed().await.map_err(|_| {
DomainError::InfrastructureError( DomainError::InfrastructureError(
@@ -129,7 +117,6 @@ impl TranscodeManager {
} }
} }
/// Remove all cached transcode directories.
pub async fn clear_cache(&self) -> std::io::Result<()> { pub async fn clear_cache(&self) -> std::io::Result<()> {
if self.transcode_dir.exists() { if self.transcode_dir.exists() {
tokio::fs::remove_dir_all(&self.transcode_dir).await?; tokio::fs::remove_dir_all(&self.transcode_dir).await?;
@@ -137,7 +124,6 @@ impl TranscodeManager {
tokio::fs::create_dir_all(&self.transcode_dir).await 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) { pub async fn cache_stats(&self) -> (u64, usize) {
let mut total_bytes = 0u64; let mut total_bytes = 0u64;
let mut item_count = 0usize; let mut item_count = 0usize;
@@ -162,7 +148,7 @@ impl TranscodeManager {
async fn run_cleanup(&self) { async fn run_cleanup(&self) {
let ttl_hours = self.cleanup_ttl_hours.load(Ordering::Relaxed) as u64; 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 now = std::time::SystemTime::now();
let Ok(mut entries) = tokio::fs::read_dir(&self.transcode_dir).await else { let Ok(mut entries) = tokio::fs::read_dir(&self.transcode_dir).await else {
@@ -174,23 +160,17 @@ impl TranscodeManager {
continue; continue;
} }
let playlist = path.join("playlist.m3u8"); let playlist = path.join("playlist.m3u8");
if let Ok(meta) = tokio::fs::metadata(&playlist).await { if let Ok(meta) = tokio::fs::metadata(&playlist).await
if let Ok(modified) = meta.modified() { && let Ok(modified) = meta.modified()
if let Ok(age) = now.duration_since(modified) { && let Ok(age) = now.duration_since(modified)
if age > ttl { && age > ttl
{
warn!("cleanup: removing stale transcode {:?}", path); warn!("cleanup: removing stale transcode {:?}", path);
let _ = tokio::fs::remove_dir_all(&path).await; let _ = tokio::fs::remove_dir_all(&path).await;
} }
} }
} }
} }
}
}
}
// ============================================================================
// FFmpeg helper
// ============================================================================
async fn do_transcode(src: &Path, out_dir: &Path, playlist: &Path) -> TranscodeStatus { async fn do_transcode(src: &Path, out_dir: &Path, playlist: &Path) -> TranscodeStatus {
let segment_pattern = out_dir.join("seg%05d.ts"); 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", "-preset",
"fast", "fast",
"-crf", "-crf",
"23", FFMPEG_CRF,
"-c:a", "-c:a",
"aac", "aac",
"-b:a", "-b:a",
"128k", FFMPEG_AUDIO_BITRATE,
"-hls_time", "-hls_time",
"6", HLS_SEGMENT_SECS,
"-hls_list_size", "-hls_list_size",
"0", "0",
"-hls_flags", "-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)), 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 start = Instant::now();
let timeout = Duration::from_secs(60); let timeout = TRANSCODE_TIMEOUT;
loop { loop {
if playlist.exists() { if playlist.exists() {
return TranscodeStatus::Ready; 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()), Err(e) => return TranscodeStatus::Failed(e.to_string()),
Ok(None) => {} Ok(None) => {}
} }
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(TRANSCODE_POLL_INTERVAL).await;
} }
} }

View File

@@ -1,5 +1,3 @@
//! PostgreSQL adapter for activity log (ActivityLogCommand + ActivityLogQuery).
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use sqlx::PgPool; use sqlx::PgPool;
@@ -62,7 +60,6 @@ impl ActivityLogQuery for PgActivityLog {
let mut events = Vec::with_capacity(rows.len()); let mut events = Vec::with_capacity(rows.len());
for (id_str, ts_str, event_type, detail, channel_id_str) in rows { 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 { let Ok(id) = parse_uuid(&id_str, "activity id") else {
continue; continue;
}; };

View File

@@ -1,5 +1,3 @@
//! PostgreSQL adapter for channel persistence (ChannelCommand + ChannelQuery).
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
@@ -7,7 +5,7 @@ use uuid::Uuid;
use adapter_common::{ use adapter_common::{
map_sqlx_error, parse_dt, parse_enum_or_default, parse_recycle_policy, parse_schedule_config, 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::{ use domain::{
ports::channel::{ChannelCommand, ChannelQuery}, 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"; 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)] #[derive(Debug, sqlx::FromRow)]
@@ -85,15 +81,6 @@ impl ChannelRow {
} }
} }
// -- Helpers ------------------------------------------------------------------
fn serialize_enum_as_string<T: serde::Serialize>(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( fn map_snapshot_row(
row: &sqlx::postgres::PgRow, row: &sqlx::postgres::PgRow,
channel_id: ChannelId, channel_id: ChannelId,
@@ -117,8 +104,6 @@ fn map_snapshot_row(
)) ))
} }
// -- Command -----------------------------------------------------------------
#[async_trait] #[async_trait]
impl ChannelCommand for PgChannelRepository { impl ChannelCommand for PgChannelRepository {
async fn save(&self, channel: &Channel) -> DomainResult<()> { async fn save(&self, channel: &Channel) -> DomainResult<()> {
@@ -261,8 +246,6 @@ impl ChannelCommand for PgChannelRepository {
} }
} }
// -- Query -------------------------------------------------------------------
#[async_trait] #[async_trait]
impl ChannelQuery for PgChannelRepository { impl ChannelQuery for PgChannelRepository {
async fn find_by_id(&self, id: ChannelId) -> DomainResult<Option<Channel>> { async fn find_by_id(&self, id: ChannelId) -> DomainResult<Option<Channel>> {

View File

@@ -1,6 +1,3 @@
//! PostgreSQL adapter crate — implements all CQRS-split repository port traits
//! for PostgreSQL via sqlx.
pub mod activity; pub mod activity;
pub mod channel; pub mod channel;
pub mod library; pub mod library;

View File

@@ -1,10 +1,7 @@
//! PostgreSQL adapter for library persistence (LibraryCommand + LibraryQuery).
use std::collections::HashSet;
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::PgPool; use sqlx::PgPool;
use adapter_common::{content_type_str, parse_content_type, parse_genres_blob};
use domain::{ use domain::{
ports::library::{LibraryCommand, LibraryQuery}, ports::library::{LibraryCommand, LibraryQuery},
ContentType, DomainError, DomainResult, LibraryCollection, LibraryItem, LibrarySearchFilter, 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)] #[derive(sqlx::FromRow)]
struct LibraryItemRow { struct LibraryItemRow {
id: String, id: String,
@@ -113,8 +90,6 @@ struct SeasonSummaryRow {
thumbnail_url: Option<String>, thumbnail_url: Option<String>,
} }
// -- Command -----------------------------------------------------------------
#[async_trait] #[async_trait]
impl LibraryCommand for PgLibraryRepository { impl LibraryCommand for PgLibraryRepository {
async fn upsert_items(&self, _provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()> { async fn upsert_items(&self, _provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()> {
@@ -223,8 +198,6 @@ impl LibraryCommand for PgLibraryRepository {
} }
} }
// -- Query -------------------------------------------------------------------
#[async_trait] #[async_trait]
impl LibraryQuery for PgLibraryRepository { impl LibraryQuery for PgLibraryRepository {
async fn search( async fn search(
@@ -505,26 +478,7 @@ impl LibraryQuery for PgLibraryRepository {
Ok(rows Ok(rows
.into_iter() .into_iter()
.map(|r| { .map(|r| {
let genres: Vec<String> = r let genres = parse_genres_blob(&r.genres_blob);
.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::<Vec<_>>()
})
.collect::<HashSet<_>>()
.into_iter()
.collect();
ShowSummary::from_persistence( ShowSummary::from_persistence(
r.series_name, r.series_name,
r.episode_count as u32, r.episode_count as u32,

View File

@@ -1,5 +1,3 @@
//! PostgreSQL adapter for provider config (ProviderConfigCommand + ProviderConfigQuery).
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::PgPool; use sqlx::PgPool;

View File

@@ -1,5 +1,3 @@
//! PostgreSQL adapter for schedule persistence (ScheduleCommand + ScheduleQuery).
use std::collections::HashMap; use std::collections::HashMap;
use async_trait::async_trait; use async_trait::async_trait;
@@ -22,8 +20,6 @@ impl PgScheduleRepository {
} }
} }
// -- Row types ---------------------------------------------------------------
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
struct ScheduleRow { struct ScheduleRow {
id: String, id: String,
@@ -36,8 +32,8 @@ struct ScheduleRow {
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
struct SlotRow { struct SlotRow {
id: String, id: String,
#[allow(dead_code)] #[sqlx(rename = "schedule_id")]
schedule_id: String, _schedule_id: String,
start_at: String, start_at: String,
end_at: String, end_at: String,
item: String, item: String,
@@ -59,8 +55,6 @@ struct PlaybackRecordRow {
generation: i64, generation: i64,
} }
// -- Mapping -----------------------------------------------------------------
fn map_slot_row(row: SlotRow) -> DomainResult<ScheduledSlot> { fn map_slot_row(row: SlotRow) -> DomainResult<ScheduledSlot> {
let id = SlotId::from_uuid(parse_uuid(&row.id, "slot id")?); 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")?); 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<PlaybackRecord> {
)) ))
} }
// -- Internal helpers --------------------------------------------------------
impl PgScheduleRepository { impl PgScheduleRepository {
async fn fetch_slots(&self, schedule_id: &str) -> DomainResult<Vec<SlotRow>> { async fn fetch_slots(&self, schedule_id: &str) -> DomainResult<Vec<SlotRow>> {
sqlx::query_as( sqlx::query_as(
@@ -118,8 +110,6 @@ impl PgScheduleRepository {
} }
} }
// -- Command -----------------------------------------------------------------
#[async_trait] #[async_trait]
impl ScheduleCommand for PgScheduleRepository { impl ScheduleCommand for PgScheduleRepository {
async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> { async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> {
@@ -142,7 +132,6 @@ impl ScheduleCommand for PgScheduleRepository {
.await .await
.map_err(map_sqlx_error)?; .map_err(map_sqlx_error)?;
// Delete-then-insert all slots
sqlx::query("DELETE FROM scheduled_slots WHERE schedule_id = $1") sqlx::query("DELETE FROM scheduled_slots WHERE schedule_id = $1")
.bind(schedule.id().value().to_string()) .bind(schedule.id().value().to_string())
.execute(&self.pool) .execute(&self.pool)
@@ -216,8 +205,6 @@ impl ScheduleCommand for PgScheduleRepository {
} }
} }
// -- Query -------------------------------------------------------------------
#[async_trait] #[async_trait]
impl ScheduleQuery for PgScheduleRepository { impl ScheduleQuery for PgScheduleRepository {
async fn find_active( async fn find_active(

View File

@@ -1,5 +1,3 @@
//! PostgreSQL adapter for app settings (AppSettingsRepository).
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::PgPool; use sqlx::PgPool;

View File

@@ -1,5 +1,3 @@
//! PostgreSQL adapter for transcode settings (TranscodeSettingsRepository).
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::PgPool; use sqlx::PgPool;

View File

@@ -1,5 +1,3 @@
//! PostgreSQL adapter for user persistence (UserCommand + UserQuery).
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::PgPool; use sqlx::PgPool;
@@ -19,8 +17,6 @@ impl PgUserRepository {
} }
} }
// -- Row type for query_as --------------------------------------------------
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
struct UserRow { struct UserRow {
id: String, id: String,
@@ -49,8 +45,6 @@ impl UserRow {
} }
} }
// -- Command -----------------------------------------------------------------
#[async_trait] #[async_trait]
impl UserCommand for PgUserRepository { impl UserCommand for PgUserRepository {
async fn save(&self, user: &User) -> DomainResult<()> { async fn save(&self, user: &User) -> DomainResult<()> {
@@ -98,8 +92,6 @@ impl UserCommand for PgUserRepository {
} }
} }
// -- Query -------------------------------------------------------------------
#[async_trait] #[async_trait]
impl UserQuery for PgUserRepository { impl UserQuery for PgUserRepository {
async fn find_by_id(&self, id: UserId) -> DomainResult<Option<User>> { async fn find_by_id(&self, id: UserId) -> DomainResult<Option<User>> {

View File

@@ -1,6 +1,3 @@
//! Wiring function that instantiates all PostgreSQL repositories and returns them
//! as trait-object Arcs.
use std::sync::Arc; use std::sync::Arc;
use sqlx::PgPool; use sqlx::PgPool;
@@ -27,7 +24,6 @@ use crate::{
user::PgUserRepository, user::PgUserRepository,
}; };
/// All PostgreSQL adapter outputs, ready to be injected into the application layer.
pub struct PostgresWireOutput { pub struct PostgresWireOutput {
pub user_command: Arc<dyn UserCommand>, pub user_command: Arc<dyn UserCommand>,
pub user_query: Arc<dyn UserQuery>, pub user_query: Arc<dyn UserQuery>,
@@ -45,10 +41,6 @@ pub struct PostgresWireOutput {
pub transcode_settings: Arc<dyn TranscodeSettingsRepository>, pub transcode_settings: Arc<dyn TranscodeSettingsRepository>,
} }
/// 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 { pub fn wire(pool: PgPool) -> PostgresWireOutput {
let user = Arc::new(PgUserRepository::new(pool.clone())); let user = Arc::new(PgUserRepository::new(pool.clone()));
let channel = Arc::new(PgChannelRepository::new(pool.clone())); let channel = Arc::new(PgChannelRepository::new(pool.clone()));

View File

@@ -1,5 +1,3 @@
//! SQLite adapter for activity log (ActivityLogCommand + ActivityLogQuery).
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -62,7 +60,6 @@ impl ActivityLogQuery for SqliteActivityLog {
let mut events = Vec::with_capacity(rows.len()); let mut events = Vec::with_capacity(rows.len());
for (id_str, ts_str, event_type, detail, channel_id_str) in rows { 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 { let Ok(id) = parse_uuid(&id_str, "activity id") else {
continue; continue;
}; };

View File

@@ -1,5 +1,3 @@
//! SQLite adapter for channel persistence (ChannelCommand + ChannelQuery).
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
@@ -7,7 +5,7 @@ use uuid::Uuid;
use adapter_common::{ use adapter_common::{
map_sqlx_error, parse_dt, parse_enum_or_default, parse_recycle_policy, parse_schedule_config, 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::{ use domain::{
ports::channel::{ChannelCommand, ChannelQuery}, 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"; 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)] #[derive(Debug, sqlx::FromRow)]
@@ -85,15 +81,6 @@ impl ChannelRow {
} }
} }
// -- Helpers ------------------------------------------------------------------
fn serialize_enum_as_string<T: serde::Serialize>(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( fn map_snapshot_row(
row: &sqlx::sqlite::SqliteRow, row: &sqlx::sqlite::SqliteRow,
channel_id: ChannelId, channel_id: ChannelId,
@@ -117,8 +104,6 @@ fn map_snapshot_row(
)) ))
} }
// -- Command -----------------------------------------------------------------
#[async_trait] #[async_trait]
impl ChannelCommand for SqliteChannelRepository { impl ChannelCommand for SqliteChannelRepository {
async fn save(&self, channel: &Channel) -> DomainResult<()> { async fn save(&self, channel: &Channel) -> DomainResult<()> {
@@ -261,8 +246,6 @@ impl ChannelCommand for SqliteChannelRepository {
} }
} }
// -- Query -------------------------------------------------------------------
#[async_trait] #[async_trait]
impl ChannelQuery for SqliteChannelRepository { impl ChannelQuery for SqliteChannelRepository {
async fn find_by_id(&self, id: ChannelId) -> DomainResult<Option<Channel>> { async fn find_by_id(&self, id: ChannelId) -> DomainResult<Option<Channel>> {

View File

@@ -1,6 +1,3 @@
//! SQLite adapter crate — implements all CQRS-split repository port traits
//! for SQLite via sqlx.
pub mod activity; pub mod activity;
pub mod channel; pub mod channel;
pub mod library; pub mod library;

View File

@@ -1,10 +1,7 @@
//! SQLite adapter for library persistence (LibraryCommand + LibraryQuery).
use std::collections::HashSet;
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use adapter_common::{content_type_str, parse_content_type, parse_genres_blob};
use domain::{ use domain::{
ports::library::{LibraryCommand, LibraryQuery}, ports::library::{LibraryCommand, LibraryQuery},
ContentType, DomainError, DomainResult, LibraryCollection, LibraryItem, LibrarySearchFilter, 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)] #[derive(sqlx::FromRow)]
struct LibraryItemRow { struct LibraryItemRow {
id: String, id: String,
@@ -113,8 +90,6 @@ struct SeasonSummaryRow {
thumbnail_url: Option<String>, thumbnail_url: Option<String>,
} }
// -- Command -----------------------------------------------------------------
#[async_trait] #[async_trait]
impl LibraryCommand for SqliteLibraryRepository { impl LibraryCommand for SqliteLibraryRepository {
async fn upsert_items(&self, _provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()> { async fn upsert_items(&self, _provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()> {
@@ -206,8 +181,6 @@ impl LibraryCommand for SqliteLibraryRepository {
} }
} }
// -- Query -------------------------------------------------------------------
#[async_trait] #[async_trait]
impl LibraryQuery for SqliteLibraryRepository { impl LibraryQuery for SqliteLibraryRepository {
async fn search( async fn search(
@@ -481,32 +454,12 @@ impl LibraryQuery for SqliteLibraryRepository {
Ok(rows Ok(rows
.into_iter() .into_iter()
.map(|r| { .map(|r| {
let genres: Vec<String> = 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::<Vec<_>>()
})
.collect::<HashSet<_>>()
.into_iter()
.collect();
ShowSummary::from_persistence( ShowSummary::from_persistence(
r.series_name, r.series_name,
r.episode_count as u32, r.episode_count as u32,
r.season_count as u32, r.season_count as u32,
r.thumbnail_url, r.thumbnail_url,
genres, parse_genres_blob(&r.genres_blob),
) )
}) })
.collect()) .collect())

View File

@@ -1,5 +1,3 @@
//! SQLite adapter for provider config (ProviderConfigCommand + ProviderConfigQuery).
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::SqlitePool; use sqlx::SqlitePool;

View File

@@ -1,5 +1,3 @@
//! SQLite adapter for schedule persistence (ScheduleCommand + ScheduleQuery).
use std::collections::HashMap; use std::collections::HashMap;
use async_trait::async_trait; use async_trait::async_trait;
@@ -22,8 +20,6 @@ impl SqliteScheduleRepository {
} }
} }
// -- Row types ---------------------------------------------------------------
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
struct ScheduleRow { struct ScheduleRow {
id: String, id: String,
@@ -36,8 +32,7 @@ struct ScheduleRow {
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
struct SlotRow { struct SlotRow {
id: String, id: String,
#[allow(dead_code)] _schedule_id: String,
schedule_id: String,
start_at: String, start_at: String,
end_at: String, end_at: String,
item: String, item: String,
@@ -59,8 +54,6 @@ struct PlaybackRecordRow {
generation: i64, generation: i64,
} }
// -- Mapping -----------------------------------------------------------------
fn map_slot_row(row: SlotRow) -> DomainResult<ScheduledSlot> { fn map_slot_row(row: SlotRow) -> DomainResult<ScheduledSlot> {
let id = SlotId::from_uuid(parse_uuid(&row.id, "slot id")?); 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")?); 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<PlaybackRecord> {
)) ))
} }
// -- Internal helpers --------------------------------------------------------
impl SqliteScheduleRepository { impl SqliteScheduleRepository {
async fn fetch_slots(&self, schedule_id: &str) -> DomainResult<Vec<SlotRow>> { async fn fetch_slots(&self, schedule_id: &str) -> DomainResult<Vec<SlotRow>> {
sqlx::query_as( sqlx::query_as(
@@ -118,8 +109,6 @@ impl SqliteScheduleRepository {
} }
} }
// -- Command -----------------------------------------------------------------
#[async_trait] #[async_trait]
impl ScheduleCommand for SqliteScheduleRepository { impl ScheduleCommand for SqliteScheduleRepository {
async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> { async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> {
@@ -142,7 +131,6 @@ impl ScheduleCommand for SqliteScheduleRepository {
.await .await
.map_err(map_sqlx_error)?; .map_err(map_sqlx_error)?;
// Delete-then-insert all slots
sqlx::query("DELETE FROM scheduled_slots WHERE schedule_id = ?") sqlx::query("DELETE FROM scheduled_slots WHERE schedule_id = ?")
.bind(schedule.id().value().to_string()) .bind(schedule.id().value().to_string())
.execute(&self.pool) .execute(&self.pool)
@@ -216,8 +204,6 @@ impl ScheduleCommand for SqliteScheduleRepository {
} }
} }
// -- Query -------------------------------------------------------------------
#[async_trait] #[async_trait]
impl ScheduleQuery for SqliteScheduleRepository { impl ScheduleQuery for SqliteScheduleRepository {
async fn find_active( async fn find_active(

View File

@@ -1,5 +1,3 @@
//! SQLite adapter for app settings (AppSettingsRepository).
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::SqlitePool; use sqlx::SqlitePool;

View File

@@ -1,5 +1,3 @@
//! SQLite adapter for transcode settings (TranscodeSettingsRepository).
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::SqlitePool; use sqlx::SqlitePool;

View File

@@ -1,5 +1,3 @@
//! SQLite adapter for user persistence (UserCommand + UserQuery).
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -19,8 +17,6 @@ impl SqliteUserRepository {
} }
} }
// -- Row type for query_as --------------------------------------------------
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
struct UserRow { struct UserRow {
id: String, id: String,
@@ -49,8 +45,6 @@ impl UserRow {
} }
} }
// -- Command -----------------------------------------------------------------
#[async_trait] #[async_trait]
impl UserCommand for SqliteUserRepository { impl UserCommand for SqliteUserRepository {
async fn save(&self, user: &User) -> DomainResult<()> { async fn save(&self, user: &User) -> DomainResult<()> {
@@ -98,8 +92,6 @@ impl UserCommand for SqliteUserRepository {
} }
} }
// -- Query -------------------------------------------------------------------
#[async_trait] #[async_trait]
impl UserQuery for SqliteUserRepository { impl UserQuery for SqliteUserRepository {
async fn find_by_id(&self, id: UserId) -> DomainResult<Option<User>> { async fn find_by_id(&self, id: UserId) -> DomainResult<Option<User>> {

View File

@@ -1,6 +1,3 @@
//! Wiring function that instantiates all SQLite repositories and returns them
//! as trait-object Arcs.
use std::sync::Arc; use std::sync::Arc;
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -27,7 +24,6 @@ use crate::{
user::SqliteUserRepository, user::SqliteUserRepository,
}; };
/// All SQLite adapter outputs, ready to be injected into the application layer.
pub struct SqliteWireOutput { pub struct SqliteWireOutput {
pub user_command: Arc<dyn UserCommand>, pub user_command: Arc<dyn UserCommand>,
pub user_query: Arc<dyn UserQuery>, pub user_query: Arc<dyn UserQuery>,
@@ -45,10 +41,6 @@ pub struct SqliteWireOutput {
pub transcode_settings: Arc<dyn TranscodeSettingsRepository>, pub transcode_settings: Arc<dyn TranscodeSettingsRepository>,
} }
/// 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 { pub fn wire(pool: SqlitePool) -> SqliteWireOutput {
let user = Arc::new(SqliteUserRepository::new(pool.clone())); let user = Arc::new(SqliteUserRepository::new(pool.clone()));
let channel = Arc::new(SqliteChannelRepository::new(pool.clone())); let channel = Arc::new(SqliteChannelRepository::new(pool.clone()));

View File

@@ -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::env;
use std::path::PathBuf; use std::path::PathBuf;
// --------------------------------------------------------------------------- const DEFAULT_HOST: &str = "0.0.0.0";
// Errors 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)] #[derive(Debug, thiserror::Error)]
pub enum ConfigError { pub enum ConfigError {
#[error("missing required env var: {0}")] #[error("missing required env var: {0}")]
@@ -23,7 +25,6 @@ pub enum ConfigError {
}, },
} }
/// Errors from pool creation or migration.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum DbError { pub enum DbError {
#[error("unsupported database URL scheme: {0}")] #[error("unsupported database URL scheme: {0}")]
@@ -36,11 +37,6 @@ pub enum DbError {
Migrate(#[from] sqlx::migrate::MigrateError), Migrate(#[from] sqlx::migrate::MigrateError),
} }
// ---------------------------------------------------------------------------
// DbPool
// ---------------------------------------------------------------------------
/// Feature-gated database pool — one variant per supported backend.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum DbPool { pub enum DbPool {
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
@@ -51,10 +47,6 @@ pub enum DbPool {
} }
impl 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<Self, DbError> { pub async fn connect(database_url: &str) -> Result<Self, DbError> {
let scheme = database_url let scheme = database_url
.split(':') .split(':')
@@ -77,7 +69,6 @@ impl DbPool {
} }
} }
/// Run the embedded migrations for the detected backend.
pub async fn run_migrations(&self) -> Result<(), DbError> { pub async fn run_migrations(&self) -> Result<(), DbError> {
match self { match self {
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
@@ -88,7 +79,6 @@ impl DbPool {
} }
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
Self::Postgres(_pool) => { Self::Postgres(_pool) => {
// TODO: add postgres migrations directory and enable
tracing::warn!("postgres migrations not yet available"); 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigSource { pub enum ConfigSource {
Env, Env,
Db, Db,
} }
/// Application configuration loaded from environment variables.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
pub config_source: ConfigSource, pub config_source: ConfigSource,
// Core
pub database_url: String, pub database_url: String,
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub log_level: String, pub log_level: String,
pub is_production: bool, pub is_production: bool,
pub base_url: String, pub base_url: String,
// HTTP
pub cors_origins: Vec<String>, pub cors_origins: Vec<String>,
pub cookie_secret: String, pub cookie_secret: String,
pub secure_cookie: bool, pub secure_cookie: bool,
// Connection pool
pub db_max_connections: u32, pub db_max_connections: u32,
pub db_min_connections: u32, pub db_min_connections: u32,
// Auth JWT
pub jwt_secret: Option<String>, pub jwt_secret: Option<String>,
pub jwt_issuer: Option<String>, pub jwt_issuer: Option<String>,
pub jwt_audience: Option<String>, pub jwt_audience: Option<String>,
pub jwt_expiry_hours: u64, pub jwt_expiry_hours: u64,
pub jwt_refresh_expiry_days: u64, pub jwt_refresh_expiry_days: u64,
pub allow_registration: bool, pub allow_registration: bool,
// Auth OIDC
pub oidc_issuer_url: Option<String>, pub oidc_issuer_url: Option<String>,
pub oidc_client_id: Option<String>, pub oidc_client_id: Option<String>,
pub oidc_client_secret: Option<String>, pub oidc_client_secret: Option<String>,
pub oidc_redirect_url: Option<String>, pub oidc_redirect_url: Option<String>,
pub oidc_resource_id: Option<String>, pub oidc_resource_id: Option<String>,
// Jellyfin media provider
pub jellyfin_url: Option<String>, pub jellyfin_url: Option<String>,
pub jellyfin_api_key: Option<String>, pub jellyfin_api_key: Option<String>,
pub jellyfin_user_id: Option<String>, pub jellyfin_user_id: Option<String>,
// Local-files provider
pub local_files_dir: Option<PathBuf>, pub local_files_dir: Option<PathBuf>,
// Transcoding
pub transcode_dir: Option<PathBuf>, pub transcode_dir: Option<PathBuf>,
pub transcode_cleanup_ttl_hours: u32, pub transcode_cleanup_ttl_hours: u32,
} }
impl Config { 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<Self, ConfigError> { pub fn from_env() -> Result<Self, ConfigError> {
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") let port: u16 = env::var("PORT")
.ok() .ok()
.and_then(|p| p.parse().ok()) .and_then(|p| p.parse().ok())
.unwrap_or(3000); .unwrap_or(DEFAULT_PORT);
let database_url = 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") let log_level = env::var("LOG_LEVEL")
.or_else(|_| env::var("RUST_LOG")) .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(|_| { let cookie_secret = env::var("COOKIE_SECRET")
"k-template-cookie-secret-key-must-be-at-least-64-bytes-long!!".to_string() .unwrap_or_else(|_| DEFAULT_COOKIE_SECRET.to_string());
});
let cors_origins: Vec<String> = env::var("CORS_ALLOWED_ORIGINS") let cors_origins: Vec<String> = env::var("CORS_ALLOWED_ORIGINS")
.unwrap_or_else(|_| "http://localhost:5173".to_string()) .unwrap_or_else(|_| DEFAULT_CORS_ORIGIN.to_string())
.split(',') .split(',')
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
@@ -196,27 +159,25 @@ impl Config {
let db_max_connections = env::var("DB_MAX_CONNECTIONS") let db_max_connections = env::var("DB_MAX_CONNECTIONS")
.ok() .ok()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(5); .unwrap_or(DEFAULT_MAX_CONNECTIONS);
let db_min_connections = env::var("DB_MIN_CONNECTIONS") let db_min_connections = env::var("DB_MIN_CONNECTIONS")
.ok() .ok()
.and_then(|s| s.parse().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_secret = env::var("JWT_SECRET").ok();
let jwt_issuer = env::var("JWT_ISSUER").ok(); let jwt_issuer = env::var("JWT_ISSUER").ok();
let jwt_audience = env::var("JWT_AUDIENCE").ok(); let jwt_audience = env::var("JWT_AUDIENCE").ok();
let jwt_expiry_hours = env::var("JWT_EXPIRY_HOURS") let jwt_expiry_hours = env::var("JWT_EXPIRY_HOURS")
.ok() .ok()
.and_then(|s| s.parse().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") let jwt_refresh_expiry_days = env::var("JWT_REFRESH_EXPIRY_DAYS")
.ok() .ok()
.and_then(|s| s.parse().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_issuer_url = env::var("OIDC_ISSUER").ok();
let oidc_client_id = env::var("OIDC_CLIENT_ID").ok(); let oidc_client_id = env::var("OIDC_CLIENT_ID").ok();
let oidc_client_secret = env::var("OIDC_CLIENT_SECRET").ok(); let oidc_client_secret = env::var("OIDC_CLIENT_SECRET").ok();
@@ -235,20 +196,16 @@ impl Config {
.map(|v| !(v == "false" || v == "0")) .map(|v| !(v == "false" || v == "0"))
.unwrap_or(true); .unwrap_or(true);
// Jellyfin
let jellyfin_url = env::var("JELLYFIN_BASE_URL").ok(); let jellyfin_url = env::var("JELLYFIN_BASE_URL").ok();
let jellyfin_api_key = env::var("JELLYFIN_API_KEY").ok(); let jellyfin_api_key = env::var("JELLYFIN_API_KEY").ok();
let jellyfin_user_id = env::var("JELLYFIN_USER_ID").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); 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_dir = env::var("TRANSCODE_DIR").ok().map(PathBuf::from);
let transcode_cleanup_ttl_hours = env::var("TRANSCODE_CLEANUP_TTL_HOURS") let transcode_cleanup_ttl_hours = env::var("TRANSCODE_CLEANUP_TTL_HOURS")
.ok() .ok()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(24); .unwrap_or(DEFAULT_TRANSCODE_CLEANUP_TTL_HOURS);
let base_url = let base_url =
env::var("BASE_URL").unwrap_or_else(|_| format!("http://localhost:{}", port)); env::var("BASE_URL").unwrap_or_else(|_| format!("http://localhost:{}", port));