autopulse_service/settings/targets/
fileflows.rs1use super::{Request, RequestBuilderPerform};
2use crate::settings::path_filter::PathFilter;
3use crate::settings::rewrite::Rewrite;
4use crate::settings::targets::TargetProcess;
5use anyhow::Context;
6use autopulse_database::models::ScanEvent;
7use autopulse_utils::{get_url, RuntimePath};
8use reqwest::header;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::HashMap;
12use tracing::{debug, error, trace};
13
14#[derive(Serialize, Deserialize, Clone)]
15pub struct FileFlows {
16 pub url: String,
18 pub rewrite: Option<Rewrite>,
20 #[serde(default)]
22 pub filter: PathFilter,
23 #[serde(default)]
25 pub request: Request,
26}
27
28#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Hash, Debug)]
29#[doc(hidden)]
30#[serde(rename_all = "PascalCase")]
31struct FileFlowsFlow {
32 uid: String,
33}
34
35#[derive(Deserialize, Clone, Eq, PartialEq, Hash, Debug)]
36#[doc(hidden)]
37#[serde(rename_all = "PascalCase")]
38struct FileFlowsLibrary {
39 uid: String,
40 enabled: bool,
41 path: Option<String>,
42 flow: Option<FileFlowsFlow>,
43}
44
45#[derive(Serialize, Debug)]
53#[doc(hidden)]
54#[serde(rename_all = "PascalCase")]
55struct FileFlowsManuallyAddRequest {
56 flow_uid: String,
57 files: Vec<String>,
58 #[serde(default)]
59 custom_variables: HashMap<String, String>,
60}
61
62#[derive(Serialize)]
63#[doc(hidden)]
64#[serde(rename_all = "PascalCase")]
65struct FileFlowsSearchRequest {
66 path: String,
67 limit: u32, }
69
70#[derive(Serialize, Default, Debug)]
71#[doc(hidden)]
72#[serde(rename_all = "PascalCase")]
73struct FileFlowsReprocessRequest {
74 uids: Vec<String>,
75 custom_variables: HashMap<String, String>,
76 mode: u8,
77 flow: Option<Value>,
78 node: Option<Value>,
79 bottom_of_queue: bool,
80}
81
82#[derive(Deserialize, Clone, Eq, PartialEq, Hash, Debug)]
83#[doc(hidden)]
84#[serde(rename_all = "PascalCase")]
85struct FileFlowsLibraryFile {
86 uid: String,
87 flow_uid: String,
88 name: String, }
90
91fn path_in_library(path: &str, library_path: &str) -> bool {
100 RuntimePath::new(path).starts_with(RuntimePath::new(library_path))
101}
102
103impl FileFlows {
104 fn get_client(&self) -> anyhow::Result<reqwest::Client> {
105 self.request
106 .client_builder(header::HeaderMap::new())
107 .build()
108 .map_err(Into::into)
109 }
110
111 async fn get_libraries(&self) -> anyhow::Result<Vec<FileFlowsLibrary>> {
112 let client = self.get_client()?;
113 let url = get_url(&self.url)?.join("api/library")?;
114
115 let res = client.get(url).perform().await?;
116
117 let libraries: Vec<FileFlowsLibrary> = res.json().await?;
118
119 Ok(libraries)
120 }
121
122 async fn get_library_file(
123 &self,
124 ev: &ScanEvent,
125 ) -> anyhow::Result<Option<FileFlowsLibraryFile>> {
126 let client = self.get_client()?;
127
128 let url = get_url(&self.url)?.join("api/library-file/search")?;
129 let req = FileFlowsSearchRequest {
130 path: ev.get_path(&self.rewrite),
131 limit: 1,
132 };
133
134 let res = client.post(url).json(&req).perform().await?;
135
136 let files: Vec<FileFlowsLibraryFile> = res.json().await?;
137
138 Ok(files.first().cloned())
139 }
140
141 async fn reprocess_library_file(&self, evs: Vec<&FileFlowsLibraryFile>) -> anyhow::Result<()> {
142 let client = self.get_client()?;
143
144 let url = get_url(&self.url)?.join("api/library-file/reprocess")?;
145
146 let req = FileFlowsReprocessRequest {
147 uids: evs.iter().map(|ev| ev.uid.clone()).collect(),
148 ..Default::default()
149 };
150
151 client.post(url).json(&req).perform().await.map(|_| ())
152 }
153
154 async fn manually_add_files(
155 &self,
156 library: &FileFlowsLibrary,
157 files: Vec<&ScanEvent>,
158 ) -> anyhow::Result<()> {
159 let client = self.get_client()?;
160
161 let url = get_url(&self.url)?.join("api/library-file/manually-add")?;
162
163 let flow = library
164 .flow
165 .as_ref()
166 .context("library has no flow configured")?;
167
168 let req = FileFlowsManuallyAddRequest {
169 flow_uid: flow.uid.clone(),
170 files: files.iter().map(|ev| ev.get_path(&self.rewrite)).collect(),
171 custom_variables: HashMap::new(),
172 };
173
174 client.post(url).json(&req).perform().await.map(|_| ())
175 }
176
177 }
214
215impl TargetProcess for FileFlows {
216 async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
217 let mut succeeded = Vec::new();
218 let libraries = self
219 .get_libraries()
220 .await
221 .context("failed to get libraries")?;
222
223 let mut to_scan: HashMap<FileFlowsLibrary, Vec<&ScanEvent>> = HashMap::new();
224
225 for library in libraries {
226 let files = evs
227 .iter()
228 .filter_map(|ev| {
229 let event_path = ev.get_path(&self.rewrite);
230 let library_path = library.path.as_deref()?;
231
232 path_in_library(&event_path, library_path).then_some(*ev)
233 })
234 .collect::<Vec<_>>();
235
236 if files.is_empty() {
237 continue;
238 }
239
240 if !library.enabled {
241 error!(
242 "library '{}' is disabled but {} files will fail to scan",
243 library.uid,
244 files.len()
245 );
246 continue;
247 }
248
249 to_scan.insert(library, files);
250 }
251
252 for (library, evs) in to_scan {
253 let mut library_files = HashMap::new();
254
255 for ev in evs {
256 let event_path = ev.get_path(&self.rewrite);
257 if RuntimePath::new(&event_path).is_directory() {
258 succeeded.push(ev.id.clone());
259 continue;
260 }
261
262 match self.get_library_file(ev).await {
263 Ok(file) => {
264 library_files.insert(ev, file);
265 }
266 Err(e) => {
267 error!("failed to get library file: {}", e);
268 library_files.insert(ev, None);
269 }
270 }
271 }
272
273 let (processed, not_processed): (Vec<_>, Vec<_>) =
274 library_files.iter().partition(|(_, file)| file.is_some());
275
276 trace!(
277 "library {} has {} processed and {} not processed files",
278 library.uid,
279 processed.len(),
280 not_processed.len()
281 );
282
283 if !processed.is_empty() {
284 match self
285 .reprocess_library_file(
286 processed
287 .iter()
288 .filter_map(|(_, file)| file.as_ref())
289 .collect(),
290 )
291 .await
292 {
293 Ok(()) => {
294 for (ev, _) in &processed {
295 debug!("reprocessed file: {}", ev.get_path(&self.rewrite));
296 }
297 succeeded.extend(processed.iter().map(|(ev, _)| ev.id.clone()));
298 }
299 Err(e) => error!("failed to reprocess files: {}", e),
300 }
301 }
302
303 if !not_processed.is_empty() {
304 match self
305 .manually_add_files(
306 &library,
307 not_processed.iter().map(|(ev, _)| **ev).collect(),
308 )
309 .await
310 {
311 Ok(()) => {
312 for (ev, _) in ¬_processed {
313 debug!("manually added file: {}", ev.get_path(&self.rewrite));
314 }
315 succeeded.extend(not_processed.iter().map(|(ev, _)| ev.id.clone()));
316 }
317 Err(e) => error!("failed to manually add files: {}", e),
318 }
319 }
320 }
321
322 Ok(succeeded)
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn matches_windows_fileflows_library_by_runtime_components() {
332 assert!(path_in_library(
333 r"D:\MEDIA\Incoming\Film\Film.mkv",
334 r"d:\media\incoming"
335 ));
336 assert!(!path_in_library(
337 r"D:\Media\Incoming-Archive\Film.mkv",
338 r"D:\Media\Incoming"
339 ));
340 assert!(!path_in_library(
341 r"D:\Media\Incoming\Film.mkv",
342 "/Media/Incoming"
343 ));
344 }
345}