Files
k-tv/k-tv-backend/api/src/routes/library.rs

478 lines
14 KiB
Rust

//! Library routes — DB-backed.
//!
//! GET /library/collections — collections derived from synced items
//! GET /library/series — series names
//! GET /library/genres — genres
//! GET /library/items — search / browse
//! GET /library/items/:id — single item
//! GET /library/sync/status — latest sync log per provider
//! POST /library/sync — trigger an ad-hoc sync (auth)
//!
//! Admin (nested under /admin/library):
//! GET /admin/library/settings — app_settings key/value
//! PUT /admin/library/settings — update app_settings
use std::collections::HashMap;
use std::sync::Arc;
use axum::{
Json, Router,
extract::{Path, Query, RawQuery, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post, put},
};
use domain::{ContentType, ILibraryRepository, LibrarySearchFilter, LibrarySyncAdapter, SeasonSummary, ShowSummary};
use serde::{Deserialize, Serialize};
use crate::{error::ApiError, extractors::{AdminUser, CurrentUser}, state::AppState};
// ============================================================================
// Routers
// ============================================================================
pub fn router() -> Router<AppState> {
Router::new()
.route("/collections", get(list_collections))
.route("/series", get(list_series))
.route("/genres", get(list_genres))
.route("/items", get(search_items))
.route("/items/:id", get(get_item))
.route("/shows", get(list_shows))
.route("/shows/:name/seasons", get(list_seasons))
.route("/sync/status", get(sync_status))
.route("/sync", post(trigger_sync))
}
pub fn admin_router() -> Router<AppState> {
Router::new()
.route("/settings", get(get_settings).put(update_settings))
}
// ============================================================================
// Response DTOs
// ============================================================================
#[derive(Debug, Serialize)]
struct CollectionResponse {
id: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
collection_type: Option<String>,
}
#[derive(Debug, Serialize)]
struct LibraryItemResponse {
id: String,
title: String,
content_type: String,
duration_secs: u32,
#[serde(skip_serializing_if = "Option::is_none")]
series_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
season_number: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
episode_number: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
year: Option<u16>,
genres: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thumbnail_url: Option<String>,
}
#[derive(Debug, Serialize)]
struct PagedResponse<T: Serialize> {
items: Vec<T>,
total: u32,
}
#[derive(Debug, Serialize)]
struct ShowSummaryResponse {
series_name: String,
episode_count: u32,
season_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
thumbnail_url: Option<String>,
genres: Vec<String>,
}
#[derive(Debug, Serialize)]
struct SeasonSummaryResponse {
season_number: u32,
episode_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
thumbnail_url: Option<String>,
}
#[derive(Debug, Serialize)]
struct SyncLogResponse {
id: i64,
provider_id: String,
started_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
finished_at: Option<String>,
items_found: u32,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
error_msg: Option<String>,
}
// ============================================================================
// Query params
// ============================================================================
#[derive(Debug, Deserialize)]
struct CollectionsQuery {
provider: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SeriesQuery {
provider: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GenresQuery {
#[serde(rename = "type")]
content_type: Option<String>,
provider: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ItemsQuery {
q: Option<String>,
#[serde(rename = "type")]
content_type: Option<String>,
#[serde(default)]
series: Vec<String>,
#[serde(default)]
genres: Vec<String>,
collection: Option<String>,
limit: Option<u32>,
offset: Option<u32>,
provider: Option<String>,
season: Option<u32>,
}
#[derive(Debug, Default, Deserialize)]
struct ShowsQuery {
q: Option<String>,
provider: Option<String>,
#[serde(default)]
genres: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct SeasonsQuery {
provider: Option<String>,
}
// ============================================================================
// Handlers
// ============================================================================
async fn list_collections(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Query(params): Query<CollectionsQuery>,
) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
let cols = state
.library_repo
.list_collections(params.provider.as_deref())
.await?;
let resp = cols
.into_iter()
.map(|c| CollectionResponse {
id: c.id,
name: c.name,
collection_type: c.collection_type,
})
.collect();
Ok(Json(resp))
}
async fn list_series(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Query(params): Query<SeriesQuery>,
) -> Result<Json<Vec<String>>, ApiError> {
let series = state
.library_repo
.list_series(params.provider.as_deref())
.await?;
Ok(Json(series))
}
async fn list_genres(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Query(params): Query<GenresQuery>,
) -> Result<Json<Vec<String>>, ApiError> {
let ct = parse_content_type(params.content_type.as_deref())?;
let genres = state
.library_repo
.list_genres(ct.as_ref(), params.provider.as_deref())
.await?;
Ok(Json(genres))
}
async fn search_items(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
RawQuery(raw_query): RawQuery,
) -> Result<Json<PagedResponse<LibraryItemResponse>>, ApiError> {
let qs_config = serde_qs::Config::new(2, false);
let params: ItemsQuery = raw_query
.as_deref()
.map(|q| qs_config.deserialize_str::<ItemsQuery>(q))
.transpose()
.map_err(|e| ApiError::validation(e.to_string()))?
.unwrap_or_default();
let limit = params.limit.unwrap_or(50).min(200);
let offset = params.offset.unwrap_or(0);
let filter = LibrarySearchFilter {
provider_id: params.provider,
content_type: parse_content_type(params.content_type.as_deref())?,
series_names: params.series,
collection_id: params.collection,
genres: params.genres,
search_term: params.q,
season_number: params.season,
offset,
limit,
..Default::default()
};
let (items, total) = state.library_repo.search(&filter).await?;
let resp = items.into_iter().map(library_item_to_response).collect();
Ok(Json(PagedResponse { items: resp, total }))
}
async fn get_item(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path(id): Path<String>,
) -> Result<Json<LibraryItemResponse>, ApiError> {
let item = state
.library_repo
.get_by_id(&id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Library item '{}' not found", id)))?;
Ok(Json(library_item_to_response(item)))
}
async fn sync_status(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
) -> Result<Json<Vec<SyncLogResponse>>, ApiError> {
let entries = state.library_repo.latest_sync_status().await?;
let resp = entries
.into_iter()
.map(|e| SyncLogResponse {
id: e.id,
provider_id: e.provider_id,
started_at: e.started_at,
finished_at: e.finished_at,
items_found: e.items_found,
status: e.status,
error_msg: e.error_msg,
})
.collect();
Ok(Json(resp))
}
async fn trigger_sync(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
) -> Result<impl IntoResponse, ApiError> {
use domain::IProviderRegistry as _;
let provider_ids: Vec<String> = {
let reg = state.provider_registry.read().await;
reg.provider_ids()
};
// 409 if any provider is already syncing
for pid in &provider_ids {
let running = state.library_repo.is_sync_running(pid).await?;
if running {
return Ok((
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": format!("Sync already running for provider '{}'", pid)
})),
)
.into_response());
}
}
// Spawn background sync
let sync_adapter: Arc<dyn LibrarySyncAdapter> = Arc::clone(&state.library_sync_adapter);
let registry = Arc::clone(&state.provider_registry);
tokio::spawn(async move {
let providers: Vec<(String, Arc<dyn domain::IMediaProvider>)> = {
let reg = registry.read().await;
provider_ids
.iter()
.filter_map(|id| reg.get_provider(id).map(|p| (id.clone(), p)))
.collect()
};
for (pid, provider) in providers {
let result = sync_adapter.sync_provider(provider.as_ref(), &pid).await;
if let Some(ref err) = result.error {
tracing::warn!("manual sync: provider '{}' failed: {}", pid, err);
} else {
tracing::info!(
"manual sync: provider '{}' done — {} items in {}ms",
pid, result.items_found, result.duration_ms
);
}
}
});
Ok((
StatusCode::ACCEPTED,
Json(serde_json::json!({ "message": "Sync started" })),
)
.into_response())
}
async fn list_shows(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Query(params): Query<ShowsQuery>,
) -> Result<Json<Vec<ShowSummaryResponse>>, ApiError> {
let shows = state
.library_repo
.list_shows(
params.provider.as_deref(),
params.q.as_deref(),
&params.genres,
)
.await?;
let resp = shows
.into_iter()
.map(|s| ShowSummaryResponse {
series_name: s.series_name,
episode_count: s.episode_count,
season_count: s.season_count,
thumbnail_url: s.thumbnail_url,
genres: s.genres,
})
.collect();
Ok(Json(resp))
}
async fn list_seasons(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path(name): Path<String>,
Query(params): Query<SeasonsQuery>,
) -> Result<Json<Vec<SeasonSummaryResponse>>, ApiError> {
let seasons = state
.library_repo
.list_seasons(&name, params.provider.as_deref())
.await?;
let resp = seasons
.into_iter()
.map(|s| SeasonSummaryResponse {
season_number: s.season_number,
episode_count: s.episode_count,
thumbnail_url: s.thumbnail_url,
})
.collect();
Ok(Json(resp))
}
async fn get_settings(
State(state): State<AppState>,
AdminUser(_user): AdminUser,
) -> Result<Json<HashMap<String, serde_json::Value>>, ApiError> {
let pairs = state.app_settings_repo.get_all().await?;
let map: HashMap<String, serde_json::Value> = pairs
.into_iter()
.map(|(k, v)| {
// Try to parse as number first, then bool, then keep as string
let val = if let Ok(n) = v.parse::<i64>() {
serde_json::Value::Number(n.into())
} else if let Ok(b) = v.parse::<bool>() {
serde_json::Value::Bool(b)
} else {
serde_json::Value::String(v)
};
(k, val)
})
.collect();
Ok(Json(map))
}
async fn update_settings(
State(state): State<AppState>,
AdminUser(_user): AdminUser,
Json(body): Json<HashMap<String, serde_json::Value>>,
) -> Result<Json<HashMap<String, serde_json::Value>>, ApiError> {
for (key, val) in &body {
let val_str = match val {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
other => other.to_string(),
};
state.app_settings_repo.set(key, &val_str).await?;
}
// Return the updated state
let pairs = state.app_settings_repo.get_all().await?;
let map: HashMap<String, serde_json::Value> = pairs
.into_iter()
.map(|(k, v)| {
let val = if let Ok(n) = v.parse::<i64>() {
serde_json::Value::Number(n.into())
} else if let Ok(b) = v.parse::<bool>() {
serde_json::Value::Bool(b)
} else {
serde_json::Value::String(v)
};
(k, val)
})
.collect();
Ok(Json(map))
}
// ============================================================================
// Helpers
// ============================================================================
fn parse_content_type(s: Option<&str>) -> Result<Option<ContentType>, ApiError> {
match s {
None | Some("") => Ok(None),
Some("movie") => Ok(Some(ContentType::Movie)),
Some("episode") => Ok(Some(ContentType::Episode)),
Some("short") => Ok(Some(ContentType::Short)),
Some(other) => Err(ApiError::validation(format!(
"Unknown content type '{}'. Use movie, episode, or short.",
other
))),
}
}
fn library_item_to_response(item: domain::LibraryItem) -> LibraryItemResponse {
LibraryItemResponse {
id: item.id,
title: item.title,
content_type: match item.content_type {
ContentType::Movie => "movie".into(),
ContentType::Episode => "episode".into(),
ContentType::Short => "short".into(),
},
duration_secs: item.duration_secs,
series_name: item.series_name,
season_number: item.season_number,
episode_number: item.episode_number,
year: item.year,
genres: item.genres,
thumbnail_url: item.thumbnail_url,
}
}