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, } 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, }) } 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 req = self .client .get(url) .header("X-Emby-Authorization", self.auth_header_value()) .query(&[ ("Recursive", "true"), ("StartIndex", &start_index.to_string()), ("Limit", &limit.to_string()), ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear"), ("SortBy", "SortName"), ("SortOrder", "Ascending"), ]); // apply query (movie/series/etc) if let Some(types) = query.include_item_types.as_deref() { req = req.query(&[("IncludeItemTypes", types)]); } 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), }) } } #[derive(Clone)] pub struct LibraryQuery { pub include_item_types: Option, pub search_term: Option, } impl LibraryQuery { pub fn movies() -> Self { Self { include_item_types: Some("Movie".into()), search_term: None } } pub fn shows() -> Self { Self { include_item_types: Some("Series".into()), search_term: None } } pub fn music() -> Self { Self { include_item_types: Some("Audio".into()), search_term: None } } pub fn all() -> Self { Self { include_item_types: None, search_term: None } } pub fn with_search(mut self, term: String) -> Self { self.search_term = Some(term); self } } pub struct PagedItems { pub items: Vec, pub total: usize, } #[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, } #[derive(Deserialize)] struct ItemsResponse { #[serde(rename = "Items")] items: Vec, #[serde(rename = "TotalRecordCount")] total_record_count: Option, }