autopulse_service/settings/targets/
plex.rs1use super::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;
9use std::{
10 collections::{HashMap, HashSet},
11 path::Path,
12};
13use tracing::{debug, error, trace};
14
15#[derive(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}
30
31#[derive(Deserialize, Clone, Debug)]
32#[serde(rename_all = "camelCase")]
33pub struct Media {
34 #[serde(rename = "Part")]
35 pub part: Vec<Part>,
36}
37
38#[derive(Deserialize, Clone, Debug)]
39#[serde(rename_all = "camelCase")]
40pub struct Part {
41 pub key: String,
43 pub file: String,
45 }
53
54#[derive(Deserialize, Clone, Debug)]
55#[serde(rename_all = "camelCase")]
56pub struct Metadata {
57 pub key: String,
58 #[serde(rename = "Media")]
59 pub media: Option<Vec<Media>>,
60 #[serde(rename = "type")]
61 pub t: String,
62}
63
64#[doc(hidden)]
65#[derive(Deserialize, Clone, Debug)]
66struct Location {
67 path: String,
68}
69
70#[doc(hidden)]
71#[derive(Deserialize, Clone, Debug)]
72struct Library {
73 title: String,
74 key: String,
75 #[serde(rename = "Location")]
76 location: Vec<Location>,
77}
78
79#[doc(hidden)]
80#[derive(Deserialize, Clone)]
81#[serde(rename_all = "PascalCase")]
82struct LibraryMediaContainer {
83 directory: Option<Vec<Library>>,
84 metadata: Option<Vec<Metadata>>,
85}
86
87#[doc(hidden)]
88#[derive(Deserialize, Clone)]
89#[serde(rename_all = "PascalCase")]
90struct SearchResult {
91 metadata: Option<Metadata>,
92}
93
94#[doc(hidden)]
95#[derive(Deserialize, Clone)]
96#[serde(rename_all = "PascalCase")]
97struct SearchLibraryMediaContainer {
98 #[serde(default)]
99 search_result: Vec<SearchResult>,
100}
101
102#[doc(hidden)]
103#[derive(Deserialize, Clone)]
104#[serde(rename_all = "PascalCase")]
105struct LibraryResponse {
106 media_container: LibraryMediaContainer,
107}
108
109#[doc(hidden)]
110#[derive(Deserialize, Clone)]
111#[serde(rename_all = "PascalCase")]
112struct SearchLibraryResponse {
113 media_container: SearchLibraryMediaContainer,
114}
115
116fn path_matches(part_file: &str, path: &Path) -> bool {
117 let part_file_path = Path::new(part_file);
118 let what_is_path = what_is(path);
119
120 if what_is_path == PathType::Directory {
121 part_file_path.starts_with(path)
122 } else {
123 part_file_path == path
124 }
125}
126
127fn has_matching_media(media: &[Media], path: &Path) -> bool {
128 media
129 .iter()
130 .any(|m| m.part.iter().any(|p| path_matches(&p.file, path)))
131}
132
133impl Plex {
134 fn get_client(&self) -> anyhow::Result<reqwest::Client> {
135 let mut headers = header::HeaderMap::new();
136
137 headers.insert("X-Plex-Token", self.token.parse().unwrap());
138 headers.insert("Accept", "application/json".parse().unwrap());
139
140 reqwest::Client::builder()
141 .timeout(std::time::Duration::from_secs(10))
142 .default_headers(headers)
143 .build()
144 .map_err(Into::into)
145 }
146
147 async fn libraries(&self) -> anyhow::Result<Vec<Library>> {
148 let client = self.get_client()?;
149 let url = get_url(&self.url)?.join("library/sections")?;
150
151 let res = client.get(url).perform().await?;
152
153 let libraries: LibraryResponse = res.json().await?;
154
155 Ok(libraries.media_container.directory.unwrap())
156 }
157
158 fn get_libraries(&self, libraries: &[Library], path: &str) -> Vec<Library> {
159 let ev_path = Path::new(path);
160 let mut matches: Vec<(usize, &Library)> = vec![];
161
162 for library in libraries {
163 for location in &library.location {
164 let loc_path = Path::new(&location.path);
165 if ev_path.starts_with(loc_path) {
166 matches.push((loc_path.components().count(), library));
167 }
168 }
169 }
170
171 matches.sort_by(|(len_a, _), (len_b, _)| len_b.cmp(len_a));
173
174 matches
175 .into_iter()
176 .map(|(_, library)| library.clone())
177 .collect()
178 }
179
180 async fn get_episodes(&self, key: &str) -> anyhow::Result<LibraryResponse> {
181 let client = self.get_client()?;
182
183 let key = key.rsplit_once('/').map(|x| x.0).unwrap_or(key);
185
186 let url = get_url(&self.url)?.join(&format!("{key}/allLeaves"))?;
187
188 let res = client.get(url).perform().await?;
189
190 let lib: LibraryResponse = res.json().await?;
191
192 Ok(lib)
193 }
194
195 fn get_search_term(&self, path: &str) -> anyhow::Result<String> {
196 let parent_or_dir = squash_directory(Path::new(path));
197
198 let parts = parent_or_dir.components().collect::<Vec<_>>();
199
200 let mut chosen_part = parent_or_dir
201 .to_str()
202 .ok_or_else(|| anyhow::anyhow!("failed to convert path to string"))?
203 .to_string()
204 .replace("/", " ");
205
206 for part in parts.iter().rev() {
207 let part_str = part.as_os_str().to_string_lossy();
208
209 if part_str.contains("Season") || part_str.is_empty() {
210 continue;
211 }
212
213 chosen_part = part_str.to_string();
214 break;
215 }
216
217 let chosen_part = chosen_part
218 .split_whitespace()
219 .filter(|&s| {
220 ["(", ")", "[", "]", "{", "}"]
221 .iter()
222 .all(|&c| !s.contains(c))
223 })
224 .collect::<Vec<_>>()
225 .join(" ");
226
227 Ok(chosen_part)
228 }
229
230 async fn search_items(&self, _library: &Library, path: &str) -> anyhow::Result<Vec<Metadata>> {
231 let client = self.get_client()?;
232 let mut results = vec![];
235
236 let rel_path = path.to_string();
237
238 trace!("searching for item with relative path: {}", rel_path);
239
240 let mut search_term = self.get_search_term(&rel_path)?;
241
242 while !search_term.is_empty() {
243 let mut url = get_url(&self.url)?.join("library/search")?;
244
245 url.query_pairs_mut().append_pair("includeCollections", "1");
246 url.query_pairs_mut()
247 .append_pair("includeExternalMedia", "1");
248 url.query_pairs_mut()
249 .append_pair("searchTypes", "movies,people,tv");
250 url.query_pairs_mut().append_pair("limit", "100");
251
252 trace!("searching for item with term: {}", search_term);
253
254 url.query_pairs_mut()
255 .append_pair("query", search_term.as_str());
257
258 let res = client.get(url).perform().await?;
259
260 let lib: SearchLibraryResponse = res.json().await?;
261
262 let path_obj = Path::new(path);
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_obj) {
294 results.push(episode.clone());
295 }
296 }
297 }
298 }
299 } else if let Some(media) = &item.media {
300 if has_matching_media(media, path_obj) {
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 path = Path::new(path);
337
338 let mut parts = vec![];
339
340 for item in lib.media_container.metadata.unwrap_or_default() {
342 match item.t.as_str() {
343 "show" => {
344 let episodes = self.get_episodes(&item.key).await?;
345
346 for episode in episodes.media_container.metadata.unwrap_or_default() {
347 if let Some(media) = &episode.media {
348 if has_matching_media(media, path) {
349 parts.push(episode.clone());
350 }
351 }
352 }
353 }
354 _ => {
355 if let Some(media) = &item.media {
356 if has_matching_media(media, path) {
357 parts.push(item.clone());
358 }
359 }
360 }
361 }
362 }
363
364 Ok(parts)
365 }
366
367 async fn refresh_item(&self, key: &str) -> anyhow::Result<()> {
368 let client = self.get_client()?;
369 let url = get_url(&self.url)?.join(&format!("{key}/refresh"))?;
370
371 client.put(url).perform().await.map(|_| ())
372 }
373
374 async fn analyze_item(&self, key: &str) -> anyhow::Result<()> {
375 let client = self.get_client()?;
376 let url = get_url(&self.url)?.join(&format!("{key}/analyze"))?;
377
378 client.put(url).perform().await.map(|_| ())
379 }
380
381 async fn scan(&self, ev: &ScanEvent, library: &Library) -> anyhow::Result<()> {
382 let client = self.get_client()?;
383 let mut url =
384 get_url(&self.url)?.join(&format!("library/sections/{}/refresh", library.key))?;
385
386 let ev_path = ev.get_path(&self.rewrite);
387
388 let squashed_path = squash_directory(&ev_path);
389 let file_dir = squashed_path
390 .as_os_str()
391 .to_str()
392 .ok_or_else(|| anyhow::anyhow!("failed to convert path to string"))?;
393
394 url.query_pairs_mut().append_pair("path", file_dir);
395
396 client.get(url).perform().await.map(|_| ())
397 }
398}
399
400impl TargetProcess for Plex {
401 async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
402 let libraries = self.libraries().await.context("failed to get libraries")?;
403
404 let mut succeeded: HashMap<String, bool> = HashMap::new();
405
406 for ev in evs {
407 let succeeded_entry = succeeded.entry(ev.id.clone()).or_insert(true);
408
409 let ev_path = ev.get_path(&self.rewrite);
410 let matched_libraries = self.get_libraries(&libraries, &ev_path);
411
412 if matched_libraries.is_empty() {
413 error!("no matching library for {ev_path}");
414
415 *succeeded_entry = false;
416
417 continue;
418 }
419
420 let mut processed_items = HashSet::new();
421
422 for library in matched_libraries {
423 trace!("found library '{}' for {ev_path}", library.title);
424
425 match self.scan(ev, &library).await {
426 Ok(()) => {
427 debug!("scanned '{}'", ev_path);
428
429 if self.analyze || self.refresh {
430 match self.search_items(&library, &ev_path).await {
431 Ok(items) => {
432 if items.is_empty() {
433 trace!(
434 "failed to find items for file: '{}', leaving at scan",
435 ev_path
436 );
437
438 *succeeded_entry = true;
439 } else {
440 trace!("found items for file '{}'", ev_path);
441
442 let mut all_success = true;
443
444 for item in items {
445 let mut item_success = true;
446
447 if processed_items.contains(&item.key) {
448 debug!(
449 "already processed item '{}' earlier, skipping",
450 item.key
451 );
452 continue;
453 }
454
455 if self.refresh {
456 match self.refresh_item(&item.key).await {
457 Ok(()) => {
458 debug!("refreshed metadata '{}'", item.key);
459 }
460 Err(e) => {
461 error!(
462 "failed to refresh metadata for '{}': {}",
463 item.key, e
464 );
465 item_success = false;
466 }
467 }
468 }
469
470 if self.analyze {
471 match self.analyze_item(&item.key).await {
472 Ok(()) => {
473 debug!("analyzed metadata '{}'", item.key);
474 }
475 Err(e) => {
476 error!(
477 "failed to analyze metadata for '{}': {}",
478 item.key, e
479 );
480 item_success = false;
481 }
482 }
483 }
484
485 if !item_success {
486 all_success = false;
487 }
488
489 processed_items.insert(item.key);
490 }
491
492 if all_success {
493 *succeeded_entry &= true;
494 }
495 }
496 }
497 Err(e) => {
498 error!("failed to get items for '{}': {:?}", ev_path, e);
499 }
500 };
501 } else {
502 *succeeded_entry &= true;
503 }
504 }
505 Err(e) => {
506 error!("failed to scan file '{}': {}", ev_path, e);
507 }
508 }
509 }
510 }
511
512 Ok(succeeded
513 .into_iter()
514 .filter_map(|(k, v)| if v { Some(k) } else { None })
515 .collect())
516 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn test_get_search_term() {
525 let plex = Plex {
526 url: String::new(),
527 token: String::new(),
528 refresh: false,
529 analyze: false,
530 rewrite: None,
531 };
532
533 let path = "/media/TV Shows/Breaking Bad/Season 1/S01E01.mkv";
535 assert_eq!(plex.get_search_term(path).unwrap(), "Breaking Bad");
536
537 let path = "/media/Movies/The Matrix (1999) [1080p]/matrix.mkv";
539 assert_eq!(plex.get_search_term(path).unwrap(), "The Matrix");
540
541 let path = "/media/Movies/Inception/inception.mkv";
543 assert_eq!(plex.get_search_term(path).unwrap(), "Inception");
544
545 let path = "/media/TV Shows/Game of Thrones/Season 2";
547 assert_eq!(plex.get_search_term(path).unwrap(), "Game of Thrones");
548
549 let path = "/media/TV Shows/Game of Thrones";
551 assert_eq!(plex.get_search_term(path).unwrap(), "Game of Thrones");
552
553 let path = "/media/TV Shows/Doctor Who/Season 10/Season 10 Part 2/S10E12.mkv";
555 assert_eq!(plex.get_search_term(path).unwrap(), "Doctor Who");
556 }
557
558 #[test]
559 fn test_get_library() {
560 let plex = Plex {
561 url: String::new(),
562 token: String::new(),
563 refresh: false,
564 analyze: false,
565 rewrite: None,
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}