feat(api): enhance Jellyfin client with async requests and token mgmt
This commit is contained in:
74
Cargo.lock
generated
74
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
129
api/src/lib.rs
129
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<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());
|
||||
}
|
||||
|
||||
35
src/main.rs
35
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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user