use serde::{Deserialize, Serialize}; use super::config::Config; use std::time::Duration; #[derive(Clone)] pub struct JellyfinClient { base_url: String, client: reqwest::blocking::Client, token: String, device_id: String, } /// Human-readable device label shown in Jellyfin's dashboard. Cosmetic only — /// DeviceId is what the server keys sessions on. fn device_name() -> String { if let Ok(h) = std::fs::read_to_string("/etc/hostname") { let h = h.trim(); if !h.is_empty() { return h.to_string(); } } std::env::var("HOSTNAME").unwrap_or_else(|_| "cjtui".to_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(Serialize)] struct PlaybackStartInfo<'a> { #[serde(rename = "ItemId")] item_id: &'a str, #[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")] position_ticks: Option, #[serde(rename = "CanSeek")] can_seek: bool, #[serde(rename = "IsPaused")] is_paused: bool, #[serde(rename = "IsMuted")] is_muted: bool, // Optional but harmless to include; some servers like seeing it present. #[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")] media_source_id: Option<&'a str>, } #[derive(Serialize)] struct PlaybackProgressInfo<'a> { #[serde(rename = "ItemId")] item_id: &'a str, #[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")] position_ticks: Option, #[serde(rename = "CanSeek")] can_seek: bool, #[serde(rename = "IsPaused")] is_paused: bool, #[serde(rename = "IsMuted")] is_muted: bool, #[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")] media_source_id: Option<&'a str>, } #[derive(Serialize)] struct PlaybackStopInfo<'a> { #[serde(rename = "ItemId")] item_id: &'a str, #[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")] position_ticks: Option, #[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")] media_source_id: Option<&'a str>, #[serde(rename = "Failed")] failed: bool, } #[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, device_id: cfg.device_id.clone().unwrap_or_default(), }) } pub fn unauthenticated(base_url: &str, device_id: &str) -> Self { Self { base_url: base_url.trim_end_matches('/').to_string(), client: reqwest::blocking::Client::new(), token: String::new(), // unused for login device_id: device_id.to_string(), } } 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=\"cjtui\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"", device_name(), self.device_id, 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 ping_base_url(base_url: &str) -> bool { // Fast, unauthenticated check. Latch result at startup. // We try a couple endpoints because Jellyfin setups vary. let base = base_url.trim_end_matches('/'); let client = match reqwest::blocking::Client::builder() .timeout(Duration::from_millis(900)) .build() { Ok(c) => c, Err(_) => return false, }; let candidates = [ format!("{base}/System/Ping"), format!("{base}/System/Info/Public"), ]; for url in candidates { if let Ok(resp) = client.get(url).send() { if resp.status().is_success() { return true; } } } false } pub fn mark_played(&self, user_id: &str, item_id: &str) -> Result<(), String> { let base = self.base_url.trim_end_matches('/'); let url = format!("{base}/Users/{user_id}/PlayedItems/{item_id}"); let resp = self .client .post(url) .header("X-Emby-Token", &self.token) .send() .map_err(|e| format!("mark_played request failed: {e}"))?; if !resp.status().is_success() { return Err(format!("mark_played failed: HTTP {}", resp.status())); } Ok(()) } pub fn mark_unplayed(&self, user_id: &str, item_id: &str) -> Result<(), String> { let base = self.base_url.trim_end_matches('/'); let url = format!("{base}/Users/{user_id}/PlayedItems/{item_id}"); let resp = self .client .delete(url) .header("X-Emby-Token", &self.token) .send() .map_err(|e| format!("mark_unplayed request failed: {e}"))?; if !resp.status().is_success() { return Err(format!("mark_unplayed failed: HTTP {}", resp.status())); } Ok(()) } pub fn report_playback_start(&self, item_id: &str, position_ticks: Option) -> Result<(), String> { let base = self.base_url.trim_end_matches('/'); let url = format!("{base}/Sessions/Playing"); let body = PlaybackStartInfo { item_id, position_ticks, can_seek: true, is_paused: false, is_muted: false, media_source_id: Some(item_id), }; let resp = self .client .post(url) .header("X-Emby-Token", &self.token) .json(&body) .send() .map_err(|e| format!("report_playback_start request failed: {e}"))?; if !resp.status().is_success() { return Err(format!("report_playback_start HTTP {}", resp.status())); } Ok(()) } pub fn report_playback_progress(&self, item_id: &str, position_ticks: Option, is_paused: bool) -> Result<(), String> { let base = self.base_url.trim_end_matches('/'); let url = format!("{base}/Sessions/Playing/Progress"); let body = PlaybackProgressInfo { item_id, position_ticks, can_seek: true, is_paused, is_muted: false, media_source_id: Some(item_id), }; let resp = self .client .post(url) .header("X-Emby-Token", &self.token) .json(&body) .send() .map_err(|e| format!("report_playback_progress request failed: {e}"))?; if !resp.status().is_success() { return Err(format!("report_playback_progress HTTP {}", resp.status())); } Ok(()) } pub fn report_playback_stopped(&self, item_id: &str, position_ticks: Option, failed: bool) -> Result<(), String> { let base = self.base_url.trim_end_matches('/'); let url = format!("{base}/Sessions/Playing/Stopped"); let body = PlaybackStopInfo { item_id, position_ticks, media_source_id: Some(item_id), failed, }; let resp = self .client .post(url) .header("X-Emby-Token", &self.token) .json(&body) .send() .map_err(|e| format!("report_playback_stopped request failed: {e}"))?; if !resp.status().is_success() { return Err(format!("report_playback_stopped HTTP {}", resp.status())); } Ok(()) } 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. DeviceId is per-install so // that two machines on the same account hold independent sessions. format!( "MediaBrowser Client=\"cjtui\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\", Token=\"{}\"", device_name(), self.device_id, 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 default_sort_by = if let Some(types) = query.include_item_types.as_deref() { if types.contains("Episode") { "ParentIndexNumber,IndexNumber,SortName".to_string() } else { "SortName".to_string() } } else { "SortName".to_string() }; let sort_by = query.sort_by.clone().unwrap_or(default_sort_by); let sort_order = query .sort_order .clone() .unwrap_or_else(|| "Ascending".to_string()); 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,ChildCount,RecursiveItemCount"), ("SortBy", &sort_by), ("SortOrder", &sort_order), ]); 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)]); } if let Some(filters) = query.filters.as_deref() { req = req.query(&[("Filters", filters)]); } 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), }) } /// Returns true if there exists at least one resumable Episode under the given parent (Series or Season). /// This is used to mark Series/Season rows as "in progress" even when Jellyfin doesn't populate /// PlayedPercentage on those container types. pub fn has_resumable_under( &self, user_id: &str, parent_id: &str, ) -> Result { // Series -> Season -> Episode, so this MUST be recursive for Series. // Recursive=true is also fine for Season. let mut q = LibraryQuery::all() .with_parent(parent_id.to_string()) .with_item_types("Episode") .with_filters("IsResumable"); q.recursive = true; let page = self.list_items(user_id, q, 0, 1)?; Ok(page.total > 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,ChildCount,RecursiveItemCount"), ]) .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 pub filters: Option, // e.g. Some("IsResumable".into()) // optional overrides pub sort_by: Option, // e.g. Some("DateCreated".into()) pub sort_order: Option, // "Ascending" or "Descending" } impl LibraryQuery { pub fn movies() -> Self { Self { include_item_types: Some("Movie".into()), parent_id: None, search_term: None, recursive: true, filters: None, sort_by: None, sort_order: None, } } pub fn shows() -> Self { Self { include_item_types: Some("Series".into()), parent_id: None, search_term: None, recursive: true, filters: None, sort_by: None, sort_order: None, } } pub fn music() -> Self { Self { include_item_types: Some("Audio".into()), parent_id: None, search_term: None, recursive: true, filters: None, sort_by: None, sort_order: None, } } // true "All" (no filter) pub fn all() -> Self { Self { include_item_types: None, parent_id: None, search_term: None, recursive: true, filters: None, sort_by: None, sort_order: None, } } 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 fn with_filters(mut self, filters: impl Into) -> Self { self.filters = Some(filters.into()); self } pub fn with_sort_by(mut self, sort_by: impl Into) -> Self { self.sort_by = Some(sort_by.into()); self } pub fn with_sort_order(mut self, sort_order: impl Into) -> Self { self.sort_order = Some(sort_order.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(Deserialize, Clone, Debug, Default)] pub struct JfUserData { #[serde(rename = "Played")] pub played: Option, #[serde(rename = "PlayedPercentage")] pub played_percentage: Option, #[serde(rename = "PlaybackPositionTicks")] pub playback_position_ticks: 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 = "ChildCount")] pub child_count: Option, #[serde(rename = "RecursiveItemCount")] pub recursive_item_count: 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, }