use async_trait::async_trait; use domain::ports::{Collection, IMediaProvider, ProviderCapabilities, SeriesSummary}; use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, SourceUri}; use crate::config::JellyfinConfig; use crate::mapping::{map_jellyfin_item, TICKS_PER_SEC}; use crate::models::{jellyfin_item_type, JellyfinItemsResponse}; pub struct JellyfinMediaProvider { client: reqwest::Client, config: JellyfinConfig, } impl JellyfinMediaProvider { pub fn new(config: JellyfinConfig) -> Self { Self { client: reqwest::Client::new(), config: JellyfinConfig { base_url: config.base_url.trim_end_matches('/').to_string(), ..config }, } } async fn fetch_items_for_series( &self, filter: &MediaFilter, series_name: 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()), ( "Fields", "Genres,Tags,RunTimeTicks,ProductionYear,Overview".into(), ), ]; if let Some(ct) = &filter.content_type { params.push(("IncludeItemTypes", jellyfin_item_type(ct).into())); } if !filter.genres.is_empty() { params.push(("Genres", filter.genres.join("|"))); } if let Some(decade) = filter.decade { params.push(("MinYear", decade.to_string())); params.push(("MaxYear", (decade + 9).to_string())); } if !filter.tags.is_empty() { params.push(("Tags", filter.tags.join("|"))); } if let Some(min) = filter.min_duration_secs { params.push(("MinRunTimeTicks", (min as i64 * TICKS_PER_SEC).to_string())); } if let Some(max) = filter.max_duration_secs { params.push(("MaxRunTimeTicks", (max as i64 * TICKS_PER_SEC).to_string())); } if let Some(name) = series_name { params.push(("SeriesName", name.to_string())); params.push(("SortBy", "ParentIndexNumber,IndexNumber".into())); params.push(("SortOrder", "Ascending".into())); if filter.content_type.is_none() { params.push(("IncludeItemTypes", "Episode".into())); } } else { if let Some(parent_id) = filter.collections.first() { params.push(("ParentId", parent_id.clone())); } } if let Some(q) = &filter.search_term { params.push(("SearchTerm", q.clone())); } 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}")) })?; // WHY: Jellyfin's SeriesName query param is a fuzzy match that can return // items from other shows; post-filter to guarantee correctness. let items = body.items.into_iter().filter_map(map_jellyfin_item); let items: Vec = if let Some(name) = series_name { items .filter(|item| { item.series_name() .map(|s| s.eq_ignore_ascii_case(name)) .unwrap_or(false) }) .collect() } else { items.collect() }; Ok(items) } } #[async_trait] impl IMediaProvider for JellyfinMediaProvider { fn capabilities(&self) -> ProviderCapabilities { ProviderCapabilities { collections: true, series: true, genres: true, tags: true, decade: true, search: true, rescan: false, } } async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult> { match filter.series_names.len() { 0 | 1 => { let series = filter.series_names.first().map(String::as_str); self.fetch_items_for_series(filter, series).await } _ => { let mut per_series: Vec> = Vec::new(); for series_name in &filter.series_names { let items = self .fetch_items_for_series(filter, Some(series_name.as_str())) .await?; if !items.is_empty() { per_series.push(items); } } let max_len = per_series.iter().map(|s| s.len()).max().unwrap_or(0); let mut all = Vec::with_capacity(per_series.iter().map(|s| s.len()).sum()); for i in 0..max_len { for s in &per_series { if let Some(item) = s.get(i) { all.push(item.clone()); } } } Ok(all) } } } async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult> { let url = format!( "{}/Users/{}/Items", self.config.base_url, self.config.user_id ); let response = self .client .get(&url) .header("X-Emby-Token", &self.config.api_key) .query(&[ ("Ids", item_id.as_ref()), ("Fields", "Genres,Tags,RunTimeTicks,ProductionYear"), ]) .send() .await .map_err(|e| { DomainError::InfrastructureError(format!("Jellyfin request failed: {e}")) })?; if !response.status().is_success() { return Ok(None); } let body: JellyfinItemsResponse = response.json().await.map_err(|e| { DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}")) })?; Ok(body.items.into_iter().next().and_then(map_jellyfin_item)) } 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()) } 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()) } 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()) } async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult { Ok(SourceUri::NetworkUrl { url: format!( "{}/Videos/{}/stream?static=true&api_key={}", self.config.base_url, item_id.as_ref(), self.config.api_key, ), }) } }