eerste versie
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user