Package musar

The Music Archivist, a Python module for validating and formatting audio tags.

Expand source code
# pylint: disable=R0913
"""The Music Archivist, a Python module for validating and formatting audio tags.
"""

__version__ = "2.0.0"
__author__ = "Yohan Chalier"
__license__ = "MIT"
__email__ = "yohan@chalier.fr"

import os
import logging
import json
import codecs
import datetime
import slugify

from .config import Config
from .download import PlaylistDownloader
from .folder import Folder
from .misc import most_common_list_value


def load_config(path, edit=False):
    """Load a config file.

    Parameters
    ----------
    path : str
        Path to the text file to load.
    edit : bool
        If `True`, a `curses` window will show up allowing user to edit the
        loaded configuration before any further step.

    Returns
    -------
    musar.config.Config
        The loaded configuration.

    """
    logging.info("Loading config from %s", os.path.realpath(path))
    config = Config.from_file(path)
    if edit:
        config.edit()
    return config


def iter_folders(top, explore=False, allow_empty=False):
    """Builds an iterator for folders containing MP3 files.

    Parameters
    ----------
    top : str
        Path to the root folder.
    explore : bool
        If `True`, every subfolder will be recursively explored. Otherwise,
        only the root folder is considered.
    allow_empty : bool
        If `False`, folders without any MP3 files in them will be ignored.

    Returns
    -------
    Iterator[musar.folder.Folder]
        Iterator over found folders.

    """
    logging.info("Exploring folders from %s", os.path.realpath(top))
    for root, _, _ in os.walk(top, topdown=True):
        logging.info("Exploring folder %s", os.path.realpath(root))
        folder = Folder(root)
        folder.load()
        if allow_empty or len(folder.tracks) > 0:
            yield folder
        if not explore:
            break


def action_check(config, folder):
    """Apply logic rules on a folder's tracks.

    Parameters
    ----------
    config : musar.config.Config
        Configuration containing the rules to apply.
    folder : musar.folder.Folder
        Folder to check.

    Returns
    -------
    bool
        Returns `True` if and only if the conjuction of rules is true.

    """
    logging.info("Checking rules on folder %s", folder.path)
    valid = True
    if len(config.rules) == 0:
        logging.warning("Checking album without any rule set up")
    for rule in config.rules:
        logging.debug("Checking rule %s", rule)
        if not rule.validate(folder):
            print("Violated %s" % rule)
            valid = False
    return valid


def action_extend(config, folder, fields):
    """Extend most common non-null field values to other tracks.

    Parameters
    ----------
    config : musar.config.Config
        A configuration to use the accessor manager of.
    folder : musar.folder.Folder
        Folder whose tracks fields will be extended.
    fields : List[str]
        List of accessor names of fields to extend.

    """
    logging.info("Extending fields on folder %s", folder.path)
    for field in fields:
        if field not in config.accessor_mgr:
            logging.error("Wrong accessor name: '%s'", field)
            continue
        values = list()
        for track in folder:
            value = config.accessor_mgr[field].get(track)
            if value is not None:
                values.append(value)
        if len(values) == 0:
            logging.error("Could not expand field %s: no non-null value found", field)
            continue
        common_value = most_common_list_value(values)
        logging.info("Selected common value %s for field %s", common_value, field)
        for track in folder:
            config.accessor_mgr[field].set(track, common_value)


def action_clean(config, folder):
    """Apply cleaners on the tracks of a folder.

    Parameters
    ----------
    config : musar.config.Config
        Configuration with the cleaners to apply.
    folder : musar.folder.Folder
        Folder whose tracks will be cleaned.

    """
    logging.info("Cleaning tags of folder %s", folder.path)
    # Emptying config accessors memory before properly setting them to store
    # values before formatting.
    logging.debug("Resetting config %s", config)
    config.reset()
    for fmt in config.formats:
        fmt.prepare(folder)
    logging.debug("Clearing all existing tags of folder %s", folder.path)
    for track in folder:
        track.tag.clear()
    logging.debug("Writing new tags for folder %s", folder.path)
    for fmt in config.formats:
        fmt.set(folder)
    for filename, track in folder.tracks.items():
        track.tag.save(filename=filename)


def generate_track_filename(track):
    """Generate a clean filename for a track. It is composed of disc numbering
    if strictly greater than 1, track numbering and track title, all slugified.
    Numberings are padded with 0s so explorer's ordering remains correct.

    Parameters
    ----------
    track : eyed3.mp3.Mp3AudioFile
        Track to generate the filename for.

    Returns
    -------
    str
        Generated filename.

    """
    disc_current, disc_total = track.tag.disc_num
    track_current, track_total = track.tag.track_num
    title = slugify.slugify(track.tag.title[:50])
    track_num_format = "%." + str(len(str(track_total))) + "d"
    filename = track_num_format % track_current + "-" + title + ".mp3"
    if disc_total > 1:
        disc_num_format = "%." + str(len(str(disc_total))) + "d"
        filename = disc_num_format % disc_current + "-" + filename
    return filename


