plex_api/
http_client.rs

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    /// `X-Plex-Provides` header value. Comma-separated list.
25    ///
26    /// Should be one or more of `controller`, `server`, `sync-target`, `player`.
27    pub x_plex_provides: String,
28
29    /// `X-Plex-Platform` header value.
30    ///
31    /// Platform name, e.g. iOS, macOS, etc.
32    pub x_plex_platform: String,
33
34    /// `X-Plex-Platform-Version` header value.
35    ///
36    /// OS version, e.g. 4.3.1
37    pub x_plex_platform_version: String,
38
39    /// `X-Plex-Product` header value.
40    ///
41    /// Application name, e.g. Laika, Plex Media Server, Media Link.
42    pub x_plex_product: String,
43
44    /// `X-Plex-Version` header value.
45    ///
46    /// Application version, e.g. 10.6.7.
47    pub x_plex_version: String,
48
49    /// `X-Plex-Device` header value.
50    ///
51    /// Device name and model number, e.g. iPhone3,2, Motorola XOOMâ„¢, LG5200TV.
52    pub x_plex_device: String,
53
54    /// `X-Plex-Device-Name` header value.
55    ///
56    /// Primary name for the device, e.g. "Plex Web (Chrome)".
57    pub x_plex_device_name: String,
58
59    /// `X-Plex-Client-Identifier` header value.
60    ///
61    /// UUID, serial number, or other number unique per device.
62    ///
63    /// **N.B.** Should be unique for each of your devices.
64    pub x_plex_client_identifier: String,
65
66    /// `X-Plex-Token` header value.
67    ///
68    /// Auth token for Plex.
69    x_plex_token: SecretString,
70
71    /// `X-Plex-Sync-Version` header value.
72    ///
73    /// Not sure what are the valid values, but at the time of writing Plex Web sends `2` here.
74    pub x_plex_sync_version: String,
75
76    /// `X-Plex-Model` header value.
77    ///
78    /// Plex Web sends `hosted`
79    pub x_plex_model: String,
80
81    /// `X-Plex-Features` header value.
82    ///
83    /// Looks like it's a replacement for X-Plex-Provides
84    pub x_plex_features: String,
85
86    /// `X-Plex-Target-Client-Identifier` header value.
87    ///
88    /// Used when proxying a client request via a server.
89    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    /// Verifies that this client has an authentication token.
126    pub fn is_authenticated(&self) -> bool {
127        !self.x_plex_token.expose_secret().is_empty()
128    }
129
130    /// Begins building a request using the HTTP POST method.
131    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    /// Does the same as HttpClient::post(), but appends only bare minimum
146    /// headers: `X-Plex-Client-Identifier` and `X-Plex-Token`.
147    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    /// Begins building a request using the HTTP GET method.
162    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    /// Does the same as HttpClient::get(), but appends only bare minimum
177    /// headers: `X-Plex-Client-Identifier` and `X-Plex-Token`.
178    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    /// Begins building a request using the HTTP PUT method.
193    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    /// Does the same as HttpClient::put(), but appends only bare minimum
208    /// headers: `X-Plex-Client-Identifier` and `X-Plex-Token`.
209    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    /// Begins building a request using the HTTP DELETE method.
224    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    /// Does the same as HttpClient::delete(), but appends only bare minimum
239    /// headers: `X-Plex-Client-Identifier` and `X-Plex-Token`.
240    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    /// Set the client's authentication token.
255    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    /// Get a reference to the client's authentication token.
266    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    /// Sets the maximum timeout for this request or disables timeouts.
295    #[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    /// Adds a body to the request.
307    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    /// Serializes the provided struct as json and adds it as a body for the request.
328    /// Header "Content-type: application/json" will be added along the way.
329    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    /// Adds a form encoded parameters to the request body.
338    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    /// Adds a request header.
346    #[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    /// Sends this request generating a response.
364    pub async fn send(self) -> Result<HttpResponse<AsyncBody>> {
365        self.body(())?.send().await
366    }
367
368    /// Sends this request and attempts to decode the response as JSON.
369    pub async fn json<T: DeserializeOwned + Unpin>(self) -> Result<T> {
370        self.body(())?.json().await
371    }
372
373    /// Sends this request and attempts to decode the response as XML.
374    pub async fn xml<T: DeserializeOwned + Unpin>(self) -> Result<T> {
375        self.body(())?.xml().await
376    }
377
378    /// Sends this request, verifies success and then consumes any response.
379    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    /// Sends this request generating a response.
402    pub async fn send(self) -> Result<HttpResponse<AsyncBody>> {
403        Ok(self.http_client.send_async(self.request).await?)
404    }
405
406    /// Sends this request and attempts to decode the response as JSON.
407    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                        // We're in tests, so it's fine to print
421                        #[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    /// Sends this request and attempts to decode the response as XML.
434    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                        // We're in tests, so it's fine to print
448                        #[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    /// Creates a client that maps to Plex's Generic profile which has no
505    /// particular settings defined for transcoding.
506    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}