1use std::collections::HashMap;
5use std::fmt::Write as _;
6use std::path::{Path, PathBuf};
7
8use crate::art::{self, ArtRules};
9use crate::config::Config;
10use crate::discogs::{Discogs, DiscogsError};
11use crate::matching::{self, Match};
12use crate::meta::{self, Tags, Track};
13use crate::musicbrainz::{MbError, MusicBrainz, Release};
14use crate::paths;
15
16#[derive(Debug, thiserror::Error)]
17pub enum ImportError {
18 #[error("no audio files in {0}")]
19 Empty(PathBuf),
20 #[error(transparent)]
21 Meta(#[from] meta::MetaError),
22 #[error(transparent)]
23 MusicBrainz(#[from] MbError),
24 #[error(transparent)]
25 Discogs(#[from] DiscogsError),
26 #[error("{0}")]
27 Io(#[from] std::io::Error),
28 #[error(transparent)]
29 Path(#[from] crate::paths::PathError),
30 #[error("{0}")]
31 Conflict(String),
32 #[error("{} is already in the library", .0.display())]
36 Exists(PathBuf),
37 #[error("the files' tags do not describe one album: {0}")]
40 Untagged(String),
41}
42
43impl ImportError {
44 pub fn is_transient(&self) -> bool {
47 matches!(self, Self::MusicBrainz(e) if e.is_transient())
48 || matches!(self, Self::Discogs(e) if e.is_transient())
49 }
50}
51
52#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
54pub struct Candidate {
55 pub id: String,
56 pub title: String,
57 pub artist: String,
58 pub date: Option<String>,
59 pub country: Option<String>,
60 pub media: Option<String>,
61 pub disambiguation: Option<String>,
62 pub tracks: usize,
63 pub distance: f64,
64 pub missing: usize,
65 pub extra: usize,
66}
67
68impl From<&Match> for Candidate {
69 fn from(m: &Match) -> Self {
70 let r = &m.release;
71 let mut formats: Vec<String> = r.media.iter().filter_map(|m| m.format.clone()).collect();
72 formats.dedup();
73 Self {
74 id: r.id.clone(),
75 title: r.title.clone(),
76 artist: r.artist(),
77 date: r.date.clone(),
78 country: r.country.clone(),
79 media: (!formats.is_empty())
80 .then(|| format!("{}×{}", r.media.len(), formats.join("+"))),
81 disambiguation: r.disambiguation.clone().filter(|d| !d.is_empty()),
82 tracks: r.track_count(),
83 distance: (m.distance * 1000.0).round() / 1000.0,
84 missing: m.missing,
85 extra: m.extra,
86 }
87 }
88}
89
90#[derive(Clone, Copy)]
92enum Filing<'a> {
93 New,
95 InPlace,
98 Replacing(&'a Path),
100}
101
102async fn set_aside(dir: &Path, root: &Path, bin: &Path) -> Result<PathBuf, ImportError> {
106 let rel = dir
107 .strip_prefix(root)
108 .ok()
109 .filter(|r| r.components().count() > 0)
110 .ok_or_else(|| {
111 ImportError::Conflict(format!(
112 "{} is not an album folder in the library",
113 dir.display()
114 ))
115 })?;
116 let mut dest = bin.join(rel);
117 let mut n = 1;
118 while dest.exists() {
119 n += 1;
120 let name = format!(
121 "{} (replaced {n})",
122 rel.file_name().unwrap_or_default().to_string_lossy()
123 );
124 dest = bin.join(rel).with_file_name(name);
125 }
126 for entry in std::fs::read_dir(dir)?.flatten() {
127 if entry.file_type().is_ok_and(|t| t.is_file()) {
128 paths::transfer(&entry.path(), &dest.join(entry.file_name()), true).await?;
129 }
130 }
131 let _ = std::fs::remove_dir(dir);
132 Ok(dest)
133}
134
135#[derive(Debug)]
136pub enum Outcome {
137 Imported {
138 dir: PathBuf,
139 release: Option<Candidate>,
141 log: String,
142 },
143 Review {
146 reason: String,
147 candidates: Vec<Candidate>,
148 log: String,
149 },
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum Source {
156 MusicBrainz,
157 Discogs,
158}
159
160pub fn source_of(id: &str) -> Source {
161 if id.starts_with("discogs:") {
162 Source::Discogs
163 } else {
164 Source::MusicBrainz
165 }
166}
167
168pub struct Importer {
169 pub cfg: Config,
170 mb: MusicBrainz,
171 discogs: Option<Discogs>,
172 http: reqwest::Client,
173}
174
175#[derive(Debug, Default)]
177pub struct Enriched {
178 pub gain_db: Option<f64>,
179 pub genres: Vec<String>,
180 pub lyrics: usize,
182 pub problems: Vec<String>,
183}
184
185const LOOKUPS: usize = 5;
188
189impl Importer {
190 pub fn new(cfg: Config) -> Self {
191 let mut mb = MusicBrainz::new(&cfg.musicbrainz_contact);
192 if let Some(dir) = &cfg.cache_dir {
193 mb = mb.with_cache(dir.join("musicbrainz"));
194 }
195 let discogs = cfg.discogs.as_ref().map(|d| {
196 let mut discogs = Discogs::new(&d.token, &cfg.musicbrainz_contact);
197 if let Some(dir) = &cfg.cache_dir {
198 discogs = discogs.with_cache(dir.clone());
199 }
200 discogs
201 });
202 Self::with_clients(cfg, mb, discogs)
203 }
204
205 pub fn with_musicbrainz(cfg: Config, mb: MusicBrainz) -> Self {
206 let discogs = cfg
207 .discogs
208 .as_ref()
209 .map(|d| Discogs::new(&d.token, &cfg.musicbrainz_contact));
210 Self::with_clients(cfg, mb, discogs)
211 }
212
213 fn with_clients(cfg: Config, mb: MusicBrainz, discogs: Option<Discogs>) -> Self {
214 let http = reqwest::Client::builder()
215 .user_agent(format!(
216 "sift/{} ( {} )",
217 env!("CARGO_PKG_VERSION"),
218 cfg.musicbrainz_contact
219 ))
220 .timeout(std::time::Duration::from_secs(60))
221 .build()
222 .expect("static client config");
223 Self {
224 cfg,
225 mb,
226 discogs,
227 http,
228 }
229 }
230
231 pub async fn import(
234 &self,
235 dir: &Path,
236 release_id: Option<&str>,
237 ) -> Result<Outcome, ImportError> {
238 self.import_with(dir, release_id, Filing::New).await
239 }
240
241 pub async fn import_replacing(
247 &self,
248 dir: &Path,
249 release_id: Option<&str>,
250 bin: &Path,
251 ) -> Result<Outcome, ImportError> {
252 self.import_with(dir, release_id, Filing::Replacing(bin))
253 .await
254 }
255
256 pub async fn enrich(&self, dir: &Path) -> Result<Enriched, ImportError> {
262 let tracks = read_dir(dir).await?;
263 let mut out = Enriched::default();
264 let paths: Vec<PathBuf> = tracks.iter().map(|t| t.path.clone()).collect();
265
266 let measured = {
267 let paths = paths.clone();
268 tokio::task::spawn_blocking(move || crate::replaygain::album(&paths, 2))
269 .await
270 .expect("replaygain panicked")
271 };
272 match measured {
273 Ok((gains, album)) => {
274 for (p, g) in paths.iter().zip(gains) {
275 if let Err(e) = meta::set_replaygain(p, g, album) {
276 out.problems.push(e.to_string());
277 }
278 }
279 out.gain_db = Some(album.db);
280 }
281 Err(e) => out.problems.push(format!("replaygain: {e}")),
282 }
283
284 let untagged = tracks
285 .iter()
286 .all(|t| t.genre.as_deref().is_none_or(|g| g.trim().is_empty()));
287 if untagged && let Some(id) = tracks.iter().find_map(|t| t.mb_album_id.clone()) {
288 match self.mb.genres(&id).await {
289 Ok(g) if !g.is_empty() => {
290 let genre = g.join("; ");
291 for p in &paths {
292 if let Err(e) = meta::set(p, &[("genre".into(), Some(genre.clone()))]) {
293 out.problems.push(e.to_string());
294 }
295 }
296 out.genres = g;
297 }
298 Ok(_) => {}
299 Err(e) => out.problems.push(format!("genres: {e}")),
300 }
301 }
302
303 let lyrics = crate::lyrics::Client::new();
304 for t in &tracks {
305 let (Some(artist), Some(title)) = (&t.artist, &t.title) else {
306 continue;
307 };
308 if meta::has_lyrics(&t.path) {
309 continue;
310 }
311 let album = t.album.as_deref().unwrap_or("");
312 match lyrics.get(artist, title, album, t.duration.as_secs()).await {
313 Ok(crate::lyrics::Lyrics::Synced(l) | crate::lyrics::Lyrics::Plain(l)) => {
314 match meta::set_lyrics(&t.path, &l) {
315 Ok(()) => out.lyrics += 1,
316 Err(e) => out.problems.push(e.to_string()),
317 }
318 }
319 Ok(_) => {}
320 Err(e) => out.problems.push(format!("lyrics: {e}")),
321 }
322 }
323 Ok(out)
324 }
325
326 pub async fn reimport(
331 &self,
332 dir: &Path,
333 release_id: Option<&str>,
334 ) -> Result<Outcome, ImportError> {
335 self.import_with(dir, release_id, Filing::InPlace).await
336 }
337
338 async fn import_with(
339 &self,
340 dir: &Path,
341 release_id: Option<&str>,
342 filing: Filing<'_>,
343 ) -> Result<Outcome, ImportError> {
344 let mut log = String::new();
345 let tracks = read_dir(dir).await?;
346 let _ = writeln!(log, "{} audio files in {}", tracks.len(), dir.display());
347 let (tracks, spare) = one_copy_each(tracks);
348 for t in &spare {
349 let _ = writeln!(
350 log,
351 "left behind (another copy of the same track): {}",
352 t.path.display()
353 );
354 }
355
356 let mut matches = match release_id {
357 Some(id) => vec![matching::score(&tracks, &self.release(id).await?)],
358 None => self.candidates(dir, &tracks, &mut log).await?,
359 };
360 matches.sort_by(|a, b| a.distance.total_cmp(&b.distance));
361 let Some(best) = matches.first() else {
362 return Ok(Outcome::Review {
363 reason: "no candidates found on MusicBrainz".into(),
364 candidates: Vec::new(),
365 log,
366 });
367 };
368 let _ = writeln!(
369 log,
370 "best: {} — {} ({}), distance {:.3} ({})",
371 best.release.artist(),
372 best.release.title,
373 best.release.id,
374 best.distance,
375 best.parts
376 );
377
378 if release_id.is_none() {
379 let reason = if best.distance > self.cfg.strong_threshold {
380 Some(format!(
381 "closest match is distance {:.3}, above {:.3}",
382 best.distance, self.cfg.strong_threshold
383 ))
384 } else if !best.is_complete() {
385 Some(format!(
386 "{} tracks missing, {} files unmatched",
387 best.missing, best.extra
388 ))
389 } else {
390 None
391 };
392 if let Some(reason) = reason {
393 return Ok(Outcome::Review {
394 reason,
395 candidates: matches.iter().take(5).map(Candidate::from).collect(),
396 log,
397 });
398 }
399 }
400 let best = best.clone();
401 let dest = self.apply(&tracks, &best, dir, filing, &mut log).await?;
402 Ok(Outcome::Imported {
403 dir: dest,
404 release: Some(Candidate::from(&best)),
405 log,
406 })
407 }
408
409 pub async fn import_as_is(&self, dir: &Path, edits: &Edits) -> Result<Outcome, ImportError> {
415 let mut log = String::new();
416 let mut tracks = read_dir(dir).await?;
417 edits.apply(&mut tracks)?;
418 let _ = writeln!(log, "{} audio files in {}", tracks.len(), dir.display());
419 let (tracks, spare) = one_copy_each(tracks);
420 for t in &spare {
421 let _ = writeln!(
422 log,
423 "left behind (another copy of the same track): {}",
424 t.path.display()
425 );
426 }
427 let mut entries = as_is_tags(&tracks, self.cfg.ft_in_title.as_ref())?;
428 if let Some(first) = entries.first().map(|(_, t)| t.clone())
432 && first.date.is_none()
433 {
434 match self
435 .mb
436 .release_group_year(&first.album_artist, &first.album)
437 .await
438 {
439 Ok(Some(year)) => {
440 let _ = writeln!(log, "year {year} found on MusicBrainz for {}", first.album);
441 for (_, t) in &mut entries {
442 t.date = Some(year.clone());
443 t.original_date = Some(year.clone());
444 }
445 }
446 Ok(None) => {}
447 Err(e) => {
448 let _ = writeln!(log, "musicbrainz year lookup failed: {e}");
449 }
450 }
451 }
452 let _ = writeln!(log, "as-is: filed by the files' own tags");
453 let dest = self
454 .file(&tracks, &entries, None, dir, Filing::New, &mut log)
455 .await?;
456 Ok(Outcome::Imported {
457 dir: dest,
458 release: None,
459 log,
460 })
461 }
462
463 pub async fn check_as_is(&self, dir: &Path) -> Result<(), ImportError> {
466 let (tracks, _) = one_copy_each(read_dir(dir).await?);
467 as_is_tags(&tracks, self.cfg.ft_in_title.as_ref()).map(|_| ())
468 }
469
470 pub async fn tracks(&self, dir: &Path) -> Result<Vec<Track>, ImportError> {
473 read_dir(dir).await
474 }
475
476 pub async fn compare(&self, dir: &Path, release_id: &str) -> Result<Comparison, ImportError> {
480 let (tracks, _) = one_copy_each(read_dir(dir).await?);
481 let release = self.release(release_id).await?;
482 let m = matching::score(&tracks, &release);
483 let remote: Vec<_> = release.tracks().collect();
484 let name = |t: &Track| {
485 t.path
486 .file_name()
487 .map(|n| n.to_string_lossy().into_owned())
488 .unwrap_or_default()
489 };
490 let pairs = m
491 .pairs
492 .iter()
493 .map(|&(l, r)| {
494 let (medium, rt) = remote[r];
495 let local = &tracks[l];
496 PairView {
497 file: name(local),
498 file_title: local.title.clone(),
499 disc: medium.position,
500 position: rt.position,
501 title: rt.title.clone(),
502 title_distance: (matching::string_distance(
503 local.title.as_deref().unwrap_or_default(),
504 &rt.title,
505 ) * 1000.0)
506 .round()
507 / 1000.0,
508 length_delta_secs: rt
509 .length
510 .or(rt.recording.length)
511 .map(|ms| (local.duration.as_secs_f64() - ms as f64 / 1000.0).round()),
512 }
513 })
514 .collect();
515 let missing = remote
516 .iter()
517 .enumerate()
518 .filter(|(r, _)| !m.pairs.iter().any(|&(_, pr)| pr == *r))
519 .map(|(_, (medium, rt))| ReleaseTrackView {
520 disc: medium.position,
521 position: rt.position,
522 title: rt.title.clone(),
523 length_secs: rt.length.or(rt.recording.length).map(|ms| ms / 1000),
524 })
525 .collect();
526 let extra = tracks
527 .iter()
528 .enumerate()
529 .filter(|(l, _)| !m.pairs.iter().any(|&(pl, _)| pl == *l))
530 .map(|(_, t)| name(t))
531 .collect();
532 Ok(Comparison {
533 release: Candidate::from(&m),
534 parts: m.parts,
535 pairs,
536 missing,
537 extra,
538 })
539 }
540
541 async fn release(&self, id: &str) -> Result<Release, ImportError> {
543 match source_of(id) {
544 Source::MusicBrainz => Ok(self.mb.release(id).await?),
545 Source::Discogs => {
546 let num: u64 = id
547 .trim_start_matches("discogs:")
548 .parse()
549 .map_err(|_| ImportError::Conflict(format!("{id:?} is not a valid id")))?;
550 let discogs = self
551 .discogs
552 .as_ref()
553 .ok_or_else(|| ImportError::Conflict("discogs is not configured".into()))?;
554 let index_tracks = self.cfg.discogs.as_ref().is_some_and(|d| d.index_tracks);
555 Ok(discogs.release(num, index_tracks).await?)
556 }
557 }
558 }
559
560 async fn candidates(
561 &self,
562 dir: &Path,
563 tracks: &[Track],
564 log: &mut String,
565 ) -> Result<Vec<Match>, ImportError> {
566 let mut releases: Vec<Release> = Vec::new();
567 if let Some(id) = matching::consensus(tracks.iter().map(|t| t.mb_album_id.as_deref())) {
570 let _ = writeln!(log, "files are tagged with release {id}");
571 if let Ok(r) = self.mb.release(&id).await {
572 releases.push(r);
573 }
574 }
575 let (artist, album) = self.query_terms(dir, tracks);
576 let base = matching::base_title(&album);
580 let mut hits = Vec::new();
581 for title in std::iter::once(base.as_str()).chain((base != album).then_some(album.as_str()))
582 {
583 let _ = writeln!(log, "searching for {artist:?} — {title:?}");
584 hits = self.mb.search_releases(&artist, title, 10).await?;
585 if hits.iter().any(|h| h.score >= 50) {
586 break;
587 }
588 }
589 hits.retain(|h| h.score >= 50);
593 hits.sort_by_key(|h| h.track_count.abs_diff(tracks.len()));
594 for hit in hits {
595 if releases.len() > LOOKUPS {
596 break;
597 }
598 if releases.iter().any(|r| r.id == hit.id) {
599 continue;
600 }
601 releases.push(self.mb.release(&hit.id).await?);
602 }
603 let mut matches: Vec<Match> = releases
604 .iter()
605 .map(|r| matching::score(tracks, r))
606 .collect();
607
608 let best = matches
611 .iter()
612 .map(|m| m.distance)
613 .fold(f64::INFINITY, f64::min);
614 if let Some(discogs) = &self.discogs
615 && best > self.cfg.strong_threshold
616 {
617 let _ = writeln!(log, "searching discogs for {artist:?} — {album:?}");
618 match discogs.search(&artist, &album).await {
619 Ok(ids) => {
620 let index_tracks = self.cfg.discogs.as_ref().is_some_and(|d| d.index_tracks);
621 for id in ids.into_iter().take(5) {
622 match discogs.release(id, index_tracks).await {
623 Ok(r) => matches.push(matching::score(tracks, &r)),
624 Err(e) => {
625 let _ = writeln!(log, "discogs release {id}: {e}");
626 }
627 }
628 }
629 }
630 Err(e) => {
631 let _ = writeln!(log, "discogs search failed: {e}");
632 }
633 }
634 }
635 Ok(matches)
636 }
637
638 fn query_terms(&self, dir: &Path, tracks: &[Track]) -> (String, String) {
642 let artist = matching::consensus(
643 tracks
644 .iter()
645 .map(|t| t.album_artist.as_deref().or(t.artist.as_deref())),
646 );
647 let album = matching::consensus(tracks.iter().map(|t| t.album.as_deref()));
648 if let (Some(a), Some(b)) = (&artist, &album) {
649 return (a.clone(), b.clone());
650 }
651 let name = dir
652 .file_name()
653 .map(|n| n.to_string_lossy().into_owned())
654 .unwrap_or_default();
655 let cleaned = strip_brackets(&name);
656 match cleaned.split_once(" - ") {
657 Some((a, b)) => (
658 artist.unwrap_or_else(|| a.trim().to_string()),
659 album.unwrap_or_else(|| b.trim().to_string()),
660 ),
661 None => {
662 let parent = dir
663 .parent()
664 .and_then(|p| p.file_name())
665 .map(|n| n.to_string_lossy().into_owned());
666 (
667 artist.or(parent).unwrap_or_default(),
668 album.unwrap_or(cleaned),
669 )
670 }
671 }
672 }
673
674 async fn apply(
678 &self,
679 tracks: &[Track],
680 m: &Match,
681 source_dir: &Path,
682 filing: Filing<'_>,
683 log: &mut String,
684 ) -> Result<PathBuf, ImportError> {
685 let release = &m.release;
686 let remote: Vec<_> = release.tracks().collect();
687 let entries: Vec<(usize, Tags)> = m
688 .pairs
689 .iter()
690 .map(|&(l, r)| {
691 let (medium, rt) = remote[r];
692 (l, self.tags(release, r, medium.position, rt, remote.len()))
693 })
694 .collect();
695 self.file(tracks, &entries, Some(release), source_dir, filing, log)
696 .await
697 }
698
699 async fn file(
702 &self,
703 tracks: &[Track],
704 entries: &[(usize, Tags)],
705 release: Option<&Release>,
706 source_dir: &Path,
707 filing: Filing<'_>,
708 log: &mut String,
709 ) -> Result<PathBuf, ImportError> {
710 let in_place = matches!(filing, Filing::InPlace);
711 let itself =
712 |from: &Path, to: &Path| paths::collision_key(from) == paths::collision_key(to);
713 let mut plan = Vec::with_capacity(entries.len());
714 let mut claimed = std::collections::HashMap::new();
715 for (l, tags) in entries {
716 let (l, tags) = (*l, tags.clone());
717 let local = &tracks[l];
718 let rel = paths::render(&self.cfg, &tags, local, release)?;
719 let ext = local
721 .path
722 .extension()
723 .map(|e| e.to_string_lossy().to_lowercase())
724 .unwrap_or_default();
725 let dest = paths::under(&self.cfg.directory, &format!("{rel}.{ext}"))?;
726 if let Some(other) = claimed.insert(paths::collision_key(&dest), l) {
727 return Err(ImportError::Conflict(format!(
728 "{} and {} would both be filed as {}",
729 tracks[other].path.display(),
730 local.path.display(),
731 dest.display()
732 )));
733 }
734 plan.push((l, tags, dest));
735 }
736
737 let mut present: Vec<bool> = plan
745 .iter()
746 .map(|(l, _, d)| d.exists() && !(in_place && itself(&tracks[*l].path, d)))
747 .collect();
748 if let Filing::Replacing(bin) = filing {
749 let mut taken: Vec<PathBuf> = plan
750 .iter()
751 .zip(&present)
752 .filter(|(_, p)| **p)
753 .filter_map(|((_, _, d), _)| d.parent().map(Path::to_path_buf))
754 .collect();
755 taken.sort();
756 taken.dedup();
757 for dir in taken {
758 let to = set_aside(&dir, &self.cfg.directory, bin).await?;
759 let _ = writeln!(log, "replaced: {} moved to {}", dir.display(), to.display());
760 }
761 present = plan.iter().map(|(_, _, d)| d.exists()).collect();
762 }
763 if !in_place && present.iter().all(|p| *p) && !plan.is_empty() {
764 let same = plan.iter().all(|(l, _, d)| {
765 meta::read(d).is_ok_and(|there| {
766 let here = &tracks[*l];
767 there.format == here.format
768 && (there.duration.as_secs_f64() - here.duration.as_secs_f64()).abs() < 1.0
769 })
770 });
771 let dir = plan[0]
772 .2
773 .parent()
774 .map(Path::to_path_buf)
775 .unwrap_or_default();
776 if same {
777 let _ = writeln!(
778 log,
779 "already in the library as {}; nothing moved",
780 dir.display()
781 );
782 return Ok(dir);
783 }
784 }
785 if let Some(i) = present.iter().position(|p| *p) {
786 return Err(ImportError::Exists(
787 plan[i]
788 .2
789 .parent()
790 .map(Path::to_path_buf)
791 .unwrap_or_default(),
792 ));
793 }
794
795 let cover_art = if self.cfg.fetch_art {
796 self.cover(release, tracks).await
797 } else {
798 None
799 };
800 let _ = writeln!(
801 log,
802 "cover art: {}",
803 match &cover_art {
804 Some(c) if c.resized => format!("{}, resized", c.source),
805 Some(c) => c.source.to_string(),
806 None => "none".to_string(),
807 }
808 );
809 let cover = cover_art.map(|c| c.bytes);
810 for (l, tags, dest) in &plan {
811 let local = &tracks[*l];
812 let (cover, tags) = (cover.clone(), tags.clone());
813 let preserve_mtime = self.cfg.import_added;
817 if self.cfg.move_files || in_place {
818 let path = local.path.clone();
819 tokio::task::spawn_blocking(move || {
820 meta::write(&path, &tags, cover.as_deref(), preserve_mtime)
821 })
822 .await
823 .expect("tag writer panicked")?;
824 if !itself(&local.path, dest) {
825 paths::transfer(&local.path, dest, true).await?;
826 }
827 } else {
828 paths::transfer(&local.path, dest, false).await?;
829 let path = dest.clone();
830 let written = tokio::task::spawn_blocking(move || {
831 meta::write(&path, &tags, cover.as_deref(), preserve_mtime)
832 })
833 .await
834 .expect("tag writer panicked");
835 if let Err(e) = written {
836 let _ = tokio::fs::remove_file(dest).await;
837 return Err(e.into());
838 }
839 }
840 let _ = writeln!(
841 log,
842 "{} → {}",
843 local
844 .path
845 .strip_prefix(source_dir)
846 .unwrap_or(&local.path)
847 .display(),
848 dest.display()
849 );
850 }
851 for (i, t) in tracks.iter().enumerate() {
852 if !entries.iter().any(|(l, _)| *l == i) {
853 let _ = writeln!(log, "left behind (no matching track): {}", t.path.display());
854 }
855 }
856 if in_place
859 && let Some(new_dir) = plan.first().and_then(|(_, _, d)| d.parent())
860 && !itself(source_dir, new_dir)
861 {
862 if let Ok(entries) = std::fs::read_dir(source_dir) {
863 for entry in entries.flatten() {
864 let from = entry.path();
865 let to = new_dir.join(entry.file_name());
866 if entry.file_type().is_ok_and(|t| t.is_file())
867 && !meta::is_audio(&from)
868 && !to.exists()
869 {
870 paths::transfer(&from, &to, true).await?;
871 }
872 }
873 }
874 if std::fs::remove_dir(source_dir).is_ok()
875 && let Some(parent) = source_dir.parent()
876 {
877 let _ = std::fs::remove_dir(parent);
878 }
879 }
880 Ok(plan
881 .first()
882 .and_then(|(_, _, d)| d.parent())
883 .map(Path::to_path_buf)
884 .unwrap_or_else(|| self.cfg.directory.clone()))
885 }
886
887 fn tags(
888 &self,
889 release: &Release,
890 index: usize,
891 disc: u32,
892 rt: &crate::musicbrainz::ReleaseTrack,
893 total: usize,
894 ) -> Tags {
895 let medium = release.media.iter().find(|m| m.position == disc);
896 let release_date = release
902 .date
903 .clone()
904 .filter(|d| !d.is_empty() && d != "0000");
905 let group_date = release
906 .release_group
907 .as_ref()
908 .and_then(|g| g.first_release_date.clone())
909 .filter(|d| !d.is_empty());
910 let date = release_date.clone().or_else(|| group_date.clone());
911 let original = group_date.or_else(|| release_date.clone());
912 let label = release.label_info.first();
913 let (track, track_total) = if self.cfg.per_disc_numbering {
914 (rt.position, medium.map_or(0, |m| m.tracks.len()) as u32)
915 } else {
916 (index as u32 + 1, total as u32)
917 };
918 let album_artist = release.artist();
919 let (artist, title) = self.fold_featuring(
920 matching::track_artist(release, index),
921 &album_artist,
922 rt.title.clone(),
923 );
924 let mb_ids = source_of(&release.id) == Source::MusicBrainz;
926 Tags {
927 title,
928 artist,
929 album: release.title.clone(),
930 album_artist,
931 track,
932 track_total,
933 disc,
934 disc_total: release.media.len() as u32,
935 date,
936 original_date: original,
937 label: label.and_then(|l| l.label.as_ref()).map(|l| l.name.clone()),
938 catalog_number: label.and_then(|l| l.catalog_number.clone()),
939 country: release.country.clone(),
940 media: medium.and_then(|m| m.format.clone()),
941 compilation: release.is_compilation(),
942 mb_recording_id: mb_ids.then(|| rt.recording.id.clone()),
943 mb_track_id: mb_ids.then(|| rt.id.clone()),
944 mb_album_id: mb_ids.then(|| release.id.clone()),
945 mb_artist_id: mb_ids
946 .then(|| {
947 rt.artist_credit
948 .first()
949 .or(release.artist_credit.first())
950 .map(|c| c.artist.id.clone())
951 })
952 .flatten(),
953 mb_album_artist_id: mb_ids
954 .then(|| release.artist_credit.first().map(|c| c.artist.id.clone()))
955 .flatten(),
956 mb_release_group_id: mb_ids
957 .then(|| release.release_group.as_ref().map(|g| g.id.clone()))
958 .flatten(),
959 }
960 }
961
962 fn fold_featuring(
966 &self,
967 artist: String,
968 album_artist: &str,
969 title: String,
970 ) -> (String, String) {
971 let Some(ft) = &self.cfg.ft_in_title else {
972 return (artist, title);
973 };
974 match crate::ftintitle::apply(&artist, album_artist, &title, ft.drop, &ft.format) {
975 Some((new_artist, new_title)) => (new_artist, new_title),
976 None => (artist, title),
977 }
978 }
979
980 async fn cover(&self, release: Option<&Release>, tracks: &[Track]) -> Option<CoverArt> {
988 let rules = ArtRules {
989 min_width: self.cfg.art_min_width,
990 max_width: self.cfg.art_max_width,
991 quality: self.cfg.art_quality,
992 ratio: self.cfg.art_ratio,
993 };
994 if let Some(release) = release
995 && source_of(&release.id) == Source::Discogs
996 {
997 if let (Some(url), Some(discogs)) = (&release.cover_url, &self.discogs)
998 && let Ok(bytes) = discogs.image(url).await
999 && !bytes.is_empty()
1000 && let Some(prepared) = prepare(bytes.to_vec(), rules).await
1001 {
1002 return Some(CoverArt {
1003 bytes: prepared.bytes,
1004 source: "discogs",
1005 resized: prepared.resized,
1006 });
1007 }
1008 return Self::embedded_cover(tracks, rules).await;
1009 }
1010 let size = match self.cfg.art_max_width {
1011 w if w >= 1200 => "1200",
1012 w if w >= 500 => "500",
1013 _ => "250",
1014 };
1015 let mut candidates: Vec<(String, &'static str)> = Vec::new();
1016 if let Some(release) = release {
1017 if self.cfg.art_high_resolution {
1018 candidates.push((
1019 format!("https://coverartarchive.org/release/{}/front", release.id),
1020 "release (original)",
1021 ));
1022 if let Some(g) = &release.release_group {
1023 candidates.push((
1024 format!("https://coverartarchive.org/release-group/{}/front", g.id),
1025 "release group (original)",
1026 ));
1027 }
1028 }
1029 candidates.push((
1030 format!(
1031 "https://coverartarchive.org/release/{}/front-{size}",
1032 release.id
1033 ),
1034 "release",
1035 ));
1036 if let Some(g) = &release.release_group {
1037 candidates.push((
1038 format!(
1039 "https://coverartarchive.org/release-group/{}/front-{size}",
1040 g.id
1041 ),
1042 "release group",
1043 ));
1044 }
1045 }
1046 for (url, source) in candidates {
1047 if let Ok(resp) = self.http.get(&url).send().await
1048 && resp.status().is_success()
1049 && let Ok(bytes) = resp.bytes().await
1050 && !bytes.is_empty()
1051 && let Some(prepared) = prepare(bytes.to_vec(), rules).await
1052 {
1053 return Some(CoverArt {
1054 bytes: prepared.bytes,
1055 source,
1056 resized: prepared.resized,
1057 });
1058 }
1059 }
1060 Self::embedded_cover(tracks, rules).await
1061 }
1062
1063 async fn embedded_cover(tracks: &[Track], rules: ArtRules) -> Option<CoverArt> {
1065 let first = tracks.first()?.path.clone();
1066 let embedded = tokio::task::spawn_blocking(move || meta::embedded_cover(&first))
1067 .await
1068 .ok()
1069 .flatten()?;
1070 let prepared = prepare(embedded, rules).await?;
1071 Some(CoverArt {
1072 bytes: prepared.bytes,
1073 source: "embedded",
1074 resized: prepared.resized,
1075 })
1076 }
1077}
1078
1079struct CoverArt {
1081 bytes: Vec<u8>,
1082 source: &'static str,
1083 resized: bool,
1084}
1085
1086async fn prepare(bytes: Vec<u8>, rules: ArtRules) -> Option<art::Prepared> {
1088 tokio::task::spawn_blocking(move || art::prepare(&bytes, &rules))
1089 .await
1090 .ok()
1091 .flatten()
1092}
1093
1094fn strip_brackets(s: &str) -> String {
1095 let mut out = String::new();
1096 let mut depth = 0;
1097 for c in s.chars() {
1098 match c {
1099 '(' | '[' | '{' => depth += 1,
1100 ')' | ']' | '}' if depth > 0 => depth -= 1,
1101 _ if depth == 0 => out.push(c),
1102 _ => {}
1103 }
1104 }
1105 out.split_whitespace().collect::<Vec<_>>().join(" ")
1106}
1107
1108#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
1112pub struct Edits {
1113 pub album: Option<String>,
1114 pub album_artist: Option<String>,
1115 pub date: Option<String>,
1116 #[serde(default)]
1117 pub tracks: Vec<TrackEdit>,
1118}
1119
1120#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
1121pub struct TrackEdit {
1122 pub file: String,
1124 pub title: Option<String>,
1125 pub artist: Option<String>,
1126 pub track: Option<u32>,
1127 pub disc: Option<u32>,
1128}
1129
1130impl Edits {
1131 fn apply(&self, tracks: &mut [Track]) -> Result<(), ImportError> {
1132 for t in tracks.iter_mut() {
1133 if let Some(v) = &self.album {
1134 t.album = Some(v.clone());
1135 }
1136 if let Some(v) = &self.album_artist {
1137 t.album_artist = Some(v.clone());
1138 }
1139 if let Some(v) = &self.date {
1140 t.date = Some(v.clone());
1141 }
1142 }
1143 for e in &self.tracks {
1144 let t = tracks
1145 .iter_mut()
1146 .find(|t| {
1147 t.path
1148 .file_name()
1149 .is_some_and(|n| n.to_string_lossy() == e.file)
1150 })
1151 .ok_or_else(|| ImportError::Untagged(format!("no file named {:?}", e.file)))?;
1152 if let Some(v) = &e.title {
1153 t.title = Some(v.clone());
1154 }
1155 if let Some(v) = &e.artist {
1156 t.artist = Some(v.clone());
1157 }
1158 if e.track.is_some() {
1159 t.track = e.track;
1160 }
1161 if e.disc.is_some() {
1162 t.disc = e.disc;
1163 }
1164 }
1165 Ok(())
1166 }
1167}
1168
1169#[derive(Debug, Clone, serde::Serialize)]
1171pub struct Comparison {
1172 pub release: Candidate,
1173 pub parts: matching::Parts,
1174 pub pairs: Vec<PairView>,
1175 pub missing: Vec<ReleaseTrackView>,
1177 pub extra: Vec<String>,
1179}
1180
1181#[derive(Debug, Clone, serde::Serialize)]
1182pub struct PairView {
1183 pub file: String,
1184 pub file_title: Option<String>,
1185 pub disc: u32,
1186 pub position: u32,
1187 pub title: String,
1188 pub title_distance: f64,
1190 pub length_delta_secs: Option<f64>,
1192}
1193
1194#[derive(Debug, Clone, serde::Serialize)]
1195pub struct ReleaseTrackView {
1196 pub disc: u32,
1197 pub position: u32,
1198 pub title: String,
1199 pub length_secs: Option<u64>,
1200}
1201
1202fn as_is_tags(
1204 tracks: &[Track],
1205 ft: Option<&crate::config::FtInTitle>,
1206) -> Result<Vec<(usize, Tags)>, ImportError> {
1207 let norm = |s: Option<&str>| s.map(matching::normalise).filter(|s| !s.is_empty());
1208 let album = matching::consensus(tracks.iter().map(|t| t.album.as_deref()))
1209 .ok_or_else(|| ImportError::Untagged("no album tag".into()))?;
1210 let disagree = tracks
1211 .iter()
1212 .filter(|t| norm(t.album.as_deref()) != Some(matching::normalise(&album)))
1213 .count();
1214 if disagree > 0 {
1215 return Err(ImportError::Untagged(format!(
1216 "{disagree} of {} files name a different album, or none",
1217 tracks.len()
1218 )));
1219 }
1220 let album_artist = matching::consensus(tracks.iter().map(|t| t.album_artist.as_deref()))
1221 .or_else(|| {
1222 let artists: std::collections::HashSet<_> = tracks
1223 .iter()
1224 .filter_map(|t| norm(t.artist.as_deref()))
1225 .collect();
1226 match artists.len() {
1227 0 => None,
1228 1 => matching::consensus(tracks.iter().map(|t| t.artist.as_deref())),
1229 _ => Some("Various Artists".into()),
1230 }
1231 })
1232 .ok_or_else(|| ImportError::Untagged("no artist tag".into()))?;
1233 let date = matching::consensus(tracks.iter().map(|t| t.date.as_deref()));
1234
1235 let mut problems = Vec::new();
1236 let mut seen = HashMap::new();
1237 let mut entries = Vec::with_capacity(tracks.len());
1238 for (i, t) in tracks.iter().enumerate() {
1239 let (number, name) = number_and_title(&t.path);
1240 let title = t.title.clone().filter(|s| !s.trim().is_empty()).or(name);
1241 let track = t.track.or(number);
1242 let disc = t.disc.unwrap_or(1);
1243 let (Some(title), Some(track)) = (title, track) else {
1244 problems.push(format!(
1245 "{} has no title or track number",
1246 t.path.file_name().unwrap_or_default().to_string_lossy()
1247 ));
1248 continue;
1249 };
1250 if let Some(other) = seen.insert((disc, track), i) {
1251 problems.push(format!(
1252 "{} and {} are both track {track}",
1253 tracks[other]
1254 .path
1255 .file_name()
1256 .unwrap_or_default()
1257 .to_string_lossy(),
1258 t.path.file_name().unwrap_or_default().to_string_lossy()
1259 ));
1260 continue;
1261 }
1262 let artist = t.artist.clone().unwrap_or_else(|| album_artist.clone());
1263 let (artist, title) = match ft {
1264 Some(ft) => {
1265 match crate::ftintitle::apply(&artist, &album_artist, &title, ft.drop, &ft.format) {
1266 Some((new_artist, new_title)) => (new_artist, new_title),
1267 None => (artist, title),
1268 }
1269 }
1270 None => (artist, title),
1271 };
1272 entries.push((
1273 i,
1274 Tags {
1275 title,
1276 artist,
1277 album: album.clone(),
1278 album_artist: album_artist.clone(),
1279 track,
1280 track_total: 0,
1281 disc,
1282 disc_total: 0,
1283 date: date.clone(),
1284 compilation: album_artist == "Various Artists",
1285 ..Tags::default()
1286 },
1287 ));
1288 }
1289 if !problems.is_empty() {
1290 return Err(ImportError::Untagged(problems.join("; ")));
1291 }
1292 let discs = entries.iter().map(|(_, t)| t.disc).max().unwrap_or(1);
1293 let per_disc: HashMap<u32, u32> = entries.iter().fold(HashMap::new(), |mut m, (_, t)| {
1294 *m.entry(t.disc).or_default() += 1;
1295 m
1296 });
1297 for (_, t) in &mut entries {
1298 t.track_total = per_disc[&t.disc];
1299 t.disc_total = discs;
1300 }
1301 Ok(entries)
1302}
1303
1304fn number_and_title(path: &Path) -> (Option<u32>, Option<String>) {
1307 let stem = path
1308 .file_stem()
1309 .map(|s| s.to_string_lossy().into_owned())
1310 .unwrap_or_default();
1311 let digits: String = stem.chars().take_while(|c| c.is_ascii_digit()).collect();
1312 let rest = stem[digits.len()..]
1313 .trim_start_matches(|c: char| !c.is_alphanumeric())
1314 .trim();
1315 (
1316 digits.parse().ok().filter(|n| *n > 0),
1317 (!rest.is_empty()).then(|| rest.to_string()),
1318 )
1319}
1320
1321fn one_copy_each(tracks: Vec<Track>) -> (Vec<Track>, Vec<Track>) {
1327 fn key(t: &Track) -> (u32, Option<u32>, String) {
1328 let stem = t
1329 .path
1330 .file_stem()
1331 .map(|s| s.to_string_lossy().into_owned())
1332 .unwrap_or_default();
1333 let stem = stem.trim_end();
1336 let stem = match stem.rsplit_once(" (") {
1337 Some((head, tail))
1338 if tail.ends_with(')')
1339 && tail[..tail.len() - 1].chars().all(|c| c.is_ascii_digit()) =>
1340 {
1341 head
1342 }
1343 _ => stem,
1344 };
1345 let digits: String = stem.chars().take_while(|c| c.is_ascii_digit()).collect();
1346 let from_name = stem[digits.len()..].trim_start_matches(|c: char| !c.is_alphanumeric());
1347 let title = t.title.as_deref().unwrap_or(from_name);
1348 (
1349 t.disc.unwrap_or(1),
1350 t.track.or_else(|| digits.parse().ok()),
1351 matching::normalise(title),
1352 )
1353 }
1354 fn rank(t: &Track) -> (u8, std::cmp::Reverse<u8>, std::cmp::Reverse<u32>) {
1355 let format = match t.format.as_str() {
1356 "FLAC" => 0,
1357 "WAV" | "AIFF" | "ALAC" | "APE" | "WavPack" => 1,
1358 _ => 2,
1359 };
1360 (
1361 format,
1362 std::cmp::Reverse(t.bit_depth.unwrap_or(0)),
1363 std::cmp::Reverse(t.bitrate.unwrap_or(0)),
1364 )
1365 }
1366 let mut best: HashMap<(u32, Option<u32>, String), Track> = HashMap::new();
1367 let mut spare = Vec::new();
1368 for t in tracks {
1369 match best.entry(key(&t)) {
1370 std::collections::hash_map::Entry::Vacant(e) => {
1371 e.insert(t);
1372 }
1373 std::collections::hash_map::Entry::Occupied(mut e) => {
1374 if rank(&t) < rank(e.get()) {
1375 spare.push(e.insert(t));
1376 } else {
1377 spare.push(t);
1378 }
1379 }
1380 }
1381 }
1382 let mut kept: Vec<Track> = best.into_values().collect();
1383 kept.sort_by(|a, b| a.path.cmp(&b.path));
1384 spare.sort_by(|a, b| a.path.cmp(&b.path));
1385 (kept, spare)
1386}
1387
1388async fn read_dir(dir: &Path) -> Result<Vec<Track>, ImportError> {
1389 let dir = dir.to_path_buf();
1390 tokio::task::spawn_blocking(move || {
1391 let mut files: Vec<PathBuf> = walk(&dir);
1392 files.sort();
1393 if files.is_empty() {
1394 return Err(ImportError::Empty(dir));
1395 }
1396 files
1397 .iter()
1398 .map(|p| meta::read(p).map_err(ImportError::from))
1399 .collect()
1400 })
1401 .await
1402 .expect("reader panicked")
1403}
1404
1405fn walk(dir: &Path) -> Vec<PathBuf> {
1406 let mut out = Vec::new();
1407 let Ok(entries) = std::fs::read_dir(dir) else {
1408 return out;
1409 };
1410 for entry in entries.flatten() {
1411 let path = entry.path();
1412 if entry.file_name().to_string_lossy().starts_with('.') {
1413 continue;
1414 }
1415 if path.is_dir() {
1416 out.extend(walk(&path));
1417 } else if meta::is_audio(&path) {
1418 out.push(path);
1419 }
1420 }
1421 out
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426 use super::*;
1427
1428 fn track(path: &str, format: &str, number: Option<u32>, title: Option<&str>) -> Track {
1429 Track {
1430 path: PathBuf::from(path),
1431 title: title.map(str::to_string),
1432 artist: None,
1433 album: None,
1434 album_artist: None,
1435 track: number,
1436 track_total: None,
1437 disc: None,
1438 disc_total: None,
1439 date: None,
1440 original_date: None,
1441 compilation: false,
1442 genre: None,
1443 mb_recording_id: None,
1444 mb_album_id: None,
1445 duration: std::time::Duration::from_secs(200),
1446 format: format.into(),
1447 bitrate: None,
1448 sample_rate: None,
1449 bit_depth: None,
1450 }
1451 }
1452
1453 fn tagged(
1454 path: &str,
1455 album: Option<&str>,
1456 artist: Option<&str>,
1457 n: Option<u32>,
1458 title: Option<&str>,
1459 ) -> Track {
1460 Track {
1461 album: album.map(str::to_string),
1462 artist: artist.map(str::to_string),
1463 ..track(path, "FLAC", n, title)
1464 }
1465 }
1466
1467 #[test]
1468 fn edits_fix_what_the_tags_got_wrong_and_the_gate_still_holds() {
1469 let mut tracks = vec![
1470 tagged("a/01 x.flac", Some("Wrong"), Some("A"), Some(1), Some("x")),
1471 tagged("a/02 y.flac", Some("Other"), Some("A"), None, Some("y")),
1472 ];
1473 let edits = Edits {
1474 album: Some("Right".into()),
1475 tracks: vec![TrackEdit {
1476 file: "02 y.flac".into(),
1477 track: Some(2),
1478 title: Some("Why".into()),
1479 ..TrackEdit::default()
1480 }],
1481 ..Edits::default()
1482 };
1483 edits.apply(&mut tracks).unwrap();
1484 let entries = as_is_tags(&tracks, None).unwrap();
1485 assert!(entries.iter().all(|(_, t)| t.album == "Right"));
1486 assert_eq!(entries[1].1.title, "Why");
1487
1488 let clash = Edits {
1491 tracks: vec![TrackEdit {
1492 file: "02 y.flac".into(),
1493 track: Some(1),
1494 ..TrackEdit::default()
1495 }],
1496 ..Edits::default()
1497 };
1498 clash.apply(&mut tracks).unwrap();
1499 assert!(
1500 matches!(as_is_tags(&tracks, None), Err(ImportError::Untagged(m)) if m.contains("both track 1"))
1501 );
1502 }
1503
1504 #[test]
1505 fn edits_refuse_files_that_are_not_there() {
1506 let mut tracks = vec![tagged(
1507 "a/01 x.flac",
1508 Some("A"),
1509 Some("A"),
1510 Some(1),
1511 Some("x"),
1512 )];
1513 for file in [
1514 "missing.flac",
1515 "",
1516 "../01 x.flac",
1517 "a/01 x.flac",
1518 "01 X.FLAC",
1519 ] {
1520 let e = Edits {
1521 tracks: vec![TrackEdit {
1522 file: file.into(),
1523 title: Some("t".into()),
1524 ..TrackEdit::default()
1525 }],
1526 ..Edits::default()
1527 };
1528 assert!(
1529 matches!(e.apply(&mut tracks), Err(ImportError::Untagged(_))),
1530 "{file:?} should name no file"
1531 );
1532 }
1533 }
1534
1535 #[test]
1536 fn empty_edited_values_do_not_pass_for_tags() {
1537 let mut tracks = vec![tagged(
1538 "a/untitled.flac",
1539 None,
1540 Some("A"),
1541 Some(1),
1542 Some("x"),
1543 )];
1544 let e = Edits {
1545 album: Some(" ".into()),
1546 ..Edits::default()
1547 };
1548 e.apply(&mut tracks).unwrap();
1549 assert!(matches!(
1550 as_is_tags(&tracks, None),
1551 Err(ImportError::Untagged(_))
1552 ));
1553 }
1554
1555 #[test]
1556 fn as_is_takes_a_coherent_album_and_fills_gaps_from_file_names() {
1557 let entries = as_is_tags(
1558 &[
1559 tagged(
1560 "a/01 Intro.flac",
1561 Some("Hidden"),
1562 Some("ANNA"),
1563 Some(1),
1564 Some("Intro"),
1565 ),
1566 tagged(
1567 "a/02 - Second Thing.flac",
1568 Some("Hidden"),
1569 Some("ANNA"),
1570 None,
1571 None,
1572 ),
1573 ],
1574 None,
1575 )
1576 .unwrap();
1577 let t: Vec<_> = entries
1578 .iter()
1579 .map(|(_, t)| (t.track, t.title.as_str(), t.track_total))
1580 .collect();
1581 assert_eq!(t, [(1, "Intro", 2), (2, "Second Thing", 2)]);
1582 assert_eq!(entries[0].1.album_artist, "ANNA");
1583 }
1584
1585 #[test]
1586 fn as_is_calls_many_artists_a_compilation() {
1587 let entries = as_is_tags(
1588 &[
1589 tagged("a/01 x.flac", Some("Mix"), Some("One"), Some(1), Some("x")),
1590 tagged("a/02 y.flac", Some("Mix"), Some("Two"), Some(2), Some("y")),
1591 ],
1592 None,
1593 )
1594 .unwrap();
1595 assert_eq!(entries[0].1.album_artist, "Various Artists");
1596 assert!(entries[0].1.compilation);
1597 assert_eq!(entries[1].1.artist, "Two");
1598 }
1599
1600 #[test]
1601 fn as_is_refuses_tags_that_do_not_describe_one_album() {
1602 let refuse = |tracks: &[Track], why: &str| match as_is_tags(tracks, None) {
1603 Err(ImportError::Untagged(msg)) => assert!(msg.contains(why), "{msg}"),
1604 other => panic!("expected a refusal about {why:?}, got {other:?}"),
1605 };
1606 refuse(
1607 &[tagged("a/01 x.flac", None, Some("A"), Some(1), Some("x"))],
1608 "no album",
1609 );
1610 refuse(
1611 &[
1612 tagged("a/01 x.flac", Some("One"), Some("A"), Some(1), Some("x")),
1613 tagged(
1614 "a/02 y.flac",
1615 Some("Another"),
1616 Some("A"),
1617 Some(2),
1618 Some("y"),
1619 ),
1620 ],
1621 "different album",
1622 );
1623 refuse(
1624 &[
1625 tagged("a/01 x.flac", Some("One"), Some("A"), Some(1), Some("x")),
1626 tagged("a/01 y.flac", Some("One"), Some("A"), Some(1), Some("y")),
1627 ],
1628 "both track 1",
1629 );
1630 refuse(
1631 &[tagged(
1632 "a/untitled.flac",
1633 Some("One"),
1634 Some("A"),
1635 None,
1636 None,
1637 )],
1638 "no title or track number",
1639 );
1640 }
1641
1642 #[test]
1643 fn tags_take_the_release_groups_year_when_the_release_has_none() {
1644 let release: Release = serde_json::from_value(serde_json::json!({
1645 "id": "r", "title": "Geogaddi",
1646 "artist-credit": [{"name": "Boards of Canada", "joinphrase": "", "artist": {"id": "a", "name": "Boards of Canada"}}],
1647 "release-group": {"id": "g", "first-release-date": "2002-02-18"},
1648 "media": [{"position": 1, "tracks": [
1649 {"id": "t1", "position": 1, "title": "Ready Lets Go", "recording": {"id": "r1"}}
1650 ]}]
1651 }))
1652 .unwrap();
1653 let importer = Importer::with_musicbrainz(Config::default(), MusicBrainz::new("test"));
1654 let rt = &release.media[0].tracks[0];
1655 let tags = importer.tags(&release, 0, 1, rt, 1);
1656 assert_eq!(tags.date.as_deref(), Some("2002-02-18"));
1657 assert_eq!(tags.original_date.as_deref(), Some("2002-02-18"));
1658 }
1659
1660 #[test]
1661 fn keeps_one_copy_of_each_track_preferring_flac() {
1662 let (kept, spare) = one_copy_each(vec![
1663 track("a/1. One More Time.wav", "WAV", None, None),
1664 track(
1665 "a/1. One More Time.flac",
1666 "FLAC",
1667 Some(1),
1668 Some("One More Time"),
1669 ),
1670 track("a/3. Digital Love (1).wav", "WAV", None, None),
1671 track("a/3. Digital Love.wav", "WAV", None, None),
1672 track(
1673 "a/3. Digital Love.flac",
1674 "FLAC",
1675 Some(3),
1676 Some("Digital Love"),
1677 ),
1678 track("a/10. Voyager.wav", "WAV", None, None),
1679 ]);
1680 let names = |ts: &[Track]| {
1681 ts.iter()
1682 .map(|t| t.path.to_string_lossy().into_owned())
1683 .collect::<Vec<_>>()
1684 };
1685 assert_eq!(
1686 names(&kept),
1687 [
1688 "a/1. One More Time.flac",
1689 "a/10. Voyager.wav",
1690 "a/3. Digital Love.flac"
1691 ]
1692 );
1693 assert_eq!(spare.len(), 3);
1694 }
1695
1696 #[test]
1697 fn different_tracks_with_one_title_both_stay() {
1698 let (kept, spare) = one_copy_each(vec![
1699 track("a/01 Intro.flac", "FLAC", Some(1), Some("Intro")),
1700 track("a/09 Intro.flac", "FLAC", Some(9), Some("Intro")),
1701 ]);
1702 assert_eq!((kept.len(), spare.len()), (2, 0));
1703 }
1704
1705 #[test]
1706 fn folder_names_yield_search_terms() {
1707 let imp = Importer::new(Config {
1708 directory: "/x".into(),
1709 ..Config::default()
1710 });
1711 let q = |p: &str| imp.query_terms(Path::new(p), &[]);
1712 assert_eq!(
1713 q("/s/Boards of Canada - Geogaddi (2002) [FLAC]"),
1714 ("Boards of Canada".into(), "Geogaddi".into())
1715 );
1716 assert_eq!(
1717 q("/s/Boards of Canada/(2002) Geogaddi [FLAC 24-96]"),
1718 ("Boards of Canada".into(), "Geogaddi".into())
1719 );
1720 }
1721
1722 #[test]
1723 fn a_discogs_prefixed_id_is_dispatched_to_discogs() {
1724 assert_eq!(source_of("discogs:123"), Source::Discogs);
1725 assert_eq!(
1726 source_of("f2f5b8f4-8c1f-4e6b-9d6a-1234567890ab"),
1727 Source::MusicBrainz
1728 );
1729 }
1730}