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:
55
crates/presentation/Cargo.toml
Normal file
55
crates/presentation/Cargo.toml
Normal file
@@ -0,0 +1,55 @@
|
||||
[package]
|
||||
name = "presentation"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "k-tv"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["sqlite", "auth-jwt", "jellyfin"]
|
||||
sqlite = ["dep:adapter-sqlite", "infra-wiring/sqlite"]
|
||||
postgres = ["dep:adapter-postgres", "infra-wiring/postgres"]
|
||||
auth-jwt = ["adapter-auth/jwt"]
|
||||
auth-oidc = ["adapter-auth/oidc"]
|
||||
jellyfin = ["dep:adapter-jellyfin"]
|
||||
local-files = ["dep:adapter-local-files", "dep:tokio-util"]
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
api-types = { workspace = true }
|
||||
infra-wiring = { workspace = true }
|
||||
adapter-auth = { workspace = true }
|
||||
adapter-event-publisher = { workspace = true }
|
||||
|
||||
# Feature-gated adapters
|
||||
adapter-sqlite = { workspace = true, optional = true }
|
||||
adapter-postgres = { workspace = true, optional = true }
|
||||
adapter-jellyfin = { workspace = true, optional = true }
|
||||
adapter-local-files = { workspace = true, optional = true }
|
||||
|
||||
# Framework
|
||||
axum = { workspace = true }
|
||||
axum-extra = { workspace = true, features = ["typed-header"] }
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# Utils
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = "1"
|
||||
dotenvy = "0.15"
|
||||
reqwest = { workspace = true }
|
||||
handlebars = "6"
|
||||
|
||||
# Local-files streaming
|
||||
tokio-util = { version = "0.7", features = ["io"], optional = true }
|
||||
85
crates/presentation/src/background/auto_scheduler.rs
Normal file
85
crates/presentation/src/background/auto_scheduler.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! Background auto-scheduler task.
|
||||
//!
|
||||
//! Runs every hour, finds channels with `auto_schedule = true`, and regenerates
|
||||
//! their schedule if it is within 24 hours of expiry.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use application::schedule::ScheduleDeps;
|
||||
use application::schedule::GenerateScheduleCommand;
|
||||
|
||||
/// Run the auto-scheduler loop.
|
||||
pub async fn run(deps: Arc<ScheduleDeps>) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||
tick(&deps).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(deps: &ScheduleDeps) {
|
||||
// List all channels, find those with auto_schedule
|
||||
let channels = match deps.channel_query.find_all().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("auto-scheduler: failed to fetch channels: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
for channel in channels {
|
||||
if !channel.auto_schedule() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check latest schedule
|
||||
let latest = match deps.schedule_query.find_latest(channel.id()).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"auto-scheduler: failed to fetch latest schedule for channel {}: {}",
|
||||
channel.id().value(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let should_generate = match &latest {
|
||||
Some(s) => {
|
||||
let remaining = s.valid_until() - now;
|
||||
remaining < chrono::Duration::hours(24)
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
|
||||
if !should_generate {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cmd = GenerateScheduleCommand {
|
||||
channel_id: channel.id().value(),
|
||||
};
|
||||
|
||||
match application::schedule::generate::execute(deps, cmd).await {
|
||||
Ok(schedule) => {
|
||||
tracing::info!(
|
||||
"auto-scheduler: generated schedule for channel {} (gen {})",
|
||||
channel.id().value(),
|
||||
schedule.generation(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"auto-scheduler: failed to generate schedule for channel {}: {}",
|
||||
channel.id().value(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
crates/presentation/src/background/broadcast_poller.rs
Normal file
119
crates/presentation/src/background/broadcast_poller.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
//! BroadcastPoller background task.
|
||||
//!
|
||||
//! Polls channels with webhook_url configured and emits domain events
|
||||
//! when the current slot changes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ports::events::EventPublisher;
|
||||
use domain::value_objects::{ChannelId, SlotId};
|
||||
|
||||
use application::schedule::ScheduleDeps;
|
||||
|
||||
/// Per-channel poll state.
|
||||
struct ChannelPollState {
|
||||
last_slot_id: Option<Uuid>,
|
||||
last_checked: Instant,
|
||||
}
|
||||
|
||||
/// Polls channels and emits broadcast transition events.
|
||||
pub async fn run(deps: Arc<ScheduleDeps>, event_publisher: Arc<dyn EventPublisher>) {
|
||||
let mut state: HashMap<Uuid, ChannelPollState> = HashMap::new();
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
tick(&deps, &event_publisher, &mut state).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(
|
||||
deps: &ScheduleDeps,
|
||||
event_publisher: &Arc<dyn EventPublisher>,
|
||||
state: &mut HashMap<Uuid, ChannelPollState>,
|
||||
) {
|
||||
let channels = match deps.channel_query.find_all().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("broadcast poller: failed to load channels: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let live_ids: std::collections::HashSet<Uuid> =
|
||||
channels.iter().map(|c| c.id().value()).collect();
|
||||
state.retain(|id, _| live_ids.contains(id));
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
for channel in channels {
|
||||
if channel.webhook_url().is_none() {
|
||||
state.remove(&channel.id().value());
|
||||
continue;
|
||||
}
|
||||
|
||||
let poll_interval = Duration::from_secs(channel.webhook_poll_interval_secs() as u64);
|
||||
let channel_uuid = channel.id().value();
|
||||
|
||||
let entry = state.entry(channel_uuid).or_insert(ChannelPollState {
|
||||
last_slot_id: None,
|
||||
last_checked: Instant::now() - poll_interval,
|
||||
});
|
||||
|
||||
if entry.last_checked.elapsed() < poll_interval {
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.last_checked = Instant::now();
|
||||
|
||||
let current_slot_id = match deps
|
||||
.schedule_query
|
||||
.find_active(channel.id(), now)
|
||||
.await
|
||||
{
|
||||
Ok(Some(schedule)) => schedule
|
||||
.slots()
|
||||
.iter()
|
||||
.find(|s| s.start_at() <= now && now < s.end_at())
|
||||
.map(|s| s.id().value()),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"broadcast poller: error checking schedule for channel {}: {}",
|
||||
channel_uuid,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if current_slot_id == entry.last_slot_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
match ¤t_slot_id {
|
||||
Some(slot_id) => {
|
||||
let _ = event_publisher
|
||||
.publish(DomainEvent::BroadcastTransition {
|
||||
channel_id: ChannelId::from(channel_uuid),
|
||||
slot_id: SlotId::from(*slot_id),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
None => {
|
||||
let _ = event_publisher
|
||||
.publish(DomainEvent::NoSignal {
|
||||
channel_id: ChannelId::from(channel_uuid),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
entry.last_slot_id = current_slot_id;
|
||||
}
|
||||
}
|
||||
125
crates/presentation/src/background/library_sync.rs
Normal file
125
crates/presentation/src/background/library_sync.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
//! Background library sync task.
|
||||
//!
|
||||
//! Fires 10 seconds after startup, then every N hours (read from app_settings).
|
||||
//! Can be triggered on-demand via the sync_trigger watch channel.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::ports::{AppSettingsRepository, IProviderRegistry, LibrarySyncAdapter};
|
||||
use tokio::sync::watch;
|
||||
|
||||
const STARTUP_DELAY_SECS: u64 = 10;
|
||||
const DEFAULT_INTERVAL_HOURS: u64 = 6;
|
||||
|
||||
pub async fn run(
|
||||
sync_adapter: Arc<dyn LibrarySyncAdapter>,
|
||||
provider_registry: Arc<dyn IProviderRegistry>,
|
||||
settings_repo: Arc<dyn AppSettingsRepository>,
|
||||
mut trigger_rx: watch::Receiver<()>,
|
||||
) {
|
||||
tokio::time::sleep(Duration::from_secs(STARTUP_DELAY_SECS)).await;
|
||||
|
||||
loop {
|
||||
do_sync(&sync_adapter, &provider_registry).await;
|
||||
|
||||
let interval_hours = load_interval_hours(&settings_repo).await;
|
||||
let sleep = tokio::time::sleep(Duration::from_secs(interval_hours * 3600));
|
||||
|
||||
tokio::select! {
|
||||
_ = sleep => {}
|
||||
_ = trigger_rx.changed() => {
|
||||
tracing::info!("library-sync: triggered manually");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_interval_hours(repo: &Arc<dyn AppSettingsRepository>) -> u64 {
|
||||
repo.get("library_sync_interval_hours")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(DEFAULT_INTERVAL_HOURS)
|
||||
}
|
||||
|
||||
async fn do_sync(
|
||||
sync_adapter: &Arc<dyn LibrarySyncAdapter>,
|
||||
registry: &Arc<dyn IProviderRegistry>,
|
||||
) {
|
||||
let provider_ids = registry.provider_ids();
|
||||
|
||||
for provider_id in provider_ids {
|
||||
// We need a &dyn IMediaProvider, but IProviderRegistry doesn't expose one.
|
||||
// The sync adapter will use the registry's fetch_items internally via its
|
||||
// own stored reference to the provider. For now, we create a thin adapter.
|
||||
tracing::info!("library-sync: syncing provider '{}'", provider_id);
|
||||
|
||||
let wrapper = RegistryProviderAdapter {
|
||||
registry: registry.clone(),
|
||||
provider_id: provider_id.clone(),
|
||||
};
|
||||
|
||||
let result = sync_adapter.sync_provider(&wrapper, &provider_id).await;
|
||||
|
||||
if let Some(err) = result.error() {
|
||||
tracing::warn!("library-sync: provider '{}' failed: {}", provider_id, err);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"library-sync: provider '{}' done — {} items in {}ms",
|
||||
provider_id,
|
||||
result.items_found(),
|
||||
result.duration_ms()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter that wraps IProviderRegistry calls for a specific provider_id,
|
||||
/// implementing IMediaProvider so it can be passed to LibrarySyncAdapter.
|
||||
struct RegistryProviderAdapter {
|
||||
registry: Arc<dyn IProviderRegistry>,
|
||||
provider_id: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::IMediaProvider for RegistryProviderAdapter {
|
||||
fn capabilities(&self) -> domain::ports::ProviderCapabilities {
|
||||
self.registry
|
||||
.capabilities(&self.provider_id)
|
||||
.unwrap_or(domain::ports::ProviderCapabilities {
|
||||
collections: false,
|
||||
series: false,
|
||||
genres: false,
|
||||
tags: false,
|
||||
decade: false,
|
||||
search: false,
|
||||
streaming_protocol: domain::ports::StreamingProtocol::DirectFile,
|
||||
rescan: false,
|
||||
transcode: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_items(
|
||||
&self,
|
||||
filter: &domain::MediaFilter,
|
||||
) -> domain::DomainResult<Vec<domain::MediaItem>> {
|
||||
self.registry.fetch_items(&self.provider_id, filter).await
|
||||
}
|
||||
|
||||
async fn fetch_by_id(
|
||||
&self,
|
||||
item_id: &domain::MediaItemId,
|
||||
) -> domain::DomainResult<Option<domain::MediaItem>> {
|
||||
self.registry.fetch_by_id(item_id).await
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
item_id: &domain::MediaItemId,
|
||||
quality: &domain::ports::StreamQuality,
|
||||
) -> domain::DomainResult<String> {
|
||||
self.registry.get_stream_url(item_id, quality).await
|
||||
}
|
||||
}
|
||||
6
crates/presentation/src/background/mod.rs
Normal file
6
crates/presentation/src/background/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
//! Background tasks spawned at server startup.
|
||||
|
||||
pub mod auto_scheduler;
|
||||
pub mod broadcast_poller;
|
||||
pub mod library_sync;
|
||||
pub mod webhook_consumer;
|
||||
204
crates/presentation/src/background/webhook_consumer.rs
Normal file
204
crates/presentation/src/background/webhook_consumer.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
//! WebhookConsumer background task.
|
||||
//!
|
||||
//! Subscribes to domain events and delivers them to per-channel webhook URLs.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use handlebars::Handlebars;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ports::ChannelQuery;
|
||||
|
||||
/// Consumes domain events and delivers them to per-channel webhook URLs.
|
||||
pub async fn run(
|
||||
mut rx: broadcast::Receiver<DomainEvent>,
|
||||
channel_query: Arc<dyn ChannelQuery>,
|
||||
client: reqwest::Client,
|
||||
) {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
let channel_id = event_channel_id(&event);
|
||||
let payload = build_payload(&event);
|
||||
|
||||
let channel_id_vo = domain::ChannelId::from(channel_id);
|
||||
match channel_query.find_by_id(channel_id_vo).await {
|
||||
Ok(Some(channel)) => {
|
||||
if let Some(url) = channel.webhook_url() {
|
||||
let url = url.to_string();
|
||||
let client = client.clone();
|
||||
let template = channel.webhook_body_template().map(|s| s.to_string());
|
||||
let headers = channel.webhook_headers().map(|s| s.to_string());
|
||||
tokio::spawn(async move {
|
||||
post_webhook(
|
||||
&client,
|
||||
&url,
|
||||
payload,
|
||||
template.as_deref(),
|
||||
headers.as_deref(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"webhook consumer: failed to look up channel {}: {}",
|
||||
channel_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!("webhook consumer lagged, {} events dropped", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
tracing::info!("webhook consumer: event bus closed, shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn event_channel_id(event: &DomainEvent) -> Uuid {
|
||||
match event {
|
||||
DomainEvent::BroadcastTransition { channel_id, .. } => channel_id.value(),
|
||||
DomainEvent::NoSignal { channel_id } => channel_id.value(),
|
||||
DomainEvent::ScheduleGenerated { channel_id, .. } => channel_id.value(),
|
||||
DomainEvent::ChannelCreated { channel_id } => channel_id.value(),
|
||||
DomainEvent::ChannelUpdated { channel_id } => channel_id.value(),
|
||||
DomainEvent::ChannelDeleted { channel_id } => channel_id.value(),
|
||||
_ => Uuid::nil(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_payload(event: &DomainEvent) -> Value {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let channel_id = event_channel_id(event);
|
||||
match event {
|
||||
DomainEvent::BroadcastTransition { slot_id, .. } => {
|
||||
json!({
|
||||
"event": "broadcast_transition",
|
||||
"timestamp": now,
|
||||
"channel_id": channel_id,
|
||||
"data": {
|
||||
"slot_id": slot_id.value(),
|
||||
}
|
||||
})
|
||||
}
|
||||
DomainEvent::NoSignal { .. } => {
|
||||
json!({
|
||||
"event": "no_signal",
|
||||
"timestamp": now,
|
||||
"channel_id": channel_id,
|
||||
"data": {}
|
||||
})
|
||||
}
|
||||
DomainEvent::ScheduleGenerated { schedule_id, .. } => {
|
||||
json!({
|
||||
"event": "schedule_generated",
|
||||
"timestamp": now,
|
||||
"channel_id": channel_id,
|
||||
"data": {
|
||||
"schedule_id": schedule_id.value(),
|
||||
}
|
||||
})
|
||||
}
|
||||
DomainEvent::ChannelCreated { .. } => {
|
||||
json!({
|
||||
"event": "channel_created",
|
||||
"timestamp": now,
|
||||
"channel_id": channel_id,
|
||||
"data": {}
|
||||
})
|
||||
}
|
||||
DomainEvent::ChannelUpdated { .. } => {
|
||||
json!({
|
||||
"event": "channel_updated",
|
||||
"timestamp": now,
|
||||
"channel_id": channel_id,
|
||||
"data": {}
|
||||
})
|
||||
}
|
||||
DomainEvent::ChannelDeleted { .. } => {
|
||||
json!({
|
||||
"event": "channel_deleted",
|
||||
"timestamp": now,
|
||||
"channel_id": channel_id,
|
||||
"data": {}
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
json!({
|
||||
"event": "unknown",
|
||||
"timestamp": now,
|
||||
"channel_id": channel_id,
|
||||
"data": {}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_webhook(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
payload: Value,
|
||||
template: Option<&str>,
|
||||
headers_json: Option<&str>,
|
||||
) {
|
||||
let body = if let Some(tmpl) = template {
|
||||
let hbs = Handlebars::new();
|
||||
match hbs.render_template(tmpl, &payload) {
|
||||
Ok(rendered) => rendered,
|
||||
Err(e) => {
|
||||
tracing::warn!("webhook template render failed for {}: {}", url, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match serde_json::to_string(&payload) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!("webhook payload serialize failed: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut req = client.post(url).body(body);
|
||||
let mut has_content_type = false;
|
||||
|
||||
if let Some(h) = headers_json {
|
||||
if let Ok(map) = serde_json::from_str::<serde_json::Map<String, Value>>(h) {
|
||||
for (k, v) in &map {
|
||||
if k.to_lowercase() == "content-type" {
|
||||
has_content_type = true;
|
||||
}
|
||||
if let Some(v_str) = v.as_str() {
|
||||
req = req.header(k.as_str(), v_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_content_type {
|
||||
req = req.header("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
match req.send().await {
|
||||
Ok(resp) => {
|
||||
if !resp.status().is_success() {
|
||||
tracing::warn!("webhook POST to {} returned status {}", url, resp.status());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("webhook POST to {} failed: {}", url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
178
crates/presentation/src/errors.rs
Normal file
178
crates/presentation/src/errors.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
//! API error handling — maps domain errors to HTTP responses.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use domain::DomainError;
|
||||
|
||||
/// API-level errors.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("{0}")]
|
||||
Domain(#[from] DomainError),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Internal server error")]
|
||||
Internal(String),
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("auth_required")]
|
||||
AuthRequired,
|
||||
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Not implemented: {0}")]
|
||||
NotImplemented(String),
|
||||
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
}
|
||||
|
||||
/// Error response body.
|
||||
#[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 {
|
||||
let (status, error_response) = match &self {
|
||||
ApiError::Domain(domain_error) => {
|
||||
let status = match domain_error {
|
||||
DomainError::UserNotFound(_)
|
||||
| DomainError::ChannelNotFound(_)
|
||||
| DomainError::NoActiveSchedule(_) => StatusCode::NOT_FOUND,
|
||||
|
||||
DomainError::UserAlreadyExists(_) => StatusCode::CONFLICT,
|
||||
|
||||
DomainError::ValidationError(_) | DomainError::TimezoneError(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
|
||||
DomainError::Unauthenticated(_) => StatusCode::UNAUTHORIZED,
|
||||
DomainError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||
|
||||
DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
(
|
||||
status,
|
||||
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::AuthRequired => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
ErrorResponse {
|
||||
error: "auth_required".to_string(),
|
||||
details: None,
|
||||
},
|
||||
),
|
||||
|
||||
ApiError::NotFound(msg) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
ErrorResponse {
|
||||
error: "Not found".to_string(),
|
||||
details: Some(msg.clone()),
|
||||
},
|
||||
),
|
||||
|
||||
ApiError::NotImplemented(msg) => (
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
ErrorResponse {
|
||||
error: "Not implemented".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 {
|
||||
pub fn validation(msg: impl Into<String>) -> Self {
|
||||
Self::Validation(msg.into())
|
||||
}
|
||||
|
||||
pub fn internal(msg: impl Into<String>) -> Self {
|
||||
Self::Internal(msg.into())
|
||||
}
|
||||
|
||||
pub fn not_found(msg: impl Into<String>) -> Self {
|
||||
Self::NotFound(msg.into())
|
||||
}
|
||||
|
||||
pub fn conflict(msg: impl Into<String>) -> Self {
|
||||
Self::Conflict(msg.into())
|
||||
}
|
||||
|
||||
pub fn not_implemented(msg: impl Into<String>) -> Self {
|
||||
Self::NotImplemented(msg.into())
|
||||
}
|
||||
}
|
||||
151
crates/presentation/src/extractors.rs
Normal file
151
crates/presentation/src/extractors.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//! Auth extractors for API handlers.
|
||||
//!
|
||||
//! Provides `CurrentUser`, `OptionalCurrentUser`, and `AdminUser` extractors.
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use domain::User;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Extracted current user from JWT Bearer token.
|
||||
pub struct CurrentUser(pub User);
|
||||
|
||||
impl FromRequestParts<AppState> for CurrentUser {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
{
|
||||
return match try_jwt_auth(parts, state).await {
|
||||
Ok(user) => Ok(CurrentUser(user)),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "auth-jwt"))]
|
||||
{
|
||||
let _ = (parts, state);
|
||||
Err(ApiError::Unauthorized(
|
||||
"No authentication backend configured".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional current user — returns None instead of error when auth missing.
|
||||
///
|
||||
/// Checks `Authorization: Bearer <token>` first; falls back to `?token=<jwt>`.
|
||||
pub struct OptionalCurrentUser(pub Option<User>);
|
||||
|
||||
impl FromRequestParts<AppState> for OptionalCurrentUser {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
{
|
||||
if let Ok(user) = try_jwt_auth(parts, state).await {
|
||||
return Ok(OptionalCurrentUser(Some(user)));
|
||||
}
|
||||
let query_token = parts.uri.query().and_then(|q| {
|
||||
q.split('&')
|
||||
.find(|seg| seg.starts_with("token="))
|
||||
.map(|seg| seg[6..].to_owned())
|
||||
});
|
||||
if let Some(token) = query_token {
|
||||
let user = validate_jwt_token(&token, state).await.ok();
|
||||
return Ok(OptionalCurrentUser(user));
|
||||
}
|
||||
Ok(OptionalCurrentUser(None))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "auth-jwt"))]
|
||||
{
|
||||
let _ = (parts, state);
|
||||
Ok(OptionalCurrentUser(None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracted admin user — returns 403 if user is not an admin.
|
||||
pub struct AdminUser(pub User);
|
||||
|
||||
impl FromRequestParts<AppState> for AdminUser {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let CurrentUser(user) = CurrentUser::from_request_parts(parts, state).await?;
|
||||
if !user.is_admin() {
|
||||
return Err(ApiError::Forbidden("Admin access required".to_string()));
|
||||
}
|
||||
Ok(AdminUser(user))
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate via JWT Bearer token from `Authorization` header.
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get(AUTHORIZATION)
|
||||
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
||||
|
||||
let auth_str = auth_header
|
||||
.to_str()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid Authorization header encoding".to_string()))?;
|
||||
|
||||
let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| {
|
||||
ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string())
|
||||
})?;
|
||||
|
||||
validate_jwt_token(token, state).await
|
||||
}
|
||||
|
||||
/// Validate a raw JWT string and return the corresponding `User`.
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub(crate) async fn validate_jwt_token(token: &str, state: &AppState) -> Result<User, ApiError> {
|
||||
let validator = state
|
||||
.jwt_validator
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?;
|
||||
|
||||
let claims = validator.validate_access_token(token).map_err(|e| {
|
||||
tracing::debug!("JWT validation failed: {:?}", e);
|
||||
match e {
|
||||
adapter_auth::JwtError::Expired => {
|
||||
ApiError::Unauthorized("Token expired".to_string())
|
||||
}
|
||||
adapter_auth::JwtError::InvalidFormat => {
|
||||
ApiError::Unauthorized("Invalid token format".to_string())
|
||||
}
|
||||
_ => ApiError::Unauthorized("Token validation failed".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()))?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
623
crates/presentation/src/factory.rs
Normal file
623
crates/presentation/src/factory.rs
Normal file
@@ -0,0 +1,623 @@
|
||||
//! Factory — builds AppState from Config + DbPool.
|
||||
//!
|
||||
//! Connects to the database, runs migrations, creates all adapter instances,
|
||||
//! constructs Deps structs, and returns a fully-wired AppState.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{
|
||||
admin::AdminDeps,
|
||||
auth::AuthDeps,
|
||||
channels::{ChannelCommandDeps, ChannelQueryDeps},
|
||||
config_snapshots::ConfigSnapshotDeps,
|
||||
iptv::IptvDeps,
|
||||
library::{LibraryCommandDeps, LibraryQueryDeps},
|
||||
providers::ProviderDeps,
|
||||
schedule::ScheduleDeps,
|
||||
};
|
||||
use domain::ports::{IMediaProvider, IProviderRegistry, ProviderCapabilities, StreamingProtocol};
|
||||
use domain::{DomainError, ScheduleEngineService};
|
||||
use infra_wiring::{Config, ConfigSource, DbPool};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Build a fully-wired AppState ready for the HTTP server.
|
||||
pub async fn build_app_state(config: Config) -> anyhow::Result<AppState> {
|
||||
// Connect to database
|
||||
let pool = DbPool::connect(&config.database_url).await?;
|
||||
pool.run_migrations().await?;
|
||||
|
||||
// Wire up all repositories from the database pool
|
||||
let wire_output = wire_repositories(&pool)?;
|
||||
|
||||
// Auth service
|
||||
let auth_service: Arc<dyn domain::ports::AuthService> =
|
||||
Arc::new(adapter_auth::PasswordAuthService);
|
||||
|
||||
// Event bus
|
||||
let event_bus = Arc::new(adapter_event_publisher::ChannelEventBus::new(64));
|
||||
let event_publisher: Arc<dyn domain::ports::EventPublisher> = event_bus.clone();
|
||||
|
||||
// Provider registry
|
||||
let provider_registry = build_provider_registry(&config).await;
|
||||
|
||||
// Library sync adapter — uses the LibraryCommand port internally
|
||||
let library_sync: Arc<dyn domain::ports::LibrarySyncAdapter> =
|
||||
build_library_sync(wire_output.library_command.clone());
|
||||
|
||||
// Schedule engine
|
||||
let schedule_engine = Arc::new(ScheduleEngineService::new(
|
||||
provider_registry.clone(),
|
||||
wire_output.channel_query.clone(),
|
||||
wire_output.schedule_query.clone(),
|
||||
wire_output.schedule_command.clone(),
|
||||
));
|
||||
|
||||
// JWT validator
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
let jwt_validator = build_jwt_validator(&config)?;
|
||||
|
||||
// Sync trigger channel
|
||||
let (sync_tx, sync_rx) = tokio::sync::watch::channel(());
|
||||
|
||||
// Build all deps structs
|
||||
let auth_deps = Arc::new(AuthDeps {
|
||||
user_command: wire_output.user_command.clone(),
|
||||
user_query: wire_output.user_query.clone(),
|
||||
auth_service,
|
||||
event_publisher: event_publisher.clone(),
|
||||
});
|
||||
|
||||
let channel_command_deps = Arc::new(ChannelCommandDeps {
|
||||
channel_command: wire_output.channel_command.clone(),
|
||||
channel_query: wire_output.channel_query.clone(),
|
||||
event_publisher: event_publisher.clone(),
|
||||
});
|
||||
|
||||
let channel_query_deps = Arc::new(ChannelQueryDeps {
|
||||
channel_query: wire_output.channel_query.clone(),
|
||||
});
|
||||
|
||||
let config_snapshot_deps = Arc::new(ConfigSnapshotDeps {
|
||||
channel_command: wire_output.channel_command.clone(),
|
||||
channel_query: wire_output.channel_query.clone(),
|
||||
});
|
||||
|
||||
let schedule_deps = Arc::new(ScheduleDeps {
|
||||
schedule_engine: schedule_engine.clone(),
|
||||
channel_query: wire_output.channel_query.clone(),
|
||||
schedule_query: wire_output.schedule_query.clone(),
|
||||
schedule_command: wire_output.schedule_command.clone(),
|
||||
event_publisher: event_publisher.clone(),
|
||||
});
|
||||
|
||||
let library_command_deps = Arc::new(LibraryCommandDeps {
|
||||
library_command: wire_output.library_command.clone(),
|
||||
library_query: wire_output.library_query.clone(),
|
||||
library_sync: library_sync.clone(),
|
||||
provider_registry: provider_registry.clone(),
|
||||
event_publisher: event_publisher.clone(),
|
||||
});
|
||||
|
||||
let library_query_deps = Arc::new(LibraryQueryDeps {
|
||||
library_query: wire_output.library_query.clone(),
|
||||
});
|
||||
|
||||
let admin_deps = Arc::new(AdminDeps {
|
||||
settings_repo: wire_output.settings.clone(),
|
||||
activity_query: wire_output.activity_query.clone(),
|
||||
});
|
||||
|
||||
let iptv_deps = Arc::new(IptvDeps {
|
||||
channel_query: wire_output.channel_query.clone(),
|
||||
schedule_query: wire_output.schedule_query.clone(),
|
||||
});
|
||||
|
||||
let provider_deps = Arc::new(ProviderDeps {
|
||||
provider_config_command: wire_output.provider_config_command.clone(),
|
||||
provider_config_query: wire_output.provider_config_query.clone(),
|
||||
});
|
||||
|
||||
let config_arc = Arc::new(config);
|
||||
|
||||
// Spawn background tasks
|
||||
let bg_schedule_deps = schedule_deps.clone();
|
||||
tokio::spawn(crate::background::auto_scheduler::run(bg_schedule_deps));
|
||||
|
||||
let bg_schedule_deps2 = schedule_deps.clone();
|
||||
let bg_event_publisher = event_publisher.clone();
|
||||
tokio::spawn(crate::background::broadcast_poller::run(
|
||||
bg_schedule_deps2,
|
||||
bg_event_publisher,
|
||||
));
|
||||
|
||||
let webhook_rx = event_bus.subscriber();
|
||||
let webhook_channel_query = wire_output.channel_query.clone();
|
||||
tokio::spawn(crate::background::webhook_consumer::run(
|
||||
webhook_rx,
|
||||
webhook_channel_query,
|
||||
reqwest::Client::new(),
|
||||
));
|
||||
|
||||
let bg_sync = library_sync.clone();
|
||||
let bg_registry = provider_registry.clone();
|
||||
let bg_settings = wire_output.settings.clone();
|
||||
tokio::spawn(crate::background::library_sync::run(
|
||||
bg_sync,
|
||||
bg_registry,
|
||||
bg_settings,
|
||||
sync_rx,
|
||||
));
|
||||
|
||||
Ok(AppState {
|
||||
auth_deps,
|
||||
channel_command_deps,
|
||||
channel_query_deps,
|
||||
config_snapshot_deps,
|
||||
schedule_deps,
|
||||
library_command_deps,
|
||||
library_query_deps,
|
||||
admin_deps,
|
||||
iptv_deps,
|
||||
provider_deps,
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
jwt_validator,
|
||||
provider_registry,
|
||||
library_sync,
|
||||
settings_repo: wire_output.settings,
|
||||
event_bus,
|
||||
config: config_arc,
|
||||
sync_trigger: sync_tx,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Repository wiring output — trait objects ready for dependency injection.
|
||||
struct WireOutput {
|
||||
user_command: Arc<dyn domain::ports::UserCommand>,
|
||||
user_query: Arc<dyn domain::ports::UserQuery>,
|
||||
channel_command: Arc<dyn domain::ports::ChannelCommand>,
|
||||
channel_query: Arc<dyn domain::ports::ChannelQuery>,
|
||||
schedule_command: Arc<dyn domain::ports::ScheduleCommand>,
|
||||
schedule_query: Arc<dyn domain::ports::ScheduleQuery>,
|
||||
library_command: Arc<dyn domain::ports::LibraryCommand>,
|
||||
library_query: Arc<dyn domain::ports::LibraryQuery>,
|
||||
activity_query: Arc<dyn domain::ports::ActivityLogQuery>,
|
||||
settings: Arc<dyn domain::ports::AppSettingsRepository>,
|
||||
provider_config_command: Arc<dyn domain::ports::ProviderConfigCommand>,
|
||||
provider_config_query: Arc<dyn domain::ports::ProviderConfigQuery>,
|
||||
}
|
||||
|
||||
fn wire_repositories(pool: &DbPool) -> anyhow::Result<WireOutput> {
|
||||
match pool {
|
||||
#[cfg(feature = "sqlite")]
|
||||
DbPool::Sqlite(sqlite_pool) => {
|
||||
let w = adapter_sqlite::wire(sqlite_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,
|
||||
})
|
||||
}
|
||||
#[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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_provider_registry(config: &Config) -> Arc<dyn IProviderRegistry> {
|
||||
// Build a concrete registry that routes to configured providers.
|
||||
let mut providers: Vec<(String, Arc<dyn IMediaProvider>)> = Vec::new();
|
||||
|
||||
match config.config_source {
|
||||
ConfigSource::Env => {
|
||||
#[cfg(feature = "jellyfin")]
|
||||
if let (Some(url), Some(api_key), Some(user_id)) = (
|
||||
&config.jellyfin_url,
|
||||
&config.jellyfin_api_key,
|
||||
&config.jellyfin_user_id,
|
||||
) {
|
||||
tracing::info!("Media provider: Jellyfin at {}", url);
|
||||
providers.push((
|
||||
"jellyfin".to_string(),
|
||||
Arc::new(adapter_jellyfin::JellyfinMediaProvider::new(
|
||||
adapter_jellyfin::JellyfinConfig {
|
||||
base_url: url.clone(),
|
||||
api_key: api_key.clone(),
|
||||
user_id: user_id.clone(),
|
||||
},
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
ConfigSource::Db => {
|
||||
// DB-based provider configs loaded elsewhere at runtime.
|
||||
// For now, fall through to noop if nothing configured via env.
|
||||
tracing::info!("CONFIG_SOURCE=db: provider configs loaded from database at runtime");
|
||||
}
|
||||
}
|
||||
|
||||
if providers.is_empty() {
|
||||
tracing::warn!("No media provider configured — using NoopMediaProvider");
|
||||
providers.push(("noop".to_string(), Arc::new(NoopMediaProvider)));
|
||||
}
|
||||
|
||||
Arc::new(SimpleProviderRegistry::new(providers))
|
||||
}
|
||||
|
||||
fn build_library_sync(
|
||||
library_command: Arc<dyn domain::ports::LibraryCommand>,
|
||||
) -> Arc<dyn domain::ports::LibrarySyncAdapter> {
|
||||
Arc::new(SimpleSyncAdapter::new(library_command))
|
||||
}
|
||||
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
fn build_jwt_validator(config: &Config) -> anyhow::Result<Option<Arc<adapter_auth::JwtValidator>>> {
|
||||
let secret = match &config.jwt_secret {
|
||||
Some(s) if !s.is_empty() => s.clone(),
|
||||
_ => {
|
||||
if config.is_production {
|
||||
anyhow::bail!("JWT_SECRET is required in production");
|
||||
}
|
||||
tracing::warn!("JWT_SECRET not set — using insecure development secret");
|
||||
"k-template-dev-secret-not-for-production-use-only".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let jwt_config = adapter_auth::JwtConfig::new(
|
||||
secret,
|
||||
config.jwt_issuer.clone(),
|
||||
config.jwt_audience.clone(),
|
||||
Some(config.jwt_expiry_hours),
|
||||
Some(config.jwt_refresh_expiry_days),
|
||||
config.is_production,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("JWT config error: {}", e))?;
|
||||
|
||||
Ok(Some(Arc::new(adapter_auth::JwtValidator::new(jwt_config))))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NoopMediaProvider — fallback when nothing is configured
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct NoopMediaProvider;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IMediaProvider for NoopMediaProvider {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
collections: false,
|
||||
series: false,
|
||||
genres: false,
|
||||
tags: false,
|
||||
decade: false,
|
||||
search: false,
|
||||
streaming_protocol: StreamingProtocol::DirectFile,
|
||||
rescan: false,
|
||||
transcode: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_items(
|
||||
&self,
|
||||
_: &domain::MediaFilter,
|
||||
) -> domain::DomainResult<Vec<domain::MediaItem>> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"No media provider configured. Set JELLYFIN_BASE_URL or LOCAL_FILES_DIR.".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_by_id(
|
||||
&self,
|
||||
_: &domain::MediaItemId,
|
||||
) -> domain::DomainResult<Option<domain::MediaItem>> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"No media provider configured.".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
_: &domain::MediaItemId,
|
||||
_: &domain::ports::StreamQuality,
|
||||
) -> domain::DomainResult<String> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"No media provider configured.".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SimpleProviderRegistry — implements IProviderRegistry for N providers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct SimpleProviderRegistry {
|
||||
providers: Vec<(String, Arc<dyn IMediaProvider>)>,
|
||||
}
|
||||
|
||||
impl SimpleProviderRegistry {
|
||||
fn new(providers: Vec<(String, Arc<dyn IMediaProvider>)>) -> Self {
|
||||
Self { providers }
|
||||
}
|
||||
|
||||
fn get(&self, id: &str) -> Option<&Arc<dyn IMediaProvider>> {
|
||||
self.providers.iter().find(|(k, _)| k == id).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
fn primary(&self) -> Option<&Arc<dyn IMediaProvider>> {
|
||||
self.providers.first().map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// Extract provider_id from a prefixed item ID (e.g. "jellyfin::abc123" → "jellyfin").
|
||||
fn extract_provider_id(item_id: &str) -> Option<&str> {
|
||||
item_id.find("::").map(|pos| &item_id[..pos])
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IProviderRegistry for SimpleProviderRegistry {
|
||||
async fn fetch_items(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
filter: &domain::MediaFilter,
|
||||
) -> domain::DomainResult<Vec<domain::MediaItem>> {
|
||||
let id = if provider_id.is_empty() {
|
||||
self.providers.first().map(|(k, _)| k.as_str()).unwrap_or("")
|
||||
} else {
|
||||
provider_id
|
||||
};
|
||||
let provider = self
|
||||
.get(id)
|
||||
.ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?;
|
||||
provider.fetch_items(filter).await
|
||||
}
|
||||
|
||||
async fn fetch_by_id(
|
||||
&self,
|
||||
item_id: &domain::MediaItemId,
|
||||
) -> domain::DomainResult<Option<domain::MediaItem>> {
|
||||
let id_str = item_id.value();
|
||||
if let Some(pid) = Self::extract_provider_id(id_str) {
|
||||
if let Some(provider) = self.get(pid) {
|
||||
return provider.fetch_by_id(item_id).await;
|
||||
}
|
||||
}
|
||||
// Fall back to primary
|
||||
if let Some(provider) = self.primary() {
|
||||
provider.fetch_by_id(item_id).await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
item_id: &domain::MediaItemId,
|
||||
quality: &domain::ports::StreamQuality,
|
||||
) -> domain::DomainResult<String> {
|
||||
let id_str = item_id.value();
|
||||
if let Some(pid) = Self::extract_provider_id(id_str) {
|
||||
if let Some(provider) = self.get(pid) {
|
||||
return provider.get_stream_url(item_id, quality).await;
|
||||
}
|
||||
}
|
||||
if let Some(provider) = self.primary() {
|
||||
provider.get_stream_url(item_id, quality).await
|
||||
} else {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"No provider available".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_ids(&self) -> Vec<String> {
|
||||
self.providers.iter().map(|(k, _)| k.clone()).collect()
|
||||
}
|
||||
|
||||
fn primary_id(&self) -> &str {
|
||||
self.providers
|
||||
.first()
|
||||
.map(|(k, _)| k.as_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn capabilities(&self, provider_id: &str) -> Option<ProviderCapabilities> {
|
||||
self.get(provider_id).map(|p| p.capabilities())
|
||||
}
|
||||
|
||||
async fn list_collections(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> domain::DomainResult<Vec<domain::ports::Collection>> {
|
||||
let id = if provider_id.is_empty() {
|
||||
self.primary_id()
|
||||
} else {
|
||||
provider_id
|
||||
};
|
||||
let provider = self
|
||||
.get(id)
|
||||
.ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?;
|
||||
provider.list_collections().await
|
||||
}
|
||||
|
||||
async fn list_series(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
collection_id: Option<&str>,
|
||||
) -> domain::DomainResult<Vec<domain::ports::SeriesSummary>> {
|
||||
let id = if provider_id.is_empty() {
|
||||
self.primary_id()
|
||||
} else {
|
||||
provider_id
|
||||
};
|
||||
let provider = self
|
||||
.get(id)
|
||||
.ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?;
|
||||
provider.list_series(collection_id).await
|
||||
}
|
||||
|
||||
async fn list_genres(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
content_type: Option<&domain::ContentType>,
|
||||
) -> domain::DomainResult<Vec<String>> {
|
||||
let id = if provider_id.is_empty() {
|
||||
self.primary_id()
|
||||
} else {
|
||||
provider_id
|
||||
};
|
||||
let provider = self
|
||||
.get(id)
|
||||
.ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?;
|
||||
provider.list_genres(content_type).await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SimpleSyncAdapter — wraps LibraryCommand for sync operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convert a MediaItem from a provider into a LibraryItem for persistence.
|
||||
fn media_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> domain::LibraryItem {
|
||||
let external_id = item.id().value().to_string();
|
||||
let id = format!("{}::{}", provider_id, external_id);
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
domain::LibraryItem::from_persistence(
|
||||
id,
|
||||
provider_id.to_string(),
|
||||
external_id,
|
||||
item.title().to_string(),
|
||||
item.content_type().clone(),
|
||||
item.duration_secs(),
|
||||
item.series_name().map(|s| s.to_string()),
|
||||
item.season_number(),
|
||||
item.episode_number(),
|
||||
item.year(),
|
||||
item.genres().to_vec(),
|
||||
item.tags().to_vec(),
|
||||
item.collection_id().map(|s| s.to_string()),
|
||||
None, // collection_name not in MediaItem
|
||||
None, // collection_type not in MediaItem
|
||||
item.thumbnail_url().map(|s| s.to_string()),
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
struct SimpleSyncAdapter {
|
||||
library_command: Arc<dyn domain::ports::LibraryCommand>,
|
||||
}
|
||||
|
||||
impl SimpleSyncAdapter {
|
||||
fn new(library_command: Arc<dyn domain::ports::LibraryCommand>) -> Self {
|
||||
Self { library_command }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::LibrarySyncAdapter for SimpleSyncAdapter {
|
||||
async fn sync_provider(
|
||||
&self,
|
||||
provider: &dyn IMediaProvider,
|
||||
provider_id: &str,
|
||||
) -> domain::LibrarySyncResult {
|
||||
use std::time::Instant;
|
||||
|
||||
let start = Instant::now();
|
||||
let log_id = match self.library_command.log_sync_start(provider_id).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
return domain::LibrarySyncResult::with_error(
|
||||
provider_id,
|
||||
0,
|
||||
format!("Failed to log sync start: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch all items from provider
|
||||
let filter = domain::MediaFilter::default();
|
||||
let items = match provider.fetch_items(&filter).await {
|
||||
Ok(items) => items,
|
||||
Err(e) => {
|
||||
let result = domain::LibrarySyncResult::with_error(
|
||||
provider_id,
|
||||
start.elapsed().as_millis() as u64,
|
||||
format!("Failed to fetch items: {e}"),
|
||||
);
|
||||
let _ = self.library_command.log_sync_finish(log_id, &result).await;
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
let items_found = items.len() as u32;
|
||||
|
||||
// Clear + insert (items are MediaItem; LibrarySyncAdapter implementations
|
||||
// typically handle the conversion. Here we delegate to library_command directly.)
|
||||
if let Err(e) = self.library_command.clear_provider(provider_id).await {
|
||||
let result = domain::LibrarySyncResult::with_error(
|
||||
provider_id,
|
||||
start.elapsed().as_millis() as u64,
|
||||
format!("Failed to clear provider items: {e}"),
|
||||
);
|
||||
let _ = self.library_command.log_sync_finish(log_id, &result).await;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Convert MediaItems to LibraryItems for storage
|
||||
let library_items: Vec<domain::LibraryItem> = items
|
||||
.into_iter()
|
||||
.map(|item| media_item_to_library_item(item, provider_id))
|
||||
.collect();
|
||||
|
||||
if let Err(e) = self
|
||||
.library_command
|
||||
.upsert_items(provider_id, library_items)
|
||||
.await
|
||||
{
|
||||
let result = domain::LibrarySyncResult::with_error(
|
||||
provider_id,
|
||||
start.elapsed().as_millis() as u64,
|
||||
format!("Failed to upsert items: {e}"),
|
||||
);
|
||||
let _ = self.library_command.log_sync_finish(log_id, &result).await;
|
||||
return result;
|
||||
}
|
||||
|
||||
let result = domain::LibrarySyncResult::new(
|
||||
provider_id,
|
||||
items_found,
|
||||
start.elapsed().as_millis() as u64,
|
||||
);
|
||||
let _ = self.library_command.log_sync_finish(log_id, &result).await;
|
||||
result
|
||||
}
|
||||
}
|
||||
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(),
|
||||
))
|
||||
}
|
||||
76
crates/presentation/src/main.rs
Normal file
76
crates/presentation/src/main.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! k-tv server entry point.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::info;
|
||||
|
||||
mod background;
|
||||
mod errors;
|
||||
mod extractors;
|
||||
mod factory;
|
||||
mod handlers;
|
||||
mod mappers;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Load .env file if present
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
// Load config
|
||||
let config = infra_wiring::Config::from_env()
|
||||
.map_err(|e| anyhow::anyhow!("Config error: {}", e))?;
|
||||
|
||||
let host = config.host.clone();
|
||||
let port = config.port;
|
||||
let cors_origins = config.cors_origins.clone();
|
||||
|
||||
info!("Starting k-tv server on {}:{}", host, port);
|
||||
|
||||
// Build the application state (connects DB, creates adapters, spawns background tasks)
|
||||
let app_state = factory::build_app_state(config).await?;
|
||||
|
||||
// Build CORS layer
|
||||
let cors = if cors_origins.iter().any(|o| o == "*") {
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
} else {
|
||||
let origins: Vec<_> = cors_origins
|
||||
.iter()
|
||||
.filter_map(|o| o.parse().ok())
|
||||
.collect();
|
||||
CorsLayer::new()
|
||||
.allow_origin(origins)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
};
|
||||
|
||||
// Build the router
|
||||
let app = axum::Router::new()
|
||||
.nest("/api/v1", routes::api_v1_router())
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(app_state);
|
||||
|
||||
// Start serving
|
||||
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
info!("Listening on {}", addr);
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
6
crates/presentation/src/mappers/mod.rs
Normal file
6
crates/presentation/src/mappers/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
//! Domain → DTO mappings.
|
||||
//!
|
||||
//! Most conversions are already handled by `From` impls in the `api-types` crate.
|
||||
//! This module is reserved for any presentation-layer-specific mappings that
|
||||
//! don't belong in `api-types` (e.g., combining multiple domain objects into a
|
||||
//! single response).
|
||||
109
crates/presentation/src/routes.rs
Normal file
109
crates/presentation/src/routes.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
//! Router construction.
|
||||
|
||||
use axum::{Router, routing::{delete, get, post, put}};
|
||||
|
||||
use crate::handlers;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Construct the API v1 router.
|
||||
pub fn api_v1_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.nest("/auth", auth_router())
|
||||
.nest("/channels", channel_router())
|
||||
.nest("/admin", admin_router())
|
||||
.nest("/admin/providers", provider_router())
|
||||
.nest("/config", config_router())
|
||||
.nest("/iptv", iptv_router())
|
||||
.nest("/library", library_router())
|
||||
.merge(local_files_router())
|
||||
}
|
||||
|
||||
fn auth_router() -> Router<AppState> {
|
||||
let r = Router::new()
|
||||
.route("/register", post(handlers::auth::register))
|
||||
.route("/login", post(handlers::auth::login))
|
||||
.route("/logout", post(handlers::auth::logout))
|
||||
.route("/me", get(handlers::auth::me));
|
||||
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
let r = r
|
||||
.route("/token", post(handlers::auth::get_token))
|
||||
.route("/refresh", post(handlers::auth::refresh_token));
|
||||
|
||||
r
|
||||
}
|
||||
|
||||
fn channel_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(handlers::channels::list_channels))
|
||||
.route("/", post(handlers::channels::create_channel))
|
||||
.route("/mine", get(handlers::channels::list_my_channels))
|
||||
.route("/{id}", get(handlers::channels::get_channel))
|
||||
.route("/{id}", put(handlers::channels::update_channel))
|
||||
.route("/{id}", delete(handlers::channels::delete_channel))
|
||||
// Schedule
|
||||
.route("/{id}/schedule", post(handlers::schedule::generate_schedule))
|
||||
.route("/{id}/schedule", get(handlers::schedule::get_active_schedule))
|
||||
.route("/{id}/schedule/history", get(handlers::schedule::list_schedule_history))
|
||||
// Broadcast
|
||||
.route("/{id}/now", get(handlers::schedule::get_current_broadcast))
|
||||
.route("/{id}/epg", get(handlers::schedule::get_epg))
|
||||
.route("/{id}/stream", get(handlers::schedule::get_stream))
|
||||
// Config snapshots
|
||||
.route("/{id}/snapshots", post(handlers::channels::save_snapshot))
|
||||
.route("/{id}/snapshots", get(handlers::channels::list_snapshots))
|
||||
.route("/{id}/snapshots/{snapshot_id}", get(handlers::channels::get_snapshot))
|
||||
.route("/{id}/snapshots/{snapshot_id}", axum::routing::patch(handlers::channels::patch_snapshot))
|
||||
.route("/{id}/snapshots/{snapshot_id}/restore", post(handlers::channels::restore_snapshot))
|
||||
}
|
||||
|
||||
fn admin_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/settings", get(handlers::admin::get_settings))
|
||||
.route("/settings", put(handlers::admin::update_settings))
|
||||
.route("/activity", get(handlers::admin::get_activity_log))
|
||||
}
|
||||
|
||||
fn provider_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(handlers::providers::list_providers))
|
||||
.route("/{id}", get(handlers::providers::get_provider))
|
||||
.route("/{id}", put(handlers::providers::upsert_provider))
|
||||
.route("/{id}", delete(handlers::providers::delete_provider))
|
||||
}
|
||||
|
||||
fn config_router() -> Router<AppState> {
|
||||
Router::new().route("/", get(handlers::config::get_config))
|
||||
}
|
||||
|
||||
fn iptv_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/playlist.m3u", get(handlers::iptv::m3u_playlist))
|
||||
.route("/epg.xml", get(handlers::iptv::xmltv_epg))
|
||||
}
|
||||
|
||||
fn library_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/items", get(handlers::library::search_items))
|
||||
.route("/items/{id}", get(handlers::library::get_item))
|
||||
.route("/collections", get(handlers::library::list_collections))
|
||||
.route("/shows", get(handlers::library::list_shows))
|
||||
.route("/seasons", get(handlers::library::list_seasons))
|
||||
.route("/genres", get(handlers::library::list_genres))
|
||||
.route("/sync/status", get(handlers::library::sync_status))
|
||||
.route("/sync", post(handlers::library::trigger_sync))
|
||||
}
|
||||
|
||||
fn local_files_router() -> Router<AppState> {
|
||||
#[cfg(feature = "local-files")]
|
||||
{
|
||||
Router::new()
|
||||
.route("/files/stream/{id}", get(handlers::files::stream_file))
|
||||
.route("/files/rescan", post(handlers::files::rescan))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local-files"))]
|
||||
{
|
||||
Router::new()
|
||||
}
|
||||
}
|
||||
51
crates/presentation/src/state.rs
Normal file
51
crates/presentation/src/state.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! Application state — holds pre-built Deps structs from the application layer.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{
|
||||
admin::AdminDeps,
|
||||
auth::AuthDeps,
|
||||
channels::{ChannelCommandDeps, ChannelQueryDeps},
|
||||
config_snapshots::ConfigSnapshotDeps,
|
||||
iptv::IptvDeps,
|
||||
library::{LibraryCommandDeps, LibraryQueryDeps},
|
||||
providers::ProviderDeps,
|
||||
schedule::ScheduleDeps,
|
||||
};
|
||||
|
||||
/// Shared application state, passed to all handlers via `State<AppState>`.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub auth_deps: Arc<AuthDeps>,
|
||||
pub channel_command_deps: Arc<ChannelCommandDeps>,
|
||||
pub channel_query_deps: Arc<ChannelQueryDeps>,
|
||||
pub config_snapshot_deps: Arc<ConfigSnapshotDeps>,
|
||||
pub schedule_deps: Arc<ScheduleDeps>,
|
||||
pub library_command_deps: Arc<LibraryCommandDeps>,
|
||||
pub library_query_deps: Arc<LibraryQueryDeps>,
|
||||
pub admin_deps: Arc<AdminDeps>,
|
||||
pub iptv_deps: Arc<IptvDeps>,
|
||||
pub provider_deps: Arc<ProviderDeps>,
|
||||
|
||||
/// JWT validator for token creation/validation in auth handlers.
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub jwt_validator: Option<Arc<adapter_auth::JwtValidator>>,
|
||||
|
||||
/// Provider registry for config/capabilities endpoints.
|
||||
pub provider_registry: Arc<dyn domain::ports::IProviderRegistry>,
|
||||
|
||||
/// Library sync adapter — needed for spawning background sync tasks.
|
||||
pub library_sync: Arc<dyn domain::ports::LibrarySyncAdapter>,
|
||||
|
||||
/// App settings — read by library sync background task.
|
||||
pub settings_repo: Arc<dyn domain::ports::AppSettingsRepository>,
|
||||
|
||||
/// Event bus for domain events.
|
||||
pub event_bus: Arc<adapter_event_publisher::ChannelEventBus>,
|
||||
|
||||
/// Application config.
|
||||
pub config: Arc<infra_wiring::Config>,
|
||||
|
||||
/// Trigger for on-demand library sync (sends () to wake the background task).
|
||||
pub sync_trigger: tokio::sync::watch::Sender<()>,
|
||||
}
|
||||
Reference in New Issue
Block a user