fix(presentation): extract 12 handler violations to use cases

- TokenService port + JwtTokenService adapter; login/refresh return LoginResult
- delete get_token (dup of login); create_tokens helper removed
- type UpdateChannelRequest schedule_config/recycle_policy (no serde_json::Value)
- update_settings returns updated Vec; handler calls one use case
- config use case in application::config; handler maps DTO only
- SyncStatusEntry moved to api-types w/ From<LibrarySyncLogEntry>
- trigger_sync: Conflict error, drop sync_trigger.send from handler
- UpsertProviderCommand.config accepts Value; serialization in use case
- get_current_broadcast returns BroadcastWithChannel; one call
- get_stream_url resolves broadcast internally; handler single call
This commit is contained in:
2026-07-12 05:28:49 +02:00
parent 9b18d3ff6d
commit 33b440d297
44 changed files with 414 additions and 280 deletions

2
Cargo.lock generated
View File

@@ -13,6 +13,7 @@ dependencies = [
"serde_json",
"thiserror",
"tracing",
"uuid",
]
[[package]]
@@ -133,6 +134,7 @@ dependencies = [
"async-trait",
"chrono",
"domain",
"serde_json",
"tokio",
"uuid",
]

View File

@@ -13,6 +13,7 @@ thiserror = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
# JWT deps
jsonwebtoken = { workspace = true, optional = true }

View File

