plex_api/server/
mod.rs

1pub mod library;
2pub(crate) mod prefs;
3pub mod transcode;
4
5use self::{
6    library::{metadata_items, FromMetadata, Item, Library, MediaItem, MetadataItem},
7    prefs::Preferences,
8    transcode::{
9        session::{transcode_session_stats, TranscodeSession, TranscodeSessionsMediaContainer},
10        transcode_artwork, ArtTranscodeOptions,
11    },
12};
13#[cfg(not(feature = "tests_deny_unknown_fields"))]
14use crate::media_container::server::library::LibraryType;
15use crate::{
16    http_client::HttpClient,
17    isahc_compat::StatusCodeExt,
18    media_container::{
19        server::{library::ContentDirectory, MediaProviderFeature, Server as ServerMediaContainer},
20        MediaContainerWrapper,
21    },
22    myplex::MyPlex,
23    transcode::download_queue::DownloadQueue,
24    url::{
25        SERVER_MEDIA_PROVIDERS, SERVER_MYPLEX_ACCOUNT, SERVER_MYPLEX_CLAIM, SERVER_SCROBBLE,
26        SERVER_TIMELINE, SERVER_TRANSCODE_SESSIONS, SERVER_UNSCROBBLE,
27    },
28    Error, HttpClientBuilder, Result,
29};
30use futures::AsyncWrite;
31use http::{StatusCode, Uri};
32use isahc::AsyncReadResponseExt;
33use std::{
34    collections::HashMap,
35    fmt::{self, Debug},
36};
37
38struct Query {
39    params: HashMap<String, String>,
40}
41
42impl Query {
43    fn new() -> Self {
44        Self {
45            params: HashMap::new(),
46        }
47    }
48
49    fn append(mut self, other: HashMap<String, String>) -> Self {
50        self.params.extend(other);
51        self
52    }
53
54    fn param<N: Into<String>, V: Into<String>>(mut self, name: N, value: V) -> Self {
55        self.params.insert(name.into(), value.into());
56        self
57    }
58}
59
60impl From<Query> for HashMap<String, String> {
61    fn from(query: Query) -> Self {
62        query.params
63    }
64}
65
66impl fmt::Display for Query {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.pad(&serde_urlencoded::to_string(&self.params).unwrap())
69    }
70}
71
72#[derive(Debug, Clone)]
73pub struct Server {
74    client: HttpClient,
75    pub myplex_api_url: Uri,
76    pub media_container: ServerMediaContainer,
77}
78
79impl Server {
80    async fn build(client: HttpClient, myplex_api_url: Uri) -> Result<Self> {
81        let media_container_wrapper: MediaContainerWrapper<ServerMediaContainer> =
82            client.get(SERVER_MEDIA_PROVIDERS).json().await?;
83
84        Ok(Self {
85            media_container: media_container_wrapper.media_container,
86            client,
87            myplex_api_url,
88        })
89    }
90
91    #[tracing::instrument(level = "debug", skip(client))]
92    pub async fn new<U>(url: U, client: HttpClient) -> Result<Self>
93    where
94        U: Debug,
95        Uri: TryFrom<U>,
96        <Uri as TryFrom<U>>::Error: Into<http::Error>,
97    {
98        let myplex_api_url = client.api_url.clone();
99        Self::build(
100            HttpClientBuilder::from(client).set_api_url(url).build()?,
101            myplex_api_url,
102        )
103        .await
104    }
105
106    fn content(&self) -> Option<&Vec<ContentDirectory>> {
107        if let Some(provider) = self
108            .media_container
109            .media_providers
110            .iter()
111            .find(|p| p.identifier == "com.plexapp.plugins.library")
112        {
113            for feature in &provider.features {
114                if let MediaProviderFeature::Content {
115                    key: _,
116                    ref directory,
117                } = feature
118                {
119                    return Some(directory);
120                }
121            }
122        }
123
124        None
125    }
126
127    pub fn libraries(&self) -> Vec<Library> {
128        if let Some(content) = self.content() {
129            content
130                .iter()
131                .filter_map(|d| match d {
132                    ContentDirectory::Media(lib) => match lib.library_type {
133                        #[cfg(not(feature = "tests_deny_unknown_fields"))]
134                        LibraryType::Unknown => None,
135                        _ => Some(Library::new(self.client.clone(), *lib.clone())),
136                    },
137                    _ => None,
138                })
139                .collect()
140        } else {
141            Vec::new()
142        }
143    }
144
145    /// Given the path to some item's artwork (`art` or `thumb` properties for
146    /// example but many other types of images will work) this will request a
147    /// scaled version of that image be written to the passed writer as a JPEG.
148    /// The image will always maintain its aspect ratio.
149    #[tracing::instrument(
150        name = "Server::transcode_artwork",
151        level = "debug",
152        skip(self, writer)
153    )]
154    pub async fn transcode_artwork<W>(
155        &self,
156        art: &str,
157        width: u32,
158        height: u32,
159        options: ArtTranscodeOptions,
160        writer: W,
161    ) -> Result<()>
162    where
163        W: AsyncWrite + Unpin,
164    {
165        transcode_artwork(&self.client, art, width, height, options, writer).await
166    }
167
168    /// Retrieves a list of the current transcode sessions.
169    #[tracing::instrument(level = "debug", skip(self))]
170    pub async fn transcode_sessions(&self) -> Result<Vec<TranscodeSession>> {
171        let wrapper: MediaContainerWrapper<TranscodeSessionsMediaContainer> =
172            self.client.get(SERVER_TRANSCODE_SESSIONS).json().await?;
173
174        Ok(wrapper
175            .media_container
176            .transcode_sessions
177            .into_iter()
178            .map(move |stats| TranscodeSession::from_stats(self.client.clone(), stats))
179            .collect())
180    }
181
182    /// Retrieves the transcode session with the passed ID.
183    #[tracing::instrument(level = "debug", skip(self))]
184    pub async fn transcode_session(&self, session_id: &str) -> Result<TranscodeSession> {
185        let stats = transcode_session_stats(&self.client, session_id).await?;
186        Ok(TranscodeSession::from_stats(self.client.clone(), stats))
187    }
188
189    /// Allows retrieving media, playlists, collections and other items using
190    /// their rating key.
191    #[tracing::instrument(level = "debug", skip(self))]
192    pub async fn item_by_id(&self, rating_key: &str) -> Result<Item> {
193        let path = format!("/library/metadata/{rating_key}?includeConcerts=1&includeExtras=1&includePopularLeaves=1&includePreferences=1&includeReviews=1&includeOnDeck=1&includeChapters=1&includeStations=1&includeExternalMedia=1&asyncAugmentMetadata=1&asyncCheckFiles=1&asyncRefreshAnalysis=1&asyncRefreshLocalMediaAgent=1&includeMarkers=1");
194
195        match metadata_items(&self.client, &path).await {
196            Ok(items) => items.into_iter().next().ok_or(Error::ItemNotFound),
197            Err(Error::UnexpectedApiResponse {
198                status_code,
199                content,
200            }) => {
201                // A 404 error indicates the item does not exist.
202                if status_code == 404 {
203                    Err(Error::ItemNotFound)
204                } else {
205                    Err(Error::UnexpectedApiResponse {
206                        status_code,
207                        content,
208                    })
209                }
210            }
211            Err(err) => Err(err),
212        }
213    }
214
215    /// Marks a media item as fully watched increasing its view count by one.
216    pub async fn mark_watched<M: MediaItem + FromMetadata>(&self, item: &M) -> Result<M> {
217        let rating_key = item.rating_key();
218        let path =
219            format!("{SERVER_SCROBBLE}?identifier=com.plexapp.plugins.library&key={rating_key}");
220
221        self.client.get(path).consume().await?;
222
223        let item = self.item_by_id(rating_key).await?;
224        Ok(M::from_metadata(
225            self.client.clone(),
226            item.metadata().clone(),
227        ))
228    }
229
230    /// Marks a media item as unwatched.
231    pub async fn mark_unwatched<M: MediaItem + FromMetadata>(&self, item: &M) -> Result<M> {
232        let rating_key = item.rating_key();
233        let path =
234            format!("{SERVER_UNSCROBBLE}?identifier=com.plexapp.plugins.library&key={rating_key}");
235
236        self.client.get(path).consume().await?;
237
238        let item = self.item_by_id(rating_key).await?;
239        Ok(M::from_metadata(
240            self.client.clone(),
241            item.metadata().clone(),
242        ))
243    }
244
245    /// Sets a media item's playback position in milliseconds. The server currently ignores any
246    /// positions equal to or less than 60000ms. The time sets the time the item was last viewed.
247    pub async fn update_timeline<M: MediaItem + FromMetadata>(
248        &self,
249        item: &M,
250        position: u64,
251    ) -> Result<M> {
252        let rating_key = item.rating_key();
253        let query = Query::new()
254            .param("key", format!("/library/metadata/{rating_key}"))
255            .param("ratingKey", rating_key)
256            .param("offline", "1")
257            .param("state", "playing")
258            .param("time", position.to_string());
259
260        self.client
261            .get(format!("{SERVER_TIMELINE}?{query}"))
262            .consume()
263            .await?;
264
265        let item = self.item_by_id(rating_key).await?;
266        Ok(M::from_metadata(
267            self.client.clone(),
268            item.metadata().clone(),
269        ))
270    }
271
272    #[tracing::instrument(level = "debug", skip(self))]
273    pub async fn refresh(self) -> Result<Self> {
274        Self::build(self.client, self.myplex_api_url).await
275    }
276
277    pub fn myplex(&self) -> Result<MyPlex> {
278        self.myplex_with_api_url(self.myplex_api_url.clone())
279    }
280
281    #[tracing::instrument(level = "debug", skip(self))]
282    pub async fn claim(self, claim_token: &str) -> Result<Self> {
283        let url = format!(
284            "{}?{}",
285            SERVER_MYPLEX_CLAIM,
286            serde_urlencoded::to_string([("token", claim_token)])?
287        );
288        let mut response = self.client.post(url).send().await?;
289
290        if response.status().as_http_status() == StatusCode::OK {
291            response.consume().await?;
292            self.refresh().await
293        } else {
294            Err(crate::Error::from_response(response).await)
295        }
296    }
297
298    #[tracing::instrument(level = "debug", skip(self))]
299    pub async fn unclaim(self) -> Result<Self> {
300        let mut response = self.client.delete(SERVER_MYPLEX_ACCOUNT).send().await?;
301
302        if response.status().as_http_status() == StatusCode::OK {
303            response.consume().await?;
304            self.refresh().await
305        } else {
306            Err(crate::Error::from_response(response).await)
307        }
308    }
309
310    pub fn myplex_with_api_url<U>(&self, api_url: U) -> Result<MyPlex>
311    where
312        Uri: TryFrom<U>,
313        <Uri as TryFrom<U>>::Error: Into<http::Error>,
314    {
315        Ok(MyPlex::new(
316            HttpClientBuilder::from(self.client.clone())
317                .set_api_url(api_url)
318                .build()?,
319        ))
320    }
321
322    pub fn client(&self) -> &HttpClient {
323        &self.client
324    }
325
326    pub async fn preferences<'a>(&self) -> Result<Preferences<'a>> {
327        Preferences::new(&self.client).await
328    }
329
330    pub fn machine_identifier(&self) -> &str {
331        &self.media_container.machine_identifier
332    }
333
334    pub async fn download_queue(&self) -> Result<DownloadQueue> {
335        DownloadQueue::get_or_create(self.client.clone()).await
336    }
337}