Skip to main content

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///     empty_trash: true
136/// ```
137///
138/// See [`Plex`] for all options
139pub mod plex;
140/// Radarr - Radarr target
141///
142/// This target is used to refresh/rescan a movie in Radarr
143///
144/// # Example
145///
146/// ```yml
147/// targets:
148///   radarr:
149///     type: radarr
150///     url: http://localhost:7878
151///     token: "<API_KEY>"
152/// ```
153///
154/// See [`Radarr`] for all options
155pub mod radarr;
156/// Sonarr - Sonarr target
157///
158/// This target is used to refresh/rescan a series in Sonarr
159///
160/// # Example
161///
162/// ```yml
163/// targets:
164///   sonarr:
165///     type: sonarr
166///     url: http://localhost:8989
167///     token: "<API_KEY>"
168/// ```
169///
170/// See [`Sonarr`] for all options
171pub mod sonarr;
172/// Tdarr - Tdarr target
173///
174/// This target is used to process a file in Tdarr
175///
176/// # Example
177///
178/// ```yml
179/// targets:
180///   tdarr:
181///     type: tdarr
182///     url: http://localhost:8265
183///     db_id: "<LIBRARY_ID>"
184/// ```
185///
186/// See [`Tdarr`] for all options
187pub mod tdarr;
188
189use crate::settings::{path_filter::PathFilter, rewrite::Rewrite};
190use audiobookshelf::Audiobookshelf;
191use autopulse_database::models::ScanEvent;
192use reqwest::{header, RequestBuilder, Response};
193use serde::{Deserialize, Serialize};
194use std::collections::HashMap;
195use {
196    autopulse::Autopulse, command::Command, emby::Emby, fileflows::FileFlows, plex::Plex,
197    radarr::Radarr, sonarr::Sonarr, tdarr::Tdarr,
198};
199
200/// HTTP request configuration options for targets
201///
202/// # Example
203///
204/// ```yml
205/// targets:
206///   my_plex:
207///     type: plex
208///     url: https://192.168.1.100:32400
209///     token: "<PLEX_TOKEN>"
210///     request:
211///       insecure: true
212///       timeout: 30
213///       headers:
214///         X-Custom-Header: "value"
215/// ```
216#[derive(Serialize, Deserialize, Clone, Default)]
217pub struct Request {
218    /// Allow insecure HTTPS connections (skip certificate verification) (default: false)
219    #[serde(default)]
220    pub insecure: bool,
221
222    /// Request timeout in seconds (default: 10)
223    pub timeout: Option<u64>,
224
225    /// Custom headers to include in requests
226    #[serde(default)]
227    pub headers: HashMap<String, String>,
228}
229
230impl Request {
231    /// Default timeout in seconds
232    pub const DEFAULT_TIMEOUT: u64 = 10;
233
234    /// Returns a pre-configured reqwest ClientBuilder with insecure, timeout, and header settings.
235    ///
236    /// Custom headers from the request config are merged into the provided headers.
237    /// Existing headers (e.g., auth tokens) are not overwritten by custom headers.
238    pub fn client_builder(&self, mut headers: header::HeaderMap) -> reqwest::ClientBuilder {
239        for (key, value) in &self.headers {
240            match (
241                header::HeaderName::from_bytes(key.as_bytes()),
242                header::HeaderValue::from_str(value),
243            ) {
244                (Ok(name), Ok(val)) => {
245                    if headers.contains_key(&name) {
246                        tracing::warn!("header '{}' already exists, ignoring custom value", key);
247                    } else {
248                        headers.insert(name, val);
249                    }
250                }
251                (Err(e), _) => tracing::warn!("invalid header name '{}': {}", key, e),
252                (_, Err(e)) => tracing::warn!("invalid header value for '{}': {}", key, e),
253            }
254        }
255
256        reqwest::Client::builder()
257            .tls_danger_accept_invalid_certs(self.insecure)
258            .timeout(std::time::Duration::from_secs(
259                self.timeout.unwrap_or(Self::DEFAULT_TIMEOUT),
260            ))
261            .default_headers(headers)
262    }
263}
264
265#[derive(Serialize, Deserialize)]
266#[serde(rename_all = "lowercase")]
267pub enum TargetType {
268    Plex,
269    Jellyfin,
270    Emby,
271    Tdarr,
272    Sonarr,
273    Radarr,
274    Command,
275    FileFlows,
276    Autopulse,
277    Audiobookshelf,
278}
279
280#[derive(Serialize, Deserialize, Clone)]
281#[serde(tag = "type", rename_all = "lowercase")]
282pub enum Target {
283    Plex(Plex),
284    Jellyfin(Emby),
285    Emby(Emby),
286    Tdarr(Tdarr),
287    Sonarr(Sonarr),
288    Radarr(Radarr),
289    Command(Command),
290    FileFlows(FileFlows),
291    Autopulse(Autopulse),
292    Audiobookshelf(Audiobookshelf),
293}
294
295impl Target {
296    fn rewrite(&self) -> &Option<Rewrite> {
297        match self {
298            Self::Plex(t) => &t.rewrite,
299            Self::Jellyfin(t) | Self::Emby(t) => &t.rewrite,
300            Self::Tdarr(t) => &t.rewrite,
301            Self::Sonarr(t) => &t.rewrite,
302            Self::Radarr(t) => &t.rewrite,
303            Self::Command(t) => &t.rewrite,
304            Self::FileFlows(t) => &t.rewrite,
305            Self::Autopulse(t) => &t.rewrite,
306            Self::Audiobookshelf(t) => &t.rewrite,
307        }
308    }
309
310    fn filter(&self) -> &PathFilter {
311        match self {
312            Self::Plex(t) => &t.filter,
313            Self::Jellyfin(t) | Self::Emby(t) => &t.filter,
314            Self::Tdarr(t) => &t.filter,
315            Self::Sonarr(t) => &t.filter,
316            Self::Radarr(t) => &t.filter,
317            Self::Command(t) => &t.filter,
318            Self::FileFlows(t) => &t.filter,
319            Self::Autopulse(t) => &t.filter,
320            Self::Audiobookshelf(t) => &t.filter,
321        }
322    }
323
324    pub fn should_process_event(&self, ev: &ScanEvent) -> bool {
325        let path = ev.get_path(self.rewrite());
326        self.filter().allows(&path)
327    }
328}
329
330pub trait TargetProcess {
331    fn process(
332        &self,
333        evs: &[&ScanEvent],
334    ) -> impl std::future::Future<Output = anyhow::Result<Vec<String>>> + Send;
335}
336
337impl TargetProcess for Target {
338    async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
339        match self {
340            Self::Plex(t) => t.process(evs).await,
341            Self::Jellyfin(t) | Self::Emby(t) => t.process(evs).await,
342            Self::Command(t) => t.process(evs).await,
343            Self::Tdarr(t) => t.process(evs).await,
344            Self::Sonarr(t) => t.process(evs).await,
345            Self::Radarr(t) => t.process(evs).await,
346            Self::FileFlows(t) => t.process(evs).await,
347            Self::Autopulse(t) => t.process(evs).await,
348            Self::Audiobookshelf(t) => t.process(evs).await,
349        }
350    }
351}
352
353pub trait RequestBuilderPerform {
354    fn perform(self) -> impl std::future::Future<Output = anyhow::Result<Response>> + Send;
355}
356
357impl RequestBuilderPerform for RequestBuilder {
358    async fn perform(self) -> anyhow::Result<Response> {
359        let copy = self
360            .try_clone()
361            .ok_or_else(|| anyhow::anyhow!("failed to clone request"))?;
362        let built = copy
363            .build()
364            .map_err(|e| anyhow::anyhow!("failed to build request: {}", e))?;
365        let response = self.send().await;
366
367        match response {
368            Ok(response) => {
369                if !response.status().is_success() {
370                    return Err(anyhow::anyhow!(
371                        // failed to PUT /path/to/file: 404 - Not Found
372                        "unable to {} {}: {} - {}",
373                        built.method(),
374                        built.url(),
375                        response.status(),
376                        response
377                            .text()
378                            .await
379                            .unwrap_or_else(|_| "unknown error".to_string()),
380                    ));
381                }
382
383                Ok(response)
384            }
385
386            Err(e) => {
387                let status = e.status();
388                if let Some(status) = status {
389                    return Err(anyhow::anyhow!(
390                        "failed to {} {}: {} - {}",
391                        built.method(),
392                        built.url(),
393                        status,
394                        e
395                    ));
396                }
397
398                Err(anyhow::anyhow!(
399                    "failed to {} {}: {}",
400                    built.method(),
401                    built.url(),
402                    e,
403                ))
404            }
405        }
406    }
407}