autopulse_service/settings/targets/
plex.rs

1use super::{Request, RequestBuilderPerform};
2use crate::settings::rewrite::Rewrite;
3use crate::settings::targets::TargetProcess;
4use anyhow::Context;
5use autopulse_database::models::ScanEvent;
6use autopulse_utils::{get_url, squash_directory, what_is, PathType};
7use reqwest::header;
8use serde::{Deserialize, Serialize};
9use std::{
10    collections::{HashMap, HashSet},
11    path::Path,
12};
13use tracing::{debug, error, trace};
14
15#[derive(Serialize, Deserialize, Clone)]
16pub struct Plex {
17    /// URL to the Plex server
18    pub url: String,
19    /// API token for the Plex server
20    pub token: String,
21    /// Whether to refresh metadata of the file (default: false)
22    #[serde(default)]
23    pub refresh: bool,
24    /// Whether to analyze the file (default: false)
25    #[serde(default)]
26    pub analyze: bool,
27    /// Rewrite path for the file
28    pub rewrite: Option<Rewrite>,
29    /// HTTP request options
30    #[serde(default)]
31    pub request: Request,
32}
33
34#[derive(Deserialize, Clone, Debug)]
35#[serde(rename_all = "camelCase")]
36pub struct Media {
37    #[serde(rename = "Part")]
38    pub part: Vec<Part>,
39}
40
41#[derive(Deserialize, Clone, Debug)]
42#[serde(rename_all = "camelCase")]
43pub struct Part {
44    // pub id: i64,
45    pub key: String,
46    // pub duration: Option<i64>,
47    pub file: String,
48    // pub size: i64,
49    // pub audio_profile: Option<String>,
50    // pub container: Option<String>,
51    // pub video_profile: Option<String>,
52    // pub has_thumbnail: Option<String>,
53    // pub has64bit_offsets: Option<bool>,
54    // pub optimized_for_streaming: Option<bool>,
55}
56
57#[derive(Deserialize, Clone, Debug)]
58#[serde(rename_all = "camelCase")]
59pub struct Metadata {
60    pub key: String,
61    #[serde(rename = "Media")]
62    pub media: Option<Vec<Media>>,
63    #[serde(rename = "type")]
64    pub t: String,
65}
66
67#[doc(hidden)]
68#[derive(Deserialize, Clone, Debug)]
69struct Location {
70    path: String,
71}
72
73#[doc(hidden)]
74#[derive(Deserialize, Clone, Debug)]
75struct Library {
76    title: String,
77    key: String,
78    #[serde(rename = "Location")]
79    location: Vec<Location>,
80}
81
82#[doc(hidden)]
83#[derive(Deserialize, Clone)]
84#[serde(rename_all = "PascalCase")]
85struct LibraryMediaContainer {
86    directory: Option<Vec<Library>>,
87    metadata: Option<Vec<Metadata>>,
88}
89
90#[doc(hidden)]
91#[derive(Deserialize, Clone)]
92#[serde(rename_all = "PascalCase")]
93struct SearchResult {
94    metadata: Option<Metadata>,
95}
96
97#[doc(hidden)]
98#[derive(Deserialize, Clone)]
99#[serde(rename_all = "PascalCase")]
100struct SearchLibraryMediaContainer {
101    #[serde(default)]
102    search_result: Vec<SearchResult>,
103}
104
105#[doc(hidden)]
106#[derive(Deserialize, Clone)]
107#[serde(rename_all = "PascalCase")]
108struct LibraryResponse {
109    media_container: LibraryMediaContainer,
110}
111
112#[doc(hidden)]
113#[derive(Deserialize, Clone)]
114#[serde(rename_all = "PascalCase")]
115struct SearchLibraryResponse {
116    media_container: SearchLibraryMediaContainer,
117}
118
119fn path_matches(part_file: &str, path: &Path) -> bool {
120    let part_file_path = Path::new(part_file);
121    let what_is_path = what_is(path);
122
123    if what_is_path == PathType::Directory {
124        part_file_path.starts_with(path)
125    } else {
126        part_file_path == path
127    }
128}
129
130fn has_matching_media(media: &[Media], path: &Path) -> bool {
131    media
132        .iter()
133        .any(|m| m.part.iter().any(|p| path_matches(&p.file, path)))
134}
135
136impl Plex {
137    fn get_client(&self) -> anyhow::Result<reqwest::Client> {
138        let mut headers = header::HeaderMap::new();
139
140        headers.insert("X-Plex-Token", self.token.parse().unwrap());
141        headers.insert("Accept", "application/json".parse().unwrap());
142
143        self.request
144            .client_builder(headers)
145            .build()
146            .map_err(Into::into)
147    }
148
149    async fn libraries(&self) -> anyhow::Result<Vec<Library>> {
150        let client = self.get_client()?;
151        let url = get_url(&self.url)?.join("library/sections")?;
152
153        let res = client.get(url).perform().await?;
154
155        let libraries: LibraryResponse = res.json().await?;
156
157        Ok(libraries.media_container.directory.unwrap())
158    }
159
160    fn get_libraries(&self, libraries: &[Library], path: &str) -> Vec<Library> {
161        let ev_path = Path::new(path);
162        let mut matches: Vec<(usize, &Library)> = vec![];
163
164        for library in libraries {
165            for location in &library.location {
166                let loc_path = Path::new(&location.path);
167                if ev_path.starts_with(loc_path) {
168                    matches.push((loc_path.components().count(), library));
169                }
170            }
171        }
172
173        // Sort the best matched library first
174        matches.sort_by(|(len_a, _), (len_b, _)| len_b.cmp(len_a));
175
176        matches
177            .into_iter()
178            .map(|(_, library)| library.clone())
179            .collect()
180    }
181
182    async fn get_episodes(&self, key: &str) -> anyhow::Result<LibraryResponse> {
183        let client = self.get_client()?;
184
185        // remove last part of the key
186        let key = key.rsplit_once('/').map(|x| x.0).unwrap_or(key);
187
188        let url = get_url(&self.url)?.join(&format!("{key}/allLeaves"))?;
189
190        let res = client.get(url).perform().await?;
191
192        let lib: LibraryResponse = res.json().await?;
193
194        Ok(lib)
195    }
196
197    fn get_search_term(&self, path: &str) -> anyhow::Result<String> {
198        let parent_or_dir = squash_directory(Path::new(path));
199
200        let parts = parent_or_dir.components().collect::<Vec<_>>();
201
202        let mut chosen_part = parent_or_dir
203            .to_str()
204            .ok_or_else(|| anyhow::anyhow!("failed to convert path to string"))?
205            .to_string()
206            .replace("/", " ");
207
208        for part in parts.iter().rev() {
209            let part_str = part.as_os_str().to_string_lossy();
210
211            if part_str.contains("Season") || part_str.is_empty() {
212                continue;
213            }
214
215            chosen_part = part_str.to_string();
216            break;
217        }
218
219        let chosen_part = chosen_part
220            .split_whitespace()
221            .filter(|&s| {
222                ["(", ")", "[", "]", "{", "}"]
223                    .iter()
224                    .all(|&c| !s.contains(c))
225            })
226            .collect::<Vec<_>>()
227            .join(" ");
228
229        Ok(chosen_part)
230    }
231
232    async fn search_items(&self, _library: &Library, path: &str) -> anyhow::Result<Vec<Metadata>> {
233        let client = self.get_client()?;
234        // let mut url = get_url(&self.url)?.join(&format!("library/sections/{}/all", library.key))?;
235
236        let mut results = vec![];
237
238        let rel_path = path.to_string();
239
240        trace!("searching for item with relative path: {}", rel_path);
241
242        let mut search_term = self.get_search_term(&rel_path)?;
243
244        while !search_term.is_empty() {
245            let mut url = get_url(&self.url)?.join("library/search")?;
246
247            url.query_pairs_mut().append_pair("includeCollections", "1");
248            url.query_pairs_mut()
249                .append_pair("includeExternalMedia", "1");
250            url.query_pairs_mut()
251                .append_pair("searchTypes", "movies,people,tv");
252            url.query_pairs_mut().append_pair("limit", "100");
253
254            trace!("searching for item with term: {}", search_term);
255
256            url.query_pairs_mut()
257                // .append_pair("title", search_term.as_str());
258                .append_pair("query", search_term.as_str());
259
260            let res = client.get(url).perform().await?;
261
262            let lib: SearchLibraryResponse = res.json().await?;
263
264            let path_obj = Path::new(path);
265
266            let mut metadata = lib
267                .media_container
268                .search_result
269                .into_iter()
270                .filter_map(|s| s.metadata)
271                .collect::<Vec<_>>();
272
273            // sort episodes then movies to the front, then the rest
274            metadata.sort_by(|a, b| {
275                if a.t == "episode" && b.t != "episode" {
276                    std::cmp::Ordering::Less
277                } else if a.t != "episode" && b.t == "episode" {
278                    std::cmp::Ordering::Greater
279                } else if a.t == "movie" && b.t != "movie" && b.t != "episode" {
280                    std::cmp::Ordering::Less
281                } else if a.t != "movie" && a.t != "episode" && b.t == "movie" {
282                    std::cmp::Ordering::Greater
283                } else {
284                    std::cmp::Ordering::Equal
285                }
286            });
287
288            for item in &metadata {
289                if item.t == "show" {
290                    let episodes = self.get_episodes(&item.key).await?;
291
292                    if let Some(episode_metadata) = episodes.media_container.metadata {
293                        for episode in episode_metadata {
294                            if let Some(media) = &episode.media {
295                                if has_matching_media(media, path_obj) {
296                                    results.push(episode.clone());
297                                }
298                            }
299                        }
300                    }
301                } else if let Some(media) = &item.media {
302                    // For movies and other content types
303                    if has_matching_media(media, path_obj) {
304                        results.push(item.clone());
305                    }
306                }
307            }
308
309            trace!(
310                "found {} out of {} items matching search",
311                results.len(),
312                metadata.len()
313            );
314
315            if results.is_empty() {
316                let mut search_parts = search_term.split_whitespace().collect::<Vec<_>>();
317                search_parts.pop();
318                search_term = search_parts.join(" ");
319            } else {
320                break;
321            }
322        }
323
324        // if show + episode then remove duplicates
325        results.dedup_by_key(|item| item.key.clone());
326
327        Ok(results)
328    }
329
330    async fn _get_items(&self, library: &Library, path: &str) -> anyhow::Result<Vec<Metadata>> {
331        let client = self.get_client()?;
332        let url = get_url(&self.url)?.join(&format!("library/sections/{}/all", library.key))?;
333
334        let res = client.get(url).perform().await?;
335
336        let lib: LibraryResponse = res.json().await?;
337
338        let path = Path::new(path);
339
340        let mut parts = vec![];
341
342        // TODO: Reduce the amount of data needed to be searched
343        for item in lib.media_container.metadata.unwrap_or_default() {
344            match item.t.as_str() {
345                "show" => {
346                    let episodes = self.get_episodes(&item.key).await?;
347
348                    for episode in episodes.media_container.metadata.unwrap_or_default() {
349                        if let Some(media) = &episode.media {
350                            if has_matching_media(media, path) {
351                                parts.push(episode.clone());
352                            }
353                        }
354                    }
355                }
356                _ => {
357                    if let Some(media) = &item.media {
358                        if has_matching_media(media, path) {
359                            parts.push(item.clone());
360                        }
361                    }
362                }
363            }
364        }
365
366        Ok(parts)
367    }
368
369    async fn refresh_item(&self, key: &str) -> anyhow::Result<()> {
370        let client = self.get_client()?;
371        let url = get_url(&self.url)?.join(&format!("{key}/refresh"))?;
372
373        client.put(url).perform().await.map(|_| ())
374    }
375
376    async fn analyze_item(&self, key: &str) -> anyhow::Result<()> {
377        let client = self.get_client()?;
378        let url = get_url(&self.url)?.join(&format!("{key}/analyze"))?;
379
380        client.put(url).perform().await.map(|_| ())
381    }
382
383    async fn scan(&self, ev: &ScanEvent, library: &Library) -> anyhow::Result<()> {
384        let client = self.get_client()?;
385        let mut url =
386            get_url(&self.url)?.join(&format!("library/sections/{}/refresh", library.key))?;
387
388        let ev_path = ev.get_path(&self.rewrite);
389
390        let squashed_path = squash_directory(&ev_path);
391        let file_dir = squashed_path
392            .as_os_str()
393            .to_str()
394            .ok_or_else(|| anyhow::anyhow!("failed to convert path to string"))?;
395
396        url.query_pairs_mut().append_pair("path", file_dir);
397
398        client.get(url).perform().await.map(|_| ())
399    }
400}
401
402impl TargetProcess for Plex {
403    async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
404        let libraries = self.libraries().await.context("failed to get libraries")?;
405
406        let mut succeeded: HashMap<String, bool> = HashMap::new();
407
408        for ev in evs {
409            let succeeded_entry = succeeded.entry(ev.id.clone()).or_insert(true);
410
411            let ev_path = ev.get_path(&self.rewrite);
412            let matched_libraries = self.get_libraries(&libraries, &ev_path);
413
414            if matched_libraries.is_empty() {
415                error!("no matching library for {ev_path}");
416
417                *succeeded_entry = false;
418
419                continue;
420            }
421
422            let mut processed_items = HashSet::new();
423
424            for library in matched_libraries {
425                trace!("found library '{}' for {ev_path}", library.title);
426
427                match self.scan(ev, &library).await {
428                    Ok(()) => {
429                        debug!("scanned '{}'", ev_path);
430
431                        if self.analyze || self.refresh {
432                            match self.search_items(&library, &ev_path).await {
433                                Ok(items) => {
434                                    if items.is_empty() {
435                                        trace!(
436                                            "failed to find items for file: '{}', leaving at scan",
437                                            ev_path
438                                        );
439
440                                        *succeeded_entry = true;
441                                    } else {
442                                        trace!("found items for file '{}'", ev_path);
443
444                                        let mut all_success = true;
445
446                                        for item in items {
447                                            let mut item_success = true;
448
449                                            if processed_items.contains(&item.key) {
450                                                debug!(
451                                                    "already processed item '{}' earlier, skipping",
452                                                    item.key
453                                                );
454                                                continue;
455                                            }
456
457                                            if self.refresh {
458                                                match self.refresh_item(&item.key).await {
459                                                    Ok(()) => {
460                                                        debug!("refreshed metadata '{}'", item.key);
461                                                    }
462                                                    Err(e) => {
463                                                        error!(
464                                                        "failed to refresh metadata for '{}': {}",
465                                                        item.key, e
466                                                    );
467                                                        item_success = false;
468                                                    }
469                                                }
470                                            }
471
472                                            if self.analyze {
473                                                match self.analyze_item(&item.key).await {
474                                                    Ok(()) => {
475                                                        debug!("analyzed metadata '{}'", item.key);
476                                                    }
477                                                    Err(e) => {
478                                                        error!(
479                                                        "failed to analyze metadata for '{}': {}",
480                                                        item.key, e
481                                                    );
482                                                        item_success = false;
483                                                    }
484                                                }
485                                            }
486
487                                            if !item_success {
488                                                all_success = false;
489                                            }
490
491                                            processed_items.insert(item.key);
492                                        }
493
494                                        if all_success {
495                                            *succeeded_entry &= true;
496                                        }
497                                    }
498                                }
499                                Err(e) => {
500                                    error!("failed to get items for '{}': {:?}", ev_path, e);
501                                }
502                            };
503                        } else {
504                            *succeeded_entry &= true;
505                        }
506                    }
507                    Err(e) => {
508                        error!("failed to scan file '{}': {}", ev_path, e);
509                    }
510                }
511            }
512        }
513
514        Ok(succeeded
515            .into_iter()
516            .filter_map(|(k, v)| if v { Some(k) } else { None })
517            .collect())
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn test_get_search_term() {
527        let plex = Plex {
528            url: String::new(),
529            token: String::new(),
530            refresh: false,
531            analyze: false,
532            rewrite: None,
533            request: Request::default(),
534        };
535
536        // Test with a path that has a file name and season directory
537        let path = "/media/TV Shows/Breaking Bad/Season 1/S01E01.mkv";
538        assert_eq!(plex.get_search_term(path).unwrap(), "Breaking Bad");
539
540        // Test with a path that has parentheses and brackets
541        let path = "/media/Movies/The Matrix (1999) [1080p]/matrix.mkv";
542        assert_eq!(plex.get_search_term(path).unwrap(), "The Matrix");
543
544        // Test with a simple path
545        let path = "/media/Movies/Inception/inception.mkv";
546        assert_eq!(plex.get_search_term(path).unwrap(), "Inception");
547
548        // Test with a directory path
549        let path = "/media/TV Shows/Game of Thrones/Season 2";
550        assert_eq!(plex.get_search_term(path).unwrap(), "Game of Thrones");
551
552        // Test with no directory path
553        let path = "/media/TV Shows/Game of Thrones";
554        assert_eq!(plex.get_search_term(path).unwrap(), "Game of Thrones");
555
556        // Test with multiple levels of season directories
557        let path = "/media/TV Shows/Doctor Who/Season 10/Season 10 Part 2/S10E12.mkv";
558        assert_eq!(plex.get_search_term(path).unwrap(), "Doctor Who");
559    }
560
561    #[test]
562    fn test_get_library() {
563        let plex = Plex {
564            url: String::new(),
565            token: String::new(),
566            refresh: false,
567            analyze: false,
568            rewrite: None,
569            request: Request::default(),
570        };
571
572        let libraries = [Library {
573            title: "Movies".to_string(),
574            key: "library_key_movies".to_string(),
575            location: vec![Location {
576                path: "/media/movies".to_string(),
577            }],
578        }];
579
580        let path = "/media/movies/Inception.mkv";
581        let libraries = plex.get_libraries(&libraries, path);
582        assert!(libraries[0].key == "library_key_movies");
583
584        let nested_libraries = [
585            Library {
586                title: "Movies".to_string(),
587                key: "library_key_movies".to_string(),
588                location: vec![Location {
589                    path: "/media/movies".to_string(),
590                }],
591            },
592            Library {
593                title: "Movies".to_string(),
594                key: "library_key_movies_4k".to_string(),
595                location: vec![Location {
596                    path: "/media/movies/4k".to_string(),
597                }],
598            },
599        ];
600
601        let path = "/media/movies/4k/Inception.mkv";
602
603        let libraries = plex.get_libraries(&nested_libraries, path);
604        assert!(libraries[0].key == "library_key_movies_4k");
605        assert!(libraries[1].key == "library_key_movies");
606    }
607}