feat(api): enhance Jellyfin client with async requests and token mgmt

This commit is contained in:
uttarayan21
2025-11-13 16:11:08 +05:30
parent ffd5562ed3
commit 5425031994
5 changed files with 238 additions and 8 deletions

74
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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<T, E = JellyfinApiError> = std::result::Result<T, E>;
@@ -11,6 +16,7 @@ type Result<T, E = JellyfinApiError> = std::result::Result<T, E>;
#[derive(Debug, Clone)]
pub struct JellyfinClient {
client: reqwest::Client,
access_token: Option<String>,
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<str>) -> reqwest::RequestBuilder {
pub fn save_token(&self, path: impl AsRef<std::path::Path>) -> 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::path::Path>) -> 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<str>) -> 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<str>) -> 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<T: Serialize + ?Sized, U: serde::de::DeserializeOwned>(
&self,
uri: impl AsRef<str>,
body: &T,
) -> Result<U> {
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<U: serde::de::DeserializeOwned>(&self, uri: impl AsRef<str>) -> Result<U> {
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<jellyfin::AuthenticationResult> {
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<jellyfin::BaseItemDtoQueryResult> {
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<str>,
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());
}

View File

@@ -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(())
}