diff --git a/Cargo.lock b/Cargo.lock index d1d79c6..89c733b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,7 +130,9 @@ dependencies = [ "reqwest", "serde", "serde_json", + "tap", "thiserror 2.0.17", + "tokio-test", ] [[package]] @@ -438,6 +440,28 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "async-task" version = "4.7.1" @@ -1452,6 +1476,12 @@ dependencies = [ "libloading", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -2978,8 +3008,10 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" name = "jello" version = "0.1.0" dependencies = [ + "api", "clap", "clap_complete", + "dotenvy", "error-stack", "gpui", "thiserror 2.0.17", @@ -5495,6 +5527,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-lexicon" version = "0.12.16" @@ -5661,9 +5699,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2 0.5.10", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -5696,6 +5746,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-util" version = "0.7.16" diff --git a/Cargo.toml b/Cargo.toml index d126a31..ddb5d38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,11 +8,13 @@ edition = "2024" license = "MIT" [dependencies] +api = { version = "0.1.0", path = "api" } clap = { version = "4.5", features = ["derive"] } clap_complete = "4.5" +dotenvy = "0.15.7" error-stack = "0.6" gpui = { version = "0.2.2", default-features = false, features = ["wayland"] } thiserror = "2.0" -tokio = "1.43.1" +tokio = { version = "1.43.1", features = ["macros", "rt-multi-thread"] } tracing = "0.1" tracing-subscriber = "0.3" diff --git a/api/Cargo.toml b/api/Cargo.toml index 6ecbe0a..45a5175 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -8,4 +8,8 @@ iref = { version = "3.2.2", features = ["serde"] } reqwest = { version = "0.12.24", features = ["json"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" +tap = "1.0.1" thiserror = "2.0.17" + +[dev-dependencies] +tokio-test = "0.4.4" diff --git a/api/src/lib.rs b/api/src/lib.rs index 256cd11..0b20bf9 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,9 +1,14 @@ pub mod jellyfin; + +use ::tap::*; 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), } type Result = std::result::Result; @@ -11,6 +16,7 @@ type Result = std::result::Result; #[derive(Debug, Clone)] pub struct JellyfinClient { client: reqwest::Client, + access_token: Option, config: JellyfinConfig, } @@ -18,26 +24,111 @@ impl JellyfinClient { pub fn new(config: JellyfinConfig) -> Self { JellyfinClient { client: reqwest::Client::new(), + access_token: None, config, } } - pub fn post(&self, uri: impl AsRef) -> reqwest::RequestBuilder { + pub fn save_token(&self, path: impl AsRef) -> std::io::Result<()> { + if let Some(token) = &self.access_token { + std::fs::write(path, token) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "No access token to save", + )) + } + } + + pub fn load_token(&mut self, path: impl AsRef) -> std::io::Result<()> { + let token = std::fs::read_to_string(path)?; + self.access_token = Some(token); + Ok(()) + } + + pub fn post_builder(&self, uri: impl AsRef) -> reqwest::RequestBuilder { let url = format!("{}/{}", self.config.server_url.as_str(), uri.as_ref()); self.client.post(&url) .header("X-Emby-Authorization", format!("MediaBrowser Client=\"Jello\", Device=\"Jello\", DeviceId=\"{}\", Version=\"1.0.0\"", self.config.device_id)) .header("Content-Type", "application/json") + .pipe(|builder| { + if let Some(token) = &self.access_token { + builder.header("X-MediaBrowser-Token", token) + } else { + builder + } + }) } - pub async fn authenticate(&mut self) -> Result<()> { - self.post("Users/AuthenticateByName") + 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 + .post_builder(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 + .get_builder(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 out = self + .post_builder("Users/AuthenticateByName") .json(&jellyfin::AuthenticateUserByName { username: Some(self.config.username.clone()), pw: Some(self.config.password.clone()), }) .send() + .await? + .error_for_status()? + .text() .await?; - Ok(()) + let auth_result: jellyfin::AuthenticationResult = serde_json::from_str(&out)?; + self.access_token = auth_result.access_token.clone(); + Ok(auth_result) + } + + async fn items(&self) -> Result { + let text = &self + .get_builder("/Items") + .send() + .await? + .error_for_status()? + .text() + .await?; + let out: jellyfin::BaseItemDtoQueryResult = serde_json::from_str(&text)?; + Ok(out) } } @@ -48,3 +139,33 @@ pub struct JellyfinConfig { 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()); +} diff --git a/src/main.rs b/src/main.rs index f3262cf..5e31bb7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,38 @@ mod errors; +use api::{JellyfinClient, JellyfinConfig}; +use errors::*; + use gpui::{ - div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window, - WindowBounds, WindowOptions, + App, Application, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div, + prelude::*, px, rgb, size, }; -pub fn main() { +#[tokio::main] +pub async fn main() -> Result<()> { + dotenvy::dotenv() + .change_context(Error) + .inspect_err(|err| { + eprintln!("Failed to load .env file: {}", err); + }) + .ok(); + let config = JellyfinConfig::new( + std::env::var("JELLYFIN_USERNAME").change_context(Error)?, + std::env::var("JELLYFIN_PASSWORD").change_context(Error)?, + std::env::var("JELLYFIN_SERVER_URL").change_context(Error)?, + "jello".to_string(), + ); + let mut jellyfin = api::JellyfinClient::new(config); + authenticate(&mut jellyfin).await?; + + Ok(()) } +pub async fn authenticate(client: &mut JellyfinClient) -> Result<()> { + if std::path::PathBuf::from(".session").exists() { + client.load_token(".session").change_context(Error)?; + } else { + client.authenticate().await.change_context(Error)?; + client.save_token(".session").change_context(Error)?; + } + Ok(()) +}