autopulse_service/settings/targets/
mod.rs

1/// Audiobookshelf - Audiobookshelf target
2///
3/// This target is used to send a file to the Audiobookshelf watcher
4///
5/// # Example
6///
7/// ```yml
8/// targets:
9///   audiobookshelf:
10///     type: audiobookshelf
11///     url: http://localhost:13378
12///     token: "<API_KEY>"
13/// ```
14///
15/// See [`Audiobookshelf`] for all options
16pub mod audiobookshelf;
17/// Autopulse - Autopulse target
18///
19/// This target is used to process a file in another instance of Autopulse
20///
21/// # Example
22///
23/// ```yml
24/// targets:
25///   autopulse:
26///     type: autopulse
27///     url: http://localhost:2875
28///     auth:
29///       username: "admin"
30///       password: "password"
31/// ```
32/// or
33/// ```yml
34/// targets:
35///   autopulse:
36///     type: autopulse
37///     url: http://localhost:2875
38///     auth:
39///       username: "admin"
40///       password: "password"
41///     trigger: "other"
42/// ```
43///
44/// See [`Autopulse`] for all options
45pub mod autopulse;
46/// Command - Command target
47///
48/// This target is used to run a command to process a file
49///
50/// # Example
51///
52/// ```yml
53/// targets:
54///   list:
55///     type: command
56///     raw: "echo $FILE_PATH >> list.log"
57/// ```
58///
59/// or
60///
61/// ```yml
62/// targets:
63///   list:
64///     type: command
65///     path: "/path/to/script.sh"
66/// ```
67///
68/// See [`Command`] for all options
69pub mod command;
70/// Emby - Emby/Jellyfin target
71///
72/// This target is used to refresh/scan a file in Emby/Jellyfin
73///
74/// # Example
75///
76/// ```yml
77/// targets:
78///   my_jellyfin:
79///     type: jellyfin
80///     url: http://localhost:8096
81///     token: "<API_KEY>"
82///     # refresh_metadata: false # To disable metadata refresh
83/// ```
84/// or
85/// ```yml
86/// targets:
87///   my_emby:
88///     type: emby
89///     url: http://localhost:8096
90///     token: "<API_KEY>"
91///     # refresh_metadata: false # To disable metadata refresh
92///     # metadata_refresh_mode: "validation_only" # To change metadata refresh mode
93/// ```
94///
95/// See [`Emby`] for all options
96#[doc(alias("jellyfin"))]
97pub mod emby;
98/// `FileFlows` - `FileFlows` target
99///
100/// This target is used to process a file in `FileFlows`
101///
102/// # Example
103///
104/// ```yml
105/// targets:
106///   fileflows:
107///     type: fileflows
108///     url: http://localhost:5000
109/// ```
110///
111/// See [`FileFlows`] for all options
112pub mod fileflows;
113/// Plex - Plex target
114///
115/// This target is used to scan a file in Plex
116///
117/// # Example
118///
119/// ```yml
120/// targets:
121///   my_plex:
122///     type: plex
123///     url: http://localhost:32400
124///     token: "<PLEX_TOKEN>"
125/// ```
126/// or
127/// ```yml
128/// targets:
129///   my_plex:
130///     type: plex
131///     url: http://localhost:32400
132///     token: "<PLEX_TOKEN>"
133///     refresh: true
134///     analyze: true
135/// ```
136///
137/// See [`Plex`] for all options
138pub mod plex;
139/// Radarr - Radarr target
140///
141/// This target is used to refresh/rescan a movie in Radarr
142///
143/// # Example
144///
145/// ```yml
146/// targets:
147///   radarr:
148///     type: radarr
149///     url: http://localhost:7878
150///     token: "<API_KEY>"
151/// ```
152///
153/// See [`Radarr`] for all options
154pub mod radarr;
155/// Sonarr - Sonarr target
156///
157/// This target is used to refresh/rescan a series in Sonarr
158///
159/// # Example
160///
161/// ```yml
162/// targets:
163///   sonarr:
164///     type: sonarr
165///     url: http://localhost:8989
166///     token: "<API_KEY>"
167/// ```
168///
169/// See [`Sonarr`] for all options
170pub mod sonarr;
171/// Tdarr - Tdarr target
172///
173/// This target is used to process a file in Tdarr
174///
175/// # Example
176///
177/// ```yml
178/// targets:
179///   tdarr:
180///     type: tdarr
181///     url: http://localhost:8265
182///     db_id: "<LIBRARY_ID>"
183/// ```
184///
185/// See [`Tdarr`] for all options
186pub mod tdarr;
187
188use audiobookshelf::Audiobookshelf;
189use autopulse_database::models::ScanEvent;
190use reqwest::{header, RequestBuilder, Response};
191use serde::{Deserialize, Serialize};
192use std::collections::HashMap;
193use {
194    autopulse::Autopulse, command::Command, emby::Emby, fileflows::FileFlows, plex::Plex,
195    radarr::Radarr, sonarr::Sonarr, tdarr::Tdarr,
196};
197
198/// HTTP request configuration options for targets
199///
200/// # Example
201///
202/// ```yml
203/// targets:
204///   my_plex:
205///     type: plex
206///     url: https://192.168.1.100:32400
207///     token: "<PLEX_TOKEN>"
208///     request:
209///       insecure: true
210///       timeout: 30
211///       headers:
212///         X-Custom-Header: "value"
213/// ```
214#[derive(Serialize, Deserialize, Clone, Default)]
215pub struct Request {
216    /// Allow insecure HTTPS connections (skip certificate verification) (default: false)
217    #[serde(default)]
218    pub insecure: bool,
219
220    /// Request timeout in seconds (default: 10)
221    pub timeout: Option<u64>,
222
223    /// Custom headers to include in requests
224    #[serde(default)]
225    pub headers: HashMap<String, String>,
226}
227
228impl Request {
229    /// Default timeout in seconds
230    pub const DEFAULT_TIMEOUT: u64 = 10;
231
232    /// Returns a pre-configured reqwest ClientBuilder with insecure, timeout, and header settings.
233    ///
234    /// Custom headers from the request config are merged into the provided headers.
235    /// Existing headers (e.g., auth tokens) are not overwritten by custom headers.
236    pub fn client_builder(&self, mut headers: header::HeaderMap) -> reqwest::ClientBuilder {
237        for (key, value) in &self.headers {
238            match (
239                header::HeaderName::from_bytes(key.as_bytes()),
240                header::HeaderValue::from_str(value),
241            ) {
242                (Ok(name), Ok(val)) => {
243                    if headers.contains_key(&name) {
244                        tracing::warn!("header '{}' already exists, ignoring custom value", key);
245                    } else {
246                        headers.insert(name, val);
247                    }
248                }
249                (Err(e), _) => tracing::warn!("invalid header name '{}': {}", key, e),
250                (_, Err(e)) => tracing::warn!("invalid header value for '{}': {}", key, e),
251            }
252        }
253
254        reqwest::Client::builder()
255            .tls_danger_accept_invalid_certs(self.insecure)
256            .timeout(std::time::Duration::from_secs(
257                self.timeout.unwrap_or(Self::DEFAULT_TIMEOUT),
258            ))
259            .default_headers(headers)
260    }
261}
262
263#[derive(Serialize, Deserialize)]
264#[serde(rename_all = "lowercase")]
265pub enum TargetType {
266    Plex,
267    Jellyfin,
268    Emby,
269    Tdarr,
270    Sonarr,
271    Radarr,
272    Command,
273    FileFlows,
274    Autopulse,
275}
276
277#[derive(Serialize, Deserialize, Clone)]
278#[serde(tag = "type", rename_all = "lowercase")]
279pub enum Target {
280    Plex(Plex),
281    Jellyfin(Emby),
282    Emby(Emby),
283    Tdarr(Tdarr),
284    Sonarr(Sonarr),
285    Radarr(Radarr),
286    Command(Command),
287    FileFlows(FileFlows),
288    Autopulse(Autopulse),
289    Audiobookshelf(Audiobookshelf),
290}
291
292pub trait TargetProcess {
293    fn process(
294        &self,
295        evs: &[&ScanEvent],
296    ) -> impl std::future::Future<Output = anyhow::Result<Vec<String>>> + Send;
297}
298
299impl TargetProcess for Target {
300    async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
301        match self {
302            Self::Plex(t) => t.process(evs).await,
303            Self::Jellyfin(t) | Self::Emby(t) => t.process(evs).await,
304            Self::Command(t) => t.process(evs).await,
305            Self::Tdarr(t) => t.process(evs).await,
306            Self::Sonarr(t) => t.process(evs).await,
307            Self::Radarr(t) => t.process(evs).await,
308            Self::FileFlows(t) => t.process(evs).await,
309            Self::Autopulse(t) => t.process(evs).await,
310            Self::Audiobookshelf(t) => t.process(evs).await,
311        }
312    }
313}
314
315pub trait RequestBuilderPerform {
316    fn perform(self) -> impl std::future::Future<Output = anyhow::Result<Response>> + Send;
317}
318
319impl RequestBuilderPerform for RequestBuilder {
320    async fn perform(self) -> anyhow::Result<Response> {
321        let copy = self
322            .try_clone()
323            .ok_or_else(|| anyhow::anyhow!("failed to clone request"))?;
324        let built = copy
325            .build()
326            .map_err(|e| anyhow::anyhow!("failed to build request: {}", e))?;
327        let response = self.send().await;
328
329        match response {
330            Ok(response) => {
331                if !response.status().is_success() {
332                    return Err(anyhow::anyhow!(
333                        // failed to PUT /path/to/file: 404 - Not Found
334                        "unable to {} {}: {} - {}",
335                        built.method(),
336                        built.url(),
337                        response.status(),
338                        response
339                            .text()
340                            .await
341                            .unwrap_or_else(|_| "unknown error".to_string()),
342                    ));
343                }
344
345                Ok(response)
346            }
347
348            Err(e) => {
349                let status = e.status();
350                if let Some(status) = status {
351                    return Err(anyhow::anyhow!(
352                        "failed to {} {}: {} - {}",
353                        built.method(),
354                        built.url(),
355                        status,
356                        e
357                    ));
358                }
359
360                Err(anyhow::anyhow!(
361                    "failed to {} {}: {}",
362                    built.method(),
363                    built.url(),
364                    e,
365                ))
366            }
367        }
368    }
369}