Source

flaccomp / compilation.py

import os
import sys
import re
import glob
from mutagen.flac import FLAC


RED_TEXT = "\033[1;31m%s\033[1;m"
GREEN_TEXT = "\033[1;32m%s\033[1;m"
YELLOW_TEXT = "\033[1;33m%s\033[1;m"
BLUE_TEXT = "\033[1;34m%s\033[1;m"

VAL_TAGS_HEADER = BLUE_TEXT % """
Common Tags
-----------"""

VAL_TRACKS_HEADER = BLUE_TEXT % """
Track Numbers & Titles
----------------------"""

VAL_FILENAMES_HEADER = BLUE_TEXT % """
File Format And Names
---------------------"""

def parse_tracknum(tracknum):
    """Get number from formats like, for instance, '1/8'"""
    separators = filter(lambda x: x in tracknum, ('-', '/'))
    for sep in separators:
        tracknum = tracknum.partition(sep)[0]
    return tracknum, separators

class CompilationError(Exception):
    pass

class Compilation:
    """
    This class performs different actions upon its set of FLAC mutagen
    track objects, like validation, adding or deleting tags, or
    using tags to rename files and viceversa.
    """
    def __init__(self, dir='.'):
        """Create FLAC mutagen tracks from files in directory"""
        self.dir = os.path.abspath(dir)
        if not os.path.exists(dir):
            raise CompilationError("Directory %s does not exist!" % self.dir)
        # the following replace() is a nasty nabuco Hack to avoid this:
        # http://stackoverflow.com/questions/2595119/python-glob-and-bracket-characters
        glob_dir = self.dir.replace('[', '[[]')
        self.files = glob.glob(os.path.join(glob_dir, '*.flac'))
        if not self.files:
            raise CompilationError("No .flac files found in folder %s" % self.dir)
        self.tracks = [FLAC(file) for file in self.files]
        self.filenames = [os.path.basename(t.filename) for t in self.tracks]

    def __repr__(self):
        return "<Compilation in %s>" % self.dir

    def __iter__(self):
        for track in self.tracks:
            yield track

    def __getitem__(self, key):
        return self.tracks[key]

    def get_tags(self):
        """Only tags appearing in all tracks are returned"""
        tags = []
        for track in self:
            for k in track.keys():
                if len(filter(lambda x: k in x, self)) == len(self.tracks):
                    tags.append(k)
        return list(set(tags))

    def common_tag_values(self, tag_name):
        """List of values for given tag in each track"""
        tag_values = []
        for track in self:
            t = track[tag_name][0]
            if t: # Avoid empty tag value
                tag_values.append(t)
        return tag_values

    def valid_common_tags(self, tag_names):
        """Tags in all tracks must match the same"""
        return filter(lambda x: len(set(self.common_tag_values(x))) == 1, tag_names)

    def valid_track_titles(self):
        """Only the first title tag value is considered"""
        return filter(None, ['title' in track and track['title'][0] for track in self])

    def valid_track_numbers(self):
        """Track numbers must start with 1 and be correlative"""
        track_nums = []
        for track in self:
            if 'tracknumber' in track:
                tracknum = parse_tracknum(track['tracknumber'][0])[0]
                if tracknum.isdigit():
                    track_nums.append(int(tracknum))
        track_nums.sort()
        return range(1, len(track_nums)+1) == track_nums

    def valid_filenames_format(self, pattern):
        """All filenames must match provided pattern"""
        files = []
        p = re.compile(pattern)
        return filter(None, [p.findall(f) and p.findall(f)[0] for f in self.filenames])

    def valid_filename_track_numbers(self):
        tracknums = []
        for track in self.tracks:
            f = os.path.basename(track.filename)
            file_tracknum = f.rpartition('.')[0].partition(' - ')[0]
            if 'tracknumber' in track and file_tracknum.isdigit() and int(file_tracknum) == int(track['tracknumber'][0]):
                tracknums.append(f)
        return tracknums

    def valid_filename_titles(self):
        """Title tag values must match exactly as titles appear in filenames"""
        titles = []
        for track in self.tracks:
            f = os.path.basename(track.filename)
            file_title = f.rpartition('.')[0].partition(' - ')[-1]
            if 'title' in track and file_title == track['title'][0]:
                titles.append(f)
        return titles

    def set_filenames_from_title(self, format_str, format_tags, tracks=None):
        """
        Rename filenames with given format ``format_str``, using the value
        of each tag in ``format_tags``.
        Optionally some ``tracks`` can be passed as a parameter, otherwise
        all tracks are processed.
        """
        if tracks is None:
            tracks = self.tracks
        for track in tracks:
            src = os.path.basename(track.filename)
            src_tags = [track[f][0] for f in format_tags]
            tags = map(lambda x: x.isdigit() and int(x) or x, src_tags)
            dst = format_str % tuple(tags)
            rename_args = map(lambda x: os.path.join(self.dir, x), (src, dst))
            os.rename(*rename_args)

    def set_track_tags_from_filename(self, format_tags, format_sep, tracks=None):
        """
        Rename tags from filename format, using``format_tags``
        and ``format_sep`` settings.
        Optionally some ``tracks`` can be passed as a parameter, otherwise
        all tracks are processed.
        """
        if tracks is None:
            tracks = self.tracks
        for track in tracks:
            src = os.path.basename(track.filename)
            tag_values = os.path.splitext(src)[0].split(format_sep, len(format_tags)-1)
            for k, v in zip(format_tags, tag_values):
                track[k] = v
            track.save()

    def fix_tracknumbers(self, tracks=None):
        """
        Formats like '1/8' or '1-8' are fixed in favor of '01'.
        Optionally some ``tracks`` can be passed as a parameter, otherwise
        all tracks are processed.
        """
        if tracks is None:
            tracks = self.tracks
        for track in tracks:
            track_num = int(parse_tracknum(track['tracknumber'][0])[0])
            track['tracknumber'] = u"%02d" % track_num
            track.save()

    def del_common_tag(self, tag, tracks=None):
        """
        Delete tag from all tracks.
        Optionally some ``tracks`` can be passed as a parameter, otherwise
        all tracks are processed.
        """
        if tracks is None:
            tracks = self.tracks
        for track in tracks:
            try:
                track.pop(tag)
                track.save()
            except KeyError:
                pass

    def set_common_tag(self, tag, tag_value, tracks=None):
        """
        Add tag to all tracks.
        Optionally some ``tracks`` can be passed as a parameter, otherwise
        all tracks are processed.
        """
        if tracks is None:
            tracks = self.tracks
        for track in tracks:
            track[tag] = tag_value
            track.save()

    def validate_tags(self, common_tags, required_common_tags, unique_tags):
        print VAL_TAGS_HEADER
        tag_str_fmt = "%s: %s"
        valid_tags = self.valid_common_tags(common_tags)
        not_valid_tags = filter(lambda x: x not in valid_tags, common_tags)
        # Print OK tags
        for t in valid_tags:
            tag_str = GREEN_TEXT % tag_str_fmt
            print tag_str % (t.capitalize(), self[0][t])
        # Print error tags
        red_tag_str = RED_TEXT % tag_str_fmt
        for t in not_valid_tags:
            print red_tag_str % (t.capitalize(), list(set(self.common_tag_values(t))))
        tags_not_found = filter(lambda x: x not in common_tags, required_common_tags)
        for t in tags_not_found:
            print red_tag_str % (t.capitalize(), '')

    def validate_tracks(self):
        print VAL_TRACKS_HEADER
        valid_titles = self.valid_track_titles()
        not_valid_titles = []
        valid_tracknums = self.valid_track_numbers()
        # Print tracks and title tags
        #for track in self:
        #    tracknum = track['tracknumber'][0] if 'tracknumber' in track else ''
        #    tracktitle = track['title'] if 'title' in track else ''
        #    track_color = 33 if parse_tracknum(tracknum)[1] or not valid_tracknums else 32
        #    title_color = 31 if tracktitle and tracktitle[0] not in valid_titles else 32
        #    track_str = "\033[1;%dm%s\033[1;m | \033[1;%dm%s\033[1;m"
        #    print track_str % (track_color, tracknum, title_color, tracktitle)
        # Print error messages
        for track in self:
            tracknum = track['tracknumber'][0] if 'tracknumber' in track else ''
            tracktitle = track['title'] if 'title' in track else ''
            color_text = GREEN_TEXT if not parse_tracknum(tracknum)[1] and (tracktitle and tracktitle[0] in valid_titles) else RED_TEXT
            print color_text % "%s | %s" % (tracknum, tracktitle)

        if not valid_tracknums:
            print RED_TEXT % "ERROR: Tracks missing or not starting from track 1"

    def validate_filenames(self, regex):
        print VAL_FILENAMES_HEADER
        valid_filenames = self.valid_filenames_format(regex)
        valid_titles = self.valid_filename_titles()
        valid_tracknums = self.valid_filename_track_numbers()
        not_valid_filenames = filter(lambda x: x not in valid_filenames, self.filenames)
        not_valid_titles = filter(lambda x: x not in valid_titles, self.filenames)
        not_valid_tracknums = filter(lambda x: x not in valid_tracknums, self.filenames)
        for f in self.filenames:
            err = filter(lambda x: f in x, (not_valid_filenames, not_valid_tracknums, not_valid_titles))
            color = RED_TEXT if err else GREEN_TEXT
            print color % f