autopulse_service/settings/targets/
plex.rs1use 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 pub url: String,
20 pub token: String,
22 #[serde(default)]
24 pub refresh: bool,
25 #[serde(default)]
27 pub analyze: bool,
28 pub rewrite: Option<Rewrite>,
30 #[serde(default)]
32 pub filter: PathFilter,
33 #[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 key: String,
50 pub file: String,
52 }
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 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 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 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("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 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 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 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 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 } 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 let path = "/media/TV Shows/Breaking Bad/Season 1/S01E01.mkv";
543 assert_eq!(plex.get_search_term(path).unwrap(), "Breaking Bad");
544
545 let path = "/media/Movies/The Matrix (1999) [1080p]/matrix.mkv";
547 assert_eq!(plex.get_search_term(path).unwrap(), "The Matrix");
548
549 let path = "/media/Movies/Inception/inception.mkv";
551 assert_eq!(plex.get_search_term(path).unwrap(), "Inception");
552
553 let path = "/media/TV Shows/Game of Thrones/Season 2";
555 assert_eq!(plex.get_search_term(path).unwrap(), "Game of Thrones");
556
557 let path = "/media/TV Shows/Game of Thrones";
559 assert_eq!(plex.get_search_term(path).unwrap(), "Game of Thrones");
560
561 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}