1use 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#[derive(Debug, Clone)]
29pub struct ScannedFile {
30 pub path: String,
32 pub size: u64,
34 pub mtime: u64,
38 pub tags: RawTags,
40 pub folder_cover: Option<String>,
42 pub sidecar: Option<String>,
44 pub integrity: Option<IntegrityRecord>,
48 pub fingerprint: Option<crate::fingerprint::Fingerprint>,
52}
53
54pub 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 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
82struct Builder {
87 catalog: Catalog,
88 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
101struct 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 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 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 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 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 for (ids, tag) in [
201 (&artist_ids, "musicbrainz_artistid"),
202 (&album_artist_ids, "musicbrainz_albumartistid"),
203 ] {
204 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 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 None
255 } else {
256 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 let key = format!("{}|{}|{}", text::normalize(&title), owner, folder);
273
274 if let Some(&id) = self.releases.get(&key) {
275 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 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 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 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 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 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 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 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 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 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 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 let display = match key == spelling {
608 true => name.trim().to_string(),
609 false => key.clone(),
610 };
611 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 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 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
737const 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
765const ALBUM_ROLE_TAGS: &[(&str, &str)] = &[
768 ("albumproducer", "producer"),
769 ("albumengineer", "engineer"),
770 ("albummixer", "mixer"),
771 ("albumarranger", "arranger"),
772 ("albummasteringengineer", "mastering_engineer"),
773];
774
775type CreditKey = (Id, EntityKind, Id, String, Vec<String>);
777
778fn 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 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
810fn 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
831fn credited_under(tags: &RawTags, tag: &str) -> Vec<String> {
839 text::without_restatements(split_all(tags.all(tag)))
840}
841
842fn 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
854fn 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
864fn finalize(catalog: &mut Catalog) {
866 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 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
917fn 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
933fn 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;