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:
2026-07-12 03:23:20 +02:00
parent afed5c01b4
commit 56d742a74c
25 changed files with 3186 additions and 5 deletions

View 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(),
))
}

View 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()))
}
}

View 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)))
}

View 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,
}))
}

View 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",
))
}

View 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,
))
}

View 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)
}

View 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;

View 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)
}

View 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(),
))
}