aede_core/model/
builder.rs

1//! Turning scanned files into a graph of entities.
2//!
3//! Construction is **deterministic**: the files are sorted by path before
4//! anything else happens, so two scans of the same library produce exactly the
5//! same identifiers. Without that there is neither a readable diff between two
6//! catalogs nor a reproducible test.
7//!
8//! The work is one pass per file, with every name interned as it is met, and a
9//! `finalize` step at the end for what can only be known once everything is in:
10//! an album's year, its cover, the order of its tracks.
11
12use std::collections::{HashMap, HashSet};
13
14use crate::tags::RawTags;
15use crate::text;
16
17use super::relations::rebuild_relations;
18use super::{
19    Artist, AudioFile, Catalog, Credit, EntityKind, Genre, GenreLink, Id, IntegrityRecord, Label,
20    Recording, Release, ReleaseGroup, Track, Work,
21};
22
23// --------------------------------------------------------------------------
24// Building the catalog
25// --------------------------------------------------------------------------
26
27/// A file read by the scanner, before entity matching.
28#[derive(Debug, Clone)]
29pub struct ScannedFile {
30    /// Absolute path of the file that was read.
31    pub path: String,
32    /// Size in bytes at the moment of the scan.
33    pub size: u64,
34    /// Modification date in seconds since the Unix epoch. The scanner stores
35    /// its nanosecond fraction separately on [`Catalog::file_mtime_subseconds`]
36    /// after building the graph; both parts and `size` decide cache reuse.
37    pub mtime: u64,
38    /// Tags and stream properties, exactly as the decoder handed them over.
39    pub tags: RawTags,
40    /// Cover art found in the folder (`cover.jpg`, `folder.png`…).
41    pub folder_cover: Option<String>,
42    /// A `.lrc` file sitting beside this one, when the walk saw one.
43    pub sidecar: Option<String>,
44    /// Integrity verdict carried over from the previous catalog, when the file
45    /// was reused unchanged. A freshly read file has none: it has to be checked
46    /// again.
47    pub integrity: Option<IntegrityRecord>,
48    /// Fingerprint carried over the same way, and dropped for the same reason:
49    /// a file that changed is different audio, and its old fingerprint
50    /// describes something that is no longer there.
51    pub fingerprint: Option<crate::fingerprint::Fingerprint>,
52}
53
54/// Assembles the graph from the files that were read.
55///
56/// Processing order is deterministic — files are sorted by path first — so two
57/// scans of the same library produce exactly the same identifiers. Without
58/// that, neither a readable diff nor a reproducible test is possible.
59pub fn build(
60    mut scanned: Vec<ScannedFile>,
61    roots: Vec<String>,
62    scanned_at: u64,
63    chosen: &[super::identity::Chosen],
64) -> Catalog {
65    scanned.sort_by(|a, b| a.path.cmp(&b.path));
66
67    // **Read before anything is interned.** Two spellings under one MusicBrainz
68    // identifier are one artist, and which spelling survives is decided by the
69    // library as a whole — the most frequent one — so it cannot be settled file
70    // by file as the walk goes. `chosen` is what the owner of the disk said
71    // about the files no identifier can answer for, and it is resolved in the
72    // same pass rather than applied over it. See [`super::identity`].
73    let aliases = super::identity::aliases(scanned.iter().flat_map(named_once), chosen);
74
75    let mut builder = Builder::new(roots, scanned_at, aliases);
76    for item in &scanned {
77        builder.add_file(item);
78    }
79    builder.finish()
80}
81
82/// Interning state shared by every step of the construction.
83///
84/// Each map answers "have I already seen this name?" in constant time. Doing
85/// it with a linear scan made construction quadratic on real libraries.
86struct Builder {
87    catalog: Catalog,
88    /// Spellings that are somebody else's, worked out before the walk.
89    aliases: super::identity::Aliases,
90    artists: HashMap<String, Id>,
91    labels: HashMap<String, Id>,
92    genres: HashMap<String, Id>,
93    releases: HashMap<String, Id>,
94    release_groups: HashMap<String, Id>,
95    recordings: HashMap<String, Id>,
96    works: HashMap<String, Id>,
97    credits: HashSet<CreditKey>,
98    release_genres: HashSet<(Id, Id)>,
99}
100
101/// Everything one file contributes, resolved before the entities are wired up.
102struct FileEntities {
103    file_id: Id,
104    artist_ids: Vec<Id>,
105    album_artist_ids: Vec<Id>,
106    release_id: Option<Id>,
107    is_compilation: bool,
108    /// The disc the file's own folder announces, when it announces one, so
109    /// that a rip which put each disc in its own folder and left the tag
110    /// empty still knows which disc a track is on.
111    disc_from_folder: Option<u32>,
112}
113
114impl Builder {
115    fn new(roots: Vec<String>, scanned_at: u64, aliases: super::identity::Aliases) -> Builder {
116        Builder {
117            catalog: Catalog {
118                roots,
119                scanned_at,
120                ..Default::default()
121            },
122            aliases,
123            artists: HashMap::new(),
124            labels: HashMap::new(),
125            genres: HashMap::new(),
126            releases: HashMap::new(),
127            release_groups: HashMap::new(),
128            recordings: HashMap::new(),
129            works: HashMap::new(),
130            credits: HashSet::new(),
131            release_genres: HashSet::new(),
132        }
133    }
134
135    /// Turns one scanned file into a file row, a track, and all the entities
136    /// and links its tags imply.
137    fn add_file(&mut self, item: &ScannedFile) {
138        let entities = self.add_entities(item);
139        let track_id = self.add_track(item, &entities);
140        let recording_id = self.catalog.tracks[track_id as usize].recording_id;
141        self.add_works(item, recording_id);
142        self.add_credits(item, &entities, track_id);
143        self.add_labels(item, &entities);
144        self.add_genres(item, &entities, track_id);
145    }
146
147    /// Records the physical file and resolves the artists and the release.
148    fn add_entities(&mut self, item: &ScannedFile) -> FileEntities {
149        let tags = &item.tags;
150        let file_id = self.catalog.files.len() as Id;
151        let file = AudioFile {
152            id: file_id,
153            path: item.path.clone(),
154            size: item.size,
155            mtime: item.mtime,
156            properties: tags.properties.clone(),
157            has_embedded_art: tags.has_embedded_art,
158            tags: tags.fields.clone(),
159            lyrics_path: item.sidecar.clone(),
160            integrity: item.integrity.clone(),
161            fingerprint: item.fingerprint.clone(),
162        };
163        // Where the *release* lives, which is not always where the file does.
164        // A box set laid out as `Album/Disc 1`, `Album/Disc 2` is one album:
165        // the disc folder is a subdivision of the release, not another edition
166        // of it, and keying the release on it split one soundtrack into two
167        // albums of the same name each numbering its tracks from one.
168        let file_folder = file.folder().to_string();
169        let disc_from_folder = text::disc_folder(text::file_name(&file_folder));
170        let folder = match disc_from_folder {
171            Some(_) => text::folder(&file_folder).to_string(),
172            None => file_folder,
173        };
174        self.catalog.files.push(file);
175
176        let artist_names = credited(tags, "artists", "artist");
177        let album_artist_names = credited(tags, "albumartists", "albumartist");
178
179        let artist_ids: Vec<Id> = artist_names
180            .iter()
181            .map(|n| {
182                let id = self.intern_artist(n);
183                self.name_artist(id, n);
184                id
185            })
186            .collect();
187        let album_artist_ids: Vec<Id> = album_artist_names
188            .iter()
189            .filter(|n| !is_various_artists(n))
190            .map(|n| {
191                let id = self.intern_artist(n);
192                self.name_artist(id, n);
193                id
194            })
195            .collect();
196
197        // MBIDs encountered along the way enrich the artist they name — both
198        // of them, since an album artist's identifier used to be read by
199        // nothing at all and is exactly as good as a track artist's.
200        for (ids, tag) in [
201            (&artist_ids, "musicbrainz_artistid"),
202            (&album_artist_ids, "musicbrainz_albumartistid"),
203        ] {
204            // One name and one identifier, or nothing — the same rule
205            // `named_once` applies, and for the same reason: with two of
206            // either, position is not evidence of which belongs to which.
207            let (Some(mbid), [id]) = (only_one(tags.all(tag)), ids.as_slice()) else {
208                continue;
209            };
210            if let Some(artist) = self.catalog.artists.get_mut(*id as usize) {
211                artist.mbid.get_or_insert_with(|| mbid.to_string());
212            }
213        }
214
215        let is_compilation = tags.first("compilation").is_some_and(|value| {
216            matches!(
217                value.trim().to_ascii_lowercase().as_str(),
218                "1" | "true" | "yes"
219            )
220        }) || album_artist_names.iter().any(|n| is_various_artists(n));
221        let release_id = self.release_for(
222            item,
223            &folder,
224            &artist_ids,
225            &album_artist_ids,
226            is_compilation,
227        );
228
229        FileEntities {
230            file_id,
231            artist_ids,
232            album_artist_ids,
233            release_id,
234            is_compilation,
235            disc_from_folder,
236        }
237    }
238
239    /// Finds or creates the release this file belongs to.
240    fn release_for(
241        &mut self,
242        item: &ScannedFile,
243        folder: &str,
244        artist_ids: &[Id],
245        album_artist_ids: &[Id],
246        is_compilation: bool,
247    ) -> Option<Id> {
248        let tags = &item.tags;
249        let title = tags.first("album").map(|s| s.trim().to_string())?;
250
251        let album_artist = if is_compilation {
252            // A compilation has no album artist: that is what distinguishes it
253            // from an ordinary album.
254            None
255        } else {
256            // With no declared album artist, fall back to the track artist.
257            album_artist_ids
258                .first()
259                .copied()
260                .or_else(|| artist_ids.first().copied())
261        };
262
263        let owner = if is_compilation {
264            "__va__".to_string()
265        } else {
266            album_artist
267                .and_then(|id| self.catalog.artist(id).map(|a| a.key.clone()))
268                .unwrap_or_default()
269        };
270        // The folder separates two same-named albums by the same artist, for
271        // instance two editions of one record.
272        let key = format!("{}|{}|{}", text::normalize(&title), owner, folder);
273
274        if let Some(&id) = self.releases.get(&key) {
275            // A field missing from the first track may show up on another one.
276            let missing_group = self
277                .catalog
278                .releases
279                .get(id as usize)
280                .filter(|release| release.release_group_id.is_none())
281                .map(|release| release.title.clone());
282            if let Some(release) = self.catalog.releases.get_mut(id as usize) {
283                if release.cover_path.is_none() {
284                    release.cover_path = item.folder_cover.clone();
285                }
286                if release.catalog_number.is_none() {
287                    release.catalog_number = tags.first("catalognumber").map(String::from);
288                }
289                if release.mbid.is_none() {
290                    release.mbid = tags.first("musicbrainz_albumid").map(String::from);
291                }
292            }
293            if let Some(title) = missing_group
294                && let Some(group_id) =
295                    self.release_group_for(&title, tags.first("musicbrainz_releasegroupid"))
296            {
297                self.catalog.releases[id as usize].release_group_id = Some(group_id);
298                self.catalog.releases[id as usize].release_group_mbid =
299                    Some(self.catalog.release_groups[group_id as usize].mbid.clone());
300                self.catalog.release_groups[group_id as usize]
301                    .release_ids
302                    .push(id);
303            }
304            return Some(id);
305        }
306
307        let id = self.catalog.releases.len() as Id;
308        let release_group_mbid = tags.first("musicbrainz_releasegroupid").map(String::from);
309        let release_group_id = self.release_group_for(&title, release_group_mbid.as_deref());
310        self.catalog.releases.push(Release {
311            id,
312            key: text::normalize(&title),
313            title,
314            album_artist_id: album_artist,
315            date: None,
316            year: None,
317            label_ids: Vec::new(),
318            catalog_number: tags.first("catalognumber").map(String::from),
319            barcode: tags.first("barcode").map(String::from),
320            media: tags.first("media").map(String::from),
321            mbid: tags.first("musicbrainz_albumid").map(String::from),
322            release_group_mbid,
323            release_group_id,
324            is_compilation,
325            folder: folder.to_string(),
326            cover_path: item.folder_cover.clone(),
327            track_ids: Vec::new(),
328        });
329        if let Some(group_id) = release_group_id {
330            self.catalog.release_groups[group_id as usize]
331                .release_ids
332                .push(id);
333        }
334        self.releases.insert(key, id);
335        Some(id)
336    }
337
338    /// Resolves a release group only from its explicit MusicBrainz identifier.
339    fn release_group_for(&mut self, title: &str, mbid: Option<&str>) -> Option<Id> {
340        let mbid = mbid?.trim();
341        if mbid.is_empty() {
342            return None;
343        }
344        if let Some(&id) = self.release_groups.get(mbid) {
345            return Some(id);
346        }
347        let id = self.catalog.release_groups.len() as Id;
348        self.catalog.release_groups.push(ReleaseGroup {
349            id,
350            title: title.to_string(),
351            key: text::normalize(title),
352            mbid: mbid.to_string(),
353            release_ids: Vec::new(),
354        });
355        self.release_groups.insert(mbid.to_string(), id);
356        Some(id)
357    }
358
359    /// Creates the track and attaches it to its release.
360    fn add_track(&mut self, item: &ScannedFile, entities: &FileEntities) -> Id {
361        let tags = &item.tags;
362        let title = tags
363            .first("title")
364            .map(|s| s.trim().to_string())
365            .unwrap_or_else(|| title_from_filename(&item.path));
366
367        let (track_no, _) = tags
368            .first("tracknumber")
369            .map(text::parse_track_number)
370            .unwrap_or((None, None));
371        let (disc_no, _) = tags
372            .first("discnumber")
373            .map(text::parse_track_number)
374            .unwrap_or((None, None));
375        // A rip that put each disc in its own folder and left the tag empty is
376        // common, and without this the two discs would both be disc one — two
377        // track 1s in one release, which is worse than the split it replaces.
378        // The tag still wins where there is one: it is what the person who
379        // made the file said.
380        let disc_no = disc_no.or(entities.disc_from_folder);
381
382        let recording_id = self.recording_for(item, &title);
383        let track_id = self.catalog.tracks.len() as Id;
384        self.catalog.tracks.push(Track {
385            id: track_id,
386            file_id: entities.file_id,
387            release_id: entities.release_id,
388            recording_id,
389            title,
390            disc_no,
391            track_no: track_no.or_else(|| track_from_filename(&item.path)),
392            duration_ms: tags.properties.duration_ms,
393            isrc: tags.first("isrc").map(String::from),
394            mbid: tags.first("musicbrainz_recordingid").map(String::from),
395        });
396        self.catalog.recordings[recording_id as usize]
397            .track_ids
398            .push(track_id);
399
400        if let Some(rid) = entities.release_id
401            && let Some(release) = self.catalog.releases.get_mut(rid as usize)
402        {
403            release.track_ids.push(track_id);
404        }
405        track_id
406    }
407
408    /// Finds the recorded performance this placement carries, or gives an
409    /// un-identified placement a recording of its own. A shared title is not
410    /// enough to merge music: only an explicit MusicBrainz recording ID is.
411    fn recording_for(&mut self, item: &ScannedFile, title: &str) -> Id {
412        let mbid = item
413            .tags
414            .first("musicbrainz_recordingid")
415            .map(str::to_owned);
416        let key = match &mbid {
417            Some(id) => format!("mbid:{id}"),
418            None => format!("local:{}", item.path),
419        };
420        if let Some(&id) = self.recordings.get(&key) {
421            return id;
422        }
423        let id = self.catalog.recordings.len() as Id;
424        self.catalog.recordings.push(Recording {
425            id,
426            title: title.to_string(),
427            key: text::normalize(title),
428            isrc: item.tags.first("isrc").map(String::from),
429            mbid,
430            track_ids: Vec::new(),
431            work_ids: Vec::new(),
432        });
433        self.recordings.insert(key, id);
434        id
435    }
436
437    /// Connects one recording to every explicitly identified work its tags
438    /// name. Several works are allowed: a medley is not forced into one title.
439    fn add_works(&mut self, item: &ScannedFile, recording_id: Id) {
440        for (position, mbid) in item.tags.all("musicbrainz_workid").iter().enumerate() {
441            let mbid = mbid.trim();
442            if mbid.is_empty() {
443                continue;
444            }
445            let work_id = match self.works.get(mbid) {
446                Some(&id) => id,
447                None => {
448                    let title = item
449                        .tags
450                        .all("grouping")
451                        .get(position)
452                        .map(String::as_str)
453                        .or_else(|| item.tags.first("grouping"))
454                        .unwrap_or(&self.catalog.recordings[recording_id as usize].title);
455                    let id = self.catalog.works.len() as Id;
456                    self.catalog.works.push(Work {
457                        id,
458                        title: title.to_string(),
459                        key: text::normalize(title),
460                        mbid: mbid.to_string(),
461                        recording_ids: Vec::new(),
462                    });
463                    self.works.insert(mbid.to_string(), id);
464                    id
465                }
466            };
467            let recording = &mut self.catalog.recordings[recording_id as usize];
468            if !recording.work_ids.contains(&work_id) {
469                recording.work_ids.push(work_id);
470                self.catalog.works[work_id as usize]
471                    .recording_ids
472                    .push(recording_id);
473            }
474        }
475    }
476
477    /// Records who did what on this track, and who signs the album.
478    fn add_credits(&mut self, item: &ScannedFile, entities: &FileEntities, track_id: Id) {
479        for &artist_id in &entities.artist_ids {
480            self.push_credit(artist_id, EntityKind::Track, track_id, "main");
481        }
482        for role in ROLE_TAGS {
483            // The whole tag at once, not one value at a time: whether a value
484            // restates the others can only be asked of the list it belongs to,
485            // and `PERFORMER` is where a collaboration is habitually written
486            // out twice — once per musician, then once as the joint credit.
487            for name in credited_under(&item.tags, role) {
488                let id = self.intern_artist(&name);
489                self.push_credit(id, EntityKind::Track, track_id, role);
490            }
491        }
492        // Picard and the Vorbis Comment convention encode instruments in the
493        // field name itself: `PERFORMER:guitar=Name`. Preserve the instrument
494        // on the relationship instead of flattening every such row into an
495        // indistinguishable performer credit.
496        for key in item
497            .tags
498            .fields
499            .keys()
500            .filter(|key| key.starts_with("performer:") && key.len() > "performer:".len())
501        {
502            let instrument = key["performer:".len()..].trim();
503            for (order, name) in credited_under(&item.tags, key).into_iter().enumerate() {
504                let id = self.intern_artist(&name);
505                let credited_as = self
506                    .catalog
507                    .artist(id)
508                    .filter(|artist| artist.name != name)
509                    .map(|_| name.clone());
510                self.push_credit_details(
511                    id,
512                    EntityKind::Track,
513                    track_id,
514                    "performer",
515                    credited_as,
516                    vec![super::CreditAttribute {
517                        id: None,
518                        name: instrument.to_string(),
519                        value: None,
520                        credited_as: None,
521                    }],
522                    Some(order as u32),
523                );
524            }
525        }
526        if let Some(rid) = entities.release_id
527            && !entities.is_compilation
528        {
529            for &artist_id in &entities.album_artist_ids {
530                self.push_credit(artist_id, EntityKind::Release, rid, "album");
531            }
532        }
533        if let Some(rid) = entities.release_id {
534            for &(tag, role) in ALBUM_ROLE_TAGS {
535                for name in credited_under(&item.tags, tag) {
536                    let id = self.intern_artist(&name);
537                    self.push_credit(id, EntityKind::Release, rid, role);
538                }
539            }
540        }
541    }
542
543    fn add_labels(&mut self, item: &ScannedFile, entities: &FileEntities) {
544        let Some(rid) = entities.release_id else {
545            return;
546        };
547        for (position, value) in item.tags.all("label").iter().enumerate() {
548            let mbid = item
549                .tags
550                .all("musicbrainz_labelid")
551                .get(position)
552                .map(String::as_str);
553            let label_id = self.intern_label(value, mbid);
554            if let Some(release) = self.catalog.releases.get_mut(rid as usize)
555                && !release.label_ids.contains(&label_id)
556            {
557                release.label_ids.push(label_id);
558            }
559        }
560    }
561
562    /// Attaches genres to the track, and to its release the first time round.
563    fn add_genres(&mut self, item: &ScannedFile, entities: &FileEntities, track_id: Id) {
564        let mut track_genres = HashSet::new();
565        for value in item.tags.all("genre") {
566            for name in value.split(';').map(str::trim).filter(|s| !s.is_empty()) {
567                let genre_id = self.intern_genre(name);
568                if !track_genres.insert(genre_id) {
569                    continue;
570                }
571                self.catalog.genre_links.push(GenreLink {
572                    genre_id,
573                    entity_kind: EntityKind::Track,
574                    entity_id: track_id,
575                });
576                if let Some(rid) = entities.release_id
577                    && self.release_genres.insert((genre_id, rid))
578                {
579                    self.catalog.genre_links.push(GenreLink {
580                        genre_id,
581                        entity_kind: EntityKind::Release,
582                        entity_id: rid,
583                    });
584                }
585            }
586        }
587    }
588
589    /// The artist this spelling belongs to, creating them the first time.
590    ///
591    /// A spelling MusicBrainz places under another one is filed there, and the
592    /// row is **named for the spelling it is filed under** rather than for
593    /// whichever file was read first: the key and the displayed name have to
594    /// agree, or a listing shows `O. Osbourne` and a query for `ozzy osbourne`
595    /// finds it, which is worse than either alone.
596    fn intern_artist(&mut self, name: &str) -> Id {
597        let spelling = text::normalize(name);
598        let key = super::identity::filed_as(&self.aliases, &spelling).to_string();
599        if let Some(&id) = self.artists.get(&key) {
600            return id;
601        }
602        // `name` is one of the spellings; the surviving key is another. Naming
603        // the row from the key would print a normalised string — lower case,
604        // no punctuation — where a reader expects a name, so the *first file
605        // carrying the surviving spelling* supplies it. Until one is read, the
606        // spelling in hand stands in, and is replaced below.
607        let display = match key == spelling {
608            true => name.trim().to_string(),
609            false => key.clone(),
610        };
611        // The other spellings that were filed here, kept so the merge can be
612        // seen: two rows becoming one is a decision, and a reader who cannot
613        // see it cannot tell it from a folder that was never scanned.
614        let mut aliases: Vec<String> = self
615            .aliases
616            .iter()
617            .filter(|(_, under)| **under == key)
618            .map(|(spelling, _)| spelling.clone())
619            .collect();
620        aliases.sort();
621        let id = self.catalog.artists.len() as Id;
622        self.catalog.artists.push(Artist {
623            id,
624            name: display.clone(),
625            sort_name: text::sort_name(&display),
626            key: key.clone(),
627            mbid: None,
628            aliases,
629        });
630        self.artists.insert(key, id);
631        id
632    }
633
634    /// Gives an artist the spelling their surviving key was taken from.
635    ///
636    /// Called with every name read, and it only ever acts once: the row keeps
637    /// the first *unnormalised* form of the winning spelling that the walk
638    /// meets. Without it a merged artist would be displayed by their key, which
639    /// is a matching form and not a name.
640    fn name_artist(&mut self, id: Id, name: &str) {
641        let Some(artist) = self.catalog.artists.get_mut(id as usize) else {
642            return;
643        };
644        if artist.name != artist.key || text::normalize(name) != artist.key {
645            return;
646        }
647        artist.name = name.trim().to_string();
648        artist.sort_name = text::sort_name(name.trim());
649    }
650
651    fn intern_label(&mut self, name: &str, mbid: Option<&str>) -> Id {
652        let key = text::normalize(name);
653        if let Some(&id) = self.labels.get(&key) {
654            if self.catalog.labels[id as usize].mbid.is_none() {
655                self.catalog.labels[id as usize].mbid = mbid.map(str::to_owned);
656            }
657            return id;
658        }
659        let id = self.catalog.labels.len() as Id;
660        self.catalog.labels.push(Label {
661            id,
662            name: name.trim().to_string(),
663            key: key.clone(),
664            mbid: mbid.map(str::to_owned),
665        });
666        self.labels.insert(key, id);
667        id
668    }
669
670    fn intern_genre(&mut self, name: &str) -> Id {
671        let key = text::normalize(name);
672        if let Some(&id) = self.genres.get(&key) {
673            return id;
674        }
675        let id = self.catalog.genres.len() as Id;
676        self.catalog.genres.push(Genre {
677            id,
678            name: name.trim().to_string(),
679            key: key.clone(),
680        });
681        self.genres.insert(key, id);
682        id
683    }
684
685    /// Adds a credit unless the same one is already recorded.
686    fn push_credit(&mut self, artist_id: Id, kind: EntityKind, id: Id, role: &str) {
687        self.push_credit_details(artist_id, kind, id, role, None, Vec::new(), None);
688    }
689
690    fn push_credit_details(
691        &mut self,
692        artist_id: Id,
693        kind: EntityKind,
694        id: Id,
695        role: &str,
696        credited_as: Option<String>,
697        attributes: Vec<super::CreditAttribute>,
698        order: Option<u32>,
699    ) {
700        let attribute_key = attributes
701            .iter()
702            .map(|attribute| {
703                format!(
704                    "{}\u{1f}{}\u{1f}{}",
705                    attribute.name,
706                    attribute.value.as_deref().unwrap_or(""),
707                    attribute.credited_as.as_deref().unwrap_or("")
708                )
709            })
710            .collect();
711        if self
712            .credits
713            .insert((artist_id, kind, id, role.to_string(), attribute_key))
714        {
715            self.catalog.credits.push(Credit {
716                artist_id,
717                entity_kind: kind,
718                entity_id: id,
719                role: role.to_string(),
720                credited_as,
721                attributes,
722                began: None,
723                ended: None,
724                order,
725                source: "tags".to_string(),
726                source_id: None,
727            });
728        }
729    }
730
731    fn finish(mut self) -> Catalog {
732        finalize(&mut self.catalog);
733        self.catalog
734    }
735}
736
737/// Tag names read as credits, each one giving its role its name.
738const ROLE_TAGS: &[&str] = &[
739    "arranger",
740    "composer",
741    "conductor",
742    "orchestra",
743    "choir",
744    "ensemble",
745    "soloist",
746    "writer",
747    "remixer",
748    "lyricist",
749    "performer",
750    "producer",
751    "engineer",
752    "mixer",
753    "djmixer",
754    "mastering_engineer",
755    "executive_producer",
756    "co_producer",
757    "mixing_engineer",
758    "recording_engineer",
759    "assistant_engineer",
760    "sound_engineer",
761    "editor",
762    "background_vocals",
763];
764
765/// Only explicitly album-scoped tags become edition credits. Track-level
766/// `PRODUCER` must never be promoted to the whole album by accident.
767const ALBUM_ROLE_TAGS: &[(&str, &str)] = &[
768    ("albumproducer", "producer"),
769    ("albumengineer", "engineer"),
770    ("albummixer", "mixer"),
771    ("albumarranger", "arranger"),
772    ("albummasteringengineer", "mastering_engineer"),
773];
774
775/// Key of a credit, used to reject duplicates in constant time.
776type CreditKey = (Id, EntityKind, Id, String, Vec<String>);
777
778/// The unambiguous (identifier, name) pairs one file states.
779///
780/// A file speaks only where it names **exactly one** artist and carries
781/// **exactly one** identifier for them. A tag naming two artists arrives as one
782/// string that `split_artists` cuts in two, while the identifiers arrive as
783/// their own list in an order nothing guarantees to match — pairing those by
784/// position would file an identifier against whichever name sorted first. Both
785/// the artist and the album artist are read, since either is evidence.
786fn named_once(item: &ScannedFile) -> Vec<super::identity::Said> {
787    let mut said = Vec::new();
788    for (plural, single, tag) in [
789        ("artists", "artist", "musicbrainz_artistid"),
790        ("albumartists", "albumartist", "musicbrainz_albumartistid"),
791    ] {
792        // Resolved exactly as the credit itself is, or the two would disagree
793        // about how many artists a file names — and this pre-pass would call a
794        // collaboration one artist while the walk called it two.
795        let names = credited(&item.tags, plural, single);
796        let (Some(mbid), [name]) = (only_one(item.tags.all(tag)), names.as_slice()) else {
797            continue;
798        };
799        if is_various_artists(name) {
800            continue;
801        }
802        said.push(super::identity::Said {
803            mbid: mbid.to_string(),
804            name: name.clone(),
805        });
806    }
807    said
808}
809
810/// The artists a file credits, from the tag written for the purpose.
811///
812/// **`ARTISTS` before `ARTIST`, and it is not a nicety.** `ARTIST` is one
813/// string holding however many names, and the conventions for joining them do
814/// not survive contact with a real library: `Rob Zombie & Ozzy Osbourne` is two
815/// artists, `Simon & Garfunkel` is one, and nothing in either string says
816/// which. `ARTISTS` is the tag taggers write with **one value per artist** for
817/// exactly this reason, and a file that carries it has already answered the
818/// question — no separator to guess at, no band name to shatter.
819///
820/// Each value still goes through [`text::split_artists`], because a tagger that
821/// writes `ARTISTS` as one joined value is not rare either, and splitting a
822/// value that holds one name gives that name back.
823fn credited(tags: &RawTags, plural: &str, single: &str) -> Vec<String> {
824    let named = credited_under(tags, plural);
825    match named.is_empty() {
826        false => named,
827        true => credited_under(tags, single),
828    }
829}
830
831/// Every artist one tag names, split and freed of the values that only restate
832/// the others.
833///
834/// The scope matters and is the whole point: [`text::without_restatements`]
835/// judges a name against the *rest of its own tag*, so the list has to be
836/// gathered before anything is dropped from it. See that function for why a
837/// joint credit sitting beside its members is a duplicate rather than a band.
838fn credited_under(tags: &RawTags, tag: &str) -> Vec<String> {
839    text::without_restatements(split_all(tags.all(tag)))
840}
841
842/// The only value there is, when there is exactly one.
843fn only_one(values: &[String]) -> Option<&str> {
844    match values {
845        [one] if !one.trim().is_empty() => Some(one.trim()),
846        _ => None,
847    }
848}
849
850fn split_all(values: &[String]) -> Vec<String> {
851    values.iter().flat_map(|v| text::split_artists(v)).collect()
852}
853
854/// `true` for the placeholder names that stand for "no album artist".
855///
856/// Recording them as artists would pollute every count and every listing.
857fn is_various_artists(name: &str) -> bool {
858    matches!(
859        text::normalize(name).as_str(),
860        "various artists" | "various" | "va" | "artistes divers" | "divers" | "multi interpretes"
861    )
862}
863
864/// Final pass: track ordering, inferred release dates, collaboration graph.
865fn finalize(catalog: &mut Catalog) {
866    // Order the tracks of each release.
867    let order: Vec<(Id, Vec<Id>)> = catalog
868        .releases
869        .iter()
870        .map(|release| {
871            let mut ids = release.track_ids.clone();
872            ids.sort_by_key(|&id| {
873                let t = &catalog.tracks[id as usize];
874                (
875                    t.disc_no.unwrap_or(1),
876                    t.track_no.unwrap_or(u32::MAX),
877                    t.title.clone(),
878                )
879            });
880            (release.id, ids)
881        })
882        .collect();
883    for (id, ids) in order {
884        catalog.releases[id as usize].track_ids = ids;
885    }
886
887    // Release year: the earliest one announced by its tracks.
888    let mut years: HashMap<Id, (Option<String>, Option<u32>)> = HashMap::new();
889    for track in &catalog.tracks {
890        let Some(rid) = track.release_id else {
891            continue;
892        };
893        let file = &catalog.files[track.file_id as usize];
894        let raw = file
895            .first_tag("originaldate")
896            .or_else(|| file.first_tag("date"))
897            .unwrap_or("");
898        if raw.is_empty() {
899            continue;
900        }
901        let year = text::extract_year(raw);
902        let entry = years.entry(rid).or_insert((None, None));
903        if entry.1.is_none() || (year.is_some() && year < entry.1) {
904            *entry = (Some(raw.to_string()), year);
905        }
906    }
907    for (rid, (date, year)) in years {
908        if let Some(release) = catalog.releases.get_mut(rid as usize) {
909            release.date = date;
910            release.year = year;
911        }
912    }
913
914    rebuild_relations(catalog);
915}
916
917/// Without a `title` tag, fall back to the file name, stripped of its leading
918/// track number and its extension.
919fn title_from_filename(path: &str) -> String {
920    let name = text::file_name(path);
921    let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(name);
922    let trimmed = stem
923        .trim_start_matches(|c: char| c.is_ascii_digit())
924        .trim_start_matches([' ', '-', '.', '_'])
925        .trim();
926    if trimmed.is_empty() {
927        stem.to_string()
928    } else {
929        trimmed.to_string()
930    }
931}
932
933/// Track number inferred from a file name that starts with digits.
934fn track_from_filename(path: &str) -> Option<u32> {
935    let name = text::file_name(path);
936    let digits: String = name.chars().take_while(|c| c.is_ascii_digit()).collect();
937    if digits.is_empty() || digits.len() > 3 {
938        return None;
939    }
940    digits.parse().ok().filter(|&n| n > 0)
941}
942
943#[cfg(test)]
944#[path = "builder_tests.rs"]
945mod tests;