def action_rename(folder, rename_hierarchy):
    """Rename the tracks within a folder with clean filenames.

    Parameters
    ----------
    folder : musar.folder.Folder
        Folder with the tracks to rename.
    rename_hierarchy : bool
        If `True`, a folder hierarchy artist > album is created within the
        original folder and tracks are moved at the bottom of it.

    """
    logging.info("Renaming tracks of folder %s", folder.path)
    hierarchy_folder = folder.create_hierarchy(mkdir=rename_hierarchy)
    for filename, track in folder.tracks.items():
        new_filename = generate_track_filename(track)
        if rename_hierarchy:
            os.rename(filename, os.path.join(hierarchy_folder, new_filename))
        else:
            os.rename(filename, os.path.join(
                os.path.dirname(filename), new_filename))


def action_format(
        config,
        root,
        check_only=False,
        force=False,
        rename=False,
        rename_hierarchy=False,
        explore=False,
        extend=None
        ):
    """Format the tracks within a folder.

    Parameters
    ----------
    config : musar.config.Config
        Configuration to follow.
    root : str
        Path to a root folder to look for audio tracks.
    check_only : bool
        If `True`, function will return after the check action.
    force : bool
        If `True`, function will try to format even if the checking failed.
    rename : bool
        If `True`, tracks are renamed once formatted.
    rename_hierarchy : bool
        If `True`, tracks are renamed once formatted and moved to a dedicated
        folder. See `musar.action_rename` for details.
    explore : bool
        If `True`, subfolders will be explored for audio tracks.
    extend : List[str]
        If empty or `None`, ignored. Else it contains accessor names for fields
        to extend. See `musar.action_extend` for details.

    """
    for folder in iter_folders(root, explore, allow_empty=False):
        valid = action_check(config, folder)
        if not check_only and (valid or force):
            if extend is not None and len(extend) > 0:
                action_extend(config, folder, extend)
            action_clean(config, folder)
            if rename or rename_hierarchy:
                action_rename(folder, rename)
        elif not check_only and not force:
            logging.warning(
                "Validation failed. Use -f to force formatting.")


def action_index(root, output):
    """Create a JSON index file of the music library.

    Parameters
    ----------
    root : str
        Path to the root folder to explore. Every subfolders are explored.
    output : str
        Path for the output JSON.

    """
    index = list()
    for folder in iter_folders(root, explore=True, allow_empty=False):
        index.append(folder.index())
    with codecs.open(output, "w", "utf8") as outfile:
        data = {
            "albums": index,
            "info": {
                "date_generation": datetime.datetime.utcnow()
                                   .replace(tzinfo=datetime.timezone.utc)
                                   .isoformat(),
                "musar_version": __version__,
                "root_folder": root,
            }
        }
        json.dump(data, outfile, sort_keys=True, indent=4)


def action_convert(config, root, explore, remove_original):
    """Convert non MP3 files to MP3 using FFmpeg.

    Parameters
    ----------
    config : musar.config.Config
        The configuration contains options and more importantly the list of
        file extensions that should be converted to MP3 when encountered.
    root : str
        Root folder to look for convertible files.
    explore : bool
        If `True`, subfolders are explored.
    remove_original : bool
        If `True`, original files are deleted once converted.

    """
    for folder in iter_folders(root, explore, allow_empty=True):
        folder.convert(config, remove_original)


def action_download(
        config,
        playlist_url,
        skip_download=False,
        skip_tags=False,
        edit_tags=False,
        format_tags=False
        ):
    """Download a YouTube playlist and automatically set the appropriate tags
    to downloaded files. Requires an up-to-date youtube-dl executable.

    Parameters
    ----------
    config : musar.config.Config
        Configuration with executable paths.
    playlist_url : str
        URL of the playlist to download. The script is best suited for actual
        album playlists, such as those automatically generated by YouTube.
    skip_download : bool
        If `True`, no downloading occurs, simply tag setting for already
        downloaded files. Files must be in the correct downloads folder.
    skip_tags : bool
        If `True`, tags are not automatically set to the tracks.
    edit_tags : bool
        If `True`, opens the Mp3tag program on the downloaded folder.
    format_tags : bool
        If `True`, `musar.action_format` is called on downloaded files.

    """
    downloader = PlaylistDownloader(config)
    pdf = downloader.main(
        playlist_url,
        skip_download,
        skip_tags,
        edit_tags
    )
    if format_tags:
        action_format(config, pdf, rename_hierarchy=True)

