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.
|
||||
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 {
|
||||
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 channels;
|
||||
pub mod config;
|
||||
pub mod library;
|
||||
|
||||
/// Construct the API v1 router
|
||||
pub fn api_v1_router() -> Router<AppState> {
|
||||
@@ -15,4 +16,5 @@ pub fn api_v1_router() -> Router<AppState> {
|
||||
.nest("/auth", auth::router())
|
||||
.nest("/channels", channels::router())
|
||||
.nest("/config", config::router())
|
||||
.nest("/library", library::router())
|
||||
}
|
||||
|
||||
@@ -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<UserService>,
|
||||
pub channel_service: Arc<ChannelService>,
|
||||
pub schedule_engine: Arc<ScheduleEngineService>,
|
||||
pub media_provider: Arc<dyn IMediaProvider>,
|
||||
pub cookie_key: Key,
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
pub oidc_service: Option<Arc<OidcService>>,
|
||||
@@ -31,6 +32,7 @@ impl AppState {
|
||||
user_service: UserService,
|
||||
channel_service: ChannelService,
|
||||
schedule_engine: ScheduleEngineService,
|
||||
media_provider: Arc<dyn IMediaProvider>,
|
||||
config: Config,
|
||||
) -> anyhow::Result<Self> {
|
||||
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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<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.
|
||||
///
|
||||
/// 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<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,
|
||||
/// filesystem path, etc.). An empty list means "all available content".
|
||||
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.
|
||||
|
||||
@@ -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<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.
|
||||
///
|
||||
/// 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<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>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user