autopulse_service/settings/targets/
plex.rs

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