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 pub url: String,
17 pub token: String,
19 #[serde(default)]
21 pub refresh: bool,
22 #[serde(default)]
24 pub analyze: bool,
25 pub rewrite: Option<Rewrite>,
27 #[serde(default)]
29 pub filter: PathFilter,
30 #[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 key: String,
47 pub file: String,
49 }
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 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 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 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 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("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 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 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 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 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 } 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 let path = "/media/TV Shows/Breaking Bad/Season 1/S01E01.mkv";
533 assert_eq!(plex.get_search_term(path).unwrap(), "Breaking Bad");
534
535 let path = "/media/Movies/The Matrix (1999) [1080p]/matrix.mkv";
537 assert_eq!(plex.get_search_term(path).unwrap(), "The Matrix");
538
539 let path = "/media/Movies/Inception/inception.mkv";
541 assert_eq!(plex.get_search_term(path).unwrap(), "Inception");
542
543 let path = "/media/TV Shows/Game of Thrones/Season 2";
545 assert_eq!(plex.get_search_term(path).unwrap(), "Game of Thrones");
546
547 let path = "/media/TV Shows/Game of Thrones";
549 assert_eq!(plex.get_search_term(path).unwrap(), "Game of Thrones");
550
551 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 assert_eq!(
690 test_plex().get_search_term(r"D:\Season 1").unwrap(),
691 "Season 1"
692 );
693 }
694}