diff --git a/k-tv-backend/api/src/main.rs b/k-tv-backend/api/src/main.rs index 8a1a6bb..a40f772 100644 --- a/k-tv-backend/api/src/main.rs +++ b/k-tv-backend/api/src/main.rs @@ -72,9 +72,20 @@ async fn main() -> anyhow::Result<()> { // Build media provider — Jellyfin if configured, no-op fallback otherwise. let media_provider: Arc = 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 { cors_origins: config.cors_allowed_origins.clone(), diff --git a/k-tv-backend/api/src/routes/library.rs b/k-tv-backend/api/src/routes/library.rs new file mode 100644 index 0000000..1b10c49 --- /dev/null +++ b/k-tv-backend/api/src/routes/library.rs @@ -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 { + 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, +} + +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, + title: String, + content_type: String, + duration_secs: u32, + #[serde(skip_serializing_if = "Option::is_none")] + series_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + season_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + episode_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + year: Option, + genres: Vec, +} + +// ============================================================================ +// Query params +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct SeriesQuery { + /// Scope results to a specific collection (provider library ID). + collection: Option, +} + +#[derive(Debug, Deserialize)] +struct GenresQuery { + /// Limit genres to a content type: "movie", "episode", or "short". + #[serde(rename = "type")] + content_type: Option, +} + +#[derive(Debug, Deserialize)] +struct ItemsQuery { + /// Free-text search. + q: Option, + /// Content type filter: "movie", "episode", or "short". + #[serde(rename = "type")] + content_type: Option, + /// Filter episodes to a specific series name. + series: Option, + /// Scope to a provider collection ID. + collection: Option, + /// Maximum number of results (default: 50, max: 200). + limit: Option, +} + +// ============================================================================ +// Handlers +// ============================================================================ + +/// List top-level collections (Jellyfin virtual libraries, Plex sections, etc.) +async fn list_collections( + State(state): State, + CurrentUser(_user): CurrentUser, +) -> Result>, 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, + CurrentUser(_user): CurrentUser, + Query(params): Query, +) -> Result>, 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, + CurrentUser(_user): CurrentUser, + Query(params): Query, +) -> Result>, 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, + CurrentUser(_user): CurrentUser, + Query(params): Query, +) -> Result>, 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 = 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, 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 + ))), + } +} diff --git a/k-tv-backend/api/src/routes/mod.rs b/k-tv-backend/api/src/routes/mod.rs index 007b955..83d5bbc 100644 --- a/k-tv-backend/api/src/routes/mod.rs +++ b/k-tv-backend/api/src/routes/mod.rs @@ -8,6 +8,7 @@ use axum::Router; pub mod auth; pub mod channels; pub mod config; +pub mod library; /// Construct the API v1 router pub fn api_v1_router() -> Router { @@ -15,4 +16,5 @@ pub fn api_v1_router() -> Router { .nest("/auth", auth::router()) .nest("/channels", channels::router()) .nest("/config", config::router()) + .nest("/library", library::router()) } diff --git a/k-tv-backend/api/src/state.rs b/k-tv-backend/api/src/state.rs index 59af773..608a650 100644 --- a/k-tv-backend/api/src/state.rs +++ b/k-tv-backend/api/src/state.rs @@ -11,13 +11,14 @@ use infra::auth::oidc::OidcService; use std::sync::Arc; use crate::config::Config; -use domain::{ChannelService, ScheduleEngineService, UserService}; +use domain::{ChannelService, IMediaProvider, ScheduleEngineService, UserService}; #[derive(Clone)] pub struct AppState { pub user_service: Arc, pub channel_service: Arc, pub schedule_engine: Arc, + pub media_provider: Arc, pub cookie_key: Key, #[cfg(feature = "auth-oidc")] pub oidc_service: Option>, @@ -31,6 +32,7 @@ impl AppState { user_service: UserService, channel_service: ChannelService, schedule_engine: ScheduleEngineService, + media_provider: Arc, config: Config, ) -> anyhow::Result { let cookie_key = Key::derive_from(config.cookie_secret.as_bytes()); @@ -96,6 +98,7 @@ impl AppState { user_service: Arc::new(user_service), channel_service: Arc::new(channel_service), schedule_engine: Arc::new(schedule_engine), + media_provider, cookie_key, #[cfg(feature = "auth-oidc")] oidc_service, diff --git a/k-tv-backend/domain/src/lib.rs b/k-tv-backend/domain/src/lib.rs index 96928fa..9d23325 100644 --- a/k-tv-backend/domain/src/lib.rs +++ b/k-tv-backend/domain/src/lib.rs @@ -13,7 +13,7 @@ pub mod value_objects; // Re-export commonly used types pub use entities::*; pub use errors::{DomainError, DomainResult}; -pub use ports::IMediaProvider; +pub use ports::{Collection, IMediaProvider, SeriesSummary}; pub use repositories::*; pub use services::{ChannelService, ScheduleEngineService, UserService}; pub use value_objects::*; diff --git a/k-tv-backend/domain/src/ports.rs b/k-tv-backend/domain/src/ports.rs index 197e86b..c1adca6 100644 --- a/k-tv-backend/domain/src/ports.rs +++ b/k-tv-backend/domain/src/ports.rs @@ -6,15 +6,56 @@ //! these traits for each concrete source. use async_trait::async_trait; +use serde::{Deserialize, Serialize}; -use crate::entities::{MediaItem}; -use crate::errors::DomainResult; -use crate::value_objects::{MediaFilter, MediaItemId}; +use crate::entities::MediaItem; +use crate::errors::{DomainError, DomainResult}; +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, +} + +/// 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, + pub year: Option, +} + +// ============================================================================ +// Port trait +// ============================================================================ /// Port for reading media content from an external provider. /// /// Implementations live in the infra layer. One adapter per provider type /// (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] pub trait IMediaProvider: Send + Sync { /// 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 /// short-lived (signed URLs, session tokens) or depend on client context. async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult; + + /// 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> { + 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> { + 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> { + let _ = content_type; + Err(DomainError::InfrastructureError( + "list_genres is not supported by this provider".into(), + )) + } } diff --git a/k-tv-backend/domain/src/value_objects.rs b/k-tv-backend/domain/src/value_objects.rs index 79cf00b..f3d3afe 100644 --- a/k-tv-backend/domain/src/value_objects.rs +++ b/k-tv-backend/domain/src/value_objects.rs @@ -606,6 +606,12 @@ pub struct MediaFilter { /// Abstract groupings interpreted by each provider (Jellyfin library, Plex section, /// filesystem path, etc.). An empty list means "all available content". pub collections: Vec, + /// Filter by TV series name. Use with `content_type: Episode` and + /// `strategy: Sequential` for ordered series playback (e.g. "iCarly"). + pub series_name: Option, + /// Free-text search term. Intended for library browsing; typically omitted + /// during schedule generation. + pub search_term: Option, } /// How the scheduling engine fills a time block with selected media items. diff --git a/k-tv-backend/infra/src/jellyfin.rs b/k-tv-backend/infra/src/jellyfin.rs index 46b756a..a1f5ec6 100644 --- a/k-tv-backend/infra/src/jellyfin.rs +++ b/k-tv-backend/infra/src/jellyfin.rs @@ -9,7 +9,7 @@ use async_trait::async_trait; 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. const TICKS_PER_SEC: i64 = 10_000_000; @@ -97,6 +97,14 @@ impl IMediaProvider for JellyfinMediaProvider { 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 .client .get(&url) @@ -156,6 +164,152 @@ impl IMediaProvider for JellyfinMediaProvider { 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> { + 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> { + 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> { + 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. /// /// 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) #[serde(rename = "IndexNumber")] index_number: Option, + /// Collection type for virtual library folders (e.g. "movies", "tvshows") + #[serde(rename = "CollectionType")] + collection_type: Option, + /// Total number of child items (used for Series to count episodes) + #[serde(rename = "RecursiveItemCount")] + recursive_item_count: Option, } // ============================================================================ diff --git a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx index a354eed..9cab003 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx @@ -7,6 +7,9 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sh import { Button } from "@/components/ui/button"; import { TagInput } from "./tag-input"; 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 { ChannelResponse, ProgrammingBlock, @@ -35,6 +38,8 @@ const mediaFilterSchema = z.object({ min_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()), + series_name: z.string().nullable().optional(), + search_term: z.string().nullable().optional(), }); const blockSchema = z.object({ @@ -194,6 +199,8 @@ function defaultFilter(): MediaFilter { min_duration_secs: null, max_duration_secs: null, 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; + pfx: string; + errors: FieldErrors; + setFilter: (patch: Partial) => 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 ( +
+

Filter

+ +
+ + + 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, + }) + } + > + + + + + + + + setStrategy(v as FillStrategy)} + > + + + + + +
+ + {/* Series — only meaningful for episodes */} + {isEpisode && ( + + setFilter({ series_name: v })} + series={series ?? []} + isLoading={loadingSeries} + /> + + )} + + {/* Library — real collection names when the provider supports it */} + + {collections && collections.length > 0 ? ( + setFilter({ collections: v ? [v] : [] })} + > + + {collections.map((c) => ( + + ))} + + ) : ( + setFilter({ collections: v })} + placeholder="Library ID…" + /> + )} + + + {/* Genres with browse-from-library shortcut */} + + setFilter({ genres: v })} + placeholder="Comedy, Animation…" + /> + {genreOptions && genreOptions.length > 0 && ( +
+ + {showGenres && ( +
+ {genreOptions + .filter((g) => !content.filter.genres.includes(g)) + .map((g) => ( + + ))} +
+ )} +
+ )} +
+ + + setFilter({ tags: v })} + placeholder="classic, family…" + /> + + +
+ + setFilter({ decade: v === "" ? null : (v as number) })} + placeholder="1990" + error={!!errors[`${pfx}.content.filter.decade`]} + /> + + + + setFilter({ min_duration_secs: v === "" ? null : (v as number) }) + } + placeholder="1200" + error={!!errors[`${pfx}.content.filter.min_duration_secs`]} + /> + + + + setFilter({ max_duration_secs: v === "" ? null : (v as number) }) + } + placeholder="3600" + error={!!errors[`${pfx}.content.filter.max_duration_secs`]} + /> + +
+ + {/* Preview — snapshot of current filter, only fetches on explicit click */} + +
+ ); +} + // --------------------------------------------------------------------------- // BlockEditor (detail form for a single block) // --------------------------------------------------------------------------- @@ -333,84 +534,13 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo {content.type === "algorithmic" && ( -
-

