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}