remove OIDC/Postgres, replace ApiError w/ AppError, move params to api-types
This commit is contained in:
694
Cargo.lock
generated
694
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring", "crates/adapters/adapter-common", "crates/adapters/sqlite", "crates/adapters/postgres", "crates/adapters/auth", "crates/adapters/jellyfin", "crates/adapters/local-files", "crates/adapters/event-publisher", "crates/presentation", "crates/mcp"]
|
members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring", "crates/adapters/adapter-common", "crates/adapters/sqlite", "crates/adapters/auth", "crates/adapters/jellyfin", "crates/adapters/local-files", "crates/adapters/event-publisher", "crates/presentation", "crates/mcp"]
|
||||||
exclude = ["k-tv-backend", "k-tv-frontend"]
|
exclude = ["k-tv-backend", "k-tv-frontend"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -33,7 +33,6 @@ api-types = { path = "crates/api-types" }
|
|||||||
infra-wiring = { path = "crates/infra-wiring" }
|
infra-wiring = { path = "crates/infra-wiring" }
|
||||||
adapter-common = { path = "crates/adapters/adapter-common" }
|
adapter-common = { path = "crates/adapters/adapter-common" }
|
||||||
adapter-sqlite = { path = "crates/adapters/sqlite" }
|
adapter-sqlite = { path = "crates/adapters/sqlite" }
|
||||||
adapter-postgres = { path = "crates/adapters/postgres" }
|
|
||||||
adapter-auth = { path = "crates/adapters/auth" }
|
adapter-auth = { path = "crates/adapters/auth" }
|
||||||
adapter-jellyfin = { path = "crates/adapters/jellyfin" }
|
adapter-jellyfin = { path = "crates/adapters/jellyfin" }
|
||||||
adapter-local-files = { path = "crates/adapters/local-files" }
|
adapter-local-files = { path = "crates/adapters/local-files" }
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ edition = "2024"
|
|||||||
[features]
|
[features]
|
||||||
default = ["jwt"]
|
default = ["jwt"]
|
||||||
jwt = ["dep:jsonwebtoken"]
|
jwt = ["dep:jsonwebtoken"]
|
||||||
oidc = ["dep:openidconnect", "dep:reqwest", "dep:url"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
@@ -18,10 +17,5 @@ serde_json = { workspace = true }
|
|||||||
# JWT deps
|
# JWT deps
|
||||||
jsonwebtoken = { workspace = true, optional = true }
|
jsonwebtoken = { workspace = true, optional = true }
|
||||||
|
|
||||||
# OIDC deps (optional)
|
|
||||||
openidconnect = { version = "4", optional = true }
|
|
||||||
reqwest = { workspace = true, optional = true }
|
|
||||||
url = { workspace = true, optional = true }
|
|
||||||
|
|
||||||
# Password hashing
|
# Password hashing
|
||||||
password-auth = "1"
|
password-auth = "1"
|
||||||
|
|||||||
@@ -3,13 +3,7 @@ pub mod password;
|
|||||||
#[cfg(feature = "jwt")]
|
#[cfg(feature = "jwt")]
|
||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
|
|
||||||
#[cfg(feature = "oidc")]
|
|
||||||
pub mod oidc;
|
|
||||||
|
|
||||||
pub use password::PasswordAuthService;
|
pub use password::PasswordAuthService;
|
||||||
|
|
||||||
#[cfg(feature = "jwt")]
|
#[cfg(feature = "jwt")]
|
||||||
pub use jwt::{JwtClaims, JwtConfig, JwtError, JwtValidator};
|
pub use jwt::{JwtClaims, JwtConfig, JwtError, JwtValidator};
|
||||||
|
|
||||||
#[cfg(feature = "oidc")]
|
|
||||||
pub use oidc::{OidcService, OidcState, OidcUser};
|
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
use domain::{
|
|
||||||
AuthorizationCode, AuthorizationUrlData, ClientId, ClientSecret, CsrfToken, IssuerUrl,
|
|
||||||
OidcNonce, PkceVerifier, RedirectUrl, ResourceId,
|
|
||||||
};
|
|
||||||
use openidconnect::{
|
|
||||||
AccessTokenHash, Client, EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet,
|
|
||||||
OAuth2TokenResponse, PkceCodeChallenge, Scope, StandardErrorResponse, TokenResponse,
|
|
||||||
UserInfoClaims,
|
|
||||||
core::{
|
|
||||||
CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType,
|
|
||||||
CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata,
|
|
||||||
CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse,
|
|
||||||
CoreTokenResponse,
|
|
||||||
},
|
|
||||||
reqwest,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
pub type OidcClient = Client<
|
|
||||||
EmptyAdditionalClaims,
|
|
||||||
CoreAuthDisplay,
|
|
||||||
CoreGenderClaim,
|
|
||||||
CoreJweContentEncryptionAlgorithm,
|
|
||||||
CoreJsonWebKey,
|
|
||||||
CoreAuthPrompt,
|
|
||||||
StandardErrorResponse<CoreErrorResponseType>,
|
|
||||||
CoreTokenResponse,
|
|
||||||
CoreTokenIntrospectionResponse,
|
|
||||||
CoreRevocableToken,
|
|
||||||
CoreRevocationErrorResponse,
|
|
||||||
EndpointSet,
|
|
||||||
EndpointNotSet,
|
|
||||||
EndpointNotSet,
|
|
||||||
EndpointNotSet,
|
|
||||||
EndpointMaybeSet,
|
|
||||||
EndpointMaybeSet,
|
|
||||||
>;
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum OidcError {
|
|
||||||
#[error("OIDC discovery failed: {0}")]
|
|
||||||
Discovery(String),
|
|
||||||
|
|
||||||
#[error("Token exchange failed: {0}")]
|
|
||||||
TokenExchange(String),
|
|
||||||
|
|
||||||
#[error("ID token verification failed: {0}")]
|
|
||||||
IdTokenVerification(String),
|
|
||||||
|
|
||||||
#[error("Missing ID token in response")]
|
|
||||||
MissingIdToken,
|
|
||||||
|
|
||||||
#[error("Invalid access token hash")]
|
|
||||||
InvalidAccessTokenHash,
|
|
||||||
|
|
||||||
#[error("User has no email address")]
|
|
||||||
MissingEmail,
|
|
||||||
|
|
||||||
#[error("HTTP client error: {0}")]
|
|
||||||
Http(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct OidcState {
|
|
||||||
pub csrf_token: CsrfToken,
|
|
||||||
pub nonce: OidcNonce,
|
|
||||||
pub pkce_verifier: PkceVerifier,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct OidcUser {
|
|
||||||
pub subject: String,
|
|
||||||
pub email: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct OidcService {
|
|
||||||
client: OidcClient,
|
|
||||||
http_client: reqwest::Client,
|
|
||||||
resource_id: Option<ResourceId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OidcService {
|
|
||||||
pub async fn new(
|
|
||||||
issuer: IssuerUrl,
|
|
||||||
client_id: ClientId,
|
|
||||||
client_secret: Option<ClientSecret>,
|
|
||||||
redirect_url: RedirectUrl,
|
|
||||||
resource_id: Option<ResourceId>,
|
|
||||||
) -> Result<Self, OidcError> {
|
|
||||||
tracing::debug!("OIDC setup: client_id={client_id}, redirect={redirect_url}");
|
|
||||||
tracing::debug!(
|
|
||||||
"OIDC setup: secret={}",
|
|
||||||
if client_secret.is_some() {
|
|
||||||
"SET"
|
|
||||||
} else {
|
|
||||||
"NONE"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let http_client = reqwest::ClientBuilder::new()
|
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
|
||||||
.build()
|
|
||||||
.map_err(|e| OidcError::Http(e.to_string()))?;
|
|
||||||
|
|
||||||
let provider_metadata = CoreProviderMetadata::discover_async(
|
|
||||||
openidconnect::IssuerUrl::new(issuer.as_ref().to_string())
|
|
||||||
.map_err(|e| OidcError::Discovery(e.to_string()))?,
|
|
||||||
&http_client,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| OidcError::Discovery(e.to_string()))?;
|
|
||||||
|
|
||||||
let oidc_client_id = openidconnect::ClientId::new(client_id.as_ref().to_string());
|
|
||||||
let oidc_client_secret = client_secret
|
|
||||||
.as_ref()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(|s| openidconnect::ClientSecret::new(s.as_ref().to_string()));
|
|
||||||
let oidc_redirect_url =
|
|
||||||
openidconnect::RedirectUrl::new(redirect_url.as_ref().to_string())
|
|
||||||
.map_err(|e| OidcError::Discovery(e.to_string()))?;
|
|
||||||
|
|
||||||
let client = CoreClient::from_provider_metadata(
|
|
||||||
provider_metadata,
|
|
||||||
oidc_client_id,
|
|
||||||
oidc_client_secret,
|
|
||||||
)
|
|
||||||
.set_redirect_uri(oidc_redirect_url);
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
client,
|
|
||||||
http_client,
|
|
||||||
resource_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_authorization_url(&self) -> (AuthorizationUrlData, OidcState) {
|
|
||||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
|
||||||
|
|
||||||
let (auth_url, csrf_token, nonce) = self
|
|
||||||
.client
|
|
||||||
.authorize_url(
|
|
||||||
CoreAuthenticationFlow::AuthorizationCode,
|
|
||||||
openidconnect::CsrfToken::new_random,
|
|
||||||
openidconnect::Nonce::new_random,
|
|
||||||
)
|
|
||||||
.add_scope(Scope::new("profile".to_string()))
|
|
||||||
.add_scope(Scope::new("email".to_string()))
|
|
||||||
.set_pkce_challenge(pkce_challenge)
|
|
||||||
.url();
|
|
||||||
|
|
||||||
let oidc_state = OidcState {
|
|
||||||
csrf_token: CsrfToken::new(csrf_token.secret().to_string()),
|
|
||||||
nonce: OidcNonce::new(nonce.secret().to_string()),
|
|
||||||
pkce_verifier: PkceVerifier::new(pkce_verifier.secret().to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let auth_data = AuthorizationUrlData {
|
|
||||||
url: auth_url.into(),
|
|
||||||
csrf_token: oidc_state.csrf_token.clone(),
|
|
||||||
nonce: oidc_state.nonce.clone(),
|
|
||||||
pkce_verifier: oidc_state.pkce_verifier.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
(auth_data, oidc_state)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn resolve_callback(
|
|
||||||
&self,
|
|
||||||
code: AuthorizationCode,
|
|
||||||
nonce: OidcNonce,
|
|
||||||
pkce_verifier: PkceVerifier,
|
|
||||||
) -> Result<OidcUser, OidcError> {
|
|
||||||
let oidc_pkce_verifier =
|
|
||||||
openidconnect::PkceCodeVerifier::new(pkce_verifier.as_ref().to_string());
|
|
||||||
let oidc_nonce = openidconnect::Nonce::new(nonce.as_ref().to_string());
|
|
||||||
|
|
||||||
let token_response = self
|
|
||||||
.client
|
|
||||||
.exchange_code(openidconnect::AuthorizationCode::new(
|
|
||||||
code.as_ref().to_string(),
|
|
||||||
))
|
|
||||||
.map_err(|e| OidcError::TokenExchange(e.to_string()))?
|
|
||||||
.set_pkce_verifier(oidc_pkce_verifier)
|
|
||||||
.request_async(&self.http_client)
|
|
||||||
.await
|
|
||||||
.map_err(|e| OidcError::TokenExchange(e.to_string()))?;
|
|
||||||
|
|
||||||
let id_token = token_response
|
|
||||||
.id_token()
|
|
||||||
.ok_or(OidcError::MissingIdToken)?;
|
|
||||||
|
|
||||||
let mut id_token_verifier = self.client.id_token_verifier().clone();
|
|
||||||
|
|
||||||
if let Some(resource_id) = &self.resource_id {
|
|
||||||
let trusted = resource_id.as_ref().to_string();
|
|
||||||
id_token_verifier =
|
|
||||||
id_token_verifier.set_other_audience_verifier_fn(move |aud| aud.as_str() == trusted);
|
|
||||||
}
|
|
||||||
|
|
||||||
let claims = id_token
|
|
||||||
.claims(&id_token_verifier, &oidc_nonce)
|
|
||||||
.map_err(|e| OidcError::IdTokenVerification(e.to_string()))?;
|
|
||||||
|
|
||||||
if let Some(expected_hash) = claims.access_token_hash() {
|
|
||||||
let actual_hash = AccessTokenHash::from_token(
|
|
||||||
token_response.access_token(),
|
|
||||||
id_token
|
|
||||||
.signing_alg()
|
|
||||||
.map_err(|e| OidcError::IdTokenVerification(e.to_string()))?,
|
|
||||||
id_token
|
|
||||||
.signing_key(&id_token_verifier)
|
|
||||||
.map_err(|e| OidcError::IdTokenVerification(e.to_string()))?,
|
|
||||||
)
|
|
||||||
.map_err(|e| OidcError::IdTokenVerification(e.to_string()))?;
|
|
||||||
|
|
||||||
if actual_hash != *expected_hash {
|
|
||||||
return Err(OidcError::InvalidAccessTokenHash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let email = if let Some(email) = claims.email() {
|
|
||||||
Some(email.as_str().to_string())
|
|
||||||
} else {
|
|
||||||
tracing::debug!("Email missing in ID token, fetching UserInfo");
|
|
||||||
|
|
||||||
let user_info: UserInfoClaims<EmptyAdditionalClaims, CoreGenderClaim> = self
|
|
||||||
.client
|
|
||||||
.user_info(token_response.access_token().clone(), None)
|
|
||||||
.map_err(|e| OidcError::TokenExchange(e.to_string()))?
|
|
||||||
.request_async(&self.http_client)
|
|
||||||
.await
|
|
||||||
.map_err(|e| OidcError::TokenExchange(e.to_string()))?;
|
|
||||||
|
|
||||||
user_info.email().map(|e| e.as_str().to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
let email = email.ok_or(OidcError::MissingEmail)?;
|
|
||||||
|
|
||||||
Ok(OidcUser {
|
|
||||||
subject: claims.subject().to_string(),
|
|
||||||
email,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "adapter-postgres"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
domain = { workspace = true }
|
|
||||||
adapter-common = { workspace = true }
|
|
||||||
infra-wiring = { workspace = true, features = ["postgres"] }
|
|
||||||
async-trait = { workspace = true }
|
|
||||||
sqlx = { workspace = true, features = ["postgres"] }
|
|
||||||
chrono = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use chrono::Utc;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use adapter_common::{map_sqlx_error, parse_dt, parse_uuid};
|
|
||||||
use domain::{
|
|
||||||
ports::activity::{ActivityLogCommand, ActivityLogQuery},
|
|
||||||
ActivityEvent, ActivityEventId, ChannelId, DomainResult,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct PgActivityLog {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PgActivityLog {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ActivityLogCommand for PgActivityLog {
|
|
||||||
async fn log(
|
|
||||||
&self,
|
|
||||||
event_type: &str,
|
|
||||||
detail: &str,
|
|
||||||
channel_id: Option<ChannelId>,
|
|
||||||
) -> DomainResult<()> {
|
|
||||||
let id = Uuid::new_v4().to_string();
|
|
||||||
let timestamp = Utc::now().to_rfc3339();
|
|
||||||
let channel_id_str = channel_id.map(|id| id.value().to_string());
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO activity_log (id, timestamp, event_type, detail, channel_id) VALUES ($1, $2, $3, $4, $5)",
|
|
||||||
)
|
|
||||||
.bind(&id)
|
|
||||||
.bind(×tamp)
|
|
||||||
.bind(event_type)
|
|
||||||
.bind(detail)
|
|
||||||
.bind(&channel_id_str)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ActivityLogQuery for PgActivityLog {
|
|
||||||
async fn recent(&self, limit: u32) -> DomainResult<Vec<ActivityEvent>> {
|
|
||||||
let rows: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as(
|
|
||||||
"SELECT id, timestamp, event_type, detail, channel_id FROM activity_log ORDER BY timestamp DESC LIMIT $1",
|
|
||||||
)
|
|
||||||
.bind(limit as i64)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
let mut events = Vec::with_capacity(rows.len());
|
|
||||||
for (id_str, ts_str, event_type, detail, channel_id_str) in rows {
|
|
||||||
let Ok(id) = parse_uuid(&id_str, "activity id") else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Ok(timestamp) = parse_dt(&ts_str) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let channel_id = channel_id_str
|
|
||||||
.and_then(|s| Uuid::parse_str(&s).ok())
|
|
||||||
.map(ChannelId::from_uuid);
|
|
||||||
events.push(ActivityEvent::from_persistence(
|
|
||||||
ActivityEventId::from_uuid(id),
|
|
||||||
timestamp,
|
|
||||||
event_type,
|
|
||||||
detail,
|
|
||||||
channel_id,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(events)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use sqlx::{PgPool, Row};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use adapter_common::{
|
|
||||||
map_sqlx_error, parse_dt, parse_enum_or_default, parse_recycle_policy, parse_schedule_config,
|
|
||||||
parse_uuid, serialize_enum_as_string,
|
|
||||||
};
|
|
||||||
use domain::{
|
|
||||||
ports::channel::{ChannelCommand, ChannelQuery},
|
|
||||||
Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow, DomainError,
|
|
||||||
DomainResult, ScheduleConfig, SnapshotId, UserId,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct PgChannelRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PgChannelRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)]
|
|
||||||
struct ChannelRow {
|
|
||||||
id: String,
|
|
||||||
owner_id: String,
|
|
||||||
name: String,
|
|
||||||
description: Option<String>,
|
|
||||||
timezone: String,
|
|
||||||
schedule_config: String,
|
|
||||||
recycle_policy: String,
|
|
||||||
auto_schedule: bool,
|
|
||||||
access_mode: String,
|
|
||||||
access_password_hash: Option<String>,
|
|
||||||
logo: Option<String>,
|
|
||||||
logo_position: String,
|
|
||||||
logo_opacity: f32,
|
|
||||||
webhook_url: Option<String>,
|
|
||||||
webhook_poll_interval_secs: i64,
|
|
||||||
webhook_body_template: Option<String>,
|
|
||||||
webhook_headers: Option<String>,
|
|
||||||
created_at: String,
|
|
||||||
updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelRow {
|
|
||||||
fn into_channel(self) -> DomainResult<Channel> {
|
|
||||||
Ok(Channel::from_persistence(DomainChannelRow {
|
|
||||||
id: ChannelId::from_uuid(parse_uuid(&self.id, "channel id")?),
|
|
||||||
owner_id: UserId::from_uuid(parse_uuid(&self.owner_id, "owner id")?),
|
|
||||||
name: self.name,
|
|
||||||
description: self.description,
|
|
||||||
timezone: self.timezone,
|
|
||||||
schedule_config: parse_schedule_config(&self.schedule_config)?,
|
|
||||||
recycle_policy: parse_recycle_policy(&self.recycle_policy)?,
|
|
||||||
auto_schedule: self.auto_schedule,
|
|
||||||
access_mode: parse_enum_or_default(self.access_mode),
|
|
||||||
access_password_hash: self.access_password_hash,
|
|
||||||
logo: self.logo,
|
|
||||||
logo_position: parse_enum_or_default(self.logo_position),
|
|
||||||
logo_opacity: self.logo_opacity,
|
|
||||||
webhook_url: self.webhook_url,
|
|
||||||
webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32,
|
|
||||||
webhook_body_template: self.webhook_body_template,
|
|
||||||
webhook_headers: self.webhook_headers,
|
|
||||||
created_at: parse_dt(&self.created_at)?,
|
|
||||||
updated_at: parse_dt(&self.updated_at)?,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_snapshot_row(
|
|
||||||
row: &sqlx::postgres::PgRow,
|
|
||||||
channel_id: ChannelId,
|
|
||||||
) -> DomainResult<ChannelConfigSnapshot> {
|
|
||||||
let id_str: String = row.get("id");
|
|
||||||
let id = SnapshotId::from_uuid(parse_uuid(&id_str, "snapshot id")?);
|
|
||||||
let config_json: String = row.get("config_json");
|
|
||||||
let config = parse_schedule_config(&config_json)?;
|
|
||||||
let version_num: i64 = row.get("version_num");
|
|
||||||
let label: Option<String> = row.get("label");
|
|
||||||
let created_at_str: String = row.get("created_at");
|
|
||||||
let created_at: DateTime<Utc> = parse_dt(&created_at_str)?;
|
|
||||||
|
|
||||||
Ok(ChannelConfigSnapshot::from_persistence(
|
|
||||||
id,
|
|
||||||
channel_id,
|
|
||||||
config,
|
|
||||||
version_num,
|
|
||||||
label,
|
|
||||||
created_at,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ChannelCommand for PgChannelRepository {
|
|
||||||
async fn save(&self, channel: &Channel) -> DomainResult<()> {
|
|
||||||
let schedule_config = serde_json::to_string(channel.schedule_config())
|
|
||||||
.map_err(|e| DomainError::RepositoryError(format!("serialize schedule_config: {e}")))?;
|
|
||||||
let recycle_policy = serde_json::to_string(channel.recycle_policy())
|
|
||||||
.map_err(|e| DomainError::RepositoryError(format!("serialize recycle_policy: {e}")))?;
|
|
||||||
let access_mode = serialize_enum_as_string(channel.access_mode(), "public");
|
|
||||||
let logo_position = serialize_enum_as_string(channel.logo_position(), "top_right");
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO channels
|
|
||||||
(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)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
timezone = EXCLUDED.timezone,
|
|
||||||
schedule_config = EXCLUDED.schedule_config,
|
|
||||||
recycle_policy = EXCLUDED.recycle_policy,
|
|
||||||
auto_schedule = EXCLUDED.auto_schedule,
|
|
||||||
access_mode = EXCLUDED.access_mode,
|
|
||||||
access_password_hash = EXCLUDED.access_password_hash,
|
|
||||||
logo = EXCLUDED.logo,
|
|
||||||
logo_position = EXCLUDED.logo_position,
|
|
||||||
logo_opacity = EXCLUDED.logo_opacity,
|
|
||||||
webhook_url = EXCLUDED.webhook_url,
|
|
||||||
webhook_poll_interval_secs = EXCLUDED.webhook_poll_interval_secs,
|
|
||||||
webhook_body_template = EXCLUDED.webhook_body_template,
|
|
||||||
webhook_headers = EXCLUDED.webhook_headers,
|
|
||||||
updated_at = EXCLUDED.updated_at
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(channel.id().value().to_string())
|
|
||||||
.bind(channel.owner_id().value().to_string())
|
|
||||||
.bind(channel.name())
|
|
||||||
.bind(channel.description())
|
|
||||||
.bind(channel.timezone())
|
|
||||||
.bind(&schedule_config)
|
|
||||||
.bind(&recycle_policy)
|
|
||||||
.bind(channel.auto_schedule())
|
|
||||||
.bind(&access_mode)
|
|
||||||
.bind(channel.access_password_hash())
|
|
||||||
.bind(channel.logo())
|
|
||||||
.bind(&logo_position)
|
|
||||||
.bind(channel.logo_opacity())
|
|
||||||
.bind(channel.webhook_url())
|
|
||||||
.bind(channel.webhook_poll_interval_secs() as i64)
|
|
||||||
.bind(channel.webhook_body_template())
|
|
||||||
.bind(channel.webhook_headers())
|
|
||||||
.bind(channel.created_at().to_rfc3339())
|
|
||||||
.bind(channel.updated_at().to_rfc3339())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, id: ChannelId) -> DomainResult<()> {
|
|
||||||
sqlx::query("DELETE FROM channels WHERE id = $1")
|
|
||||||
.bind(id.value().to_string())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save_config_snapshot(
|
|
||||||
&self,
|
|
||||||
channel_id: ChannelId,
|
|
||||||
config: &ScheduleConfig,
|
|
||||||
label: Option<String>,
|
|
||||||
) -> DomainResult<ChannelConfigSnapshot> {
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
let now = Utc::now();
|
|
||||||
let config_json = serde_json::to_string(config)
|
|
||||||
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
let version_num: i64 = sqlx::query_scalar(
|
|
||||||
"SELECT COALESCE(MAX(version_num), 0) + 1 FROM channel_config_snapshots WHERE channel_id = $1",
|
|
||||||
)
|
|
||||||
.bind(channel_id.value().to_string())
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO channel_config_snapshots (id, channel_id, config_json, version_num, label, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
|
||||||
)
|
|
||||||
.bind(id.to_string())
|
|
||||||
.bind(channel_id.value().to_string())
|
|
||||||
.bind(&config_json)
|
|
||||||
.bind(version_num)
|
|
||||||
.bind(&label)
|
|
||||||
.bind(now.to_rfc3339())
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
tx.commit().await.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(ChannelConfigSnapshot::from_persistence(
|
|
||||||
SnapshotId::from_uuid(id),
|
|
||||||
channel_id,
|
|
||||||
config.clone(),
|
|
||||||
version_num,
|
|
||||||
label,
|
|
||||||
now,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn patch_config_snapshot_label(
|
|
||||||
&self,
|
|
||||||
channel_id: ChannelId,
|
|
||||||
snapshot_id: SnapshotId,
|
|
||||||
label: Option<String>,
|
|
||||||
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
|
||||||
let updated = sqlx::query(
|
|
||||||
"UPDATE channel_config_snapshots SET label = $1 WHERE id = $2 AND channel_id = $3 RETURNING id",
|
|
||||||
)
|
|
||||||
.bind(&label)
|
|
||||||
.bind(snapshot_id.value().to_string())
|
|
||||||
.bind(channel_id.value().to_string())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
if updated.is_none() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
self.get_config_snapshot(channel_id, snapshot_id).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ChannelQuery for PgChannelRepository {
|
|
||||||
async fn find_by_id(&self, id: ChannelId) -> DomainResult<Option<Channel>> {
|
|
||||||
let sql = format!("SELECT {SELECT_COLS} FROM channels WHERE id = $1");
|
|
||||||
let row: Option<ChannelRow> = sqlx::query_as(&sql)
|
|
||||||
.bind(id.value().to_string())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
row.map(ChannelRow::into_channel).transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_owner(&self, owner_id: UserId) -> DomainResult<Vec<Channel>> {
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT {SELECT_COLS} FROM channels WHERE owner_id = $1 ORDER BY created_at ASC"
|
|
||||||
);
|
|
||||||
let rows: Vec<ChannelRow> = sqlx::query_as(&sql)
|
|
||||||
.bind(owner_id.value().to_string())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
rows.into_iter().map(ChannelRow::into_channel).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_all(&self) -> DomainResult<Vec<Channel>> {
|
|
||||||
let sql = format!("SELECT {SELECT_COLS} FROM channels ORDER BY created_at ASC");
|
|
||||||
let rows: Vec<ChannelRow> = sqlx::query_as(&sql)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
rows.into_iter().map(ChannelRow::into_channel).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_auto_schedule_enabled(&self) -> DomainResult<Vec<Channel>> {
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT {SELECT_COLS} FROM channels WHERE auto_schedule = TRUE ORDER BY created_at ASC"
|
|
||||||
);
|
|
||||||
let rows: Vec<ChannelRow> = sqlx::query_as(&sql)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
rows.into_iter().map(ChannelRow::into_channel).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_config_snapshots(
|
|
||||||
&self,
|
|
||||||
channel_id: ChannelId,
|
|
||||||
) -> DomainResult<Vec<ChannelConfigSnapshot>> {
|
|
||||||
let rows = sqlx::query(
|
|
||||||
"SELECT id, config_json, version_num, label, created_at
|
|
||||||
FROM channel_config_snapshots WHERE channel_id = $1
|
|
||||||
ORDER BY version_num DESC",
|
|
||||||
)
|
|
||||||
.bind(channel_id.value().to_string())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
rows.iter()
|
|
||||||
.map(|row| map_snapshot_row(row, channel_id))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_config_snapshot(
|
|
||||||
&self,
|
|
||||||
channel_id: ChannelId,
|
|
||||||
snapshot_id: SnapshotId,
|
|
||||||
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
|
||||||
let row = sqlx::query(
|
|
||||||
"SELECT id, config_json, version_num, label, created_at
|
|
||||||
FROM channel_config_snapshots WHERE id = $1 AND channel_id = $2",
|
|
||||||
)
|
|
||||||
.bind(snapshot_id.value().to_string())
|
|
||||||
.bind(channel_id.value().to_string())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
match row {
|
|
||||||
None => Ok(None),
|
|
||||||
Some(row) => Ok(Some(map_snapshot_row(&row, channel_id)?)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
pub mod activity;
|
|
||||||
pub mod channel;
|
|
||||||
pub mod library;
|
|
||||||
pub mod provider_config;
|
|
||||||
pub mod schedule;
|
|
||||||
pub mod settings;
|
|
||||||
pub mod transcode;
|
|
||||||
pub mod user;
|
|
||||||
pub mod wire;
|
|
||||||
|
|
||||||
pub use wire::{wire, PostgresWireOutput};
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
use adapter_common::{content_type_str, parse_content_type, parse_genres_blob};
|
|
||||||
use domain::{
|
|
||||||
ports::library::{LibraryCommand, LibraryQuery},
|
|
||||||
ContentType, DomainError, DomainResult, LibraryCollection, LibraryItem,
|
|
||||||
LibraryItemRow as DomainLibraryItemRow, LibrarySearchFilter, LibrarySyncLogEntry,
|
|
||||||
LibrarySyncResult, SeasonSummary, ShowSummary,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct PgLibraryRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PgLibraryRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct LibraryItemRow {
|
|
||||||
id: String,
|
|
||||||
provider_id: String,
|
|
||||||
external_id: String,
|
|
||||||
title: String,
|
|
||||||
content_type: String,
|
|
||||||
duration_secs: i64,
|
|
||||||
series_name: Option<String>,
|
|
||||||
season_number: Option<i64>,
|
|
||||||
episode_number: Option<i64>,
|
|
||||||
year: Option<i64>,
|
|
||||||
genres: String,
|
|
||||||
tags: String,
|
|
||||||
collection_id: Option<String>,
|
|
||||||
collection_name: Option<String>,
|
|
||||||
collection_type: Option<String>,
|
|
||||||
thumbnail_url: Option<String>,
|
|
||||||
synced_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LibraryItemRow {
|
|
||||||
fn into_library_item(self) -> LibraryItem {
|
|
||||||
LibraryItem::from_persistence(DomainLibraryItemRow {
|
|
||||||
id: self.id,
|
|
||||||
provider_id: self.provider_id,
|
|
||||||
external_id: self.external_id,
|
|
||||||
title: self.title,
|
|
||||||
content_type: parse_content_type(&self.content_type),
|
|
||||||
duration_secs: self.duration_secs as u32,
|
|
||||||
series_name: self.series_name,
|
|
||||||
season_number: self.season_number.map(|n| n as u32),
|
|
||||||
episode_number: self.episode_number.map(|n| n as u32),
|
|
||||||
year: self.year.map(|n| n as u16),
|
|
||||||
genres: serde_json::from_str(&self.genres).unwrap_or_default(),
|
|
||||||
tags: serde_json::from_str(&self.tags).unwrap_or_default(),
|
|
||||||
collection_id: self.collection_id,
|
|
||||||
collection_name: self.collection_name,
|
|
||||||
collection_type: self.collection_type,
|
|
||||||
thumbnail_url: self.thumbnail_url,
|
|
||||||
synced_at: self.synced_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct SyncLogRow {
|
|
||||||
id: i64,
|
|
||||||
provider_id: String,
|
|
||||||
started_at: String,
|
|
||||||
finished_at: Option<String>,
|
|
||||||
items_found: i64,
|
|
||||||
status: String,
|
|
||||||
error_msg: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct ShowSummaryRow {
|
|
||||||
series_name: String,
|
|
||||||
episode_count: i64,
|
|
||||||
season_count: i64,
|
|
||||||
thumbnail_url: Option<String>,
|
|
||||||
genres_blob: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct SeasonSummaryRow {
|
|
||||||
season_number: i64,
|
|
||||||
episode_count: i64,
|
|
||||||
thumbnail_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl LibraryCommand for PgLibraryRepository {
|
|
||||||
async fn upsert_items(&self, _provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()> {
|
|
||||||
let mut tx = self
|
|
||||||
.pool
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
|
|
||||||
for item in items {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO library_items
|
|
||||||
(id, provider_id, external_id, title, content_type, duration_secs,
|
|
||||||
series_name, season_number, episode_number, year, genres, tags,
|
|
||||||
collection_id, collection_name, collection_type, thumbnail_url, synced_at)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
provider_id = EXCLUDED.provider_id,
|
|
||||||
external_id = EXCLUDED.external_id,
|
|
||||||
title = EXCLUDED.title,
|
|
||||||
content_type = EXCLUDED.content_type,
|
|
||||||
duration_secs = EXCLUDED.duration_secs,
|
|
||||||
series_name = EXCLUDED.series_name,
|
|
||||||
season_number = EXCLUDED.season_number,
|
|
||||||
episode_number = EXCLUDED.episode_number,
|
|
||||||
year = EXCLUDED.year,
|
|
||||||
genres = EXCLUDED.genres,
|
|
||||||
tags = EXCLUDED.tags,
|
|
||||||
collection_id = EXCLUDED.collection_id,
|
|
||||||
collection_name = EXCLUDED.collection_name,
|
|
||||||
collection_type = EXCLUDED.collection_type,
|
|
||||||
thumbnail_url = EXCLUDED.thumbnail_url,
|
|
||||||
synced_at = EXCLUDED.synced_at",
|
|
||||||
)
|
|
||||||
.bind(item.id())
|
|
||||||
.bind(item.provider_id())
|
|
||||||
.bind(item.external_id())
|
|
||||||
.bind(item.title())
|
|
||||||
.bind(content_type_str(item.content_type()))
|
|
||||||
.bind(item.duration_secs() as i64)
|
|
||||||
.bind(item.series_name())
|
|
||||||
.bind(item.season_number().map(|n| n as i64))
|
|
||||||
.bind(item.episode_number().map(|n| n as i64))
|
|
||||||
.bind(item.year().map(|n| n as i64))
|
|
||||||
.bind(serde_json::to_string(item.genres()).unwrap_or_default())
|
|
||||||
.bind(serde_json::to_string(item.tags()).unwrap_or_default())
|
|
||||||
.bind(item.collection_id())
|
|
||||||
.bind(item.collection_name())
|
|
||||||
.bind(item.collection_type())
|
|
||||||
.bind(item.thumbnail_url())
|
|
||||||
.bind(item.synced_at())
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()> {
|
|
||||||
sqlx::query("DELETE FROM library_items WHERE provider_id = $1")
|
|
||||||
.bind(provider_id)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn log_sync_start(&self, provider_id: &str) -> DomainResult<i64> {
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
let id = sqlx::query_scalar::<_, i64>(
|
|
||||||
"INSERT INTO library_sync_log (provider_id, started_at, status)
|
|
||||||
VALUES ($1, $2, 'running') RETURNING id",
|
|
||||||
)
|
|
||||||
.bind(provider_id)
|
|
||||||
.bind(&now)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()> {
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
let status = if result.error().is_none() {
|
|
||||||
"done"
|
|
||||||
} else {
|
|
||||||
"error"
|
|
||||||
};
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE library_sync_log
|
|
||||||
SET finished_at = $1, items_found = $2, status = $3, error_msg = $4
|
|
||||||
WHERE id = $5",
|
|
||||||
)
|
|
||||||
.bind(&now)
|
|
||||||
.bind(result.items_found() as i64)
|
|
||||||
.bind(status)
|
|
||||||
.bind(result.error())
|
|
||||||
.bind(log_id)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl LibraryQuery for PgLibraryRepository {
|
|
||||||
async fn search(
|
|
||||||
&self,
|
|
||||||
filter: &LibrarySearchFilter,
|
|
||||||
) -> DomainResult<(Vec<LibraryItem>, u32)> {
|
|
||||||
let mut conditions: Vec<String> = vec![];
|
|
||||||
|
|
||||||
if let Some(p) = filter.provider_id() {
|
|
||||||
conditions.push(format!("provider_id = '{}'", p.replace('\'', "''")));
|
|
||||||
}
|
|
||||||
if let Some(ct) = filter.content_type() {
|
|
||||||
conditions.push(format!("content_type = '{}'", content_type_str(ct)));
|
|
||||||
}
|
|
||||||
if let Some(st) = filter.search_term() {
|
|
||||||
conditions.push(format!("title ILIKE '%{}%'", st.replace('\'', "''")));
|
|
||||||
}
|
|
||||||
if let Some(cid) = filter.collection_id() {
|
|
||||||
conditions.push(format!("collection_id = '{}'", cid.replace('\'', "''")));
|
|
||||||
}
|
|
||||||
if let Some(decade) = filter.decade() {
|
|
||||||
let end = decade + 10;
|
|
||||||
conditions.push(format!("year >= {} AND year < {}", decade, end));
|
|
||||||
}
|
|
||||||
if let Some(min) = filter.min_duration_secs() {
|
|
||||||
conditions.push(format!("duration_secs >= {}", min));
|
|
||||||
}
|
|
||||||
if let Some(max) = filter.max_duration_secs() {
|
|
||||||
conditions.push(format!("duration_secs <= {}", max));
|
|
||||||
}
|
|
||||||
if !filter.series_names().is_empty() {
|
|
||||||
let quoted: Vec<String> = filter
|
|
||||||
.series_names()
|
|
||||||
.iter()
|
|
||||||
.map(|s| format!("'{}'", s.replace('\'', "''")))
|
|
||||||
.collect();
|
|
||||||
conditions.push(format!("series_name IN ({})", quoted.join(",")));
|
|
||||||
}
|
|
||||||
if !filter.genres().is_empty() {
|
|
||||||
let genre_conditions: Vec<String> = filter
|
|
||||||
.genres()
|
|
||||||
.iter()
|
|
||||||
.map(|g| {
|
|
||||||
format!(
|
|
||||||
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(library_items.genres::jsonb) je WHERE je = '{}')",
|
|
||||||
g.replace('\'', "''")
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
conditions.push(format!("({})", genre_conditions.join(" OR ")));
|
|
||||||
}
|
|
||||||
if let Some(sn) = filter.season_number() {
|
|
||||||
conditions.push(format!("season_number = {}", sn));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_clause = if conditions.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("WHERE {}", conditions.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM library_items {}", where_clause);
|
|
||||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
|
|
||||||
let items_sql = format!(
|
|
||||||
"SELECT * FROM library_items {} ORDER BY title ASC LIMIT {} OFFSET {}",
|
|
||||||
where_clause,
|
|
||||||
filter.limit(),
|
|
||||||
filter.offset()
|
|
||||||
);
|
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, LibraryItemRow>(&items_sql)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
rows.into_iter()
|
|
||||||
.map(LibraryItemRow::into_library_item)
|
|
||||||
.collect(),
|
|
||||||
total as u32,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>> {
|
|
||||||
let row = sqlx::query_as::<_, LibraryItemRow>(
|
|
||||||
"SELECT * FROM library_items WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
Ok(row.map(LibraryItemRow::into_library_item))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_collections(
|
|
||||||
&self,
|
|
||||||
provider_id: Option<&str>,
|
|
||||||
) -> DomainResult<Vec<LibraryCollection>> {
|
|
||||||
let rows: Vec<(String, Option<String>, Option<String>)> = if let Some(p) = provider_id {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT DISTINCT collection_id, collection_name, collection_type
|
|
||||||
FROM library_items WHERE collection_id IS NOT NULL AND provider_id = $1
|
|
||||||
ORDER BY collection_name ASC",
|
|
||||||
)
|
|
||||||
.bind(p)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT DISTINCT collection_id, collection_name, collection_type
|
|
||||||
FROM library_items WHERE collection_id IS NOT NULL
|
|
||||||
ORDER BY collection_name ASC",
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|(id, name, ct)| {
|
|
||||||
LibraryCollection::from_persistence(id, name.unwrap_or_default(), ct)
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_series(&self, provider_id: Option<&str>) -> DomainResult<Vec<String>> {
|
|
||||||
let rows: Vec<(String,)> = if let Some(p) = provider_id {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT DISTINCT series_name FROM library_items
|
|
||||||
WHERE series_name IS NOT NULL AND provider_id = $1 ORDER BY series_name ASC",
|
|
||||||
)
|
|
||||||
.bind(p)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT DISTINCT series_name FROM library_items
|
|
||||||
WHERE series_name IS NOT NULL ORDER BY series_name ASC",
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(|(s,)| s).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_genres(
|
|
||||||
&self,
|
|
||||||
content_type: Option<&ContentType>,
|
|
||||||
provider_id: Option<&str>,
|
|
||||||
) -> DomainResult<Vec<String>> {
|
|
||||||
let sql = match (content_type, provider_id) {
|
|
||||||
(Some(ct), Some(p)) => format!(
|
|
||||||
"SELECT DISTINCT je AS value FROM library_items li, \
|
|
||||||
LATERAL jsonb_array_elements_text(li.genres::jsonb) je \
|
|
||||||
WHERE li.content_type = '{}' AND li.provider_id = '{}' ORDER BY value ASC",
|
|
||||||
content_type_str(ct),
|
|
||||||
p.replace('\'', "''")
|
|
||||||
),
|
|
||||||
(Some(ct), None) => format!(
|
|
||||||
"SELECT DISTINCT je AS value FROM library_items li, \
|
|
||||||
LATERAL jsonb_array_elements_text(li.genres::jsonb) je \
|
|
||||||
WHERE li.content_type = '{}' ORDER BY value ASC",
|
|
||||||
content_type_str(ct)
|
|
||||||
),
|
|
||||||
(None, Some(p)) => format!(
|
|
||||||
"SELECT DISTINCT je AS value FROM library_items li, \
|
|
||||||
LATERAL jsonb_array_elements_text(li.genres::jsonb) je \
|
|
||||||
WHERE li.provider_id = '{}' ORDER BY value ASC",
|
|
||||||
p.replace('\'', "''")
|
|
||||||
),
|
|
||||||
(None, None) => {
|
|
||||||
"SELECT DISTINCT je AS value FROM library_items li, \
|
|
||||||
LATERAL jsonb_array_elements_text(li.genres::jsonb) je \
|
|
||||||
ORDER BY value ASC"
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let rows: Vec<(String,)> = sqlx::query_as(&sql)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
Ok(rows.into_iter().map(|(s,)| s).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn latest_sync_status(&self) -> DomainResult<Vec<LibrarySyncLogEntry>> {
|
|
||||||
let rows = sqlx::query_as::<_, SyncLogRow>(
|
|
||||||
"SELECT * FROM library_sync_log
|
|
||||||
WHERE id IN (
|
|
||||||
SELECT MAX(id) FROM library_sync_log GROUP BY provider_id
|
|
||||||
)
|
|
||||||
ORDER BY started_at DESC",
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
LibrarySyncLogEntry::from_persistence(
|
|
||||||
r.id,
|
|
||||||
r.provider_id,
|
|
||||||
r.started_at,
|
|
||||||
r.finished_at,
|
|
||||||
r.items_found as u32,
|
|
||||||
r.status,
|
|
||||||
r.error_msg,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn is_sync_running(&self, provider_id: &str) -> DomainResult<bool> {
|
|
||||||
let count: i64 = sqlx::query_scalar(
|
|
||||||
"SELECT COUNT(*) FROM library_sync_log WHERE provider_id = $1 AND status = 'running'",
|
|
||||||
)
|
|
||||||
.bind(provider_id)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
Ok(count > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_shows(
|
|
||||||
&self,
|
|
||||||
provider_id: Option<&str>,
|
|
||||||
search_term: Option<&str>,
|
|
||||||
genres: &[String],
|
|
||||||
) -> DomainResult<Vec<ShowSummary>> {
|
|
||||||
let mut conditions = vec![
|
|
||||||
"content_type = 'episode'".to_string(),
|
|
||||||
"series_name IS NOT NULL".to_string(),
|
|
||||||
];
|
|
||||||
if let Some(p) = provider_id {
|
|
||||||
conditions.push(format!("provider_id = '{}'", p.replace('\'', "''")));
|
|
||||||
}
|
|
||||||
if let Some(st) = search_term {
|
|
||||||
let escaped = st.replace('\'', "''");
|
|
||||||
conditions.push(format!(
|
|
||||||
"(title ILIKE '%{escaped}%' OR series_name ILIKE '%{escaped}%')"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !genres.is_empty() {
|
|
||||||
let genre_conditions: Vec<String> = genres
|
|
||||||
.iter()
|
|
||||||
.map(|g| {
|
|
||||||
format!(
|
|
||||||
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(library_items.genres::jsonb) je WHERE je = '{}')",
|
|
||||||
g.replace('\'', "''")
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
conditions.push(format!("({})", genre_conditions.join(" OR ")));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_clause = format!("WHERE {}", conditions.join(" AND "));
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT series_name, COUNT(*) AS episode_count, \
|
|
||||||
COUNT(DISTINCT season_number) AS season_count, \
|
|
||||||
MAX(thumbnail_url) AS thumbnail_url, \
|
|
||||||
STRING_AGG(genres, ',') AS genres_blob \
|
|
||||||
FROM library_items {} GROUP BY series_name ORDER BY series_name ASC",
|
|
||||||
where_clause
|
|
||||||
);
|
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, ShowSummaryRow>(&sql)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
let genres = parse_genres_blob(&r.genres_blob);
|
|
||||||
ShowSummary::from_persistence(
|
|
||||||
r.series_name,
|
|
||||||
r.episode_count as u32,
|
|
||||||
r.season_count as u32,
|
|
||||||
r.thumbnail_url,
|
|
||||||
genres,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_seasons(
|
|
||||||
&self,
|
|
||||||
series_name: &str,
|
|
||||||
provider_id: Option<&str>,
|
|
||||||
) -> DomainResult<Vec<SeasonSummary>> {
|
|
||||||
let mut conditions = vec![
|
|
||||||
format!("series_name = '{}'", series_name.replace('\'', "''")),
|
|
||||||
"content_type = 'episode'".to_string(),
|
|
||||||
];
|
|
||||||
if let Some(p) = provider_id {
|
|
||||||
conditions.push(format!("provider_id = '{}'", p.replace('\'', "''")));
|
|
||||||
}
|
|
||||||
let where_clause = format!("WHERE {}", conditions.join(" AND "));
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT season_number, COUNT(*) AS episode_count, \
|
|
||||||
MAX(thumbnail_url) AS thumbnail_url \
|
|
||||||
FROM library_items {} GROUP BY season_number ORDER BY season_number ASC",
|
|
||||||
where_clause
|
|
||||||
);
|
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, SeasonSummaryRow>(&sql)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
SeasonSummary::from_persistence(
|
|
||||||
r.season_number as u32,
|
|
||||||
r.episode_count as u32,
|
|
||||||
r.thumbnail_url,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
use adapter_common::map_sqlx_error;
|
|
||||||
use domain::{
|
|
||||||
ports::provider_config::{ProviderConfigCommand, ProviderConfigQuery},
|
|
||||||
DomainResult, ProviderConfigRow,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct PgProviderConfig {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PgProviderConfig {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ProviderConfigCommand for PgProviderConfig {
|
|
||||||
async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"INSERT INTO provider_configs (id, provider_type, config_json, enabled, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
provider_type = EXCLUDED.provider_type,
|
|
||||||
config_json = EXCLUDED.config_json,
|
|
||||||
enabled = EXCLUDED.enabled,
|
|
||||||
updated_at = EXCLUDED.updated_at"#,
|
|
||||||
)
|
|
||||||
.bind(row.id())
|
|
||||||
.bind(row.provider_type())
|
|
||||||
.bind(row.config_json())
|
|
||||||
.bind(row.enabled())
|
|
||||||
.bind(row.updated_at())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, id: &str) -> DomainResult<()> {
|
|
||||||
sqlx::query("DELETE FROM provider_configs WHERE id = $1")
|
|
||||||
.bind(id)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ProviderConfigQuery for PgProviderConfig {
|
|
||||||
async fn get_all(&self) -> DomainResult<Vec<ProviderConfigRow>> {
|
|
||||||
let rows: Vec<(String, String, String, bool, String)> = sqlx::query_as(
|
|
||||||
"SELECT id, provider_type, config_json, enabled, updated_at FROM provider_configs",
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|(id, provider_type, config_json, enabled, updated_at)| {
|
|
||||||
ProviderConfigRow::from_persistence(
|
|
||||||
id,
|
|
||||||
provider_type,
|
|
||||||
config_json,
|
|
||||||
enabled,
|
|
||||||
updated_at,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_by_id(&self, id: &str) -> DomainResult<Option<ProviderConfigRow>> {
|
|
||||||
let row: Option<(String, String, String, bool, String)> = sqlx::query_as(
|
|
||||||
"SELECT id, provider_type, config_json, enabled, updated_at FROM provider_configs WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(row.map(|(id, provider_type, config_json, enabled, updated_at)| {
|
|
||||||
ProviderConfigRow::from_persistence(id, provider_type, config_json, enabled, updated_at)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
use adapter_common::{map_sqlx_error, parse_dt, parse_json, parse_uuid};
|
|
||||||
use domain::{
|
|
||||||
ports::schedule::{ScheduleCommand, ScheduleQuery},
|
|
||||||
BlockId, ChannelId, DomainError, DomainResult, GeneratedSchedule, MediaItem, MediaItemId,
|
|
||||||
PlaybackRecord, PlaybackRecordId, ScheduleId, ScheduledSlot, SlotId,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct PgScheduleRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PgScheduleRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct ScheduleRow {
|
|
||||||
id: String,
|
|
||||||
channel_id: String,
|
|
||||||
valid_from: String,
|
|
||||||
valid_until: String,
|
|
||||||
generation: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct SlotRow {
|
|
||||||
id: String,
|
|
||||||
#[sqlx(rename = "schedule_id")]
|
|
||||||
_schedule_id: String,
|
|
||||||
start_at: String,
|
|
||||||
end_at: String,
|
|
||||||
item: String,
|
|
||||||
source_block_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct LastSlotRow {
|
|
||||||
source_block_id: String,
|
|
||||||
item: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct PlaybackRecordRow {
|
|
||||||
id: String,
|
|
||||||
channel_id: String,
|
|
||||||
item_id: String,
|
|
||||||
played_at: String,
|
|
||||||
generation: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_slot_row(row: SlotRow) -> DomainResult<ScheduledSlot> {
|
|
||||||
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 item: MediaItem = parse_json(&row.item, "slot item")?;
|
|
||||||
|
|
||||||
Ok(ScheduledSlot::from_persistence(
|
|
||||||
id,
|
|
||||||
parse_dt(&row.start_at)?,
|
|
||||||
parse_dt(&row.end_at)?,
|
|
||||||
item,
|
|
||||||
source_block_id,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_schedule(row: ScheduleRow, slot_rows: Vec<SlotRow>) -> DomainResult<GeneratedSchedule> {
|
|
||||||
let id = ScheduleId::from_uuid(parse_uuid(&row.id, "schedule id")?);
|
|
||||||
let channel_id = ChannelId::from_uuid(parse_uuid(&row.channel_id, "channel id")?);
|
|
||||||
let slots: Result<Vec<ScheduledSlot>, _> = slot_rows.into_iter().map(map_slot_row).collect();
|
|
||||||
|
|
||||||
Ok(GeneratedSchedule::from_persistence(
|
|
||||||
id,
|
|
||||||
channel_id,
|
|
||||||
parse_dt(&row.valid_from)?,
|
|
||||||
parse_dt(&row.valid_until)?,
|
|
||||||
row.generation as u32,
|
|
||||||
slots?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_playback_row(row: PlaybackRecordRow) -> DomainResult<PlaybackRecord> {
|
|
||||||
let id = PlaybackRecordId::from_uuid(parse_uuid(&row.id, "playback record id")?);
|
|
||||||
let channel_id = ChannelId::from_uuid(parse_uuid(&row.channel_id, "channel id")?);
|
|
||||||
|
|
||||||
Ok(PlaybackRecord::from_persistence(
|
|
||||||
id,
|
|
||||||
channel_id,
|
|
||||||
MediaItemId::new(row.item_id),
|
|
||||||
parse_dt(&row.played_at)?,
|
|
||||||
row.generation as u32,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PgScheduleRepository {
|
|
||||||
async fn fetch_slots(&self, schedule_id: &str) -> DomainResult<Vec<SlotRow>> {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, schedule_id, start_at, end_at, item, source_block_id \
|
|
||||||
FROM scheduled_slots WHERE schedule_id = $1 ORDER BY start_at",
|
|
||||||
)
|
|
||||||
.bind(schedule_id)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ScheduleCommand for PgScheduleRepository {
|
|
||||||
async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO generated_schedules (id, channel_id, valid_from, valid_until, generation)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
valid_from = EXCLUDED.valid_from,
|
|
||||||
valid_until = EXCLUDED.valid_until,
|
|
||||||
generation = EXCLUDED.generation
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(schedule.id().value().to_string())
|
|
||||||
.bind(schedule.channel_id().value().to_string())
|
|
||||||
.bind(schedule.valid_from().to_rfc3339())
|
|
||||||
.bind(schedule.valid_until().to_rfc3339())
|
|
||||||
.bind(schedule.generation() as i64)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM scheduled_slots WHERE schedule_id = $1")
|
|
||||||
.bind(schedule.id().value().to_string())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
for slot in schedule.slots() {
|
|
||||||
let item_json = serde_json::to_string(slot.item())
|
|
||||||
.map_err(|e| DomainError::RepositoryError(format!("serialize slot item: {e}")))?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO scheduled_slots (id, schedule_id, start_at, end_at, item, source_block_id)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
|
||||||
)
|
|
||||||
.bind(slot.id().value().to_string())
|
|
||||||
.bind(schedule.id().value().to_string())
|
|
||||||
.bind(slot.start_at().to_rfc3339())
|
|
||||||
.bind(slot.end_at().to_rfc3339())
|
|
||||||
.bind(&item_json)
|
|
||||||
.bind(slot.source_block_id().value().to_string())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save_playback_record(&self, record: &PlaybackRecord) -> DomainResult<()> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO playback_records (id, channel_id, item_id, played_at, generation)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
ON CONFLICT(id) DO NOTHING
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(record.id().to_string())
|
|
||||||
.bind(record.channel_id().value().to_string())
|
|
||||||
.bind(record.item_id().value())
|
|
||||||
.bind(record.played_at().to_rfc3339())
|
|
||||||
.bind(record.generation() as i64)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_schedules_after(
|
|
||||||
&self,
|
|
||||||
channel_id: ChannelId,
|
|
||||||
target_generation: u32,
|
|
||||||
) -> DomainResult<()> {
|
|
||||||
let ch = channel_id.value().to_string();
|
|
||||||
let target_gen = target_generation as i64;
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM playback_records WHERE channel_id = $1 AND generation > $2")
|
|
||||||
.bind(&ch)
|
|
||||||
.bind(target_gen)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM generated_schedules WHERE channel_id = $1 AND generation > $2")
|
|
||||||
.bind(&ch)
|
|
||||||
.bind(target_gen)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ScheduleQuery for PgScheduleRepository {
|
|
||||||
async fn find_active(
|
|
||||||
&self,
|
|
||||||
channel_id: ChannelId,
|
|
||||||
at: chrono::DateTime<chrono::Utc>,
|
|
||||||
) -> DomainResult<Option<GeneratedSchedule>> {
|
|
||||||
let at_str = at.to_rfc3339();
|
|
||||||
let row: Option<ScheduleRow> = sqlx::query_as(
|
|
||||||
"SELECT id, channel_id, valid_from, valid_until, generation \
|
|
||||||
FROM generated_schedules \
|
|
||||||
WHERE channel_id = $1 AND valid_from <= $2 AND valid_until > $3 \
|
|
||||||
LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(channel_id.value().to_string())
|
|
||||||
.bind(&at_str)
|
|
||||||
.bind(&at_str)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
match row {
|
|
||||||
None => Ok(None),
|
|
||||||
Some(r) => {
|
|
||||||
let slots = self.fetch_slots(&r.id).await?;
|
|
||||||
Some(map_schedule(r, slots)).transpose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_latest(&self, channel_id: ChannelId) -> DomainResult<Option<GeneratedSchedule>> {
|
|
||||||
let row: Option<ScheduleRow> = sqlx::query_as(
|
|
||||||
"SELECT id, channel_id, valid_from, valid_until, generation \
|
|
||||||
FROM generated_schedules \
|
|
||||||
WHERE channel_id = $1 ORDER BY valid_from DESC LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(channel_id.value().to_string())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
match row {
|
|
||||||
None => Ok(None),
|
|
||||||
Some(r) => {
|
|
||||||
let slots = self.fetch_slots(&r.id).await?;
|
|
||||||
Some(map_schedule(r, slots)).transpose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_playback_history(
|
|
||||||
&self,
|
|
||||||
channel_id: ChannelId,
|
|
||||||
) -> DomainResult<Vec<PlaybackRecord>> {
|
|
||||||
let rows: Vec<PlaybackRecordRow> = sqlx::query_as(
|
|
||||||
"SELECT id, channel_id, item_id, played_at, generation \
|
|
||||||
FROM playback_records WHERE channel_id = $1 ORDER BY played_at DESC",
|
|
||||||
)
|
|
||||||
.bind(channel_id.value().to_string())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
rows.into_iter().map(map_playback_row).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_last_slot_per_block(
|
|
||||||
&self,
|
|
||||||
channel_id: ChannelId,
|
|
||||||
) -> DomainResult<HashMap<BlockId, MediaItemId>> {
|
|
||||||
let channel_id_str = channel_id.value().to_string();
|
|
||||||
let rows: Vec<LastSlotRow> = sqlx::query_as(
|
|
||||||
"SELECT ss.source_block_id, ss.item \
|
|
||||||
FROM scheduled_slots ss \
|
|
||||||
INNER JOIN generated_schedules gs ON gs.id = ss.schedule_id \
|
|
||||||
WHERE gs.channel_id = $1 \
|
|
||||||
AND ss.start_at = ( \
|
|
||||||
SELECT MAX(ss2.start_at) \
|
|
||||||
FROM scheduled_slots ss2 \
|
|
||||||
INNER JOIN generated_schedules gs2 ON gs2.id = ss2.schedule_id \
|
|
||||||
WHERE ss2.source_block_id = ss.source_block_id \
|
|
||||||
AND gs2.channel_id = $2 \
|
|
||||||
)",
|
|
||||||
)
|
|
||||||
.bind(&channel_id_str)
|
|
||||||
.bind(&channel_id_str)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
let mut map = HashMap::new();
|
|
||||||
for row in rows {
|
|
||||||
let block_id =
|
|
||||||
BlockId::from_uuid(parse_uuid(&row.source_block_id, "block id")?);
|
|
||||||
let item: MediaItem = parse_json(&row.item, "slot item")?;
|
|
||||||
map.insert(block_id, item.id().clone());
|
|
||||||
}
|
|
||||||
Ok(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_schedule_history(
|
|
||||||
&self,
|
|
||||||
channel_id: ChannelId,
|
|
||||||
) -> DomainResult<Vec<GeneratedSchedule>> {
|
|
||||||
let rows: Vec<ScheduleRow> = sqlx::query_as(
|
|
||||||
"SELECT id, channel_id, valid_from, valid_until, generation \
|
|
||||||
FROM generated_schedules WHERE channel_id = $1 ORDER BY generation DESC",
|
|
||||||
)
|
|
||||||
.bind(channel_id.value().to_string())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
rows.into_iter()
|
|
||||||
.map(|r| map_schedule(r, vec![]))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_schedule_by_id(
|
|
||||||
&self,
|
|
||||||
channel_id: ChannelId,
|
|
||||||
schedule_id: ScheduleId,
|
|
||||||
) -> DomainResult<Option<GeneratedSchedule>> {
|
|
||||||
let row: Option<ScheduleRow> = sqlx::query_as(
|
|
||||||
"SELECT id, channel_id, valid_from, valid_until, generation \
|
|
||||||
FROM generated_schedules WHERE id = $1 AND channel_id = $2",
|
|
||||||
)
|
|
||||||
.bind(schedule_id.value().to_string())
|
|
||||||
.bind(channel_id.value().to_string())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
match row {
|
|
||||||
None => Ok(None),
|
|
||||||
Some(r) => {
|
|
||||||
let slots = self.fetch_slots(&r.id).await?;
|
|
||||||
Some(map_schedule(r, slots)).transpose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
use domain::{
|
|
||||||
ports::settings::AppSettingsRepository,
|
|
||||||
DomainError, DomainResult,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct PgAppSettings {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PgAppSettings {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl AppSettingsRepository for PgAppSettings {
|
|
||||||
async fn get(&self, key: &str) -> DomainResult<Option<String>> {
|
|
||||||
sqlx::query_scalar::<_, String>("SELECT value FROM app_settings WHERE key = $1")
|
|
||||||
.bind(key)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set(&self, key: &str, value: &str) -> DomainResult<()> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO app_settings (key, value) VALUES ($1, $2)
|
|
||||||
ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value",
|
|
||||||
)
|
|
||||||
.bind(key)
|
|
||||||
.bind(value)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_all(&self) -> DomainResult<Vec<(String, String)>> {
|
|
||||||
sqlx::query_as::<_, (String, String)>("SELECT key, value FROM app_settings ORDER BY key")
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
use domain::{
|
|
||||||
ports::transcode::TranscodeSettingsRepository,
|
|
||||||
DomainError, DomainResult,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct PgTranscodeSettings {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PgTranscodeSettings {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl TranscodeSettingsRepository for PgTranscodeSettings {
|
|
||||||
async fn load_cleanup_ttl(&self) -> DomainResult<Option<u32>> {
|
|
||||||
let row: Option<(i64,)> =
|
|
||||||
sqlx::query_as("SELECT cleanup_ttl_hours FROM transcode_settings WHERE id = 1")
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
Ok(row.map(|(h,)| h as u32))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save_cleanup_ttl(&self, hours: u32) -> DomainResult<()> {
|
|
||||||
sqlx::query("UPDATE transcode_settings SET cleanup_ttl_hours = $1 WHERE id = 1")
|
|
||||||
.bind(hours as i64)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
use adapter_common::{map_sqlx_error, parse_dt, parse_uuid};
|
|
||||||
use domain::{
|
|
||||||
ports::user::{UserCommand, UserQuery},
|
|
||||||
DomainError, DomainResult, Email, User, UserId,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct PgUserRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PgUserRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct UserRow {
|
|
||||||
id: String,
|
|
||||||
subject: String,
|
|
||||||
email: String,
|
|
||||||
password_hash: Option<String>,
|
|
||||||
is_admin: bool,
|
|
||||||
created_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserRow {
|
|
||||||
fn into_user(self) -> DomainResult<User> {
|
|
||||||
let id = UserId::from_uuid(parse_uuid(&self.id, "user id")?);
|
|
||||||
let email = Email::new(&self.email)
|
|
||||||
.map_err(|e| DomainError::RepositoryError(format!("Invalid email: {e}")))?;
|
|
||||||
let created_at = parse_dt(&self.created_at)?;
|
|
||||||
|
|
||||||
Ok(User::from_persistence(
|
|
||||||
id,
|
|
||||||
self.subject,
|
|
||||||
email,
|
|
||||||
self.password_hash,
|
|
||||||
self.is_admin,
|
|
||||||
created_at,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl UserCommand for PgUserRepository {
|
|
||||||
async fn save(&self, user: &User) -> DomainResult<()> {
|
|
||||||
let id = user.id().value().to_string();
|
|
||||||
let created_at = user.created_at().to_rfc3339();
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO users (id, subject, email, password_hash, is_admin, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
subject = EXCLUDED.subject,
|
|
||||||
email = EXCLUDED.email,
|
|
||||||
password_hash = EXCLUDED.password_hash,
|
|
||||||
is_admin = EXCLUDED.is_admin
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(&id)
|
|
||||||
.bind(user.subject())
|
|
||||||
.bind(user.email().as_ref())
|
|
||||||
.bind(user.password_hash())
|
|
||||||
.bind(user.is_admin())
|
|
||||||
.bind(&created_at)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
let msg = e.to_string();
|
|
||||||
if msg.contains("unique constraint") || msg.contains("duplicate key") {
|
|
||||||
DomainError::UserAlreadyExists(user.email().as_ref().to_string())
|
|
||||||
} else {
|
|
||||||
map_sqlx_error(e)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, id: UserId) -> DomainResult<()> {
|
|
||||||
sqlx::query("DELETE FROM users WHERE id = $1")
|
|
||||||
.bind(id.value().to_string())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl UserQuery for PgUserRepository {
|
|
||||||
async fn find_by_id(&self, id: UserId) -> DomainResult<Option<User>> {
|
|
||||||
let row: Option<UserRow> = sqlx::query_as(
|
|
||||||
"SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(id.value().to_string())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
row.map(UserRow::into_user).transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
|
|
||||||
let row: Option<UserRow> = sqlx::query_as(
|
|
||||||
"SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE subject = $1",
|
|
||||||
)
|
|
||||||
.bind(subject)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
row.map(UserRow::into_user).transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
|
|
||||||
let row: Option<UserRow> = sqlx::query_as(
|
|
||||||
"SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE email = $1",
|
|
||||||
)
|
|
||||||
.bind(email)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
row.map(UserRow::into_user).transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_users(&self) -> DomainResult<u64> {
|
|
||||||
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
Ok(count as u64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
use domain::ports::{
|
|
||||||
activity::{ActivityLogCommand, ActivityLogQuery},
|
|
||||||
channel::{ChannelCommand, ChannelQuery},
|
|
||||||
library::{LibraryCommand, LibraryQuery},
|
|
||||||
provider_config::{ProviderConfigCommand, ProviderConfigQuery},
|
|
||||||
schedule::{ScheduleCommand, ScheduleQuery},
|
|
||||||
settings::AppSettingsRepository,
|
|
||||||
transcode::TranscodeSettingsRepository,
|
|
||||||
user::{UserCommand, UserQuery},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
activity::PgActivityLog,
|
|
||||||
channel::PgChannelRepository,
|
|
||||||
library::PgLibraryRepository,
|
|
||||||
provider_config::PgProviderConfig,
|
|
||||||
schedule::PgScheduleRepository,
|
|
||||||
settings::PgAppSettings,
|
|
||||||
transcode::PgTranscodeSettings,
|
|
||||||
user::PgUserRepository,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct PostgresWireOutput {
|
|
||||||
pub user_command: Arc<dyn UserCommand>,
|
|
||||||
pub user_query: Arc<dyn UserQuery>,
|
|
||||||
pub channel_command: Arc<dyn ChannelCommand>,
|
|
||||||
pub channel_query: Arc<dyn ChannelQuery>,
|
|
||||||
pub schedule_command: Arc<dyn ScheduleCommand>,
|
|
||||||
pub schedule_query: Arc<dyn ScheduleQuery>,
|
|
||||||
pub library_command: Arc<dyn LibraryCommand>,
|
|
||||||
pub library_query: Arc<dyn LibraryQuery>,
|
|
||||||
pub activity_command: Arc<dyn ActivityLogCommand>,
|
|
||||||
pub activity_query: Arc<dyn ActivityLogQuery>,
|
|
||||||
pub settings: Arc<dyn AppSettingsRepository>,
|
|
||||||
pub provider_config_command: Arc<dyn ProviderConfigCommand>,
|
|
||||||
pub provider_config_query: Arc<dyn ProviderConfigQuery>,
|
|
||||||
pub transcode_settings: Arc<dyn TranscodeSettingsRepository>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wire(pool: PgPool) -> PostgresWireOutput {
|
|
||||||
let user = Arc::new(PgUserRepository::new(pool.clone()));
|
|
||||||
let channel = Arc::new(PgChannelRepository::new(pool.clone()));
|
|
||||||
let schedule = Arc::new(PgScheduleRepository::new(pool.clone()));
|
|
||||||
let library = Arc::new(PgLibraryRepository::new(pool.clone()));
|
|
||||||
let activity = Arc::new(PgActivityLog::new(pool.clone()));
|
|
||||||
let settings = Arc::new(PgAppSettings::new(pool.clone()));
|
|
||||||
let provider_config = Arc::new(PgProviderConfig::new(pool.clone()));
|
|
||||||
let transcode_settings = Arc::new(PgTranscodeSettings::new(pool));
|
|
||||||
|
|
||||||
PostgresWireOutput {
|
|
||||||
user_command: user.clone(),
|
|
||||||
user_query: user,
|
|
||||||
channel_command: channel.clone(),
|
|
||||||
channel_query: channel,
|
|
||||||
schedule_command: schedule.clone(),
|
|
||||||
schedule_query: schedule,
|
|
||||||
library_command: library.clone(),
|
|
||||||
library_query: library,
|
|
||||||
activity_command: activity.clone(),
|
|
||||||
activity_query: activity,
|
|
||||||
settings,
|
|
||||||
provider_config_command: provider_config.clone(),
|
|
||||||
provider_config_query: provider_config,
|
|
||||||
transcode_settings,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,11 @@ pub struct ActivityEventResponse {
|
|||||||
pub channel_id: Option<Uuid>,
|
pub channel_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct ActivityLogParams {
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
impl From<domain::ActivityEvent> for ActivityEventResponse {
|
impl From<domain::ActivityEvent> for ActivityEventResponse {
|
||||||
fn from(e: domain::ActivityEvent) -> Self {
|
fn from(e: domain::ActivityEvent) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
7
crates/api-types/src/iptv.rs
Normal file
7
crates/api-types/src/iptv.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct IptvParams {
|
||||||
|
pub token: Option<String>,
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ pub mod auth;
|
|||||||
pub mod channels;
|
pub mod channels;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod iptv;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
pub mod transcode;
|
pub mod transcode;
|
||||||
|
|
||||||
pub use admin::{ActivityEventResponse, SettingsResponse};
|
pub use admin::{ActivityEventResponse, ActivityLogParams, SettingsResponse};
|
||||||
pub use auth::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse};
|
pub use auth::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse};
|
||||||
pub use channels::{
|
pub use channels::{
|
||||||
ChannelResponse, ConfigSnapshotResponse, CreateChannelRequest, PatchSnapshotRequest,
|
ChannelResponse, ConfigSnapshotResponse, CreateChannelRequest, PatchSnapshotRequest,
|
||||||
@@ -16,7 +17,11 @@ pub use channels::{
|
|||||||
};
|
};
|
||||||
pub use common::{ErrorResponse, PaginatedResponse};
|
pub use common::{ErrorResponse, PaginatedResponse};
|
||||||
pub use config::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
|
pub use config::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
|
||||||
pub use library::{CollectionResponse, LibraryItemResponse, SeasonResponse, ShowResponse};
|
pub use iptv::IptvParams;
|
||||||
|
pub use library::{
|
||||||
|
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, ProviderParam,
|
||||||
|
SeasonResponse, SeasonsParams, ShowResponse, ShowsParams,
|
||||||
|
};
|
||||||
pub use providers::{ProviderConfigRequest, ProviderConfigResponse};
|
pub use providers::{ProviderConfigRequest, ProviderConfigResponse};
|
||||||
pub use schedule::{
|
pub use schedule::{
|
||||||
CurrentBroadcastResponse, MediaItemResponse, ScheduleHistoryEntry, ScheduleResponse,
|
CurrentBroadcastResponse, MediaItemResponse, ScheduleHistoryEntry, ScheduleResponse,
|
||||||
|
|||||||
@@ -102,3 +102,44 @@ impl From<domain::SeasonSummary> for SeasonResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct LibrarySearchParams {
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
#[serde(default, rename = "genres[]")]
|
||||||
|
pub genres: Vec<String>,
|
||||||
|
pub search_term: Option<String>,
|
||||||
|
pub collection_id: Option<String>,
|
||||||
|
#[serde(default, rename = "series_names[]")]
|
||||||
|
pub series_names: Vec<String>,
|
||||||
|
pub season_number: Option<u32>,
|
||||||
|
pub decade: Option<u16>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct ProviderParam {
|
||||||
|
pub provider: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct ShowsParams {
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub search_term: Option<String>,
|
||||||
|
#[serde(default, rename = "genres[]")]
|
||||||
|
pub genres: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct SeasonsParams {
|
||||||
|
pub series_name: String,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct GenresParams {
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ rand = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
url = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ pub enum DomainError {
|
|||||||
#[error("Forbidden: {0}")]
|
#[error("Forbidden: {0}")]
|
||||||
Forbidden(String),
|
Forbidden(String),
|
||||||
|
|
||||||
|
#[error("Not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("Conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
#[error("Repository error: {0}")]
|
#[error("Repository error: {0}")]
|
||||||
RepositoryError(String),
|
RepositoryError(String),
|
||||||
|
|
||||||
@@ -52,12 +58,12 @@ impl DomainError {
|
|||||||
pub fn is_not_found(&self) -> bool {
|
pub fn is_not_found(&self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self,
|
self,
|
||||||
DomainError::UserNotFound(_) | DomainError::ChannelNotFound(_)
|
DomainError::UserNotFound(_) | DomainError::ChannelNotFound(_) | DomainError::NotFound(_)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_conflict(&self) -> bool {
|
pub fn is_conflict(&self) -> bool {
|
||||||
matches!(self, DomainError::UserAlreadyExists(_))
|
matches!(self, DomainError::UserAlreadyExists(_) | DomainError::Conflict(_))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod channel;
|
pub mod channel;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod oidc;
|
|
||||||
pub mod scheduling;
|
pub mod scheduling;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
pub use channel::*;
|
pub use channel::*;
|
||||||
pub use ids::*;
|
pub use ids::*;
|
||||||
pub use oidc::*;
|
|
||||||
pub use scheduling::*;
|
pub use scheduling::*;
|
||||||
pub use search::*;
|
pub use search::*;
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
use serde::{Deserialize, Deserializer, Serialize};
|
|
||||||
use std::fmt;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use super::auth::ValidationError;
|
|
||||||
|
|
||||||
// Stores original string to preserve exact formatting — OIDC providers expect issuer URLs to match exactly
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(try_from = "String", into = "String")]
|
|
||||||
pub struct IssuerUrl(String);
|
|
||||||
|
|
||||||
impl IssuerUrl {
|
|
||||||
pub fn new(value: impl AsRef<str>) -> Result<Self, ValidationError> {
|
|
||||||
let value = value.as_ref().trim().to_string();
|
|
||||||
Url::parse(&value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?;
|
|
||||||
Ok(Self(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for IssuerUrl {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for IssuerUrl {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<String> for IssuerUrl {
|
|
||||||
type Error = ValidationError;
|
|
||||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
|
||||||
Self::new(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<IssuerUrl> for String {
|
|
||||||
fn from(val: IssuerUrl) -> Self {
|
|
||||||
val.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(try_from = "String", into = "String")]
|
|
||||||
pub struct ClientId(String);
|
|
||||||
|
|
||||||
impl ClientId {
|
|
||||||
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
|
|
||||||
let value = value.into().trim().to_string();
|
|
||||||
if value.is_empty() {
|
|
||||||
return Err(ValidationError::Empty("client_id".to_string()));
|
|
||||||
}
|
|
||||||
Ok(Self(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for ClientId {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for ClientId {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<String> for ClientId {
|
|
||||||
type Error = ValidationError;
|
|
||||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
|
||||||
Self::new(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ClientId> for String {
|
|
||||||
fn from(val: ClientId) -> Self {
|
|
||||||
val.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hidden in Debug for security
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
|
||||||
pub struct ClientSecret(String);
|
|
||||||
|
|
||||||
impl ClientSecret {
|
|
||||||
pub fn new(value: impl Into<String>) -> Self {
|
|
||||||
Self(value.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.0.trim().is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for ClientSecret {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hidden in Debug for security
|
|
||||||
impl fmt::Debug for ClientSecret {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "ClientSecret(***)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for ClientSecret {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "***")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for ClientSecret {
|
|
||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
||||||
let s = String::deserialize(deserializer)?;
|
|
||||||
Ok(Self::new(s))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClientSecret must NOT implement Serialize
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(try_from = "String", into = "String")]
|
|
||||||
pub struct RedirectUrl(Url);
|
|
||||||
|
|
||||||
impl RedirectUrl {
|
|
||||||
pub fn new(value: impl AsRef<str>) -> Result<Self, ValidationError> {
|
|
||||||
let value = value.as_ref().trim();
|
|
||||||
let url = Url::parse(value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?;
|
|
||||||
Ok(Self(url))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_url(&self) -> &Url {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for RedirectUrl {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
self.0.as_str()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for RedirectUrl {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<String> for RedirectUrl {
|
|
||||||
type Error = ValidationError;
|
|
||||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
|
||||||
Self::new(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<RedirectUrl> for String {
|
|
||||||
fn from(val: RedirectUrl) -> Self {
|
|
||||||
val.0.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(try_from = "String", into = "String")]
|
|
||||||
pub struct ResourceId(String);
|
|
||||||
|
|
||||||
impl ResourceId {
|
|
||||||
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
|
|
||||||
let value = value.into().trim().to_string();
|
|
||||||
if value.is_empty() {
|
|
||||||
return Err(ValidationError::Empty("resource_id".to_string()));
|
|
||||||
}
|
|
||||||
Ok(Self(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for ResourceId {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for ResourceId {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<String> for ResourceId {
|
|
||||||
type Error = ValidationError;
|
|
||||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
|
||||||
Self::new(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ResourceId> for String {
|
|
||||||
fn from(val: ResourceId) -> Self {
|
|
||||||
val.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct CsrfToken(String);
|
|
||||||
|
|
||||||
impl CsrfToken {
|
|
||||||
pub fn new(value: impl Into<String>) -> Self {
|
|
||||||
Self(value.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for CsrfToken {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for CsrfToken {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct OidcNonce(String);
|
|
||||||
|
|
||||||
impl OidcNonce {
|
|
||||||
pub fn new(value: impl Into<String>) -> Self {
|
|
||||||
Self(value.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for OidcNonce {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for OidcNonce {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hidden in Debug for security
|
|
||||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct PkceVerifier(String);
|
|
||||||
|
|
||||||
impl PkceVerifier {
|
|
||||||
pub fn new(value: impl Into<String>) -> Self {
|
|
||||||
Self(value.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for PkceVerifier {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hidden in Debug for security
|
|
||||||
impl fmt::Debug for PkceVerifier {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "PkceVerifier(***)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hidden in Debug for security
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
|
||||||
pub struct AuthorizationCode(String);
|
|
||||||
|
|
||||||
impl AuthorizationCode {
|
|
||||||
pub fn new(value: impl Into<String>) -> Self {
|
|
||||||
Self(value.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for AuthorizationCode {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hidden in Debug for security
|
|
||||||
impl fmt::Debug for AuthorizationCode {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "AuthorizationCode(***)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for AuthorizationCode {
|
|
||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
||||||
let s = String::deserialize(deserializer)?;
|
|
||||||
Ok(Self::new(s))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AuthorizationUrlData {
|
|
||||||
pub url: Url,
|
|
||||||
pub csrf_token: CsrfToken,
|
|
||||||
pub nonce: OidcNonce,
|
|
||||||
pub pkce_verifier: PkceVerifier,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const MIN_JWT_SECRET_LENGTH: usize = 32;
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
|
||||||
pub struct JwtSecret(String);
|
|
||||||
|
|
||||||
impl JwtSecret {
|
|
||||||
pub fn new(value: impl Into<String>, is_production: bool) -> Result<Self, ValidationError> {
|
|
||||||
let value = value.into();
|
|
||||||
if is_production && value.len() < MIN_JWT_SECRET_LENGTH {
|
|
||||||
return Err(ValidationError::SecretTooShort {
|
|
||||||
min: MIN_JWT_SECRET_LENGTH,
|
|
||||||
actual: value.len(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(Self(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_unchecked(value: impl Into<String>) -> Self {
|
|
||||||
Self(value.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for JwtSecret {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for JwtSecret {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "JwtSecret(***)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/oidc.rs"]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
mod oidc_tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_issuer_url_valid() {
|
|
||||||
assert!(IssuerUrl::new("https://auth.example.com").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_issuer_url_invalid() {
|
|
||||||
assert!(IssuerUrl::new("not-a-url").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_id_non_empty() {
|
|
||||||
assert!(ClientId::new("my-client").is_ok());
|
|
||||||
assert!(ClientId::new("").is_err());
|
|
||||||
assert!(ClientId::new(" ").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_secret_hides_in_debug() {
|
|
||||||
let secret = ClientSecret::new("super-secret");
|
|
||||||
let debug = format!("{:?}", secret);
|
|
||||||
assert!(!debug.contains("super-secret"));
|
|
||||||
assert!(debug.contains("***"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod secret_tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_jwt_secret_production_check() {
|
|
||||||
let short = "short";
|
|
||||||
let long = "a".repeat(32);
|
|
||||||
|
|
||||||
assert!(JwtSecret::new(short, true).is_err());
|
|
||||||
assert!(JwtSecret::new(&long, true).is_ok());
|
|
||||||
|
|
||||||
assert!(JwtSecret::new(short, false).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_secrets_hide_in_debug() {
|
|
||||||
let jwt = JwtSecret::new_unchecked("secret");
|
|
||||||
assert!(!format!("{:?}", jwt).contains("secret"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ edition = "2024"
|
|||||||
[features]
|
[features]
|
||||||
default = ["sqlite"]
|
default = ["sqlite"]
|
||||||
sqlite = ["sqlx/sqlite"]
|
sqlite = ["sqlx/sqlite"]
|
||||||
postgres = ["sqlx/postgres"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ pub enum DbError {
|
|||||||
pub enum DbPool {
|
pub enum DbPool {
|
||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite")]
|
||||||
Sqlite(sqlx::SqlitePool),
|
Sqlite(sqlx::SqlitePool),
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
Postgres(sqlx::PgPool),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DbPool {
|
impl DbPool {
|
||||||
@@ -60,11 +57,6 @@ impl DbPool {
|
|||||||
let pool = sqlx::SqlitePool::connect(database_url).await?;
|
let pool = sqlx::SqlitePool::connect(database_url).await?;
|
||||||
Ok(Self::Sqlite(pool))
|
Ok(Self::Sqlite(pool))
|
||||||
}
|
}
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
"postgres" | "postgresql" => {
|
|
||||||
let pool = sqlx::PgPool::connect(database_url).await?;
|
|
||||||
Ok(Self::Postgres(pool))
|
|
||||||
}
|
|
||||||
other => Err(DbError::UnsupportedScheme(other.to_string())),
|
other => Err(DbError::UnsupportedScheme(other.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,10 +69,6 @@ impl DbPool {
|
|||||||
.run(pool)
|
.run(pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
Self::Postgres(_pool) => {
|
|
||||||
tracing::warn!("postgres migrations not yet available");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -112,11 +100,6 @@ pub struct Config {
|
|||||||
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,
|
||||||
pub oidc_issuer_url: Option<String>,
|
|
||||||
pub oidc_client_id: Option<String>,
|
|
||||||
pub oidc_client_secret: Option<String>,
|
|
||||||
pub oidc_redirect_url: Option<String>,
|
|
||||||
pub oidc_resource_id: Option<String>,
|
|
||||||
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>,
|
||||||
@@ -178,12 +161,6 @@ impl Config {
|
|||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(DEFAULT_JWT_REFRESH_EXPIRY_DAYS);
|
.unwrap_or(DEFAULT_JWT_REFRESH_EXPIRY_DAYS);
|
||||||
|
|
||||||
let oidc_issuer_url = env::var("OIDC_ISSUER").ok();
|
|
||||||
let oidc_client_id = env::var("OIDC_CLIENT_ID").ok();
|
|
||||||
let oidc_client_secret = env::var("OIDC_CLIENT_SECRET").ok();
|
|
||||||
let oidc_redirect_url = env::var("OIDC_REDIRECT_URL").ok();
|
|
||||||
let oidc_resource_id = env::var("OIDC_RESOURCE_ID").ok();
|
|
||||||
|
|
||||||
let is_production = env::var("PRODUCTION")
|
let is_production = env::var("PRODUCTION")
|
||||||
.or_else(|_| env::var("RUST_ENV"))
|
.or_else(|_| env::var("RUST_ENV"))
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
@@ -234,11 +211,6 @@ impl Config {
|
|||||||
jwt_expiry_hours,
|
jwt_expiry_hours,
|
||||||
jwt_refresh_expiry_days,
|
jwt_refresh_expiry_days,
|
||||||
allow_registration,
|
allow_registration,
|
||||||
oidc_issuer_url,
|
|
||||||
oidc_client_id,
|
|
||||||
oidc_client_secret,
|
|
||||||
oidc_redirect_url,
|
|
||||||
oidc_resource_id,
|
|
||||||
jellyfin_url,
|
jellyfin_url,
|
||||||
jellyfin_api_key,
|
jellyfin_api_key,
|
||||||
jellyfin_user_id,
|
jellyfin_user_id,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ path = "src/main.rs"
|
|||||||
[features]
|
[features]
|
||||||
default = ["sqlite", "jellyfin"]
|
default = ["sqlite", "jellyfin"]
|
||||||
sqlite = ["dep:adapter-sqlite", "infra-wiring/sqlite"]
|
sqlite = ["dep:adapter-sqlite", "infra-wiring/sqlite"]
|
||||||
postgres = ["dep:adapter-postgres", "infra-wiring/postgres"]
|
|
||||||
jellyfin = ["dep:adapter-jellyfin"]
|
jellyfin = ["dep:adapter-jellyfin"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -21,7 +20,6 @@ adapter-auth = { workspace = true }
|
|||||||
adapter-event-publisher = { workspace = true }
|
adapter-event-publisher = { workspace = true }
|
||||||
|
|
||||||
adapter-sqlite = { workspace = true, optional = true }
|
adapter-sqlite = { workspace = true, optional = true }
|
||||||
adapter-postgres = { workspace = true, optional = true }
|
|
||||||
adapter-jellyfin = { workspace = true, optional = true }
|
adapter-jellyfin = { workspace = true, optional = true }
|
||||||
|
|
||||||
rmcp = { version = "0.1", features = ["server", "transport-io"] }
|
rmcp = { version = "0.1", features = ["server", "transport-io"] }
|
||||||
|
|||||||
@@ -118,18 +118,6 @@ fn wire_repositories(pool: &DbPool) -> anyhow::Result<WireOutput> {
|
|||||||
library_query: w.library_query,
|
library_query: w.library_query,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
DbPool::Postgres(pg_pool) => {
|
|
||||||
let w = adapter_postgres::wire(pg_pool.clone());
|
|
||||||
Ok(WireOutput {
|
|
||||||
channel_command: w.channel_command,
|
|
||||||
channel_query: w.channel_query,
|
|
||||||
schedule_command: w.schedule_command,
|
|
||||||
schedule_query: w.schedule_query,
|
|
||||||
library_query: w.library_query,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => anyhow::bail!("database backend not compiled into this binary"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ path = "src/main.rs"
|
|||||||
[features]
|
[features]
|
||||||
default = ["sqlite", "auth-jwt", "jellyfin"]
|
default = ["sqlite", "auth-jwt", "jellyfin"]
|
||||||
sqlite = ["dep:adapter-sqlite", "infra-wiring/sqlite"]
|
sqlite = ["dep:adapter-sqlite", "infra-wiring/sqlite"]
|
||||||
postgres = ["dep:adapter-postgres", "infra-wiring/postgres"]
|
|
||||||
auth-jwt = ["adapter-auth/jwt"]
|
auth-jwt = ["adapter-auth/jwt"]
|
||||||
auth-oidc = ["adapter-auth/oidc"]
|
|
||||||
jellyfin = ["dep:adapter-jellyfin"]
|
jellyfin = ["dep:adapter-jellyfin"]
|
||||||
local-files = ["dep:adapter-local-files", "dep:tokio-util"]
|
local-files = ["dep:adapter-local-files", "dep:tokio-util"]
|
||||||
|
|
||||||
@@ -26,7 +24,6 @@ adapter-event-publisher = { workspace = true }
|
|||||||
|
|
||||||
# Feature-gated adapters
|
# Feature-gated adapters
|
||||||
adapter-sqlite = { workspace = true, optional = true }
|
adapter-sqlite = { workspace = true, optional = true }
|
||||||
adapter-postgres = { workspace = true, optional = true }
|
|
||||||
adapter-jellyfin = { workspace = true, optional = true }
|
adapter-jellyfin = { workspace = true, optional = true }
|
||||||
adapter-local-files = { workspace = true, optional = true }
|
adapter-local-files = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,19 @@
|
|||||||
use axum::{
|
use axum::Json;
|
||||||
Json,
|
use axum::http::StatusCode;
|
||||||
http::StatusCode,
|
use axum::response::{IntoResponse, Response};
|
||||||
response::{IntoResponse, Response},
|
|
||||||
};
|
|
||||||
use serde::Serialize;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
use domain::DomainError;
|
use domain::DomainError;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
pub struct AppError(pub DomainError);
|
||||||
pub enum ApiError {
|
|
||||||
#[error("{0}")]
|
|
||||||
Domain(#[from] DomainError),
|
|
||||||
|
|
||||||
#[error("Validation error: {0}")]
|
impl IntoResponse for AppError {
|
||||||
Validation(String),
|
|
||||||
|
|
||||||
#[error("Internal server error")]
|
|
||||||
Internal(String),
|
|
||||||
|
|
||||||
#[error("Forbidden: {0}")]
|
|
||||||
Forbidden(String),
|
|
||||||
|
|
||||||
#[error("Unauthorized: {0}")]
|
|
||||||
Unauthorized(String),
|
|
||||||
|
|
||||||
#[error("Not found: {0}")]
|
|
||||||
NotFound(String),
|
|
||||||
|
|
||||||
#[error("Conflict: {0}")]
|
|
||||||
Conflict(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ErrorResponse {
|
|
||||||
pub error: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub details: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, error_response) = match &self {
|
let status = match &self.0 {
|
||||||
ApiError::Domain(domain_error) => {
|
|
||||||
let status = match domain_error {
|
|
||||||
DomainError::UserNotFound(_)
|
DomainError::UserNotFound(_)
|
||||||
| DomainError::ChannelNotFound(_)
|
| DomainError::ChannelNotFound(_)
|
||||||
| DomainError::NoActiveSchedule(_) => StatusCode::NOT_FOUND,
|
| DomainError::NoActiveSchedule(_)
|
||||||
|
| DomainError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
|
||||||
DomainError::UserAlreadyExists(_) => StatusCode::CONFLICT,
|
DomainError::UserAlreadyExists(_) | DomainError::Conflict(_) => StatusCode::CONFLICT,
|
||||||
|
|
||||||
DomainError::ValidationError(_) | DomainError::TimezoneError(_) => {
|
DomainError::ValidationError(_) | DomainError::TimezoneError(_) => {
|
||||||
StatusCode::BAD_REQUEST
|
StatusCode::BAD_REQUEST
|
||||||
@@ -64,81 +29,19 @@ impl IntoResponse for ApiError {
|
|||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
};
|
};
|
||||||
|
|
||||||
(
|
let body = api_types::ErrorResponse::new(self.0.to_string());
|
||||||
status,
|
(status, Json(body)).into_response()
|
||||||
ErrorResponse {
|
|
||||||
error: domain_error.to_string(),
|
|
||||||
details: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiError::Validation(msg) => (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
ErrorResponse {
|
|
||||||
error: "Validation error".to_string(),
|
|
||||||
details: Some(msg.clone()),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
ApiError::Internal(msg) => {
|
|
||||||
tracing::error!("Internal error: {}", msg);
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
ErrorResponse {
|
|
||||||
error: "Internal server error".to_string(),
|
|
||||||
details: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiError::Forbidden(msg) => (
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
ErrorResponse {
|
|
||||||
error: "Forbidden".to_string(),
|
|
||||||
details: Some(msg.clone()),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
ApiError::Unauthorized(msg) => (
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
ErrorResponse {
|
|
||||||
error: "Unauthorized".to_string(),
|
|
||||||
details: Some(msg.clone()),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
ApiError::NotFound(msg) => (
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
ErrorResponse {
|
|
||||||
error: "Not found".to_string(),
|
|
||||||
details: Some(msg.clone()),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
ApiError::Conflict(msg) => (
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
ErrorResponse {
|
|
||||||
error: "Conflict".to_string(),
|
|
||||||
details: Some(msg.clone()),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
(status, Json(error_response)).into_response()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiError {
|
impl From<DomainError> for AppError {
|
||||||
pub fn validation(msg: impl Into<String>) -> Self {
|
fn from(e: DomainError) -> Self {
|
||||||
Self::Validation(msg.into())
|
Self(e)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pub fn not_found(msg: impl Into<String>) -> Self {
|
|
||||||
Self::NotFound(msg.into())
|
impl From<serde_json::Error> for AppError {
|
||||||
}
|
fn from(e: serde_json::Error) -> Self {
|
||||||
|
Self(DomainError::ValidationError(e.to_string()))
|
||||||
pub fn conflict(msg: impl Into<String>) -> Self {
|
|
||||||
Self::Conflict(msg.into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use axum::extract::FromRequestParts;
|
use axum::extract::FromRequestParts;
|
||||||
use axum::http::request::Parts;
|
use axum::http::request::Parts;
|
||||||
use domain::User;
|
use domain::{DomainError, User};
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use crate::errors::AppError;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub struct CurrentUser(pub User);
|
pub struct CurrentUser(pub User);
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for CurrentUser {
|
impl FromRequestParts<AppState> for CurrentUser {
|
||||||
type Rejection = ApiError;
|
type Rejection = AppError;
|
||||||
|
|
||||||
async fn from_request_parts(
|
async fn from_request_parts(
|
||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
@@ -25,9 +25,9 @@ impl FromRequestParts<AppState> for CurrentUser {
|
|||||||
#[cfg(not(feature = "auth-jwt"))]
|
#[cfg(not(feature = "auth-jwt"))]
|
||||||
{
|
{
|
||||||
let _ = (parts, state);
|
let _ = (parts, state);
|
||||||
Err(ApiError::Unauthorized(
|
Err(AppError(DomainError::Unauthenticated(
|
||||||
"No authentication backend configured".to_string(),
|
"No authentication backend configured".to_string(),
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ impl FromRequestParts<AppState> for CurrentUser {
|
|||||||
pub struct OptionalCurrentUser(pub Option<User>);
|
pub struct OptionalCurrentUser(pub Option<User>);
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for OptionalCurrentUser {
|
impl FromRequestParts<AppState> for OptionalCurrentUser {
|
||||||
type Rejection = ApiError;
|
type Rejection = AppError;
|
||||||
|
|
||||||
async fn from_request_parts(
|
async fn from_request_parts(
|
||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
@@ -69,7 +69,7 @@ impl FromRequestParts<AppState> for OptionalCurrentUser {
|
|||||||
pub struct AdminUser(pub User);
|
pub struct AdminUser(pub User);
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for AdminUser {
|
impl FromRequestParts<AppState> for AdminUser {
|
||||||
type Rejection = ApiError;
|
type Rejection = AppError;
|
||||||
|
|
||||||
async fn from_request_parts(
|
async fn from_request_parts(
|
||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
@@ -77,64 +77,64 @@ impl FromRequestParts<AppState> for AdminUser {
|
|||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
let CurrentUser(user) = CurrentUser::from_request_parts(parts, state).await?;
|
let CurrentUser(user) = CurrentUser::from_request_parts(parts, state).await?;
|
||||||
if !user.is_admin() {
|
if !user.is_admin() {
|
||||||
return Err(ApiError::Forbidden("Admin access required".to_string()));
|
return Err(AppError(DomainError::Forbidden("Admin access required".to_string())));
|
||||||
}
|
}
|
||||||
Ok(AdminUser(user))
|
Ok(AdminUser(user))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "auth-jwt")]
|
#[cfg(feature = "auth-jwt")]
|
||||||
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {
|
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, AppError> {
|
||||||
use axum::http::header::AUTHORIZATION;
|
use axum::http::header::AUTHORIZATION;
|
||||||
|
|
||||||
let auth_header = parts
|
let auth_header = parts
|
||||||
.headers
|
.headers
|
||||||
.get(AUTHORIZATION)
|
.get(AUTHORIZATION)
|
||||||
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
.ok_or_else(|| AppError(DomainError::Unauthenticated("Missing Authorization header".to_string())))?;
|
||||||
|
|
||||||
let auth_str = auth_header
|
let auth_str = auth_header
|
||||||
.to_str()
|
.to_str()
|
||||||
.map_err(|_| ApiError::Unauthorized("Invalid Authorization header encoding".to_string()))?;
|
.map_err(|_| AppError(DomainError::Unauthenticated("Invalid Authorization header encoding".to_string())))?;
|
||||||
|
|
||||||
let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| {
|
let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| {
|
||||||
ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string())
|
AppError(DomainError::Unauthenticated("Authorization header must use Bearer scheme".to_string()))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
validate_jwt_token(token, state).await
|
validate_jwt_token(token, state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "auth-jwt")]
|
#[cfg(feature = "auth-jwt")]
|
||||||
pub(crate) async fn validate_jwt_token(token: &str, state: &AppState) -> Result<User, ApiError> {
|
pub(crate) async fn validate_jwt_token(token: &str, state: &AppState) -> Result<User, AppError> {
|
||||||
let validator = state
|
let validator = state
|
||||||
.jwt_validator
|
.jwt_validator
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?;
|
.ok_or_else(|| AppError(DomainError::InfrastructureError("JWT validator not configured".to_string())))?;
|
||||||
|
|
||||||
let claims = validator.validate_access_token(token).map_err(|e| {
|
let claims = validator.validate_access_token(token).map_err(|e| {
|
||||||
tracing::debug!("JWT validation failed: {:?}", e);
|
tracing::debug!("JWT validation failed: {:?}", e);
|
||||||
match e {
|
match e {
|
||||||
adapter_auth::JwtError::Expired => {
|
adapter_auth::JwtError::Expired => {
|
||||||
ApiError::Unauthorized("Token expired".to_string())
|
AppError(DomainError::Unauthenticated("Token expired".to_string()))
|
||||||
}
|
}
|
||||||
adapter_auth::JwtError::InvalidFormat => {
|
adapter_auth::JwtError::InvalidFormat => {
|
||||||
ApiError::Unauthorized("Invalid token format".to_string())
|
AppError(DomainError::Unauthenticated("Invalid token format".to_string()))
|
||||||
}
|
}
|
||||||
_ => ApiError::Unauthorized("Token validation failed".to_string()),
|
_ => AppError(DomainError::Unauthenticated("Token validation failed".to_string())),
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let user_id: uuid::Uuid = claims
|
let user_id: uuid::Uuid = claims
|
||||||
.sub
|
.sub
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?;
|
.map_err(|_| AppError(DomainError::Unauthenticated("Invalid user ID in token".to_string())))?;
|
||||||
|
|
||||||
let user = state
|
let user = state
|
||||||
.auth_deps
|
.auth_deps
|
||||||
.user_query
|
.user_query
|
||||||
.find_by_id(domain::UserId::from(user_id))
|
.find_by_id(domain::UserId::from(user_id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?
|
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to fetch user: {}", e))))?
|
||||||
.ok_or_else(|| ApiError::Unauthorized("User not found".to_string()))?;
|
.ok_or_else(|| AppError(DomainError::Unauthenticated("User not found".to_string())))?;
|
||||||
|
|
||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,25 +192,6 @@ fn wire_repositories(pool: &DbPool) -> anyhow::Result<WireOutput> {
|
|||||||
provider_config_query: w.provider_config_query,
|
provider_config_query: w.provider_config_query,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
DbPool::Postgres(pg_pool) => {
|
|
||||||
let w = adapter_postgres::wire(pg_pool.clone());
|
|
||||||
Ok(WireOutput {
|
|
||||||
user_command: w.user_command,
|
|
||||||
user_query: w.user_query,
|
|
||||||
channel_command: w.channel_command,
|
|
||||||
channel_query: w.channel_query,
|
|
||||||
schedule_command: w.schedule_command,
|
|
||||||
schedule_query: w.schedule_query,
|
|
||||||
library_command: w.library_command,
|
|
||||||
library_query: w.library_query,
|
|
||||||
activity_query: w.activity_query,
|
|
||||||
settings: w.settings,
|
|
||||||
provider_config_command: w.provider_config_command,
|
|
||||||
provider_config_query: w.provider_config_query,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => anyhow::bail!("database backend not compiled into this binary"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use serde::Deserialize;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use api_types::{ActivityEventResponse, SettingsResponse};
|
use api_types::{ActivityEventResponse, ActivityLogParams, SettingsResponse};
|
||||||
use application::admin::{GetActivityLogQuery, GetSettingsQuery, UpdateSettingsCommand};
|
use application::admin::{GetActivityLogQuery, GetSettingsQuery, UpdateSettingsCommand};
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use crate::errors::AppError;
|
||||||
use crate::extractors::AdminUser;
|
use crate::extractors::AdminUser;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ const DEFAULT_ACTIVITY_LIMIT: u32 = 50;
|
|||||||
pub async fn get_settings(
|
pub async fn get_settings(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AdminUser(_user): AdminUser,
|
AdminUser(_user): AdminUser,
|
||||||
) -> Result<Json<SettingsResponse>, ApiError> {
|
) -> Result<Json<SettingsResponse>, AppError> {
|
||||||
let pairs =
|
let pairs =
|
||||||
application::admin::get_settings::execute(&state.admin_deps, GetSettingsQuery).await?;
|
application::admin::get_settings::execute(&state.admin_deps, GetSettingsQuery).await?;
|
||||||
let settings: HashMap<String, String> = pairs.into_iter().collect();
|
let settings: HashMap<String, String> = pairs.into_iter().collect();
|
||||||
@@ -26,7 +25,7 @@ pub async fn update_settings(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AdminUser(_user): AdminUser,
|
AdminUser(_user): AdminUser,
|
||||||
Json(body): Json<HashMap<String, String>>,
|
Json(body): Json<HashMap<String, String>>,
|
||||||
) -> Result<Json<SettingsResponse>, ApiError> {
|
) -> Result<Json<SettingsResponse>, AppError> {
|
||||||
let settings_vec: Vec<(String, String)> = body.into_iter().collect();
|
let settings_vec: Vec<(String, String)> = body.into_iter().collect();
|
||||||
let cmd = UpdateSettingsCommand {
|
let cmd = UpdateSettingsCommand {
|
||||||
settings: settings_vec,
|
settings: settings_vec,
|
||||||
@@ -39,16 +38,11 @@ pub async fn update_settings(
|
|||||||
Ok(Json(SettingsResponse { settings }))
|
Ok(Json(SettingsResponse { settings }))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ActivityLogParams {
|
|
||||||
pub limit: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_activity_log(
|
pub async fn get_activity_log(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AdminUser(_user): AdminUser,
|
AdminUser(_user): AdminUser,
|
||||||
Query(params): Query<ActivityLogParams>,
|
Query(params): Query<ActivityLogParams>,
|
||||||
) -> Result<Json<Vec<ActivityEventResponse>>, ApiError> {
|
) -> Result<Json<Vec<ActivityEventResponse>>, AppError> {
|
||||||
let query = GetActivityLogQuery {
|
let query = GetActivityLogQuery {
|
||||||
limit: params.limit.unwrap_or(DEFAULT_ACTIVITY_LIMIT),
|
limit: params.limit.unwrap_or(DEFAULT_ACTIVITY_LIMIT),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ use axum::extract::State;
|
|||||||
|
|
||||||
use api_types::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse};
|
use api_types::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse};
|
||||||
use application::auth::{LoginCommand, RegisterCommand};
|
use application::auth::{LoginCommand, RegisterCommand};
|
||||||
|
use domain::DomainError;
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use crate::errors::AppError;
|
||||||
use crate::extractors::CurrentUser;
|
use crate::extractors::CurrentUser;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ const SECS_PER_HOUR: u64 = 3600;
|
|||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<RegisterRequest>,
|
Json(req): Json<RegisterRequest>,
|
||||||
) -> Result<Json<UserResponse>, ApiError> {
|
) -> Result<Json<UserResponse>, AppError> {
|
||||||
let cmd = RegisterCommand {
|
let cmd = RegisterCommand {
|
||||||
email: req.email,
|
email: req.email,
|
||||||
password: req.password,
|
password: req.password,
|
||||||
@@ -26,7 +27,7 @@ pub async fn register(
|
|||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<LoginRequest>,
|
Json(req): Json<LoginRequest>,
|
||||||
) -> Result<Json<TokenResponse>, ApiError> {
|
) -> Result<Json<TokenResponse>, AppError> {
|
||||||
let cmd = LoginCommand {
|
let cmd = LoginCommand {
|
||||||
email: req.email,
|
email: req.email,
|
||||||
password: req.password,
|
password: req.password,
|
||||||
@@ -41,13 +42,13 @@ pub async fn login(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout() -> Result<Json<serde_json::Value>, ApiError> {
|
pub async fn logout() -> Result<Json<serde_json::Value>, AppError> {
|
||||||
Ok(Json(serde_json::json!({"message": "logged out"})))
|
Ok(Json(serde_json::json!({"message": "logged out"})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn me(
|
pub async fn me(
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
) -> Result<Json<UserResponse>, ApiError> {
|
) -> Result<Json<UserResponse>, AppError> {
|
||||||
Ok(Json(UserResponse::from(user)))
|
Ok(Json(UserResponse::from(user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ pub async fn me(
|
|||||||
pub async fn get_token(
|
pub async fn get_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<LoginRequest>,
|
Json(req): Json<LoginRequest>,
|
||||||
) -> Result<Json<TokenResponse>, ApiError> {
|
) -> Result<Json<TokenResponse>, AppError> {
|
||||||
let cmd = LoginCommand {
|
let cmd = LoginCommand {
|
||||||
email: req.email,
|
email: req.email,
|
||||||
password: req.password,
|
password: req.password,
|
||||||
@@ -74,29 +75,29 @@ pub async fn get_token(
|
|||||||
pub async fn refresh_token(
|
pub async fn refresh_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<RefreshRequest>,
|
Json(req): Json<RefreshRequest>,
|
||||||
) -> Result<Json<TokenResponse>, ApiError> {
|
) -> Result<Json<TokenResponse>, AppError> {
|
||||||
let validator = state
|
let validator = state
|
||||||
.jwt_validator
|
.jwt_validator
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
|
.ok_or_else(|| AppError(DomainError::InfrastructureError("JWT not configured".to_string())))?;
|
||||||
|
|
||||||
let claims = validator.validate_refresh_token(&req.refresh_token).map_err(|e| {
|
let claims = validator.validate_refresh_token(&req.refresh_token).map_err(|e| {
|
||||||
tracing::debug!("Refresh token validation failed: {:?}", e);
|
tracing::debug!("Refresh token validation failed: {:?}", e);
|
||||||
ApiError::Unauthorized("Invalid refresh token".to_string())
|
AppError(DomainError::Unauthenticated("Invalid refresh token".to_string()))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let user_id: uuid::Uuid = claims
|
let user_id: uuid::Uuid = claims
|
||||||
.sub
|
.sub
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?;
|
.map_err(|_| AppError(DomainError::Unauthenticated("Invalid user ID in token".to_string())))?;
|
||||||
|
|
||||||
let user = state
|
let user = state
|
||||||
.auth_deps
|
.auth_deps
|
||||||
.user_query
|
.user_query
|
||||||
.find_by_id(domain::UserId::from(user_id))
|
.find_by_id(domain::UserId::from(user_id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?
|
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to fetch user: {}", e))))?
|
||||||
.ok_or_else(|| ApiError::Unauthorized("User not found".to_string()))?;
|
.ok_or_else(|| AppError(DomainError::Unauthenticated("User not found".to_string())))?;
|
||||||
|
|
||||||
let (access_token, refresh_token) = create_tokens(&user, &state, true)?;
|
let (access_token, refresh_token) = create_tokens(&user, &state, true)?;
|
||||||
Ok(Json(TokenResponse {
|
Ok(Json(TokenResponse {
|
||||||
@@ -111,23 +112,23 @@ fn create_tokens(
|
|||||||
user: &domain::User,
|
user: &domain::User,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
remember_me: bool,
|
remember_me: bool,
|
||||||
) -> Result<(String, Option<String>), ApiError> {
|
) -> Result<(String, Option<String>), AppError> {
|
||||||
#[cfg(feature = "auth-jwt")]
|
#[cfg(feature = "auth-jwt")]
|
||||||
{
|
{
|
||||||
let validator = state
|
let validator = state
|
||||||
.jwt_validator
|
.jwt_validator
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
|
.ok_or_else(|| AppError(DomainError::InfrastructureError("JWT not configured".to_string())))?;
|
||||||
|
|
||||||
let access = validator
|
let access = validator
|
||||||
.create_token(user)
|
.create_token(user)
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to create token: {}", e)))?;
|
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to create token: {}", e))))?;
|
||||||
|
|
||||||
let refresh = if remember_me {
|
let refresh = if remember_me {
|
||||||
Some(
|
Some(
|
||||||
validator
|
validator
|
||||||
.create_refresh_token(user)
|
.create_refresh_token(user)
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to create refresh token: {}", e)))?,
|
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to create refresh token: {}", e))))?,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -139,6 +140,6 @@ fn create_tokens(
|
|||||||
#[cfg(not(feature = "auth-jwt"))]
|
#[cfg(not(feature = "auth-jwt"))]
|
||||||
{
|
{
|
||||||
let _ = (user, state, remember_me);
|
let _ = (user, state, remember_me);
|
||||||
Err(ApiError::Internal("JWT feature not enabled".to_string()))
|
Err(AppError(DomainError::InfrastructureError("JWT feature not enabled".to_string())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,15 +13,16 @@ use application::config_snapshots::{
|
|||||||
GetSnapshotQuery, ListSnapshotsQuery, PatchLabelCommand, RestoreSnapshotCommand,
|
GetSnapshotQuery, ListSnapshotsQuery, PatchLabelCommand, RestoreSnapshotCommand,
|
||||||
SaveSnapshotCommand,
|
SaveSnapshotCommand,
|
||||||
};
|
};
|
||||||
|
use domain::DomainError;
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use crate::errors::AppError;
|
||||||
use crate::extractors::CurrentUser;
|
use crate::extractors::CurrentUser;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub async fn list_channels(
|
pub async fn list_channels(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
) -> Result<Json<Vec<ChannelResponse>>, ApiError> {
|
) -> Result<Json<Vec<ChannelResponse>>, AppError> {
|
||||||
let channels =
|
let channels =
|
||||||
application::channels::list::execute(&state.channel_query_deps, ListChannelsQuery).await?;
|
application::channels::list::execute(&state.channel_query_deps, ListChannelsQuery).await?;
|
||||||
Ok(Json(channels.into_iter().map(ChannelResponse::from).collect()))
|
Ok(Json(channels.into_iter().map(ChannelResponse::from).collect()))
|
||||||
@@ -30,7 +31,7 @@ pub async fn list_channels(
|
|||||||
pub async fn list_my_channels(
|
pub async fn list_my_channels(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
) -> Result<Json<Vec<ChannelResponse>>, ApiError> {
|
) -> Result<Json<Vec<ChannelResponse>>, AppError> {
|
||||||
let query = ListByOwnerQuery {
|
let query = ListByOwnerQuery {
|
||||||
owner_id: user.id(),
|
owner_id: user.id(),
|
||||||
};
|
};
|
||||||
@@ -43,7 +44,7 @@ pub async fn create_channel(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
Json(req): Json<CreateChannelRequest>,
|
Json(req): Json<CreateChannelRequest>,
|
||||||
) -> Result<Json<ChannelResponse>, ApiError> {
|
) -> Result<Json<ChannelResponse>, AppError> {
|
||||||
let cmd = CreateChannelCommand {
|
let cmd = CreateChannelCommand {
|
||||||
owner_id: user.id(),
|
owner_id: user.id(),
|
||||||
name: req.name,
|
name: req.name,
|
||||||
@@ -57,13 +58,13 @@ pub async fn get_channel(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Path(id): Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
) -> Result<Json<ChannelResponse>, ApiError> {
|
) -> Result<Json<ChannelResponse>, AppError> {
|
||||||
let query = GetChannelQuery {
|
let query = GetChannelQuery {
|
||||||
channel_id: id.into(),
|
channel_id: id.into(),
|
||||||
};
|
};
|
||||||
let channel = application::channels::get::execute(&state.channel_query_deps, query)
|
let channel = application::channels::get::execute(&state.channel_query_deps, query)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiError::not_found(format!("Channel {id} not found")))?;
|
.ok_or_else(|| AppError(DomainError::NotFound(format!("Channel {id} not found"))))?;
|
||||||
Ok(Json(ChannelResponse::from(channel)))
|
Ok(Json(ChannelResponse::from(channel)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,12 +73,12 @@ pub async fn update_channel(
|
|||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
Path(id): Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
Json(req): Json<UpdateChannelRequest>,
|
Json(req): Json<UpdateChannelRequest>,
|
||||||
) -> Result<Json<ChannelResponse>, ApiError> {
|
) -> Result<Json<ChannelResponse>, AppError> {
|
||||||
let schedule_config = req
|
let schedule_config = req
|
||||||
.schedule_config
|
.schedule_config
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
serde_json::from_value(v)
|
serde_json::from_value(v)
|
||||||
.map_err(|e| ApiError::validation(format!("Invalid schedule_config: {e}")))
|
.map_err(|e| AppError(DomainError::ValidationError(format!("Invalid schedule_config: {e}"))))
|
||||||
})
|
})
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ pub async fn update_channel(
|
|||||||
.recycle_policy
|
.recycle_policy
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
serde_json::from_value(v)
|
serde_json::from_value(v)
|
||||||
.map_err(|e| ApiError::validation(format!("Invalid recycle_policy: {e}")))
|
.map_err(|e| AppError(DomainError::ValidationError(format!("Invalid recycle_policy: {e}"))))
|
||||||
})
|
})
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ pub async fn delete_channel(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
Path(id): Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
) -> Result<axum::http::StatusCode, ApiError> {
|
) -> Result<axum::http::StatusCode, AppError> {
|
||||||
let cmd = DeleteChannelCommand {
|
let cmd = DeleteChannelCommand {
|
||||||
channel_id: id.into(),
|
channel_id: id.into(),
|
||||||
owner_id: user.id(),
|
owner_id: user.id(),
|
||||||
@@ -120,7 +121,7 @@ pub async fn save_snapshot(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Path(id): Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
) -> Result<Json<ConfigSnapshotResponse>, ApiError> {
|
) -> Result<Json<ConfigSnapshotResponse>, AppError> {
|
||||||
let cmd = SaveSnapshotCommand {
|
let cmd = SaveSnapshotCommand {
|
||||||
channel_id: id.into(),
|
channel_id: id.into(),
|
||||||
label: None,
|
label: None,
|
||||||
@@ -133,7 +134,7 @@ pub async fn list_snapshots(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Path(id): Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
) -> Result<Json<Vec<ConfigSnapshotResponse>>, ApiError> {
|
) -> Result<Json<Vec<ConfigSnapshotResponse>>, AppError> {
|
||||||
let query = ListSnapshotsQuery {
|
let query = ListSnapshotsQuery {
|
||||||
channel_id: id.into(),
|
channel_id: id.into(),
|
||||||
};
|
};
|
||||||
@@ -145,14 +146,14 @@ pub async fn get_snapshot(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||||
) -> Result<Json<ConfigSnapshotResponse>, ApiError> {
|
) -> Result<Json<ConfigSnapshotResponse>, AppError> {
|
||||||
let query = GetSnapshotQuery {
|
let query = GetSnapshotQuery {
|
||||||
channel_id: id.into(),
|
channel_id: id.into(),
|
||||||
snapshot_id: snapshot_id.into(),
|
snapshot_id: snapshot_id.into(),
|
||||||
};
|
};
|
||||||
let snap = application::config_snapshots::get::execute(&state.config_snapshot_deps, query)
|
let snap = application::config_snapshots::get::execute(&state.config_snapshot_deps, query)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiError::not_found("Snapshot not found"))?;
|
.ok_or_else(|| AppError(DomainError::NotFound("Snapshot not found".into())))?;
|
||||||
Ok(Json(ConfigSnapshotResponse::from(snap)))
|
Ok(Json(ConfigSnapshotResponse::from(snap)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ pub async fn patch_snapshot(
|
|||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||||
Json(req): Json<PatchSnapshotRequest>,
|
Json(req): Json<PatchSnapshotRequest>,
|
||||||
) -> Result<Json<ConfigSnapshotResponse>, ApiError> {
|
) -> Result<Json<ConfigSnapshotResponse>, AppError> {
|
||||||
let cmd = PatchLabelCommand {
|
let cmd = PatchLabelCommand {
|
||||||
channel_id: id.into(),
|
channel_id: id.into(),
|
||||||
snapshot_id: snapshot_id.into(),
|
snapshot_id: snapshot_id.into(),
|
||||||
@@ -170,7 +171,7 @@ pub async fn patch_snapshot(
|
|||||||
let snap =
|
let snap =
|
||||||
application::config_snapshots::patch_label::execute(&state.config_snapshot_deps, cmd)
|
application::config_snapshots::patch_label::execute(&state.config_snapshot_deps, cmd)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiError::not_found("Snapshot not found"))?;
|
.ok_or_else(|| AppError(DomainError::NotFound("Snapshot not found".into())))?;
|
||||||
Ok(Json(ConfigSnapshotResponse::from(snap)))
|
Ok(Json(ConfigSnapshotResponse::from(snap)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +179,7 @@ pub async fn restore_snapshot(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||||
) -> Result<Json<ChannelResponse>, ApiError> {
|
) -> Result<Json<ChannelResponse>, AppError> {
|
||||||
let cmd = RestoreSnapshotCommand {
|
let cmd = RestoreSnapshotCommand {
|
||||||
channel_id: id.into(),
|
channel_id: id.into(),
|
||||||
snapshot_id: snapshot_id.into(),
|
snapshot_id: snapshot_id.into(),
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ use axum::extract::State;
|
|||||||
|
|
||||||
use api_types::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
|
use api_types::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use crate::errors::AppError;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
const FALLBACK_STREAMING_PROTOCOL: &str = "direct_file";
|
const FALLBACK_STREAMING_PROTOCOL: &str = "direct_file";
|
||||||
|
|
||||||
pub async fn get_config(
|
pub async fn get_config(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<ConfigResponse>, ApiError> {
|
) -> Result<Json<ConfigResponse>, AppError> {
|
||||||
let registry = &state.provider_registry;
|
let registry = &state.provider_registry;
|
||||||
let provider_ids = registry.provider_ids();
|
let provider_ids = registry.provider_ids();
|
||||||
let primary_id = registry.primary_id().to_string();
|
let primary_id = registry.primary_id().to_string();
|
||||||
|
|||||||
@@ -2,22 +2,24 @@ use axum::extract::{Path, State};
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use domain::DomainError;
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub async fn stream_file(
|
pub async fn stream_file(
|
||||||
State(_state): State<AppState>,
|
State(_state): State<AppState>,
|
||||||
Path(_id): Path<String>,
|
Path(_id): Path<String>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
Err::<StatusCode, _>(ApiError::NotFound(
|
Err::<StatusCode, _>(AppError(DomainError::NotFound(
|
||||||
"Local file streaming not yet wired in presentation crate".to_string(),
|
"Local file streaming not yet wired in presentation crate".to_string(),
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn rescan(
|
pub async fn rescan(
|
||||||
State(_state): State<AppState>,
|
State(_state): State<AppState>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
Err::<StatusCode, _>(ApiError::NotFound(
|
Err::<StatusCode, _>(AppError(DomainError::NotFound(
|
||||||
"Local file rescan not yet wired in presentation crate".to_string(),
|
"Local file rescan not yet wired in presentation crate".to_string(),
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::header;
|
use axum::http::header;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
|
use api_types::IptvParams;
|
||||||
use application::iptv::{GetM3uQuery, GetXmltvQuery};
|
use application::iptv::{GetM3uQuery, GetXmltvQuery};
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use crate::errors::AppError;
|
||||||
use crate::extractors::OptionalCurrentUser;
|
use crate::extractors::OptionalCurrentUser;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
const M3U_CONTENT_TYPE: &str = "audio/x-mpegurl; charset=utf-8";
|
const M3U_CONTENT_TYPE: &str = "audio/x-mpegurl; charset=utf-8";
|
||||||
const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8";
|
const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct IptvParams {
|
|
||||||
pub token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn m3u_playlist(
|
pub async fn m3u_playlist(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
OptionalCurrentUser(_user): OptionalCurrentUser,
|
OptionalCurrentUser(_user): OptionalCurrentUser,
|
||||||
Query(params): Query<IptvParams>,
|
Query(params): Query<IptvParams>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let query = GetM3uQuery {
|
let query = GetM3uQuery {
|
||||||
base_url: state.config.base_url.clone(),
|
base_url: state.config.base_url.clone(),
|
||||||
token: params.token,
|
token: params.token,
|
||||||
@@ -33,7 +28,7 @@ pub async fn m3u_playlist(
|
|||||||
pub async fn xmltv_epg(
|
pub async fn xmltv_epg(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
OptionalCurrentUser(_user): OptionalCurrentUser,
|
OptionalCurrentUser(_user): OptionalCurrentUser,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let content = application::iptv::xmltv::execute(&state.iptv_deps, GetXmltvQuery).await?;
|
let content = application::iptv::xmltv::execute(&state.iptv_deps, GetXmltvQuery).await?;
|
||||||
Ok(([(header::CONTENT_TYPE, XML_CONTENT_TYPE)], content))
|
Ok(([(header::CONTENT_TYPE, XML_CONTENT_TYPE)], content))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,28 @@
|
|||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Serialize;
|
||||||
|
|
||||||
use api_types::{CollectionResponse, LibraryItemResponse, PaginatedResponse, SeasonResponse, ShowResponse};
|
use api_types::{
|
||||||
|
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, PaginatedResponse,
|
||||||
|
ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams,
|
||||||
|
};
|
||||||
use application::library::{
|
use application::library::{
|
||||||
GetItemQuery, GetSyncStatusQuery, ListCollectionsQuery, ListGenresQuery, ListSeasonsQuery,
|
GetItemQuery, GetSyncStatusQuery, ListCollectionsQuery, ListGenresQuery, ListSeasonsQuery,
|
||||||
ListShowsQuery, SearchItemsQuery, TriggerSyncCommand,
|
ListShowsQuery, SearchItemsQuery, TriggerSyncCommand,
|
||||||
};
|
};
|
||||||
|
use domain::DomainError;
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use crate::errors::AppError;
|
||||||
use crate::extractors::{AdminUser, CurrentUser};
|
use crate::extractors::{AdminUser, CurrentUser};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
const DEFAULT_SEARCH_LIMIT: u32 = 50;
|
const DEFAULT_SEARCH_LIMIT: u32 = 50;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct LibrarySearchParams {
|
|
||||||
pub provider: Option<String>,
|
|
||||||
pub content_type: Option<String>,
|
|
||||||
#[serde(default, rename = "genres[]")]
|
|
||||||
pub genres: Vec<String>,
|
|
||||||
pub search_term: Option<String>,
|
|
||||||
pub collection_id: Option<String>,
|
|
||||||
#[serde(default, rename = "series_names[]")]
|
|
||||||
pub series_names: Vec<String>,
|
|
||||||
pub season_number: Option<u32>,
|
|
||||||
pub decade: Option<u16>,
|
|
||||||
pub offset: Option<u32>,
|
|
||||||
pub limit: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn search_items(
|
pub async fn search_items(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Query(params): Query<LibrarySearchParams>,
|
Query(params): Query<LibrarySearchParams>,
|
||||||
) -> Result<Json<PaginatedResponse<LibraryItemResponse>>, ApiError> {
|
) -> Result<Json<PaginatedResponse<LibraryItemResponse>>, AppError> {
|
||||||
let query = SearchItemsQuery {
|
let query = SearchItemsQuery {
|
||||||
provider_id: params.provider,
|
provider_id: params.provider,
|
||||||
content_type: params.content_type,
|
content_type: params.content_type,
|
||||||
@@ -58,11 +46,11 @@ pub async fn get_item(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<LibraryItemResponse>, ApiError> {
|
) -> Result<Json<LibraryItemResponse>, AppError> {
|
||||||
let query = GetItemQuery { item_id: id.clone() };
|
let query = GetItemQuery { item_id: id.clone() };
|
||||||
let item = application::library::get_item::execute(&state.library_query_deps, query)
|
let item = application::library::get_item::execute(&state.library_query_deps, query)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiError::not_found(format!("Library item {id} not found")))?;
|
.ok_or_else(|| AppError(DomainError::NotFound(format!("Library item {id} not found"))))?;
|
||||||
Ok(Json(LibraryItemResponse::from(item)))
|
Ok(Json(LibraryItemResponse::from(item)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +58,7 @@ pub async fn list_collections(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Query(params): Query<ProviderParam>,
|
Query(params): Query<ProviderParam>,
|
||||||
) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
|
) -> Result<Json<Vec<CollectionResponse>>, AppError> {
|
||||||
let query = ListCollectionsQuery {
|
let query = ListCollectionsQuery {
|
||||||
provider_id: params.provider,
|
provider_id: params.provider,
|
||||||
};
|
};
|
||||||
@@ -84,24 +72,11 @@ pub async fn list_collections(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ProviderParam {
|
|
||||||
pub provider: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ShowsParams {
|
|
||||||
pub provider: Option<String>,
|
|
||||||
pub search_term: Option<String>,
|
|
||||||
#[serde(default, rename = "genres[]")]
|
|
||||||
pub genres: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_shows(
|
pub async fn list_shows(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Query(params): Query<ShowsParams>,
|
Query(params): Query<ShowsParams>,
|
||||||
) -> Result<Json<Vec<ShowResponse>>, ApiError> {
|
) -> Result<Json<Vec<ShowResponse>>, AppError> {
|
||||||
let query = ListShowsQuery {
|
let query = ListShowsQuery {
|
||||||
provider_id: params.provider,
|
provider_id: params.provider,
|
||||||
search_term: params.search_term,
|
search_term: params.search_term,
|
||||||
@@ -111,17 +86,11 @@ pub async fn list_shows(
|
|||||||
Ok(Json(shows.into_iter().map(ShowResponse::from).collect()))
|
Ok(Json(shows.into_iter().map(ShowResponse::from).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct SeasonsParams {
|
|
||||||
pub series_name: String,
|
|
||||||
pub provider: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_seasons(
|
pub async fn list_seasons(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Query(params): Query<SeasonsParams>,
|
Query(params): Query<SeasonsParams>,
|
||||||
) -> Result<Json<Vec<SeasonResponse>>, ApiError> {
|
) -> Result<Json<Vec<SeasonResponse>>, AppError> {
|
||||||
let query = ListSeasonsQuery {
|
let query = ListSeasonsQuery {
|
||||||
series_name: params.series_name,
|
series_name: params.series_name,
|
||||||
provider_id: params.provider,
|
provider_id: params.provider,
|
||||||
@@ -133,17 +102,11 @@ pub async fn list_seasons(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct GenresParams {
|
|
||||||
pub content_type: Option<String>,
|
|
||||||
pub provider: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_genres(
|
pub async fn list_genres(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Query(params): Query<GenresParams>,
|
Query(params): Query<GenresParams>,
|
||||||
) -> Result<Json<Vec<String>>, ApiError> {
|
) -> Result<Json<Vec<String>>, AppError> {
|
||||||
let query = ListGenresQuery {
|
let query = ListGenresQuery {
|
||||||
content_type: params.content_type,
|
content_type: params.content_type,
|
||||||
provider_id: params.provider,
|
provider_id: params.provider,
|
||||||
@@ -166,7 +129,7 @@ pub(crate) struct SyncStatusEntry {
|
|||||||
pub async fn sync_status(
|
pub async fn sync_status(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
) -> Result<Json<Vec<SyncStatusEntry>>, ApiError> {
|
) -> Result<Json<Vec<SyncStatusEntry>>, AppError> {
|
||||||
let entries =
|
let entries =
|
||||||
application::library::get_sync_status::execute(&state.library_query_deps, GetSyncStatusQuery)
|
application::library::get_sync_status::execute(&state.library_query_deps, GetSyncStatusQuery)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -187,15 +150,15 @@ pub async fn sync_status(
|
|||||||
pub async fn trigger_sync(
|
pub async fn trigger_sync(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AdminUser(_user): AdminUser,
|
AdminUser(_user): AdminUser,
|
||||||
) -> Result<axum::http::StatusCode, ApiError> {
|
) -> Result<axum::http::StatusCode, AppError> {
|
||||||
let cmd = TriggerSyncCommand { provider_id: None };
|
let cmd = TriggerSyncCommand { provider_id: None };
|
||||||
application::library::sync::execute(&state.library_command_deps, cmd)
|
application::library::sync::execute(&state.library_command_deps, cmd)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
if e.to_string().contains("already running") {
|
if e.to_string().contains("already running") {
|
||||||
ApiError::conflict(e.to_string())
|
AppError(DomainError::Conflict(e.to_string()))
|
||||||
} else {
|
} else {
|
||||||
ApiError::from(e)
|
AppError::from(e)
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ use api_types::{ProviderConfigRequest, ProviderConfigResponse};
|
|||||||
use application::providers::{
|
use application::providers::{
|
||||||
DeleteProviderCommand, GetProviderQuery, ListProvidersQuery, UpsertProviderCommand,
|
DeleteProviderCommand, GetProviderQuery, ListProvidersQuery, UpsertProviderCommand,
|
||||||
};
|
};
|
||||||
|
use domain::DomainError;
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use crate::errors::AppError;
|
||||||
use crate::extractors::AdminUser;
|
use crate::extractors::AdminUser;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub async fn list_providers(
|
pub async fn list_providers(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AdminUser(_user): AdminUser,
|
AdminUser(_user): AdminUser,
|
||||||
) -> Result<Json<Vec<ProviderConfigResponse>>, ApiError> {
|
) -> Result<Json<Vec<ProviderConfigResponse>>, AppError> {
|
||||||
let providers =
|
let providers =
|
||||||
application::providers::list::execute(&state.provider_deps, ListProvidersQuery).await?;
|
application::providers::list::execute(&state.provider_deps, ListProvidersQuery).await?;
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
@@ -28,11 +29,11 @@ pub async fn get_provider(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AdminUser(_user): AdminUser,
|
AdminUser(_user): AdminUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<ProviderConfigResponse>, ApiError> {
|
) -> Result<Json<ProviderConfigResponse>, AppError> {
|
||||||
let query = GetProviderQuery { id: id.clone() };
|
let query = GetProviderQuery { id: id.clone() };
|
||||||
let provider = application::providers::get::execute(&state.provider_deps, query)
|
let provider = application::providers::get::execute(&state.provider_deps, query)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiError::not_found(format!("Provider {id} not found")))?;
|
.ok_or_else(|| AppError(DomainError::NotFound(format!("Provider {id} not found"))))?;
|
||||||
Ok(Json(ProviderConfigResponse::from(provider)))
|
Ok(Json(ProviderConfigResponse::from(provider)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +42,9 @@ pub async fn upsert_provider(
|
|||||||
AdminUser(_user): AdminUser,
|
AdminUser(_user): AdminUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(req): Json<ProviderConfigRequest>,
|
Json(req): Json<ProviderConfigRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
let config_json = serde_json::to_string(&req.config)
|
let config_json = serde_json::to_string(&req.config)
|
||||||
.map_err(|e| ApiError::validation(format!("Invalid config JSON: {e}")))?;
|
.map_err(|e| AppError(DomainError::ValidationError(format!("Invalid config JSON: {e}"))))?;
|
||||||
|
|
||||||
let cmd = UpsertProviderCommand {
|
let cmd = UpsertProviderCommand {
|
||||||
id,
|
id,
|
||||||
@@ -59,7 +60,7 @@ pub async fn delete_provider(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AdminUser(_user): AdminUser,
|
AdminUser(_user): AdminUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<axum::http::StatusCode, ApiError> {
|
) -> Result<axum::http::StatusCode, AppError> {
|
||||||
let cmd = DeleteProviderCommand { id };
|
let cmd = DeleteProviderCommand { id };
|
||||||
application::providers::delete::execute(&state.provider_deps, cmd).await?;
|
application::providers::delete::execute(&state.provider_deps, cmd).await?;
|
||||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use application::schedule::{
|
|||||||
GetStreamUrlQuery, ListHistoryQuery,
|
GetStreamUrlQuery, ListHistoryQuery,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::errors::ApiError;
|
use crate::errors::AppError;
|
||||||
use crate::extractors::CurrentUser;
|
use crate::extractors::CurrentUser;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ pub async fn generate_schedule(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Path(id): Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
) -> Result<Json<ScheduleResponse>, ApiError> {
|
) -> Result<Json<ScheduleResponse>, AppError> {
|
||||||
let cmd = GenerateScheduleCommand { channel_id: id };
|
let cmd = GenerateScheduleCommand { channel_id: id };
|
||||||
let schedule = application::schedule::generate::execute(&state.schedule_deps, cmd).await?;
|
let schedule = application::schedule::generate::execute(&state.schedule_deps, cmd).await?;
|
||||||
Ok(Json(ScheduleResponse::from(schedule)))
|
Ok(Json(ScheduleResponse::from(schedule)))
|
||||||
@@ -29,7 +29,7 @@ pub async fn get_active_schedule(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Path(id): Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
) -> Result<axum::response::Response, ApiError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let query = GetActiveScheduleQuery { channel_id: id };
|
let query = GetActiveScheduleQuery { channel_id: id };
|
||||||
match application::schedule::get_active::execute(&state.schedule_deps, query).await? {
|
match application::schedule::get_active::execute(&state.schedule_deps, query).await? {
|
||||||
Some(schedule) => Ok(Json(ScheduleResponse::from(schedule)).into_response()),
|
Some(schedule) => Ok(Json(ScheduleResponse::from(schedule)).into_response()),
|
||||||
@@ -40,7 +40,7 @@ pub async fn get_active_schedule(
|
|||||||
pub async fn get_current_broadcast(
|
pub async fn get_current_broadcast(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
) -> Result<axum::response::Response, ApiError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let query = GetCurrentBroadcastQuery { channel_id: id };
|
let query = GetCurrentBroadcastQuery { channel_id: id };
|
||||||
match application::schedule::get_current_broadcast::execute(&state.schedule_deps, query).await?
|
match application::schedule::get_current_broadcast::execute(&state.schedule_deps, query).await?
|
||||||
{
|
{
|
||||||
@@ -69,7 +69,7 @@ pub async fn get_current_broadcast(
|
|||||||
pub async fn get_epg(
|
pub async fn get_epg(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
) -> Result<Json<Vec<SlotResponse>>, ApiError> {
|
) -> Result<Json<Vec<SlotResponse>>, AppError> {
|
||||||
let query = GetEpgQuery { channel_id: id };
|
let query = GetEpgQuery { channel_id: id };
|
||||||
let slots = application::schedule::get_epg::execute(&state.schedule_deps, query).await?;
|
let slots = application::schedule::get_epg::execute(&state.schedule_deps, query).await?;
|
||||||
Ok(Json(slots.into_iter().map(SlotResponse::from).collect()))
|
Ok(Json(slots.into_iter().map(SlotResponse::from).collect()))
|
||||||
@@ -78,7 +78,7 @@ pub async fn get_epg(
|
|||||||
pub async fn get_stream(
|
pub async fn get_stream(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
) -> Result<axum::response::Response, ApiError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let broadcast_query = GetCurrentBroadcastQuery { channel_id: id };
|
let broadcast_query = GetCurrentBroadcastQuery { channel_id: id };
|
||||||
let broadcast =
|
let broadcast =
|
||||||
application::schedule::get_current_broadcast::execute(&state.schedule_deps, broadcast_query)
|
application::schedule::get_current_broadcast::execute(&state.schedule_deps, broadcast_query)
|
||||||
@@ -107,7 +107,7 @@ pub async fn list_schedule_history(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Path(id): Path<uuid::Uuid>,
|
Path(id): Path<uuid::Uuid>,
|
||||||
) -> Result<Json<Vec<ScheduleHistoryEntry>>, ApiError> {
|
) -> Result<Json<Vec<ScheduleHistoryEntry>>, AppError> {
|
||||||
let query = ListHistoryQuery { channel_id: id };
|
let query = ListHistoryQuery { channel_id: id };
|
||||||
let history =
|
let history =
|
||||||
application::schedule::list_history::execute(&state.schedule_deps, query).await?;
|
application::schedule::list_history::execute(&state.schedule_deps, query).await?;
|
||||||
|
|||||||
Reference in New Issue
Block a user