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}