autopulse_service/settings/targets/
plex.rs1use 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 pub url: String,
19 pub token: String,
21 #[serde(default)]
23 pub refresh: bool,
24 #[serde(default)]
26 pub analyze: bool,
27 pub rewrite: Option<Rewrite>,
29 #[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 key: String,
46 pub file: String,
48 }
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 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 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 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 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 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 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 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 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 let path = "/media/TV Shows/Breaking Bad/Season 1/S01E01.mkv";
538 assert_eq!(plex.get_search_term(path).unwrap(), "Breaking Bad");
539
540 let path = "/media/Movies/The Matrix (1999) [1080p]/matrix.mkv";
542 assert_eq!(plex.get_search_term(path).unwrap(), "The Matrix");
543
544 let path = "/media/Movies/Inception/inception.mkv";
546 assert_eq!(plex.get_search_term(path).unwrap(), "Inception");
547
548 let path = "/media/TV Shows/Game of Thrones/Season 2";
550 assert_eq!(plex.get_search_term(path).unwrap(), "Game of Thrones");
551
552 let path = "/media/TV Shows/Game of Thrones";
554 assert_eq!(plex.get_search_term(path).unwrap(), "Game of Thrones");
555
556 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}