# An APEv2 tag reader
#
# Copyright 2005 Joe Wreschnig
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# $Id: apev2.py 4008 2007-04-21 04:02:07Z piman $

"""APEv2 reading and writing.

The APEv2 format is most commonly used with Musepack files, but is
also the format of choice for WavPack and other formats. Some MP3s
also have APEv2 tags, but this can cause problems with many MP3
decoders and taggers.

APEv2 tags, like Vorbis comments, are freeform key=value pairs. APEv2
keys can be any ASCII string with characters from 0x20 to 0x7E,
between 2 and 255 characters long.  Keys are case-sensitive, but
readers are recommended to be case insensitive, and it is forbidden to
multiple keys which differ only in case.  Keys are usually stored
title-cased (e.g. 'Artist' rather than 'artist').

APEv2 values are slightly more structured than Vorbis comments; values
are flagged as one of text, binary, or an external reference (usually
a URI).

Based off the format specification found at
http://wiki.hydrogenaudio.org/index.php?title=APEv2_specification.
"""

__all__ = ["APEv2", "APEv2File", "Open", "delete"]

import struct
from cStringIO import StringIO

def is_valid_apev2_key(key):
    return (2 <= len(key) <= 255 and min(key) >= ' ' and max(key) <= '~' and
            key not in ["OggS", "TAG", "ID3", "MP+"])

# There are three different kinds of APE tag values.
# "0: Item contains text information coded in UTF-8
#  1: Item contains binary information
#  2: Item is a locator of external stored information [e.g. URL]
#  3: reserved"
TEXT, BINARY, EXTERNAL = range(3)

HAS_HEADER = 1L << 31
HAS_NO_FOOTER = 1L << 30
IS_HEADER  = 1L << 29

class error(IOError): pass
class APENoHeaderError(error, ValueError): pass
class APEUnsupportedVersionError(error, ValueError): pass
class APEBadItemError(error, ValueError): pass

from mutagen import Metadata, FileType
from mutagen._util import DictMixin, cdata, utf8, delete_bytes

class _APEv2Data(object):
    # Store offsets of the important parts of the file.
    start = header = data = footer = end = None
    # Footer or header; seek here and read 32 to get version/size/items/flags
    metadata = None
    # Actual tag data
    tag = None

    version = None
    size = None
    items = None
    flags = 0

    # The tag is at the start rather than the end. A tag at both
    # the start and end of the file (i.e. the tag is the whole file)
    # is not considered to be at the start.
    is_at_start = False

    def __init__(self, fileobj):
        self.__find_metadata(fileobj)
        self.metadata = max(self.header, self.footer)
        if self.metadata is None: return
        self.__fill_missing(fileobj)
        self.__fix_brokenness(fileobj)
        if self.data is not None:
            fileobj.seek(self.data)
            self.tag = fileobj.read(self.size)

    def __find_metadata(self, fileobj):
        # Try to find a header or footer.

        # Check for a simple footer.
        try: fileobj.seek(-32, 2)
        except IOError:
            fileobj.seek(0, 2)
            return
        if fileobj.read(8) == "APETAGEX":
            fileobj.seek(-8, 1)
            self.footer = self.metadata = fileobj.tell()
            return

        # Check for an APEv2 tag followed by an ID3v1 tag at the end.
        try:
            fileobj.seek(-128, 2)
            if fileobj.read(3) == "TAG":

                fileobj.seek(-35, 1) # "TAG" + header length
                if fileobj.read(8) == "APETAGEX":
                    fileobj.seek(-8, 1)
                    self.footer = fileobj.tell()
                    return

                # ID3v1 tag at the end, maybe preceded by Lyrics3v2.
                # (http://www.id3.org/lyrics3200.html)
                # (header length - "APETAGEX") - "LYRICS200"
                fileobj.seek(15, 1)
                if fileobj.read(9) == 'LYRICS200':
                    fileobj.seek(-15, 1) # "LYRICS200" + size tag
                    try: offset = int(fileobj.read(6))
                    except ValueError:
                        raise IOError

                    fileobj.seek(-32 - offset - 6, 1)
                    if fileobj.read(8) == "APETAGEX":
                        fileobj.seek(-8, 1)
                        self.footer = fileobj.tell()
                        return

        except IOError:
            pass

        # Check for a tag at the start.
        fileobj.seek(0, 0)
        if fileobj.read(8) == "APETAGEX":
            self.is_at_start = True
            self.header = 0

    def __fill_missing(self, fileobj):
        fileobj.seek(self.metadata + 8)
        self.version = fileobj.read(4)
        self.size = cdata.uint_le(fileobj.read(4))
        self.items = cdata.uint_le(fileobj.read(4))
        self.flags = cdata.uint_le(fileobj.read(4))

        if self.header is not None:
            self.data = self.header + 32
            # If we're reading the header, the size is the header
            # offset + the size, which includes the footer.
            self.end = self.data + self.size
            fileobj.seek(self.end - 32, 0)
            if fileobj.read(8) == "APETAGEX":
                self.footer = self.end - 32
        elif self.footer is not None:
            self.end = self.footer + 32
            self.data = self.end - self.size
            if self.flags & HAS_HEADER:
                self.header = self.data - 32
            else:
                self.header = self.data
        else: raise APENoHeaderError("No APE tag found")

    def __fix_brokenness(self, fileobj):
        # Fix broken tags written with PyMusepack.
        if self.header is not None: start = self.header
        else: start = self.data
        fileobj.seek(start)

        while start > 0:
            # Clean up broken writing from pre-Mutagen PyMusepack.
            # It didn't remove the first 24 bytes of header.
            try: fileobj.seek(-24, 1)
            except IOError:
                break
            else:
                if fileobj.read(8) == "APETAGEX":
                    fileobj.seek(-8, 1)
                    start = fileobj.tell()
                else: break
        self.start = start

