Skip to content
Snippets Groups Projects
metadata.py 12.5 KiB
Newer Older
import mutagen._util
import mutagen.oggtheora
import mutagen.oggvorbis
import mutagen.flac

Eliot Berriot's avatar
Eliot Berriot committed
from django import forms
Eliot Berriot's avatar
Eliot Berriot committed
    if k == "pictures":
        return f.tags.getall("APIC")
    # First we try to grab the standard key
Eliot Berriot's avatar
Eliot Berriot committed
    possible_attributes = [("text", True), ("url", False)]
    for attr, select_first in possible_attributes:
        try:
            v = getattr(f.tags[k], attr)
            if select_first:
                v = v[0]
            return v
        except KeyError:
            break
        except AttributeError:
            continue

    # then we fallback on parsing non standard tags
Eliot Berriot's avatar
Eliot Berriot committed
    all_tags = f.tags.getall("TXXX")
Eliot Berriot's avatar
Eliot Berriot committed
        matches = [t for t in all_tags if t.desc.lower() == k.lower()]
        return matches[0].text[0]
    except (KeyError, IndexError):
        raise TagNotFound(k)


def clean_id3_pictures(apic):
    pictures = []
    for p in list(apic):
Eliot Berriot's avatar
Eliot Berriot committed
        pictures.append(
            {
                "mimetype": p.mime,
                "content": p.data,
                "description": p.desc,
                "type": p.type.real,
            }
        )
def get_flac_tag(f, k):
Eliot Berriot's avatar
Eliot Berriot committed
    if k == "pictures":
    except (KeyError, IndexError):
        raise TagNotFound(k)


def clean_flac_pictures(apic):
    pictures = []
    for p in list(apic):
Eliot Berriot's avatar
Eliot Berriot committed
        pictures.append(
            {
                "mimetype": p.mime,
                "content": p.data,
                "description": p.desc,
                "type": p.type.real,
            }
        )
def clean_ogg_pictures(metadata_block_picture):
    pictures = []
    for b64_data in [metadata_block_picture]:

        try:
            data = base64.b64decode(b64_data)
        except (TypeError, ValueError):
            continue

        try:
            picture = mutagen.flac.Picture(data)
        except mutagen.flac.FLACError:
            continue

        pictures.append(
            {
                "mimetype": picture.mime,
                "content": picture.data,
                "description": "",
                "type": picture.type.real,
            }
        )
    return pictures


Eliot Berriot's avatar
Eliot Berriot committed
        return [t for t in f.tags.getall("UFID") if "musicbrainz.org" in t.owner][
            0
        ].data.decode("utf-8")
    try:
        return int(v)
    except ValueError:
        # maybe the position is of the form "1/4"
        pass

    try:
Eliot Berriot's avatar
Eliot Berriot committed
        return int(v.split("/")[0])
    except (ValueError, AttributeError, IndexError):
        pass

class FirstUUIDField(forms.UUIDField):
    def to_python(self, value):
        try:
Eliot Berriot's avatar
Eliot Berriot committed
            # sometimes, Picard leaves two uuids in the field, separated
            # by a slash or a ;
            value = value.split(";")[0].split("/")[0].strip()
        except (AttributeError, IndexError, TypeError):
            pass

        return super().to_python(value)


    ADDITIONAL_FORMATS = ["%Y-%d-%m %H:%M"]  # deezer date format
    try:
        parsed = pendulum.parse(str(value))
        return datetime.date(parsed.year, parsed.month, parsed.day)
    except pendulum.exceptions.ParserError:
        pass

    for date_format in ADDITIONAL_FORMATS:
        try:
            parsed = datetime.datetime.strptime(value, date_format)
        except ValueError:
            continue
        else:
            return datetime.date(parsed.year, parsed.month, parsed.day)

    raise ParseError("{} cannot be parsed as a date".format(value))
Eliot Berriot's avatar
Eliot Berriot committed
def split_and_return_first(separator):
    def inner(v):
        return v.split(separator)[0].strip()

    return inner


