feat(library): add media library browsing functionality
- Introduced new `library` module in the API routes to handle media library requests. - Enhanced `AppState` to include a media provider for library interactions. - Defined new `IMediaProvider` trait methods for listing collections, series, and genres. - Implemented Jellyfin media provider methods for fetching collections and series. - Added frontend components for selecting series and displaying filter previews. - Created hooks for fetching collections, series, and genres from the library. - Updated media filter to support series name and search term. - Enhanced API client to handle new library-related endpoints.
This commit is contained in:
@@ -72,9 +72,20 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Build media provider — Jellyfin if configured, no-op fallback otherwise.
|
// Build media provider — Jellyfin if configured, no-op fallback otherwise.
|
||||||
let media_provider: Arc<dyn IMediaProvider> = build_media_provider(&config);
|
let media_provider: Arc<dyn IMediaProvider> = build_media_provider(&config);
|
||||||
|
|
||||||
let schedule_engine = ScheduleEngineService::new(media_provider, channel_repo, schedule_repo);
|
let schedule_engine = ScheduleEngineService::new(
|
||||||
|
Arc::clone(&media_provider),
|
||||||
|
channel_repo,
|
||||||
|
schedule_repo,
|
||||||
|
);
|
||||||
|
|
||||||
let state = AppState::new(user_service, channel_service, schedule_engine, config.clone()).await?;
|
let state = AppState::new(
|
||||||
|
user_service,
|
||||||
|
channel_service,
|
||||||
|
schedule_engine,
|
||||||
|
media_provider,
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let server_config = ServerConfig {
|
let server_config = ServerConfig {
|
||||||
cors_origins: config.cors_allowed_origins.clone(),
|
cors_origins: config.cors_allowed_origins.clone(),
|
||||||
|
|||||||
221
k-tv-backend/api/src/routes/library.rs
Normal file
221
k-tv-backend/api/src/routes/library.rs
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
//! Library browsing routes
|
||||||
|
//!
|
||||||
|
//! 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 — 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)
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
|
extract::{Query, State},
|
||||||
|
routing::get,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use domain::{Collection, ContentType, MediaFilter, SeriesSummary};
|
||||||
|
|
||||||
|
use crate::{error::ApiError, extractors::CurrentUser, state::AppState};
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Response DTOs
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct CollectionResponse {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
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,
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Query params
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SeriesQuery {
|
||||||
|
/// Scope results to a specific collection (provider library ID).
|
||||||
|
collection: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GenresQuery {
|
||||||
|
/// Limit genres to a content type: "movie", "episode", or "short".
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
content_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, 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 to a specific series name.
|
||||||
|
series: Option<String>,
|
||||||
|
/// Scope to a provider collection ID.
|
||||||
|
collection: Option<String>,
|
||||||
|
/// Maximum number of results (default: 50, max: 200).
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Handlers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// List top-level collections (Jellyfin virtual libraries, Plex sections, etc.)
|
||||||
|
async fn list_collections(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(_user): CurrentUser,
|
||||||
|
) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
|
||||||
|
let collections = state.media_provider.list_collections().await?;
|
||||||
|
Ok(Json(collections.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 series = state
|
||||||
|
.media_provider
|
||||||
|
.list_series(params.collection.as_deref())
|
||||||
|
.await?;
|
||||||
|
Ok(Json(series.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 ct = parse_content_type(params.content_type.as_deref())?;
|
||||||
|
let genres = state.media_provider.list_genres(ct.as_ref()).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,
|
||||||
|
Query(params): Query<ItemsQuery>,
|
||||||
|
) -> Result<Json<Vec<LibraryItemResponse>>, ApiError> {
|
||||||
|
let limit = params.limit.unwrap_or(50).min(200);
|
||||||
|
|
||||||
|
let filter = MediaFilter {
|
||||||
|
content_type: parse_content_type(params.content_type.as_deref())?,
|
||||||
|
search_term: params.q,
|
||||||
|
series_name: params.series,
|
||||||
|
collections: params
|
||||||
|
.collection
|
||||||
|
.map(|c| vec![c])
|
||||||
|
.unwrap_or_default(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let items = state.media_provider.fetch_items(&filter).await?;
|
||||||
|
|
||||||
|
let response: Vec<LibraryItemResponse> = items
|
||||||
|
.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,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use axum::Router;
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod library;
|
||||||
|
|
||||||
/// Construct the API v1 router
|
/// Construct the API v1 router
|
||||||
pub fn api_v1_router() -> Router<AppState> {
|
pub fn api_v1_router() -> Router<AppState> {
|
||||||
@@ -15,4 +16,5 @@ pub fn api_v1_router() -> Router<AppState> {
|
|||||||
.nest("/auth", auth::router())
|
.nest("/auth", auth::router())
|
||||||
.nest("/channels", channels::router())
|
.nest("/channels", channels::router())
|
||||||
.nest("/config", config::router())
|
.nest("/config", config::router())
|
||||||
|
.nest("/library", library::router())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ use infra::auth::oidc::OidcService;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use domain::{ChannelService, ScheduleEngineService, UserService};
|
use domain::{ChannelService, IMediaProvider, ScheduleEngineService, UserService};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub user_service: Arc<UserService>,
|
pub user_service: Arc<UserService>,
|
||||||
pub channel_service: Arc<ChannelService>,
|
pub channel_service: Arc<ChannelService>,
|
||||||
pub schedule_engine: Arc<ScheduleEngineService>,
|
pub schedule_engine: Arc<ScheduleEngineService>,
|
||||||
|
pub media_provider: Arc<dyn IMediaProvider>,
|
||||||
pub cookie_key: Key,
|
pub cookie_key: Key,
|
||||||
#[cfg(feature = "auth-oidc")]
|
#[cfg(feature = "auth-oidc")]
|
||||||
pub oidc_service: Option<Arc<OidcService>>,
|
pub oidc_service: Option<Arc<OidcService>>,
|
||||||
@@ -31,6 +32,7 @@ impl AppState {
|
|||||||
user_service: UserService,
|
user_service: UserService,
|
||||||
channel_service: ChannelService,
|
channel_service: ChannelService,
|
||||||
schedule_engine: ScheduleEngineService,
|
schedule_engine: ScheduleEngineService,
|
||||||
|
media_provider: Arc<dyn IMediaProvider>,
|
||||||
config: Config,
|
config: Config,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
let cookie_key = Key::derive_from(config.cookie_secret.as_bytes());
|
let cookie_key = Key::derive_from(config.cookie_secret.as_bytes());
|
||||||
@@ -96,6 +98,7 @@ impl AppState {
|
|||||||
user_service: Arc::new(user_service),
|
user_service: Arc::new(user_service),
|
||||||
channel_service: Arc::new(channel_service),
|
channel_service: Arc::new(channel_service),
|
||||||
schedule_engine: Arc::new(schedule_engine),
|
schedule_engine: Arc::new(schedule_engine),
|
||||||
|
media_provider,
|
||||||
cookie_key,
|
cookie_key,
|
||||||
#[cfg(feature = "auth-oidc")]
|
#[cfg(feature = "auth-oidc")]
|
||||||
oidc_service,
|
oidc_service,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub mod value_objects;
|
|||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
pub use entities::*;
|
pub use entities::*;
|
||||||
pub use errors::{DomainError, DomainResult};
|
pub use errors::{DomainError, DomainResult};
|
||||||
pub use ports::IMediaProvider;
|
pub use ports::{Collection, IMediaProvider, SeriesSummary};
|
||||||
pub use repositories::*;
|
pub use repositories::*;
|
||||||
pub use services::{ChannelService, ScheduleEngineService, UserService};
|
pub use services::{ChannelService, ScheduleEngineService, UserService};
|
||||||
pub use value_objects::*;
|
pub use value_objects::*;
|
||||||
|
|||||||
@@ -6,15 +6,56 @@
|
|||||||
//! these traits for each concrete source.
|
//! these traits for each concrete source.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::entities::{MediaItem};
|
use crate::entities::MediaItem;
|
||||||
use crate::errors::DomainResult;
|
use crate::errors::{DomainError, DomainResult};
|
||||||
use crate::value_objects::{MediaFilter, MediaItemId};
|
use crate::value_objects::{ContentType, MediaFilter, MediaItemId};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Library browsing types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// A top-level media collection / library exposed by a provider.
|
||||||
|
///
|
||||||
|
/// In Jellyfin this maps to a virtual library (Movies, TV Shows, …).
|
||||||
|
/// In Plex it maps to a section. The `id` is provider-specific and is used
|
||||||
|
/// as the value for `MediaFilter::collections`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Collection {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
/// Provider-specific type hint, e.g. "movies", "tvshows". `None` when the
|
||||||
|
/// provider does not expose this information.
|
||||||
|
pub collection_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightweight summary of a TV series available in the provider's library.
|
||||||
|
/// Returned by `IMediaProvider::list_series` for the dashboard browser.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SeriesSummary {
|
||||||
|
/// Provider-specific series ID (opaque — used for ParentId filtering).
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
/// Total number of episodes across all seasons, if the provider exposes it.
|
||||||
|
pub episode_count: u32,
|
||||||
|
pub genres: Vec<String>,
|
||||||
|
pub year: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Port trait
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/// Port for reading media content from an external provider.
|
/// Port for reading media content from an external provider.
|
||||||
///
|
///
|
||||||
/// Implementations live in the infra layer. One adapter per provider type
|
/// Implementations live in the infra layer. One adapter per provider type
|
||||||
/// (e.g. `JellyfinMediaProvider`, `PlexMediaProvider`, `LocalFileProvider`).
|
/// (e.g. `JellyfinMediaProvider`, `PlexMediaProvider`, `LocalFileProvider`).
|
||||||
|
///
|
||||||
|
/// The three browsing methods (`list_collections`, `list_series`, `list_genres`)
|
||||||
|
/// have default implementations that return an `InfrastructureError`. Adapters
|
||||||
|
/// that support library browsing override them; those that don't (e.g. the
|
||||||
|
/// `NoopMediaProvider`) inherit the default and return a clear error.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait IMediaProvider: Send + Sync {
|
pub trait IMediaProvider: Send + Sync {
|
||||||
/// Fetch metadata for all items matching `filter` from this provider.
|
/// Fetch metadata for all items matching `filter` from this provider.
|
||||||
@@ -36,4 +77,38 @@ pub trait IMediaProvider: Send + Sync {
|
|||||||
/// URLs are intentionally *not* stored in the schedule because they may be
|
/// URLs are intentionally *not* stored in the schedule because they may be
|
||||||
/// short-lived (signed URLs, session tokens) or depend on client context.
|
/// short-lived (signed URLs, session tokens) or depend on client context.
|
||||||
async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult<String>;
|
async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult<String>;
|
||||||
|
|
||||||
|
/// List top-level collections (libraries/sections) available in this provider.
|
||||||
|
///
|
||||||
|
/// Used by the dashboard to populate the collections picker so users don't
|
||||||
|
/// need to know provider-internal IDs.
|
||||||
|
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
|
||||||
|
Err(DomainError::InfrastructureError(
|
||||||
|
"list_collections is not supported by this provider".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List TV series available in an optional collection.
|
||||||
|
///
|
||||||
|
/// `collection_id` corresponds to `Collection::id` returned by
|
||||||
|
/// `list_collections`. Pass `None` to search across all libraries.
|
||||||
|
async fn list_series(&self, collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>> {
|
||||||
|
let _ = collection_id;
|
||||||
|
Err(DomainError::InfrastructureError(
|
||||||
|
"list_series is not supported by this provider".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all genres available for a given content type.
|
||||||
|
///
|
||||||
|
/// Pass `None` to return genres across all content types.
|
||||||
|
async fn list_genres(
|
||||||
|
&self,
|
||||||
|
content_type: Option<&ContentType>,
|
||||||
|
) -> DomainResult<Vec<String>> {
|
||||||
|
let _ = content_type;
|
||||||
|
Err(DomainError::InfrastructureError(
|
||||||
|
"list_genres is not supported by this provider".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -606,6 +606,12 @@ pub struct MediaFilter {
|
|||||||
/// Abstract groupings interpreted by each provider (Jellyfin library, Plex section,
|
/// Abstract groupings interpreted by each provider (Jellyfin library, Plex section,
|
||||||
/// filesystem path, etc.). An empty list means "all available content".
|
/// filesystem path, etc.). An empty list means "all available content".
|
||||||
pub collections: Vec<String>,
|
pub collections: Vec<String>,
|
||||||
|
/// Filter by TV series name. Use with `content_type: Episode` and
|
||||||
|
/// `strategy: Sequential` for ordered series playback (e.g. "iCarly").
|
||||||
|
pub series_name: Option<String>,
|
||||||
|
/// Free-text search term. Intended for library browsing; typically omitted
|
||||||
|
/// during schedule generation.
|
||||||
|
pub search_term: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How the scheduling engine fills a time block with selected media items.
|
/// How the scheduling engine fills a time block with selected media items.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use domain::{ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem, MediaItemId};
|
use domain::{Collection, ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem, MediaItemId, SeriesSummary};
|
||||||
|
|
||||||
/// Ticks are Jellyfin's time unit: 1 tick = 100 nanoseconds → 10,000,000 ticks/sec.
|
/// Ticks are Jellyfin's time unit: 1 tick = 100 nanoseconds → 10,000,000 ticks/sec.
|
||||||
const TICKS_PER_SEC: i64 = 10_000_000;
|
const TICKS_PER_SEC: i64 = 10_000_000;
|
||||||
@@ -97,6 +97,14 @@ impl IMediaProvider for JellyfinMediaProvider {
|
|||||||
params.push(("ParentId", parent_id.clone()));
|
params.push(("ParentId", parent_id.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(series_name) = &filter.series_name {
|
||||||
|
params.push(("SeriesName", series_name.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(q) = &filter.search_term {
|
||||||
|
params.push(("SearchTerm", q.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
@@ -156,6 +164,152 @@ impl IMediaProvider for JellyfinMediaProvider {
|
|||||||
Ok(body.items.into_iter().next().and_then(map_jellyfin_item))
|
Ok(body.items.into_iter().next().and_then(map_jellyfin_item))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List top-level virtual libraries available to the configured user.
|
||||||
|
///
|
||||||
|
/// Uses the `/Users/{userId}/Views` endpoint which returns exactly the
|
||||||
|
/// top-level nodes the user has access to (Movies, TV Shows, etc.).
|
||||||
|
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/Users/{}/Views",
|
||||||
|
self.config.base_url, self.config.user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Emby-Token", &self.config.api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Jellyfin request failed: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(DomainError::InfrastructureError(format!(
|
||||||
|
"Jellyfin returned HTTP {}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(body
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| Collection {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
collection_type: item.collection_type,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all Series items, optionally scoped to a collection (ParentId).
|
||||||
|
///
|
||||||
|
/// Results are sorted alphabetically. `RecursiveItemCount` gives the total
|
||||||
|
/// episode count across all seasons without a second round-trip.
|
||||||
|
async fn list_series(&self, collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/Users/{}/Items",
|
||||||
|
self.config.base_url, self.config.user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut params: Vec<(&str, String)> = vec![
|
||||||
|
("Recursive", "true".into()),
|
||||||
|
("IncludeItemTypes", "Series".into()),
|
||||||
|
(
|
||||||
|
"Fields",
|
||||||
|
"Genres,ProductionYear,RecursiveItemCount".into(),
|
||||||
|
),
|
||||||
|
("SortBy", "SortName".into()),
|
||||||
|
("SortOrder", "Ascending".into()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(id) = collection_id {
|
||||||
|
params.push(("ParentId", id.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Emby-Token", &self.config.api_key)
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Jellyfin request failed: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(DomainError::InfrastructureError(format!(
|
||||||
|
"Jellyfin returned HTTP {}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(body
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| SeriesSummary {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
episode_count: item.recursive_item_count.unwrap_or(0),
|
||||||
|
genres: item.genres.unwrap_or_default(),
|
||||||
|
year: item.production_year,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List available genres from the Jellyfin `/Genres` endpoint.
|
||||||
|
///
|
||||||
|
/// Optionally filtered to a specific content type (Movie or Episode).
|
||||||
|
async fn list_genres(
|
||||||
|
&self,
|
||||||
|
content_type: Option<&ContentType>,
|
||||||
|
) -> DomainResult<Vec<String>> {
|
||||||
|
let url = format!("{}/Genres", self.config.base_url);
|
||||||
|
|
||||||
|
let mut params: Vec<(&str, String)> = vec![
|
||||||
|
("UserId", self.config.user_id.clone()),
|
||||||
|
("SortBy", "SortName".into()),
|
||||||
|
("SortOrder", "Ascending".into()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(ct) = content_type {
|
||||||
|
params.push(("IncludeItemTypes", jellyfin_item_type(ct).into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Emby-Token", &self.config.api_key)
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Jellyfin request failed: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(DomainError::InfrastructureError(format!(
|
||||||
|
"Jellyfin returned HTTP {}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
|
||||||
|
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(body.items.into_iter().map(|item| item.name).collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Build an HLS stream URL for a Jellyfin item.
|
/// Build an HLS stream URL for a Jellyfin item.
|
||||||
///
|
///
|
||||||
/// Returns a `master.m3u8` playlist URL. Jellyfin transcodes to H.264/AAC
|
/// Returns a `master.m3u8` playlist URL. Jellyfin transcodes to H.264/AAC
|
||||||
@@ -215,6 +369,12 @@ struct JellyfinItem {
|
|||||||
/// Episode number within the season (episodes only)
|
/// Episode number within the season (episodes only)
|
||||||
#[serde(rename = "IndexNumber")]
|
#[serde(rename = "IndexNumber")]
|
||||||
index_number: Option<u32>,
|
index_number: Option<u32>,
|
||||||
|
/// Collection type for virtual library folders (e.g. "movies", "tvshows")
|
||||||
|
#[serde(rename = "CollectionType")]
|
||||||
|
collection_type: Option<String>,
|
||||||
|
/// Total number of child items (used for Series to count episodes)
|
||||||
|
#[serde(rename = "RecursiveItemCount")]
|
||||||
|
recursive_item_count: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sh
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TagInput } from "./tag-input";
|
import { TagInput } from "./tag-input";
|
||||||
import { BlockTimeline, BLOCK_COLORS, timeToMins, minsToTime } from "./block-timeline";
|
import { BlockTimeline, BLOCK_COLORS, timeToMins, minsToTime } from "./block-timeline";
|
||||||
|
import { SeriesPicker } from "./series-picker";
|
||||||
|
import { FilterPreview } from "./filter-preview";
|
||||||
|
import { useCollections, useSeries, useGenres } from "@/hooks/use-library";
|
||||||
import type {
|
import type {
|
||||||
ChannelResponse,
|
ChannelResponse,
|
||||||
ProgrammingBlock,
|
ProgrammingBlock,
|
||||||
@@ -35,6 +38,8 @@ const mediaFilterSchema = z.object({
|
|||||||
min_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
|
min_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
|
||||||
max_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
|
max_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
|
||||||
collections: z.array(z.string()),
|
collections: z.array(z.string()),
|
||||||
|
series_name: z.string().nullable().optional(),
|
||||||
|
search_term: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const blockSchema = z.object({
|
const blockSchema = z.object({
|
||||||
@@ -194,6 +199,8 @@ function defaultFilter(): MediaFilter {
|
|||||||
min_duration_secs: null,
|
min_duration_secs: null,
|
||||||
max_duration_secs: null,
|
max_duration_secs: null,
|
||||||
collections: [],
|
collections: [],
|
||||||
|
series_name: null,
|
||||||
|
search_term: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +214,200 @@ function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AlgorithmicFilterEditor — filter section with live library data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AlgorithmicFilterEditorProps {
|
||||||
|
content: Extract<BlockContent, { type: "algorithmic" }>;
|
||||||
|
pfx: string;
|
||||||
|
errors: FieldErrors;
|
||||||
|
setFilter: (patch: Partial<MediaFilter>) => void;
|
||||||
|
setStrategy: (strategy: FillStrategy) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlgorithmicFilterEditor({
|
||||||
|
content,
|
||||||
|
pfx,
|
||||||
|
errors,
|
||||||
|
setFilter,
|
||||||
|
setStrategy,
|
||||||
|
}: AlgorithmicFilterEditorProps) {
|
||||||
|
const [showGenres, setShowGenres] = useState(false);
|
||||||
|
|
||||||
|
const { data: collections, isLoading: loadingCollections } = useCollections();
|
||||||
|
const { data: series, isLoading: loadingSeries } = useSeries();
|
||||||
|
const { data: genreOptions } = useGenres(content.filter.content_type ?? undefined);
|
||||||
|
|
||||||
|
const isEpisode = content.filter.content_type === "episode";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Filter</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Media type">
|
||||||
|
<NativeSelect
|
||||||
|
value={content.filter.content_type ?? ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
setFilter({
|
||||||
|
content_type: v === "" ? null : (v as ContentType),
|
||||||
|
// clear series name if switching away from episode
|
||||||
|
series_name: v !== "episode" ? null : content.filter.series_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="movie">Movie</option>
|
||||||
|
<option value="episode">Episode</option>
|
||||||
|
<option value="short">Short</option>
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
<Field label="Strategy">
|
||||||
|
<NativeSelect
|
||||||
|
value={content.strategy}
|
||||||
|
onChange={(v) => setStrategy(v as FillStrategy)}
|
||||||
|
>
|
||||||
|
<option value="random">Random</option>
|
||||||
|
<option value="best_fit">Best fit</option>
|
||||||
|
<option value="sequential">Sequential</option>
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Series — only meaningful for episodes */}
|
||||||
|
{isEpisode && (
|
||||||
|
<Field
|
||||||
|
label="Series"
|
||||||
|
hint={
|
||||||
|
content.strategy === "sequential"
|
||||||
|
? "Episodes will play in chronological order"
|
||||||
|
: "Filter to one show, or leave empty for all"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SeriesPicker
|
||||||
|
value={content.filter.series_name ?? null}
|
||||||
|
onChange={(v) => setFilter({ series_name: v })}
|
||||||
|
series={series ?? []}
|
||||||
|
isLoading={loadingSeries}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Library — real collection names when the provider supports it */}
|
||||||
|
<Field
|
||||||
|
label="Library"
|
||||||
|
hint={
|
||||||
|
loadingCollections
|
||||||
|
? "Loading libraries…"
|
||||||
|
: collections
|
||||||
|
? "Scope this block to one library"
|
||||||
|
: "Enter a provider library ID"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{collections && collections.length > 0 ? (
|
||||||
|
<NativeSelect
|
||||||
|
value={content.filter.collections[0] ?? ""}
|
||||||
|
onChange={(v) => setFilter({ collections: v ? [v] : [] })}
|
||||||
|
>
|
||||||
|
<option value="">All libraries</option>
|
||||||
|
{collections.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
{c.collection_type ? ` (${c.collection_type})` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
) : (
|
||||||
|
<TagInput
|
||||||
|
values={content.filter.collections}
|
||||||
|
onChange={(v) => setFilter({ collections: v })}
|
||||||
|
placeholder="Library ID…"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Genres with browse-from-library shortcut */}
|
||||||
|
<Field label="Genres" hint="Press Enter or comma to add">
|
||||||
|
<TagInput
|
||||||
|
values={content.filter.genres}
|
||||||
|
onChange={(v) => setFilter({ genres: v })}
|
||||||
|
placeholder="Comedy, Animation…"
|
||||||
|
/>
|
||||||
|
{genreOptions && genreOptions.length > 0 && (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowGenres((s) => !s)}
|
||||||
|
className="text-[11px] text-zinc-600 hover:text-zinc-400"
|
||||||
|
>
|
||||||
|
{showGenres ? "Hide" : "Browse"} available genres
|
||||||
|
</button>
|
||||||
|
{showGenres && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{genreOptions
|
||||||
|
.filter((g) => !content.filter.genres.includes(g))
|
||||||
|
.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter({ genres: [...content.filter.genres, g] })}
|
||||||
|
className="rounded px-1.5 py-0.5 text-[11px] bg-zinc-700/50 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
+ {g}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Tags" hint="Press Enter or comma to add">
|
||||||
|
<TagInput
|
||||||
|
values={content.filter.tags}
|
||||||
|
onChange={(v) => setFilter({ tags: v })}
|
||||||
|
placeholder="classic, family…"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Field label="Decade" hint="e.g. 1990" error={errors[`${pfx}.content.filter.decade`]}>
|
||||||
|
<NumberInput
|
||||||
|
value={content.filter.decade ?? ""}
|
||||||
|
onChange={(v) => setFilter({ decade: v === "" ? null : (v as number) })}
|
||||||
|
placeholder="1990"
|
||||||
|
error={!!errors[`${pfx}.content.filter.decade`]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Min duration (s)" error={errors[`${pfx}.content.filter.min_duration_secs`]}>
|
||||||
|
<NumberInput
|
||||||
|
value={content.filter.min_duration_secs ?? ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
setFilter({ min_duration_secs: v === "" ? null : (v as number) })
|
||||||
|
}
|
||||||
|
placeholder="1200"
|
||||||
|
error={!!errors[`${pfx}.content.filter.min_duration_secs`]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Max duration (s)" error={errors[`${pfx}.content.filter.max_duration_secs`]}>
|
||||||
|
<NumberInput
|
||||||
|
value={content.filter.max_duration_secs ?? ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
setFilter({ max_duration_secs: v === "" ? null : (v as number) })
|
||||||
|
}
|
||||||
|
placeholder="3600"
|
||||||
|
error={!!errors[`${pfx}.content.filter.max_duration_secs`]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview — snapshot of current filter, only fetches on explicit click */}
|
||||||
|
<FilterPreview filter={content.filter} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// BlockEditor (detail form for a single block)
|
// BlockEditor (detail form for a single block)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -333,84 +534,13 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{content.type === "algorithmic" && (
|
{content.type === "algorithmic" && (
|
||||||
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
<AlgorithmicFilterEditor
|
||||||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Filter</p>
|
content={content}
|
||||||
|
pfx={pfx}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
errors={errors}
|
||||||
<Field label="Media type">
|
setFilter={setFilter}
|
||||||
<NativeSelect
|
setStrategy={setStrategy}
|
||||||
value={content.filter.content_type ?? ""}
|
/>
|
||||||
onChange={(v) => setFilter({ content_type: v === "" ? null : (v as ContentType) })}
|
|
||||||
>
|
|
||||||
<option value="">Any</option>
|
|
||||||
<option value="movie">Movie</option>
|
|
||||||
<option value="episode">Episode</option>
|
|
||||||
<option value="short">Short</option>
|
|
||||||
</NativeSelect>
|
|
||||||
</Field>
|
|
||||||
<Field label="Strategy">
|
|
||||||
<NativeSelect
|
|
||||||
value={content.strategy}
|
|
||||||
onChange={(v) => setStrategy(v as FillStrategy)}
|
|
||||||
>
|
|
||||||
<option value="random">Random</option>
|
|
||||||
<option value="best_fit">Best fit</option>
|
|
||||||
<option value="sequential">Sequential</option>
|
|
||||||
</NativeSelect>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field label="Genres" hint="Press Enter or comma to add">
|
|
||||||
<TagInput
|
|
||||||
values={content.filter.genres}
|
|
||||||
onChange={(v) => setFilter({ genres: v })}
|
|
||||||
placeholder="Comedy, Sci-Fi…"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Tags" hint="Press Enter or comma to add">
|
|
||||||
<TagInput
|
|
||||||
values={content.filter.tags}
|
|
||||||
onChange={(v) => setFilter({ tags: v })}
|
|
||||||
placeholder="classic, family…"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<Field label="Decade" hint="e.g. 1990" error={errors[`${pfx}.content.filter.decade`]}>
|
|
||||||
<NumberInput
|
|
||||||
value={content.filter.decade ?? ""}
|
|
||||||
onChange={(v) => setFilter({ decade: v === "" ? null : (v as number) })}
|
|
||||||
placeholder="1990"
|
|
||||||
error={!!errors[`${pfx}.content.filter.decade`]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Min duration (s)" error={errors[`${pfx}.content.filter.min_duration_secs`]}>
|
|
||||||
<NumberInput
|
|
||||||
value={content.filter.min_duration_secs ?? ""}
|
|
||||||
onChange={(v) => setFilter({ min_duration_secs: v === "" ? null : (v as number) })}
|
|
||||||
placeholder="1200"
|
|
||||||
error={!!errors[`${pfx}.content.filter.min_duration_secs`]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Max duration (s)" error={errors[`${pfx}.content.filter.max_duration_secs`]}>
|
|
||||||
<NumberInput
|
|
||||||
value={content.filter.max_duration_secs ?? ""}
|
|
||||||
onChange={(v) => setFilter({ max_duration_secs: v === "" ? null : (v as number) })}
|
|
||||||
placeholder="3600"
|
|
||||||
error={!!errors[`${pfx}.content.filter.max_duration_secs`]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field label="Collections" hint="Jellyfin library IDs">
|
|
||||||
<TagInput
|
|
||||||
values={content.filter.collections}
|
|
||||||
onChange={(v) => setFilter({ collections: v })}
|
|
||||||
placeholder="abc123…"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{content.type === "manual" && (
|
{content.type === "manual" && (
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useLibraryItems } from "@/hooks/use-library";
|
||||||
|
import type { MediaFilter, LibraryItemResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
interface FilterPreviewProps {
|
||||||
|
filter: MediaFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(secs: number): string {
|
||||||
|
const h = Math.floor(secs / 3600);
|
||||||
|
const m = Math.floor((secs % 3600) / 60);
|
||||||
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemRow({ item }: { item: LibraryItemResponse }) {
|
||||||
|
const label =
|
||||||
|
item.content_type === "episode" && item.series_name
|
||||||
|
? `${item.series_name}${item.season_number != null ? ` S${item.season_number}` : ""}${item.episode_number != null ? `E${String(item.episode_number).padStart(2, "0")}` : ""} – ${item.title}`
|
||||||
|
: item.title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex items-baseline justify-between gap-3 py-1.5">
|
||||||
|
<span className="truncate text-xs text-zinc-300">{label}</span>
|
||||||
|
<span className="shrink-0 font-mono text-[11px] text-zinc-600">
|
||||||
|
{fmtDuration(item.duration_secs)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterPreview({ filter }: FilterPreviewProps) {
|
||||||
|
// Snapshot of filter at the moment "Preview" was clicked, so edits to the
|
||||||
|
// filter don't silently re-fetch while the user is still configuring.
|
||||||
|
const [snapshot, setSnapshot] = useState<MediaFilter | null>(null);
|
||||||
|
|
||||||
|
const { data: items, isFetching, isError } = useLibraryItems(snapshot, !!snapshot);
|
||||||
|
|
||||||
|
const handlePreview = () => setSnapshot({ ...filter });
|
||||||
|
|
||||||
|
const filterChanged =
|
||||||
|
snapshot !== null && JSON.stringify(snapshot) !== JSON.stringify(filter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePreview}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-800/60 px-2.5 py-1 text-[11px] text-zinc-400 hover:border-zinc-600 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
{snapshot ? "Refresh preview" : "Preview results"}
|
||||||
|
</button>
|
||||||
|
{filterChanged && (
|
||||||
|
<span className="text-[11px] text-amber-500">Filter changed</span>
|
||||||
|
)}
|
||||||
|
{isFetching && <Loader2 className="size-3 animate-spin text-zinc-600" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<p className="text-[11px] text-red-400">Failed to load preview.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items && !isFetching && (
|
||||||
|
<div className="rounded-md border border-zinc-700/60 bg-zinc-800/30 px-3 py-1">
|
||||||
|
<p className="pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wider text-zinc-600">
|
||||||
|
{items.length === 30 ? "First 30 matches" : `${items.length} match${items.length !== 1 ? "es" : ""}`}
|
||||||
|
</p>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="py-1.5 text-xs text-zinc-600">No items match this filter.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-zinc-800">
|
||||||
|
{items.map((item) => (
|
||||||
|
<ItemRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import type { SeriesResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
interface SeriesPickerProps {
|
||||||
|
value: string | null;
|
||||||
|
onChange: (v: string | null) => void;
|
||||||
|
series: SeriesResponse[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeriesPicker({ value, onChange, series, isLoading }: SeriesPickerProps) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const filtered = search.trim()
|
||||||
|
? series.filter((s) => s.name.toLowerCase().includes(search.toLowerCase())).slice(0, 40)
|
||||||
|
: series.slice(0, 40);
|
||||||
|
|
||||||
|
const handleSelect = (name: string) => {
|
||||||
|
onChange(name);
|
||||||
|
setSearch("");
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onChange(null);
|
||||||
|
setSearch("");
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delay blur so clicks inside the dropdown register before closing
|
||||||
|
const handleBlur = () => setTimeout(() => setOpen(false), 150);
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2">
|
||||||
|
<span className="flex-1 truncate text-sm text-zinc-100">{value}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="shrink-0 text-zinc-500 hover:text-zinc-300"
|
||||||
|
aria-label="Clear series"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
placeholder={isLoading ? "Loading series…" : "Search series…"}
|
||||||
|
disabled={isLoading}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{open && filtered.length > 0 && (
|
||||||
|
<ul className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-md border border-zinc-700 bg-zinc-900 py-1 shadow-xl">
|
||||||
|
{filtered.map((s) => (
|
||||||
|
<li key={s.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={() => handleSelect(s.name)}
|
||||||
|
className="flex w-full items-baseline gap-2 px-3 py-1.5 text-left hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<span className="truncate text-sm text-zinc-100">{s.name}</span>
|
||||||
|
<span className="shrink-0 font-mono text-[11px] text-zinc-600">
|
||||||
|
{s.episode_count} ep{s.episode_count !== 1 ? "s" : ""}
|
||||||
|
{s.year ? ` · ${s.year}` : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open && !isLoading && series.length === 0 && (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-500 shadow-xl">
|
||||||
|
No series found in library.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
k-tv-frontend/hooks/use-library.ts
Normal file
62
k-tv-frontend/hooks/use-library.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuthContext } from "@/context/auth-context";
|
||||||
|
import type { MediaFilter } from "@/lib/types";
|
||||||
|
|
||||||
|
const STALE = 10 * 60 * 1000; // 10 min — library metadata rarely changes in a session
|
||||||
|
|
||||||
|
/** List top-level collections (Jellyfin libraries, Plex sections, etc.) */
|
||||||
|
export function useCollections() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["library", "collections"],
|
||||||
|
queryFn: () => api.library.collections(token!),
|
||||||
|
enabled: !!token,
|
||||||
|
staleTime: STALE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List TV series, optionally scoped to a collection.
|
||||||
|
* All series are loaded upfront so the series picker can filter client-side
|
||||||
|
* without a request per keystroke.
|
||||||
|
*/
|
||||||
|
export function useSeries(collectionId?: string) {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["library", "series", collectionId ?? null],
|
||||||
|
queryFn: () => api.library.series(token!, collectionId),
|
||||||
|
enabled: !!token,
|
||||||
|
staleTime: STALE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List available genres, optionally scoped to a content type. */
|
||||||
|
export function useGenres(contentType?: string) {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["library", "genres", contentType ?? null],
|
||||||
|
queryFn: () => api.library.genres(token!, contentType),
|
||||||
|
enabled: !!token,
|
||||||
|
staleTime: STALE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch items matching a filter for the block editor's "Preview results" panel.
|
||||||
|
* Pass `enabled: false` until the user explicitly requests a preview.
|
||||||
|
*/
|
||||||
|
export function useLibraryItems(
|
||||||
|
filter: Pick<MediaFilter, "content_type" | "series_name" | "collections" | "search_term" | "genres"> | null,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["library", "items", filter],
|
||||||
|
queryFn: () => api.library.items(token!, filter!, 30),
|
||||||
|
enabled: !!token && enabled && !!filter,
|
||||||
|
staleTime: 2 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import type {
|
|||||||
ScheduleResponse,
|
ScheduleResponse,
|
||||||
ScheduledSlotResponse,
|
ScheduledSlotResponse,
|
||||||
CurrentBroadcastResponse,
|
CurrentBroadcastResponse,
|
||||||
|
CollectionResponse,
|
||||||
|
SeriesResponse,
|
||||||
|
LibraryItemResponse,
|
||||||
|
MediaFilter,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
const API_BASE =
|
const API_BASE =
|
||||||
@@ -103,6 +107,39 @@ export const api = {
|
|||||||
request<void>(`/channels/${id}`, { method: "DELETE", token }),
|
request<void>(`/channels/${id}`, { method: "DELETE", token }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
library: {
|
||||||
|
collections: (token: string) =>
|
||||||
|
request<CollectionResponse[]>("/library/collections", { token }),
|
||||||
|
|
||||||
|
series: (token: string, collectionId?: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (collectionId) params.set("collection", collectionId);
|
||||||
|
const qs = params.toString();
|
||||||
|
return request<SeriesResponse[]>(`/library/series${qs ? `?${qs}` : ""}`, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
genres: (token: string, contentType?: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (contentType) params.set("type", contentType);
|
||||||
|
const qs = params.toString();
|
||||||
|
return request<string[]>(`/library/genres${qs ? `?${qs}` : ""}`, { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
items: (
|
||||||
|
token: string,
|
||||||
|
filter: Pick<MediaFilter, "content_type" | "series_name" | "collections" | "search_term" | "genres">,
|
||||||
|
limit = 50,
|
||||||
|
) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filter.search_term) params.set("q", filter.search_term);
|
||||||
|
if (filter.content_type) params.set("type", filter.content_type);
|
||||||
|
if (filter.series_name) params.set("series", filter.series_name);
|
||||||
|
if (filter.collections?.[0]) params.set("collection", filter.collections[0]);
|
||||||
|
params.set("limit", String(limit));
|
||||||
|
return request<LibraryItemResponse[]>(`/library/items?${params}`, { token });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
schedule: {
|
schedule: {
|
||||||
generate: (channelId: string, token: string) =>
|
generate: (channelId: string, token: string) =>
|
||||||
request<ScheduleResponse>(`/channels/${channelId}/schedule`, {
|
request<ScheduleResponse>(`/channels/${channelId}/schedule`, {
|
||||||
|
|||||||
@@ -12,6 +12,38 @@ export interface MediaFilter {
|
|||||||
min_duration_secs?: number | null;
|
min_duration_secs?: number | null;
|
||||||
max_duration_secs?: number | null;
|
max_duration_secs?: number | null;
|
||||||
collections: string[];
|
collections: string[];
|
||||||
|
/** Filter by TV series name, e.g. "iCarly". Use with Sequential strategy. */
|
||||||
|
series_name?: string | null;
|
||||||
|
/** Free-text search, used for library browsing only. */
|
||||||
|
search_term?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library browsing
|
||||||
|
|
||||||
|
export interface CollectionResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
collection_type?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeriesResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
episode_count: number;
|
||||||
|
genres: string[];
|
||||||
|
year?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryItemResponse {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content_type: ContentType;
|
||||||
|
duration_secs: number;
|
||||||
|
series_name?: string | null;
|
||||||
|
season_number?: number | null;
|
||||||
|
episode_number?: number | null;
|
||||||
|
year?: number | null;
|
||||||
|
genres: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecyclePolicy {
|
export interface RecyclePolicy {
|
||||||
|
|||||||
Reference in New Issue
Block a user