use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; use crate::config::JellyfinConfig; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CardLayout { Poster, // 2:3 ratio (Movies, TV Shows) - wat kleinere plaatjes (135 x 200) Square, // 1:1 ratio (Music Albums, Artists) - vierkant (160 x 160) LandscapeThumb,// 16:9 ratio (Verder kijken, Music Videos) - liggend (250 x 140) PhotoLarge, // Foto albums - wat grotere plaatjes (280 x 190) } #[derive(Debug, Clone)] pub enum PlayableTarget { Stream { url: String, title: String, media_type: String, artist: Option, genre: Option, }, Photo { url: String, title: String }, OpenFolder { folder_id: String, name: String }, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct MediaItem { pub id: String, pub name: String, pub sort_name: Option, pub item_type: String, pub series_name: Option, pub artist_name: Option, pub genre: Option, pub index_number: Option, pub parent_index_number: Option, pub season_name: Option, pub year: Option, pub overview: Option, pub rating: Option, pub runtime_ticks: Option, pub played_percentage: Option, pub is_folder: bool, pub has_primary_image: bool, pub has_backdrop_image: bool, pub parent_primary_image_item_id: Option, pub parent_backdrop_item_id: Option, pub recursive_item_count: Option, pub child_count: Option, pub series_id: Option, pub album_id: Option, pub album: Option, } impl MediaItem { pub fn primary_image_id(&self) -> &str { if let Some(ref p_id) = self.parent_primary_image_item_id { if p_id.starts_with("http://") || p_id.starts_with("https://") { return p_id; } } if let Some(ref b_id) = self.parent_backdrop_item_id { if b_id.starts_with("http://") || b_id.starts_with("https://") { return b_id; } } if self.has_primary_image { &self.id } else if let Some(ref p_id) = self.parent_primary_image_item_id { p_id } else if let Some(ref b_id) = self.parent_backdrop_item_id { b_id } else { &self.id } } pub fn poster_url(&self, server_url: &str) -> String { let img = self.primary_image_id(); if img.starts_with("http://") || img.starts_with("https://") { return img.to_string(); } format!("{}/Items/{}/Images/Primary?fillWidth=320&fillHeight=480&quality=85", server_url.trim_end_matches('/'), img) } pub fn thumb_url(&self, server_url: &str) -> String { let img = self.primary_image_id(); if img.starts_with("http://") || img.starts_with("https://") { return img.to_string(); } format!("{}/Items/{}/Images/Primary?fillWidth=480&fillHeight=270&quality=85", server_url.trim_end_matches('/'), img) } pub fn square_url(&self, server_url: &str) -> String { let img = self.primary_image_id(); if img.starts_with("http://") || img.starts_with("https://") { return img.to_string(); } format!("{}/Items/{}/Images/Primary?fillWidth=300&fillHeight=300&quality=85", server_url.trim_end_matches('/'), img) } pub fn photo_url(&self, server_url: &str) -> String { let img = self.primary_image_id(); if img.starts_with("http://") || img.starts_with("https://") { return img.to_string(); } format!("{}/Items/{}/Images/Primary?fillWidth=1280&quality=90", server_url.trim_end_matches('/'), img) } pub fn backdrop_url(&self, server_url: &str) -> String { let img = self.primary_image_id(); if img.starts_with("http://") || img.starts_with("https://") { return img.to_string(); } let base = server_url.trim_end_matches('/'); if self.has_backdrop_image { format!("{}/Items/{}/Images/Backdrop/0?maxWidth=1920&quality=85", base, self.id) } else if let Some(ref p_id) = self.parent_backdrop_item_id { format!("{}/Items/{}/Images/Backdrop/0?maxWidth=1920&quality=85", base, p_id) } else { format!("{}/Items/{}/Images/Primary?maxWidth=1280&quality=85", base, self.id) } } pub fn stream_url(&self, server_url: &str, token: &str) -> String { let base = server_url.trim_end_matches('/'); if self.item_type == "Audio" || self.item_type == "MusicAlbum" { format!("{}/Audio/{}/stream?static=true&api_key={}", base, self.id, token) } else { format!("{}/Videos/{}/stream?static=true&api_key={}", base, self.id, token) } } pub fn formatted_runtime(&self) -> String { if let Some(ticks) = self.runtime_ticks { let total_mins = ticks / (10_000_000 * 60); let hours = total_mins / 60; let mins = total_mins % 60; if hours > 0 { format!("{}u {}m", hours, mins) } else { format!("{}m", mins) } } else { String::new() } } pub fn sort_letter(&self) -> char { let text = self.sort_name.as_deref().unwrap_or(&self.name); let trimmed = text.trim().trim_start_matches(|c: char| !c.is_alphanumeric()); for c in trimmed.chars() { if c.is_ascii_digit() { return '#'; } if c.is_ascii_alphabetic() { return c.to_ascii_uppercase(); } } '#' } } pub fn sort_media_items_alphabetically(items: &mut [MediaItem]) { items.sort_by(|a, b| { let la = a.sort_letter(); let lb = b.sort_letter(); let order_a = if la == '#' { 0 } else { (la as u32) + 1 }; let order_b = if lb == '#' { 0 } else { (lb as u32) + 1 }; order_a.cmp(&order_b).then_with(|| { let na = a.sort_name.as_deref().unwrap_or(&a.name).trim_start_matches(|c: char| !c.is_alphanumeric()); let nb = b.sort_name.as_deref().unwrap_or(&b.name).trim_start_matches(|c: char| !c.is_alphanumeric()); na.to_lowercase().cmp(&nb.to_lowercase()) }) }); } #[derive(Debug, Clone, Default)] pub struct MediaLibrary { // Dashboard (Home) pub resume_items: Vec, pub latest_movies: Vec, pub latest_shows: Vec, pub latest_music: Vec, // Libraries pub all_movies: Vec, pub all_shows: Vec, pub music_artists: Vec, pub music_albums: Vec, pub music_videos: Vec, pub photos: Vec, pub live_channels: Vec, pub favorites: Vec, } #[derive(Clone)] pub struct JellyfinClient { pub config: JellyfinConfig, client: reqwest::Client, pub token: Arc>>, pub user_id: Arc>>, pub library: Arc>, } impl JellyfinClient { pub fn new(config: JellyfinConfig) -> Self { Self { config, client: reqwest::Client::new(), token: Arc::new(RwLock::new(None)), user_id: Arc::new(RwLock::new(None)), library: Arc::new(RwLock::new(MediaLibrary::default())), } } fn auth_header_value(&self, token: Option<&str>) -> String { let base = format!( "MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"1.0.0\"", self.config.client_name, self.config.device_name, self.config.device_id ); if let Some(t) = token { format!("{}, Token=\"{}\"", base, t) } else { base } } pub async fn authenticate(&self) -> Result<(), Box> { let url = format!("{}/Users/AuthenticateByName", self.config.server_url.trim_end_matches('/')); let auth_val = self.auth_header_value(None); let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_val)?); let payload = serde_json::json!({ "Username": self.config.username, "Pw": self.config.password.clone().unwrap_or_default(), }); let resp = self.client.post(&url) .headers(headers) .json(&payload) .send() .await?; if !resp.status().is_success() { let err = resp.text().await?; return Err(format!("Jellyfin authentication failed: {}", err).into()); } let data: serde_json::Value = resp.json().await?; let token = data["AccessToken"].as_str().ok_or("No AccessToken in response")?.to_string(); let user_id = data["User"]["Id"].as_str().ok_or("No User.Id in response")?.to_string(); *self.token.write().await = Some(token); *self.user_id.write().await = Some(user_id); tracing::info!("Authenticated successfully with Jellyfin as {}", self.config.username); Ok(()) } pub async fn ensure_authenticated(&self) -> Result<(), Box> { if self.token.read().await.is_none() { self.authenticate().await?; } Ok(()) } pub async fn get_token(&self) -> Option { self.token.read().await.clone() } async fn get_authed_headers(&self) -> Result> { let token_guard = self.token.read().await; let token = token_guard.as_deref().ok_or("Not authenticated")?; let auth_val = self.auth_header_value(Some(token)); let mut headers = HeaderMap::new(); headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_val)?); headers.insert("X-Emby-Token", HeaderValue::from_str(token)?); Ok(headers) } pub async fn fetch_folder_items(&self, folder_id: &str) -> Vec { let base_url = self.config.server_url.trim_end_matches('/'); if let Ok(headers) = self.get_authed_headers().await { let user_id = self.user_id.read().await.clone().unwrap_or_default(); // 1. Try standard folder items with parentId let url = format!( "{}/Users/{}/Items?parentId={}&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres", base_url, user_id, folder_id ); let mut items = fetch_endpoint(&self.client, &url, &headers).await; // 2. If empty, maybe this is a TV Show (Series): fetch seasons if items.is_empty() { let url_seasons = format!( "{}/Shows/{}/Seasons?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres", base_url, folder_id, user_id ); items = fetch_endpoint(&self.client, &url_seasons, &headers).await; } // 3. If empty, maybe this is a Season: fetch episodes if items.is_empty() { let url_episodes = format!( "{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres", base_url, folder_id, user_id ); items = fetch_endpoint(&self.client, &url_episodes, &headers).await; } // 4. If empty, maybe this is an artist: try fetching albums via albumArtistIds if items.is_empty() { let url_albums = format!( "{}/Users/{}/Items?albumArtistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres", base_url, user_id, folder_id ); items = fetch_endpoint(&self.client, &url_albums, &headers).await; } // 5. If still empty, try artistIds for albums if items.is_empty() { let url_albums2 = format!( "{}/Users/{}/Items?artistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres", base_url, user_id, folder_id ); items = fetch_endpoint(&self.client, &url_albums2, &headers).await; } // 6. If still empty (e.g. ABBA, *NSYNC who have no albums, only compilation tracks), fetch individual audio songs directly if items.is_empty() { let url_songs = format!( "{}/Users/{}/Items?artistIds={}&includeItemTypes=Audio&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres", base_url, user_id, folder_id ); items = fetch_endpoint(&self.client, &url_songs, &headers).await; } // If the folder items are Audio tracks, Seasons, or Episodes, sort them by index_number if items.iter().any(|i| i.item_type == "Audio" || i.item_type == "Season" || i.item_type == "Episode") { items.sort_by_key(|i| i.index_number.unwrap_or(u32::MAX)); } else { sort_media_items_alphabetically(&mut items); } return items; } Vec::new() } pub async fn resolve_playable_item(&self, item: &MediaItem) -> Result> { let base_url = self.config.server_url.trim_end_matches('/'); let token = { let guard = self.token.read().await; guard.clone().unwrap_or_default() }; // 0. YouTube Video or direct stream URL if item.item_type == "YouTubeVideo" || item.id.starts_with("https://www.youtube.com") || item.id.starts_with("https://youtu.be") { return Ok(PlayableTarget::Stream { url: item.id.clone(), title: item.name.clone(), media_type: "stream".to_string(), artist: item.series_name.clone(), genre: item.genre.clone(), }); } // 1. Photo item -> view photo if item.item_type == "Photo" { return Ok(PlayableTarget::Photo { url: item.photo_url(base_url), title: item.name.clone(), }); } // 2. Series, Season, PhotoAlbum, Folder, MusicArtist, or MusicAlbum -> Open folder in UI if item.item_type == "Series" || item.item_type == "Season" || item.item_type == "PhotoAlbum" || item.item_type == "MusicArtist" || item.item_type == "MusicAlbum" || item.is_folder { return Ok(PlayableTarget::OpenFolder { folder_id: item.id.clone(), name: item.name.clone(), }); } // 5. Standard playable stream (Movie, Episode, Video, MusicVideo, Audio, TvChannel) let (media_type, artist) = match item.item_type.as_str() { "Audio" => ("music".to_string(), item.artist_name.clone()), "TvChannel" => ("stream".to_string(), None), "MusicVideo" => ("video".to_string(), item.artist_name.clone()), _ => ("video".to_string(), None), }; Ok(PlayableTarget::Stream { url: item.stream_url(base_url, &token), title: item.name.clone(), media_type, artist, genre: item.genre.clone(), }) } pub async fn fetch_all_media(&self) -> Result> { if self.token.read().await.is_none() { self.authenticate().await?; } let user_id = { let guard = self.user_id.read().await; guard.clone().ok_or("Missing user_id")? }; let headers = self.get_authed_headers().await?; let base_url = self.config.server_url.trim_end_matches('/'); // 1. Fetch Views to discover all library categories let views_url = format!("{}/Users/{}/Views", base_url, user_id); let views_resp: serde_json::Value = self.client.get(&views_url) .headers(headers.clone()) .send() .await? .json() .await?; let mut movies_id: Option = None; let mut shows_id: Option = None; let mut music_id: Option = None; let mut photo_id: Option = None; let mut music_video_id: Option = None; if let Some(items) = views_resp["Items"].as_array() { for v in items { let col_type = v["CollectionType"].as_str().unwrap_or(""); let name = v["Name"].as_str().unwrap_or("").to_lowercase(); let id = v["Id"].as_str().unwrap_or("").to_string(); if col_type == "movies" || name.contains("movie") || name.contains("film") { if movies_id.is_none() { movies_id = Some(id); } } else if col_type == "tvshows" || name.contains("show") || name.contains("serie") { if shows_id.is_none() { shows_id = Some(id); } } else if col_type == "music" && !name.contains("video") { if music_id.is_none() { music_id = Some(id); } } else if col_type == "musicvideos" || name.contains("music video") { if music_video_id.is_none() { music_video_id = Some(id); } } else if col_type == "homevideos" || col_type == "photos" || name.contains("foto") || name.contains("photo") { if photo_id.is_none() { photo_id = Some(id); } } } } // 2. Fetch Resume / Next Up let resume_url = format!( "{}/UserItems/Resume?userId={}&limit=16&fields=Overview,PrimaryImageAspectRatio,SeriesName,ParentBackdropItemId,UserData", base_url, user_id ); let resume_items = fetch_endpoint(&self.client, &resume_url, &headers).await; // 3. Fetch Latest & All Movies let mut latest_movies = Vec::new(); let mut all_movies = Vec::new(); if let Some(ref m_id) = movies_id { let url_latest = format!( "{}/Users/{}/Items?parentId={}&includeItemTypes=Movie&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName", base_url, user_id, m_id ); latest_movies = fetch_endpoint(&self.client, &url_latest, &headers).await; let url_all = format!( "{}/Users/{}/Items?parentId={}&includeItemTypes=Movie&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName", base_url, user_id, m_id ); all_movies = fetch_endpoint(&self.client, &url_all, &headers).await; sort_media_items_alphabetically(&mut all_movies); } // 4. Fetch Latest & All Shows let mut latest_shows = Vec::new(); let mut all_shows = Vec::new(); if let Some(ref s_id) = shows_id { let url_latest = format!( "{}/Users/{}/Items?parentId={}&includeItemTypes=Series&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName", base_url, user_id, s_id ); latest_shows = fetch_endpoint(&self.client, &url_latest, &headers).await; let url_all = format!( "{}/Users/{}/Items?parentId={}&includeItemTypes=Series&sortBy=SortName&sortOrder=Ascending&limit=1000&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName", base_url, user_id, s_id ); all_shows = fetch_endpoint(&self.client, &url_all, &headers).await; sort_media_items_alphabetically(&mut all_shows); } // 5. Fetch Music (Latest Albums, Artists, and All Albums) let mut latest_music = Vec::new(); let mut music_artists = Vec::new(); let mut music_albums = Vec::new(); if let Some(ref mus_id) = music_id { let url_latest_mus = format!( "{}/Users/{}/Items?parentId={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,AlbumArtist,SortName", base_url, user_id, mus_id ); latest_music = fetch_endpoint(&self.client, &url_latest_mus, &headers).await; // Fetch artists using dedicated /Artists endpoint first let url_artists = format!( "{}/Artists?userId={}&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,SortName", base_url, user_id ); music_artists = fetch_endpoint(&self.client, &url_artists, &headers).await; if music_artists.is_empty() { let url_artists_fb = format!( "{}/Users/{}/Items?parentId={}&includeItemTypes=MusicArtist&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,SortName", base_url, user_id, mus_id ); music_artists = fetch_endpoint(&self.client, &url_artists_fb, &headers).await; } sort_media_items_alphabetically(&mut music_artists); let url_albums = format!( "{}/Users/{}/Items?parentId={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,ProductionYear,AlbumArtist,SortName", base_url, user_id, mus_id ); music_albums = fetch_endpoint(&self.client, &url_albums, &headers).await; sort_media_items_alphabetically(&mut music_albums); } // 6. Fetch Music Videos let mut music_videos = Vec::new(); if let Some(ref mv_id) = music_video_id { let url_mv = format!( "{}/Users/{}/Items?parentId={}&includeItemTypes=MusicVideo&sortBy=DateCreated&sortOrder=Descending&limit=200&fields=Overview,Artist,SortName", base_url, user_id, mv_id ); music_videos = fetch_endpoint(&self.client, &url_mv, &headers).await; sort_media_items_alphabetically(&mut music_videos); } // 7. Fetch Photos / Home Videos let mut photos = Vec::new(); if let Some(ref p_id) = photo_id { let url_photo = format!( "{}/Users/{}/Items?parentId={}&sortBy=DateCreated&sortOrder=Descending&limit=500&fields=Overview,SortName", base_url, user_id, p_id ); photos = fetch_endpoint(&self.client, &url_photo, &headers).await; sort_media_items_alphabetically(&mut photos); } // 8. Fetch Live TV Channels let url_live = format!("{}/LiveTv/Channels?userId={}&limit=100", base_url, user_id); let live_channels = fetch_endpoint(&self.client, &url_live, &headers).await; // 9. Fetch Favorites let url_fav = format!( "{}/Users/{}/Items?userId={}&isFavorite=true&recursive=true&limit=200&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,SortName", base_url, user_id, user_id ); let mut favorites = fetch_endpoint(&self.client, &url_fav, &headers).await; sort_media_items_alphabetically(&mut favorites); let lib = MediaLibrary { resume_items, latest_movies, latest_shows, latest_music, all_movies, all_shows, music_artists, music_albums, music_videos, photos, live_channels, favorites, }; *self.library.write().await = lib.clone(); Ok(lib) } pub async fn get_library(&self) -> MediaLibrary { { let guard = self.library.read().await; if !guard.all_movies.is_empty() || !guard.all_shows.is_empty() { return guard.clone(); } } if let Ok(lib) = self.fetch_all_media().await { return lib; } self.library.read().await.clone() } pub async fn fetch_show_episodes(&self, show_id: &str) -> Vec { let base_url = self.config.server_url.trim_end_matches('/'); if let Ok(headers) = self.get_authed_headers().await { let user_id = self.user_id.read().await.clone().unwrap_or_default(); let url = format!( "{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId", base_url, show_id, user_id ); return fetch_endpoint(&self.client, &url, &headers).await; } Vec::new() } pub async fn fetch_episodes_paged( &self, show_id: Option<&str>, season: Option, start: usize, limit: usize, ) -> (usize, Vec) { let base_url = self.config.server_url.trim_end_matches('/'); if let Ok(headers) = self.get_authed_headers().await { let user_id = self.user_id.read().await.clone().unwrap_or_default(); let url = if let Some(sid) = show_id { if let Some(s_num) = season { format!( "{}/Shows/{}/Episodes?userId={}&season={}&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId", base_url, sid, user_id, s_num, start, limit ) } else { format!( "{}/Shows/{}/Episodes?userId={}&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId", base_url, sid, user_id, start, limit ) } } else { format!( "{}/Users/{}/Items?includeItemTypes=Episode&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId", base_url, user_id, start, limit ) }; return fetch_endpoint_paged(&self.client, &url, &headers).await; } (0, Vec::new()) } pub async fn fetch_songs_paged( &self, album_id: Option<&str>, artist_id: Option<&str>, start: usize, limit: usize, ) -> (usize, Vec) { let base_url = self.config.server_url.trim_end_matches('/'); if let Ok(headers) = self.get_authed_headers().await { let user_id = self.user_id.read().await.clone().unwrap_or_default(); let url = if let Some(aid) = album_id { format!( "{}/Users/{}/Items?parentId={}&includeItemTypes=Audio&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks", base_url, user_id, aid, start, limit ) } else if let Some(arid) = artist_id { format!( "{}/Users/{}/Items?artistIds={}&includeItemTypes=Audio&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks", base_url, user_id, arid, start, limit ) } else { format!( "{}/Users/{}/Items?includeItemTypes=Audio&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks", base_url, user_id, start, limit ) }; return fetch_endpoint_paged(&self.client, &url, &headers).await; } (0, Vec::new()) } } async fn fetch_endpoint(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> Vec { fetch_endpoint_paged(client, url, headers).await.1 } async fn fetch_endpoint_paged(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> (usize, Vec) { if let Ok(resp) = client.get(url).headers(headers.clone()).send().await { if let Ok(json) = resp.json::().await { let items = parse_items(&json); let total = json["TotalRecordCount"] .as_u64() .map(|n| n as usize) .unwrap_or(items.len()); return (total, items); } } (0, Vec::new()) } fn parse_items(json: &serde_json::Value) -> Vec { let mut out = Vec::new(); if let Some(items) = json["Items"].as_array() { for it in items { let id = it["Id"].as_str().unwrap_or("").to_string(); let name = it["Name"].as_str().unwrap_or("Onbekend").to_string(); let item_type = it["Type"].as_str().unwrap_or("Video").to_string(); let series_name = it["SeriesName"].as_str().map(|s| s.to_string()); let artist_name = it["AlbumArtist"] .as_str() .or_else(|| it["Artist"].as_str()) .map(|s| s.to_string()) .or_else(|| { it["Artists"].as_array().and_then(|arr| arr.first().and_then(|a| a.as_str())).map(|s| s.to_string()) }) .or_else(|| { it["ArtistItems"].as_array().and_then(|arr| arr.first().and_then(|a| a["Name"].as_str())).map(|s| s.to_string()) }); let genre = it["Genres"].as_array().and_then(|arr| { let list: Vec<&str> = arr.iter().filter_map(|g| g.as_str()).collect(); if list.is_empty() { None } else { Some(list.join(", ")) } }).or_else(|| it["Genre"].as_str().map(|s| s.to_string())); let year = it["ProductionYear"].as_u64().map(|y| y as u32); let overview = it["Overview"].as_str().map(|s| s.to_string()); let rating = it["CommunityRating"].as_f64().map(|r| r as f32); let runtime_ticks = it["RunTimeTicks"].as_u64(); let played_percentage = it["UserData"]["PlayedPercentage"].as_f64().map(|p| p as f32); let is_folder = it["IsFolder"].as_bool().unwrap_or(false); let has_primary_image = it["ImageTags"]["Primary"].is_string(); let has_backdrop_image = it["BackdropImageTags"].as_array().map_or(false, |b| !b.is_empty()); let parent_primary_image_item_id = it["ParentPrimaryImageItemId"] .as_str() .or_else(|| it["AlbumId"].as_str()) .or_else(|| it["SeriesId"].as_str()) .map(|s| s.to_string()); let parent_backdrop_item_id = it["ParentBackdropItemId"] .as_str() .or_else(|| it["SeriesId"].as_str()) .map(|s| s.to_string()); let sort_name = it["SortName"].as_str().map(|s| s.to_string()); let index_number = it["IndexNumber"].as_u64().map(|n| n as u32); let parent_index_number = it["ParentIndexNumber"].as_u64().map(|n| n as u32); let season_name = it["SeasonName"].as_str().map(|s| s.to_string()); let recursive_item_count = it["RecursiveItemCount"].as_u64().map(|n| n as u32); let child_count = it["ChildCount"].as_u64().map(|n| n as u32); let series_id = it["SeriesId"].as_str().map(|s| s.to_string()); let album_id = it["AlbumId"].as_str().map(|s| s.to_string()); let album = it["Album"].as_str().map(|s| s.to_string()); if !id.is_empty() { out.push(MediaItem { id, name, sort_name, item_type, series_name, artist_name, genre, index_number, parent_index_number, season_name, year, overview, rating, runtime_ticks, played_percentage, is_folder, has_primary_image, has_backdrop_image, parent_primary_image_item_id, parent_backdrop_item_id, recursive_item_count, child_count, series_id, album_id, album, }); } } } out }