class APEv2(DictMixin, Metadata):
    """A file with an APEv2 tag.

    ID3v1 tags are silently ignored and overwritten.
    """

    filename = None

    def __init__(self, *args, **kwargs):
        self.__casemap = {}
        self.__dict = {}
        super(APEv2, self).__init__(*args, **kwargs)
        # Internally all names are stored as lowercase, but the case
        # they were set with is remembered and used when saving.  This
        # is roughly in line with the standard, which says that keys
        # are case-sensitive but two keys differing only in case are
        # not allowed, and recommends case-insensitive
        # implementations.

    def pprint(self):
        """Return tag key=value pairs in a human-readable format."""
        items = self.items()
        items.sort()
        return "\n".join(["%s=%s" % (k, v.pprint()) for k, v in items])

    def load(self, filename):
        """Load tags from a filename."""
        self.filename = filename
        fileobj = file(filename, "rb")
        try:
            data = _APEv2Data(fileobj)
        finally:
            fileobj.close()
        if data.tag:
            self.clear()
            self.__casemap.clear()
            self.__parse_tag(data.tag, data.items)
        else:
            raise APENoHeaderError("No APE tag found")

    def __parse_tag(self, tag, count):
        fileobj = StringIO(tag)

        for i in range(count):
            size = cdata.uint_le(fileobj.read(4))
            flags = cdata.uint_le(fileobj.read(4))

            # Bits 1 and 2 bits are flags, 0-3
            # Bit 0 is read/write flag, ignored
            kind = (flags & 6) >> 1
            if kind == 3:
                raise APEBadItemError("value type must be 0, 1, or 2")
            key = value = fileobj.read(1)
            while key[-1:] != '\x00' and value:
                value = fileobj.read(1)
                key += value
            if key[-1:] == "\x00":
                key = key[:-1]
            value = fileobj.read(size)
            self[key] = APEValue(value, kind)

    def __getitem__(self, key):
        if not is_valid_apev2_key(key):
            raise KeyError("%r is not a valid APEv2 key" % key)
        return self.__dict[key.lower()]

    def __delitem__(self, key):
        if not is_valid_apev2_key(key):
            raise KeyError("%r is not a valid APEv2 key" % key)
        del(self.__dict[key.lower()])

    def __setitem__(self, key, value):
        """'Magic' value setter.

        This function tries to guess at what kind of value you want to
        store. If you pass in a valid UTF-8 or Unicode string, it
        treats it as a text value. If you pass in a list, it treats it
        as a list of string/Unicode values.  If you pass in a string
        that is not valid UTF-8, it assumes it is a binary value.

        If you need to force a specific type of value (e.g. binary
        data that also happens to be valid UTF-8, or an external
        reference), use the APEValue factory and set the value to the
        result of that:
            from mutagen.apev2 import APEValue, EXTERNAL
            tag['Website'] = APEValue('http://example.org', EXTERNAL)
        """

        if not is_valid_apev2_key(key):
            raise KeyError("%r is not a valid APEv2 key" % key)

        if not isinstance(value, _APEValue):
            # let's guess at the content if we're not already a value...
            if isinstance(value, unicode):
                # unicode? we've got to be text.
                value = APEValue(utf8(value), TEXT)
            elif isinstance(value, list):
                # list? text.
                value = APEValue("\0".join(map(utf8, value)), TEXT)
            else:
                try: dummy = value.decode("utf-8")
                except UnicodeError:
                    # invalid UTF8 text, probably binary
                    value = APEValue(value, BINARY)
                else:
                    # valid UTF8, probably text
                    value = APEValue(value, TEXT)
        self.__casemap[key.lower()] = key
        self.__dict[key.lower()] = value

    def keys(self):
        return [self.__casemap.get(key, key) for key in self.__dict.keys()]

    def save(self, filename=None):
        """Save changes to a file.

        If no filename is given, the one most recently loaded is used.

        Tags are always written at the end of the file, and include
        a header and a footer.
        """

        filename = filename or self.filename
        try:
            fileobj = file(filename, "r+b")
        except IOError:
            fileobj = file(filename, "w+b")
        data = _APEv2Data(fileobj)

        if data.is_at_start:
            delete_bytes(fileobj, data.end - data.start, data.start)
        elif data.start is not None:
            fileobj.seek(data.start)
            # Delete an ID3v1 tag if present, too.
            fileobj.truncate()
        fileobj.seek(0, 2)

        # "APE tags items should be sorted ascending by size... This is
        # not a MUST, but STRONGLY recommended. Actually the items should
        # be sorted by importance/byte, but this is not feasible."
        tags = [v._internal(k) for k, v in self.items()]
        tags.sort(lambda a, b: cmp(len(a), len(b)))
        num_tags = len(tags)
        tags = "".join(tags)

        header = "APETAGEX%s%s" %(
            # version, tag size, item count, flags
            struct.pack("<4I", 2000, len(tags) + 32, num_tags,
                        HAS_HEADER | IS_HEADER),
            "\0" * 8)
        fileobj.write(header)

        fileobj.write(tags)

        footer = "APETAGEX%s%s" %(
            # version, tag size, item count, flags
            struct.pack("<4I", 2000, len(tags) + 32, num_tags,
                        HAS_HEADER),
            "\0" * 8)
        fileobj.write(footer)
        fileobj.close()

    def delete(self, filename=None):
        """Remove tags from a file."""
        filename = filename or self.filename
        fileobj = file(filename, "r+b")
        try:
            data = _APEv2Data(fileobj)
            if data.start is not None and data.size is not None:
                delete_bytes(fileobj, data.end - data.start, data.start)
        finally:
            fileobj.close()
        self.clear()