VALIDATION = {
Eliot Berriot's avatar
Eliot Berriot committed
    "musicbrainz_artistid": FirstUUIDField(),
    "musicbrainz_albumid": FirstUUIDField(),
    "musicbrainz_recordingid": FirstUUIDField(),
Eliot Berriot's avatar
Eliot Berriot committed
    "musicbrainz_albumartistid": FirstUUIDField(),
    "OggOpus": {
        "getter": lambda f, k: f[k][0],
        "fields": {
            "track_number": {
                "field": "TRACKNUMBER",
                "to_application": convert_position,
            "disc_number": {"field": "DISCNUMBER", "to_application": convert_position},
            "title": {},
            "artist": {},
Eliot Berriot's avatar
Eliot Berriot committed
            "album_artist": {
                "field": "albumartist",
                "to_application": split_and_return_first(";"),
            },
            "album": {},
            "date": {"field": "date", "to_application": get_date},
            "musicbrainz_albumid": {},
            "musicbrainz_artistid": {},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_albumartistid": {},
            "musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
Eliot Berriot's avatar
Eliot Berriot committed
            "license": {},
            "copyright": {},
Eliot Berriot's avatar
Eliot Berriot committed
    "OggVorbis": {
        "getter": lambda f, k: f[k][0],
        "fields": {
            "track_number": {
                "field": "TRACKNUMBER",
                "to_application": convert_position,
Eliot Berriot's avatar
Eliot Berriot committed
            },
            "disc_number": {"field": "DISCNUMBER", "to_application": convert_position},
Eliot Berriot's avatar
Eliot Berriot committed
            "title": {},
            "artist": {},
Eliot Berriot's avatar
Eliot Berriot committed
            "album_artist": {
                "field": "albumartist",
                "to_application": split_and_return_first(";"),
            },
Eliot Berriot's avatar
Eliot Berriot committed
            "album": {},
            "date": {"field": "date", "to_application": get_date},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_albumid": {},
            "musicbrainz_artistid": {},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_albumartistid": {},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
Eliot Berriot's avatar
Eliot Berriot committed
            "license": {},
            "copyright": {},
            "pictures": {
                "field": "metadata_block_picture",
                "to_application": clean_ogg_pictures,
            },
Eliot Berriot's avatar
Eliot Berriot committed
        },
EorlBruder's avatar
EorlBruder committed
    },
Eliot Berriot's avatar
Eliot Berriot committed
    "OggTheora": {
        "getter": lambda f, k: f[k][0],
        "fields": {
            "track_number": {
                "field": "TRACKNUMBER",
                "to_application": convert_position,
Eliot Berriot's avatar
Eliot Berriot committed
            },
            "disc_number": {"field": "DISCNUMBER", "to_application": convert_position},
Eliot Berriot's avatar
Eliot Berriot committed
            "title": {},
            "artist": {},
Eliot Berriot's avatar
Eliot Berriot committed
            "album_artist": {"field": "albumartist"},
Eliot Berriot's avatar
Eliot Berriot committed
            "album": {},
            "date": {"field": "date", "to_application": get_date},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_albumid": {"field": "MusicBrainz Album Id"},
            "musicbrainz_artistid": {"field": "MusicBrainz Artist Id"},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_albumartistid": {"field": "MusicBrainz Album Artist Id"},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_recordingid": {"field": "MusicBrainz Track Id"},
Eliot Berriot's avatar
Eliot Berriot committed
        },
Eliot Berriot's avatar
Eliot Berriot committed
    "MP3": {
        "getter": get_id3_tag,
        "clean_pictures": clean_id3_pictures,
        "fields": {
            "track_number": {"field": "TRCK", "to_application": convert_position},
            "disc_number": {"field": "TPOS", "to_application": convert_position},
Eliot Berriot's avatar
Eliot Berriot committed
            "title": {"field": "TIT2"},
            "artist": {"field": "TPE1"},
Eliot Berriot's avatar
Eliot Berriot committed
            "album_artist": {"field": "TPE2"},
Eliot Berriot's avatar
Eliot Berriot committed
            "album": {"field": "TALB"},
            "date": {"field": "TDRC", "to_application": get_date},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_albumid": {"field": "MusicBrainz Album Id"},
            "musicbrainz_artistid": {"field": "MusicBrainz Artist Id"},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_albumartistid": {"field": "MusicBrainz Album Artist Id"},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_recordingid": {
                "field": "UFID",
                "getter": get_mp3_recording_id,
            },
            "pictures": {},
Eliot Berriot's avatar
Eliot Berriot committed
            "license": {"field": "WCOP"},
            "copyright": {"field": "TCOP"},
Eliot Berriot's avatar
Eliot Berriot committed
        },
Eliot Berriot's avatar
Eliot Berriot committed
    "FLAC": {
        "getter": get_flac_tag,
        "clean_pictures": clean_flac_pictures,
        "fields": {
            "track_number": {
                "field": "tracknumber",
                "to_application": convert_position,
Eliot Berriot's avatar
Eliot Berriot committed
            },
            "disc_number": {"field": "discnumber", "to_application": convert_position},
Eliot Berriot's avatar
Eliot Berriot committed
            "title": {},
            "artist": {},
Eliot Berriot's avatar
Eliot Berriot committed
            "album_artist": {"field": "albumartist"},
Eliot Berriot's avatar
Eliot Berriot committed
            "album": {},
            "date": {"field": "date", "to_application": get_date},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_albumid": {},
            "musicbrainz_artistid": {},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_albumartistid": {},
Eliot Berriot's avatar
Eliot Berriot committed
            "musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
            "test": {},
            "pictures": {},
Eliot Berriot's avatar
Eliot Berriot committed
            "license": {},
            "copyright": {},
Eliot Berriot's avatar
Eliot Berriot committed
        },
Eliot Berriot's avatar
Eliot Berriot committed
ALL_FIELDS = [
    "track_number",
Eliot Berriot's avatar
Eliot Berriot committed
    "title",
    "artist",
    "album_artist",
    "album",
    "date",
    "musicbrainz_albumid",
    "musicbrainz_artistid",
    "musicbrainz_albumartistid",
    "musicbrainz_recordingid",
Eliot Berriot's avatar
Eliot Berriot committed
    "license",
    "copyright",
    def __init__(self, filething, kind=mutagen.File):
        self._file = kind(filething)
        if self._file is None:
            raise ValueError("Cannot parse metadata from {}".format(filething))
        self.fallback = self.load_fallback(filething, self._file)
        ft = self.get_file_type(self._file)
        try:
            self._conf = CONF[ft]
        except KeyError:
Eliot Berriot's avatar
Eliot Berriot committed
            raise ValueError("Unsupported format {}".format(ft))
    def get_file_type(self, f):
        return f.__class__.__name__

    def load_fallback(self, filething, parent):
        """
        In some situations, such as Ogg Theora files tagged with MusicBrainz Picard,
        part of the tags are only available in the ogg vorbis comments
        """
        try:
            filething.seek(0)
        except AttributeError:
            pass
        if isinstance(parent, mutagen.oggtheora.OggTheora):
            try:
                return Metadata(filething, kind=mutagen.oggvorbis.OggVorbis)
            except (ValueError, mutagen._util.MutagenError):
                raise
                pass

    def get(self, key, default=NODEFAULT):
        try:
            return self._get_from_self(key)
        except TagNotFound:
            if not self.fallback:
                if default != NODEFAULT:
                    return default
                else:
                    raise
            else:
                return self.fallback.get(key, default=default)
        except UnsupportedTag:
            if not self.fallback:
                raise
            else:
                return self.fallback.get(key, default=default)

    def _get_from_self(self, key, default=NODEFAULT):
Eliot Berriot's avatar
Eliot Berriot committed
            field_conf = self._conf["fields"][key]
Eliot Berriot's avatar
Eliot Berriot committed
            raise UnsupportedTag("{} is not supported for this file format".format(key))
        real_key = field_conf.get("field", key)
Eliot Berriot's avatar
Eliot Berriot committed
            getter = field_conf.get("getter", self._conf["getter"])
Eliot Berriot's avatar
Eliot Berriot committed
        converter = field_conf.get("to_application")
        field = VALIDATION.get(key)
        if field:
            v = field.to_python(v)
Eliot Berriot's avatar
Eliot Berriot committed
        """
        Return a dict containing all metadata of the file
        """

        data = {}
        for field in ALL_FIELDS:
            try:
                data[field] = self.get(field, None)
            except (TagNotFound, forms.ValidationError):
                data[field] = None
            except ParseError as e:
                if not ignore_parse_errors:
                    raise
                logger.warning("Unparsable field {}: {}".format(field, str(e)))
                data[field] = None
Eliot Berriot's avatar
Eliot Berriot committed

        return data

    def get_picture(self, *picture_types):
        if not picture_types:
            raise ValueError("You need to request at least one picture type")
        ptypes = [
            getattr(mutagen.id3.PictureType, picture_type.upper())
            for picture_type in picture_types
        ]

Eliot Berriot's avatar
Eliot Berriot committed
            pictures = self.get("pictures")
Eliot Berriot's avatar
Eliot Berriot committed
        cleaner = self._conf.get("clean_pictures", lambda v: v)
        if not pictures:
            return
        for ptype in ptypes:
            for p in pictures:
                if p["type"] == ptype:
                    return p