1use std::collections::{BTreeMap, BTreeSet};
9
10use crate::model::{self, Catalog, Id};
11use crate::text;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub enum Severity {
19 Error,
21 Warning,
23 Info,
25}
26
27impl Severity {
28 pub fn label(self) -> &'static str {
30 match self {
31 Severity::Error => "error",
32 Severity::Warning => "warning",
33 Severity::Info => "info",
34 }
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub enum IssueKind {
41 MissingTitle,
43 MissingArtist,
45 MissingAlbum,
47 MissingDate,
49 MissingTrackNumber,
51 MissingDuration,
53 DuplicateTrack,
55 SameAudio,
62 DuplicateAlbum,
64 OtherEdition,
66 Md5Mismatch,
68 IncompleteAlbum,
71 MixedQuality,
74 MissingCover,
76 SuspiciousYear,
78 DamagedAudio,
81 SameArtistMaybe,
91 SourceDisagrees,
99 SourcesDisagree,
101 SourceNeedsReview,
103 SourceIdentityConflict,
105 IncompleteSourceCredits,
108 OrphanedRelationAnnotation,
110}
111
112impl IssueKind {
113 pub fn severity(self) -> Severity {
115 match self {
116 IssueKind::MissingTitle
117 | IssueKind::MissingArtist
118 | IssueKind::MissingDuration
119 | IssueKind::DamagedAudio
120 | IssueKind::Md5Mismatch => Severity::Error,
121 IssueKind::MissingAlbum
122 | IssueKind::DuplicateTrack
123 | IssueKind::SameAudio
124 | IssueKind::DuplicateAlbum
125 | IssueKind::IncompleteAlbum
126 | IssueKind::MixedQuality
127 | IssueKind::SuspiciousYear => Severity::Warning,
128 IssueKind::MissingDate
129 | IssueKind::MissingTrackNumber
130 | IssueKind::MissingCover
131 | IssueKind::SourceDisagrees
132 | IssueKind::SourcesDisagree
133 | IssueKind::SourceNeedsReview
134 | IssueKind::SourceIdentityConflict
135 | IssueKind::IncompleteSourceCredits
136 | IssueKind::OrphanedRelationAnnotation
137 | IssueKind::SameArtistMaybe
138 | IssueKind::OtherEdition => Severity::Info,
139 }
140 }
141
142 pub fn label(self) -> &'static str {
144 match self {
145 IssueKind::SourceDisagrees => "source disagrees",
146 IssueKind::SourcesDisagree => "sources disagree",
147 IssueKind::SourceNeedsReview => "source claim needs review",
148 IssueKind::SourceIdentityConflict => "source identity conflict",
149 IssueKind::IncompleteSourceCredits => "source credits incomplete",
150 IssueKind::OrphanedRelationAnnotation => "relation annotation is waiting",
151 IssueKind::SameArtistMaybe => "possibly one artist",
155 IssueKind::MissingTitle => "missing title",
156 IssueKind::MissingArtist => "missing artist",
157 IssueKind::MissingAlbum => "missing album",
158 IssueKind::MissingDate => "missing year",
159 IssueKind::MissingTrackNumber => "missing track number",
160 IssueKind::MissingDuration => "unreadable duration",
161 IssueKind::DuplicateTrack => "likely duplicate",
162 IssueKind::SameAudio => "the same audio",
167 IssueKind::DuplicateAlbum => "album present twice",
168 IssueKind::OtherEdition => "album kept in two encodings",
169 IssueKind::Md5Mismatch => "audio does not match its MD5",
170 IssueKind::IncompleteAlbum => "incomplete album",
171 IssueKind::MixedQuality => "mixed quality within an album",
172 IssueKind::MissingCover => "missing cover art",
173 IssueKind::SuspiciousYear => "implausible year",
174 IssueKind::DamagedAudio => "damaged audio",
175 }
176 }
177}
178
179fn announced(file: &crate::model::AudioFile, field: &str, numbered: &str) -> Option<u32> {
193 let leading = |value: &str| {
194 let digits: String = value
195 .trim()
196 .chars()
197 .take_while(|c| c.is_ascii_digit())
198 .collect();
199 digits.parse::<u32>().ok()
200 };
201 let from_pair = file
202 .first_tag(numbered)
203 .and_then(|value| value.split_once('/').map(|(_, total)| total.to_string()))
204 .and_then(|total| leading(&total));
205 let from_field = file.first_tag(field).and_then(leading);
206 from_pair.into_iter().chain(from_field).max()
207}
208
209fn announced_total(file: &crate::model::AudioFile) -> Option<u32> {
211 announced(file, "tracktotal", "tracknumber")
212}
213
214fn announced_discs(file: &crate::model::AudioFile) -> Option<u32> {
216 announced(file, "disctotal", "discnumber")
217}
218
219#[derive(Debug, Clone)]
221pub struct Issue {
222 pub kind: IssueKind,
224 pub detail: String,
226 pub files: Vec<String>,
228}
229
230impl Issue {
231 pub fn severity(&self) -> Severity {
233 self.kind.severity()
234 }
235}
236
237pub fn diagnose(catalog: &Catalog, sources: &crate::sources::Sources) -> Vec<Issue> {
240 let mut issues = Vec::new();
241 check_tracks(catalog, &mut issues);
242 check_integrity(catalog, &mut issues);
243 check_imported_analyses(catalog, &mut issues);
244 check_source_quality(catalog, sources, &mut issues);
245 check_sources(catalog, sources, &mut issues);
246 check_duplicate_albums(catalog, &mut issues);
247 check_other_editions(catalog, &mut issues);
248 check_duplicates(catalog, &mut issues);
249 check_same_audio(catalog, &mut issues);
250 check_releases(catalog, &mut issues);
251 check_same_person(catalog, &mut issues);
252
253 issues.sort_by(|a, b| {
254 a.severity()
255 .cmp(&b.severity())
256 .then_with(|| a.kind.cmp(&b.kind))
257 .then_with(|| a.files.cmp(&b.files))
258 });
259 issues
260}
261
262pub fn orphaned_relation_annotations(
269 catalog: &Catalog,
270 sources: &crate::sources::Sources,
271 user: &crate::user::UserData,
272) -> Vec<Issue> {
273 let current = crate::graph::edges(catalog, sources)
274 .into_iter()
275 .map(|edge| edge.reference)
276 .collect::<BTreeSet<_>>();
277 user.relation_annotations
278 .iter()
279 .filter(|annotation| {
280 !current.contains(&annotation.relation)
281 && (!annotation.relation.kind.starts_with("credit:")
282 || !current.iter().any(|relation| {
283 crate::user::same_annotation_relation(catalog, &annotation.relation, relation)
284 }))
285 })
286 .map(|annotation| Issue {
287 kind: IssueKind::OrphanedRelationAnnotation,
288 detail: format!(
289 "{} → {} → {} is not in the current graph; it is kept — aede relation {} --remove drops your annotation",
290 annotation.relation.source.to_token(),
291 annotation.relation.kind,
292 annotation.relation.target.to_token(),
293 annotation.relation.id()
294 ),
295 files: Vec::new(),
296 })
297 .collect()
298}
299
300pub fn summary(issues: &[Issue]) -> BTreeMap<Severity, usize> {
302 let mut out = BTreeMap::new();
303 for issue in issues {
304 *out.entry(issue.severity()).or_insert(0) += 1;
305 }
306 out
307}
308
309fn check_tracks(catalog: &Catalog, issues: &mut Vec<Issue>) {
310 for track in &catalog.tracks {
311 let Some(file) = catalog.file(track.file_id) else {
312 continue;
313 };
314 let path = file.path.clone();
315
316 if file.first_tag("title").is_none() {
317 issues.push(Issue {
318 kind: IssueKind::MissingTitle,
319 detail: format!("title inferred from the file name: \"{}\"", track.title),
320 files: vec![path.clone()],
321 });
322 }
323 if file
324 .first_tag("artists")
325 .or_else(|| file.first_tag("artist"))
326 .is_none()
327 {
328 issues.push(Issue {
329 kind: IssueKind::MissingArtist,
330 detail: "no artist in the tags".into(),
331 files: vec![path.clone()],
332 });
333 }
334 if file.first_tag("album").is_none() {
335 issues.push(Issue {
336 kind: IssueKind::MissingAlbum,
337 detail: "track cannot be attached to an album".into(),
338 files: vec![path.clone()],
339 });
340 }
341 if track.duration_ms.is_none() || track.duration_ms == Some(0) {
342 issues.push(Issue {
343 kind: IssueKind::MissingDuration,
344 detail: "duration not determined: file possibly corrupted".into(),
345 files: vec![path.clone()],
346 });
347 }
348 if track.track_no.is_none() {
349 issues.push(Issue {
350 kind: IssueKind::MissingTrackNumber,
351 detail: "playback order not guaranteed within the album".into(),
352 files: vec![path.clone()],
353 });
354 }
355
356 let year = file
357 .first_tag("originaldate")
358 .or_else(|| file.first_tag("date"))
359 .and_then(text::extract_year);
360 match year {
361 None => issues.push(Issue {
362 kind: IssueKind::MissingDate,
363 detail: "no usable year".into(),
364 files: vec![path],
365 }),
366 Some(year) if !(1860..=2100).contains(&year) => issues.push(Issue {
367 kind: IssueKind::SuspiciousYear,
368 detail: format!("year announced: {year}"),
369 files: vec![path],
370 }),
371 Some(_) => {}
372 }
373 }
374}
375
376fn check_integrity(catalog: &Catalog, issues: &mut Vec<Issue>) {
382 for file in &catalog.files {
383 if let Some(record) = &file.integrity
384 && let crate::audit::integrity::Verdict::Damaged { detail } = &record.verdict
385 {
386 issues.push(Issue {
387 kind: IssueKind::DamagedAudio,
388 detail: format!("{detail} — restore from a backup or rip again"),
389 files: vec![file.path.clone()],
390 });
391 }
392 }
393}
394
395fn check_source_quality(
415 catalog: &Catalog,
416 sources: &crate::sources::Sources,
417 issues: &mut Vec<Issue>,
418) {
419 use crate::sources::{Facts, ReviewReason};
420
421 for item in sources
422 .review_items(catalog)
423 .into_iter()
424 .filter(|item| item.decision.is_none())
425 {
426 let (kind, detail) = match item.reason {
427 ReviewReason::Approximate { score } => (
428 IssueKind::SourceNeedsReview,
429 format!(
430 "{} {}: {} proposed {} at {score}% — aede review --accept={} or --reject={}",
431 item.entity.kind.as_str(),
432 item.entity.key,
433 item.source,
434 item.source_id
435 .as_deref()
436 .unwrap_or("an unidentified candidate"),
437 item.id,
438 item.id
439 ),
440 ),
441 ReviewReason::IdentityConflict {
442 local_id,
443 sourced_id,
444 } => (
445 IssueKind::SourceIdentityConflict,
446 format!(
447 "{} {}: local identity {local_id}, {} says {sourced_id} — \
448 aede review --accept={} or --reject={}",
449 item.entity.kind.as_str(),
450 item.entity.key,
451 item.source,
452 item.id,
453 item.id
454 ),
455 ),
456 ReviewReason::PriorDecision => continue,
457 };
458 issues.push(Issue {
459 kind,
460 files: files_for_entity(catalog, &item.entity),
461 detail,
462 });
463 }
464
465 let complete_recordings: BTreeSet<_> = crate::credit_coverage::recordings(catalog, sources)
470 .into_iter()
471 .filter(|coverage| {
472 matches!(
473 coverage.status,
474 crate::credit_coverage::CreditStatus::Empty
475 | crate::credit_coverage::CreditStatus::Credited
476 )
477 })
478 .map(|coverage| coverage.recording_id)
479 .collect();
480 let mut reported_recordings = BTreeSet::new();
481 let mut incomplete_files = Vec::new();
482 for record in &sources.records {
483 let Facts::Track(facts) = &record.facts else {
484 continue;
485 };
486 if !sources.is_trusted(catalog, record)
487 || facts.recording.is_none()
488 || facts.relationships_complete
489 {
490 continue;
491 }
492 let entity = record.entity();
493 let Some(track_id) = entity.resolve(catalog) else {
494 continue;
495 };
496 let Some(recording_id) = catalog.track(track_id).map(|track| track.recording_id) else {
497 continue;
498 };
499 if complete_recordings.contains(&recording_id) || !reported_recordings.insert(recording_id)
500 {
501 continue;
502 }
503 incomplete_files.extend(files_for_entity(catalog, &entity));
504 }
505 if !reported_recordings.is_empty() {
506 issues.push(Issue {
507 kind: IssueKind::IncompleteSourceCredits,
508 detail: format!(
509 "{} identified without a complete relationship pass — run aede fetch --credits",
510 match reported_recordings.len() {
511 1 => "1 recording is".to_string(),
512 count => format!("{count} recordings are"),
513 }
514 ),
515 files: incomplete_files,
516 });
517 }
518
519 check_source_disagreements(catalog, sources, issues);
520}
521
522fn files_for_entity(catalog: &Catalog, entity: &crate::user::EntityRef) -> Vec<String> {
523 let Some(id) = entity.resolve(catalog) else {
524 return Vec::new();
525 };
526 let track_ids: Vec<Id> = match entity.kind {
527 model::EntityKind::Track => vec![id],
528 model::EntityKind::Release => catalog
529 .release(id)
530 .map(|release| release.track_ids.clone())
531 .unwrap_or_default(),
532 model::EntityKind::Artist => catalog.tracks_of_artist(id),
533 model::EntityKind::Label => catalog.tracks_of_label(id),
534 _ => Vec::new(),
535 };
536 track_ids
537 .into_iter()
538 .filter_map(|track| catalog.track(track))
539 .filter_map(|track| catalog.file(track.file_id))
540 .map(|file| file.path.clone())
541 .take(1)
542 .collect()
543}
544
545fn check_source_disagreements(
546 catalog: &Catalog,
547 sources: &crate::sources::Sources,
548 issues: &mut Vec<Issue>,
549) {
550 use crate::sources::Facts;
551
552 let mut by_entity: BTreeMap<(model::EntityKind, String), Vec<&crate::sources::SourceRecord>> =
553 BTreeMap::new();
554 for record in &sources.records {
555 if sources.is_trusted(catalog, record) {
556 by_entity
557 .entry((record.facts.kind(), record.key.clone()))
558 .or_default()
559 .push(record);
560 }
561 }
562 for ((kind, key), records) in by_entity {
563 if records.len() < 2 {
564 continue;
565 }
566 let entity = crate::user::EntityRef {
567 kind,
568 key: key.clone(),
569 };
570 for left_index in 0..records.len() {
571 for right in records.iter().skip(left_index + 1) {
572 let left = records[left_index];
573 for (field, a, b) in conflicting_fields(&left.facts, &right.facts) {
574 issues.push(Issue {
575 kind: IssueKind::SourcesDisagree,
576 detail: format!(
577 "{} {key}: {} says {field} is \"{a}\", {} says \"{b}\"",
578 kind.as_str(),
579 left.source,
580 right.source
581 ),
582 files: files_for_entity(catalog, &entity),
583 });
584 }
585 }
586 }
587 }
588
589 fn conflicting_fields(left: &Facts, right: &Facts) -> Vec<(&'static str, String, String)> {
590 let mut out = Vec::new();
591 let compare = |out: &mut Vec<(&'static str, String, String)>,
592 field: &'static str,
593 a: Option<&str>,
594 b: Option<&str>| {
595 let (Some(a), Some(b)) = (a, b) else {
596 return;
597 };
598 if !a.trim().is_empty()
599 && !b.trim().is_empty()
600 && crate::text::normalize(a) != crate::text::normalize(b)
601 {
602 out.push((field, a.to_string(), b.to_string()));
603 }
604 };
605 match (left, right) {
606 (Facts::Artist(a), Facts::Artist(b)) => {
607 compare(&mut out, "type", a.kind.as_deref(), b.kind.as_deref());
608 compare(&mut out, "area", a.area.as_deref(), b.area.as_deref());
609 compare(
610 &mut out,
611 "begin date",
612 a.began.as_deref(),
613 b.began.as_deref(),
614 );
615 compare(&mut out, "end date", a.ended.as_deref(), b.ended.as_deref());
616 }
617 (Facts::Release(a), Facts::Release(b)) => {
618 compare(
619 &mut out,
620 "release type",
621 a.primary_type.as_deref(),
622 b.primary_type.as_deref(),
623 );
624 if let (Some(a), Some(b)) = (&a.first_released, &b.first_released)
625 && matches!(
626 crate::sources::verdict_date(a, Some(b)),
627 crate::sources::Verdict::Differs { .. }
628 )
629 {
630 out.push(("first release date", a.clone(), b.clone()));
631 }
632 compare(&mut out, "label", a.label.as_deref(), b.label.as_deref());
633 }
634 (Facts::Track(a), Facts::Track(b)) => {
635 compare(
636 &mut out,
637 "recording",
638 a.recording.as_deref(),
639 b.recording.as_deref(),
640 );
641 compare(&mut out, "title", a.title.as_deref(), b.title.as_deref());
642 compare(&mut out, "album", a.album.as_deref(), b.album.as_deref());
643 if !a.artists.is_empty()
644 && !b.artists.is_empty()
645 && !matches!(
646 crate::sources::verdict_set(&a.artists, &b.artists),
647 crate::sources::Verdict::Agrees
648 )
649 {
650 out.push(("artists", a.artists.join(", "), b.artists.join(", ")));
651 }
652 }
653 _ => {}
654 }
655 out
656 }
657}
658
659fn check_sources(catalog: &Catalog, sources: &crate::sources::Sources, issues: &mut Vec<Issue>) {
660 use crate::sources::{Facts, Verdict};
661 use crate::user::EntityRef;
662
663 for record in &sources.records {
664 if !sources.is_trusted(catalog, record) {
665 continue;
666 }
667 let Facts::Release(facts) = &record.facts else {
668 continue;
669 };
670 let entity: EntityRef = record.entity();
671 let Some(release) = entity.resolve(catalog).and_then(|id| catalog.release(id)) else {
672 continue;
673 };
674
675 let tag = |name: &str| -> Option<String> {
678 release
679 .track_ids
680 .first()
681 .and_then(|&t| catalog.track(t))
682 .and_then(|t| catalog.file(t.file_id))
683 .and_then(|f| f.first_tag(name).map(str::to_string))
684 };
685
686 let label_tag = release
687 .label_ids
688 .first()
689 .and_then(|&id| catalog.label(id))
690 .map(|l| l.name.clone());
691
692 let comparisons = [
693 (
694 "release type",
695 facts.primary_type.as_deref(),
696 tag("releasetype"),
697 false,
698 ),
699 (
700 "date",
701 facts.first_released.as_deref(),
702 release.date.clone(),
703 true,
704 ),
705 ("label", facts.label.as_deref(), label_tag, false),
706 ];
707
708 for (what, theirs, yours, is_date) in comparisons {
709 let Some(theirs) = theirs else { continue };
710 let verdict = match is_date {
711 true => crate::sources::verdict_date(theirs, yours.as_deref()),
712 false => crate::sources::verdict(theirs, yours.as_deref()),
713 };
714 let Verdict::Differs { theirs, yours } = verdict else {
715 continue;
716 };
717 issues.push(Issue {
718 kind: IssueKind::SourceDisagrees,
719 detail: format!(
720 "\"{}\": {} says the {what} is \"{theirs}\", the tags say \"{yours}\"",
721 release.title, record.source
722 ),
723 files: release
724 .track_ids
725 .iter()
726 .filter_map(|&t| catalog.track(t))
727 .filter_map(|t| catalog.file(t.file_id))
728 .map(|f| f.path.clone())
729 .take(1)
730 .collect(),
731 });
732 }
733 }
734}
735
736fn check_imported_analyses(catalog: &Catalog, issues: &mut Vec<Issue>) {
737 let files: BTreeMap<&str, &model::AudioFile> =
738 catalog.files.iter().map(|f| (f.path.as_str(), f)).collect();
739 for record in &catalog.analyses {
740 let Some(file) = files.get(record.path.as_str()) else {
743 continue;
744 };
745 if !record.still_applies(file.size, file.mtime) {
748 continue;
749 }
750
751 if record.md5_failed() {
752 let contradiction = matches!(
753 file.integrity.as_ref().map(|r| &r.verdict),
754 Some(crate::audit::integrity::Verdict::Intact)
755 );
756 let detail = if contradiction {
757 format!(
758 "{} decoded the audio and it does not match the file's own MD5, \
759 although the frame checksums are valid: the stream was re-encoded",
760 record.source
761 )
762 } else {
763 format!(
764 "{} found the audio does not match the file's own MD5",
765 record.source
766 )
767 };
768 issues.push(Issue {
769 kind: IssueKind::Md5Mismatch,
770 detail,
771 files: vec![file.path.clone()],
772 });
773 }
774
775 }
780}
781
782fn check_duplicate_albums(catalog: &Catalog, issues: &mut Vec<Issue>) {
787 let mut seen: std::collections::BTreeSet<(Id, Id)> = Default::default();
788 for relation in &catalog.relations {
789 if relation.source_kind == model::EntityKind::Release
790 && relation.target_kind == model::EntityKind::Release
791 && relation.kind == model::DUPLICATE
792 {
793 let Some(release) = catalog.release(relation.source_id) else {
794 continue;
795 };
796 let other_id = relation.target_id;
797 let pair = (release.id.min(other_id), release.id.max(other_id));
798 if !seen.insert(pair) {
799 continue;
800 }
801 let Some(other) = catalog.release(other_id) else {
802 continue;
803 };
804 let wasted = release_size(catalog, release.id).min(release_size(catalog, other_id));
807 issues.push(Issue {
808 kind: IssueKind::DuplicateAlbum,
809 detail: format!(
810 "\"{}\" is present twice in the same quality ({} recoverable)",
811 release.title,
812 text::format_size(wasted)
813 ),
814 files: vec![release.folder.clone(), other.folder.clone()],
815 });
816 }
817 }
818}
819
820fn covered_by_the_album(catalog: &Catalog, cluster: &[(Id, u64)]) -> bool {
827 let releases: Vec<Id> = cluster
828 .iter()
829 .filter_map(|&(id, _)| catalog.track(id))
830 .filter_map(|t| t.release_id)
831 .collect();
832 if releases.len() != cluster.len() || releases.len() < 2 {
833 return false;
834 }
835 let first = releases[0];
836 let linked: std::collections::BTreeSet<Id> = catalog
837 .related_releases(first, model::DUPLICATE)
838 .into_iter()
839 .chain(catalog.related_releases(first, model::OTHER_EDITION))
840 .collect();
841 releases
842 .iter()
843 .skip(1)
844 .all(|other| *other == first || linked.contains(other))
845}
846
847fn check_other_editions(catalog: &Catalog, issues: &mut Vec<Issue>) {
851 let mut seen: std::collections::BTreeSet<(Id, Id)> = Default::default();
852 for relation in &catalog.relations {
853 if relation.source_kind == model::EntityKind::Release
854 && relation.target_kind == model::EntityKind::Release
855 && relation.kind == model::OTHER_EDITION
856 {
857 let Some(release) = catalog.release(relation.source_id) else {
858 continue;
859 };
860 let other_id = relation.target_id;
861 let pair = (release.id.min(other_id), release.id.max(other_id));
862 if !seen.insert(pair) {
863 continue;
864 }
865 let Some(other) = catalog.release(other_id) else {
866 continue;
867 };
868 issues.push(Issue {
869 kind: IssueKind::OtherEdition,
870 detail: format!(
871 "\"{}\" is kept in two encodings ({} and {})",
872 release.title,
873 quality_summary(catalog, release.id),
874 quality_summary(catalog, other_id)
875 ),
876 files: vec![release.folder.clone(), other.folder.clone()],
877 });
878 }
879 }
880}
881
882fn quality_summary(catalog: &Catalog, release_id: Id) -> String {
884 let labels: std::collections::BTreeSet<String> = catalog
885 .release(release_id)
886 .map(|r| {
887 r.track_ids
888 .iter()
889 .filter_map(|&id| catalog.track(id))
890 .filter_map(|t| catalog.file(t.file_id))
891 .map(|f| f.properties.quality_label())
892 .collect()
893 })
894 .unwrap_or_default();
895 labels.into_iter().collect::<Vec<_>>().join(", ")
896}
897
898fn release_size(catalog: &Catalog, release_id: Id) -> u64 {
899 catalog
900 .release(release_id)
901 .map(|r| {
902 r.track_ids
903 .iter()
904 .filter_map(|&id| catalog.track(id))
905 .filter_map(|t| catalog.file(t.file_id))
906 .map(|f| f.size)
907 .fold(0u64, u64::saturating_add)
908 })
909 .unwrap_or(0)
910}
911
912fn check_same_audio(catalog: &Catalog, issues: &mut Vec<Issue>) {
927 let mut groups: BTreeMap<&str, Vec<&crate::model::AudioFile>> = BTreeMap::new();
928 for file in &catalog.files {
929 if let Some(print) = &file.fingerprint {
930 groups.entry(print.data.as_str()).or_default().push(file);
931 }
932 }
933 for (_, files) in groups {
934 if files.len() < 2 {
935 continue;
936 }
937 let wasted = files
940 .iter()
941 .skip(1)
942 .map(|f| f.size)
943 .fold(0u64, u64::saturating_add);
944 issues.push(Issue {
945 kind: IssueKind::SameAudio,
946 detail: format!(
947 "{} files are the same recording ({} recoverable)",
948 files.len(),
949 text::format_size(wasted)
950 ),
951 files: files.iter().map(|f| f.path.clone()).collect(),
952 });
953 }
954}
955
956fn check_duplicates(catalog: &Catalog, issues: &mut Vec<Issue>) {
961 let mut groups: BTreeMap<(String, String), Vec<(Id, u64)>> = BTreeMap::new();
962
963 for track in &catalog.tracks {
964 let Some(file) = catalog.file(track.file_id) else {
965 continue;
966 };
967 let Some(duration) = track.duration_ms.filter(|duration| *duration > 0) else {
968 continue;
969 };
970 let artist = file.first_tag("artist").unwrap_or("");
971 let title = file.first_tag("title").unwrap_or("");
972 let key = (text::normalize(artist), text::normalize(title));
973 if key.0.is_empty() || key.1.is_empty() {
974 continue;
975 }
976 groups.entry(key).or_default().push((track.id, duration));
977 }
978
979 for ((artist, title), mut entries) in groups {
980 if entries.len() < 2 {
981 continue;
982 }
983 entries.sort_by_key(|&(_, duration)| duration);
984
985 let mut cluster: Vec<(Id, u64)> = vec![entries[0]];
988 let mut clusters: Vec<Vec<(Id, u64)>> = Vec::new();
989 for &entry in &entries[1..] {
990 let first = cluster.first().map(|&(_, d)| d).unwrap_or(0);
991 if entry.1.abs_diff(first) <= 3_000 {
992 cluster.push(entry);
993 } else {
994 clusters.push(std::mem::take(&mut cluster));
995 cluster = vec![entry];
996 }
997 }
998 clusters.push(cluster);
999
1000 for cluster in clusters.into_iter().filter(|c| c.len() > 1) {
1001 if covered_by_the_album(catalog, &cluster) {
1005 continue;
1006 }
1007 let files: Vec<String> = cluster
1008 .iter()
1009 .filter_map(|&(id, _)| catalog.track(id))
1010 .filter_map(|t| catalog.file(t.file_id))
1011 .map(|f| f.path.clone())
1012 .collect();
1013 let wasted: u64 = cluster
1014 .iter()
1015 .skip(1)
1016 .filter_map(|&(id, _)| catalog.track(id))
1017 .filter_map(|t| catalog.file(t.file_id))
1018 .map(|f| f.size)
1019 .fold(0u64, u64::saturating_add);
1020 issues.push(Issue {
1021 kind: IssueKind::DuplicateTrack,
1022 detail: format!(
1023 "\"{title}\" by \"{artist}\" present {} times ({} recoverable)",
1024 cluster.len(),
1025 text::format_size(wasted)
1026 ),
1027 files,
1028 });
1029 }
1030 }
1031}
1032
1033fn album_key(release: &model::Release) -> (String, Option<Id>) {
1039 (release.key.clone(), release.album_artist_id)
1040}
1041
1042fn discs_by_album(catalog: &Catalog) -> BTreeMap<(String, Option<Id>), BTreeSet<u32>> {
1044 let mut out: BTreeMap<(String, Option<Id>), BTreeSet<u32>> = BTreeMap::new();
1045 for release in &catalog.releases {
1046 let discs = out.entry(album_key(release)).or_default();
1047 for track in release.track_ids.iter().filter_map(|&id| catalog.track(id)) {
1048 discs.insert(track.disc_no.unwrap_or(1));
1049 }
1050 }
1051 out
1052}
1053
1054fn check_releases(catalog: &Catalog, issues: &mut Vec<Issue>) {
1055 let discs_elsewhere = discs_by_album(catalog);
1056 for release in &catalog.releases {
1057 let tracks: Vec<_> = release
1058 .track_ids
1059 .iter()
1060 .filter_map(|&id| catalog.track(id))
1061 .collect();
1062 if tracks.is_empty() {
1063 continue;
1064 }
1065 let paths: Vec<String> = tracks
1066 .iter()
1067 .filter_map(|t| catalog.file(t.file_id))
1068 .map(|f| f.path.clone())
1069 .collect();
1070
1071 let mut by_disc: BTreeMap<u32, (Vec<u32>, u32)> = BTreeMap::new();
1078 for track in &tracks {
1079 if let Some(no) = track.track_no {
1080 let entry = by_disc.entry(track.disc_no.unwrap_or(1)).or_default();
1081 entry.0.push(no);
1082 if let Some(total) = catalog.file(track.file_id).and_then(announced_total) {
1083 entry.1 = entry.1.max(total);
1084 }
1085 }
1086 }
1087 for (disc, (mut numbers, announced)) in by_disc {
1088 numbers.sort_unstable();
1089 numbers.dedup();
1090 let Some(&last) = numbers.last() else {
1091 continue;
1092 };
1093 let max = last.max(announced);
1096 let missing: Vec<u32> = (1..=max)
1099 .filter(|n| numbers.binary_search(n).is_err())
1100 .take(13)
1101 .collect();
1102 if !missing.is_empty() {
1103 let list: Vec<String> = missing.iter().take(12).map(|n| n.to_string()).collect();
1104 issues.push(Issue {
1105 kind: IssueKind::IncompleteAlbum,
1106 detail: format!(
1107 "\"{}\" disc {disc}: missing tracks {}{}",
1108 release.title,
1109 list.join(", "),
1110 if missing.len() > 12 { "…" } else { "" }
1111 ),
1112 files: paths.clone(),
1113 });
1114 }
1115 }
1116
1117 let present: BTreeSet<u32> = tracks.iter().map(|t| t.disc_no.unwrap_or(1)).collect();
1123 let announced = tracks
1124 .iter()
1125 .filter_map(|t| catalog.file(t.file_id))
1126 .filter_map(announced_discs)
1127 .max();
1128 let ceiling = present
1131 .iter()
1132 .copied()
1133 .max()
1134 .unwrap_or(1)
1135 .max(announced.unwrap_or(0));
1136 let sibling = discs_elsewhere.get(&album_key(release));
1137 let missing: Vec<u32> = (1..=ceiling)
1138 .filter(|n| !present.contains(n))
1139 .filter(|n| !sibling.is_some_and(|discs| discs.contains(n)))
1145 .take(13)
1146 .collect();
1147 if !missing.is_empty() {
1148 let list: Vec<String> = missing.iter().take(12).map(|n| n.to_string()).collect();
1149 issues.push(Issue {
1150 kind: IssueKind::IncompleteAlbum,
1151 detail: format!(
1152 "\"{}\": missing {} {}{}{}",
1153 release.title,
1154 if missing.len() > 1 { "discs" } else { "disc" },
1155 list.join(", "),
1156 if missing.len() > 12 { "…" } else { "" },
1157 match announced {
1158 Some(total) => format!(" of {total}"),
1159 None => String::new(),
1160 }
1161 ),
1162 files: paths.clone(),
1163 });
1164 }
1165
1166 let files: Vec<_> = tracks
1169 .iter()
1170 .filter_map(|t| catalog.file(t.file_id))
1171 .collect();
1172 let codecs: std::collections::BTreeSet<&str> =
1173 files.iter().map(|f| f.properties.codec.as_str()).collect();
1174 let rates: std::collections::BTreeSet<u32> = files
1175 .iter()
1176 .filter_map(|f| f.properties.sample_rate)
1177 .collect();
1178 if codecs.len() > 1 || rates.len() > 1 {
1179 let mut details = Vec::new();
1180 if codecs.len() > 1 {
1181 details.push(format!(
1182 "codecs: {}",
1183 codecs.iter().copied().collect::<Vec<_>>().join(", ")
1184 ));
1185 }
1186 if rates.len() > 1 {
1187 details.push(format!(
1188 "sample rates: {}",
1189 rates
1190 .iter()
1191 .map(|r| r.to_string())
1192 .collect::<Vec<_>>()
1193 .join(", ")
1194 ));
1195 }
1196 issues.push(Issue {
1197 kind: IssueKind::MixedQuality,
1198 detail: format!("\"{}\" — {}", release.title, details.join("; ")),
1199 files: paths.clone(),
1200 });
1201 }
1202
1203 let has_cover = release.cover_path.is_some() || files.iter().any(|f| f.has_embedded_art);
1205 if !has_cover {
1206 issues.push(Issue {
1207 kind: IssueKind::MissingCover,
1208 detail: format!("\"{}\" has no cover art", release.title),
1209 files: vec![release.folder.clone()],
1210 });
1211 }
1212 }
1213}
1214
1215#[cfg(test)]
1216#[path = "doctor_tests.rs"]
1217mod tests;
1218
1219#[cfg(test)]
1220#[path = "doctor_source_tests.rs"]
1221mod source_tests;
1222
1223fn check_same_person(catalog: &Catalog, issues: &mut Vec<Issue>) {
1250 let named: Vec<&model::Artist> = catalog
1253 .artists
1254 .iter()
1255 .filter(|a| !catalog.tracks_of_artist(a.id).is_empty())
1256 .collect();
1257
1258 for (at, one) in named.iter().enumerate() {
1259 for other in named.iter().skip(at + 1) {
1260 if let (Some(a), Some(b)) = (&one.mbid, &other.mbid)
1263 && a != b
1264 {
1265 continue;
1266 }
1267 let together = || shares_a_release(catalog, one.id, other.id);
1268 let why = if initialism(&one.key, &other.key) {
1269 "one is the other with a first name reduced to its initial"
1270 } else if shortened(&one.key, &other.key) && together() {
1271 "one is the other without its first name, and both are credited on one album"
1272 } else {
1273 continue;
1274 };
1275 let (short, long) = match one.key.len() <= other.key.len() {
1278 true => (one, other),
1279 false => (other, one),
1280 };
1281 issues.push(Issue {
1282 kind: IssueKind::SameArtistMaybe,
1283 detail: format!(
1284 "\"{}\" ({}) and \"{}\" ({}): {why}. \
1285 If they are, aede merge \"{}\" \"{}\" says so — \
1286 nothing in your files changes",
1287 short.name,
1288 crate::text::plural(catalog.tracks_of_artist(short.id).len(), "track"),
1289 long.name,
1290 crate::text::plural(catalog.tracks_of_artist(long.id).len(), "track"),
1291 short.name,
1292 long.name,
1293 ),
1294 files: catalog
1297 .tracks_of_artist(short.id)
1298 .iter()
1299 .filter_map(|&t| catalog.track(t))
1300 .filter_map(|t| catalog.file(t.file_id))
1301 .map(|f| f.path.clone())
1302 .take(2)
1303 .collect(),
1304 });
1305 }
1306 }
1307}
1308
1309fn initialism(one: &str, other: &str) -> bool {
1316 let (a, b): (Vec<&str>, Vec<&str>) = (one.split(' ').collect(), other.split(' ').collect());
1317 if a.len() != b.len() || a.len() < 2 {
1318 return false;
1319 }
1320 let mut abbreviated = false;
1321 for (left, right) in a.iter().zip(&b) {
1322 if left == right {
1323 continue;
1324 }
1325 let opens = |short: &str, full: &str| {
1326 short.chars().count() == 1 && full.starts_with(short) && full.chars().count() > 1
1327 };
1328 match opens(left, right) || opens(right, left) {
1329 true => abbreviated = true,
1330 false => return false,
1331 }
1332 }
1333 abbreviated
1334}
1335
1336fn shortened(one: &str, other: &str) -> bool {
1343 let (a, b): (Vec<&str>, Vec<&str>) = (one.split(' ').collect(), other.split(' ').collect());
1344 let (short, long) = if a.len() < b.len() { (a, b) } else { (b, a) };
1345 !short.is_empty() && short.len() < long.len() && long.ends_with(&short[..])
1346}
1347
1348fn shares_a_release(catalog: &Catalog, one: Id, other: Id) -> bool {
1350 let mine: BTreeSet<Id> = catalog.releases_of_artist(one).into_iter().collect();
1351 catalog
1352 .releases_of_artist(other)
1353 .iter()
1354 .any(|id| mine.contains(id))
1355}