use serde::{Deserialize, Serialize}; use super::config::Config; #[derive(Clone)] pub struct JellyfinClient { base_url: String, client: reqwest::blocking::Client, token: String, } pub struct LoginResult { pub access_token: String, pub user_id: Option, } #[derive(Serialize)] struct AuthenticateUserByName<'a> { #[serde(rename = "Username")] username: &'a str, #[serde(rename = "Pw")] pw: &'a str, } #[derive(Deserialize)] struct AuthenticationResult { #[serde(rename = "AccessToken")] access_token: Option, #[serde(rename = "User")] user: Option, } #[derive(Deserialize)] struct UserDto { #[serde(rename = "Id")] id: Option, } #[derive(Debug, Clone, Deserialize)] pub struct ViewsResponse { #[serde(rename = "Items", default)] pub items: Vec, } #[derive(Deserialize)] struct PlaylistItemsResponse { #[serde(rename = "Items", default)] items: Vec, #[serde(rename = "TotalRecordCount")] total_record_count: Option, } impl JellyfinClient { pub fn from_config(cfg: &Config) -> Result { let token = cfg .access_token .clone() .ok_or_else(|| "missing access_token (need login)".to_string())?; Ok(Self { base_url: cfg.base_url.trim_end_matches('/').to_string(), client: reqwest::blocking::Client::new(), token, }) } pub fn unauthenticated(base_url: &str) -> Self { Self { base_url: base_url.trim_end_matches('/').to_string(), client: reqwest::blocking::Client::new(), token: String::new(), // unused for login } } pub fn login_by_name(&self, username: &str, password: &str) -> Result { let url = format!("{}/Users/AuthenticateByName", self.base_url); let auth_header = format!( "MediaBrowser Client=\"cj-jftui\", Device=\"kitty\", DeviceId=\"cj-jftui\", Version=\"{}\"", env!("CARGO_PKG_VERSION"), ); let body = AuthenticateUserByName { username, pw: password }; let resp = self .client .post(url) .header("X-Emby-Authorization", auth_header) .json(&body) .send() .map_err(|e| format!("request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().unwrap_or_default(); return Err(format!("login failed: {status} {text}")); } let parsed: AuthenticationResult = resp .json() .map_err(|e| format!("bad response json: {e}"))?; let token = parsed .access_token .ok_or_else(|| "missing AccessToken in response".to_string())?; let user_id = parsed.user.and_then(|u| u.id); Ok(LoginResult { access_token: token, user_id, }) } pub fn list_views(&self, user_id: &str) -> Result, String> { let url = format!("{}/Users/{}/Views", self.base_url, user_id); let resp = self .client .get(url) .header("X-Emby-Authorization", self.auth_header_value()) .send() .map_err(|e| format!("Views request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().unwrap_or_default(); return Err(format!("Views failed: {status} {text}")); } let parsed: ViewsResponse = resp .json() .map_err(|e| format!("Views parse failed: {e}"))?; Ok(parsed.items) } fn auth_header_value(&self) -> String { // Identify as a client and include the token. format!( "MediaBrowser Client=\"cj-jftui\", Device=\"kitty\", DeviceId=\"cj-jftui\", Version=\"{}\", Token=\"{}\"", env!("CARGO_PKG_VERSION"), self.token ) } pub fn list_items( &self, user_id: &str, query: LibraryQuery, start_index: usize, limit: usize, ) -> Result { let url = format!("{}/Users/{}/Items", self.base_url, user_id); let mut sort_by = "SortName"; if let Some(types) = query.include_item_types.as_deref() { if types.contains("Episode") { sort_by = "ParentIndexNumber,IndexNumber,SortName"; } else if types.contains("Audio") { sort_by = "SortName"; } } let recursive = if query.recursive { "true" } else { "false" }; let mut req = self .client .get(url) .header("X-Emby-Authorization", self.auth_header_value()) .query(&[ ("Recursive", recursive), ("StartIndex", &start_index.to_string()), ("Limit", &limit.to_string()), ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists,UserData"), ("SortBy", sort_by), ("SortOrder", "Ascending"), ]); if let Some(types) = query.include_item_types.as_deref() { // Jellyfin expects IncludeItemTypes as repeated query params, not CSV for t in types.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) { req = req.query(&[("IncludeItemTypes", t)]); } } if let Some(parent) = query.parent_id.as_deref() { req = req.query(&[("ParentId", parent)]); } if let Some(term) = query.search_term.as_deref() { req = req.query(&[("SearchTerm", term)]); } let resp = req.send().map_err(|e| format!("request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().unwrap_or_default(); return Err(format!("list_items failed: {status} {text}")); } let parsed: ItemsResponse = resp .json() .map_err(|e| format!("bad response json: {e}"))?; Ok(PagedItems { items: parsed.items, total: parsed.total_record_count.unwrap_or(0), }) } pub fn playlist_items( &self, user_id: &str, playlist_id: &str, start_index: usize, limit: usize, ) -> Result { let url = format!("{}/Playlists/{}/Items", self.base_url, playlist_id); let resp = self .client .get(url) .header("X-Emby-Authorization", self.auth_header_value()) .query(&[ ("UserId", user_id), ("StartIndex", &start_index.to_string()), ("Limit", &limit.to_string()), ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists,UserData"), ]) .send() .map_err(|e| format!("request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().unwrap_or_default(); return Err(format!("playlist_items failed: {status} {text}")); } let parsed: PlaylistItemsResponse = resp .json() .map_err(|e| format!("bad response json: {e}"))?; Ok(PagedItems { items: parsed.items, total: parsed.total_record_count.unwrap_or(0), }) } } /// What kind of library list we want. #[derive(Clone)] pub struct LibraryQuery { pub include_item_types: Option, // e.g. Some("Movie".into()) pub parent_id: Option, // e.g. Some(series_id) pub search_term: Option, // e.g. Some("matrix".into()) pub recursive: bool, // true for big lists, false for drilldown } impl LibraryQuery { pub fn movies() -> Self { Self { include_item_types: Some("Movie".into()), parent_id: None, search_term: None, recursive: true, } } pub fn shows() -> Self { Self { include_item_types: Some("Series".into()), parent_id: None, search_term: None, recursive: true, } } pub fn music() -> Self { Self { include_item_types: Some("Audio".into()), parent_id: None, search_term: None, recursive: true, } } // true "All" (no filter) pub fn all() -> Self { Self { include_item_types: None, parent_id: None, search_term: None, recursive: true, } } pub fn with_parent(mut self, parent_id: impl Into) -> Self { self.parent_id = Some(parent_id.into()); self.recursive = false; // drilldown is usually direct children self } pub fn with_search(mut self, term: impl Into) -> Self { self.search_term = Some(term.into()); self } pub fn with_item_types(mut self, types: impl Into) -> Self { self.include_item_types = Some(types.into()); self } } pub struct PagedItems { pub items: Vec, pub total: usize, } #[derive(Debug, Clone, Deserialize)] pub struct JfStudio { #[serde(rename = "Name")] pub name: String, } #[derive(Debug, Clone, Deserialize)] pub struct JfUserData { #[serde(rename = "Played")] pub played: Option, #[serde(rename = "PlaybackPositionTicks")] pub playback_position_ticks: Option, #[serde(rename = "PlayedPercentage")] pub played_percentage: Option, #[serde(rename = "UnplayedItemCount")] pub unplayed_item_count: Option, } #[derive(Debug, Clone, Deserialize)] pub struct JfItem { #[serde(rename = "Id")] pub id: String, #[serde(rename = "Name")] pub name: String, #[serde(rename = "Type")] pub item_type: Option, #[serde(rename = "ProductionYear")] pub production_year: Option, #[serde(rename = "IndexNumber")] pub index_number: Option, #[serde(rename = "ParentIndexNumber")] pub parent_index_number: Option, // ✅ add these #[serde(rename = "RunTimeTicks")] pub run_time_ticks: Option, #[serde(rename = "Genres", default)] pub genres: Vec, #[serde(rename = "Studios", default)] pub studios: Vec, #[serde(rename = "Overview")] pub overview: Option, #[serde(rename = "SeriesName")] pub series_name: Option, #[serde(rename = "Album")] pub album: Option, #[serde(rename = "AlbumArtist")] pub album_artist: Option, #[serde(rename = "Artists", default)] pub artists: Vec, #[serde(rename = "UserData")] pub user_data: Option, } #[derive(Deserialize)] struct ItemsResponse { #[serde(rename = "Items")] items: Vec, #[serde(rename = "TotalRecordCount")] total_record_count: Option, } #[derive(Debug, Clone, Deserialize)] pub struct StudioDto { #[serde(rename = "Name")] pub name: Option, }