Skip to main content

autopulse_service/settings/targets/
radarr.rs

1use crate::settings::path_filter::PathFilter;
2use crate::settings::rewrite::Rewrite;
3use crate::settings::targets::TargetProcess;
4use autopulse_database::models::ScanEvent;
5use autopulse_utils::{get_url, RuntimePath};
6use reqwest::header;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use tracing::error;
10
11use super::{Request, RequestBuilderPerform};
12
13#[derive(Serialize, Deserialize, Clone)]
14pub struct Radarr {
15    /// URL to the Radarr server
16    pub url: String,
17    /// API token for the Radarr server
18    pub token: String,
19    /// Rewrite path for the file
20    pub rewrite: Option<Rewrite>,
21    /// Path filter matched against the target-rewritten path.
22    #[serde(default)]
23    pub filter: PathFilter,
24    /// HTTP request options
25    #[serde(default)]
26    pub request: Request,
27}
28
29#[derive(Deserialize, Debug)]
30struct RadarrMovie {
31    id: i64,
32    path: String,
33}
34
35#[derive(Serialize)]
36#[serde(rename_all = "camelCase")]
37struct RefreshMovie {
38    movie_ids: Vec<i64>,
39}
40
41#[derive(Serialize)]
42#[serde(tag = "name")]
43#[serde(rename_all = "PascalCase")]
44enum Command {
45    RefreshMovie(RefreshMovie),
46}
47
48fn matching_movie_id(path: &str, movies: &[RadarrMovie]) -> Option<i64> {
49    let event = RuntimePath::new(path);
50    movies
51        .iter()
52        .find(|movie| event.starts_with(RuntimePath::new(&movie.path)))
53        .map(|movie| movie.id)
54}
55
56impl Radarr {
57    fn get_client(&self) -> anyhow::Result<reqwest::Client> {
58        let mut headers = header::HeaderMap::new();
59
60        headers.insert("X-Api-Key", self.token.parse()?);
61        headers.insert("Accept", "application/json".parse()?);
62
63        self.request
64            .client_builder(headers)
65            .build()
66            .map_err(Into::into)
67    }
68
69    async fn get_movies(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<i64>> {
70        let client = self.get_client()?;
71
72        let url = get_url(&self.url)?.join("api/v3/movie")?;
73        let mut to_be_refreshed: HashMap<i64, Vec<String>> = HashMap::new();
74
75        let res = client.get(url).perform().await?;
76
77        let movies = res.json::<Vec<RadarrMovie>>().await?;
78
79        for ev in evs {
80            let ev_path = ev.get_path(&self.rewrite);
81
82            if let Some(movie_id) = matching_movie_id(&ev_path, &movies) {
83                to_be_refreshed
84                    .entry(movie_id)
85                    .or_default()
86                    .push(ev.id.clone());
87            }
88        }
89
90        // TODO: per-movie commands would let us isolate partial failures,
91        // but the serial-POST cost on large imports outweighs that today.
92        Ok(to_be_refreshed.into_keys().collect())
93    }
94
95    async fn refresh_movies(&self, movie_ids: Vec<i64>) -> anyhow::Result<()> {
96        let client = self.get_client()?;
97        let url = get_url(&self.url)?.join("api/v3/command")?;
98        let payload = Command::RefreshMovie(RefreshMovie { movie_ids });
99
100        client.post(url).json(&payload).perform().await.map(|_| ())
101    }
102}
103
104impl TargetProcess for Radarr {
105    async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
106        let mut succeeded = Vec::new();
107
108        let movies = self.get_movies(evs).await?;
109
110        match self.refresh_movies(movies).await {
111            Ok(()) => {
112                succeeded.extend(evs.iter().map(|ev| ev.id.clone()));
113            }
114            Err(e) => {
115                error!("failed to refresh movies: {e}");
116            }
117        }
118
119        Ok(succeeded)
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn matches_windows_movie_case_insensitively_by_components() {
129        let movies = vec![RadarrMovie {
130            id: 24,
131            path: r"\\SERVER\Movies\The Matrix".to_string(),
132        }];
133
134        assert_eq!(
135            matching_movie_id(r"\\server\movies\the matrix\The Matrix (1999).mkv", &movies),
136            Some(24)
137        );
138    }
139
140    #[test]
141    fn rejects_movie_text_prefix_and_mixed_flavor() {
142        let windows = vec![RadarrMovie {
143            id: 24,
144            path: r"C:\Movies\Alien".to_string(),
145        }];
146        let unix = vec![RadarrMovie {
147            id: 12,
148            path: "/movies/Alien".to_string(),
149        }];
150
151        assert_eq!(
152            matching_movie_id(r"C:\Movies\Aliens\Aliens.mkv", &windows),
153            None
154        );
155        assert_eq!(matching_movie_id(r"C:\movies\Alien\Alien.mkv", &unix), None);
156    }
157}