mtag-cli 0.2.0

Organize music for self-built media libraries like Plex, Emby, and Jellyfin
Documentation
use std::{
    collections::{HashMap, HashSet},
    path::{Path, PathBuf},
};

use crate::{
    error::{MtagError, MtagResult},
    metadata::TrackMetadata,
};

/// Default destination template used by the CLI.
///
/// This renders files as `{album_artist}/{album}/{file_name}`.
pub const DEFAULT_TEMPLATE: &str = "{album_artist}/{album}/{file_name}";

/// Options that control how source metadata becomes destination paths.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OrganizationOptions {
    /// Slash-separated destination template.
    ///
    /// Each rendered segment is sanitized before it is pushed into the target path.
    pub template: String,
}

impl Default for OrganizationOptions {
    fn default() -> Self {
        Self {
            template: DEFAULT_TEMPLATE.to_string(),
        }
    }
}

impl OrganizationOptions {
    /// Returns a template that writes every planned file directly into the target root.
    pub fn flat() -> Self {
        Self {
            template: "{file_name}".to_string(),
        }
    }
}

/// Filesystem operations produced by organization planning.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CopyPlan {
    /// Ordered file operations to apply.
    pub tasks: Vec<CopyTask>,
}

/// One file operation from a source path to a destination path.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CopyTask {
    /// Existing source file.
    pub from: PathBuf,
    /// Destination path after metadata rendering and path sanitization.
    pub to: PathBuf,
}

#[derive(Clone, Debug)]
struct AlbumGroup {
    artists: HashSet<String>,
}

/// Builds copy tasks from metadata without touching destination files.
///
/// The planner also looks for same-stem `.lrc` files next to each source audio file
/// and emits companion tasks for them when present.
///
/// Album grouping rules:
///
/// - Use `album_artist` when the tag provides one.
/// - Use `Various Artists` when tracks in the same source folder share an album name
///   but have different artists.
/// - Keep same-named albums from different source folders separate.
///
/// # Errors
///
/// Returns [`MtagError::MissingFileName`] when a source path has no file name,
/// [`MtagError::InvalidTemplateVariable`] for unknown template variables, and
/// [`MtagError::UnclosedTemplateVariable`] for malformed template segments.
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 })
}

/// Sanitizes a metadata value before using it as one path component.
///
/// ```
/// assert_eq!(mtag_cli::planner::sanitize_path_component("AC/DC: Live?"), "AC_DC_ Live_");
/// assert_eq!(mtag_cli::planner::sanitize_path_component(".."), "_");
/// ```
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())
}