autopulse_service/settings/targets/
radarr.rs

1use crate::settings::rewrite::Rewrite;
2use crate::settings::targets::TargetProcess;
3use autopulse_database::models::ScanEvent;
4use autopulse_utils::get_url;
5use reqwest::header;
6use serde::{Deserialize, Serialize};
7use std::{collections::HashMap, path::Path};
8use tracing::error;
9
10use super::RequestBuilderPerform;
11
12#[derive(Deserialize, Clone)]
13pub struct Radarr {
14    /// URL to the Plex server
15    pub url: String,
16    /// API token for the Plex server
17    pub token: String,
18    /// Rewrite path for the file
19    pub rewrite: Option<Rewrite>,
20}
21
22#[derive(Deserialize, Debug)]
23struct RadarrMovie {
24    id: i64,
25    path: String,
26}
27
28#[derive(Serialize)]
29#[serde(rename_all = "camelCase")]
30struct RefreshMovie {
31    movie_ids: Vec<i64>,
32}
33
34#[derive(Serialize)]
35#[serde(tag = "name")]
36#[serde(rename_all = "PascalCase")]
37enum Command {
38    RefreshMovie(RefreshMovie),
39}
40
41impl Radarr {
42    fn get_client(&self) -> anyhow::Result<reqwest::Client> {
43        let mut headers = header::HeaderMap::new();
44
45        headers.insert("X-Api-Key", self.token.parse().unwrap());
46        headers.insert("Accept", "application/json".parse().unwrap());
47
48        reqwest::Client::builder()
49            .timeout(std::time::Duration::from_secs(10))
50            .default_headers(headers)
51            .build()
52            .map_err(Into::into)
53    }
54
55    async fn get_movies(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<i64>> {
56        let client = self.get_client().unwrap();
57
58        let url = get_url(&self.url)?.join("api/v3/movie")?;
59        let mut to_be_refreshed: HashMap<i64, Vec<String>> = HashMap::new();
60
61        let res = client.get(url).perform().await?;
62
63        let movies = res.json::<Vec<RadarrMovie>>().await?;
64
65        for ev in evs {
66            let ev_path = ev.get_path(&self.rewrite);
67            let ev_path = Path::new(&ev_path);
68
69            for movie in &movies {
70                let movie_path = Path::new(&movie.path);
71                if ev_path.starts_with(movie_path) {
72                    to_be_refreshed
73                        .entry(movie.id)
74                        .or_default()
75                        .push(ev.id.clone());
76                    break;
77                }
78            }
79        }
80
81        // In future instead of batching the refresh command, just send individual refresh commands
82        // per movie and then only partially fail events that failed to refresh
83        Ok(to_be_refreshed.into_keys().collect())
84    }
85
86    async fn refresh_movies(&self, movie_ids: Vec<i64>) -> anyhow::Result<()> {
87        let client = self.get_client().unwrap();
88        let url = get_url(&self.url)?.join("api/v3/command")?;
89        let payload = Command::RefreshMovie(RefreshMovie { movie_ids });
90
91        client.post(url).json(&payload).perform().await.map(|_| ())
92    }
93}
94
95impl TargetProcess for Radarr {
96    async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
97        let mut succeeded = Vec::new();
98
99        let movies = self.get_movies(evs).await?;
100
101        match self.refresh_movies(movies).await {
102            Ok(()) => {
103                succeeded.extend(evs.iter().map(|ev| ev.id.clone()));
104            }
105            Err(e) => {
106                error!("failed to refresh series: {}", e);
107            }
108        }
109
110        Ok(succeeded)
111    }
112}