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 pub x_plex_provides: String,
27
28 pub x_plex_platform: String,
32
33 pub x_plex_platform_version: String,
37
38 pub x_plex_product: String,
42
43 pub x_plex_version: String,
47
48 pub x_plex_device: String,
52
53 pub x_plex_device_name: String,
57
58 pub x_plex_client_identifier: String,
64
65 x_plex_token: SecretString,
69
70 pub x_plex_sync_version: String,
74
75 pub x_plex_model: String,
79
80 pub x_plex_features: String,
84
85 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 pub fn is_authenticated(&self) -> bool {
126 !self.x_plex_token.expose_secret().is_empty()
127 }
128
129 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 #[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 pub async fn send(self) -> Result<HttpResponse<AsyncBody>> {
380 self.body(())?.send().await
381 }
382
383 pub async fn json<T: DeserializeOwned + Unpin>(self) -> Result<T> {
385 self.body(())?.json().await
386 }
387
388 pub async fn xml<T: DeserializeOwned + Unpin>(self) -> Result<T> {
390 self.body(())?.xml().await
391 }
392
393 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 pub async fn send(self) -> Result<HttpResponse<AsyncBody>> {
418 Ok(self.http_client.send_async(self.request).await?)
419 }
420
421 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 #[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 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 #[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 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}