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;
6use reqwest::header;
7use serde::{Deserialize, Serialize};
8use std::{collections::HashMap, path::Path};
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
48impl Radarr {
49    fn get_client(&self) -> anyhow::Result<reqwest::Client> {
50        let mut headers = header::HeaderMap::new();
51
52        headers.insert("X-Api-Key", self.token.parse()?);
53        headers.insert("Accept", "application/json".parse()?);
54
55        self.request
56            .client_builder(headers)
57            .build()
58            .map_err(Into::into)
59    }
60
61    async fn get_movies(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<i64>> {
62        let client = self.get_client()?;
63
64        let url = get_url(&self.url)?.join("api/v3/movie")?;
65        let mut to_be_refreshed: HashMap<i64, Vec<String>> = HashMap::new();
66
67        let res = client.get(url).perform().await?;
68
69        let movies = res.json::<Vec<RadarrMovie>>().await?;
70
71        for ev in evs {
72            let ev_path = ev.get_path(&self.rewrite);
73            let ev_path = Path::new(&ev_path);
74
75            for movie in &movies {
76                let movie_path = Path::new(&movie.path);
77                if ev_path.starts_with(movie_path) {
78                    to_be_refreshed
79                        .entry(movie.id)
80                        .or_default()
81                        .push(ev.id.clone());
82                    break;
83                }
84            }
85        }
86
87        // TODO: per-movie commands would let us isolate partial failures,
88        // but the serial-POST cost on large imports outweighs that today.
89        Ok(to_be_refreshed.into_keys().collect())
90    }
91
92    async fn refresh_movies(&self, movie_ids: Vec<i64>) -> anyhow::Result<()> {
93        let client = self.get_client()?;
94        let url = get_url(&self.url)?.join("api/v3/command")?;
95        let payload = Command::RefreshMovie(RefreshMovie { movie_ids });
96
97        client.post(url).json(&payload).perform().await.map(|_| ())
98    }
99}
100
101impl TargetProcess for Radarr {
102    async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
103        let mut succeeded = Vec::new();
104
105        let movies = self.get_movies(evs).await?;
106
107        match self.refresh_movies(movies).await {
108            Ok(()) => {
109                succeeded.extend(evs.iter().map(|ev| ev.id.clone()));
110            }
111            Err(e) => {
112                error!("failed to refresh movies: {e}");
113            }
114        }
115
116        Ok(succeeded)
117    }
118}