mtag-cli 0.2.0

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

use lofty::{read_from_path, Accessor, ItemKey, TaggedFileExt};

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

/// Metadata fields used by the organization planner.
///
/// Every field except [`TrackMetadata::source_path`] is optional because tags are often
/// incomplete in real music libraries. Planning code falls back to `Other` for missing
/// artist or album values.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TrackMetadata {
    /// Original audio file path.
    pub source_path: PathBuf,
    /// Track artist, usually the performer of one song.
    pub artist: Option<String>,
    /// Album title.
    pub album: Option<String>,
    /// Album artist, preferred for folder grouping when available.
    pub album_artist: Option<String>,
    /// Disc number as read from tags.
    pub disc: Option<String>,
    /// Track number as read from tags.
    pub track: Option<String>,
    /// Track title.
    pub title: Option<String>,
}

/// Reads the tag metadata needed by the planner from one audio file.
///
/// # Errors
///
/// Returns [`MtagError::ReadMetadata`] if Lofty cannot parse the file and
/// [`MtagError::MissingTags`] when the file contains no readable tag block.
pub fn read_track_metadata(path: &Path) -> MtagResult<TrackMetadata> {
    let tagged_file = read_from_path(path).map_err(|source| MtagError::ReadMetadata {
        path: path.to_path_buf(),
        source,
    })?;
    let tag = tagged_file
        .primary_tag()
        .or_else(|| tagged_file.first_tag())
        .ok_or_else(|| MtagError::MissingTags {
            path: path.to_path_buf(),
        })?;

    Ok(TrackMetadata {
        source_path: path.to_path_buf(),
        artist: clean_string(tag.artist().as_deref()),
        album: clean_string(tag.album().as_deref()),
        album_artist: clean_string(tag.get_string(&ItemKey::AlbumArtist)),
        disc: tag.disk().map(|disk| disk.to_string()),
        track: tag.track().map(|track| track.to_string()),
        title: clean_string(tag.title().as_deref()),
    })
}

fn clean_string(value: Option<&str>) -> Option<String> {
    value
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}