//! 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 { 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 { 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, } #[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, #[serde(skip_serializing_if = "Option::is_none")] season_number: Option, #[serde(skip_serializing_if = "Option::is_none")] episode_number: Option, #[serde(skip_serializing_if = "Option::is_none")] year: Option, genres: Vec, #[serde(skip_serializing_if = "Option::is_none")] thumbnail_url: Option, } #[derive(Debug, Serialize)] struct PagedResponse { items: Vec, 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, genres: Vec, } #[derive(Debug, Serialize)] struct SeasonSummaryResponse { season_number: u32, episode_count: u32, #[serde(skip_serializing_if = "Option::is_none")] thumbnail_url: Option, } #[derive(Debug, Serialize)] struct SyncLogResponse { id: i64, provider_id: String, started_at: String, #[serde(skip_serializing_if = "Option::is_none")] finished_at: Option, items_found: u32, status: String, #[serde(skip_serializing_if = "Option::is_none")] error_msg: Option, } // ============================================================================ // Query params // ============================================================================ #[derive(Debug, Deserialize)] struct CollectionsQuery { provider: Option, } #[derive(Debug, Deserialize)] struct SeriesQuery { provider: Option, } #[derive(Debug, Deserialize)] struct GenresQuery { #[serde(rename = "type")] content_type: Option, provider: Option, } #[derive(Debug, Default, Deserialize)] struct ItemsQuery { q: Option, #[serde(rename = "type")] content_type: Option, #[serde(default)] series: Vec, #[serde(default)] genres: Vec, collection: Option, limit: Option, offset: Option, provider: Option, season: Option, } #[derive(Debug, Default, Deserialize)] struct ShowsQuery { q: Option, provider: Option, #[serde(default)] genres: Vec, } #[derive(Debug, Deserialize)] struct SeasonsQuery { provider: Option, } // ============================================================================ // Handlers // ============================================================================ async fn list_collections( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, ) -> Result>, 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, CurrentUser(_user): CurrentUser, Query(params): Query, ) -> Result>, ApiError> { let series = state .library_repo .list_series(params.provider.as_deref()) .await?; Ok(Json(series)) } async fn list_genres( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, ) -> Result>, 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, CurrentUser(_user): CurrentUser, RawQuery(raw_query): RawQuery, ) -> Result>, ApiError> { let qs_config = serde_qs::Config::new(2, false); let params: ItemsQuery = raw_query .as_deref() .map(|q| qs_config.deserialize_str::(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, CurrentUser(_user): CurrentUser, Path(id): Path, ) -> Result, 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, CurrentUser(_user): CurrentUser, ) -> Result>, 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, CurrentUser(_user): CurrentUser, ) -> Result { use domain::IProviderRegistry as _; let provider_ids: Vec = { 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 = Arc::clone(&state.library_sync_adapter); let registry = Arc::clone(&state.provider_registry); tokio::spawn(async move { let providers: Vec<(String, Arc)> = { 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, CurrentUser(_user): CurrentUser, Query(params): Query, ) -> Result>, ApiError> { let shows = state .library_repo .list_shows( params.provider.as_deref(), params.q.as_deref(), ¶ms.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, CurrentUser(_user): CurrentUser, Path(name): Path, Query(params): Query, ) -> Result>, 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, AdminUser(_user): AdminUser, ) -> Result>, ApiError> { let pairs = state.app_settings_repo.get_all().await?; let map: HashMap = 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::() { serde_json::Value::Number(n.into()) } else if let Ok(b) = v.parse::() { serde_json::Value::Bool(b) } else { serde_json::Value::String(v) }; (k, val) }) .collect(); Ok(Json(map)) } async fn update_settings( State(state): State, AdminUser(_user): AdminUser, Json(body): Json>, ) -> Result>, 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 = pairs .into_iter() .map(|(k, v)| { let val = if let Ok(n) = v.parse::() { serde_json::Value::Number(n.into()) } else if let Ok(b) = v.parse::() { 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, 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, } }