Filter

- -
- - setFilter({ content_type: v === "" ? null : (v as ContentType) })} - > - - - - - - - - setStrategy(v as FillStrategy)} - > - - - - - -
- - - setFilter({ genres: v })} - placeholder="Comedy, Sci-Fi…" - /> - - - - setFilter({ tags: v })} - placeholder="classic, family…" - /> - - -
- - setFilter({ decade: v === "" ? null : (v as number) })} - placeholder="1990" - error={!!errors[`${pfx}.content.filter.decade`]} - /> - - - setFilter({ min_duration_secs: v === "" ? null : (v as number) })} - placeholder="1200" - error={!!errors[`${pfx}.content.filter.min_duration_secs`]} - /> - - - setFilter({ max_duration_secs: v === "" ? null : (v as number) })} - placeholder="3600" - error={!!errors[`${pfx}.content.filter.max_duration_secs`]} - /> - -
- - - setFilter({ collections: v })} - placeholder="abc123…" - /> - -
+ )} {content.type === "manual" && ( diff --git a/k-tv-frontend/app/(main)/dashboard/components/filter-preview.tsx b/k-tv-frontend/app/(main)/dashboard/components/filter-preview.tsx new file mode 100644 index 0000000..874f937 --- /dev/null +++ b/k-tv-frontend/app/(main)/dashboard/components/filter-preview.tsx @@ -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 ( +
  • + {label} + + {fmtDuration(item.duration_secs)} + +
  • + ); +} + +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(null); + + const { data: items, isFetching, isError } = useLibraryItems(snapshot, !!snapshot); + + const handlePreview = () => setSnapshot({ ...filter }); + + const filterChanged = + snapshot !== null && JSON.stringify(snapshot) !== JSON.stringify(filter); + + return ( +
    +
    + + {filterChanged && ( + Filter changed + )} + {isFetching && } +
    + + {isError && ( +

    Failed to load preview.

    + )} + + {items && !isFetching && ( +
    +

    + {items.length === 30 ? "First 30 matches" : `${items.length} match${items.length !== 1 ? "es" : ""}`} +

    + {items.length === 0 ? ( +

    No items match this filter.

    + ) : ( +
      + {items.map((item) => ( + + ))} +
    + )} +
    + )} +
    + ); +} diff --git a/k-tv-frontend/app/(main)/dashboard/components/series-picker.tsx b/k-tv-frontend/app/(main)/dashboard/components/series-picker.tsx new file mode 100644 index 0000000..d4b8dfa --- /dev/null +++ b/k-tv-frontend/app/(main)/dashboard/components/series-picker.tsx @@ -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(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 ( +
    + {value} + +
    + ); + } + + return ( +
    + { 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 && ( +
      + {filtered.map((s) => ( +
    • + +
    • + ))} +
    + )} + + {open && !isLoading && series.length === 0 && ( +
    + No series found in library. +
    + )} +
    + ); +} diff --git a/k-tv-frontend/hooks/use-library.ts b/k-tv-frontend/hooks/use-library.ts new file mode 100644 index 0000000..d25bc2c --- /dev/null +++ b/k-tv-frontend/hooks/use-library.ts @@ -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 | 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, + }); +} diff --git a/k-tv-frontend/lib/api.ts b/k-tv-frontend/lib/api.ts index b72c935..fdd5ec5 100644 --- a/k-tv-frontend/lib/api.ts +++ b/k-tv-frontend/lib/api.ts @@ -8,6 +8,10 @@ import type { ScheduleResponse, ScheduledSlotResponse, CurrentBroadcastResponse, + CollectionResponse, + SeriesResponse, + LibraryItemResponse, + MediaFilter, } from "@/lib/types"; const API_BASE = @@ -103,6 +107,39 @@ export const api = { request(`/channels/${id}`, { method: "DELETE", token }), }, + library: { + collections: (token: string) => + request("/library/collections", { token }), + + series: (token: string, collectionId?: string) => { + const params = new URLSearchParams(); + if (collectionId) params.set("collection", collectionId); + const qs = params.toString(); + return request(`/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(`/library/genres${qs ? `?${qs}` : ""}`, { token }); + }, + + items: ( + token: string, + filter: Pick, + 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(`/library/items?${params}`, { token }); + }, + }, + schedule: { generate: (channelId: string, token: string) => request(`/channels/${channelId}/schedule`, { diff --git a/k-tv-frontend/lib/types.ts b/k-tv-frontend/lib/types.ts index 2336579..342578b 100644 --- a/k-tv-frontend/lib/types.ts +++ b/k-tv-frontend/lib/types.ts @@ -12,6 +12,38 @@ export interface MediaFilter { min_duration_secs?: number | null; max_duration_secs?: number | null; 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 {