use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
};
use crate::{
error::{MtagError, MtagResult},
metadata::TrackMetadata,
};
pub const DEFAULT_TEMPLATE: &str = "{album_artist}/{album}/{file_name}";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OrganizationOptions {
pub template: String,
}
impl Default for OrganizationOptions {
fn default() -> Self {
Self {
template: DEFAULT_TEMPLATE.to_string(),
}
}
}
impl OrganizationOptions {
pub fn flat() -> Self {
Self {
template: "{file_name}".to_string(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CopyPlan {
pub tasks: Vec<CopyTask>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CopyTask {
pub from: PathBuf,
pub to: PathBuf,
}
#[derive(Clone, Debug)]
struct AlbumGroup {
artists: HashSet<String>,
}
pub fn build_copy_plan(
tracks: &[TrackMetadata],
target_root: &Path,
options: &OrganizationOptions,
) -> MtagResult<CopyPlan> {
let groups = build_album_groups(tracks);
let mut tasks = Vec::new();
for track in tracks {
let album_artist = album_artist_for_track(track, &groups);
let target_path = render_target_path(track, target_root, options, &album_artist)?;
tasks.push(CopyTask {
from: track.source_path.clone(),
to: target_path,
});
if let Some(lyric_path) = lyric_companion_path(&track.source_path) {
let lyric_file_name =
lyric_path
.file_name()
.ok_or_else(|| MtagError::MissingFileName {
path: lyric_path.clone(),
})?;
let lyric_track = TrackMetadata {
source_path: lyric_path.clone(),
artist: track.artist.clone(),
album: track.album.clone(),
album_artist: Some(album_artist),
disc: track.disc.clone(),
track: track.track.clone(),
title: track.title.clone(),
};
let target_path = render_target_path_with_file_name(
&lyric_track,
target_root,
options,
lyric_file_name,
)?;
tasks.push(CopyTask {
from: lyric_path,
to: target_path,
});
}
}
Ok(CopyPlan { tasks })
}
pub fn sanitize_path_component(component: &str) -> String {
let sanitized: String = component
.chars()
.map(|ch| {
if ch.is_control() || matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
{
'_'
} else {
ch
}
})
.collect();
let trimmed = sanitized.trim().trim_matches('.');
if trimmed.is_empty() || trimmed == "." || trimmed == ".." {
"_".to_string()
} else {
trimmed.to_string()
}
}
fn build_album_groups(tracks: &[TrackMetadata]) -> HashMap<(String, PathBuf), AlbumGroup> {
let mut groups: HashMap<(String, PathBuf), AlbumGroup> = HashMap::new();
for track in tracks {
if track
.album_artist
.as_deref()
.and_then(clean_string)
.is_some()
{
continue;
}
let album = value_or_other(track.album.as_deref());
if album == "Other" {
continue;
}
let parent = track
.source_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let artist = primary_artist(value_or_other(track.artist.as_deref()).as_str());
groups
.entry((album, parent))
.or_insert_with(|| AlbumGroup {
artists: HashSet::new(),
})
.artists
.insert(artist);
}
groups
}
fn album_artist_for_track(
track: &TrackMetadata,
groups: &HashMap<(String, PathBuf), AlbumGroup>,
) -> String {
if let Some(album_artist) = track.album_artist.as_deref().and_then(clean_string) {
return album_artist;
}
let album = value_or_other(track.album.as_deref());
if album == "Other" {
return primary_artist(value_or_other(track.artist.as_deref()).as_str());
}
let parent = track
.source_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let group_key = (album, parent);
if groups
.get(&group_key)
.is_some_and(|group| group.artists.len() > 1)
{
"Various Artists".to_string()
} else {
primary_artist(value_or_other(track.artist.as_deref()).as_str())
}
}
fn render_target_path(
track: &TrackMetadata,
target_root: &Path,
options: &OrganizationOptions,
album_artist: &str,
) -> MtagResult<PathBuf> {
let file_name = track
.source_path
.file_name()
.ok_or_else(|| MtagError::MissingFileName {
path: track.source_path.clone(),
})?;
let track = TrackMetadata {
album_artist: Some(album_artist.to_string()),
..track.clone()
};
render_target_path_with_file_name(&track, target_root, options, file_name)
}
fn render_target_path_with_file_name(
track: &TrackMetadata,
target_root: &Path,
options: &OrganizationOptions,
file_name: &std::ffi::OsStr,
) -> MtagResult<PathBuf> {
let file_name = file_name.to_string_lossy();
let mut target_path = target_root.to_path_buf();
for segment in options.template.split('/') {
if segment.is_empty() {
continue;
}
let rendered = render_template_segment(track, segment, &file_name)?;
target_path.push(sanitize_path_component(&rendered));
}
Ok(target_path)
}
fn render_template_segment(
track: &TrackMetadata,
segment: &str,
file_name: &str,
) -> MtagResult<String> {
let mut output = String::new();
let mut rest = segment;
while let Some(start) = rest.find('{') {
output.push_str(&rest[..start]);
let after_open = &rest[start + 1..];
let Some(end) = after_open.find('}') else {
return Err(MtagError::UnclosedTemplateVariable {
template: segment.to_string(),
});
};
let variable = &after_open[..end];
output.push_str(template_value(track, variable, file_name)?);
rest = &after_open[end + 1..];
}
output.push_str(rest);
Ok(output)
}
fn template_value<'a>(
track: &'a TrackMetadata,
variable: &str,
file_name: &'a str,
) -> MtagResult<&'a str> {
match variable {
"album_artist" => Ok(track
.album_artist
.as_deref()
.and_then(clean_str)
.unwrap_or("Other")),
"album" => Ok(track
.album
.as_deref()
.and_then(clean_str)
.unwrap_or("Other")),
"artist" => Ok(track
.artist
.as_deref()
.and_then(clean_str)
.unwrap_or("Other")),
"disc" => Ok(track.disc.as_deref().and_then(clean_str).unwrap_or("")),
"track" => Ok(track.track.as_deref().and_then(clean_str).unwrap_or("")),
"title" => Ok(track.title.as_deref().and_then(clean_str).unwrap_or("")),
"file_name" => Ok(file_name),
_ => Err(MtagError::InvalidTemplateVariable {
variable: variable.to_string(),
}),
}
}
fn lyric_companion_path(source_path: &Path) -> Option<PathBuf> {
let mut lyric_path = source_path.to_path_buf();
lyric_path.set_extension("lrc");
lyric_path.exists().then_some(lyric_path)
}
fn value_or_other(value: Option<&str>) -> String {
value
.and_then(clean_string)
.unwrap_or_else(|| "Other".to_string())
}
fn clean_string(value: &str) -> Option<String> {
clean_str(value).map(ToOwned::to_owned)
}
fn clean_str(value: &str) -> Option<&str> {
let trimmed = value.trim();
(!trimmed.is_empty()).then_some(trimmed)
}
fn primary_artist(artist: &str) -> String {
artist
.split('/')
.next()
.and_then(clean_string)
.unwrap_or_else(|| "Other".to_string())
}