Open = APEv2

def delete(filename):
    """Remove tags from a file."""
    try: APEv2(filename).delete()
    except APENoHeaderError: pass

def APEValue(value, kind):
    """APEv2 tag value factory.

    Use this if you need to specify the value's type manually.  Binary
    and text data are automatically detected by APEv2.__setitem__.
    """
    if kind == TEXT: return APETextValue(value, kind)
    elif kind == BINARY: return APEBinaryValue(value, kind)
    elif kind == EXTERNAL: return APEExtValue(value, kind)
    else: raise ValueError("kind must be TEXT, BINARY, or EXTERNAL")

class _APEValue(object):
    def __init__(self, value, kind):
        self.kind = kind
        self.value = value

    def __len__(self):
        return len(self.value)
    def __str__(self):
        return self.value

    # Packed format for an item:
    # 4B: Value length
    # 4B: Value type
    # Key name
    # 1B: Null
    # Key value
    def _internal(self, key):
        return "%s%s\0%s" %(
            struct.pack("<2I", len(self.value), self.kind << 1),
            key, self.value)

    def __repr__(self):
        return "%s(%r, %d)" % (type(self).__name__, self.value, self.kind)

class APETextValue(_APEValue):
    """An APEv2 text value.

    Text values are Unicode/UTF-8 strings. They can be accessed like
    strings (with a null seperating the values), or arrays of strings."""

    def __unicode__(self):
        return unicode(str(self), "utf-8")

    def __iter__(self):
        """Iterate over the strings of the value (not the characters)"""
        return iter(unicode(self).split("\0"))

    def __getitem__(self, index):
        return unicode(self).split("\0")[index]

    def __len__(self):
        return self.value.count("\0") + 1

    def __cmp__(self, other):
        return cmp(unicode(self), other)

    def __setitem__(self, index, value):
        values = list(self)
        values[index] = value.encode("utf-8")
        self.value = "\0".join(values).encode("utf-8")

    def pprint(self):
        return " / ".join(self)

class APEBinaryValue(_APEValue):
    """An APEv2 binary value."""

    def pprint(self): return "[%d bytes]" % len(self)

class APEExtValue(_APEValue):
    """An APEv2 external value.

    External values are usually URI or IRI strings.
    """
    def pprint(self): return "[External] %s" % unicode(self)

class APEv2File(FileType):
    class _Info(object):
        length = 0
        bitrate = 0
        def __init__(self, fileobj): pass
        pprint = staticmethod(lambda: "Unknown format with APEv2 tag.")

    def load(self, filename):
        self.filename = filename
        self.info = self._Info(file(filename, "rb"))
        try: self.tags = APEv2(filename)
        except error: self.tags = None

    def add_tags(self):
        if self.tags is None:
            self.tags = APEv2()
        else:
            raise ValueError("%r already has tags: %r" % (self, self.tags))

    def score(filename, fileobj, header):
        try: fileobj.seek(-160, 2)
        except IOError:
            fileobj.seek(0)
        footer = fileobj.read()
        filename = filename.lower()
        return (("APETAGEX" in footer) - header.startswith("ID3"))
    score = staticmethod(score)
