use colored::*;
use mp3rgain::{read_album_tags, repeated_position, AlbumLabel, AlbumTags, ReleaseKey};
use rayon::prelude::*;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use crate::cli::options::{AlbumGrouping, Options};
pub struct AlbumGroup {
pub id: AlbumLabel,
pub files: Vec<PathBuf>,
}
pub fn group_files(files: &[PathBuf], opts: &Options) -> (Vec<AlbumGroup>, Vec<String>) {
match opts.album_by {
AlbumGrouping::Tag => group_by_tags(files),
AlbumGrouping::Depth(levels) => {
let groups = group_by_depth(files, &opts.arg_roots, levels);
let warnings = split_release_warnings(&groups);
(groups, warnings)
}
_ => {
let groups = group_by_parent(files);
let warnings = split_release_warnings(&groups);
(groups, warnings)
}
}
}
fn group_by_parent(files: &[PathBuf]) -> Vec<AlbumGroup> {
group_by_directory(files.iter().map(|f| (parent_of(f), f.clone())))
}
fn group_by_depth(files: &[PathBuf], roots: &[PathBuf], levels: usize) -> Vec<AlbumGroup> {
let mut roots: Vec<&Path> = roots.iter().map(|p| p.as_path()).collect();
roots.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
group_by_directory(files.iter().map(|file| {
let group = roots
.iter()
.find_map(|root| album_dir_under(file, root, levels))
.unwrap_or_else(|| parent_of(file));
(group, file.clone())
}))
}
fn album_dir_under(file: &Path, root: &Path, levels: usize) -> Option<PathBuf> {
let relative = file.strip_prefix(root).ok()?;
let Some(dirs) = relative.parent() else {
return Some(parent_of(file));
};
let mut dir = root.to_path_buf();
dir.extend(dirs.components().take(levels));
Some(dir)
}
fn split_release_warnings(groups: &[AlbumGroup]) -> Vec<String> {
let mut siblings: BTreeMap<PathBuf, Vec<usize>> = BTreeMap::new();
for (i, group) in groups.iter().enumerate() {
let AlbumLabel::Directory(dir) = &group.id else {
continue;
};
if let Some(parent) = dir.parent() {
siblings.entry(parent.to_path_buf()).or_default().push(i);
}
}
let candidates: Vec<usize> = siblings
.values()
.filter(|g| g.len() > 1)
.flatten()
.copied()
.collect();
if candidates.is_empty() {
return Vec::new();
}
let probed: HashMap<usize, AlbumTags> = candidates
.par_iter()
.filter_map(|&i| {
let tags = read_album_tags(groups[i].files.first()?)?;
tags.has_album().then_some((i, tags))
})
.collect();
let mut warnings = Vec::new();
for members in siblings.values().filter(|g| g.len() > 1) {
let mut by_release: BTreeMap<(String, String), Vec<usize>> = BTreeMap::new();
for &i in members {
let Some(tags) = probed.get(&i) else { continue };
let key = (
tags.effective_artist().unwrap_or_default().to_string(),
tags.album.clone().unwrap_or_default(),
);
by_release.entry(key).or_default().push(i);
}
for ((_, album), split) in by_release.iter().filter(|(_, s)| s.len() > 1) {
let first = &probed[&split[0]];
if !split[1..]
.iter()
.any(|i| looks_like_one_release(first, &probed[i]))
{
continue;
}
let mut warning = format!(
" {} ALBUM \"{}\" is split across {} directories and was scanned as that many albums\n",
"!".yellow(),
album,
split.len()
);
for &i in split {
warning.push_str(&format!(" {}\n", groups[i].id));
}
warning.push_str(" use --album-by=tag to treat them as one release");
warnings.push(warning);
}
}
warnings
}
fn looks_like_one_release(a: &AlbumTags, b: &AlbumTags) -> bool {
if let (Some(x), Some(y)) = (&a.musicbrainz_album_id, &b.musicbrainz_album_id) {
if x != y {
return false;
}
}
match (a.disc, b.disc) {
(Some(x), Some(y)) => x != y,
_ => a.track != b.track || a.track.is_none(),
}
}
fn group_by_directory(entries: impl Iterator<Item = (PathBuf, PathBuf)>) -> Vec<AlbumGroup> {
let mut groups: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
for (dir, file) in entries {
groups.entry(dir).or_default().push(file);
}
groups
.into_iter()
.map(|(dir, files)| AlbumGroup {
id: AlbumLabel::Directory(dir),
files,
})
.collect()
}
fn parent_of(file: &Path) -> PathBuf {
file.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."))
.to_path_buf()
}
#[derive(PartialEq, Eq, Hash, Clone)]
enum TagKey {
Release(ReleaseKey),
Directory(PathBuf),
}
fn group_by_tags(files: &[PathBuf]) -> (Vec<AlbumGroup>, Vec<String>) {
let tags: Vec<Option<AlbumTags>> = files.par_iter().map(|f| read_album_tags(f)).collect();
let mut order: Vec<TagKey> = Vec::new();
let mut members: HashMap<TagKey, Vec<usize>> = HashMap::new();
let mut untagged = 0usize;
for (i, file_tags) in tags.iter().enumerate() {
let key = match file_tags.as_ref().and_then(AlbumTags::release_key) {
Some(release) => TagKey::Release(release),
None => {
untagged += 1;
TagKey::Directory(parent_of(&files[i]))
}
};
let slot = members.entry(key.clone()).or_default();
if slot.is_empty() {
order.push(key);
}
slot.push(i);
}
let mut warnings = Vec::new();
if untagged > 0 {
warnings.push(format!(
" {} {} file(s) carry no ALBUM tag and were grouped by directory instead",
"!".yellow(),
untagged
));
}
let mut groups = Vec::with_capacity(order.len());
for key in order {
let indices = members.remove(&key).unwrap_or_default();
let id = match &key {
TagKey::Directory(dir) => AlbumLabel::Directory(dir.clone()),
TagKey::Release(_) => tags[indices[0]]
.as_ref()
.and_then(AlbumLabel::from_tags)
.unwrap_or_else(|| AlbumLabel::Directory(parent_of(&files[indices[0]]))),
};
if let Some(warning) = collision_warning(&id, &indices, &tags, files) {
warnings.push(warning);
}
groups.push(AlbumGroup {
files: indices.iter().map(|&i| files[i].clone()).collect(),
id,
});
}
(groups, warnings)
}
fn collision_warning(
id: &AlbumLabel,
indices: &[usize],
tags: &[Option<AlbumTags>],
files: &[PathBuf],
) -> Option<String> {
if matches!(id, AlbumLabel::Directory(_)) {
return None;
}
let (disc, track, copies) =
repeated_position(indices.iter().filter_map(|&i| tags[i].as_ref()))?;
let mut dirs: Vec<String> = indices
.iter()
.map(|&i| parent_of(&files[i]).display().to_string())
.collect();
dirs.sort();
dirs.dedup();
let position = match disc {
Some(d) => format!("disc {} track {}", d, track),
None => format!("track {}", track),
};
let mut warning = format!(
" {} {}: {} appears {} times, so this is probably {} releases sharing one album and artist string\n",
"!".yellow(),
id,
position,
copies,
copies
);
for dir in &dirs {
warning.push_str(&format!(" {}\n", dir));
}
warning.push_str(
" use --album-by=dir to keep them apart, or tag each release with its own MUSICBRAINZ_ALBUMID",
);
Some(warning)
}