use crate::{url::MYPLEX_DEFAULT_API_URL, Result};
use core::convert::TryFrom;
use http::{uri::PathAndQuery, HeaderValue, StatusCode, Uri};
use isahc::{
config::{Configurable, RedirectPolicy},
http::request::Builder,
AsyncBody, AsyncReadResponseExt, HttpClient as IsahcHttpClient, Request as HttpRequest,
Response as HttpResponse,
};
use secrecy::{ExposeSecret, SecretString};
use serde::{de::DeserializeOwned, Serialize};
use std::time::Duration;
use uuid::Uuid;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
pub struct HttpClient {
pub api_url: Uri,
pub http_client: IsahcHttpClient,
pub x_plex_provides: String,
pub x_plex_platform: String,
pub x_plex_platform_version: String,
pub x_plex_product: String,
pub x_plex_version: String,
pub x_plex_device: String,
pub x_plex_device_name: String,
pub x_plex_client_identifier: String,
x_plex_token: SecretString,
pub x_plex_sync_version: String,
pub x_plex_model: String,
pub x_plex_features: String,
pub x_plex_target_client_identifier: String,
}
impl HttpClient {
fn prepare_request(&self) -> Builder {
self.prepare_request_min()
.header("X-Plex-Provides", &self.x_plex_provides)
.header("X-Plex-Platform", &self.x_plex_platform)
.header("X-Plex-Platform-Version", &self.x_plex_platform_version)
.header("X-Plex-Product", &self.x_plex_product)
.header("X-Plex-Version", &self.x_plex_version)
.header("X-Plex-Device", &self.x_plex_device)
.header("X-Plex-Device-Name", &self.x_plex_device_name)
.header("X-Plex-Sync-Version", &self.x_plex_sync_version)
.header("X-Plex-Model", &self.x_plex_model)
.header("X-Plex-Features", &self.x_plex_features)
}
fn prepare_request_min(&self) -> Builder {
let mut request = HttpRequest::builder()
.header("X-Plex-Client-Identifier", &self.x_plex_client_identifier);
if !self.x_plex_target_client_identifier.is_empty() {
request = request.header(
"X-Plex-Target-Client-Identifier",
&self.x_plex_target_client_identifier,
);
}
if !self.x_plex_token.expose_secret().is_empty() {
request = request.header("X-Plex-Token", self.x_plex_token.expose_secret());
}
request
}
pub fn is_authenticated(&self) -> bool {
!self.x_plex_token.expose_secret().is_empty()
}
pub fn post<T>(&self, path: T) -> RequestBuilder<'_, T>
where
PathAndQuery: TryFrom<T>,
<PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
{
RequestBuilder {
http_client: &self.http_client,
base_url: self.api_url.clone(),
path_and_query: path,
request_builder: self.prepare_request().method("POST"),
timeout: Some(DEFAULT_TIMEOUT),
}
}
pub fn postm<T>(&self, path: T) -> RequestBuilder<'_, T>
where
PathAndQuery: TryFrom<T>,
<PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
{
RequestBuilder {
http_client: &self.http_client,
base_url: self.api_url.clone(),
path_and_query: path,
request_builder: self.prepare_request_min().method("POST"),
timeout: Some(DEFAULT_TIMEOUT),
}
}
pub fn get<T>(&self, path: T) -> RequestBuilder<'_, T>
where
PathAndQuery: TryFrom<T>,
<PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
{
RequestBuilder {
http_client: &self.http_client,
base_url: self.api_url.clone(),
path_and_query: path,
request_builder: self.prepare_request().method("GET"),
timeout: Some(DEFAULT_TIMEOUT),
}
}
pub fn getm<T>(&self, path: T) -> RequestBuilder<'_, T>
where
PathAndQuery: TryFrom<T>,
<PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
{
RequestBuilder {
http_client: &self.http_client,
base_url: self.api_url.clone(),
path_and_query: path,
request_builder: self.prepare_request_min().method("GET"),
timeout: Some(DEFAULT_TIMEOUT),
}
}
pub fn put<T>(&self, path: T) -> RequestBuilder<'_, T>
where
PathAndQuery: TryFrom<T>,
<PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
{
RequestBuilder {
http_client: &self.http_client,
base_url: self.api_url.clone(),
path_and_query: path,
request_builder: self.prepare_request().method("PUT"),
timeout: Some(DEFAULT_TIMEOUT),
}
}
pub fn putm<T>(&self, path: T) -> RequestBuilder<'_, T>
where
PathAndQuery: TryFrom<T>,
<PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
{
RequestBuilder {
http_client: &self.http_client,
base_url: self.api_url.clone(),
path_and_query: path,
request_builder: self.prepare_request_min().method("PUT"),
timeout: Some(DEFAULT_TIMEOUT),
}
}
pub fn delete<T>(&self, path: T) -> RequestBuilder<'_, T>
where
PathAndQuery: TryFrom<T>,
<PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
{
RequestBuilder {
http_client: &self.http_client,
base_url: self.api_url.clone(),
path_and_query: path,
request_builder: self.prepare_request().method("DELETE"),
timeout: Some(DEFAULT_TIMEOUT),
}
}
pub fn deletem<T>(&self, path: T) -> RequestBuilder<'_, T>
where
PathAndQuery: TryFrom<T>,
<PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
{
RequestBuilder {
http_client: &self.http_client,
base_url: self.api_url.clone(),
path_and_query: path,
request_builder: self.prepare_request_min().method("DELETE"),
timeout: Some(DEFAULT_TIMEOUT),
}
}
pub fn set_x_plex_token<T>(self, x_plex_token: T) -> Self
where
T: Into<SecretString>,
{
Self {
x_plex_token: x_plex_token.into(),
..self
}
}
pub fn x_plex_token(&self) -> &str {
self.x_plex_token.expose_secret()
}
}
impl From<&HttpClient> for HttpClient {
fn from(value: &HttpClient) -> Self {
value.to_owned()
}
}
pub struct RequestBuilder<'a, P>
where
PathAndQuery: TryFrom<P>,
<PathAndQuery as TryFrom<P>>::Error: Into<http::Error>,
{
http_client: &'a IsahcHttpClient,
base_url: Uri,
path_and_query: P,
request_builder: Builder,
timeout: Option<Duration>,
}
impl<'a, P> RequestBuilder<'a, P>
where
PathAndQuery: TryFrom<P>,
<PathAndQuery as TryFrom<P>>::Error: Into<http::Error>,
{
#[must_use]
pub fn timeout(self, timeout: Option<Duration>) -> Self {
Self {
http_client: self.http_client,
base_url: self.base_url,
path_and_query: self.path_and_query,
request_builder: self.request_builder,
timeout,
}
}
pub fn body<B>(self, body: B) -> Result<Request<'a, B>>
where
B: Into<AsyncBody>,
{
let path_and_query = PathAndQuery::try_from(self.path_and_query).map_err(Into::into)?;
let mut uri_parts = self.base_url.into_parts();
uri_parts.path_and_query = Some(path_and_query);
let uri = Uri::from_parts(uri_parts).map_err(Into::<http::Error>::into)?;
let mut builder = self.request_builder.uri(uri);
if let Some(timeout) = self.timeout {
builder = builder.timeout(timeout);
}
Ok(Request {
http_client: self.http_client,
request: builder.body(body)?,
})
}
pub fn json_body<B>(self, body: &B) -> Result<Request<'a, String>>
where
B: ?Sized + Serialize,
{
self.header("Content-type", "application/json")
.body(serde_json::to_string(body)?)
}
pub fn form(self, params: &[(&str, &str)]) -> Result<Request<'a, String>> {
let body = serde_urlencoded::to_string(params)?;
self.header("Content-type", "application/x-www-form-urlencoded")
.header("Content-Length", body.len().to_string())
.body(body)
}
#[must_use]
pub fn header<K, V>(self, key: K, value: V) -> Self
where
http::header::HeaderName: TryFrom<K>,
<http::header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
http::header::HeaderValue: TryFrom<V>,
<http::header::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
{
Self {
http_client: self.http_client,
base_url: self.base_url,
path_and_query: self.path_and_query,
request_builder: self.request_builder.header(key, value),
timeout: self.timeout,
}
}
pub async fn send(self) -> Result<HttpResponse<AsyncBody>> {
self.body(())?.send().await
}
pub async fn json<T: DeserializeOwned + Unpin>(self) -> Result<T> {
self.body(())?.json().await
}
pub async fn xml<T: DeserializeOwned + Unpin>(self) -> Result<T> {
self.body(())?.xml().await
}
pub async fn consume(self) -> Result<()> {
let mut response = self.header("Accept", "application/json").send().await?;
match response.status() {
StatusCode::OK => {
response.consume().await?;
Ok(())
}
_ => Err(crate::Error::from_response(response).await),
}
}
}
pub struct Request<'a, T> {
http_client: &'a IsahcHttpClient,
request: HttpRequest<T>,
}
impl<'a, T> Request<'a, T>
where
T: Into<AsyncBody>,
{
pub async fn send(self) -> Result<HttpResponse<AsyncBody>> {
Ok(self.http_client.send_async(self.request).await?)
}
pub async fn json<R: DeserializeOwned + Unpin>(mut self) -> Result<R> {
let headers = self.request.headers_mut();
headers.insert("Accept", HeaderValue::from_static("application/json"));
let mut response = self.send().await?;
match response.status() {
StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED => {
let body = response.text().await?;
match serde_json::from_str(&body) {
Ok(response) => Ok(response),
Err(error) => {
#[cfg(feature = "tests_deny_unknown_fields")]
#[allow(clippy::print_stdout)]
{
println!("Received body: {body}");
}
Err(error.into())
}
}
}
_ => Err(crate::Error::from_response(response).await),
}
}
pub async fn xml<R: DeserializeOwned + Unpin>(mut self) -> Result<R> {
let headers = self.request.headers_mut();
headers.insert("Accept", HeaderValue::from_static("application/xml"));
let mut response = self.send().await?;
match response.status() {
StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED => {
let body = response.text().await?;
match quick_xml::de::from_str(&body) {
Ok(response) => Ok(response),
Err(error) => {
#[cfg(feature = "tests_deny_unknown_fields")]
#[allow(clippy::print_stdout)]
{
println!("Received body: {body}");
}
Err(error.into())
}
}
}
_ => Err(crate::Error::from_response(response).await),
}
}
}
pub struct HttpClientBuilder {
client: Result<HttpClient>,
}
impl Default for HttpClientBuilder {
fn default() -> Self {
let sys_platform = sys_info::os_type().unwrap_or_else(|_| "unknown".to_string());
let sys_version = sys_info::os_release().unwrap_or_else(|_| "unknown".to_string());
let sys_hostname = sys_info::hostname().unwrap_or_else(|_| "unknown".to_string());
let random_uuid = Uuid::new_v4();
let client = HttpClient {
api_url: Uri::from_static(MYPLEX_DEFAULT_API_URL),
http_client: IsahcHttpClient::builder()
.connect_timeout(DEFAULT_CONNECTION_TIMEOUT)
.redirect_policy(RedirectPolicy::None)
.build()
.expect("failed to create default http client"),
x_plex_provides: String::from("controller"),
x_plex_product: option_env!("CARGO_PKG_NAME")
.unwrap_or("plex-api")
.to_string(),
x_plex_platform: sys_platform.clone(),
x_plex_platform_version: sys_version,
x_plex_version: option_env!("CARGO_PKG_VERSION")
.unwrap_or("unknown")
.to_string(),
x_plex_device: sys_platform,
x_plex_device_name: sys_hostname,
x_plex_client_identifier: random_uuid.to_string(),
x_plex_sync_version: String::from("2"),
x_plex_token: SecretString::new("".to_owned()),
x_plex_model: String::from("hosted"),
x_plex_features: String::from("external-media,indirect-media,hub-style-list"),
x_plex_target_client_identifier: String::from(""),
};
Self { client: Ok(client) }
}
}
impl HttpClientBuilder {
pub fn generic() -> Self {
Self::default().set_x_plex_platform("Generic")
}
pub fn build(self) -> Result<HttpClient> {
self.client
}
pub fn set_http_client(self, http_client: IsahcHttpClient) -> Self {
Self {
client: self.client.map(move |mut client| {
client.http_client = http_client;
client
}),
}
}
pub fn from(client: HttpClient) -> Self {
Self { client: Ok(client) }
}
pub fn new<U>(api_url: U) -> Self
where
Uri: TryFrom<U>,
<Uri as TryFrom<U>>::Error: Into<http::Error>,
{
Self::default().set_api_url(api_url)
}
pub fn set_api_url<U>(self, api_url: U) -> Self
where
Uri: TryFrom<U>,
<Uri as TryFrom<U>>::Error: Into<http::Error>,
{
Self {
client: self.client.and_then(move |mut client| {
client.api_url = Uri::try_from(api_url).map_err(Into::into)?;
Ok(client)
}),
}
}
pub fn set_x_plex_token<S: Into<SecretString>>(self, token: S) -> Self {
Self {
client: self.client.map(move |mut client| {
client.x_plex_token = token.into();
client
}),
}
}
pub fn set_x_plex_client_identifier<S: Into<String>>(self, client_identifier: S) -> Self {
Self {
client: self.client.map(move |mut client| {
client.x_plex_client_identifier = client_identifier.into();
client
}),
}
}
pub fn set_x_plex_provides(self, x_plex_provides: &[&str]) -> Self {
Self {
client: self.client.map(move |mut client| {
client.x_plex_provides = x_plex_provides.join(",");
client
}),
}
}
pub fn set_x_plex_platform<S: Into<String>>(self, platform: S) -> Self {
Self {
client: self.client.map(move |mut client| {
client.x_plex_platform = platform.into();
client
}),
}
}
pub fn set_x_plex_platform_version<S: Into<String>>(self, platform_version: S) -> Self {
Self {
client: self.client.map(move |mut client| {
client.x_plex_platform_version = platform_version.into();
client
}),
}
}
pub fn set_x_plex_product<S: Into<String>>(self, product: S) -> Self {
Self {
client: self.client.map(move |mut client| {
client.x_plex_product = product.into();
client
}),
}
}
pub fn set_x_plex_version<S: Into<String>>(self, version: S) -> Self {
Self {
client: self.client.map(move |mut client| {
client.x_plex_version = version.into();
client
}),
}
}
pub fn set_x_plex_device<S: Into<String>>(self, device: S) -> Self {
Self {
client: self.client.map(move |mut client| {
client.x_plex_device = device.into();
client
}),
}
}
pub fn set_x_plex_device_name<S: Into<String>>(self, device_name: S) -> Self {
Self {
client: self.client.map(move |mut client| {
client.x_plex_device_name = device_name.into();
client
}),
}
}
pub fn set_x_plex_model<S: Into<String>>(self, model: S) -> Self {
Self {
client: self.client.map(move |mut client| {
client.x_plex_model = model.into();
client
}),
}
}
pub fn set_x_plex_features(self, features: &[&str]) -> Self {
Self {
client: self.client.map(move |mut client| {
client.x_plex_features = features.join(",");
client
}),
}
}
}