plex_api/
http_client.rs

1use crate::{isahc_compat::StatusCodeExt, url::MYPLEX_DEFAULT_API_URL, Result};
2use http::{uri::PathAndQuery, StatusCode, Uri};
3use isahc::{
4    config::{Configurable, RedirectPolicy},
5    http::{request::Builder, HeaderValue as IsahcHeaderValue},
6    AsyncBody, AsyncReadResponseExt, HttpClient as IsahcHttpClient, Request as HttpRequest,
7    Response as HttpResponse,
8};
9use secrecy::{ExposeSecret, SecretString};
10use serde::{de::DeserializeOwned, Serialize};
11use std::time::Duration;
12use uuid::Uuid;
13
14const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
15const DEFAULT_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
16
17#[derive(Debug, Clone)]
18pub struct HttpClient {
19    pub api_url: Uri,
20
21    pub http_client: IsahcHttpClient,
22
23    /// `X-Plex-Provides` header value. Comma-separated list.
24    ///
25    /// Should be one or more of `controller`, `server`, `sync-target`, `player`.
26    pub x_plex_provides: String,
27
28    /// `X-Plex-Platform` header value.
29    ///
30    /// Platform name, e.g. iOS, macOS, etc.
31    pub x_plex_platform: String,
32
33    /// `X-Plex-Platform-Version` header value.
34    ///
35    /// OS version, e.g. 4.3.1
36    pub x_plex_platform_version: String,
37
38    /// `X-Plex-Product` header value.
39    ///
40    /// Application name, e.g. Laika, Plex Media Server, Media Link.
41    pub x_plex_product: String,
42
43    /// `X-Plex-Version` header value.
44    ///
45    /// Application version, e.g. 10.6.7.
46    pub x_plex_version: String,
47
48    /// `X-Plex-Device` header value.
49    ///
50    /// Device name and model number, e.g. iPhone3,2, Motorola XOOMâ„¢, LG5200TV.
51    pub x_plex_device: String,
52
53    /// `X-Plex-Device-Name` header value.
54    ///
55    /// Primary name for the device, e.g. "Plex Web (Chrome)".
56    pub x_plex_device_name: String,
57
58    /// `X-Plex-Client-Identifier` header value.
59    ///
60    /// UUID, serial number, or other number unique per device.
61    ///
62    /// **N.B.** Should be unique for each of your devices.
63    pub x_plex_client_identifier: String,
64
65    /// `X-Plex-Token` header value.
66    ///
67    /// Auth token for Plex.
68    x_plex_token: SecretString,
69
70    /// `X-Plex-Sync-Version` header value.
71    ///
72    /// Not sure what are the valid values, but at the time of writing Plex Web sends `2` here.
73    pub x_plex_sync_version: String,
74
75    /// `X-Plex-Model` header value.
76    ///
77    /// Plex Web sends `hosted`
78    pub x_plex_model: String,
79
80    /// `X-Plex-Features` header value.
81    ///
82    /// Looks like it's a replacement for X-Plex-Provides
83    pub x_plex_features: String,
84
85    /// `X-Plex-Target-Client-Identifier` header value.
86    ///
87    /// Used when proxying a client request via a server.
88    pub x_plex_target_client_identifier: String,
89}
90
91impl HttpClient {
92    fn prepare_request(&self) -> Builder {
93        self.prepare_request_min()
94            .header("X-Plex-Provides", &self.x_plex_provides)
95            .header("X-Plex-Platform", &self.x_plex_platform)
96            .header("X-Plex-Platform-Version", &self.x_plex_platform_version)
97            .header("X-Plex-Product", &self.x_plex_product)
98            .header("X-Plex-Version", &self.x_plex_version)
99            .header("X-Plex-Device", &self.x_plex_device)
100            .header("X-Plex-Device-Name", &self.x_plex_device_name)
101            .header("X-Plex-Sync-Version", &self.x_plex_sync_version)
102            .header("X-Plex-Model", &self.x_plex_model)
103            .header("X-Plex-Features", &self.x_plex_features)
104    }
105
106    fn prepare_request_min(&self) -> Builder {
107        let mut request = HttpRequest::builder()
108            .header("X-Plex-Client-Identifier", &self.x_plex_client_identifier);
109
110        if !self.x_plex_target_client_identifier.is_empty() {
111            request = request.header(
112                "X-Plex-Target-Client-Identifier",
113                &self.x_plex_target_client_identifier,
114            );
115        }
116
117        if !self.x_plex_token.expose_secret().is_empty() {
118            request = request.header("X-Plex-Token", self.x_plex_token.expose_secret());
119        }
120
121        request
122    }
123
124    /// Verifies that this client has an authentication token.
125    pub fn is_authenticated(&self) -> bool {
126        !self.x_plex_token.expose_secret().is_empty()
127    }
128
129    /// Begins building a request using the HTTP POST method.
130    pub fn post<T>(&self, path: T) -> RequestBuilder<'_, T>
131    where
132        PathAndQuery: TryFrom<T>,
133        <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
134    {
135        RequestBuilder {
136            http_client: &self.http_client,
137            base_url: self.api_url.clone(),
138            path_and_query: path,
139            request_builder: self.prepare_request().method("POST"),
140            timeout: Some(DEFAULT_TIMEOUT),
141        }
142    }
143
144    /// Does the same as HttpClient::post(), but appends only bare minimum
145    /// headers: `X-Plex-Client-Identifier` and `X-Plex-Token`.
146    pub fn postm<T>(&self, path: T) -> RequestBuilder<'_, T>
147    where
148        PathAndQuery: TryFrom<T>,
149        <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
150    {
151        RequestBuilder {
152            http_client: &self.http_client,
153            base_url: self.api_url.clone(),
154            path_and_query: path,
155            request_builder: self.prepare_request_min().method("POST"),
156            timeout: Some(DEFAULT_TIMEOUT),
157        }
158    }
159
160    /// Begins building a request using the HTTP HEAD method.
161    pub fn head<T>(&self, path: T) -> RequestBuilder<'_, T>
162    where
163        PathAndQuery: TryFrom<T>,
164        <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
165    {
166        RequestBuilder {
167            http_client: &self.http_client,
168            base_url: self.api_url.clone(),
169            path_and_query: path,
170            request_builder: self.prepare_request().method("HEAD"),
171            timeout: Some(DEFAULT_TIMEOUT),
172        }
173    }
174
175    /// Begins building a request using the HTTP GET method.
176    pub fn get<T>(&self, path: T) -> RequestBuilder<'_, T>
177    where
178        PathAndQuery: TryFrom<T>,
179        <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
180    {
181        RequestBuilder {
182            http_client: &self.http_client,
183            base_url: self.api_url.clone(),
184            path_and_query: path,
185            request_builder: self.prepare_request().method("GET"),
186            timeout: Some(DEFAULT_TIMEOUT),
187        }
188    }
189
190    /// Does the same as HttpClient::get(), but appends only bare minimum
191    /// headers: `X-Plex-Client-Identifier` and `X-Plex-Token`.
192    pub fn getm<T>(&self, path: T) -> RequestBuilder<'_, T>
193    where
194        PathAndQuery: TryFrom<T>,
195        <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
196    {
197        RequestBuilder {
198            http_client: &self.http_client,
199            base_url: self.api_url.clone(),
200            path_and_query: path,
201            request_builder: self.prepare_request_min().method("GET"),
202            timeout: Some(DEFAULT_TIMEOUT),
203        }
204    }
205
206    /// Begins building a request using the HTTP PUT method.
207    pub fn put<T>(&self, path: T) -> RequestBuilder<'_, T>
208    where
209        PathAndQuery: TryFrom<T>,
210        <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
211    {
212        RequestBuilder {
213            http_client: &self.http_client,
214            base_url: self.api_url.clone(),
215            path_and_query: path,
216            request_builder: self.prepare_request().method("PUT"),
217            timeout: Some(DEFAULT_TIMEOUT),
218        }
219    }
220
221    /// Does the same as HttpClient::put(), but appends only bare minimum
222    /// headers: `X-Plex-Client-Identifier` and `X-Plex-Token`.
223    pub fn putm<T>(&self, path: T) -> RequestBuilder<'_, T>
224    where
225        PathAndQuery: TryFrom<T>,
226        <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
227    {
228        RequestBuilder {
229            http_client: &self.http_client,
230            base_url: self.api_url.clone(),
231            path_and_query: path,
232            request_builder: self.prepare_request_min().method("PUT"),
233            timeout: Some(DEFAULT_TIMEOUT),
234        }
235    }
236
237    /// Begins building a request using the HTTP DELETE method.
238    pub fn delete<T>(&self, path: T) -> RequestBuilder<'_, T>
239    where
240        PathAndQuery: TryFrom<T>,
241        <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
242    {
243        RequestBuilder {
244            http_client: &self.http_client,
245            base_url: self.api_url.clone(),
246            path_and_query: path,
247            request_builder: self.prepare_request().method("DELETE"),
248            timeout: Some(DEFAULT_TIMEOUT),
249        }
250    }
251
252    /// Does the same as HttpClient::delete(), but appends only bare minimum
253    /// headers: `X-Plex-Client-Identifier` and `X-Plex-Token`.
254    pub fn deletem<T>(&self, path: T) -> RequestBuilder<'_, T>
255    where
256        PathAndQuery: TryFrom<T>,
257        <PathAndQuery as TryFrom<T>>::Error: Into<http::Error>,
258    {
259        RequestBuilder {
260            http_client: &self.http_client,
261            base_url: self.api_url.clone(),
262            path_and_query: path,
263            request_builder: self.prepare_request_min().method("DELETE"),
264            timeout: Some(DEFAULT_TIMEOUT),
265        }
266    }
267
268    /// Set the client's authentication token.
269    pub fn set_x_plex_token<T>(self, x_plex_token: T) -> Self
270    where
271        T: Into<SecretString>,
272    {
273        Self {
274            x_plex_token: x_plex_token.into(),
275            ..self
276        }
277    }
278
279    /// Get a reference to the client's authentication token.
280    pub fn x_plex_token(&self) -> &str {
281        self.x_plex_token.expose_secret()
282    }
283}
284
285impl From<&HttpClient> for HttpClient {
286    fn from(value: &HttpClient) -> Self {
287        value.to_owned()
288    }
289}
290
291pub struct RequestBuilder<'a, P>
292where
293    PathAndQuery: TryFrom<P>,
294    <PathAndQuery as TryFrom<P>>::Error: Into<http::Error>,
295{
296    http_client: &'a IsahcHttpClient,
297    base_url: Uri,
298    path_and_query: P,
299    request_builder: Builder,
300    timeout: Option<Duration>,
301}
302
303impl<'a, P> RequestBuilder<'a, P>
304where
305    PathAndQuery: TryFrom<P>,
306    <PathAndQuery as TryFrom<P>>::Error: Into<http::Error>,
307{
308    /// Sets the maximum timeout for this request or disables timeouts.
309    #[must_use]
310    pub fn timeout(self, timeout: Option<Duration>) -> Self {
311        Self {
312            http_client: self.http_client,
313            base_url: self.base_url,
314            path_and_query: self.path_and_query,
315            request_builder: self.request_builder,
316            timeout,
317        }
318    }
319
320    /// Adds a body to the request.
321    pub fn body<B>(self, body: B) -> Result<Request<'a, B>>
322    where
323        B: Into<AsyncBody>,
324    {
325        let path_and_query = PathAndQuery::try_from(self.path_and_query).map_err(Into::into)?;
326        let mut uri_parts = self.base_url.into_parts();
327        uri_parts.path_and_query = Some(path_and_query);
328        let uri = Uri::from_parts(uri_parts).map_err(Into::<http::Error>::into)?;
329        let uri_string = uri.to_string();
330
331        let mut builder = self.request_builder.uri(uri_string);
332        if let Some(timeout) = self.timeout {
333            builder = builder.timeout(timeout);
334        }
335
336        Ok(Request {
337            http_client: self.http_client,
338            request: builder.body(body)?,
339        })
340    }
341
342    /// Serializes the provided struct as json and adds it as a body for the request.
343    /// Header "Content-type: application/json" will be added along the way.
344    pub fn json_body<B>(self, body: &B) -> Result<Request<'a, String>>
345    where
346        B: ?Sized + Serialize,
347    {
348        self.header("Content-type", "application/json")
349            .body(serde_json::to_string(body)?)
350    }
351
352    /// Adds a form encoded parameters to the request body.
353    pub fn form(self, params: &[(&str, &str)]) -> Result<Request<'a, String>> {
354        let body = serde_urlencoded::to_string(params)?;
355        self.header("Content-type", "application/x-www-form-urlencoded")
356            .header("Content-Length", body.len().to_string())
357            .body(body)
358    }
359
360    /// Adds a request header.
361    #[must_use]
362    pub fn header<K, V>(self, key: K, value: V) -> Self
363    where
364        isahc::http::header::HeaderName: TryFrom<K>,
365        <isahc::http::header::HeaderName as TryFrom<K>>::Error: Into<isahc::http::Error>,
366        isahc::http::header::HeaderValue: TryFrom<V>,
367        <isahc::http::header::HeaderValue as TryFrom<V>>::Error: Into<isahc::http::Error>,
368    {
369        Self {
370            http_client: self.http_client,
371            base_url: self.base_url,
372            path_and_query: self.path_and_query,
373            request_builder: self.request_builder.header(key, value),
374            timeout: self.timeout,
375        }
376    }
377
378    /// Sends this request generating a response.
379    pub async fn send(self) -> Result<HttpResponse<AsyncBody>> {
380        self.body(())?.send().await
381    }
382
383    /// Sends this request and attempts to decode the response as JSON.
384    pub async fn json<T: DeserializeOwned + Unpin>(self) -> Result<T> {
385        self.body(())?.json().await
386    }
387
388    /// Sends this request and attempts to decode the response as XML.
389    pub async fn xml<T: DeserializeOwned + Unpin>(self) -> Result<T> {
390        self.body(())?.xml().await
391    }
392
393    /// Sends this request, verifies success and then consumes any response.
394    pub async fn consume(self) -> Result<()> {
395        let mut response = self.header("Accept", "application/json").send().await?;
396
397        match response.status().as_http_status() {
398            StatusCode::OK => {
399                response.consume().await?;
400                Ok(())
401            }
402            _ => Err(crate::Error::from_response(response).await),
403        }
404    }
405}
406
407pub struct Request<'a, T> {
408    http_client: &'a IsahcHttpClient,
409    request: HttpRequest<T>,
410}
411
412impl<'a, T> Request<'a, T>
413where
414    T: Into<AsyncBody>,
415{
416    /// Sends this request generating a response.
417    pub async fn send(self) -> Result<HttpResponse<AsyncBody>> {
418        Ok(self.http_client.send_async(self.request).await?)
419    }
420
421    /// Sends this request and attempts to decode the response as JSON.
422    pub async fn json<R: DeserializeOwned + Unpin>(mut self) -> Result<R> {
423        let headers = self.request.headers_mut();
424        headers.insert("Accept", IsahcHeaderValue::from_static("application/json"));
425
426        let mut response = self.send().await?;
427
428        match response.status().as_http_status() {
429            StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED => {
430                let body = response.text().await?;
431                match serde_json::from_str(&body) {
432                    Ok(response) => Ok(response),
433                    Err(error) => {
434                        #[cfg(feature = "tests_deny_unknown_fields")]
435                        // We're in tests, so it's fine to print
436                        #[allow(clippy::print_stdout)]
437                        {
438                            println!("Received body: {body}");
439                        }
440                        Err(error.into())
441                    }
442                }
443            }
444            _ => Err(crate::Error::from_response(response).await),
445        }
446    }
447
448    /// Sends this request and attempts to decode the response as XML.
449    pub async fn xml<R: DeserializeOwned + Unpin>(mut self) -> Result<R> {
450        let headers = self.request.headers_mut();
451        headers.insert("Accept", IsahcHeaderValue::from_static("application/xml"));
452
453        let mut response = self.send().await?;
454
455        match response.status().as_http_status() {
456            StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED => {
457                let body = response.text().await?;
458                match quick_xml::de::from_str(&body) {
459                    Ok(response) => Ok(response),
460                    Err(error) => {
461                        #[cfg(feature = "tests_deny_unknown_fields")]
462                        // We're in tests, so it's fine to print
463                        #[allow(clippy::print_stdout)]
464                        {
465                            println!("Received body: {body}");
466                        }
467                        Err(error.into())
468                    }
469                }
470            }
471            _ => Err(crate::Error::from_response(response).await),
472        }
473    }
474}
475
476pub struct HttpClientBuilder {
477    client: Result<HttpClient>,
478}
479
480impl Default for HttpClientBuilder {
481    fn default() -> Self {
482        let sys_platform = sysinfo::System::name().unwrap_or("unknown".to_string());
483        let sys_version = sysinfo::System::os_version().unwrap_or("unknown".to_string());
484        let sys_hostname = sysinfo::System::host_name().unwrap_or("unknown".to_string());
485
486        let random_uuid = Uuid::new_v4();
487
488        let client = HttpClient {
489            api_url: Uri::from_static(MYPLEX_DEFAULT_API_URL),
490            http_client: IsahcHttpClient::builder()
491                .connect_timeout(DEFAULT_CONNECTION_TIMEOUT)
492                .redirect_policy(RedirectPolicy::None)
493                .build()
494                .expect("failed to create default http client"),
495            x_plex_provides: String::from("controller"),
496            x_plex_product: option_env!("CARGO_PKG_NAME")
497                .unwrap_or("plex-api")
498                .to_string(),
499            x_plex_platform: sys_platform.clone(),
500            x_plex_platform_version: sys_version,
501            x_plex_version: option_env!("CARGO_PKG_VERSION")
502                .unwrap_or("unknown")
503                .to_string(),
504            x_plex_device: sys_platform,
505            x_plex_device_name: sys_hostname,
506            x_plex_client_identifier: random_uuid.to_string(),
507            x_plex_sync_version: String::from("2"),
508            x_plex_token: SecretString::new("".into()),
509            x_plex_model: String::from("hosted"),
510            x_plex_features: String::from("external-media,indirect-media,hub-style-list"),
511            x_plex_target_client_identifier: String::from(""),
512        };
513
514        Self { client: Ok(client) }
515    }
516}
517
518impl HttpClientBuilder {
519    /// Creates a client that maps to Plex's Generic profile which has no
520    /// particular settings defined for transcoding.
521    pub fn generic() -> Self {
522        Self::default().set_x_plex_platform("Generic")
523    }
524
525    pub fn build(self) -> Result<HttpClient> {
526        self.client
527    }
528
529    pub fn set_http_client(self, http_client: IsahcHttpClient) -> Self {
530        Self {
531            client: self.client.map(move |mut client| {
532                client.http_client = http_client;
533                client
534            }),
535        }
536    }
537
538    pub fn from(client: HttpClient) -> Self {
539        Self { client: Ok(client) }
540    }
541
542    pub fn new<U>(api_url: U) -> Self
543    where
544        Uri: TryFrom<U>,
545        <Uri as TryFrom<U>>::Error: Into<http::Error>,
546    {
547        Self::default().set_api_url(api_url)
548    }
549
550    pub fn set_api_url<U>(self, api_url: U) -> Self
551    where
552        Uri: TryFrom<U>,
553        <Uri as TryFrom<U>>::Error: Into<http::Error>,
554    {
555        Self {
556            client: self.client.and_then(move |mut client| {
557                client.api_url = Uri::try_from(api_url).map_err(Into::into)?;
558                Ok(client)
559            }),
560        }
561    }
562
563    pub fn set_x_plex_token<S: Into<SecretString>>(self, token: S) -> Self {
564        Self {
565            client: self.client.map(move |mut client| {
566                client.x_plex_token = token.into();
567                client
568            }),
569        }
570    }
571
572    pub fn set_x_plex_client_identifier<S: Into<String>>(self, client_identifier: S) -> Self {
573        Self {
574            client: self.client.map(move |mut client| {
575                client.x_plex_client_identifier = client_identifier.into();
576                client
577            }),
578        }
579    }
580
581    pub fn set_x_plex_provides(self, x_plex_provides: &[&str]) -> Self {
582        Self {
583            client: self.client.map(move |mut client| {
584                client.x_plex_provides = x_plex_provides.join(",");
585                client
586            }),
587        }
588    }
589
590    pub fn set_x_plex_platform<S: Into<String>>(self, platform: S) -> Self {
591        Self {
592            client: self.client.map(move |mut client| {
593                client.x_plex_platform = platform.into();
594                client
595            }),
596        }
597    }
598
599    pub fn set_x_plex_platform_version<S: Into<String>>(self, platform_version: S) -> Self {
600        Self {
601            client: self.client.map(move |mut client| {
602                client.x_plex_platform_version = platform_version.into();
603                client
604            }),
605        }
606    }
607
608    pub fn set_x_plex_product<S: Into<String>>(self, product: S) -> Self {
609        Self {
610            client: self.client.map(move |mut client| {
611                client.x_plex_product = product.into();
612                client
613            }),
614        }
615    }
616
617    pub fn set_x_plex_version<S: Into<String>>(self, version: S) -> Self {
618        Self {
619            client: self.client.map(move |mut client| {
620                client.x_plex_version = version.into();
621                client
622            }),
623        }
624    }
625
626    pub fn set_x_plex_device<S: Into<String>>(self, device: S) -> Self {
627        Self {
628            client: self.client.map(move |mut client| {
629                client.x_plex_device = device.into();
630                client
631            }),
632        }
633    }
634
635    pub fn set_x_plex_device_name<S: Into<String>>(self, device_name: S) -> Self {
636        Self {
637            client: self.client.map(move |mut client| {
638                client.x_plex_device_name = device_name.into();
639                client
640            }),
641        }
642    }
643
644    pub fn set_x_plex_model<S: Into<String>>(self, model: S) -> Self {
645        Self {
646            client: self.client.map(move |mut client| {
647                client.x_plex_model = model.into();
648                client
649            }),
650        }
651    }
652
653    pub fn set_x_plex_features(self, features: &[&str]) -> Self {
654        Self {
655            client: self.client.map(move |mut client| {
656                client.x_plex_features = features.join(",");
657                client
658            }),
659        }
660    }
661}