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:
@@ -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