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:
2026-03-12 02:54:30 +01:00
parent f069376136
commit bf07a65dcd
14 changed files with 1005 additions and 86 deletions

View File

@@ -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(&params)
.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(&params)
.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>,
}
// ============================================================================