oximedia_container/metadata/
batch.rs1use std::path::{Path, PathBuf};
28
29#[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#[derive(Debug, Default)]
59pub struct BatchResult {
60 pub ok: Vec<PathBuf>,
62 pub failed: Vec<(PathBuf, BatchError)>,
64}
65
66impl BatchResult {
67 #[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 #[must_use]
90 pub fn all_succeeded(&self) -> bool {
91 self.failed.is_empty()
92 }
93
94 #[must_use]
96 pub fn total(&self) -> usize {
97 self.ok.len() + self.failed.len()
98 }
99}
100
101#[derive(Debug, Default)]
111pub struct BatchMetadataUpdate {
112 files: Vec<PathBuf>,
114 tags: Vec<(String, String)>,
116 copy_from: Option<(PathBuf, Vec<String>)>,
121}
122
123impl BatchMetadataUpdate {
124 #[must_use]
126 pub fn new() -> Self {
127 Self::default()
128 }
129
130 #[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 #[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 #[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 pub fn apply(self) -> BatchResult {
164 let mut result = BatchResult::default();
165
166 let merged_tags: Vec<(String, String)> = match self.build_merged_tags() {
168 Ok(tags) => tags,
169 Err(e) => {
170 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 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 merged.extend(self.tags.iter().cloned());
203
204 Ok(merged)
205 }
206}
207
208#[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#[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 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#[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 #[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 #[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 #[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 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 #[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}