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