Skip to main content

autopulse_service/settings/targets/
sonarr.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 Sonarr {
15    /// URL to the Sonarr server
16    pub url: String,
17    /// API token for the Sonarr 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 SonarrSeries {
31    id: i64,
32    path: String,
33}
34
35#[derive(Serialize)]
36#[serde(rename_all = "camelCase")]
37struct RefreshSeries {
38    series_id: i64,
39}
40
41#[derive(Serialize)]
42#[serde(tag = "name")]
43#[serde(rename_all = "PascalCase")]
44enum Command {
45    RefreshSeries(RefreshSeries),
46}
47
48fn matching_series_id(path: &str, series: &[SonarrSeries]) -> Option<i64> {
49    let event = RuntimePath::new(path);
50    series
51        .iter()
52        .find(|candidate| event.starts_with(RuntimePath::new(&candidate.path)))
53        .map(|candidate| candidate.id)
54}
55
56impl Sonarr {
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_series(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<(i64, Vec<String>)>> {
70        let client = self.get_client()?;
71        let url = get_url(&self.url)?.join("api/v3/series")?;
72        let mut to_be_refreshed: HashMap<i64, Vec<String>> = HashMap::new();
73
74        let res = client.get(url).perform().await?;
75
76        let series = res.json::<Vec<SonarrSeries>>().await?;
77
78        for ev in evs {
79            let ev_path = ev.get_path(&self.rewrite);
80
81            if let Some(series_id) = matching_series_id(&ev_path, &series) {
82                to_be_refreshed
83                    .entry(series_id)
84                    .or_default()
85                    .push(ev.id.clone());
86            }
87        }
88
89        Ok(to_be_refreshed.into_iter().collect())
90    }
91
92    async fn refresh_series(&self, series_id: i64) -> anyhow::Result<()> {
93        let client = self.get_client()?;
94        let url = get_url(&self.url)?.join("api/v3/command")?;
95        let payload = Command::RefreshSeries(RefreshSeries { series_id });
96
97        client.post(url).json(&payload).perform().await.map(|_| ())
98    }
99}
100
101impl TargetProcess for Sonarr {
102    async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
103        let mut succeeded = Vec::new();
104
105        let series = self.get_series(evs).await?;
106
107        for (series_id, ev_ids) in series {
108            match self.refresh_series(series_id).await {
109                Ok(()) => {
110                    succeeded.extend(ev_ids);
111                }
112                Err(e) => {
113                    error!("failed to refresh series: {}", e);
114                }
115            }
116        }
117
118        Ok(succeeded)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn matches_windows_series_case_insensitively_by_components() {
128        let series = vec![SonarrSeries {
129            id: 42,
130            path: r"D:\TV\Breaking Bad".to_string(),
131        }];
132
133        assert_eq!(
134            matching_series_id(r"d:\tv\breaking bad\Season 1\S01E01.mkv", &series),
135            Some(42)
136        );
137    }
138
139    #[test]
140    fn rejects_series_text_prefix_and_mixed_flavor() {
141        let windows = vec![SonarrSeries {
142            id: 42,
143            path: r"D:\TV\Show".to_string(),
144        }];
145        let unix = vec![SonarrSeries {
146            id: 7,
147            path: "/tv/Show".to_string(),
148        }];
149
150        assert_eq!(
151            matching_series_id(r"D:\TV\Showcase\Episode.mkv", &windows),
152            None
153        );
154        assert_eq!(matching_series_id(r"D:\tv\Show\Episode.mkv", &unix), None);
155    }
156}