diff --git a/k-tv-backend/api/src/error.rs b/k-tv-backend/api/src/error.rs index 75eb1bf..8e42be5 100644 --- a/k-tv-backend/api/src/error.rs +++ b/k-tv-backend/api/src/error.rs @@ -42,6 +42,9 @@ pub enum ApiError { #[error("Not implemented: {0}")] NotImplemented(String), + + #[error("Conflict: {0}")] + Conflict(String), } /// Error response body @@ -155,6 +158,14 @@ impl IntoResponse for ApiError { details: Some(msg.clone()), }, ), + + ApiError::Conflict(msg) => ( + StatusCode::CONFLICT, + ErrorResponse { + error: "Conflict".to_string(), + details: Some(msg.clone()), + }, + ), }; (status, Json(error_response)).into_response() @@ -166,16 +177,18 @@ impl ApiError { Self::Validation(msg.into()) } - #[cfg(feature = "local-files")] pub fn internal(msg: impl Into) -> Self { Self::Internal(msg.into()) } - #[cfg(feature = "local-files")] pub fn not_found(msg: impl Into) -> Self { Self::NotFound(msg.into()) } + pub fn conflict(msg: impl Into) -> Self { + Self::Conflict(msg.into()) + } + pub fn not_implemented(msg: impl Into) -> Self { Self::NotImplemented(msg.into()) } diff --git a/k-tv-backend/api/src/routes/library.rs b/k-tv-backend/api/src/routes/library.rs index 8bd577f..1f1ba18 100644 --- a/k-tv-backend/api/src/routes/library.rs +++ b/k-tv-backend/api/src/routes/library.rs @@ -1,25 +1,35 @@ -//! Library browsing routes +//! Library routes — DB-backed. //! -//! These endpoints expose the media provider's library to the dashboard so -//! users can discover what's available without knowing provider-internal IDs. -//! All routes require authentication. +//! 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) //! -//! GET /library/collections — top-level libraries (Jellyfin views, Plex sections) -//! GET /library/series — TV series, optionally scoped to a collection -//! GET /library/genres — available genres, optionally filtered by content type -//! GET /library/items — search / browse items (used for block filter preview) +//! 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::{Query, RawQuery, State}, - routing::get, + extract::{Path, Query, RawQuery, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post, put}, }; -use domain::IProviderRegistry as _; +use domain::{ContentType, ILibraryRepository, LibrarySearchFilter, LibrarySyncAdapter}; use serde::{Deserialize, Serialize}; -use domain::{Collection, ContentType, MediaFilter, SeriesSummary}; +use crate::{error::ApiError, extractors::{AdminUser, CurrentUser}, state::AppState}; -use crate::{error::ApiError, extractors::CurrentUser, state::AppState}; +// ============================================================================ +// Routers +// ============================================================================ pub fn router() -> Router { Router::new() @@ -27,6 +37,14 @@ pub fn router() -> Router { .route("/series", get(list_series)) .route("/genres", get(list_genres)) .route("/items", get(search_items)) + .route("/items/:id", get(get_item)) + .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)) } // ============================================================================ @@ -41,38 +59,6 @@ struct CollectionResponse { collection_type: Option, } -impl From for CollectionResponse { - fn from(c: Collection) -> Self { - Self { - id: c.id, - name: c.name, - collection_type: c.collection_type, - } - } -} - -#[derive(Debug, Serialize)] -struct SeriesResponse { - id: String, - name: String, - episode_count: u32, - genres: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - year: Option, -} - -impl From for SeriesResponse { - fn from(s: SeriesSummary) -> Self { - Self { - id: s.id, - name: s.name, - episode_count: s.episode_count, - genres: s.genres, - year: s.year, - } - } -} - #[derive(Debug, Serialize)] struct LibraryItemResponse { id: String, @@ -88,6 +74,27 @@ struct LibraryItemResponse { #[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 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, } // ============================================================================ @@ -96,47 +103,33 @@ struct LibraryItemResponse { #[derive(Debug, Deserialize)] struct CollectionsQuery { - /// Provider key to query (default: primary). provider: Option, } #[derive(Debug, Deserialize)] struct SeriesQuery { - /// Scope results to a specific collection (provider library ID). - collection: Option, - /// Provider key to query (default: primary). provider: Option, } #[derive(Debug, Deserialize)] struct GenresQuery { - /// Limit genres to a content type: "movie", "episode", or "short". #[serde(rename = "type")] content_type: Option, - /// Provider key to query (default: primary). provider: Option, } #[derive(Debug, Default, Deserialize)] struct ItemsQuery { - /// Free-text search. q: Option, - /// Content type filter: "movie", "episode", or "short". #[serde(rename = "type")] content_type: Option, - /// Filter episodes by series name. Repeat the param for multiple series: - /// `?series[]=iCarly&series[]=Victorious` #[serde(default)] series: Vec, - /// Scope to a provider collection ID. + #[serde(default)] + genres: Vec, collection: Option, - /// Maximum number of results (default: 50, max: 200). - limit: Option, - /// Fill strategy to simulate: "random" | "sequential" | "best_fit". - /// Applies the same ordering the schedule engine would use so the preview - /// reflects what will actually be scheduled. - strategy: Option, - /// Provider key to query (default: primary). + limit: Option, + offset: Option, provider: Option, } @@ -144,130 +137,225 @@ struct ItemsQuery { // Handlers // ============================================================================ -/// List top-level collections (Jellyfin virtual libraries, Plex sections, etc.) async fn list_collections( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, ) -> Result>, ApiError> { - let provider_id = params.provider.as_deref().unwrap_or(""); - let registry = state.provider_registry.read().await; - let caps = registry.capabilities(provider_id).ok_or_else(|| { - ApiError::validation(format!("Unknown provider '{}'", provider_id)) - })?; - if !caps.collections { - return Err(ApiError::not_implemented("collections not supported by this provider")); - } - let collections = registry.list_collections(provider_id).await?; - Ok(Json(collections.into_iter().map(Into::into).collect())) + 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)) } -/// List TV series, optionally scoped to a collection. async fn list_series( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, -) -> Result>, ApiError> { - let provider_id = params.provider.as_deref().unwrap_or(""); - let registry = state.provider_registry.read().await; - let caps = registry.capabilities(provider_id).ok_or_else(|| { - ApiError::validation(format!("Unknown provider '{}'", provider_id)) - })?; - if !caps.series { - return Err(ApiError::not_implemented("series not supported by this provider")); - } - let series = registry - .list_series(provider_id, params.collection.as_deref()) +) -> Result>, ApiError> { + let series = state + .library_repo + .list_series(params.provider.as_deref()) .await?; - Ok(Json(series.into_iter().map(Into::into).collect())) + Ok(Json(series)) } -/// List available genres, optionally filtered to a content type. async fn list_genres( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, ) -> Result>, ApiError> { - let provider_id = params.provider.as_deref().unwrap_or(""); - let registry = state.provider_registry.read().await; - let caps = registry.capabilities(provider_id).ok_or_else(|| { - ApiError::validation(format!("Unknown provider '{}'", provider_id)) - })?; - if !caps.genres { - return Err(ApiError::not_implemented("genres not supported by this provider")); - } let ct = parse_content_type(params.content_type.as_deref())?; - let genres = registry.list_genres(provider_id, ct.as_ref()).await?; + let genres = state + .library_repo + .list_genres(ct.as_ref(), params.provider.as_deref()) + .await?; Ok(Json(genres)) } -/// Search / browse library items. Used by the block editor to preview what a -/// filter matches before saving a channel config. 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); // non-strict: accept encoded brackets +) -> 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 provider_id = params.provider.as_deref().unwrap_or(""); - - let filter = MediaFilter { + let filter = LibrarySearchFilter { + provider_id: params.provider, content_type: parse_content_type(params.content_type.as_deref())?, - search_term: params.q, series_names: params.series, - collections: params - .collection - .map(|c| vec![c]) - .unwrap_or_default(), + collection_id: params.collection, + genres: params.genres, + search_term: params.q, + offset, + limit, ..Default::default() }; - let registry = state.provider_registry.read().await; - let mut items = registry.fetch_items(provider_id, &filter).await?; + 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 })) +} - // Apply the same ordering the schedule engine uses so the preview reflects - // what will actually be scheduled rather than raw provider order. - match params.strategy.as_deref() { - Some("random") => { - use rand::seq::SliceRandom; - items.shuffle(&mut rand::thread_rng()); - } - Some("best_fit") => { - // Mirror the greedy bin-packing: longest items first. - items.sort_by(|a, b| b.duration_secs.cmp(&a.duration_secs)); - } - _ => {} // "sequential" / unset: keep provider order (episode order per series) - } +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))) +} - let response: Vec = items +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() - .take(limit) - .map(|item| LibraryItemResponse { - id: item.id.into_inner(), - title: item.title, - content_type: match item.content_type { - domain::ContentType::Movie => "movie".into(), - domain::ContentType::Episode => "episode".into(), - domain::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, + .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)) +} - Ok(Json(response)) +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 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)) } // ============================================================================ @@ -286,3 +374,22 @@ fn parse_content_type(s: Option<&str>) -> Result, ApiError> ))), } } + +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, + } +} diff --git a/k-tv-backend/api/src/routes/mod.rs b/k-tv-backend/api/src/routes/mod.rs index fa48157..7076cb4 100644 --- a/k-tv-backend/api/src/routes/mod.rs +++ b/k-tv-backend/api/src/routes/mod.rs @@ -25,4 +25,5 @@ pub fn api_v1_router() -> Router { .nest("/files", files::router()) .nest("/iptv", iptv::router()) .nest("/library", library::router()) + .nest("/admin/library", library::admin_router()) }