Skip to main content

autopulse_service/settings/targets/
plex.rs

1use super::{Request, RequestBuilderPerform};
2use crate::settings::path_filter::PathFilter;
3use crate::settings::rewrite::Rewrite;
4use crate::settings::targets::TargetProcess;
5use anyhow::Context;
6use autopulse_database::models::ScanEvent;
7use autopulse_utils::{get_url, RuntimePath};
8use reqwest::header;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use tracing::{debug, error, trace};
12
13#[derive(Serialize, Deserialize, Clone)]
14pub struct Plex {
15    /// URL to the Plex server
16    pub url: String,
17    /// API token for the Plex server
18    pub token: String,
19    /// Whether to refresh metadata of the file (default: false)
20    #[serde(default)]
21    pub refresh: bool,
22    /// Whether to analyze the file (default: false)
23    #[serde(default)]
24    pub analyze: bool,
25    /// Rewrite path for the file
26    pub rewrite: Option<Rewrite>,
27    /// Path filter matched against the target-rewritten path.
28    #[serde(default)]
29    pub filter: PathFilter,
30    /// HTTP request options
31    #[serde(default)]
32    pub request: Request,
33}
34
35#[derive(Deserialize, Clone, Debug)]
36#[serde(rename_all = "camelCase")]
37pub struct Media {
38    #[serde(rename = "Part")]
39    pub part: Vec<Part>,
40}
41
42#[derive(Deserialize, Clone, Debug)]
43#[serde(rename_all = "camelCase")]
44pub struct Part {
45    // pub id: i64,
46    pub key: String,
47    // pub duration: Option<i64>,
48    pub file: String,
49    // pub size: i64,
50    // pub audio_profile: Option<String>,
51    // pub container: Option<String>,
52    // pub video_profile: Option<String>,
53    // pub has_thumbnail: Option<String>,
54    // pub has64bit_offsets: Option<bool>,
55    // pub optimized_for_streaming: Option<bool>,
56}
57
58#[derive(Deserialize, Clone, Debug)]
59#[serde(rename_all = "camelCase")]
60pub struct Metadata {
61    pub key: String,
62    #[serde(rename = "Media")]
63    pub media: Option<Vec<Media>>,
64    #[serde(rename = "type")]
65    pub t: String,
66}
67
68#[doc(hidden)]
69#[derive(Deserialize, Clone, Debug)]
70struct Location {
71    path: String,
72}
73
74#[doc(hidden)]
75#[derive(Deserialize, Clone, Debug)]
76struct Library {
77    title: String,
78    key: String,
79    #[serde(rename = "Location")]
80    location: Vec<Location>,
81}
82
83#[doc(hidden)]
84#[derive(Deserialize, Clone)]
85#[serde(rename_all = "PascalCase")]
86struct LibraryMediaContainer {
87    directory: Option<Vec<Library>>,
88    metadata: Option<Vec<Metadata>>,
89}
90
91#[doc(hidden)]
92#[derive(Deserialize, Clone)]
93#[serde(rename_all = "PascalCase")]
94struct SearchResult {
95    metadata: Option<Metadata>,
96}
97
98#[doc(hidden)]
99#[derive(Deserialize, Clone)]
100#[serde(rename_all = "PascalCase")]
101struct SearchLibraryMediaContainer {
102    #[serde(default)]
103    search_result: Vec<SearchResult>,
104}
105
106#[doc(hidden)]
107#[derive(Deserialize, Clone)]
108#[serde(rename_all = "PascalCase")]
109struct LibraryResponse {
110    media_container: LibraryMediaContainer,
111}
112
113#[doc(hidden)]
114#[derive(Deserialize, Clone)]
115#[serde(rename_all = "PascalCase")]
116struct SearchLibraryResponse {
117    media_container: SearchLibraryMediaContainer,
118}
119
120fn path_matches(part_file: &str, path: &str) -> bool {
121    let part_file = RuntimePath::new(part_file);
122    let path = RuntimePath::new(path);
123
124    if path.is_directory() {
125        part_file.starts_with(path)
126    } else {
127        part_file.equals(path)
128    }
129}
130
131fn has_matching_media(media: &[Media], path: &str) -> bool {
132    media.iter().any(|media_item| {
133        media_item
134            .part
135            .iter()
136            .any(|part| path_matches(&part.file, path))
137    })
138}
139
140fn scan_directory(path: &str) -> &str {
141    RuntimePath::new(path).parent_or_self().as_str()
142}
143
144impl Plex {
145    fn get_client(&self) -> anyhow::Result<reqwest::Client> {
146        let mut headers = header::HeaderMap::new();
147
148        headers.insert("X-Plex-Token", self.token.parse()?);
149        headers.insert("Accept", "application/json".parse()?);
150
151        self.request
152            .client_builder(headers)
153            .build()
154            .map_err(Into::into)
155    }
156
157    async fn libraries(&self) -> anyhow::Result<Vec<Library>> {
158        let client = self.get_client()?;
159        let url = get_url(&self.url)?.join("library/sections")?;
160
161        let res = client.get(url).perform().await?;
162
163        let libraries: LibraryResponse = res.json().await?;
164
165        Ok(libraries.media_container.directory.unwrap_or_default())
166    }
167
168    fn get_libraries(&self, libraries: &[Library], path: &str) -> Vec<Library> {
169        let event_path = RuntimePath::new(path);
170        let mut matches: Vec<(usize, &Library)> = vec![];
171
172        for library in libraries {
173            for location in &library.location {
174                let location_path = RuntimePath::new(&location.path);
175                if event_path.starts_with(location_path) {
176                    matches.push((location_path.component_count(), library));
177                }
178            }
179        }
180
181        // Most-specific (highest component count) match first
182        matches.sort_by(|(components_a, _), (components_b, _)| components_b.cmp(components_a));
183
184        matches
185            .into_iter()
186            .map(|(_, library)| library.clone())
187            .collect()
188    }
189
190    async fn get_episodes(&self, key: &str) -> anyhow::Result<LibraryResponse> {
191        let client = self.get_client()?;
192
193        // remove last part of the key
194        let key = key.rsplit_once('/').map(|x| x.0).unwrap_or(key);
195
196        let url = get_url(&self.url)?.join(&format!("{key}/allLeaves"))?;
197
198        let res = client.get(url).perform().await?;
199
200        let lib: LibraryResponse = res.json().await?;
201
202        Ok(lib)
203    }
204
205    fn get_search_term(&self, path: &str) -> anyhow::Result<String> {
206        let parent_or_directory = RuntimePath::new(path).parent_or_self();
207        let components = parent_or_directory.normal_components().collect::<Vec<_>>();
208
209        let chosen = components
210            .iter()
211            .rev()
212            .copied()
213            .find(|component| !component.contains("Season") && !component.is_empty())
214            .map(ToString::to_string)
215            .unwrap_or_else(|| {
216                // All components were "Season N": use normalized components,
217                // not raw source, to keep drive letters/UNC/backslashes out
218                components.join(" ")
219            });
220
221        Ok(chosen
222            .split_whitespace()
223            .filter(|part| {
224                ["(", ")", "[", "]", "{", "}"]
225                    .iter()
226                    .all(|character| !part.contains(character))
227            })
228            .collect::<Vec<_>>()
229            .join(" "))
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 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) {
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) {
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 mut parts = vec![];
337
338        // TODO: Reduce the amount of data needed to be searched
339        for item in lib.media_container.metadata.unwrap_or_default() {
340            match item.t.as_str() {
341                "show" => {
342                    let episodes = self.get_episodes(&item.key).await?;
343
344                    for episode in episodes.media_container.metadata.unwrap_or_default() {
345                        if let Some(media) = &episode.media {
346                            if has_matching_media(media, path) {
347                                parts.push(episode.clone());
348                            }
349                        }
350                    }
351                }
352                _ => {
353                    if let Some(media) = &item.media {
354                        if has_matching_media(media, path) {
355                            parts.push(item.clone());
356                        }
357                    }
358                }
359            }
360        }
361
362        Ok(parts)
363    }
364
365    async fn refresh_item(&self, key: &str) -> anyhow::Result<()> {
366        let client = self.get_client()?;
367        let url = get_url(&self.url)?.join(&format!("{key}/refresh"))?;
368
369        client.put(url).perform().await.map(|_| ())
370    }
371
372    async fn analyze_item(&self, key: &str) -> anyhow::Result<()> {
373        let client = self.get_client()?;
374        let url = get_url(&self.url)?.join(&format!("{key}/analyze"))?;
375
376        client.put(url).perform().await.map(|_| ())
377    }
378
379    async fn scan(&self, ev: &ScanEvent, library: &Library) -> anyhow::Result<()> {
380        let client = self.get_client()?;
381        let mut url =
382            get_url(&self.url)?.join(&format!("library/sections/{}/refresh", library.key))?;
383
384        let ev_path = ev.get_path(&self.rewrite);
385        url.query_pairs_mut()
386            .append_pair("path", scan_directory(&ev_path));
387
388        client.get(url).perform().await.map(|_| ())
389    }
390}
391
392impl TargetProcess for Plex {
393    async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
394        let libraries = self.libraries().await.context("failed to get libraries")?;
395
396        let mut succeeded: HashMap<String, bool> = HashMap::new();
397
398        for ev in evs {
399            let succeeded_entry = succeeded.entry(ev.id.clone()).or_insert(true);
400
401            let ev_path = ev.get_path(&self.rewrite);
402            let matched_libraries = self.get_libraries(&libraries, &ev_path);
403
404            if matched_libraries.is_empty() {
405                error!("no matching library for {ev_path}");
406
407                *succeeded_entry = false;
408
409                continue;
410            }
411
412            let mut processed_items = HashSet::new();
413
414            for library in matched_libraries {
415                trace!("found library '{}' for {ev_path}", library.title);
416
417                match self.scan(ev, &library).await {
418                    Ok(()) => {
419                        debug!("scanned '{}'", ev_path);
420
421                        if self.analyze || self.refresh {
422                            match self.search_items(&library, &ev_path).await {
423                                Ok(items) => {
424                                    if items.is_empty() {
425                                        trace!(
426                                            "failed to find items for file: '{}', leaving at scan",
427                                            ev_path
428                                        );
429
430                                        // scan succeeded, no items to refresh/analyze
431                                    } else {
432                                        trace!("found items for file '{}'", ev_path);
433
434                                        let mut all_success = true;
435
436                                        for item in items {
437                                            let mut item_success = true;
438
439                                            if processed_items.contains(&item.key) {
440                                                debug!(
441                                                    "already processed item '{}' earlier, skipping",
442                                                    item.key
443                                                );
444                                                continue;
445                                            }
446
447                                            if self.refresh {
448                                                match self.refresh_item(&item.key).await {
449                                                    Ok(()) => {
450                                                        debug!("refreshed metadata '{}'", item.key);
451                                                    }
452                                                    Err(e) => {
453                                                        error!(
454                                                        "failed to refresh metadata for '{}': {}",
455                                                        item.key, e
456                                                    );
457                                                        item_success = false;
458                                                    }
459                                                }
460                                            }
461
462                                            if self.analyze {
463                                                match self.analyze_item(&item.key).await {
464                                                    Ok(()) => {
465                                                        debug!("analyzed metadata '{}'", item.key);
466                                                    }
467                                                    Err(e) => {
468                                                        error!(
469                                                        "failed to analyze metadata for '{}': {}",
470                                                        item.key, e
471                                                    );
472                                                        item_success = false;
473                                                    }
474                                                }
475                                            }
476
477                                            if !item_success {
478                                                all_success = false;
479                                            }
480
481                                            processed_items.insert(item.key);
482                                        }
483
484                                        if !all_success {
485                                            *succeeded_entry = false;
486                                        }
487                                    }
488                                }
489                                Err(e) => {
490                                    error!("failed to get items for '{}': {:?}", ev_path, e);
491                                    *succeeded_entry = false;
492                                }
493                            };
494                        }
495                    }
496                    Err(e) => {
497                        error!("failed to scan file '{}': {}", ev_path, e);
498                        *succeeded_entry = false;
499                    }
500                }
501            }
502        }
503
504        Ok(succeeded
505            .into_iter()
506            .filter_map(|(k, v)| if v { Some(k) } else { None })
507            .collect())
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    fn test_plex() -> Plex {
516        Plex {
517            url: String::new(),
518            token: String::new(),
519            refresh: false,
520            analyze: false,
521            rewrite: None,
522            filter: PathFilter::default(),
523            request: Request::default(),
524        }
525    }
526
527    #[test]
528    fn test_get_search_term() {
529        let plex = test_plex();
530
531        // Test with a path that has a file name and season directory
532        let path = "/media/TV Shows/Breaking Bad/Season 1/S01E01.mkv";
533        assert_eq!(plex.get_search_term(path).unwrap(), "Breaking Bad");
534
535        // Test with a path that has parentheses and brackets
536        let path = "/media/Movies/The Matrix (1999) [1080p]/matrix.mkv";
537        assert_eq!(plex.get_search_term(path).unwrap(), "The Matrix");
538
539        // Test with a simple path
540        let path = "/media/Movies/Inception/inception.mkv";
541        assert_eq!(plex.get_search_term(path).unwrap(), "Inception");
542
543        // Test with a directory path
544        let path = "/media/TV Shows/Game of Thrones/Season 2";
545        assert_eq!(plex.get_search_term(path).unwrap(), "Game of Thrones");
546
547        // Test with no directory path
548        let path = "/media/TV Shows/Game of Thrones";
549        assert_eq!(plex.get_search_term(path).unwrap(), "Game of Thrones");
550
551        // Test with multiple levels of season directories
552        let path = "/media/TV Shows/Doctor Who/Season 10/Season 10 Part 2/S10E12.mkv";
553        assert_eq!(plex.get_search_term(path).unwrap(), "Doctor Who");
554    }
555
556    #[test]
557    fn test_get_library() {
558        let plex = Plex {
559            url: String::new(),
560            token: String::new(),
561            refresh: false,
562            analyze: false,
563            rewrite: None,
564            filter: PathFilter::default(),
565            request: Request::default(),
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
604    #[test]
605    fn windows_library_matching_prefers_the_most_specific_location() {
606        let libraries = [
607            Library {
608                title: "Movies".to_string(),
609                key: "movies".to_string(),
610                location: vec![Location {
611                    path: r"\\server\media".to_string(),
612                }],
613            },
614            Library {
615                title: "4K Movies".to_string(),
616                key: "movies-4k".to_string(),
617                location: vec![Location {
618                    path: r"\\SERVER\MEDIA\4K".to_string(),
619                }],
620            },
621        ];
622
623        let matches = test_plex().get_libraries(&libraries, r"\\server\media\4k\Film\Film.mkv");
624
625        assert_eq!(
626            matches
627                .iter()
628                .map(|library| library.key.as_str())
629                .collect::<Vec<_>>(),
630            ["movies-4k", "movies"]
631        );
632    }
633
634    #[test]
635    fn unc_file_produces_a_non_empty_scan_directory_in_original_syntax() {
636        assert_eq!(
637            scan_directory(r"\\server\media\TV\Show\Season 1\S01E01.mkv"),
638            r"\\server\media\TV\Show\Season 1"
639        );
640    }
641
642    #[test]
643    fn windows_search_term_skips_season_components() {
644        assert_eq!(
645            test_plex()
646                .get_search_term(r"D:\TV Shows\Breaking Bad\Season 1\S01E01.mkv")
647                .unwrap(),
648            "Breaking Bad"
649        );
650    }
651
652    #[test]
653    fn windows_media_matching_uses_runtime_case_and_boundaries() {
654        assert!(path_matches(
655            r"D:\MEDIA\Movies\Film\Film.mkv",
656            r"d:\media\movies\film\film.MKV"
657        ));
658        assert!(path_matches(
659            r"\\server\media\Shows\Show\Episode.mkv",
660            r"\\SERVER\MEDIA\SHOWS\SHOW"
661        ));
662        assert!(!path_matches(
663            r"\\server\media-archive\Film.mkv",
664            r"\\server\media"
665        ));
666    }
667
668    #[test]
669    fn unix_search_term_without_a_non_season_parent_uses_the_directory_name() {
670        assert_eq!(
671            test_plex().get_search_term("/Season 1/file.mkv").unwrap(),
672            "Season 1"
673        );
674    }
675
676    #[test]
677    fn windows_search_term_without_a_non_season_parent_uses_the_directory_name() {
678        assert_eq!(
679            test_plex()
680                .get_search_term(r"D:\Season 1\S01E01.mkv")
681                .unwrap(),
682            "Season 1"
683        );
684
685        // Same fallback, but called directly on the Season directory rather
686        // than a file within it: `parent_or_self` takes its other branch
687        // (`is_file()` is false, so the source is used unchanged) and must
688        // still resolve to the directory name.
689        assert_eq!(
690            test_plex().get_search_term(r"D:\Season 1").unwrap(),
691            "Season 1"
692        );
693    }
694}