eerste versie

This commit is contained in:
bart
2026-11-03 22:21:03 +01:00
parent 8a08620866
commit 99cbc00357
33 changed files with 11157 additions and 2163 deletions
+651
View File
@@ -0,0 +1,651 @@
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<String>,
genre: Option<String>,
},
Photo { url: String, title: String },
OpenFolder { folder_id: String, name: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaItem {
pub id: String,
pub name: String,
pub sort_name: Option<String>,
pub item_type: String,
pub series_name: Option<String>,
pub artist_name: Option<String>,
pub genre: Option<String>,
pub index_number: Option<u32>,
pub year: Option<u32>,
pub overview: Option<String>,
pub rating: Option<f32>,
pub runtime_ticks: Option<u64>,
pub played_percentage: Option<f32>,
pub is_folder: bool,
pub has_primary_image: bool,
pub has_backdrop_image: bool,
pub parent_primary_image_item_id: Option<String>,
pub parent_backdrop_item_id: Option<String>,
}
impl MediaItem {
pub fn primary_image_id(&self) -> &str {
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<MediaItem>,
pub latest_movies: Vec<MediaItem>,
pub latest_shows: Vec<MediaItem>,
pub latest_music: Vec<MediaItem>,
// Libraries
pub all_movies: Vec<MediaItem>,
pub all_shows: Vec<MediaItem>,
pub music_artists: Vec<MediaItem>,
pub music_albums: Vec<MediaItem>,
pub music_videos: Vec<MediaItem>,
pub photos: Vec<MediaItem>,
pub live_channels: Vec<MediaItem>,
pub favorites: Vec<MediaItem>,
}
#[derive(Clone)]
pub struct JellyfinClient {
pub config: JellyfinConfig,
client: reqwest::Client,
pub token: Arc<RwLock<Option<String>>>,
pub user_id: Arc<RwLock<Option<String>>>,
}
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)),
}
}
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<dyn std::error::Error + Send + Sync>> {
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(())
}
async fn get_authed_headers(&self) -> Result<HeaderMap, Box<dyn std::error::Error + Send + Sync>> {
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<MediaItem> {
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 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;
}
// 3. 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;
}
// 4. 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, sort them by index_number
if items.iter().any(|i| i.item_type == "Audio") {
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<PlayableTarget, Box<dyn std::error::Error + Send + Sync>> {
let base_url = self.config.server_url.trim_end_matches('/');
let token = {
let guard = self.token.read().await;
guard.clone().unwrap_or_default()
};
let user_id = {
let guard = self.user_id.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 -> resolve next unplayed episode
if item.item_type == "Series" {
if let Ok(headers) = self.get_authed_headers().await {
let next_url = format!("{}/Shows/NextUp?seriesId={}&userId={}&fields=Genres", base_url, item.id, user_id);
let episodes = fetch_endpoint(&self.client, &next_url, &headers).await;
if let Some(ep) = episodes.first() {
return Ok(PlayableTarget::Stream {
url: ep.stream_url(base_url, &token),
title: format!("{}: {}", item.name, ep.name),
media_type: "video".to_string(),
artist: None,
genre: ep.genre.clone().or_else(|| item.genre.clone()),
});
}
// Fallback: episode 1
let ep1_url = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=Episode&recursive=true&sortBy=SortName&limit=1&fields=Genres",
base_url, user_id, item.id
);
let ep1 = fetch_endpoint(&self.client, &ep1_url, &headers).await;
if let Some(ep) = ep1.first() {
return Ok(PlayableTarget::Stream {
url: ep.stream_url(base_url, &token),
title: format!("{}: {}", item.name, ep.name),
media_type: "video".to_string(),
artist: None,
genre: ep.genre.clone().or_else(|| item.genre.clone()),
});
}
}
}
// 3. PhotoAlbum, Folder, MusicArtist, or MusicAlbum -> Open folder in UI
if item.item_type == "PhotoAlbum"
|| item.item_type == "MusicArtist"
|| item.item_type == "MusicAlbum"
|| (item.is_folder && item.item_type != "Series")
{
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<MediaLibrary, Box<dyn std::error::Error + Send + Sync>> {
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<String> = None;
let mut shows_id: Option<String> = None;
let mut music_id: Option<String> = None;
let mut photo_id: Option<String> = None;
let mut music_video_id: Option<String> = 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);
Ok(MediaLibrary {
resume_items,
latest_movies,
latest_shows,
latest_music,
all_movies,
all_shows,
music_artists,
music_albums,
music_videos,
photos,
live_channels,
favorites,
})
}
}
async fn fetch_endpoint(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> Vec<MediaItem> {
if let Ok(resp) = client.get(url).headers(headers.clone()).send().await {
if let Ok(json) = resp.json::<serde_json::Value>().await {
return parse_items(&json);
}
}
Vec::new()
}
fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
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());
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);
if !id.is_empty() {
out.push(MediaItem {
id,
name,
sort_name,
item_type,
series_name,
artist_name,
genre,
index_number,
year,
overview,
rating,
runtime_ticks,
played_percentage,
is_folder,
has_primary_image,
has_backdrop_image,
parent_primary_image_item_id,
parent_backdrop_item_id,
});
}
}
}
out
}