presentation: HTTP server crate w/ handlers, routes, background tasks
Axum binary that wires all clean-arch crates together: - AppState holds pre-built Deps structs (auth, channels, schedule, library, etc.) - JWT extractors (CurrentUser, AdminUser, OptionalCurrentUser) - Handlers delegate to application use cases, map to api-types DTOs - Routes: auth, channels, schedule, library, admin, providers, config, iptv - Background: auto-scheduler, broadcast poller, webhook consumer, library sync - Factory builds everything from Config + DbPool - SimpleProviderRegistry impl of IProviderRegistry trait - NoopMediaProvider fallback
This commit is contained in:
66
crates/presentation/src/handlers/admin.rs
Normal file
66
crates/presentation/src/handlers/admin.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! Admin handlers.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use api_types::{ActivityEventResponse, SettingsResponse};
|
||||
use application::admin::{GetActivityLogQuery, GetSettingsQuery, UpdateSettingsCommand};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::AdminUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// GET /admin/settings
|
||||
pub async fn get_settings(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
) -> Result<Json<SettingsResponse>, ApiError> {
|
||||
let pairs =
|
||||
application::admin::get_settings::execute(&state.admin_deps, GetSettingsQuery).await?;
|
||||
let settings: HashMap<String, String> = pairs.into_iter().collect();
|
||||
Ok(Json(SettingsResponse { settings }))
|
||||
}
|
||||
|
||||
/// PUT /admin/settings
|
||||
pub async fn update_settings(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Json(body): Json<HashMap<String, String>>,
|
||||
) -> Result<Json<SettingsResponse>, ApiError> {
|
||||
let settings_vec: Vec<(String, String)> = body.into_iter().collect();
|
||||
let cmd = UpdateSettingsCommand {
|
||||
settings: settings_vec,
|
||||
};
|
||||
application::admin::update_settings::execute(&state.admin_deps, cmd).await?;
|
||||
|
||||
// Re-read after update
|
||||
let pairs =
|
||||
application::admin::get_settings::execute(&state.admin_deps, GetSettingsQuery).await?;
|
||||
let settings: HashMap<String, String> = pairs.into_iter().collect();
|
||||
Ok(Json(SettingsResponse { settings }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ActivityLogParams {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// GET /admin/activity
|
||||
pub async fn get_activity_log(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Query(params): Query<ActivityLogParams>,
|
||||
) -> Result<Json<Vec<ActivityEventResponse>>, ApiError> {
|
||||
let query = GetActivityLogQuery {
|
||||
limit: params.limit.unwrap_or(50),
|
||||
};
|
||||
let events = application::admin::activity_log::execute(&state.admin_deps, query).await?;
|
||||
Ok(Json(
|
||||
events
|
||||
.into_iter()
|
||||
.map(ActivityEventResponse::from)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
149
crates/presentation/src/handlers/auth.rs
Normal file
149
crates/presentation/src/handlers/auth.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
//! Authentication handlers.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
|
||||
use api_types::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse};
|
||||
use application::auth::{LoginCommand, RegisterCommand};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::CurrentUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// POST /auth/register
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<Json<UserResponse>, ApiError> {
|
||||
let cmd = RegisterCommand {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
};
|
||||
let user = application::auth::register::execute(&state.auth_deps, cmd).await?;
|
||||
Ok(Json(UserResponse::from(user)))
|
||||
}
|
||||
|
||||
/// POST /auth/login
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<TokenResponse>, ApiError> {
|
||||
let cmd = LoginCommand {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
};
|
||||
let user = application::auth::login::execute(&state.auth_deps, cmd).await?;
|
||||
let (access_token, refresh_token) = create_tokens(&user, &state, req.remember_me)?;
|
||||
Ok(Json(TokenResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
refresh_token,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /auth/logout — no-op for JWT (stateless)
|
||||
pub async fn logout() -> Result<Json<serde_json::Value>, ApiError> {
|
||||
Ok(Json(serde_json::json!({"message": "logged out"})))
|
||||
}
|
||||
|
||||
/// GET /auth/me
|
||||
pub async fn me(
|
||||
CurrentUser(user): CurrentUser,
|
||||
) -> Result<Json<UserResponse>, ApiError> {
|
||||
Ok(Json(UserResponse::from(user)))
|
||||
}
|
||||
|
||||
/// POST /auth/token — exchange credentials for tokens
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub async fn get_token(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<TokenResponse>, ApiError> {
|
||||
let cmd = LoginCommand {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
};
|
||||
let user = application::auth::login::execute(&state.auth_deps, cmd).await?;
|
||||
let (access_token, refresh_token) = create_tokens(&user, &state, req.remember_me)?;
|
||||
Ok(Json(TokenResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
refresh_token,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /auth/refresh — refresh an access token
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub async fn refresh_token(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> Result<Json<TokenResponse>, ApiError> {
|
||||
let validator = state
|
||||
.jwt_validator
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
|
||||
|
||||
let claims = validator.validate_refresh_token(&req.refresh_token).map_err(|e| {
|
||||
tracing::debug!("Refresh token validation failed: {:?}", e);
|
||||
ApiError::Unauthorized("Invalid refresh token".to_string())
|
||||
})?;
|
||||
|
||||
let user_id: uuid::Uuid = claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?;
|
||||
|
||||
let user = state
|
||||
.auth_deps
|
||||
.user_query
|
||||
.find_by_id(domain::UserId::from(user_id))
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?
|
||||
.ok_or_else(|| ApiError::Unauthorized("User not found".to_string()))?;
|
||||
|
||||
let (access_token, refresh_token) = create_tokens(&user, &state, true)?;
|
||||
Ok(Json(TokenResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
refresh_token,
|
||||
}))
|
||||
}
|
||||
|
||||
fn create_tokens(
|
||||
user: &domain::User,
|
||||
state: &AppState,
|
||||
remember_me: bool,
|
||||
) -> Result<(String, Option<String>), ApiError> {
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
{
|
||||
let validator = state
|
||||
.jwt_validator
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
|
||||
|
||||
let access = validator
|
||||
.create_token(user)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create token: {}", e)))?;
|
||||
|
||||
let refresh = if remember_me {
|
||||
Some(
|
||||
validator
|
||||
.create_refresh_token(user)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create refresh token: {}", e)))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok((access, refresh))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "auth-jwt"))]
|
||||
{
|
||||
let _ = (user, state, remember_me);
|
||||
Err(ApiError::Internal("JWT feature not enabled".to_string()))
|
||||
}
|
||||
}
|
||||
200
crates/presentation/src/handlers/channels.rs
Normal file
200
crates/presentation/src/handlers/channels.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
//! Channel CRUD handlers.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
|
||||
use api_types::{
|
||||
ChannelResponse, ConfigSnapshotResponse, CreateChannelRequest, PatchSnapshotRequest,
|
||||
UpdateChannelRequest,
|
||||
};
|
||||
use application::channels::{
|
||||
CreateChannelCommand, DeleteChannelCommand, GetChannelQuery, ListByOwnerQuery,
|
||||
ListChannelsQuery, UpdateChannelCommand,
|
||||
};
|
||||
use application::config_snapshots::{
|
||||
GetSnapshotQuery, ListSnapshotsQuery, PatchLabelCommand, RestoreSnapshotCommand,
|
||||
SaveSnapshotCommand,
|
||||
};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::CurrentUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// GET /channels
|
||||
pub async fn list_channels(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
) -> Result<Json<Vec<ChannelResponse>>, ApiError> {
|
||||
let channels =
|
||||
application::channels::list::execute(&state.channel_query_deps, ListChannelsQuery).await?;
|
||||
Ok(Json(channels.into_iter().map(ChannelResponse::from).collect()))
|
||||
}
|
||||
|
||||
/// GET /channels/mine
|
||||
pub async fn list_my_channels(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
) -> Result<Json<Vec<ChannelResponse>>, ApiError> {
|
||||
let query = ListByOwnerQuery {
|
||||
owner_id: user.id().value(),
|
||||
};
|
||||
let channels =
|
||||
application::channels::list_by_owner::execute(&state.channel_query_deps, query).await?;
|
||||
Ok(Json(channels.into_iter().map(ChannelResponse::from).collect()))
|
||||
}
|
||||
|
||||
/// POST /channels
|
||||
pub async fn create_channel(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Json(req): Json<CreateChannelRequest>,
|
||||
) -> Result<Json<ChannelResponse>, ApiError> {
|
||||
let cmd = CreateChannelCommand {
|
||||
owner_id: user.id().value(),
|
||||
name: req.name,
|
||||
timezone: req.timezone,
|
||||
};
|
||||
let channel = application::channels::create::execute(&state.channel_command_deps, cmd).await?;
|
||||
Ok(Json(ChannelResponse::from(channel)))
|
||||
}
|
||||
|
||||
/// GET /channels/:id
|
||||
pub async fn get_channel(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ChannelResponse>, ApiError> {
|
||||
let query = GetChannelQuery { channel_id: id };
|
||||
let channel = application::channels::get::execute(&state.channel_query_deps, query)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found(format!("Channel {id} not found")))?;
|
||||
Ok(Json(ChannelResponse::from(channel)))
|
||||
}
|
||||
|
||||
/// PUT /channels/:id
|
||||
pub async fn update_channel(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
Json(req): Json<UpdateChannelRequest>,
|
||||
) -> Result<Json<ChannelResponse>, ApiError> {
|
||||
let schedule_config = req
|
||||
.schedule_config
|
||||
.map(|v| {
|
||||
serde_json::from_value(v)
|
||||
.map_err(|e| ApiError::validation(format!("Invalid schedule_config: {e}")))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let recycle_policy = req
|
||||
.recycle_policy
|
||||
.map(|v| {
|
||||
serde_json::from_value(v)
|
||||
.map_err(|e| ApiError::validation(format!("Invalid recycle_policy: {e}")))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let cmd = UpdateChannelCommand {
|
||||
channel_id: id,
|
||||
owner_id: user.id().value(),
|
||||
name: req.name,
|
||||
description: req.description.map(Some),
|
||||
timezone: req.timezone,
|
||||
schedule_config,
|
||||
recycle_policy,
|
||||
auto_schedule: req.auto_schedule,
|
||||
};
|
||||
let channel = application::channels::update::execute(&state.channel_command_deps, cmd).await?;
|
||||
Ok(Json(ChannelResponse::from(channel)))
|
||||
}
|
||||
|
||||
/// DELETE /channels/:id
|
||||
pub async fn delete_channel(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
let cmd = DeleteChannelCommand {
|
||||
channel_id: id,
|
||||
owner_id: user.id().value(),
|
||||
};
|
||||
application::channels::delete::execute(&state.channel_command_deps, cmd).await?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ── Config snapshots ─────────────────────────────────────────────────────
|
||||
|
||||
/// POST /channels/:id/snapshots
|
||||
pub async fn save_snapshot(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ConfigSnapshotResponse>, ApiError> {
|
||||
let cmd = SaveSnapshotCommand {
|
||||
channel_id: id,
|
||||
label: None,
|
||||
};
|
||||
let snap = application::config_snapshots::save::execute(&state.config_snapshot_deps, cmd).await?;
|
||||
Ok(Json(ConfigSnapshotResponse::from(snap)))
|
||||
}
|
||||
|
||||
/// GET /channels/:id/snapshots
|
||||
pub async fn list_snapshots(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<Vec<ConfigSnapshotResponse>>, ApiError> {
|
||||
let query = ListSnapshotsQuery { channel_id: id };
|
||||
let snaps = application::config_snapshots::list::execute(&state.config_snapshot_deps, query).await?;
|
||||
Ok(Json(snaps.into_iter().map(ConfigSnapshotResponse::from).collect()))
|
||||
}
|
||||
|
||||
/// GET /channels/:id/snapshots/:snapshot_id
|
||||
pub async fn get_snapshot(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||
) -> Result<Json<ConfigSnapshotResponse>, ApiError> {
|
||||
let query = GetSnapshotQuery {
|
||||
channel_id: id,
|
||||
snapshot_id,
|
||||
};
|
||||
let snap = application::config_snapshots::get::execute(&state.config_snapshot_deps, query)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("Snapshot not found"))?;
|
||||
Ok(Json(ConfigSnapshotResponse::from(snap)))
|
||||
}
|
||||
|
||||
/// PATCH /channels/:id/snapshots/:snapshot_id
|
||||
pub async fn patch_snapshot(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||
Json(req): Json<PatchSnapshotRequest>,
|
||||
) -> Result<Json<ConfigSnapshotResponse>, ApiError> {
|
||||
let cmd = PatchLabelCommand {
|
||||
channel_id: id,
|
||||
snapshot_id,
|
||||
label: req.label,
|
||||
};
|
||||
let snap =
|
||||
application::config_snapshots::patch_label::execute(&state.config_snapshot_deps, cmd)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("Snapshot not found"))?;
|
||||
Ok(Json(ConfigSnapshotResponse::from(snap)))
|
||||
}
|
||||
|
||||
/// POST /channels/:id/snapshots/:snapshot_id/restore
|
||||
pub async fn restore_snapshot(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path((id, snapshot_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||
) -> Result<Json<ChannelResponse>, ApiError> {
|
||||
let cmd = RestoreSnapshotCommand {
|
||||
channel_id: id,
|
||||
snapshot_id,
|
||||
};
|
||||
let channel =
|
||||
application::config_snapshots::restore::execute(&state.config_snapshot_deps, cmd).await?;
|
||||
Ok(Json(ChannelResponse::from(channel)))
|
||||
}
|
||||
56
crates/presentation/src/handlers/config.rs
Normal file
56
crates/presentation/src/handlers/config.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! System configuration handler.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
|
||||
use api_types::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// GET /config — public system configuration
|
||||
pub async fn get_config(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<ConfigResponse>, ApiError> {
|
||||
let registry = &state.provider_registry;
|
||||
let provider_ids = registry.provider_ids();
|
||||
let primary_id = registry.primary_id().to_string();
|
||||
|
||||
let providers: Vec<ProviderInfo> = provider_ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
registry.capabilities(id).map(|caps| ProviderInfo {
|
||||
id: id.clone(),
|
||||
capabilities: ProviderCapabilitiesResponse::from(caps),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let primary_caps = registry
|
||||
.capabilities(&primary_id)
|
||||
.map(ProviderCapabilitiesResponse::from)
|
||||
.unwrap_or(ProviderCapabilitiesResponse {
|
||||
collections: false,
|
||||
series: false,
|
||||
genres: false,
|
||||
tags: false,
|
||||
decade: false,
|
||||
search: false,
|
||||
streaming_protocol: "direct_file".to_string(),
|
||||
rescan: false,
|
||||
transcode: false,
|
||||
});
|
||||
|
||||
let mut available_types = Vec::new();
|
||||
#[cfg(feature = "jellyfin")]
|
||||
available_types.push("jellyfin".to_string());
|
||||
#[cfg(feature = "local-files")]
|
||||
available_types.push("local_files".to_string());
|
||||
|
||||
Ok(Json(ConfigResponse {
|
||||
allow_registration: state.config.allow_registration,
|
||||
providers,
|
||||
provider_capabilities: primary_caps,
|
||||
available_provider_types: available_types,
|
||||
}))
|
||||
}
|
||||
32
crates/presentation/src/handlers/files.rs
Normal file
32
crates/presentation/src/handlers/files.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! Local file streaming handlers (feature-gated).
|
||||
//!
|
||||
//! Placeholder — the actual streaming logic requires the local-files adapter
|
||||
//! which provides file index and transcoding. This will be fleshed out once
|
||||
//! the local-files adapter integration is complete.
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// GET /files/stream/:id — stream a local file
|
||||
pub async fn stream_file(
|
||||
State(_state): State<AppState>,
|
||||
Path(_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// TODO: integrate with adapter-local-files for actual streaming
|
||||
Err::<StatusCode, _>(ApiError::not_implemented(
|
||||
"Local file streaming not yet wired in presentation crate",
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /files/rescan — rescan local files
|
||||
pub async fn rescan(
|
||||
State(_state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
Err::<StatusCode, _>(ApiError::not_implemented(
|
||||
"Local file rescan not yet wired in presentation crate",
|
||||
))
|
||||
}
|
||||
46
crates/presentation/src/handlers/iptv.rs
Normal file
46
crates/presentation/src/handlers/iptv.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! IPTV export handlers (M3U, XMLTV).
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::header;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::Deserialize;
|
||||
|
||||
use application::iptv::{GetM3uQuery, GetXmltvQuery};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::OptionalCurrentUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IptvParams {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /iptv/playlist.m3u — M3U playlist
|
||||
pub async fn m3u_playlist(
|
||||
State(state): State<AppState>,
|
||||
OptionalCurrentUser(_user): OptionalCurrentUser,
|
||||
Query(params): Query<IptvParams>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let query = GetM3uQuery {
|
||||
base_url: state.config.base_url.clone(),
|
||||
token: params.token,
|
||||
};
|
||||
let content = application::iptv::m3u::execute(&state.iptv_deps, query).await?;
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "audio/x-mpegurl; charset=utf-8")],
|
||||
content,
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /iptv/epg.xml — XMLTV electronic program guide
|
||||
pub async fn xmltv_epg(
|
||||
State(state): State<AppState>,
|
||||
OptionalCurrentUser(_user): OptionalCurrentUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let content = application::iptv::xmltv::execute(&state.iptv_deps, GetXmltvQuery).await?;
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
|
||||
content,
|
||||
))
|
||||
}
|
||||
210
crates/presentation/src/handlers/library.rs
Normal file
210
crates/presentation/src/handlers/library.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
//! Library browsing handlers.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use serde::Deserialize;
|
||||
|
||||
use api_types::{CollectionResponse, LibraryItemResponse, PaginatedResponse, SeasonResponse, ShowResponse};
|
||||
use application::library::{
|
||||
GetItemQuery, GetSyncStatusQuery, ListCollectionsQuery, ListGenresQuery, ListSeasonsQuery,
|
||||
ListShowsQuery, SearchItemsQuery, TriggerSyncCommand,
|
||||
};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::{AdminUser, CurrentUser};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// GET /library/items
|
||||
pub async fn search_items(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<LibrarySearchParams>,
|
||||
) -> Result<Json<PaginatedResponse<LibraryItemResponse>>, ApiError> {
|
||||
let query = SearchItemsQuery {
|
||||
provider_id: params.provider,
|
||||
content_type: params.content_type,
|
||||
genres: params.genres,
|
||||
search_term: params.search_term,
|
||||
collection_id: params.collection_id,
|
||||
series_names: params.series_names,
|
||||
season_number: params.season_number,
|
||||
decade: params.decade,
|
||||
offset: params.offset.unwrap_or(0),
|
||||
limit: params.limit.unwrap_or(50),
|
||||
};
|
||||
let (items, total) = application::library::search::execute(&state.library_query_deps, query).await?;
|
||||
Ok(Json(PaginatedResponse::new(
|
||||
items.into_iter().map(LibraryItemResponse::from).collect(),
|
||||
total as u64,
|
||||
)))
|
||||
}
|
||||
|
||||
/// GET /library/items/:id
|
||||
pub async fn get_item(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<LibraryItemResponse>, ApiError> {
|
||||
let query = GetItemQuery { item_id: id.clone() };
|
||||
let item = application::library::get_item::execute(&state.library_query_deps, query)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found(format!("Library item {id} not found")))?;
|
||||
Ok(Json(LibraryItemResponse::from(item)))
|
||||
}
|
||||
|
||||
/// GET /library/collections
|
||||
pub async fn list_collections(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<ProviderParam>,
|
||||
) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
|
||||
let query = ListCollectionsQuery {
|
||||
provider_id: params.provider,
|
||||
};
|
||||
let collections =
|
||||
application::library::list_collections::execute(&state.library_query_deps, query).await?;
|
||||
Ok(Json(
|
||||
collections
|
||||
.into_iter()
|
||||
.map(CollectionResponse::from)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// GET /library/shows
|
||||
pub async fn list_shows(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<ShowsParams>,
|
||||
) -> Result<Json<Vec<ShowResponse>>, ApiError> {
|
||||
let query = ListShowsQuery {
|
||||
provider_id: params.provider,
|
||||
search_term: params.search_term,
|
||||
genres: params.genres,
|
||||
};
|
||||
let shows = application::library::list_shows::execute(&state.library_query_deps, query).await?;
|
||||
Ok(Json(shows.into_iter().map(ShowResponse::from).collect()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SeasonsParams {
|
||||
pub series_name: String,
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /library/seasons
|
||||
pub async fn list_seasons(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<SeasonsParams>,
|
||||
) -> Result<Json<Vec<SeasonResponse>>, ApiError> {
|
||||
let query = ListSeasonsQuery {
|
||||
series_name: params.series_name,
|
||||
provider_id: params.provider,
|
||||
};
|
||||
let seasons =
|
||||
application::library::list_seasons::execute(&state.library_query_deps, query).await?;
|
||||
Ok(Json(
|
||||
seasons.into_iter().map(SeasonResponse::from).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GenresParams {
|
||||
pub content_type: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /library/genres
|
||||
pub async fn list_genres(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<GenresParams>,
|
||||
) -> Result<Json<Vec<String>>, ApiError> {
|
||||
let query = ListGenresQuery {
|
||||
content_type: params.content_type,
|
||||
provider_id: params.provider,
|
||||
};
|
||||
let genres =
|
||||
application::library::list_genres::execute(&state.library_query_deps, query).await?;
|
||||
Ok(Json(genres))
|
||||
}
|
||||
|
||||
/// GET /library/sync/status
|
||||
pub async fn sync_status(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let entries =
|
||||
application::library::get_sync_status::execute(&state.library_query_deps, GetSyncStatusQuery)
|
||||
.await?;
|
||||
let result: Vec<serde_json::Value> = entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"provider_id": e.provider_id(),
|
||||
"started_at": e.started_at(),
|
||||
"finished_at": e.finished_at().unwrap_or(""),
|
||||
"items_found": e.items_found(),
|
||||
"status": e.status(),
|
||||
"error_msg": e.error_msg().unwrap_or(""),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(serde_json::Value::Array(result)))
|
||||
}
|
||||
|
||||
/// POST /library/sync — trigger sync (admin only)
|
||||
///
|
||||
/// Validates that no sync is already running, then sends a signal to the
|
||||
/// background sync task to start a sync cycle immediately.
|
||||
pub async fn trigger_sync(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
let cmd = TriggerSyncCommand { provider_id: None };
|
||||
let _provider_ids =
|
||||
application::library::sync::execute(&state.library_command_deps, cmd)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("already running") {
|
||||
ApiError::conflict(e.to_string())
|
||||
} else {
|
||||
ApiError::from(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
// Signal the background sync task to run immediately
|
||||
let _ = state.sync_trigger.send(());
|
||||
|
||||
Ok(axum::http::StatusCode::ACCEPTED)
|
||||
}
|
||||
10
crates/presentation/src/handlers/mod.rs
Normal file
10
crates/presentation/src/handlers/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod channels;
|
||||
pub mod config;
|
||||
#[cfg(feature = "local-files")]
|
||||
pub mod files;
|
||||
pub mod iptv;
|
||||
pub mod library;
|
||||
pub mod providers;
|
||||
pub mod schedule;
|
||||
72
crates/presentation/src/handlers/providers.rs
Normal file
72
crates/presentation/src/handlers/providers.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Provider configuration CRUD handlers.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
|
||||
use api_types::{ProviderConfigRequest, ProviderConfigResponse};
|
||||
use application::providers::{
|
||||
DeleteProviderCommand, GetProviderQuery, ListProvidersQuery, UpsertProviderCommand,
|
||||
};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::AdminUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// GET /admin/providers
|
||||
pub async fn list_providers(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
) -> Result<Json<Vec<ProviderConfigResponse>>, ApiError> {
|
||||
let providers =
|
||||
application::providers::list::execute(&state.provider_deps, ListProvidersQuery).await?;
|
||||
Ok(Json(
|
||||
providers
|
||||
.into_iter()
|
||||
.map(ProviderConfigResponse::from)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /admin/providers/:id
|
||||
pub async fn get_provider(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ProviderConfigResponse>, ApiError> {
|
||||
let query = GetProviderQuery { id: id.clone() };
|
||||
let provider = application::providers::get::execute(&state.provider_deps, query)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found(format!("Provider {id} not found")))?;
|
||||
Ok(Json(ProviderConfigResponse::from(provider)))
|
||||
}
|
||||
|
||||
/// PUT /admin/providers/:id
|
||||
pub async fn upsert_provider(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<ProviderConfigRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let config_json = serde_json::to_string(&req.config)
|
||||
.map_err(|e| ApiError::validation(format!("Invalid config JSON: {e}")))?;
|
||||
|
||||
let cmd = UpsertProviderCommand {
|
||||
id,
|
||||
provider_type: req.provider_type,
|
||||
config_json,
|
||||
enabled: req.enabled,
|
||||
};
|
||||
application::providers::upsert::execute(&state.provider_deps, cmd).await?;
|
||||
Ok(Json(serde_json::json!({"status": "ok"})))
|
||||
}
|
||||
|
||||
/// DELETE /admin/providers/:id
|
||||
pub async fn delete_provider(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
let cmd = DeleteProviderCommand { id };
|
||||
application::providers::delete::execute(&state.provider_deps, cmd).await?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
131
crates/presentation/src/handlers/schedule.rs
Normal file
131
crates/presentation/src/handlers/schedule.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! Schedule, broadcast, and stream handlers.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use api_types::{
|
||||
CurrentBroadcastResponse, ScheduleHistoryEntry, ScheduleResponse, SlotResponse,
|
||||
};
|
||||
use application::schedule::{
|
||||
GenerateScheduleCommand, GetActiveScheduleQuery, GetCurrentBroadcastQuery, GetEpgQuery,
|
||||
GetStreamUrlQuery, ListHistoryQuery,
|
||||
};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::CurrentUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// POST /channels/:id/schedule — generate a new schedule
|
||||
pub async fn generate_schedule(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ScheduleResponse>, ApiError> {
|
||||
let cmd = GenerateScheduleCommand { channel_id: id };
|
||||
let schedule = application::schedule::generate::execute(&state.schedule_deps, cmd).await?;
|
||||
Ok(Json(ScheduleResponse::from(schedule)))
|
||||
}
|
||||
|
||||
/// GET /channels/:id/schedule — get the active schedule
|
||||
pub async fn get_active_schedule(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<axum::response::Response, ApiError> {
|
||||
let query = GetActiveScheduleQuery { channel_id: id };
|
||||
match application::schedule::get_active::execute(&state.schedule_deps, query).await? {
|
||||
Some(schedule) => Ok(Json(ScheduleResponse::from(schedule)).into_response()),
|
||||
None => Ok(StatusCode::NO_CONTENT.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
/// GET /channels/:id/now — what's currently playing
|
||||
pub async fn get_current_broadcast(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<axum::response::Response, ApiError> {
|
||||
let query = GetCurrentBroadcastQuery { channel_id: id };
|
||||
match application::schedule::get_current_broadcast::execute(&state.schedule_deps, query).await?
|
||||
{
|
||||
Some(broadcast) => {
|
||||
// Look up the channel to resolve block access mode
|
||||
let channel_query = application::channels::GetChannelQuery { channel_id: id };
|
||||
let channel = application::channels::get::execute(&state.channel_query_deps, channel_query).await?;
|
||||
|
||||
let slot_response = match &channel {
|
||||
Some(ch) => SlotResponse::with_block_access(broadcast.slot().clone(), ch),
|
||||
None => SlotResponse::from(broadcast.slot().clone()),
|
||||
};
|
||||
|
||||
let block_access_mode = slot_response.block_access_mode.clone();
|
||||
|
||||
Ok(Json(CurrentBroadcastResponse {
|
||||
slot: slot_response,
|
||||
offset_secs: broadcast.offset_secs(),
|
||||
block_access_mode,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
None => Ok(StatusCode::NO_CONTENT.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /channels/:id/epg — electronic program guide
|
||||
pub async fn get_epg(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<Vec<SlotResponse>>, ApiError> {
|
||||
let query = GetEpgQuery { channel_id: id };
|
||||
let slots = application::schedule::get_epg::execute(&state.schedule_deps, query).await?;
|
||||
Ok(Json(slots.into_iter().map(SlotResponse::from).collect()))
|
||||
}
|
||||
|
||||
/// GET /channels/:id/stream — redirect to stream URL (307)
|
||||
pub async fn get_stream(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<axum::response::Response, ApiError> {
|
||||
// Find the current broadcast first to get the item ID
|
||||
let broadcast_query = GetCurrentBroadcastQuery { channel_id: id };
|
||||
let broadcast =
|
||||
application::schedule::get_current_broadcast::execute(&state.schedule_deps, broadcast_query)
|
||||
.await?;
|
||||
|
||||
match broadcast {
|
||||
Some(b) => {
|
||||
let stream_query = GetStreamUrlQuery {
|
||||
channel_id: id,
|
||||
item_id: b.slot().item().id().value().to_string(),
|
||||
};
|
||||
let url =
|
||||
application::schedule::get_stream_url::execute(&state.schedule_deps, stream_query)
|
||||
.await?;
|
||||
Ok((
|
||||
StatusCode::TEMPORARY_REDIRECT,
|
||||
[("Location", url.as_str())],
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
None => Ok(StatusCode::NO_CONTENT.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /channels/:id/schedule/history — list schedule generations
|
||||
pub async fn list_schedule_history(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<Vec<ScheduleHistoryEntry>>, ApiError> {
|
||||
let query = ListHistoryQuery { channel_id: id };
|
||||
let history =
|
||||
application::schedule::list_history::execute(&state.schedule_deps, query).await?;
|
||||
Ok(Json(
|
||||
history
|
||||
.into_iter()
|
||||
.map(ScheduleHistoryEntry::from)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user