303 lines
9.3 KiB
Rust
303 lines
9.3 KiB
Rust
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<T, E = JellyfinApiError> = std::result::Result<T, E>;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct JellyfinClient {
|
|
client: reqwest::Client,
|
|
access_token: Option<Arc<str>>,
|
|
pub config: Arc<JellyfinConfig>,
|
|
}
|
|
|
|
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::path::Path>) -> 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::path::Path>,
|
|
) -> std::io::Result<String> {
|
|
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<str>) {
|
|
self.access_token = Some(token.as_ref().into());
|
|
}
|
|
|
|
pub fn request_builder(
|
|
&self,
|
|
method: reqwest::Method,
|
|
uri: impl AsRef<str>,
|
|
) -> 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<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
|
|
.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<U: serde::de::DeserializeOwned>(&self, uri: impl AsRef<str>) -> Result<U> {
|
|
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<jellyfin::AuthenticationResult> {
|
|
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<std::path::Path>,
|
|
) -> Result<String> {
|
|
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<jellyfin::BaseItemDtoQueryResult> {
|
|
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<Option<uuid::Uuid>>,
|
|
) -> Result<Vec<jellyfin::BaseItemDto>> {
|
|
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<str>) -> Result<Vec<jellyfin::BaseItemDto>> {
|
|
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<bytes::Bytes> {
|
|
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<jellyfin::PlaybackInfoDto> {
|
|
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<jellyfin::UserItemDataDto> {
|
|
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<url::Url> {
|
|
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<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());
|
|
}
|