Skip to main content

sift/
import.rs

1//! A folder in, an album in the library out — or candidates for a person to
2//! choose from.
3
4use 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    /// The album is already filed at this directory: its destinations are
33    /// taken, by files not close enough to these to be the same copy. Filed
34    /// by the same rules into the same format's folder, it is the same album.
35    #[error("{} is already in the library", .0.display())]
36    Exists(PathBuf),
37    /// The files' own tags do not describe one album well enough to file
38    /// it by them.
39    #[error("the files' tags do not describe one album: {0}")]
40    Untagged(String),
41}
42
43impl ImportError {
44    /// Whether the same import could succeed later without anything
45    /// changing here.
46    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/// A release that might be the album, for someone to choose between.
53#[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/// Where an import's files go relative to what is already filed.
91#[derive(Clone, Copy)]
92enum Filing<'a> {
93    /// Into the library, refusing a destination another copy holds.
94    New,
95    /// Retagged, and moved only if their path changes: the album is
96    /// already in the library.
97    InPlace,
98    /// Into the library, a copy already there moved into this bin first.
99    Replacing(&'a Path),
100}
101
102/// Move an album folder out of the library into `bin`, at its path relative
103/// to the library root, under a new name if the bin already holds one by
104/// that name. Nothing is deleted: putting it back is a move.
105async 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        /// The release applied; none for an import as-is.
140        release: Option<Candidate>,
141        log: String,
142    },
143    /// Nothing was changed. `candidates` is best first; importing again with
144    /// one of their ids applies it.
145    Review {
146        reason: String,
147        candidates: Vec<Candidate>,
148        log: String,
149    },
150}
151
152/// Which service a release id belongs to. `discogs:<number>` ids are
153/// namespaced; a MusicBrainz id is a UUID and never contains a colon.
154#[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/// What [`Importer::enrich`] added.
176#[derive(Debug, Default)]
177pub struct Enriched {
178    pub gain_db: Option<f64>,
179    pub genres: Vec<String>,
180    /// Tracks given lyrics.
181    pub lyrics: usize,
182    pub problems: Vec<String>,
183}
184
185/// How many search hits are looked up in full. Each is a rate-limited
186/// request, so this is the time an import spends at MusicBrainz.
187const 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    /// Match and import `dir`. With `release_id`, that release is applied
232    /// whatever its distance — the way a person answers a review.
233    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    /// [`Importer::import`], replacing a copy already filed: a folder these
242    /// files would land in is moved into `bin` first, at its path relative
243    /// to the library, rather than refusing the import or taking the files
244    /// for a repeat of it. For a new copy fetched because the filed one is
245    /// damaged, which looks the same as a repeat by format and length.
246    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    /// What beets' `replaygain`, `lastgenre` and `lyrics` plugins add after
257    /// an import: album and track gain, genres from MusicBrainz when the
258    /// album has none, and lyrics for tracks without them. Each is written
259    /// only to its own tags, and each failure is reported rather than
260    /// stopping the rest.
261    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    /// Match an album already in the library again, as beets' `import -L`
327    /// does: retag it, and move it if the new tags file it elsewhere. A
328    /// file that stays where it is is retagged in place, and the album's
329    /// other files (cover, cue, log) follow it to a new directory.
330    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    /// File the album by the files' own tags, without MusicBrainz: for a
410    /// release it does not have. Refused, saying why, unless the tags
411    /// describe one album: an album and an artist every file agrees on, and
412    /// a title and a distinct track number for each file (taken from the
413    /// file name where a tag is missing).
414    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        // The files carry no date the tagger trusts: MusicBrainz may still
429        // have a year for this album and artist, even without a matching
430        // release, as beets' `yearfixer` plugin looks one up.
431        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    /// Whether `import_as_is` would accept the folder as its tags stand,
464    /// and if not, why: the same check, without filing anything.
465    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    /// The folder's files and the tags they carry, as an import would read
471    /// them.
472    pub async fn tracks(&self, dir: &Path) -> Result<Vec<Track>, ImportError> {
473        read_dir(dir).await
474    }
475
476    /// How the folder lines up against one release, track by track: what a
477    /// person or assistant needs to decide whether it is the right one, and
478    /// what differs if it nearly is.
479    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    /// A release by id, dispatched to whichever service it names.
542    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        // Files that already name their release are the strongest evidence
568        // there is.
569        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        // The plain title first: an edition suffix is in the tags far more
577        // often than in MusicBrainz's title. The title as tagged is the
578        // fallback, for the release whose edition really is in its name.
579        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        // An album usually has many pressings scoring alike; the ones with
590        // as many tracks as the folder are looked up first, since the
591        // lookups run out before the list does.
592        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        // MusicBrainz found nothing worth applying on its own: ask Discogs
609        // for the same artist and album, and let the two compete on distance.
610        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    /// Artist and album from the tags, or from the folder name when the
639    /// files are untagged: `Artist - Album (Year)` and `(Year) Album` are the
640    /// shapes folders on the network actually take.
641    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    /// Plan every destination first and refuse the album if any collides —
675    /// with another track in it, or with a file already there — so a
676    /// conflict on the fifth track does not leave four already moved.
677    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    /// Tag and move each `(file, tags)` into place, with the release's cover
700    /// when there is one and the files' own otherwise.
701    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            // Not `with_extension`: it cuts at the last dot, and titles have dots.
720            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        // Every destination already there, each the same recording as its
738        // source, is this album filed once before: a repeated import (a
739        // retry, a second request queued behind the first) succeeds and
740        // moves nothing. Anything short of that is a conflict, and nothing
741        // is overwritten either way.
742        // In place, a file whose destination is itself is not "already
743        // there": it is the file being retagged.
744        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            // Moving: the source is ours to change, so tag it and move it.
814            // Copying: the source must come out untouched, so copy first and
815            // tag the copy, taking it back out if tagging fails.
816            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        // A re-imported album that changed directory takes its other files
857        // along, and leaves no empty directory behind.
858        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        // A release with no date of its own (or MusicBrainz's placeholder
897        // "0000") takes its release group's first release date instead, as
898        // beets' `yearfixer` plugin does; a release group with none leaves
899        // the release's own date standing in for it, so one missing side
900        // never blanks the other.
901        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        // A Discogs release carries none of MusicBrainz's own identifiers.
925        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    /// Folds a featured artist out of `artist` and into `title`, per the
963    /// `ftintitle` plugin config; unchanged when it isn't configured or
964    /// doesn't apply.
965    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    /// The release's front cover: for a Discogs release, its primary image;
981    /// otherwise the Cover Art Archive's, then the release group's. When
982    /// `art_high_resolution` is set, each source's full-size original is
983    /// tried before its thumbnail. Whatever the files already carry comes
984    /// last. A candidate narrower than `art_min_width` or failing `art_ratio`
985    /// is skipped for the next one; one wider than `art_max_width` is resized
986    /// down and re-encoded as JPEG at `art_quality`.
987    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    /// Whatever the files already carry, under the same rules as fetched art.
1064    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
1079/// A cover ready to embed, with what the import log should say about it.
1080struct CoverArt {
1081    bytes: Vec<u8>,
1082    source: &'static str,
1083    resized: bool,
1084}
1085
1086/// Runs [`art::prepare`] off the async runtime.
1087async 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/// Corrections to the files' own tags, applied before an import as-is and
1109/// checked by the same rules as the tags themselves: fields set here apply to
1110/// every file, and per-file fields to the file named.
1111#[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    /// The file's name in the folder, as `tracks` reports it.
1123    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/// A folder against one release. See [`Importer::compare`].
1170#[derive(Debug, Clone, serde::Serialize)]
1171pub struct Comparison {
1172    pub release: Candidate,
1173    pub parts: matching::Parts,
1174    pub pairs: Vec<PairView>,
1175    /// Release tracks with no file.
1176    pub missing: Vec<ReleaseTrackView>,
1177    /// Files with no release track.
1178    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    /// 0 is identical.
1189    pub title_distance: f64,
1190    /// The file's length less the release's, when the release gives one.
1191    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
1202/// Tags for filing each file as it is, or why its tags cannot be trusted to.
1203fn 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
1304/// "03 - Title.flac", "03. Title.flac", "Title.flac": the number and the
1305/// title a file name gives, for files whose tags lack them.
1306fn 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
1321/// One file per track, when a folder holds an album more than once: FLAC
1322/// and WAV side by side, or "Track (1).flac" beside "Track.flac". Matching
1323/// every copy counts the spares as extra tracks, and filing them splits the
1324/// album across formats. The best copy stays in the import; the rest are
1325/// returned to be left where they are.
1326fn 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        // "03. Digital Love (1)": the number and the copy marker are not the
1334        // title.
1335        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        // An edit cannot talk its way past the gate: two files made the same
1489        // track are still refused.
1490        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}