avis_imgv/
metadata.rs

1use crate::db;
2use regex::{self, Regex};
3use std::sync::mpsc;
4use std::{
5    collections::HashMap,
6    path::PathBuf,
7    process::{Command, Output, Stdio},
8    thread,
9    time::Instant,
10};
11
12//for exiftool, the bigger the chunk the better as the startup time is slow
13pub const CHUNK_SIZE: &usize = &500;
14pub const METADATA_PROFILE_DESCRIPTION: &str = "Profile Description";
15pub const METADATA_ORIENTATION: &str = "Orientation";
16pub const METADATA_DIRECTORY: &str = "Directory";
17pub const METADATA_DATE: &str = "Date/Time Original";
18
19pub enum Orientation {
20    Normal,
21    MirrorHorizontal,
22    Rotate180,
23    MirrorVertical,
24    MirrorHorizontalRotate270,
25    Rotate90CW,
26    MirrorHorizontalRotate90CW,
27    Rotate270CW,
28}
29
30impl Orientation {
31    pub fn from_orientation_metadata(orientation: &str) -> Orientation {
32        match orientation {
33            "Horizontal (normal)" => Orientation::Normal,
34            "Mirror horizontal" => Orientation::MirrorHorizontal,
35            "Rotate 180" => Orientation::Rotate180,
36            "Mirror vertical" => Orientation::MirrorVertical,
37            "Mirror horizontal and rotate 270 CW" => Orientation::MirrorHorizontalRotate270,
38            "Rotate 90 CW" => Orientation::Rotate90CW,
39            "Mirror horizontal and rotate 90 CW" => Orientation::MirrorHorizontalRotate90CW,
40            "Rotate 270 CW" => Orientation::Rotate270CW,
41            _ => Orientation::Normal,
42        }
43    }
44}
45
46pub struct Metadata {}
47
48impl Metadata {
49    pub fn cache_metadata_for_images_in_background(image_paths: &[PathBuf]) {
50        let image_paths = image_paths.to_vec();
51        thread::spawn(move || {
52            Self::cache_metadata_for_images(&image_paths);
53        });
54    }
55
56    pub fn cache_metadata_for_images(image_paths: &[PathBuf]) {
57        let timer = Instant::now();
58
59        let mut image_paths = image_paths
60            .iter()
61            .map(|p| p.to_string_lossy().to_string())
62            .collect::<Vec<String>>();
63
64        let cached_paths = match db::Db::get_cached_images_by_paths(&image_paths) {
65            Ok(cached_paths) => cached_paths,
66            Err(e) => {
67                println!("Failure fetching cached metadata paths, aborting caching process {e}");
68                return;
69            }
70        };
71
72        image_paths.retain(|x| !cached_paths.contains(x));
73
74        //A bit of a hack but simpler than diverging code paths
75        let single_image_path = if image_paths.len() == 1 {
76            Some(&image_paths[0])
77        } else {
78            None
79        };
80
81        let chunks: Vec<&[String]> = image_paths.chunks(*CHUNK_SIZE).collect();
82        let total_chunks = chunks.len();
83        let mut total_elapsed_time_ms = 0u128;
84
85        println!(
86            "Caching a total of {} imgs in {} chunks",
87            image_paths.len(),
88            total_chunks
89        );
90
91        for (i, chunk) in chunks.iter().enumerate() {
92            println!("Caching chunk {i} of {}", chunks.len());
93
94            let chunk_timer = Instant::now();
95
96            let (tx, rx) = mpsc::channel();
97            let mut handles = vec![];
98            //4 threads, should be enough to max a HDD
99            //Make configurable to take advantage of SSD speeds
100            let chunks: Vec<&[String]> = chunk.chunks(*CHUNK_SIZE / 4).collect();
101            for chunk in chunks {
102                let tx = tx.clone();
103                let chunk = chunk.to_vec();
104                let handle = thread::spawn(move || {
105                    let cmd = Command::new("exiftool")
106                        .args(chunk)
107                        .stdout(Stdio::piped())
108                        .spawn();
109
110                    match cmd {
111                        Ok(cmd) => match cmd.wait_with_output() {
112                            Ok(output) => {
113                                tx.send(output).unwrap();
114                            }
115                            Err(e) => println!("Error fetching metadata -> {e}"),
116                        },
117                        Err(e) => println!("Error fetching metadata -> {e}"),
118                    };
119                });
120
121                handles.push(handle);
122            }
123
124            for handle in handles {
125                handle.join().unwrap(); // Wait for each thread to complete
126            }
127
128            drop(tx);
129
130            for output in rx {
131                Self::parse_exiftool_output(&output, single_image_path);
132            }
133
134            let chunk_elapsed_ms = chunk_timer.elapsed().as_millis();
135            total_elapsed_time_ms += chunk_elapsed_ms;
136            let processed_chunks_count = (i + 1) as u128;
137
138            println!(
139                "Cached metadata chunk containing {} images in {}ms",
140                chunk.len(),
141                chunk_elapsed_ms
142            );
143
144            if processed_chunks_count < total_chunks as u128 {
145                let avg_time_per_chunk_ms = total_elapsed_time_ms / processed_chunks_count;
146                let remaining_chunks = (total_chunks as u128) - processed_chunks_count;
147                let estimated_remaining_ms = avg_time_per_chunk_ms * remaining_chunks;
148
149                let estimated_remaining_seconds = estimated_remaining_ms / 1000;
150                let estimated_remaining_minutes = estimated_remaining_seconds / 60;
151                let estimated_remaining_seconds_remainder = estimated_remaining_seconds % 60;
152
153                println!(
154                    "Estimated time remaining: {}m {}s",
155                    estimated_remaining_minutes, estimated_remaining_seconds_remainder
156                );
157            }
158        }
159
160        println!(
161            "Finished caching metadata for all images in {}ms",
162            timer.elapsed().as_millis()
163        );
164    }
165
166    pub fn parse_exiftool_output(output: &Output, path: Option<&String>) {
167        //only panics if regex is invalid, impossible to happen in tested builds
168        let re = regex::Regex::new(r"========").unwrap();
169
170        let string_output = String::from_utf8_lossy(&output.stdout);
171
172        let mut metadata_to_insert: Vec<(String, String)> = vec![];
173        for image_metadata in re.split(&string_output) {
174            if let Some((path, tags)) = Self::parse_exiftool_output_str(image_metadata) {
175                let metadata_json = match serde_json::to_string(&tags) {
176                    Ok(json) => json,
177                    Err(e) => {
178                        println!("Failure serializing metadata into json -> {e}");
179                        continue;
180                    }
181                };
182                metadata_to_insert.push((path, metadata_json))
183            }
184        }
185
186        //This is required because exiftool doesn't print the filename
187        //When only one image is passed
188        if let Some(path) = path {
189            metadata_to_insert[0].0 = path.clone()
190        }
191
192        match db::Db::insert_files_metadata(metadata_to_insert) {
193            Ok(_) => {}
194            Err(e) => {
195                println!("Failure inserting metadata into db -> {e}");
196            }
197        }
198    }
199
200    pub fn parse_exiftool_output_str(output: &str) -> Option<(String, HashMap<String, String>)> {
201        let lines: Vec<&str> = output.split('\n').collect();
202        let file_path = lines.first()?;
203
204        if file_path.is_empty() {
205            return None;
206        }
207
208        let tags = output
209            .lines()
210            .filter(|x| !x.is_empty() && x.contains(':'))
211            .filter_map(|x| {
212                let split: Vec<&str> = x.split(':').collect();
213
214                if split.len() < 2 {
215                    return None;
216                }
217
218                let first = String::from(split[0].trim());
219                let last = String::from(split[1..].join(":").trim());
220
221                Some((first, last))
222            })
223            .collect();
224
225        Some((file_path.trim().to_string(), tags))
226    }
227
228    pub fn get_image_metadata(path: &str) -> Option<HashMap<String, String>> {
229        match db::Db::get_image_metadata(path) {
230            Ok(opt) => {
231                if let Some(data) = opt {
232                    return Some(serde_json::from_str(&data).unwrap_or_default());
233                }
234            }
235            Err(e) => println!("Error fetching image metadata from db -> {e}"),
236        };
237
238        println!("Metadata not yet in database, fetching for {path}");
239
240        //This path is useful for the first files that are opened
241        //as the first batch(depending on chunk) still takes a bit of time.
242
243        let cmd = Command::new("exiftool")
244            .arg(path)
245            .stdout(Stdio::piped())
246            .spawn();
247
248        let output = match cmd {
249            Ok(cmd) => match cmd.wait_with_output() {
250                Ok(output) => output,
251                Err(e) => {
252                    println!("Failure waiting for exiftool process -> {e}");
253                    return None;
254                }
255            },
256            Err(e) => {
257                println!("Failure spawning exiftool process -> {e}");
258                return None;
259            }
260        };
261
262        Self::parse_exiftool_output_str(String::from_utf8_lossy(&output.stdout).as_ref())
263            .map(|(_, metadata)| metadata)
264    }
265
266    pub fn extract_icc_from_image(path: &PathBuf) -> Option<Vec<u8>> {
267        let cmd = Command::new("exiftool")
268            .arg("-icc_profile")
269            .arg("-b")
270            .arg(path)
271            .stdout(Stdio::piped())
272            .spawn();
273
274        match cmd {
275            Ok(cmd) => match cmd.wait_with_output() {
276                Ok(output) => {
277                    if !output.stdout.is_empty() {
278                        Some(output.stdout)
279                    } else {
280                        None
281                    }
282                }
283                Err(e) => {
284                    println!("Error fetching image icc -> {e}");
285                    None
286                }
287            },
288            Err(e) => {
289                println!("Error fetching image icc -> {e}");
290                None
291            }
292        }
293    }
294
295    pub fn format_string_with_metadata(input: &str, metadata: &HashMap<String, String>) -> String {
296        let mut output = String::from(input);
297
298        let tag_regex = Regex::new("(\\$\\(([^\\(\\)]*#([\\w \\s]*)#[^\\(\\)]*)\\))").unwrap();
299
300        for cap_group in tag_regex.captures_iter(input) {
301            //Whole string including  $()
302            let expression = match cap_group.get(0) {
303                Some(m) => m.as_str(),
304                None => continue,
305            };
306
307            //Above sring without $()
308            let string_to_format = match cap_group.get(2) {
309                Some(m) => m.as_str(),
310                None => continue,
311            };
312
313            //Only the metadata key we need to replace
314            let metadata_tag = match cap_group.get(3) {
315                Some(m) => m.as_str(),
316                None => continue,
317            };
318
319            let to_replace = if let Some(metadata_value) = metadata.get(metadata_tag) {
320                string_to_format.replace(&format!("#{metadata_tag}#"), metadata_value)
321            } else {
322                "".to_string()
323            };
324
325            output = output.replace(expression, &to_replace);
326        }
327
328        output
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_format_string_with_metadata() {
338        let input = "$(#File Name#)$( • ƒ#Aperture#)$( • #Shutter Speed#)$( • #ISO# ISO)";
339        let mut metadata: HashMap<String, String> = HashMap::new();
340        metadata.insert("File Name".to_string(), "test.jpg".to_string());
341        metadata.insert("Aperture".to_string(), "5.0".to_string());
342        metadata.insert("ISO".to_string(), "500".to_string());
343
344        assert_eq!(
345            Metadata::format_string_with_metadata(input, &metadata),
346            "test.jpg • ƒ5.0 • 500 ISO".to_string()
347        );
348    }
349}