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