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:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -133,6 +134,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"domain",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -6,4 +6,5 @@ pub struct RegisterCommand {
|
||||
pub struct LoginCommand {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub remember_me: bool,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
24
crates/application/src/auth/refresh.rs
Normal file
24
crates/application/src/auth/refresh.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
6
crates/application/src/auth/results.rs
Normal file
6
crates/application/src/auth/results.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
#[derive(Debug)]
|
||||
pub struct LoginResult {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_in: u64,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
9
crates/application/src/config/deps.rs
Normal file
9
crates/application/src/config/deps.rs
Normal 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>,
|
||||
}
|
||||
55
crates/application/src/config/get_config.rs
Normal file
55
crates/application/src/config/get_config.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
6
crates/application/src/config/mod.rs
Normal file
6
crates/application/src/config/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod deps;
|
||||
pub mod get_config;
|
||||
pub mod queries;
|
||||
|
||||
pub use deps::ConfigDeps;
|
||||
pub use queries::GetConfigQuery;
|
||||
1
crates/application/src/config/queries.rs
Normal file
1
crates/application/src/config/queries.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub struct GetConfigQuery;
|
||||
@@ -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;
|
||||
|
||||
@@ -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}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ pub struct GetEpgQuery {
|
||||
|
||||
pub struct GetStreamUrlQuery {
|
||||
pub channel_id: Uuid,
|
||||
pub item_id: String,
|
||||
}
|
||||
|
||||
pub struct ListHistoryQuery {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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())))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<()>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user