mirror of
https://github.com/rembo10/headphones.git
synced 2026-03-21 20:29:27 +00:00
466 lines
16 KiB
Python
466 lines
16 KiB
Python
# 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 lib.mutagen import Metadata, FileType
|
|
from lib.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 lib.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)
|