feat(api): replace live-provider library routes with DB-backed routes; add sync + admin settings endpoints
This commit is contained in:
@@ -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<String>) -> Self {
|
||||
Self::Internal(msg.into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "local-files")]
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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<AppState> {
|
||||
Router::new()
|
||||
@@ -27,6 +37,14 @@ pub fn router() -> Router<AppState> {
|
||||
.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<AppState> {
|
||||
Router::new()
|
||||
.route("/settings", get(get_settings).put(update_settings))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -41,38 +59,6 @@ struct CollectionResponse {
|
||||
collection_type: Option<String>,
|
||||
}
|
||||
|
||||
impl From<Collection> 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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
year: Option<u16>,
|
||||
}
|
||||
|
||||
impl From<SeriesSummary> 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<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 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>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -96,47 +103,33 @@ struct LibraryItemResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CollectionsQuery {
|
||||
/// Provider key to query (default: primary).
|
||||
provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeriesQuery {
|
||||
/// Scope results to a specific collection (provider library ID).
|
||||
collection: Option<String>,
|
||||
/// Provider key to query (default: primary).
|
||||
provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GenresQuery {
|
||||
/// Limit genres to a content type: "movie", "episode", or "short".
|
||||
#[serde(rename = "type")]
|
||||
content_type: Option<String>,
|
||||
/// Provider key to query (default: primary).
|
||||
provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ItemsQuery {
|
||||
/// Free-text search.
|
||||
q: Option<String>,
|
||||
/// Content type filter: "movie", "episode", or "short".
|
||||
#[serde(rename = "type")]
|
||||
content_type: Option<String>,
|
||||
/// Filter episodes by series name. Repeat the param for multiple series:
|
||||
/// `?series[]=iCarly&series[]=Victorious`
|
||||
#[serde(default)]
|
||||
series: Vec<String>,
|
||||
/// Scope to a provider collection ID.
|
||||
#[serde(default)]
|
||||
genres: Vec<String>,
|
||||
collection: Option<String>,
|
||||
/// Maximum number of results (default: 50, max: 200).
|
||||
limit: Option<usize>,
|
||||
/// 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<String>,
|
||||
/// Provider key to query (default: primary).
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
provider: Option<String>,
|
||||
}
|
||||
|
||||
@@ -144,130 +137,225 @@ struct ItemsQuery {
|
||||
// Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// List top-level collections (Jellyfin virtual libraries, Plex sections, etc.)
|
||||
async fn list_collections(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<CollectionsQuery>,
|
||||
) -> Result<Json<Vec<CollectionResponse>>, 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<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<SeriesQuery>,
|
||||
) -> Result<Json<Vec<SeriesResponse>>, 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<Json<Vec<String>>, 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<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<GenresQuery>,
|
||||
) -> Result<Json<Vec<String>>, 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<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
) -> Result<Json<Vec<LibraryItemResponse>>, ApiError> {
|
||||
let qs_config = serde_qs::Config::new(2, false); // non-strict: accept encoded brackets
|
||||
) -> 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 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?;
|
||||
|
||||
// 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)
|
||||
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 }))
|
||||
}
|
||||
|
||||
let response: Vec<LibraryItemResponse> = items
|
||||
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()
|
||||
.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<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 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))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -286,3 +374,22 @@ fn parse_content_type(s: Option<&str>) -> Result<Option<ContentType>, 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,5 @@ pub fn api_v1_router() -> Router<AppState> {
|
||||
.nest("/files", files::router())
|
||||
.nest("/iptv", iptv::router())
|
||||
.nest("/library", library::router())
|
||||
.nest("/admin/library", library::admin_router())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user