1use crate::{url::MYPLEX_DEFAULT_API_URL, Result};
2use core::convert::TryFrom;
3use http::{uri::PathAndQuery, HeaderValue, StatusCode, Uri};
4use isahc::{
5 config::{Configurable, RedirectPolicy},
6 http::request::Builder,
7 AsyncBody, AsyncReadResponseExt, HttpClient as IsahcHttpClient, Request as HttpRequest,
8 Response as HttpResponse,
9};
10use secrecy::{ExposeSecret, SecretString};
11use serde::{de::DeserializeOwned, Serialize};
12use std::time::Duration;
13use uuid::Uuid;
14
15const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
16const DEFAULT_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
17
18#[derive(Debug, Clone)]
19pub struct HttpClient {
20 pub api_url: Uri,
21
22 pub http_client: IsahcHttpClient,
23
24 pub x_plex_provides: String,
28
29 pub x_plex_platform: String,
33
34 pub x_plex_platform_version: String,
38
39 pub x_plex_product: String,
43
44 pub x_plex_version: String,
48
49 pub x_plex_device: String,
53
54 pub x_plex_device_name: String,
58
59 pub x_plex_client_identifier: String,
65
66 x_plex_token: SecretString,
70
71 pub x_plex_sync_version: String,
75
76 pub x_plex_model: String,
80
81 pub x_plex_features: String,
85
86 pub x_plex_target_client_identifier: String,
90}
91
92impl HttpClient {
93 fn prepare_request(&self) -> Builder {
94 self.prepare_request_min()
95 .header("X-Plex-Provides", &self.x_plex_provides)
96 .header("X-Plex-Platform", &self.x_plex_platform)
97 .header("X-Plex-Platform-Version", &self.x_plex_platform_version)
98 .header("X-Plex-Product", &self.x_plex_product)
99 .header("X-Plex-Version", &self.x_plex_version)
100 .header("X-Plex-Device", &self.x_plex_device)
101 .header("X-Plex-Device-Name", &self.x_plex_device_name)
102 .header("X-Plex-Sync-Version", &self.x_plex_sync_version)
103 .header("X-Plex-Model", &self.x_plex_model)
104 .header("X-Plex-Features", &self.x_plex_features)
105 }
106
107 fn prepare_request_min(&self) -> Builder {
108 let mut request = HttpRequest::builder()
109 .header("X-Plex-Client-Identifier", &self.x_plex_client_identifier);
110
111 if !self.x_plex_target_client_identifier.is_empty() {
112 request = request.header(
113 "X-Plex-Target-Client-Identifier",
114 &self.x_plex_target_client_identifier,
115 );
116 }
117
118 if !self.x_plex_token.expose_secret().is_empty() {
119 request = request.header("X-Plex-Token", self.x_plex_token.expose_secret());
120 }
121
122 request
123 }
124
125 pub fn is_authenticated(&self) -> bool {
127 !self.x_plex_token.expose_secret().is_empty()
128 }
129
130 pub fn post<T>(&self, path: T) -> RequestBuilder<'_, T>
132 where
133 PathAndQuery: TryFrom<T>,
134 <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
135 {
136 RequestBuilder {
137 http_client: &self.http_client,
138 base_url: self.api_url.clone(),
139 path_and_query: path,
140 request_builder: self.prepare_request().method("POST"),
141 timeout: Some(DEFAULT_TIMEOUT),
142 }
143 }
144
145 pub fn postm<T>(&self, path: T) -> RequestBuilder<'_, T>
148 where
149 PathAndQuery: TryFrom<T>,
150 <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
151 {
152 RequestBuilder {
153 http_client: &self.http_client,
154 base_url: self.api_url.clone(),
155 path_and_query: path,
156 request_builder: self.prepare_request_min().method("POST"),
157 timeout: Some(DEFAULT_TIMEOUT),
158 }
159 }
160
161 pub fn get<T>(&self, path: T) -> RequestBuilder<'_, T>
163 where
164 PathAndQuery: TryFrom<T>,
165 <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
166 {
167 RequestBuilder {
168 http_client: &self.http_client,
169 base_url: self.api_url.clone(),
170 path_and_query: path,
171 request_builder: self.prepare_request().method("GET"),
172 timeout: Some(DEFAULT_TIMEOUT),
173 }
174 }
175
176 pub fn getm<T>(&self, path: T) -> RequestBuilder<'_, T>
179 where
180 PathAndQuery: TryFrom<T>,
181 <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
182 {
183 RequestBuilder {
184 http_client: &self.http_client,
185 base_url: self.api_url.clone(),
186 path_and_query: path,
187 request_builder: self.prepare_request_min().method("GET"),
188 timeout: Some(DEFAULT_TIMEOUT),
189 }
190 }
191
192 pub fn put<T>(&self, path: T) -> RequestBuilder<'_, T>
194 where
195 PathAndQuery: TryFrom<T>,
196 <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
197 {
198 RequestBuilder {
199 http_client: &self.http_client,
200 base_url: self.api_url.clone(),
201 path_and_query: path,
202 request_builder: self.prepare_request().method("PUT"),
203 timeout: Some(DEFAULT_TIMEOUT),
204 }
205 }
206
207 pub fn putm<T>(&self, path: T) -> RequestBuilder<'_, T>
210 where
211 PathAndQuery: TryFrom<T>,
212 <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
213 {
214 RequestBuilder {
215 http_client: &self.http_client,
216 base_url: self.api_url.clone(),
217 path_and_query: path,
218 request_builder: self.prepare_request_min().method("PUT"),
219 timeout: Some(DEFAULT_TIMEOUT),
220 }
221 }
222
223 pub fn delete<T>(&self, path: T) -> RequestBuilder<'_, T>
225 where
226 PathAndQuery: TryFrom<T>,
227 <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
228 {
229 RequestBuilder {
230 http_client: &self.http_client,
231 base_url: self.api_url.clone(),
232 path_and_query: path,
233 request_builder: self.prepare_request().method("DELETE"),
234 timeout: Some(DEFAULT_TIMEOUT),
235 }
236 }
237
238 pub fn deletem<T>(&self, path: T) -> RequestBuilder<'_, T>
241 where
242 PathAndQuery: TryFrom<T>,
243 <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
244 {
245 RequestBuilder {
246 http_client: &self.http_client,
247 base_url: self.api_url.clone(),
248 path_and_query: path,
249 request_builder: self.prepare_request_min().method("DELETE"),
250 timeout: Some(DEFAULT_TIMEOUT),
251 }
252 }
253
254 pub fn set_x_plex_token<T>(self, x_plex_token: T) -> Self
256 where
257 T: Into<SecretString>,
258 {
259 Self {
260 x_plex_token: x_plex_token.into(),
261 ..self
262 }
263 }
264
265 pub fn x_plex_token(&self) -> &str {
267 self.x_plex_token.expose_secret()
268 }
269}
270
271impl From<&HttpClient> for HttpClient {
272 fn from(value: &HttpClient) -> Self {
273 value.to_owned()
274 }
275}
276
277pub struct RequestBuilder<'a, P>
278where
279 PathAndQuery: TryFrom<P>,
280 <PathAndQuery as TryFrom<P>>::Error: Into<http::Error>,
281{
282 http_client: &'a IsahcHttpClient,
283 base_url: Uri,
284 path_and_query: P,
285 request_builder: Builder,
286 timeout: Option<Duration>,
287}
288
289impl<'a, P> RequestBuilder<'a, P>
290where
291 PathAndQuery: TryFrom<P>,
292 <PathAndQuery as TryFrom<P>>::Error: Into<http::Error>,
293{
294 #[must_use]
296 pub fn timeout(self, timeout: Option<Duration>) -> Self {
297 Self {
298 http_client: self.http_client,
299 base_url: self.base_url,
300 path_and_query: self.path_and_query,
301 request_builder: self.request_builder,
302 timeout,
303 }
304 }
305
306 pub fn body<B>(self, body: B) -> Result<Request<'a, B>>
308 where
309 B: Into<AsyncBody>,
310 {
311 let path_and_query = PathAndQuery::try_from(self.path_and_query).map_err(Into::into)?;
312 let mut uri_parts = self.base_url.into_parts();
313 uri_parts.path_and_query = Some(path_and_query);
314 let uri = Uri::from_parts(uri_parts).map_err(Into::<http::Error>::into)?;
315
316 let mut builder = self.request_builder.uri(uri);
317 if let Some(timeout) = self.timeout {
318 builder = builder.timeout(timeout);
319 }
320
321 Ok(Request {
322 http_client: self.http_client,
323 request: builder.body(body)?,
324 })
325 }
326
327 pub fn json_body<B>(self, body: &B) -> Result<Request<'a, String>>
330 where
331 B: ?Sized + Serialize,
332 {
333 self.header("Content-type", "application/json")
334 .body(serde_json::to_string(body)?)
335 }
336
337 pub fn form(self, params: &[(&str, &str)]) -> Result<Request<'a, String>> {
339 let body = serde_urlencoded::to_string(params)?;
340 self.header("Content-type", "application/x-www-form-urlencoded")
341 .header("Content-Length", body.len().to_string())
342 .body(body)
343 }
344
345 #[must_use]
347 pub fn header<K, V>(self, key: K, value: V) -> Self
348 where
349 http::header::HeaderName: TryFrom<K>,
350 <http::header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
351 http::header::HeaderValue: TryFrom<V>,
352 <http::header::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
353 {
354 Self {
355 http_client: self.http_client,
356 base_url: self.base_url,
357 path_and_query: self.path_and_query,
358 request_builder: self.request_builder.header(key, value),
359 timeout: self.timeout,
360 }
361 }
362
363 pub async fn send(self) -> Result<HttpResponse<AsyncBody>> {
365 self.body(())?.send().await
366 }
367
368 pub async fn json<T: DeserializeOwned + Unpin>(self) -> Result<T> {
370 self.body(())?.json().await
371 }
372
373 pub async fn xml<T: DeserializeOwned + Unpin>(self) -> Result<T> {
375 self.body(())?.xml().await
376 }
377
378 pub async fn consume(self) -> Result<()> {
380 let mut response = self.header("Accept", "application/json").send().await?;
381
382 match response.status() {
383 StatusCode::OK => {
384 response.consume().await?;
385 Ok(())
386 }
387 _ => Err(crate::Error::from_response(response).await),
388 }
389 }
390}
391
392pub struct Request<'a, T> {
393 http_client: &'a IsahcHttpClient,
394 request: HttpRequest<T>,
395}
396
397impl<'a, T> Request<'a, T>
398where
399 T: Into<AsyncBody>,
400{
401 pub async fn send(self) -> Result<HttpResponse<AsyncBody>> {
403 Ok(self.http_client.send_async(self.request).await?)
404 }
405
406 pub async fn json<R: DeserializeOwned + Unpin>(mut self) -> Result<R> {
408 let headers = self.request.headers_mut();
409 headers.insert("Accept", HeaderValue::from_static("application/json"));
410
411 let mut response = self.send().await?;
412
413 match response.status() {
414 StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED => {
415 let body = response.text().await?;
416 match serde_json::from_str(&body) {
417 Ok(response) => Ok(response),
418 Err(error) => {
419 #[cfg(feature = "tests_deny_unknown_fields")]
420 #[allow(clippy::print_stdout)]
422 {
423 println!("Received body: {body}");
424 }
425 Err(error.into())
426 }
427 }
428 }
429 _ => Err(crate::Error::from_response(response).await),
430 }
431 }
432
433 pub async fn xml<R: DeserializeOwned + Unpin>(mut self) -> Result<R> {
435 let headers = self.request.headers_mut();
436 headers.insert("Accept", HeaderValue::from_static("application/xml"));
437
438 let mut response = self.send().await?;
439
440 match response.status() {
441 StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED => {
442 let body = response.text().await?;
443 match quick_xml::de::from_str(&body) {
444 Ok(response) => Ok(response),
445 Err(error) => {
446 #[cfg(feature = "tests_deny_unknown_fields")]
447 #[allow(clippy::print_stdout)]
449 {
450 println!("Received body: {body}");
451 }
452 Err(error.into())
453 }
454 }
455 }
456 _ => Err(crate::Error::from_response(response).await),
457 }
458 }
459}
460
461pub struct HttpClientBuilder {
462 client: Result<HttpClient>,
463}
464
465impl Default for HttpClientBuilder {
466 fn default() -> Self {
467 let sys_platform = sys_info::os_type().unwrap_or_else(|_| "unknown".to_string());
468 let sys_version = sys_info::os_release().unwrap_or_else(|_| "unknown".to_string());
469 let sys_hostname = sys_info::hostname().unwrap_or_else(|_| "unknown".to_string());
470
471 let random_uuid = Uuid::new_v4();
472
473 let client = HttpClient {
474 api_url: Uri::from_static(MYPLEX_DEFAULT_API_URL),
475 http_client: IsahcHttpClient::builder()
476 .connect_timeout(DEFAULT_CONNECTION_TIMEOUT)
477 .redirect_policy(RedirectPolicy::None)
478 .build()
479 .expect("failed to create default http client"),
480 x_plex_provides: String::from("controller"),
481 x_plex_product: option_env!("CARGO_PKG_NAME")
482 .unwrap_or("plex-api")
483 .to_string(),
484 x_plex_platform: sys_platform.clone(),
485 x_plex_platform_version: sys_version,
486 x_plex_version: option_env!("CARGO_PKG_VERSION")
487 .unwrap_or("unknown")
488 .to_string(),
489 x_plex_device: sys_platform,
490 x_plex_device_name: sys_hostname,
491 x_plex_client_identifier: random_uuid.to_string(),
492 x_plex_sync_version: String::from("2"),
493 x_plex_token: SecretString::new("".to_owned()),
494 x_plex_model: String::from("hosted"),
495 x_plex_features: String::from("external-media,indirect-media,hub-style-list"),
496 x_plex_target_client_identifier: String::from(""),
497 };
498
499 Self { client: Ok(client) }
500 }
501}
502
503impl HttpClientBuilder {
504 pub fn generic() -> Self {
507 Self::default().set_x_plex_platform("Generic")
508 }
509
510 pub fn build(self) -> Result<HttpClient> {
511 self.client
512 }
513
514 pub fn set_http_client(self, http_client: IsahcHttpClient) -> Self {
515 Self {
516 client: self.client.map(move |mut client| {
517 client.http_client = http_client;
518 client
519 }),
520 }
521 }
522
523 pub fn from(client: HttpClient) -> Self {
524 Self { client: Ok(client) }
525 }
526
527 pub fn new<U>(api_url: U) -> Self
528 where
529 Uri: TryFrom<U>,
530 <Uri as TryFrom<U>>::Error: Into<http::Error>,
531 {
532 Self::default().set_api_url(api_url)
533 }
534
535 pub fn set_api_url<U>(self, api_url: U) -> Self
536 where
537 Uri: TryFrom<U>,
538 <Uri as TryFrom<U>>::Error: Into<http::Error>,
539 {
540 Self {
541 client: self.client.and_then(move |mut client| {
542 client.api_url = Uri::try_from(api_url).map_err(Into::into)?;
543 Ok(client)
544 }),
545 }
546 }
547
548 pub fn set_x_plex_token<S: Into<SecretString>>(self, token: S) -> Self {
549 Self {
550 client: self.client.map(move |mut client| {
551 client.x_plex_token = token.into();
552 client
553 }),
554 }
555 }
556
557 pub fn set_x_plex_client_identifier<S: Into<String>>(self, client_identifier: S) -> Self {
558 Self {
559 client: self.client.map(move |mut client| {
560 client.x_plex_client_identifier = client_identifier.into();
561 client
562 }),
563 }
564 }
565
566 pub fn set_x_plex_provides(self, x_plex_provides: &[&str]) -> Self {
567 Self {
568 client: self.client.map(move |mut client| {
569 client.x_plex_provides = x_plex_provides.join(",");
570 client
571 }),
572 }
573 }
574
575 pub fn set_x_plex_platform<S: Into<String>>(self, platform: S) -> Self {
576 Self {
577 client: self.client.map(move |mut client| {
578 client.x_plex_platform = platform.into();
579 client
580 }),
581 }
582 }
583
584 pub fn set_x_plex_platform_version<S: Into<String>>(self, platform_version: S) -> Self {
585 Self {
586 client: self.client.map(move |mut client| {
587 client.x_plex_platform_version = platform_version.into();
588 client
589 }),
590 }
591 }
592
593 pub fn set_x_plex_product<S: Into<String>>(self, product: S) -> Self {
594 Self {
595 client: self.client.map(move |mut client| {
596 client.x_plex_product = product.into();
597 client
598 }),
599 }
600 }
601
602 pub fn set_x_plex_version<S: Into<String>>(self, version: S) -> Self {
603 Self {
604 client: self.client.map(move |mut client| {
605 client.x_plex_version = version.into();
606 client
607 }),
608 }
609 }
610
611 pub fn set_x_plex_device<S: Into<String>>(self, device: S) -> Self {
612 Self {
613 client: self.client.map(move |mut client| {
614 client.x_plex_device = device.into();
615 client
616 }),
617 }
618 }
619
620 pub fn set_x_plex_device_name<S: Into<String>>(self, device_name: S) -> Self {
621 Self {
622 client: self.client.map(move |mut client| {
623 client.x_plex_device_name = device_name.into();
624 client
625 }),
626 }
627 }
628
629 pub fn set_x_plex_model<S: Into<String>>(self, model: S) -> Self {
630 Self {
631 client: self.client.map(move |mut client| {
632 client.x_plex_model = model.into();
633 client
634 }),
635 }
636 }
637
638 pub fn set_x_plex_features(self, features: &[&str]) -> Self {
639 Self {
640 client: self.client.map(move |mut client| {
641 client.x_plex_features = features.join(",");
642 client
643 }),
644 }
645 }
646}