Sub-modules

musar.accessors

Interface between eyed3 and musar.

musar.cleaners

Field value cleaners.

musar.config

Wrapper for configurable options and parameters.

musar.constraints

Implementation of constraints over sets of values.

musar.download

Tools to download YouTube playlists and set the appropriate audio tags.

musar.folder

Wrapper for handling sets of tracks.

musar.formats

Wrapper for applying several cleaners on one accessor value.

musar.misc

General purpose classes and functions.

musar.rules

Wrapper for expressing logical rules.

musar.scopes

Scopes for grouping album tracks over some criteria.

Functions

def action_check(config, folder)

Apply logic rules on a folder's tracks.

Parameters

config : Config
Configuration containing the rules to apply.
folder : Folder
Folder to check.

Returns

bool
Returns True if and only if the conjuction of rules is true.
Expand source code
def action_check(config, folder):
    """Apply logic rules on a folder's tracks.

    Parameters
    ----------
    config : musar.config.Config
        Configuration containing the rules to apply.
    folder : musar.folder.Folder
        Folder to check.

    Returns
    -------
    bool
        Returns `True` if and only if the conjuction of rules is true.

    """
    logging.info("Checking rules on folder %s", folder.path)
    valid = True
    if len(config.rules) == 0:
        logging.warning("Checking album without any rule set up")
    for rule in config.rules:
        logging.debug("Checking rule %s", rule)
        if not rule.validate(folder):
            print("Violated %s" % rule)
            valid = False
    return valid
def action_clean(config, folder)

Apply cleaners on the tracks of a folder.

Parameters

config : Config
Configuration with the cleaners to apply.
folder : Folder
Folder whose tracks will be cleaned.
Expand source code
def action_clean(config, folder):
    """Apply cleaners on the tracks of a folder.

    Parameters
    ----------
    config : musar.config.Config
        Configuration with the cleaners to apply.
    folder : musar.folder.Folder
        Folder whose tracks will be cleaned.

    """
    logging.info("Cleaning tags of folder %s", folder.path)
    # Emptying config accessors memory before properly setting them to store
    # values before formatting.
    logging.debug("Resetting config %s", config)
    config.reset()
    for fmt in config.formats:
        fmt.prepare(folder)
    logging.debug("Clearing all existing tags of folder %s", folder.path)
    for track in folder:
        track.tag.clear()
    logging.debug("Writing new tags for folder %s", folder.path)
    for fmt in config.formats:
        fmt.set(folder)
    for filename, track in folder.tracks.items():
        track.tag.save(filename=filename)
def action_convert(config, root, explore, remove_original)

Convert non MP3 files to MP3 using FFmpeg.

Parameters

config : Config
The configuration contains options and more importantly the list of file extensions that should be converted to MP3 when encountered.
root : str
Root folder to look for convertible files.
explore : bool
If True, subfolders are explored.
remove_original : bool
If True, original files are deleted once converted.
Expand source code
def action_convert(config, root, explore, remove_original):
    """Convert non MP3 files to MP3 using FFmpeg.

    Parameters
    ----------
    config : musar.config.Config
        The configuration contains options and more importantly the list of
        file extensions that should be converted to MP3 when encountered.
    root : str
        Root folder to look for convertible files.
    explore : bool
        If `True`, subfolders are explored.
    remove_original : bool
        If `True`, original files are deleted once converted.

    """
    for folder in iter_folders(root, explore, allow_empty=True):
        folder.convert(config, remove_original)
def action_download(config, playlist_url, skip_download=False, skip_tags=False, edit_tags=False, format_tags=False)

Download a YouTube playlist and automatically set the appropriate tags to downloaded files. Requires an up-to-date youtube-dl executable.

Parameters

