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}