pub mod jellyfin; use std::sync::Arc; use ::tap::*; use reqwest::Method; use serde::{Deserialize, Serialize}; #[derive(thiserror::Error, Debug)] pub enum JellyfinApiError { #[error("Jellyfin API error: {0}")] ReqwestError(#[from] reqwest::Error), #[error("Serialization/Deserialization error: {0}")] SerdeError(#[from] serde_json::Error), #[error("IO error: {0}")] IoError(#[from] std::io::Error), #[error("Unknown Jellyfin API error")] Unknown, } type Result = std::result::Result; #[derive(Debug, Clone)] pub struct JellyfinClient { client: reqwest::Client, access_token: Option>, pub config: Arc, } impl JellyfinClient { pub fn new(config: JellyfinConfig) -> Self { JellyfinClient { client: reqwest::Client::new(), access_token: None, config: Arc::new(config), } } pub async fn save_token(&self, path: impl AsRef) -> std::io::Result<()> { if let Some(token) = &self.access_token { tokio::fs::write(path, &**token).await } else { Err(std::io::Error::new( std::io::ErrorKind::Other, "No access token to save", )) } } pub async fn load_token( &mut self, path: impl AsRef, ) -> std::io::Result { let token = tokio::fs::read_to_string(path).await?; self.access_token = Some(token.clone().into()); Ok(token) } pub fn set_token(&mut self, token: impl AsRef) { self.access_token = Some(token.as_ref().into()); } pub fn request_builder( &self, method: reqwest::Method, uri: impl AsRef, ) -> reqwest::RequestBuilder { let url = format!("{}/{}", self.config.server_url.as_str(), uri.as_ref()); self.client.request(method, &url) .header("X-Emby-Authorization", format!("MediaBrowser Client=\"Jello\", Device=\"Jello\", DeviceId=\"{}\", Version=\"1.0.0\"", self.config.device_id)) .pipe(|builder| { if let Some(token) = &self.access_token { builder.header("X-MediaBrowser-Token", &**token) } else { builder } }) } // pub fn get_builder(&self, uri: impl AsRef) -> reqwest::RequestBuilder { // let url = format!("{}/{}", self.config.server_url.as_str(), uri.as_ref()); // self.client.get(&url) // .header("X-Emby-Authorization", format!("MediaBrowser Client=\"Jello\", Device=\"Jello\", DeviceId=\"{}\", Version=\"1.0.0\"", self.config.device_id)) // .pipe(|builder| { // if let Some(token) = &self.access_token { // builder.header("X-MediaBrowser-Token", token) // } else { // builder // } // }) // } pub async fn post( &self, uri: impl AsRef, body: &T, ) -> Result { let text = self .request_builder(reqwest::Method::POST, uri) .json(body) .send() .await? .error_for_status()? .text() .await?; let out: U = serde_json::from_str(&text)?; Ok(out) } pub async fn get(&self, uri: impl AsRef) -> Result { let text = self .request_builder(reqwest::Method::GET, uri) .send() .await? .error_for_status()? .text() .await?; let out: U = serde_json::from_str(&text)?; Ok(out) } pub async fn authenticate(&mut self) -> Result { let auth_result: jellyfin::AuthenticationResult = self .post( "Users/AuthenticateByName", &jellyfin::AuthenticateUserByName { username: Some(self.config.username.clone()), pw: Some(self.config.password.clone()), }, ) .await?; self.access_token = auth_result.access_token.clone().map(Into::into); Ok(auth_result) } pub async fn authenticate_with_cached_token( &mut self, path: impl AsRef, ) -> Result { let path = path.as_ref(); if let Ok(token) = self .load_token(path) .await .inspect_err(|err| tracing::warn!("Failed to load cached token: {}", err)) { tracing::info!("Authenticating with cached token from {:?}", path); self.access_token = Some(token.clone().into()); Ok(token) } else { tracing::info!("No cached token found at {:?}, authenticating...", path); let token = self .authenticate() .await? .access_token .ok_or_else(|| JellyfinApiError::Unknown)?; self.save_token(path).await?; Ok(token) } } pub async fn raw_items(&self) -> Result { let text = &self .request_builder(Method::GET, "Items") .send() .await? .error_for_status()? .text() .await?; let out: jellyfin::BaseItemDtoQueryResult = serde_json::from_str(&text)?; Ok(out) } pub async fn items( &self, root: impl Into>, ) -> Result> { let text = &self .request_builder(Method::GET, "Items") .query(&[("parentId", root.into())]) .send() .await? .error_for_status()? .text() .await?; let out: jellyfin::BaseItemDtoQueryResult = serde_json::from_str(&text)?; Ok(out.items) } pub async fn search(&self, query: impl AsRef) -> Result> { let text = &self .request_builder(Method::GET, "Items/Search") .query(&[("searchTerm", query.as_ref()), ("recursive", "true")]) .send() .await? .error_for_status()? .text() .await?; let out: jellyfin::BaseItemDtoQueryResult = serde_json::from_str(&text)?; Ok(out.items) } pub async fn thumbnail( &self, item: uuid::Uuid, image_type: jellyfin::ImageType, ) -> Result { let uri = format!( "Items/{}/Images/{}", item, serde_json::to_string(&image_type).expect("Failed to serialize image type") ); let bytes = self .request_builder(Method::GET, uri) .send() .await? .error_for_status()? .bytes() .await?; Ok(bytes) } pub async fn playback_info(&self, item: uuid::Uuid) -> Result { let uri = format!("Items/{}/PlaybackInfo", item); let text = &self .request_builder(Method::GET, uri) .send() .await? .error_for_status()? .text() .await?; let out: jellyfin::PlaybackInfoDto = serde_json::from_str(&text)?; Ok(out) } pub async fn user_data(&self, item: uuid::Uuid) -> Result { let uri = format!("UserItems/{}/UserData", item); let text = &self .request_builder(Method::GET, uri) .send() .await? .error_for_status()? .text() .await?; let out: jellyfin::UserItemDataDto = serde_json::from_str(&text)?; Ok(out) } pub fn stream_url(&self, item: uuid::Uuid) -> Result { let stream_url = format!( "{}/Videos/{}/stream?static=true", self.config.server_url.as_str(), item, // item, ); Ok(url::Url::parse(&stream_url).expect("Failed to parse stream URL")) } } // pub trait Item { // fn id(&self) -> &str; // fn name(&self) -> &str; // fn type_(&self) -> jellyfin::BaseItemKind; // fn media_type(&self) -> &str; // } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct JellyfinConfig { pub username: String, pub password: String, pub server_url: iref::IriBuf, pub device_id: String, } impl JellyfinConfig { pub fn new( username: String, password: String, server_url: impl AsRef, device_id: String, ) -> Self { JellyfinConfig { username, password, server_url: iref::IriBuf::new(server_url.as_ref().into()) .expect("Failed to parse server URL"), device_id, } } } #[test] fn test_client_authenticate() { let config = JellyfinConfig { username: "servius".to_string(), password: "nfz6yqr_NZD1nxk!faj".to_string(), server_url: iref::IriBuf::new("https://jellyfin.tsuba.darksailor.dev".into()).unwrap(), device_id: "testdeviceid".to_string(), }; let mut client = JellyfinClient::new(config); let auth_result = tokio_test::block_on(client.authenticate()); assert!(auth_result.is_ok()); }