config : Config
Configuration with executable paths.
playlist_url : str
URL of the playlist to download. The script is best suited for actual album playlists, such as those automatically generated by YouTube.
skip_download : bool
If True, no downloading occurs, simply tag setting for already downloaded files. Files must be in the correct downloads folder.
skip_tags : bool
If True, tags are not automatically set to the tracks.
edit_tags : bool
If True, opens the Mp3tag program on the downloaded folder.
format_tags : bool
If True, action_format() is called on downloaded files.
Expand source code
def action_download(
        config,
        playlist_url,
        skip_download=False,
        skip_tags=False,
        edit_tags=False,
        format_tags=False
        ):
    """Download a YouTube playlist and automatically set the appropriate tags
    to downloaded files. Requires an up-to-date youtube-dl executable.

    Parameters
    ----------
    config : musar.config.Config
        Configuration with executable paths.
    playlist_url : str
        URL of the playlist to download. The script is best suited for actual
        album playlists, such as those automatically generated by YouTube.
    skip_download : bool
        If `True`, no downloading occurs, simply tag setting for already
        downloaded files. Files must be in the correct downloads folder.
    skip_tags : bool
        If `True`, tags are not automatically set to the tracks.
    edit_tags : bool
        If `True`, opens the Mp3tag program on the downloaded folder.
    format_tags : bool
        If `True`, `musar.action_format` is called on downloaded files.

    """
    downloader = PlaylistDownloader(config)
    pdf = downloader.main(
        playlist_url,
        skip_download,
        skip_tags,
        edit_tags
    )
    if format_tags:
        action_format(config, pdf, rename_hierarchy=True)
def action_extend(config, folder, fields)

Extend most common non-null field values to other tracks.

Parameters

config : Config
A configuration to use the accessor manager of.
folder : Folder
Folder whose tracks fields will be extended.
fields : List[str]
List of accessor names of fields to extend.
Expand source code
def action_extend(config, folder, fields):
    """Extend most common non-null field values to other tracks.

    Parameters
    ----------
    config : musar.config.Config
        A configuration to use the accessor manager of.
    folder : musar.folder.Folder
        Folder whose tracks fields will be extended.
    fields : List[str]
        List of accessor names of fields to extend.

    """
    logging.info("Extending fields on folder %s", folder.path)
    for field in fields:
        if field not in config.accessor_mgr:
            logging.error("Wrong accessor name: '%s'", field)
            continue
        values = list()
        for track in folder:
            value = config.accessor_mgr[field].get(track)
            if value is not None:
                values.append(value)
        if len(values) == 0:
            logging.error("Could not expand field %s: no non-null value found", field)
            continue
        common_value = most_common_list_value(values)
        logging.info("Selected common value %s for field %s", common_value, field)
        for track in folder:
            config.accessor_mgr[field].set(track, common_value)
def action_format(config, root, check_only=False, force=False, rename=False, rename_hierarchy=False, explore=False, extend=None)

Format the tracks within a folder.

Parameters

config : Config
Configuration to follow.
root : str
Path to a root folder to look for audio tracks.
check_only : bool
If True, function will return after the check action.
force : bool
If True, function will try to format even if the checking failed.
rename : bool
If True, tracks are renamed once formatted.
rename_hierarchy : bool
If True, tracks are renamed once formatted and moved to a dedicated folder. See action_rename() for details.
explore : bool
If True, subfolders will be explored for audio tracks.
extend : List[str]
If empty or None, ignored. Else it contains accessor names for fields to extend. See action_extend() for details.
Expand source code
def action_format(
        config,
        root,
        check_only=False,
        force=False,
        rename=False,
        rename_hierarchy=False,
        explore=False,
        extend=None
        ):
    """Format the tracks within a folder.

    Parameters
    ----------
    config : musar.config.Config
        Configuration to follow.
    root : str
        Path to a root folder to look for audio tracks.
    check_only : bool
        If `True`, function will return after the check action.
    force : bool
        If `True`, function will try to format even if the checking failed.
    rename : bool
        If `True`, tracks are renamed once formatted.
    rename_hierarchy : bool
        If `True`, tracks are renamed once formatted and moved to a dedicated
        folder. See `musar.action_rename` for details.
    explore : bool
        If `True`, subfolders will be explored for audio tracks.
    extend : List[str]
        If empty or `None`, ignored. Else it contains accessor names for fields
        to extend. See `musar.action_extend` for details.

    """
    for folder in iter_folders(root, explore, allow_empty=False):
        valid = action_check(config, folder)
        if not check_only and (valid or force):
            if extend is not None and len(extend) > 0:
                action_extend(config, folder, extend)
            action_clean(config, folder)
            if rename or rename_hierarchy:
                action_rename(folder, rename)
        elif not check_only and not force:
            logging.warning(
                "Validation failed. Use -f to force formatting.")
def action_index(root, output)

Create a JSON index file of the music library.

Parameters