@@ -205,6 +205,51 @@ impl JwtValidator {
.map_err(|_| JwtError::InvalidFormat)?;
Ok(token_data.claims)
}
pub fn expiry_hours(&self) -> u64 {
self.config.expiry_hours
}
}
pub struct JwtTokenService {
validator: JwtValidator,
}
impl JwtTokenService {
pub fn new(validator: JwtValidator) -> Self {
Self { validator }
}
}
impl domain::ports::TokenService for JwtTokenService {
fn create_access_token(&self, user: &domain::User) -> domain::DomainResult<String> {
self.validator.create_token(user).map_err(|e| {
domain::DomainError::InfrastructureError(format!("Failed to create access token: {e}"))
})
}
fn create_refresh_token(&self, user: &domain::User) -> domain::DomainResult<String> {
self.validator.create_refresh_token(user).map_err(|e| {
domain::DomainError::InfrastructureError(format!(
"Failed to create refresh token: {e}"
))
})
}
fn validate_refresh_token(&self, token: &str) -> domain::DomainResult<domain::UserId> {
let claims = self.validator.validate_refresh_token(token).map_err(|e| {
tracing::debug!("Refresh token validation failed: {:?}", e);
domain::DomainError::Unauthenticated("Invalid refresh token".to_string())
})?;
let user_id: uuid::Uuid = claims.sub.parse().map_err(|_| {
domain::DomainError::Unauthenticated("Invalid user ID in token".to_string())
})?;
Ok(domain::UserId::from(user_id))
}
fn token_expiry_secs(&self) -> u64 {
self.validator.expiry_hours() * 3600
}
}
impl std::fmt::Debug for JwtValidator {

View File

@@ -6,4 +6,4 @@ pub mod jwt;
pub use password::PasswordAuthService;
#[cfg(feature = "jwt")]
pub use jwt::{JwtClaims, JwtConfig, JwtError, JwtValidator};
pub use jwt::{JwtClaims, JwtConfig, JwtError, JwtTokenService, JwtValidator};

View File

@@ -18,13 +18,15 @@ pub struct CreateChannelRequest {
pub webhook_headers: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateChannelRequest {
pub name: Option<String>,
pub description: Option<String>,
pub timezone: Option<String>,
pub schedule_config: Option<serde_json::Value>,
pub recycle_policy: Option<serde_json::Value>,
#[schema(value_type = Option<Object>)]
pub schedule_config: Option<domain::models::ScheduleConfigCompat>,
#[schema(value_type = Option<Object>)]
pub recycle_policy: Option<domain::RecyclePolicy>,
pub auto_schedule: Option<bool>,
pub access_mode: Option<String>,
pub access_password: Option<String>,

View File

@@ -20,7 +20,7 @@ pub use config::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
pub use iptv::IptvParams;
pub use library::{
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam,
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams,
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry,
};
pub use providers::{ProviderConfigRequest, ProviderConfigResponse};
pub use schedule::{

View File

@@ -103,6 +103,29 @@ impl From<domain::SeasonSummary> for SeasonResponse {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SyncStatusEntry {
pub provider_id: String,
pub started_at: String,
pub finished_at: String,
pub items_found: u32,
pub status: String,
pub error_msg: String,
}
impl From<domain::LibrarySyncLogEntry> for SyncStatusEntry {
fn from(e: domain::LibrarySyncLogEntry) -> Self {
Self {
provider_id: e.provider_id().to_string(),
started_at: e.started_at().to_string(),
finished_at: e.finished_at().unwrap_or("").to_string(),
items_found: e.items_found(),
status: e.status().to_string(),
error_msg: e.error_msg().unwrap_or("").to_string(),
}
}
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct LibrarySearchParams {
pub provider: Option<String>,

View File

@@ -7,6 +7,7 @@ edition = "2024"
domain = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]

View File

@@ -3,11 +3,11 @@ use domain::DomainResult;
use super::commands::UpdateSettingsCommand;
use super::deps::AdminDeps;
pub async fn execute(deps: &AdminDeps, cmd: UpdateSettingsCommand) -> DomainResult<()> {
pub async fn execute(deps: &AdminDeps, cmd: UpdateSettingsCommand) -> DomainResult<Vec<(String, String)>> {
for (key, value) in &cmd.settings {
deps.settings_repo.set(key, value).await?;
}
Ok(())
deps.settings_repo.get_all().await
}
#[cfg(test)]

View File

@@ -6,4 +6,5 @@ pub struct RegisterCommand {
pub struct LoginCommand {
pub email: String,
pub password: String,
pub remember_me: bool,
}

View File

@@ -1,10 +1,11 @@
use std::sync::Arc;
use domain::ports::{AuthService, EventPublisher, UserCommand, UserQuery};
use domain::ports::{AuthService, EventPublisher, TokenService, UserCommand, UserQuery};
pub struct AuthDeps {
pub user_command: Arc<dyn UserCommand>,
pub user_query: Arc<dyn UserQuery>,
pub auth_service: Arc<dyn AuthService>,
pub token_service: Arc<dyn TokenService>,
pub event_publisher: Arc<dyn EventPublisher>,
}

View File

@@ -1,12 +1,12 @@
use domain::models::User;
use domain::{DomainError, DomainResult, Email};
use super::commands::LoginCommand;
use super::deps::AuthDeps;
use super::results::LoginResult;
const INVALID_CREDENTIALS: &str = "Invalid credentials";
pub async fn execute(deps: &AuthDeps, cmd: LoginCommand) -> DomainResult<User> {
pub async fn execute(deps: &AuthDeps, cmd: LoginCommand) -> DomainResult<LoginResult> {
let email = Email::new(&cmd.email)?;
let user = deps
@@ -24,7 +24,19 @@ pub async fn execute(deps: &AuthDeps, cmd: LoginCommand) -> DomainResult<User> {
return Err(DomainError::unauthenticated(INVALID_CREDENTIALS));
}
Ok(user)
let access_token = deps.token_service.create_access_token(&user)?;
let refresh_token = if cmd.remember_me {
Some(deps.token_service.create_refresh_token(&user)?)
} else {
None
};
let expires_in = deps.token_service.token_expiry_secs();
Ok(LoginResult {
access_token,
refresh_token,
expires_in,
})
}
#[cfg(test)]

View File

@@ -2,7 +2,10 @@ pub mod commands;
pub mod deps;
pub mod login;
pub mod queries;
pub mod refresh;
pub mod register;
pub mod results;
pub use commands::{LoginCommand, RegisterCommand};
pub use deps::AuthDeps;
pub use results::LoginResult;

View File

@@ -0,0 +1,24 @@
use domain::{DomainError, DomainResult};
use super::deps::AuthDeps;
use super::results::LoginResult;
pub async fn execute(deps: &AuthDeps, refresh_token: String) -> DomainResult<LoginResult> {
let user_id = deps.token_service.validate_refresh_token(&refresh_token)?;
let user = deps
.user_query
.find_by_id(user_id)
.await?
.ok_or_else(|| DomainError::Unauthenticated("User not found".to_string()))?;
let access_token = deps.token_service.create_access_token(&user)?;
let new_refresh = Some(deps.token_service.create_refresh_token(&user)?);
let expires_in = deps.token_service.token_expiry_secs();
Ok(LoginResult {
access_token,
refresh_token: new_refresh,
expires_in,
})
}

View File

@@ -0,0 +1,6 @@
#[derive(Debug)]
pub struct LoginResult {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: u64,
}

View File

@@ -2,14 +2,13 @@ use std::sync::Arc;
use domain::errors::DomainResult;
use domain::ports::AuthService;
use domain::testing::{InMemoryUserRepository, NoopEventPublisher};
use domain::testing::{InMemoryUserRepository, NoopEventPublisher, NoopTokenService};
use domain::{DomainError, Email};
use crate::auth::commands::LoginCommand;
use crate::auth::deps::AuthDeps;
use crate::auth::login;
/// Fake auth service: prefixes "hashed:" for hashing, verifies by checking prefix.
struct FakeAuthService;
impl AuthService for FakeAuthService {
@@ -27,19 +26,15 @@ fn make_deps_with_user(
password_hash: &str,
) -> (AuthDeps, Arc<InMemoryUserRepository>) {
let repo = Arc::new(InMemoryUserRepository::new());
// Seed a user
let e = Email::new(email).unwrap();
let user = domain::models::User::new_local(e, password_hash);
repo.store
.lock()
.unwrap()
.insert(user.id(), user);
repo.store.lock().unwrap().insert(user.id(), user);
let deps = AuthDeps {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
token_service: Arc::new(NoopTokenService::new()),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
(deps, repo)
@@ -49,17 +44,38 @@ fn make_deps_with_user(
async fn login_succeeds_with_correct_credentials() {
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:password123");
let user = login::execute(
let result = login::execute(
&deps,
LoginCommand {
email: "alice@example.com".into(),
password: "password123".into(),
remember_me: false,
},
)
.await
.unwrap();
assert_eq!(user.email().as_ref(), "alice@example.com");
assert!(!result.access_token.is_empty());
assert!(result.refresh_token.is_none());
}
#[tokio::test]
async fn login_with_remember_me_returns_refresh_token() {
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:password123");
let result = login::execute(
&deps,
LoginCommand {
email: "alice@example.com".into(),
password: "password123".into(),
remember_me: true,
},
)
.await
.unwrap();
assert!(!result.access_token.is_empty());
assert!(result.refresh_token.is_some());
}
#[tokio::test]
@@ -71,6 +87,7 @@ async fn login_fails_with_wrong_password() {
LoginCommand {
email: "alice@example.com".into(),
password: "wrong".into(),
remember_me: false,
},
)
.await;
@@ -91,6 +108,7 @@ async fn login_fails_for_unknown_email() {
LoginCommand {
email: "nobody@example.com".into(),
password: "password123".into(),
remember_me: false,
},
)
.await;
@@ -105,19 +123,15 @@ async fn login_fails_for_unknown_email() {
#[tokio::test]
async fn login_fails_for_oidc_only_user() {
let repo = Arc::new(InMemoryUserRepository::new());
// Create an OIDC user (no password hash)
let email = Email::new("oidc@example.com").unwrap();
let user = domain::models::User::new("oidc|subject", email);
repo.store
.lock()
.unwrap()
.insert(user.id(), user);
repo.store.lock().unwrap().insert(user.id(), user);
let deps = AuthDeps {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
token_service: Arc::new(NoopTokenService::new()),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
@@ -126,6 +140,7 @@ async fn login_fails_for_oidc_only_user() {
LoginCommand {
email: "oidc@example.com".into(),
password: "password123".into(),
remember_me: false,
},
)
.await;
@@ -144,6 +159,7 @@ async fn login_rejects_invalid_email() {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
token_service: Arc::new(NoopTokenService::new()),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
@@ -152,6 +168,7 @@ async fn login_rejects_invalid_email() {
LoginCommand {
email: "not-an-email".into(),
password: "password123".into(),
remember_me: false,
},
)
.await;

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use domain::errors::DomainResult;
use domain::ports::AuthService;
use domain::testing::{InMemoryUserRepository, NoopEventPublisher};
use domain::testing::{InMemoryUserRepository, NoopEventPublisher, NoopTokenService};
use domain::{DomainError, Email};
use crate::auth::commands::RegisterCommand;
@@ -28,6 +28,7 @@ fn make_deps() -> (AuthDeps, Arc<InMemoryUserRepository>) {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
token_service: Arc::new(NoopTokenService::new()),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
(deps, repo)

View File

@@ -0,0 +1,9 @@
use std::sync::Arc;
use domain::ports::IProviderRegistry;
pub struct ConfigDeps {
pub provider_registry: Arc<dyn IProviderRegistry>,
pub allow_registration: bool,
pub available_provider_types: Vec<String>,
}

View File

@@ -0,0 +1,55 @@
use domain::ports::ProviderCapabilities;
use super::deps::ConfigDeps;
use super::queries::GetConfigQuery;
pub struct ProviderInfo {
pub id: String,
pub capabilities: ProviderCapabilities,
}
pub struct SystemConfig {
pub allow_registration: bool,
pub providers: Vec<ProviderInfo>,
pub primary_capabilities: ProviderCapabilities,
pub available_provider_types: Vec<String>,
}
pub fn execute(deps: &ConfigDeps, _query: GetConfigQuery) -> SystemConfig {
let provider_ids = deps.provider_registry.provider_ids();
let primary_id = deps.provider_registry.primary_id().to_string();
let providers: Vec<ProviderInfo> = provider_ids
.iter()
.filter_map(|id| {
deps.provider_registry
.capabilities(id)
.map(|caps| ProviderInfo {
id: id.clone(),
capabilities: caps,
})
})
.collect();
let primary_capabilities = deps
.provider_registry
.capabilities(&primary_id)
.unwrap_or(ProviderCapabilities {
collections: false,
series: false,
genres: false,
tags: false,
decade: false,
search: false,
streaming_protocol: domain::ports::StreamingProtocol::DirectFile,
rescan: false,
transcode: false,
});
SystemConfig {
allow_registration: deps.allow_registration,
providers,
primary_capabilities,
available_provider_types: deps.available_provider_types.clone(),
}
}

View File

@@ -0,0 +1,6 @@
pub mod deps;
pub mod get_config;
pub mod queries;
pub use deps::ConfigDeps;
pub use queries::GetConfigQuery;

View File

@@ -0,0 +1 @@
pub struct GetConfigQuery;

View File

@@ -1,6 +1,7 @@
pub mod admin;
pub mod auth;
pub mod channels;
pub mod config;
pub mod config_snapshots;
pub mod iptv;
pub mod library;

View File

@@ -14,7 +14,7 @@ pub async fn execute(
for pid in &provider_ids {
if deps.library_query.is_sync_running(pid).await? {
return Err(DomainError::ValidationError(format!(
return Err(DomainError::Conflict(format!(
"Sync already running for provider '{pid}'"
)));
}

View File

@@ -1,7 +1,7 @@
pub struct UpsertProviderCommand {
pub id: String,
pub provider_type: String,
pub config_json: String,
pub config: serde_json::Value,
pub enabled: bool,
}

View File

@@ -32,7 +32,7 @@ async fn list_returns_all_providers() {
UpsertProviderCommand {
id: id.into(),
provider_type: "jellyfin".into(),
config_json: "{}".into(),
config: serde_json::json!({}),
enabled: true,
},
)

View File

@@ -23,7 +23,7 @@ async fn upsert_stores_provider() {
UpsertProviderCommand {
id: "jf-1".into(),
provider_type: "jellyfin".into(),
config_json: r#"{"url":"http://localhost:8096"}"#.into(),
config: serde_json::json!({"url":"http://localhost:8096"}),
enabled: true,
},
)
@@ -46,7 +46,7 @@ async fn upsert_overwrites_existing() {
UpsertProviderCommand {
id: "jf-1".into(),
provider_type: "jellyfin".into(),
config_json: r#"{"url":"http://old"}"#.into(),
config: serde_json::json!({"url":"http://old"}),
enabled: true,
},
)
@@ -58,7 +58,7 @@ async fn upsert_overwrites_existing() {
UpsertProviderCommand {
id: "jf-1".into(),
provider_type: "jellyfin".into(),
config_json: r#"{"url":"http://new"}"#.into(),
config: serde_json::json!({"url":"http://new"}),
enabled: false,
},
)

View File

@@ -5,10 +5,12 @@ use super::commands::UpsertProviderCommand;
use super::deps::ProviderDeps;
pub async fn execute(deps: &ProviderDeps, cmd: UpsertProviderCommand) -> DomainResult<()> {
let config_json = serde_json::to_string(&cmd.config)
.map_err(|e| domain::DomainError::ValidationError(format!("Invalid config: {e}")))?;
let row = ProviderConfigRow::from_persistence(
cmd.id,
cmd.provider_type,
cmd.config_json,
config_json,
cmd.enabled,
String::new(),
);

View File

@@ -1,16 +1,21 @@
use chrono::Utc;
use domain::models::CurrentBroadcast;
use domain::models::{Channel, CurrentBroadcast};
use domain::value_objects::ChannelId;
use domain::{DomainResult, ScheduleEngineService};
use super::deps::ScheduleDeps;
use super::queries::GetCurrentBroadcastQuery;
pub struct BroadcastWithChannel {
pub broadcast: CurrentBroadcast,
pub channel: Option<Channel>,
}
pub async fn execute(
deps: &ScheduleDeps,
query: GetCurrentBroadcastQuery,
) -> DomainResult<Option<CurrentBroadcast>> {
) -> DomainResult<Option<BroadcastWithChannel>> {
let channel_id = ChannelId::from(query.channel_id);
let now = Utc::now();
@@ -19,7 +24,13 @@ pub async fn execute(
None => return Ok(None),
};
Ok(ScheduleEngineService::get_current_broadcast(&schedule, now))
match ScheduleEngineService::get_current_broadcast(&schedule, now) {
Some(broadcast) => {
let channel = deps.channel_query.find_by_id(channel_id).await?;
Ok(Some(BroadcastWithChannel { broadcast, channel }))
}
None => Ok(None),
}
}
#[cfg(test)]

View File

@@ -1,13 +1,30 @@
use chrono::Utc;
use domain::ports::StreamQuality;
use domain::value_objects::MediaItemId;
use domain::DomainResult;
use domain::value_objects::ChannelId;
use domain::{DomainResult, ScheduleEngineService};
use super::deps::ScheduleDeps;
use super::queries::GetStreamUrlQuery;
pub async fn execute(deps: &ScheduleDeps, query: GetStreamUrlQuery) -> DomainResult<String> {
let item_id = MediaItemId::new(&query.item_id);
deps.schedule_engine
pub async fn execute(deps: &ScheduleDeps, query: GetStreamUrlQuery) -> DomainResult<Option<String>> {
let channel_id = ChannelId::from(query.channel_id);
let now = Utc::now();
let schedule = match deps.schedule_query.find_active(channel_id, now).await? {
Some(s) => s,
None => return Ok(None),
};
let broadcast = match ScheduleEngineService::get_current_broadcast(&schedule, now) {
Some(b) => b,
None => return Ok(None),
};
let item_id = broadcast.slot().item().id().clone();
let url = deps
.schedule_engine
.get_stream_url(&item_id, &StreamQuality::Direct)
.await
.await?;
Ok(Some(url))
}

View File

@@ -14,7 +14,6 @@ pub struct GetEpgQuery {
pub struct GetStreamUrlQuery {
pub channel_id: Uuid,
pub item_id: String,
}
pub struct ListHistoryQuery {

View File

@@ -1,8 +1,16 @@
use crate::errors::DomainResult;
use crate::models::User;
use crate::value_objects::UserId;
// Intentionally sync: CPU-bound hashing should run on a blocking thread pool
pub trait AuthService: Send + Sync {
fn hash_password(&self, password: &str) -> DomainResult<String>;
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool>;
}
pub trait TokenService: Send + Sync {
fn create_access_token(&self, user: &User) -> DomainResult<String>;
fn create_refresh_token(&self, user: &User) -> DomainResult<String>;
fn validate_refresh_token(&self, token: &str) -> DomainResult<UserId>;
fn token_expiry_secs(&self) -> u64;
}

View File

@@ -11,7 +11,7 @@ pub mod transcode;
pub mod user;
pub use activity::{ActivityLogCommand, ActivityLogQuery};
pub use auth::AuthService;
pub use auth::{AuthService, TokenService};
pub use channel::{ChannelCommand, ChannelQuery};
pub use events::{DomainEvent, EventConsumer, EventHandler, EventPublisher};
pub use library::{LibraryCommand, LibraryQuery, LibrarySyncAdapter};

View File

@@ -114,6 +114,49 @@ impl ActivityLogQuery for NoopActivityLog {
}
}
pub struct NoopTokenService;
impl NoopTokenService {
pub fn new() -> Self {
Self
}
}
impl Default for NoopTokenService {
fn default() -> Self {
Self::new()
}
}
impl crate::ports::TokenService for NoopTokenService {
fn create_access_token(
&self,
_user: &crate::models::User,
) -> crate::errors::DomainResult<String> {
Ok("noop-access-token".to_string())
}
fn create_refresh_token(
&self,
_user: &crate::models::User,
) -> crate::errors::DomainResult<String> {
Ok("noop-refresh-token".to_string())
}
fn validate_refresh_token(
&self,
_token: &str,
) -> crate::errors::DomainResult<crate::value_objects::UserId> {
Err(crate::errors::DomainError::Unauthenticated(
"NoopTokenService".to_string(),
))
}
fn token_expiry_secs(&self) -> u64 {
3600
}
}
pub struct NoopLibrarySync;
impl NoopLibrarySync {

View File

@@ -35,10 +35,10 @@ pub async fn get_active_schedule(deps: &Arc<ScheduleDeps>, channel_id: Uuid) ->
pub async fn get_current_broadcast(deps: &Arc<ScheduleDeps>, channel_id: Uuid) -> String {
let query = GetCurrentBroadcastQuery { channel_id };
match application::schedule::get_current_broadcast::execute(deps, query).await {
Ok(Some(b)) => {
let offset = b.offset_secs();
Ok(Some(result)) => {
let offset = result.broadcast.offset_secs();
ok_json(&CurrentBroadcastDto {
slot: b.into_slot(),
slot: result.broadcast.into_slot(),
offset_secs: offset,
})
}

View File

@@ -4,6 +4,7 @@ use application::{
admin::AdminDeps,
auth::AuthDeps,
channels::{ChannelCommandDeps, ChannelQueryDeps},
config::ConfigDeps,
config_snapshots::ConfigSnapshotDeps,
iptv::IptvDeps,
library::{LibraryCommandDeps, LibraryQueryDeps},
@@ -46,12 +47,23 @@ pub async fn build_app_state(config: Config) -> anyhow::Result<AppState> {
#[cfg(feature = "auth-jwt")]
let jwt_validator = build_jwt_validator(&config)?;
#[cfg(feature = "auth-jwt")]
let token_service: Arc<dyn domain::ports::TokenService> = {
let validator = jwt_validator
.as_ref()
.expect("JWT validator required")
.as_ref()
.clone();
Arc::new(adapter_auth::JwtTokenService::new(validator))
};
let (sync_tx, sync_rx) = tokio::sync::watch::channel(());
let auth_deps = Arc::new(AuthDeps {
user_command: wire_output.user_command.clone(),
user_query: wire_output.user_query.clone(),
auth_service,
token_service,
event_publisher: event_publisher.clone(),
});
@@ -105,6 +117,18 @@ pub async fn build_app_state(config: Config) -> anyhow::Result<AppState> {
provider_config_query: wire_output.provider_config_query.clone(),
});
let mut available_provider_types = Vec::new();
#[cfg(feature = "jellyfin")]
available_provider_types.push("jellyfin".to_string());
#[cfg(feature = "local-files")]
available_provider_types.push("local_files".to_string());
let config_deps = Arc::new(ConfigDeps {
provider_registry: provider_registry.clone(),
allow_registration: config.allow_registration,
available_provider_types,
});
let config_arc = Arc::new(config);
let bg_schedule_deps = schedule_deps.clone();
@@ -140,6 +164,7 @@ pub async fn build_app_state(config: Config) -> anyhow::Result<AppState> {
channel_command_deps,
channel_query_deps,
config_snapshot_deps,
config_deps,
schedule_deps,
library_command_deps,
library_query_deps,
@@ -148,12 +173,11 @@ pub async fn build_app_state(config: Config) -> anyhow::Result<AppState> {
provider_deps,
#[cfg(feature = "auth-jwt")]
jwt_validator,
provider_registry,
_library_sync: library_sync,
_settings_repo: wire_output.settings,
_event_bus: event_bus,
config: config_arc,
sync_trigger: sync_tx,
_sync_trigger: sync_tx,
})
}

View File

@@ -3,7 +3,7 @@ use axum::extract::{Query, State};
use std::collections::HashMap;
use api_types::{ActivityEventResponse, ActivityLogParams, SettingsResponse};
use application::admin::{GetActivityLogQuery, GetSettingsQuery, UpdateSettingsCommand};
use application::admin::{GetActivityLogQuery, UpdateSettingsCommand};
use crate::errors::AppError;
use crate::extractors::AdminUser;
@@ -16,7 +16,7 @@ pub async fn get_settings(
AdminUser(_user): AdminUser,
) -> Result<Json<SettingsResponse>, AppError> {
let pairs =
application::admin::get_settings::execute(&state.admin_deps, GetSettingsQuery).await?;
application::admin::get_settings::execute(&state.admin_deps, application::admin::GetSettingsQuery).await?;
let settings: HashMap<String, String> = pairs.into_iter().collect();
Ok(Json(SettingsResponse { settings }))
}
@@ -30,10 +30,7 @@ pub async fn update_settings(
let cmd = UpdateSettingsCommand {
settings: settings_vec,
};
application::admin::update_settings::execute(&state.admin_deps, cmd).await?;
let pairs =
application::admin::get_settings::execute(&state.admin_deps, GetSettingsQuery).await?;
let pairs = application::admin::update_settings::execute(&state.admin_deps, cmd).await?;
let settings: HashMap<String, String> = pairs.into_iter().collect();
Ok(Json(SettingsResponse { settings }))
}

View File

@@ -3,14 +3,12 @@ use axum::extract::State;
use api_types::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse};
use application::auth::{LoginCommand, RegisterCommand};
use domain::DomainError;
use crate::errors::AppError;
use crate::extractors::CurrentUser;
use crate::state::AppState;
const TOKEN_TYPE_BEARER: &str = "Bearer";
const SECS_PER_HOUR: u64 = 3600;
pub async fn register(
State(state): State<AppState>,
@@ -31,14 +29,14 @@ pub async fn login(
let cmd = LoginCommand {
email: req.email,
password: req.password,
remember_me: req.remember_me,
};
let user = application::auth::login::execute(&state.auth_deps, cmd).await?;
let (access_token, refresh_token) = create_tokens(&user, &state, req.remember_me)?;
let result = application::auth::login::execute(&state.auth_deps, cmd).await?;
Ok(Json(TokenResponse {
access_token,
access_token: result.access_token,
token_type: TOKEN_TYPE_BEARER.to_string(),
expires_in: state.config.jwt_expiry_hours * SECS_PER_HOUR,
refresh_token,
expires_in: result.expires_in,
refresh_token: result.refresh_token,
}))
}
@@ -46,100 +44,21 @@ pub async fn logout() -> Result<Json<serde_json::Value>, AppError> {
Ok(Json(serde_json::json!({"message": "logged out"})))
}
pub async fn me(
CurrentUser(user): CurrentUser,
) -> Result<Json<UserResponse>, AppError> {
pub async fn me(CurrentUser(user): CurrentUser) -> Result<Json<UserResponse>, AppError> {
Ok(Json(UserResponse::from(user)))
}
#[cfg(feature = "auth-jwt")]
pub async fn get_token(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<TokenResponse>, AppError> {
let cmd = LoginCommand {
email: req.email,
password: req.password,
};
let user = application::auth::login::execute(&state.auth_deps, cmd).await?;
let (access_token, refresh_token) = create_tokens(&user, &state, req.remember_me)?;
Ok(Json(TokenResponse {
access_token,
token_type: TOKEN_TYPE_BEARER.to_string(),
expires_in: state.config.jwt_expiry_hours * SECS_PER_HOUR,
refresh_token,
}))
}
#[cfg(feature = "auth-jwt")]
pub async fn refresh_token(
State(state): State<AppState>,
Json(req): Json<RefreshRequest>,
) -> Result<Json<TokenResponse>, AppError> {
let validator = state
.jwt_validator
.as_ref()
.ok_or_else(|| AppError(DomainError::InfrastructureError("JWT not configured".to_string())))?;
let claims = validator.validate_refresh_token(&req.refresh_token).map_err(|e| {
tracing::debug!("Refresh token validation failed: {:?}", e);
AppError(DomainError::Unauthenticated("Invalid refresh token".to_string()))
})?;
let user_id: uuid::Uuid = claims
.sub
.parse()
.map_err(|_| AppError(DomainError::Unauthenticated("Invalid user ID in token".to_string())))?;
let user = state
.auth_deps
.user_query
.find_by_id(domain::UserId::from(user_id))
.await
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to fetch user: {}", e))))?
.ok_or_else(|| AppError(DomainError::Unauthenticated("User not found".to_string())))?;
let (access_token, refresh_token) = create_tokens(&user, &state, true)?;
let result =
application::auth::refresh::execute(&state.auth_deps, req.refresh_token).await?;
Ok(Json(TokenResponse {
access_token,
access_token: result.access_token,
token_type: TOKEN_TYPE_BEARER.to_string(),
expires_in: state.config.jwt_expiry_hours * SECS_PER_HOUR,
refresh_token,
expires_in: result.expires_in,
refresh_token: result.refresh_token,
}))
}
fn create_tokens(
user: &domain::User,
state: &AppState,
remember_me: bool,
) -> Result<(String, Option<String>), AppError> {
#[cfg(feature = "auth-jwt")]
{
let validator = state
.jwt_validator
.as_ref()
.ok_or_else(|| AppError(DomainError::InfrastructureError("JWT not configured".to_string())))?;
let access = validator
.create_token(user)
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to create token: {}", e))))?;
let refresh = if remember_me {
Some(
validator
.create_refresh_token(user)
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to create refresh token: {}", e))))?,
)
} else {
None
};
Ok((access, refresh))
}
#[cfg(not(feature = "auth-jwt"))]
{
let _ = (user, state, remember_me);
Err(AppError(DomainError::InfrastructureError("JWT feature not enabled".to_string())))
}
}

View File

@@ -74,30 +74,14 @@ pub async fn update_channel(
Path(id): Path<uuid::Uuid>,
Json(req): Json<UpdateChannelRequest>,
) -> Result<Json<ChannelResponse>, AppError> {
let schedule_config = req
.schedule_config
.map(|v| {
serde_json::from_value(v)
.map_err(|e| AppError(DomainError::ValidationError(format!("Invalid schedule_config: {e}"))))
})
.transpose()?;
let recycle_policy = req
.recycle_policy
.map(|v| {
serde_json::from_value(v)
.map_err(|e| AppError(DomainError::ValidationError(format!("Invalid recycle_policy: {e}"))))
})
.transpose()?;
let cmd = UpdateChannelCommand {
channel_id: id.into(),
owner_id: user.id(),
name: req.name,
description: req.description.map(Some),
timezone: req.timezone,
schedule_config,
recycle_policy,
schedule_config: req.schedule_config.map(Into::into),
recycle_policy: req.recycle_policy,
auto_schedule: req.auto_schedule,
};
let channel = application::channels::update::execute(&state.channel_command_deps, cmd).await?;

View File

@@ -2,54 +2,28 @@ use axum::Json;
use axum::extract::State;
use api_types::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
use application::config::GetConfigQuery;
use crate::errors::AppError;
use crate::state::AppState;
const FALLBACK_STREAMING_PROTOCOL: &str = "direct_file";
pub async fn get_config(
State(state): State<AppState>,
) -> Result<Json<ConfigResponse>, AppError> {
let registry = &state.provider_registry;
let provider_ids = registry.provider_ids();
let primary_id = registry.primary_id().to_string();
let providers: Vec<ProviderInfo> = provider_ids
.iter()
.filter_map(|id| {
registry.capabilities(id).map(|caps| ProviderInfo {
id: id.clone(),
capabilities: ProviderCapabilitiesResponse::from(caps),
})
let sys_config = application::config::get_config::execute(&state.config_deps, GetConfigQuery);
let providers: Vec<ProviderInfo> = sys_config
.providers
.into_iter()
.map(|p| ProviderInfo {
id: p.id,
capabilities: ProviderCapabilitiesResponse::from(p.capabilities),
})
.collect();
let primary_caps = registry
.capabilities(&primary_id)
.map(ProviderCapabilitiesResponse::from)
.unwrap_or(ProviderCapabilitiesResponse {
collections: false,
series: false,
genres: false,
tags: false,
decade: false,
search: false,
streaming_protocol: FALLBACK_STREAMING_PROTOCOL.to_string(),
rescan: false,
transcode: false,
});
let mut available_types = Vec::new();
#[cfg(feature = "jellyfin")]
available_types.push("jellyfin".to_string());
#[cfg(feature = "local-files")]
available_types.push("local_files".to_string());
let primary_caps = ProviderCapabilitiesResponse::from(sys_config.primary_capabilities);
Ok(Json(ConfigResponse {
allow_registration: state.config.allow_registration,
allow_registration: sys_config.allow_registration,
providers,
provider_capabilities: primary_caps,
available_provider_types: available_types,
available_provider_types: sys_config.available_provider_types,
}))
}

View File

@@ -1,10 +1,9 @@
use axum::Json;
use axum::extract::{Path, Query, State};
use serde::Serialize;
use api_types::{
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, PaginatedResponse,
ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams,
ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry,
};
use application::library::{
GetItemQuery, GetSyncStatusQuery, ListCollectionsQuery, ListGenresQuery, ListSeasonsQuery,
@@ -116,16 +115,6 @@ pub async fn list_genres(
Ok(Json(genres))
}
#[derive(Debug, Serialize)]
pub(crate) struct SyncStatusEntry {
provider_id: String,
started_at: String,
finished_at: String,
items_found: u32,
status: String,
error_msg: String,
}
pub async fn sync_status(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
@@ -133,18 +122,7 @@ pub async fn sync_status(
let entries =
application::library::get_sync_status::execute(&state.library_query_deps, GetSyncStatusQuery)
.await?;
let result: Vec<SyncStatusEntry> = entries
.into_iter()
.map(|e| SyncStatusEntry {
provider_id: e.provider_id().to_string(),
started_at: e.started_at().to_string(),
finished_at: e.finished_at().unwrap_or("").to_string(),
items_found: e.items_found(),
status: e.status().to_string(),
error_msg: e.error_msg().unwrap_or("").to_string(),
})
.collect();
Ok(Json(result))
Ok(Json(entries.into_iter().map(SyncStatusEntry::from).collect()))
}
pub async fn trigger_sync(
@@ -152,17 +130,6 @@ pub async fn trigger_sync(
AdminUser(_user): AdminUser,
) -> Result<axum::http::StatusCode, AppError> {
let cmd = TriggerSyncCommand { provider_id: None };
application::library::sync::execute(&state.library_command_deps, cmd)
.await
.map_err(|e| {
if e.to_string().contains("already running") {
AppError(DomainError::Conflict(e.to_string()))
} else {
AppError::from(e)
}
})?;
let _ = state.sync_trigger.send(());
application::library::sync::execute(&state.library_command_deps, cmd).await?;
Ok(axum::http::StatusCode::ACCEPTED)
}

View File

@@ -43,13 +43,10 @@ pub async fn upsert_provider(
Path(id): Path<String>,
Json(req): Json<ProviderConfigRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let config_json = serde_json::to_string(&req.config)
.map_err(|e| AppError(DomainError::ValidationError(format!("Invalid config JSON: {e}"))))?;
let cmd = UpsertProviderCommand {
id,
provider_type: req.provider_type,
config_json,
config: req.config,
enabled: req.enabled,
};
application::providers::upsert::execute(&state.provider_deps, cmd).await?;

View File

@@ -44,20 +44,15 @@ pub async fn get_current_broadcast(
let query = GetCurrentBroadcastQuery { channel_id: id };
match application::schedule::get_current_broadcast::execute(&state.schedule_deps, query).await?
{
Some(broadcast) => {
let channel_query = application::channels::GetChannelQuery { channel_id: id.into() };
let channel = application::channels::get::execute(&state.channel_query_deps, channel_query).await?;
let slot_response = match &channel {
Some(ch) => SlotResponse::with_block_access(broadcast.slot().clone(), ch),
None => SlotResponse::from(broadcast.slot().clone()),
Some(result) => {
let slot_response = match &result.channel {
Some(ch) => SlotResponse::with_block_access(result.broadcast.slot().clone(), ch),
None => SlotResponse::from(result.broadcast.slot().clone()),
};
let block_access_mode = slot_response.block_access_mode.clone();
Ok(Json(CurrentBroadcastResponse {
slot: slot_response,
offset_secs: broadcast.offset_secs(),
offset_secs: result.broadcast.offset_secs(),
block_access_mode,
})
.into_response())
@@ -79,26 +74,13 @@ pub async fn get_stream(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> Result<axum::response::Response, AppError> {
let broadcast_query = GetCurrentBroadcastQuery { channel_id: id };
let broadcast =
application::schedule::get_current_broadcast::execute(&state.schedule_deps, broadcast_query)
.await?;
match broadcast {
Some(b) => {
let stream_query = GetStreamUrlQuery {
channel_id: id,
item_id: b.slot().item().id().value().to_string(),
};
let url =
application::schedule::get_stream_url::execute(&state.schedule_deps, stream_query)
.await?;
Ok((
StatusCode::TEMPORARY_REDIRECT,
[("Location", url.as_str())],
)
.into_response())
}
let query = GetStreamUrlQuery { channel_id: id };
match application::schedule::get_stream_url::execute(&state.schedule_deps, query).await? {
Some(url) => Ok((
StatusCode::TEMPORARY_REDIRECT,
[("Location", url.as_str())],
)
.into_response()),
None => Ok(StatusCode::NO_CONTENT.into_response()),
}
}

View File

@@ -24,7 +24,7 @@ fn auth_router() -> Router<AppState> {
#[cfg(feature = "auth-jwt")]
let r = r
.route("/token", post(handlers::auth::get_token))
.route("/token", post(handlers::auth::login))
.route("/refresh", post(handlers::auth::refresh_token));
r

View File

@@ -4,6 +4,7 @@ use application::{
admin::AdminDeps,
auth::AuthDeps,
channels::{ChannelCommandDeps, ChannelQueryDeps},
config::ConfigDeps,
config_snapshots::ConfigSnapshotDeps,
iptv::IptvDeps,
library::{LibraryCommandDeps, LibraryQueryDeps},
@@ -17,6 +18,7 @@ pub struct AppState {
pub channel_command_deps: Arc<ChannelCommandDeps>,
pub channel_query_deps: Arc<ChannelQueryDeps>,
pub config_snapshot_deps: Arc<ConfigSnapshotDeps>,
pub config_deps: Arc<ConfigDeps>,
pub schedule_deps: Arc<ScheduleDeps>,
pub library_command_deps: Arc<LibraryCommandDeps>,
pub library_query_deps: Arc<LibraryQueryDeps>,
@@ -27,13 +29,10 @@ pub struct AppState {
#[cfg(feature = "auth-jwt")]
pub jwt_validator: Option<Arc<adapter_auth::JwtValidator>>,
pub provider_registry: Arc<dyn domain::ports::IProviderRegistry>,
// kept alive for background tasks — not read from handlers
pub _library_sync: Arc<dyn domain::ports::LibrarySyncAdapter>,
pub _settings_repo: Arc<dyn domain::ports::AppSettingsRepository>,
pub _event_bus: Arc<adapter_event_publisher::ChannelEventBus>,
pub config: Arc<infra_wiring::Config>,
pub sync_trigger: tokio::sync::watch::Sender<()>,
pub _sync_trigger: tokio::sync::watch::Sender<()>,
}