790 lines
34 KiB
Rust
790 lines
34 KiB
Rust
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, Default)]
|
|
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 parent_index_number: Option<u32>,
|
|
pub season_name: Option<String>,
|
|
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>,
|
|
pub recursive_item_count: Option<u32>,
|
|
pub child_count: Option<u32>,
|
|
pub series_id: Option<String>,
|
|
pub album_id: Option<String>,
|
|
pub album: Option<String>,
|
|
}
|
|
|
|
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<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>>>,
|
|
pub library: Arc<RwLock<MediaLibrary>>,
|
|
}
|
|
|
|
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<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(())
|
|
}
|
|
|
|
pub async fn ensure_authenticated(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
if self.token.read().await.is_none() {
|
|
self.authenticate().await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_token(&self) -> Option<String> {
|
|
self.token.read().await.clone()
|
|
}
|
|
|
|
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 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<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()
|
|
};
|
|
|
|
// 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<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);
|
|
|
|
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<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();
|
|
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<u32>,
|
|
start: usize,
|
|
limit: usize,
|
|
) -> (usize, 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();
|
|
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<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();
|
|
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<MediaItem> {
|
|
fetch_endpoint_paged(client, url, headers).await.1
|
|
}
|
|
|
|
async fn fetch_endpoint_paged(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> (usize, Vec<MediaItem>) {
|
|
if let Ok(resp) = client.get(url).headers(headers.clone()).send().await {
|
|
if let Ok(json) = resp.json::<serde_json::Value>().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<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())
|
|
.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
|
|
}
|