root : str
Path to the root folder to explore. Every subfolders are explored.
output : str
Path for the output JSON.
Expand source code
def action_index(root, output):
    """Create a JSON index file of the music library.

    Parameters
    ----------
    root : str
        Path to the root folder to explore. Every subfolders are explored.
    output : str
        Path for the output JSON.

    """
    index = list()
    for folder in iter_folders(root, explore=True, allow_empty=False):
        index.append(folder.index())
    with codecs.open(output, "w", "utf8") as outfile:
        data = {
            "albums": index,
            "info": {
                "date_generation": datetime.datetime.utcnow()
                                   .replace(tzinfo=datetime.timezone.utc)
                                   .isoformat(),
                "musar_version": __version__,
                "root_folder": root,
            }
        }
        json.dump(data, outfile, sort_keys=True, indent=4)
def action_rename(folder, rename_hierarchy)

Rename the tracks within a folder with clean filenames.

Parameters

folder : Folder
Folder with the tracks to rename.
rename_hierarchy : bool
If True, a folder hierarchy artist > album is created within the original folder and tracks are moved at the bottom of it.
Expand source code
def action_rename(folder, rename_hierarchy):
    """Rename the tracks within a folder with clean filenames.

    Parameters
    ----------
    folder : musar.folder.Folder
        Folder with the tracks to rename.
    rename_hierarchy : bool
        If `True`, a folder hierarchy artist > album is created within the
        original folder and tracks are moved at the bottom of it.

    """
    logging.info("Renaming tracks of folder %s", folder.path)
    hierarchy_folder = folder.create_hierarchy(mkdir=rename_hierarchy)
    for filename, track in folder.tracks.items():
        new_filename = generate_track_filename(track)
        if rename_hierarchy:
            os.rename(filename, os.path.join(hierarchy_folder, new_filename))
        else:
            os.rename(filename, os.path.join(
                os.path.dirname(filename), new_filename))
def generate_track_filename(track)

Generate a clean filename for a track. It is composed of disc numbering if strictly greater than 1, track numbering and track title, all slugified. Numberings are padded with 0s so explorer's ordering remains correct.

Parameters

track : eyed3.mp3.Mp3AudioFile
Track to generate the filename for.

Returns

str
Generated filename.
Expand source code
def generate_track_filename(track):
    """Generate a clean filename for a track. It is composed of disc numbering
    if strictly greater than 1, track numbering and track title, all slugified.
    Numberings are padded with 0s so explorer's ordering remains correct.

    Parameters
    ----------
    track : eyed3.mp3.Mp3AudioFile
        Track to generate the filename for.

    Returns
    -------
    str
        Generated filename.

    """
    disc_current, disc_total = track.tag.disc_num
    track_current, track_total = track.tag.track_num
    title = slugify.slugify(track.tag.title[:50])
    track_num_format = "%." + str(len(str(track_total))) + "d"
    filename = track_num_format % track_current + "-" + title + ".mp3"
    if disc_total > 1:
        disc_num_format = "%." + str(len(str(disc_total))) + "d"
        filename = disc_num_format % disc_current + "-" + filename
    return filename
def iter_folders(top, explore=False, allow_empty=False)

Builds an iterator for folders containing MP3 files.

Parameters

top : str
Path to the root folder.
explore : bool
If True, every subfolder will be recursively explored. Otherwise, only the root folder is considered.
allow_empty : bool
If False, folders without any MP3 files in them will be ignored.

Returns

Iterator[Folder]
Iterator over found folders.
Expand source code
def iter_folders(top, explore=False, allow_empty=False):
    """Builds an iterator for folders containing MP3 files.

    Parameters
    ----------
    top : str
        Path to the root folder.
    explore : bool
        If `True`, every subfolder will be recursively explored. Otherwise,
        only the root folder is considered.
    allow_empty : bool
        If `False`, folders without any MP3 files in them will be ignored.

    Returns
    -------
    Iterator[musar.folder.Folder]
        Iterator over found folders.

    """
    logging.info("Exploring folders from %s", os.path.realpath(top))
    for root, _, _ in os.walk(top, topdown=True):
        logging.info("Exploring folder %s", os.path.realpath(root))
        folder = Folder(root)
        folder.load()
        if allow_empty or len(folder.tracks) > 0:
            yield folder
        if not explore:
            break
def load_config(path, edit=False)

Load a config file.

Parameters

path : str
Path to the text file to load.
edit : bool
If True, a curses window will show up allowing user to edit the loaded configuration before any further step.

Returns

Config
The loaded configuration.
Expand source code
def load_config(path, edit=False):
    """Load a config file.

    Parameters
    ----------
    path : str
        Path to the text file to load.
    edit : bool
        If `True`, a `curses` window will show up allowing user to edit the
        loaded configuration before any further step.

    Returns
    -------
    musar.config.Config
        The loaded configuration.

    """
    logging.info("Loading config from %s", os.path.realpath(path))
    config = Config.from_file(path)
    if edit:
        config.edit()
    return config