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