Skip to main content

oximedia_container/metadata/
batch.rs

1//! Multi-file batch metadata update.
2//!
3//! [`BatchMetadataUpdate`] applies a uniform set of tag changes to a list of
4//! media files in a single pass.  Results are collected into a [`BatchResult`]
5//! that separates successfully updated paths from failures, without aborting on
6//! the first error.
7//!
8//! Internally the implementation delegates to the single-file
9//! [`super::editor::BatchMetadataEditor`] for each path, so the same
10//! format-detection and round-trip write logic is reused.
11//!
12//! # Example
13//!
14//! ```ignore
15//! use oximedia_container::metadata::batch::{BatchMetadataUpdate, BatchResult};
16//!
17//! let result = BatchMetadataUpdate::new()
18//!     .add_file("track01.flac")
19//!     .add_file("track02.flac")
20//!     .set_tag("ALBUM", "Greatest Hits")
21//!     .set_tag("DATE", "2026")
22//!     .apply();
23//!
24//! println!("{}", result.into_report());
25//! ```
26
27use std::path::{Path, PathBuf};
28
29// ─── BatchError ───────────────────────────────────────────────────────────────
30
31/// An error that occurred while processing a single file in the batch.
32#[derive(Debug)]
33pub struct BatchError(pub String);
34
35impl std::fmt::Display for BatchError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.write_str(&self.0)
38    }
39}
40
41impl std::error::Error for BatchError {}
42
43impl BatchError {
44    fn new(msg: impl Into<String>) -> Self {
45        Self(msg.into())
46    }
47}
48
49// ─── BatchResult ──────────────────────────────────────────────────────────────
50
51/// Outcome of a [`BatchMetadataUpdate::apply`] call.
52///
53/// Paths that were updated successfully are in [`ok`]; paths where the update
54/// failed are in [`failed`] together with the associated error.
55///
56/// [`ok`]: BatchResult::ok
57/// [`failed`]: BatchResult::failed
58#[derive(Debug, Default)]
59pub struct BatchResult {
60    /// Paths that were updated without error.
61    pub ok: Vec<PathBuf>,
62    /// Paths where the update failed, paired with the error.
63    pub failed: Vec<(PathBuf, BatchError)>,
64}
65
66impl BatchResult {
67    /// Formats a human-readable one-line summary.
68    #[must_use]
69    pub fn into_report(self) -> String {
70        let ok_count = self.ok.len();
71        if self.failed.is_empty() {
72            format!("Updated {} file(s) successfully.", ok_count)
73        } else {
74            let details: Vec<String> = self
75                .failed
76                .iter()
77                .map(|(p, e)| format!("{}: {}", p.display(), e))
78                .collect();
79            format!(
80                "Updated {} file(s). Failed on {} file(s): {}",
81                ok_count,
82                self.failed.len(),
83                details.join("; ")
84            )
85        }
86    }
87
88    /// Returns `true` if every file was updated without error.
89    #[must_use]
90    pub fn all_succeeded(&self) -> bool {
91        self.failed.is_empty()
92    }
93
94    /// Returns the total number of files processed (succeeded + failed).
95    #[must_use]
96    pub fn total(&self) -> usize {
97        self.ok.len() + self.failed.len()
98    }
99}
100
101// ─── BatchMetadataUpdate ──────────────────────────────────────────────────────
102
103/// Builder for applying a uniform set of metadata tag changes to multiple files.
104///
105/// Chain builder calls to configure the target files and tags, then call
106/// [`apply`] to execute the batch.  One error in a single file does not abort
107/// the remaining files; all results are collected and returned as a [`BatchResult`].
108///
109/// [`apply`]: BatchMetadataUpdate::apply
110#[derive(Debug, Default)]
111pub struct BatchMetadataUpdate {
112    /// Target file paths.
113    files: Vec<PathBuf>,
114    /// (key, value) pairs to write on every target file.
115    tags: Vec<(String, String)>,
116    /// Optional source file and list of tag keys to copy from it.
117    ///
118    /// Tags copied from the source are merged with `tags`; entries in `tags`
119    /// take precedence over those copied from the source.
120    copy_from: Option<(PathBuf, Vec<String>)>,
121}
122
123impl BatchMetadataUpdate {
124    /// Creates a new, empty builder.
125    #[must_use]
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    /// Adds a target file path.
131    #[must_use]
132    pub fn add_file(mut self, path: impl AsRef<Path>) -> Self {
133        self.files.push(path.as_ref().to_path_buf());
134        self
135    }
136
137    /// Schedules a tag `key` → `value` write for every target file.
138    ///
139    /// If the same key is added more than once, the last value wins.
140    #[must_use]
141    pub fn set_tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
142        self.tags.push((key.into(), value.into()));
143        self
144    }
145
146    /// Configures the update to also copy specific tags from `source` into
147    /// each target file before applying the explicit [`set_tag`] entries.
148    ///
149    /// When a key appears in both `source` and the explicit tags, the
150    /// explicit value wins.
151    ///
152    /// [`set_tag`]: BatchMetadataUpdate::set_tag
153    #[must_use]
154    pub fn copy_from(mut self, source: impl AsRef<Path>, keys: Vec<String>) -> Self {
155        self.copy_from = Some((source.as_ref().to_path_buf(), keys));
156        self
157    }
158
159    /// Executes the batch update and returns a [`BatchResult`].
160    ///
161    /// Each file is processed independently; a failure on one file does not
162    /// abort the remaining files.
163    pub fn apply(self) -> BatchResult {
164        let mut result = BatchResult::default();
165
166        // Pre-compute merged tag list (copy_from source tags + explicit tags).
167        let merged_tags: Vec<(String, String)> = match self.build_merged_tags() {
168            Ok(tags) => tags,
169            Err(e) => {
170                // If we cannot read the copy_from source we fail every file.
171                let msg = format!("failed to read copy_from source: {e}");
172                for path in self.files {
173                    result.failed.push((path, BatchError::new(msg.clone())));
174                }
175                return result;
176            }
177        };
178
179        for path in &self.files {
180            match apply_tags_to_file(path, &merged_tags) {
181                Ok(()) => result.ok.push(path.clone()),
182                Err(e) => result.failed.push((path.clone(), e)),
183            }
184        }
185
186        result
187    }
188
189    /// Builds the final `(key, value)` list from `copy_from` + explicit tags.
190    ///
191    /// The copy_from source entries come first; explicit entries follow, so
192    /// explicit values override copied ones when processed by the editor.
193    fn build_merged_tags(&self) -> Result<Vec<(String, String)>, BatchError> {
194        let mut merged: Vec<(String, String)> = Vec::new();
195
196        if let Some((source_path, keys)) = &self.copy_from {
197            let source_tags = read_tags_from_file(source_path, keys)?;
198            merged.extend(source_tags);
199        }
200
201        // Explicit tags always override copy_from values.
202        merged.extend(self.tags.iter().cloned());
203
204        Ok(merged)
205    }
206}
207
208// ─── Internal helpers ─────────────────────────────────────────────────────────
209
210/// Reads the specified tag keys from `path` and returns (key, text_value) pairs.
211///
212/// Only `Text` tag values are returned; binary tags are silently skipped.
213///
214/// On WASM there is no async file I/O available so this returns an error.
215#[cfg(target_arch = "wasm32")]
216fn read_tags_from_file(
217    _path: &Path,
218    _keys: &[String],
219) -> Result<Vec<(String, String)>, BatchError> {
220    Err(BatchError::new("file I/O is not supported on WASM"))
221}
222
223#[cfg(not(target_arch = "wasm32"))]
224fn read_tags_from_file(path: &Path, keys: &[String]) -> Result<Vec<(String, String)>, BatchError> {
225    let rt = tokio::runtime::Builder::new_current_thread()
226        .enable_all()
227        .build()
228        .map_err(|e| BatchError::new(e.to_string()))?;
229
230    rt.block_on(async {
231        use crate::metadata::editor::MetadataEditor;
232
233        let editor = MetadataEditor::open(path)
234            .await
235            .map_err(|e| BatchError::new(e.to_string()))?;
236
237        let mut out = Vec::new();
238        for key in keys {
239            if let Some(value) = editor.get_text(key.as_str()) {
240                out.push((key.clone(), value.to_string()));
241            }
242        }
243        Ok(out)
244    })
245}
246
247/// Applies `tags` to the media file at `path` using the high-level
248/// [`BatchMetadataEditor`].
249///
250/// On WASM the function always returns an error because file I/O is unavailable.
251#[cfg(target_arch = "wasm32")]
252fn apply_tags_to_file(_path: &Path, _tags: &[(String, String)]) -> Result<(), BatchError> {
253    Err(BatchError::new("file I/O is not supported on WASM"))
254}
255
256#[cfg(not(target_arch = "wasm32"))]
257fn apply_tags_to_file(path: &Path, tags: &[(String, String)]) -> Result<(), BatchError> {
258    use super::tags::TagValue;
259    use crate::metadata::editor::BatchMetadataEditor;
260
261    // Validate the path exists before attempting to open it.
262    if !path.exists() {
263        return Err(BatchError::new(format!(
264            "file not found: {}",
265            path.display()
266        )));
267    }
268
269    let mut editor = BatchMetadataEditor::new();
270    for (key, value) in tags {
271        editor = editor.set(key.as_str(), TagValue::Text(value.clone()));
272    }
273
274    editor
275        .apply_to_file(path)
276        .map(|_count| ())
277        .map_err(|e| BatchError::new(e.to_string()))
278}
279
280// ─── Tests ────────────────────────────────────────────────────────────────────
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::path::PathBuf;
286
287    fn tmp_str(name: &str) -> String {
288        std::env::temp_dir()
289            .join(format!("oximedia-container-metadata-batch-{name}"))
290            .to_string_lossy()
291            .into_owned()
292    }
293
294    // ── BatchResult ────────────────────────────────────────────────────
295
296    #[test]
297    fn batch_result_report_all_ok() {
298        let mut r = BatchResult::default();
299        r.ok.push(PathBuf::from("a.flac"));
300        r.ok.push(PathBuf::from("b.flac"));
301        let report = r.into_report();
302        assert!(report.contains("2"), "expected count '2' in '{report}'");
303        assert!(
304            report.contains("successfully"),
305            "expected 'successfully' in '{report}'"
306        );
307    }
308
309    #[test]
310    fn batch_result_report_partial_failure() {
311        let mut r = BatchResult::default();
312        r.ok.push(PathBuf::from("ok.flac"));
313        r.failed.push((
314            PathBuf::from("bad.mkv"),
315            BatchError::new("format unsupported"),
316        ));
317        let report = r.into_report();
318        assert!(report.contains("1"), "should mention 1 success");
319        assert!(report.contains("bad.mkv"), "should name the failed path");
320        assert!(
321            report.contains("format unsupported"),
322            "should include the error"
323        );
324    }
325
326    #[test]
327    fn batch_result_report_all_failed() {
328        let mut r = BatchResult::default();
329        r.failed
330            .push((PathBuf::from("x.wav"), BatchError::new("io error")));
331        r.failed
332            .push((PathBuf::from("y.ogg"), BatchError::new("crc mismatch")));
333        let report = r.into_report();
334        assert!(report.contains("0"), "0 succeeded");
335        assert!(report.contains("2"), "2 failed");
336    }
337
338    #[test]
339    fn batch_result_all_succeeded_true() {
340        let mut r = BatchResult::default();
341        r.ok.push(PathBuf::from("a.flac"));
342        assert!(r.all_succeeded());
343    }
344
345    #[test]
346    fn batch_result_all_succeeded_false() {
347        let mut r = BatchResult::default();
348        r.ok.push(PathBuf::from("a.flac"));
349        r.failed
350            .push((PathBuf::from("b.flac"), BatchError::new("err")));
351        assert!(!r.all_succeeded());
352    }
353
354    #[test]
355    fn batch_result_total() {
356        let mut r = BatchResult::default();
357        r.ok.push(PathBuf::from("a.flac"));
358        r.ok.push(PathBuf::from("b.flac"));
359        r.failed
360            .push((PathBuf::from("c.flac"), BatchError::new("err")));
361        assert_eq!(r.total(), 3);
362    }
363
364    // ── BatchMetadataUpdate builder ────────────────────────────────────
365
366    #[test]
367    fn builder_default_is_empty() {
368        let b = BatchMetadataUpdate::new();
369        assert!(b.files.is_empty());
370        assert!(b.tags.is_empty());
371        assert!(b.copy_from.is_none());
372    }
373
374    #[test]
375    fn builder_add_file() {
376        let b = BatchMetadataUpdate::new()
377            .add_file("a.flac")
378            .add_file("b.flac");
379        assert_eq!(b.files.len(), 2);
380    }
381
382    #[test]
383    fn builder_set_tag() {
384        let b = BatchMetadataUpdate::new()
385            .set_tag("TITLE", "Hello")
386            .set_tag("ARTIST", "World");
387        assert_eq!(b.tags.len(), 2);
388        assert_eq!(b.tags[0], ("TITLE".to_string(), "Hello".to_string()));
389        assert_eq!(b.tags[1], ("ARTIST".to_string(), "World".to_string()));
390    }
391
392    #[test]
393    fn builder_copy_from() {
394        let src = tmp_str("source.flac");
395        let b = BatchMetadataUpdate::new().copy_from(&src, vec!["TITLE".into(), "ARTIST".into()]);
396        let (path, keys) = b.copy_from.as_ref().expect("copy_from should be set");
397        assert_eq!(path, Path::new(&src));
398        assert_eq!(keys, &["TITLE", "ARTIST"]);
399    }
400
401    // ── apply with non-existent paths (forced failure) ─────────────────
402
403    #[test]
404    fn apply_nonexistent_path_reports_failure() {
405        let result = BatchMetadataUpdate::new()
406            .add_file(tmp_str("definitely_does_not_exist.flac"))
407            .set_tag("TITLE", "Test")
408            .apply();
409        assert_eq!(result.ok.len(), 0);
410        assert_eq!(result.failed.len(), 1);
411    }
412
413    #[test]
414    fn apply_mixed_paths_collects_failures() {
415        // Two nonexistent paths → both fail; zero succeed.
416        let result = BatchMetadataUpdate::new()
417            .add_file(tmp_str("missing_a.flac"))
418            .add_file(tmp_str("missing_b.flac"))
419            .set_tag("ALBUM", "Test Album")
420            .apply();
421        assert_eq!(result.ok.len(), 0, "no files should succeed");
422        assert_eq!(result.failed.len(), 2, "both files should fail");
423        let report = result.into_report();
424        assert!(report.contains("2"), "report should mention 2 failures");
425    }
426
427    #[test]
428    fn apply_no_files_returns_empty_result() {
429        let result = BatchMetadataUpdate::new()
430            .set_tag("TITLE", "Unused")
431            .apply();
432        assert_eq!(result.ok.len(), 0);
433        assert_eq!(result.failed.len(), 0);
434        assert_eq!(result.total(), 0);
435    }
436
437    // ── build_merged_tags order ────────────────────────────────────────
438
439    #[test]
440    fn merged_tags_explicit_only() {
441        let b = BatchMetadataUpdate::new().set_tag("TITLE", "Explicit");
442        let merged = b
443            .build_merged_tags()
444            .expect("should not fail without copy_from");
445        assert_eq!(merged.len(), 1);
446        assert_eq!(merged[0], ("TITLE".to_string(), "Explicit".to_string()));
447    }
448
449    #[test]
450    fn merged_tags_empty_when_no_config() {
451        let b = BatchMetadataUpdate::new();
452        let merged = b.build_merged_tags().expect("should not fail");
453        assert!(merged.is_empty());
454    }
455}