+# id3 support for mutagen
+# Copyright (C) 2005 Michael Urman
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of version 2 of the GNU General Public License as
+# published by the Free Software Foundation.
+#
+# $Id: id3.py 4275 2008-06-01 06:32:37Z piman $
+
+"""ID3v2 reading and writing.
+
+This is based off of the following references:
+ http://www.id3.org/id3v2.4.0-structure.txt
+ http://www.id3.org/id3v2.4.0-frames.txt
+ http://www.id3.org/id3v2.3.0.html
+ http://www.id3.org/id3v2-00.txt
+ http://www.id3.org/id3v1.html
+
+Its largest deviation from the above (versions 2.3 and 2.2) is that it
+will not interpret the / characters as a separator, and will almost
+always accept null separators to generate multi-valued text frames.
+
+Because ID3 frame structure differs between frame types, each frame is
+implemented as a different class (e.g. TIT2 as mutagen.id3.TIT2). Each
+frame's documentation contains a list of its attributes.
+
+Since this file's documentation is a little unwieldy, you are probably
+interested in the 'ID3' class to start with.
+"""
+
+__all__ = ['ID3', 'ID3FileType', 'Frames', 'Open', 'delete']
+
+import struct; from struct import unpack, pack
+from zlib import error as zlibError
+from warnings import warn
+
+import mutagen
+from mutagen._util import insert_bytes, delete_bytes, DictProxy
+
+class error(Exception): pass
+class ID3NoHeaderError(error, ValueError): pass
+class ID3BadUnsynchData(error, ValueError): pass
+class ID3BadCompressedData(error, ValueError): pass
+class ID3TagError(error, ValueError): pass
+class ID3UnsupportedVersionError(error, NotImplementedError): pass
+class ID3EncryptionUnsupportedError(error, NotImplementedError): pass
+class ID3JunkFrameError(error, ValueError): pass
+
+class ID3Warning(error, UserWarning): pass
+
+def is_valid_frame_id(frame_id):
+ return frame_id.isalnum() and frame_id.isupper()
+
+class ID3(DictProxy, mutagen.Metadata):
+ """A file with an ID3v2 tag.
+
+ Attributes:
+ version -- ID3 tag version as a tuple
+ unknown_frames -- raw frame data of any unknown frames found
+ size -- the total size of the ID3 tag, including the header
+ """
+
+ PEDANTIC = True
+ version = (2, 4, 0)
+
+ filename = None
+ size = 0
+ __flags = 0
+ __readbytes = 0
+ __crc = None
+
+ def __init__(self, *args, **kwargs):
+ self.unknown_frames = []
+ super(ID3, self).__init__(*args, **kwargs)
+
+ def __fullread(self, size):
+ try:
+ if size < 0:
+ raise ValueError('Requested bytes (%s) less than zero' % size)
+ if size > self.__filesize:
+ raise EOFError('Requested %#x of %#x (%s)' %
+ (long(size), long(self.__filesize), self.filename))
+ except AttributeError: pass
+ data = self.__fileobj.read(size)
+ if len(data) != size: raise EOFError
+ self.__readbytes += size
+ return data
+
+ def load(self, filename, known_frames=None, translate=True):
+ """Load tags from a filename.
+
+ Keyword arguments:
+ filename -- filename to load tag data from
+ known_frames -- dict mapping frame IDs to Frame objects
+ translate -- Update all tags to ID3v2.4 internally. Mutagen is
+ only capable of writing ID3v2.4 tags, so if you
+ intend to save, this must be true.
+
+ Example of loading a custom frame:
+ my_frames = dict(mutagen.id3.Frames)
+ class XMYF(Frame): ...
+ my_frames["XMYF"] = XMYF
+ mutagen.id3.ID3(filename, known_frames=my_frames)
+ """
+
+ from os.path import getsize
+ self.filename = filename
+ self.__known_frames = known_frames
+ self.__fileobj = file(filename, 'rb')
+ self.__filesize = getsize(filename)
+ try:
+ try:
+ self.__load_header()
+ except EOFError:
+ self.size = 0
+ raise ID3NoHeaderError("%s: too small (%d bytes)" %(
+ filename, self.__filesize))
+ except (ID3NoHeaderError, ID3UnsupportedVersionError), err:
+ self.size = 0
+ import sys
+ stack = sys.exc_info()[2]
+ try: self.__fileobj.seek(-128, 2)
+ except EnvironmentError: raise err, None, stack
+ else:
+ frames = ParseID3v1(self.__fileobj.read(128))
+ if frames is not None:
+ self.version = (1, 1)
+ map(self.add, frames.values())
+ else: raise err, None, stack
+ else:
+ frames = self.__known_frames
+ if frames is None:
+ if (2,3,0) <= self.version: frames = Frames
+ elif (2,2,0) <= self.version: frames = Frames_2_2
+ data = self.__fullread(self.size - 10)
+ for frame in self.__read_frames(data, frames=frames):
+ if isinstance(frame, Frame): self.add(frame)
+ else: self.unknown_frames.append(frame)
+ finally:
+ self.__fileobj.close()
+ del self.__fileobj
+ del self.__filesize
+ if translate:
+ self.update_to_v24()
+
+ def getall(self, key):
+ """Return all frames with a given name (the list may be empty).
+
+ This is best explained by examples:
+ id3.getall('TIT2') == [id3['TIT2']]
+ id3.getall('TTTT') == []
+ id3.getall('TXXX') == [TXXX(desc='woo', text='bar'),
+ TXXX(desc='baz', text='quuuux'), ...]
+
+ Since this is based on the frame's HashKey, which is
+ colon-separated, you can use it to do things like
+ getall('COMM:MusicMatch') or getall('TXXX:QuodLibet:').
+ """
+ if key in self: return [self[key]]
+ else:
+ key = key + ":"
+ return [v for s,v in self.items() if s.startswith(key)]
+
+ def delall(self, key):
+ """Delete all tags of a given kind; see getall."""
+ if key in self: del(self[key])
+ else:
+ key = key + ":"
+ for k in filter(lambda s: s.startswith(key), self.keys()):
+ del(self[k])
+
+ def setall(self, key, values):
+ """Delete frames of the given type and add frames in 'values'."""
+ self.delall(key)
+ for tag in values:
+ self[tag.HashKey] = tag
+
+ def pprint(self):
+ """Return tags in a human-readable format.
+
+ "Human-readable" is used loosely here. The format is intended
+ to mirror that used for Vorbis or APEv2 output, e.g.
+ TIT2=My Title
+ However, ID3 frames can have multiple keys:
+ POPM=user@example.org=3 128/255
+ """
+ return "\n".join(map(Frame.pprint, self.values()))
+
+ def loaded_frame(self, tag):
+ """Deprecated; use the add method."""
+ # turn 2.2 into 2.3/2.4 tags
+ if len(type(tag).__name__) == 3: tag = type(tag).__base__(tag)
+ self[tag.HashKey] = tag
+
+ # add = loaded_frame (and vice versa) break applications that
+ # expect to be able to override loaded_frame (e.g. Quod Libet),
+ # as does making loaded_frame call add.
+ def add(self, frame):
+ """Add a frame to the tag."""
+ return self.loaded_frame(frame)
+
+ def __load_header(self):
+ fn = self.filename
+ data = self.__fullread(10)
+ id3, vmaj, vrev, flags, size = unpack('>3sBBB4s', data)
+ self.__flags = flags
+ self.size = BitPaddedInt(size) + 10
+ self.version = (2, vmaj, vrev)
+
+ if id3 != 'ID3':
+ raise ID3NoHeaderError("'%s' doesn't start with an ID3 tag" % fn)
+ if vmaj not in [2, 3, 4]:
+ raise ID3UnsupportedVersionError("'%s' ID3v2.%d not supported"
+ % (fn, vmaj))
+
+ if self.PEDANTIC:
+ if (2,4,0) <= self.version and (flags & 0x0f):
+ raise ValueError("'%s' has invalid flags %#02x" % (fn, flags))
+ elif (2,3,0) <= self.version and (flags & 0x1f):
+ raise ValueError("'%s' has invalid flags %#02x" % (fn, flags))
+
+ if self.f_extended:
+ if self.version >= (2,4,0):
+ # "Where the 'Extended header size' is the size of the whole
+ # extended header, stored as a 32 bit synchsafe integer."
+ self.__extsize = BitPaddedInt(self.__fullread(4)) - 4
+ else:
+ # "Where the 'Extended header size', currently 6 or 10 bytes,
+ # excludes itself."
+ self.__extsize = unpack('>L', self.__fullread(4))[0]
+ self.__extdata = self.__fullread(self.__extsize)
+
+ def __determine_bpi(self, data, frames):
+ if self.version < (2,4,0): return int
+ # have to special case whether to use bitpaddedints here
+ # spec says to use them, but iTunes has it wrong
+
+ # count number of tags found as BitPaddedInt and how far past
+ o = 0
+ asbpi = 0
+ while o < len(data)-10:
+ name, size, flags = unpack('>4sLH', data[o:o+10])
+ size = BitPaddedInt(size)
+ o += 10+size
+ if name in frames: asbpi += 1
+ bpioff = o - len(data)
+
+ # count number of tags found as int and how far past
+ o = 0
+ asint = 0
+ while o < len(data)-10:
+ name, size, flags = unpack('>4sLH', data[o:o+10])
+ o += 10+size
+ if name in frames: asint += 1
+ intoff = o - len(data)
+
+ # if more tags as int, or equal and bpi is past and int is not
+ if asint > asbpi or (asint == asbpi and (bpioff >= 1 and intoff <= 1)):
+ return int
+ return BitPaddedInt
+
+ def __read_frames(self, data, frames):
+ if self.version < (2,4,0) and self.f_unsynch:
+ try: data = unsynch.decode(data)
+ except ValueError: pass
+
+ if (2,3,0) <= self.version:
+ bpi = self.__determine_bpi(data, frames)
+ while data:
+ header = data[:10]
+ try: name, size, flags = unpack('>4sLH', header)
+ except struct.error: return # not enough header
+ if name.strip('\x00') == '': return
+ size = bpi(size)
+ framedata = data[10:10+size]
+ data = data[10+size:]
+ if size == 0: continue # drop empty frames
+ try: tag = frames[name]
+ except KeyError:
+ if is_valid_frame_id(name): yield header + framedata
+ else:
+ try: yield self.__load_framedata(tag, flags, framedata)
+ except NotImplementedError: yield header + framedata
+ except ID3JunkFrameError: pass
+
+ elif (2,2,0) <= self.version:
+ while data:
+ header = data[0:6]
+ try: name, size = unpack('>3s3s', header)
+ except struct.error: return # not enough header
+ size, = struct.unpack('>L', '\x00'+size)
+ if name.strip('\x00') == '': return
+ framedata = data[6:6+size]
+ data = data[6+size:]
+ if size == 0: continue # drop empty frames
+ try: tag = frames[name]
+ except KeyError:
+ if is_valid_frame_id(name): yield header + framedata
+ else:
+ try: yield self.__load_framedata(tag, 0, framedata)
+ except NotImplementedError: yield header + framedata
+ except ID3JunkFrameError: pass
+
+ def __load_framedata(self, tag, flags, framedata):
+ return tag.fromData(self, flags, framedata)
+
+ f_unsynch = property(lambda s: bool(s.__flags & 0x80))
+ f_extended = property(lambda s: bool(s.__flags & 0x40))
+ f_experimental = property(lambda s: bool(s.__flags & 0x20))
+ f_footer = property(lambda s: bool(s.__flags & 0x10))
+
+ #f_crc = property(lambda s: bool(s.__extflags & 0x8000))
+
+ def save(self, filename=None, v1=1):
+ """Save changes to a file.
+
+ If no filename is given, the one most recently loaded is used.
+
+ Keyword arguments:
+ v1 -- if 0, ID3v1 tags will be removed
+ if 1, ID3v1 tags will be updated but not added
+ if 2, ID3v1 tags will be created and/or updated
+
+ The lack of a way to update only an ID3v1 tag is intentional.
+ """
+
+ # Sort frames by 'importance'
+ order = ["TIT2", "TPE1", "TRCK", "TALB", "TPOS", "TDRC", "TCON"]
+ order = dict(zip(order, range(len(order))))
+ last = len(order)
+ frames = self.items()
+ frames.sort(lambda a, b: cmp(order.get(a[0][:4], last),
+ order.get(b[0][:4], last)))
+
+ framedata = [self.__save_frame(frame) for (key, frame) in frames]
+ framedata.extend([data for data in self.unknown_frames
+ if len(data) > 10])
+ if not framedata:
+ try:
+ self.delete(filename)
+ except EnvironmentError, err:
+ from errno import ENOENT
+ if err.errno != ENOENT: raise
+ return
+
+ framedata = ''.join(framedata)
+ framesize = len(framedata)
+
+ if filename is None: filename = self.filename
+ try: f = open(filename, 'rb+')
+ except IOError, err:
+ from errno import ENOENT
+ if err.errno != ENOENT: raise
+ f = open(filename, 'ab') # create, then reopen
+ f = open(filename, 'rb+')
+ try:
+ idata = f.read(10)
+ try: id3, vmaj, vrev, flags, insize = unpack('>3sBBB4s', idata)
+ except struct.error: id3, insize = '', 0
+ insize = BitPaddedInt(insize)
+ if id3 != 'ID3': insize = -10
+
+ if insize >= framesize: outsize = insize
+ else: outsize = (framesize + 1023) & ~0x3FF
+ framedata += '\x00' * (outsize - framesize)
+
+ framesize = BitPaddedInt.to_str(outsize, width=4)
+ flags = 0
+ header = pack('>3sBBB4s', 'ID3', 4, 0, flags, framesize)
+ data = header + framedata
+
+ if (insize < outsize):
+ insert_bytes(f, outsize-insize, insize+10)
+ f.seek(0)
+ f.write(data)
+
+ try:
+ f.seek(-128, 2)
+ except IOError, err:
+ from errno import EINVAL
+ if err.errno != EINVAL: raise
+ f.seek(0, 2) # ensure read won't get "TAG"
+
+ if f.read(3) == "TAG":
+ f.seek(-128, 2)
+ if v1 > 0: f.write(MakeID3v1(self))
+ else: f.truncate()
+ elif v1 == 2:
+ f.seek(0, 2)
+ f.write(MakeID3v1(self))
+
+ finally:
+ f.close()
+
+ def delete(self, filename=None, delete_v1=True, delete_v2=True):
+ """Remove tags from a file.
+
+ If no filename is given, the one most recently loaded is used.
+
+ Keyword arguments:
+ delete_v1 -- delete any ID3v1 tag
+ delete_v2 -- delete any ID3v2 tag
+ """
+ if filename is None:
+ filename = self.filename
+ delete(filename, delete_v1, delete_v2)
+ self.clear()
+
+ def __save_frame(self, frame):
+ flags = 0
+ if self.PEDANTIC and isinstance(frame, TextFrame):
+ if len(str(frame)) == 0: return ''
+ framedata = frame._writeData()
+ usize = len(framedata)
+ if usize > 2048:
+ framedata = BitPaddedInt.to_str(usize) + framedata.encode('zlib')
+ flags |= Frame.FLAG24_COMPRESS | Frame.FLAG24_DATALEN
+ datasize = BitPaddedInt.to_str(len(framedata), width=4)
+ header = pack('>4s4sH', type(frame).__name__, datasize, flags)
+ return header + framedata
+
+ def update_to_v24(self):
+ """Convert older tags into an ID3v2.4 tag.
+
+ This updates old ID3v2 frames to ID3v2.4 ones (e.g. TYER to
+ TDRC). If you intend to save tags, you must call this function
+ at some point; it is called by default when loading the tag.
+ """
+
+ if self.version < (2,3,0): del self.unknown_frames[:]
+ # unsafe to write
+
+ # TDAT, TYER, and TIME have been turned into TDRC.
+ try:
+ if str(self.get("TYER", "")).strip("\x00"):
+ date = str(self.pop("TYER"))
+ if str(self.get("TDAT", "")).strip("\x00"):
+ dat = str(self.pop("TDAT"))
+ date = "%s-%s-%s" % (date, dat[2:], dat[:2])
+ if str(self.get("TIME", "")).strip("\x00"):
+ time = str(self.pop("TIME"))
+ date += "T%s:%s:00" % (time[:2], time[2:])
+ if "TDRC" not in self:
+ self.add(TDRC(encoding=0, text=date))
+ except UnicodeDecodeError:
+ # Old ID3 tags have *lots* of Unicode problems, so if TYER
+ # is bad, just chuck the frames.
+ pass
+
+ # TORY can be the first part of a TDOR.
+ if "TORY" in self:
+ f = self.pop("TORY")
+ if "TDOR" not in self:
+ try:
+ self.add(TDOR(encoding=0, text=str(f)))
+ except UnicodeDecodeError:
+ pass
+
+ # IPLS is now TIPL.
+ if "IPLS" in self:
+ f = self.pop("IPLS")
+ if "TIPL" not in self:
+ self.add(TIPL(encoding=f.encoding, people=f.people))
+
+ if "TCON" in self:
+ # Get rid of "(xx)Foobr" format.
+ self["TCON"].genres = self["TCON"].genres
+
+ if self.version < (2, 3):
+ # ID3v2.2 PIC frames are slightly different.
+ pics = self.getall("APIC")
+ mimes = { "PNG": "image/png", "JPG": "image/jpeg" }
+ self.delall("APIC")
+ for pic in pics:
+ newpic = APIC(
+ encoding=pic.encoding, mime=mimes.get(pic.mime, pic.mime),
+ type=pic.type, desc=pic.desc, data=pic.data)
+ self.add(newpic)
+
+ # ID3v2.2 LNK frames are just way too different to upgrade.
+ self.delall("LINK")
+
+ # These can't be trivially translated to any ID3v2.4 tags, or
+ # should have been removed already.
+ for key in ["RVAD", "EQUA", "TRDA", "TSIZ", "TDAT", "TIME", "CRM"]:
+ if key in self: del(self[key])
+
+def delete(filename, delete_v1=True, delete_v2=True):
+ """Remove tags from a file.
+
+ Keyword arguments:
+ delete_v1 -- delete any ID3v1 tag
+ delete_v2 -- delete any ID3v2 tag
+ """
+
+ f = open(filename, 'rb+')
+
+ if delete_v1:
+ try:
+ f.seek(-128, 2)
+ except IOError: pass
+ else:
+ if f.read(3) == "TAG":
+ f.seek(-128, 2)
+ f.truncate()
+
+ # technically an insize=0 tag is invalid, but we delete it anyway
+ # (primarily because we used to write it)
+ if delete_v2:
+ f.seek(0, 0)
+ idata = f.read(10)
+ try: id3, vmaj, vrev, flags, insize = unpack('>3sBBB4s', idata)
+ except struct.error: id3, insize = '', -1
+ insize = BitPaddedInt(insize)
+ if id3 == 'ID3' and insize >= 0:
+ delete_bytes(f, insize + 10, 0)
+
+class BitPaddedInt(int):
+ def __new__(cls, value, bits=7, bigendian=True):
+ "Strips 8-bits bits out of every byte"
+ mask = (1<<(bits))-1
+ if isinstance(value, (int, long)):
+ bytes = []
+ while value:
+ bytes.append(value & ((1<<bits)-1))
+ value = value >> 8
+ if isinstance(value, str):
+ bytes = [ord(byte) & mask for byte in value]
+ if bigendian: bytes.reverse()
+ numeric_value = 0
+ for shift, byte in zip(range(0, len(bytes)*bits, bits), bytes):
+ numeric_value += byte << shift
+ if isinstance(numeric_value, long):
+ self = long.__new__(BitPaddedLong, numeric_value)
+ else:
+ self = int.__new__(BitPaddedInt, numeric_value)
+ self.bits = bits
+ self.bigendian = bigendian
+ return self
+
+ def as_str(value, bits=7, bigendian=True, width=4):
+ bits = getattr(value, 'bits', bits)
+ bigendian = getattr(value, 'bigendian', bigendian)
+ value = int(value)
+ mask = (1<<bits)-1
+ bytes = []
+ while value:
+ bytes.append(value & mask)
+ value = value >> bits
+ # PCNT and POPM use growing integers of at least 4 bytes as counters.
+ if width == -1: width = max(4, len(bytes))
+ if len(bytes) > width:
+ raise ValueError, 'Value too wide (%d bytes)' % len(bytes)
+ else: bytes.extend([0] * (width-len(bytes)))
+ if bigendian: bytes.reverse()
+ return ''.join(map(chr, bytes))
+ to_str = staticmethod(as_str)
+
+class BitPaddedLong(long):
+ def as_str(value, bits=7, bigendian=True, width=4):
+ return BitPaddedInt.to_str(value, bits, bigendian, width)
+ to_str = staticmethod(as_str)
+
+class unsynch(object):
+ def decode(value):
+ output = []
+ safe = True
+ append = output.append
+ for val in value:
+ if safe:
+ append(val)
+ safe = val != '\xFF'
+ else:
+ if val >= '\xE0': raise ValueError('invalid sync-safe string')
+ elif val != '\x00': append(val)
+ safe = True
+ if not safe: raise ValueError('string ended unsafe')
+ return ''.join(output)
+ decode = staticmethod(decode)
+
+ def encode(value):
+ output = []
+ safe = True
+ append = output.append
+ for val in value:
+ if safe:
+ append(val)
+ if val == '\xFF': safe = False
+ elif val == '\x00' or val >= '\xE0':
+ append('\x00')
+ append(val)
+ safe = val != '\xFF'
+ else:
+ append(val)
+ safe = True
+ if not safe: append('\x00')
+ return ''.join(output)
+ encode = staticmethod(encode)
+
+class Spec(object):
+ def __init__(self, name): self.name = name
+ def __hash__(self): raise TypeError("Spec objects are unhashable")
+
+class ByteSpec(Spec):
+ def read(self, frame, data): return ord(data[0]), data[1:]
+ def write(self, frame, value): return chr(value)
+ def validate(self, frame, value): return value
+
+class IntegerSpec(Spec):
+ def read(self, frame, data):
+ return int(BitPaddedInt(data, bits=8)), ''
+ def write(self, frame, value):
+ return BitPaddedInt.to_str(value, bits=8, width=-1)
+ def validate(self, frame, value):
+ return value
+
+class SizedIntegerSpec(Spec):
+ def __init__(self, name, size):
+ self.name, self.__sz = name, size
+ def read(self, frame, data):
+ return int(BitPaddedInt(data[:self.__sz], bits=8)), data[self.__sz:]
+ def write(self, frame, value):
+ return BitPaddedInt.to_str(value, bits=8, width=self.__sz)
+ def validate(self, frame, value):
+ return value
+
+class EncodingSpec(ByteSpec):
+ def read(self, frame, data):
+ enc, data = super(EncodingSpec, self).read(frame, data)
+ if enc < 16: return enc, data
+ else: return 0, chr(enc)+data
+
+ def validate(self, frame, value):
+ if 0 <= value <= 3: return value
+ if value is None: return None
+ raise ValueError, 'Invalid Encoding: %r' % value
+
+class StringSpec(Spec):
+ def __init__(self, name, length):
+ super(StringSpec, self).__init__(name)
+ self.len = length
+ def read(s, frame, data): return data[:s.len], data[s.len:]
+ def write(s, frame, value):
+ if value is None: return '\x00' * s.len
+ else: return (str(value) + '\x00' * s.len)[:s.len]
+ def validate(s, frame, value):
+ if value is None: return None
+ if isinstance(value, basestring) and len(value) == s.len: return value
+ raise ValueError, 'Invalid StringSpec[%d] data: %r' % (s.len, value)
+
+class BinaryDataSpec(Spec):
+ def read(self, frame, data): return data, ''
+ def write(self, frame, value): return str(value)
+ def validate(self, frame, value): return str(value)
+
+class EncodedTextSpec(Spec):
+ # Okay, seriously. This is private and defined explicitly and
+ # completely by the ID3 specification. You can't just add
+ # encodings here however you want.
+ _encodings = ( ('latin1', '\x00'), ('utf16', '\x00\x00'),
+ ('utf_16_be', '\x00\x00'), ('utf8', '\x00') )
+
+ def read(self, frame, data):
+ enc, term = self._encodings[frame.encoding]
+ ret = ''
+ if len(term) == 1:
+ if term in data:
+ data, ret = data.split(term, 1)
+ else:
+ offset = -1
+ try:
+ while True:
+ offset = data.index(term, offset+1)
+ if offset & 1: continue
+ data, ret = data[0:offset], data[offset+2:]; break
+ except ValueError: pass
+
+ if len(data) < len(term): return u'', ret
+ return data.decode(enc), ret
+
+ def write(self, frame, value):
+ enc, term = self._encodings[frame.encoding]
+ return value.encode(enc) + term
+
+ def validate(self, frame, value): return unicode(value)
+
+class MultiSpec(Spec):
+ def __init__(self, name, *specs, **kw):
+ super(MultiSpec, self).__init__(name)
+ self.specs = specs
+ self.sep = kw.get('sep')
+
+ def read(self, frame, data):
+ values = []
+ while data:
+ record = []
+ for spec in self.specs:
+ value, data = spec.read(frame, data)
+ record.append(value)
+ if len(self.specs) != 1: values.append(record)
+ else: values.append(record[0])
+ return values, data
+
+ def write(self, frame, value):
+ data = []
+ if len(self.specs) == 1:
+ for v in value:
+ data.append(self.specs[0].write(frame, v))
+ else:
+ for record in value:
+ for v, s in zip(record, self.specs):
+ data.append(s.write(frame, v))
+ return ''.join(data)
+
+ def validate(self, frame, value):
+ if value is None: return []
+ if self.sep and isinstance(value, basestring):
+ value = value.split(self.sep)
+ if isinstance(value, list):
+ if len(self.specs) == 1:
+ return [self.specs[0].validate(frame, v) for v in value]
+ else:
+ return [
+ [s.validate(frame, v) for (v,s) in zip(val, self.specs)]
+ for val in value ]
+ raise ValueError, 'Invalid MultiSpec data: %r' % value
+
+class EncodedNumericTextSpec(EncodedTextSpec): pass
+class EncodedNumericPartTextSpec(EncodedTextSpec): pass
+
+class Latin1TextSpec(EncodedTextSpec):
+ def read(self, frame, data):
+ if '\x00' in data: data, ret = data.split('\x00',1)
+ else: ret = ''
+ return data.decode('latin1'), ret
+
+ def write(self, data, value):
+ return value.encode('latin1') + '\x00'
+
+ def validate(self, frame, value): return unicode(value)
+
+class ID3TimeStamp(object):
+ """A time stamp in ID3v2 format.
+
+ This is a restricted form of the ISO 8601 standard; time stamps
+ take the form of:
+ YYYY-MM-DD HH:MM:SS
+ Or some partial form (YYYY-MM-DD HH, YYYY, etc.).
+
+ The 'text' attribute contains the raw text data of the time stamp.
+ """
+
+ import re
+ def __init__(self, text):
+ if isinstance(text, ID3TimeStamp): text = text.text
+ self.text = text
+
+ __formats = ['%04d'] + ['%02d'] * 5
+ __seps = ['-', '-', ' ', ':', ':', 'x']
+ def get_text(self):
+ parts = [self.year, self.month, self.day,
+ self.hour, self.minute, self.second]
+ pieces = []
+ for i, part in enumerate(iter(iter(parts).next, None)):
+ pieces.append(self.__formats[i]%part + self.__seps[i])
+ return u''.join(pieces)[:-1]
+
+ def set_text(self, text, splitre=re.compile('[-T:/.]|\s+')):
+ year, month, day, hour, minute, second = \
+ splitre.split(text + ':::::')[:6]
+ for a in 'year month day hour minute second'.split():
+ try: v = int(locals()[a])
+ except ValueError: v = None
+ setattr(self, a, v)
+
+ text = property(get_text, set_text, doc="ID3v2.4 date and time.")
+
+ def __str__(self): return self.text
+ def __repr__(self): return repr(self.text)
+ def __cmp__(self, other): return cmp(self.text, other.text)
+ def encode(self, *args): return self.text.encode(*args)
+
+class TimeStampSpec(EncodedTextSpec):
+ def read(self, frame, data):
+ value, data = super(TimeStampSpec, self).read(frame, data)
+ return self.validate(frame, value), data
+
+ def write(self, frame, data):
+ return super(TimeStampSpec, self).write(frame,
+ data.text.replace(' ', 'T'))
+
+ def validate(self, frame, value):
+ try: return ID3TimeStamp(value)
+ except TypeError: raise ValueError, "Invalid ID3TimeStamp: %r" % value
+
+class ChannelSpec(ByteSpec):
+ (OTHER, MASTER, FRONTRIGHT, FRONTLEFT, BACKRIGHT, BACKLEFT, FRONTCENTRE,
+ BACKCENTRE, SUBWOOFER) = range(9)
+
+class VolumeAdjustmentSpec(Spec):
+ def read(self, frame, data):
+ value, = unpack('>h', data[0:2])
+ return value/512.0, data[2:]
+
+ def write(self, frame, value):
+ return pack('>h', int(round(value * 512)))
+
+ def validate(self, frame, value): return value
+
+class VolumePeakSpec(Spec):
+ def read(self, frame, data):
+ # http://bugs.xmms.org/attachment.cgi?id=113&action=view
+ peak = 0
+ bits = ord(data[0])
+ bytes = min(4, (bits + 7) >> 3)
+ # not enough frame data
+ if bytes + 1 > len(data): raise ID3JunkFrameError
+ shift = ((8 - (bits & 7)) & 7) + (4 - bytes) * 8
+ for i in range(1, bytes+1):
+ peak *= 256
+ peak += ord(data[i])
+ peak *= 2**shift
+ return (float(peak) / (2**31-1)), data[1+bytes:]
+
+ def write(self, frame, value):
+ # always write as 16 bits for sanity.
+ return "\x10" + pack('>H', int(round(value * 32768)))
+
+ def validate(self, frame, value): return value
+
+class SynchronizedTextSpec(EncodedTextSpec):
+ def read(self, frame, data):
+ texts = []
+ encoding, term = self._encodings[frame.encoding]
+ while data:
+ l = len(term)
+ value_idx = data.index(term)
+ value = data[:value_idx].decode(encoding)
+ time, = struct.unpack(">I", data[value_idx+l:value_idx+l+4])
+ texts.append((value, time))
+ data = data[value_idx+l+4:]
+ return texts, ""
+
+ def write(self, frame, value):
+ data = []
+ encoding, term = self._encodings[frame.encoding]
+ for text, time in frame.text:
+ text = text.encode(encoding) + term
+ data.append(text + struct.pack(">I", time))
+ return "".join(data)
+
+ def validate(self, frame, value):
+ return value
+
+class KeyEventSpec(Spec):
+ def read(self, frame, data):
+ events = []
+ while len(data) >= 5:
+ events.append(struct.unpack(">bI", data[:5]))
+ data = data[5:]
+ return events, data
+
+ def write(self, frame, value):
+ return "".join([struct.pack(">bI", *event) for event in value])
+
+ def validate(self, frame, value):
+ return value
+
+class VolumeAdjustmentsSpec(Spec):
+ # Not to be confused with VolumeAdjustmentSpec.
+ def read(self, frame, data):
+ adjustments = {}
+ while len(data) >= 4:
+ freq, adj = struct.unpack(">Hh", data[:4])
+ data = data[4:]
+ freq /= 2.0
+ adj /= 512.0
+ adjustments[freq] = adj
+ adjustments = adjustments.items()
+ adjustments.sort()
+ return adjustments, data
+
+ def write(self, frame, value):
+ value.sort()
+ return "".join([struct.pack(">Hh", int(freq * 2), int(adj * 512))
+ for (freq, adj) in value])
+
+ def validate(self, frame, value):
+ return value
+
+class ASPIIndexSpec(Spec):
+ def read(self, frame, data):
+ if frame.b == 16:
+ format = "H"
+ size = 2
+ elif frame.b == 8:
+ format = "B"
+ size = 1
+ else:
+ warn("invalid bit count in ASPI (%d)" % frame.b, ID3Warning)
+ return [], data
+
+ indexes = data[:frame.N * size]
+ data = data[frame.N * size:]
+ return list(struct.unpack(">" + format * frame.N, indexes)), data
+
+ def write(self, frame, values):
+ if frame.b == 16: format = "H"
+ elif frame.b == 8: format = "B"
+ else: raise ValueError("frame.b must be 8 or 16")
+ return struct.pack(">" + format * frame.N, *values)
+
+ def validate(self, frame, values):
+ return values
+
+class Frame(object):
+ """Fundamental unit of ID3 data.
+
+ ID3 tags are split into frames. Each frame has a potentially
+ different structure, and so this base class is not very featureful.
+ """
+
+ FLAG23_ALTERTAG = 0x8000
+ FLAG23_ALTERFILE = 0x4000
+ FLAG23_READONLY = 0x2000
+ FLAG23_COMPRESS = 0x0080
+ FLAG23_ENCRYPT = 0x0040
+ FLAG23_GROUP = 0x0020
+
+ FLAG24_ALTERTAG = 0x4000
+ FLAG24_ALTERFILE = 0x2000
+ FLAG24_READONLY = 0x1000
+ FLAG24_GROUPID = 0x0040
+ FLAG24_COMPRESS = 0x0008
+ FLAG24_ENCRYPT = 0x0004
+ FLAG24_UNSYNCH = 0x0002
+ FLAG24_DATALEN = 0x0001
+
+ _framespec = []
+ def __init__(self, *args, **kwargs):
+ if len(args)==1 and len(kwargs)==0 and isinstance(args[0], type(self)):
+ other = args[0]
+ for checker in self._framespec:
+ val = checker.validate(self, getattr(other, checker.name))
+ setattr(self, checker.name, val)
+ else:
+ for checker, val in zip(self._framespec, args):
+ setattr(self, checker.name, checker.validate(self, val))
+ for checker in self._framespec[len(args):]:
+ validated = checker.validate(
+ self, kwargs.get(checker.name, None))
+ setattr(self, checker.name, validated)
+
+ HashKey = property(
+ lambda s: s.FrameID,
+ doc="an internal key used to ensure frame uniqueness in a tag")
+ FrameID = property(
+ lambda s: type(s).__name__,
+ doc="ID3v2 three or four character frame ID")
+
+ def __repr__(self):
+ """Python representation of a frame.
+
+ The string returned is a valid Python expression to construct
+ a copy of this frame.
+ """
+ kw = []
+ for attr in self._framespec:
+ kw.append('%s=%r' % (attr.name, getattr(self, attr.name)))
+ return '%s(%s)' % (type(self).__name__, ', '.join(kw))
+
+ def _readData(self, data):
+ odata = data
+ for reader in self._framespec:
+ if len(data):
+ try: value, data = reader.read(self, data)
+ except UnicodeDecodeError:
+ raise ID3JunkFrameError
+ else: raise ID3JunkFrameError
+ setattr(self, reader.name, value)
+ if data.strip('\x00'):
+ warn('Leftover data: %s: %r (from %r)' % (
+ type(self).__name__, data, odata),
+ ID3Warning)
+
+ def _writeData(self):
+ data = []
+ for writer in self._framespec:
+ data.append(writer.write(self, getattr(self, writer.name)))
+ return ''.join(data)
+
+ def pprint(self):
+ """Return a human-readable representation of the frame."""
+ return "%s=%s" % (type(self).__name__, self._pprint())
+
+ def _pprint(self):
+ return "[unrepresentable data]"
+
+ def fromData(cls, id3, tflags, data):
+ """Construct this ID3 frame from raw string data."""
+
+ if (2,4,0) <= id3.version:
+ if tflags & (Frame.FLAG24_COMPRESS | Frame.FLAG24_DATALEN):
+ # The data length int is syncsafe in 2.4 (but not 2.3).
+ # However, we don't actually need the data length int,
+ # except to work around a QL 0.12 bug, and in that case
+ # all we need are the raw bytes.
+ datalen_bytes = data[:4]
+ data = data[4:]
+ if tflags & Frame.FLAG24_UNSYNCH or id3.f_unsynch:
+ try: data = unsynch.decode(data)
+ except ValueError, err:
+ if id3.PEDANTIC:
+ raise ID3BadUnsynchData, '%s: %r' % (err, data)
+ if tflags & Frame.FLAG24_ENCRYPT:
+ raise ID3EncryptionUnsupportedError
+ if tflags & Frame.FLAG24_COMPRESS:
+ try: data = data.decode('zlib')
+ except zlibError, err:
+ # the initial mutagen that went out with QL 0.12 did not
+ # write the 4 bytes of uncompressed size. Compensate.
+ data = datalen_bytes + data
+ try: data = data.decode('zlib')
+ except zlibError, err:
+ if id3.PEDANTIC:
+ raise ID3BadCompressedData, '%s: %r' % (err, data)
+
+ elif (2,3,0) <= id3.version:
+ if tflags & Frame.FLAG23_COMPRESS:
+ usize, = unpack('>L', data[:4])
+ data = data[4:]
+ if tflags & Frame.FLAG23_ENCRYPT:
+ raise ID3EncryptionUnsupportedError
+ if tflags & Frame.FLAG23_COMPRESS:
+ try: data = data.decode('zlib')
+ except zlibError, err:
+ if id3.PEDANTIC:
+ raise ID3BadCompressedData, '%s: %r' % (err, data)
+
+ frame = cls()
+ frame._rawdata = data
+ frame._flags = tflags
+ frame._readData(data)
+ return frame
+ fromData = classmethod(fromData)
+
+ def __hash__(self):
+ raise TypeError("Frame objects are unhashable")
+
+class FrameOpt(Frame):
+ """A frame with optional parts.
+
+ Some ID3 frames have optional data; this class extends Frame to
+ provide support for those parts.
+ """
+ _optionalspec = []
+
+ def __init__(self, *args, **kwargs):
+ super(FrameOpt, self).__init__(*args, **kwargs)
+ for spec in self._optionalspec:
+ if spec.name in kwargs:
+ validated = spec.validate(self, kwargs[spec.name])
+ setattr(self, spec.name, validated)
+ else: break
+
+ def _readData(self, data):
+ odata = data
+ for reader in self._framespec:
+ if len(data): value, data = reader.read(self, data)
+ else: raise ID3JunkFrameError
+ setattr(self, reader.name, value)
+ if data:
+ for reader in self._optionalspec:
+ if len(data): value, data = reader.read(self, data)
+ else: break
+ setattr(self, reader.name, value)
+ if data.strip('\x00'):
+ warn('Leftover data: %s: %r (from %r)' % (
+ type(self).__name__, data, odata),
+ ID3Warning)
+
+ def _writeData(self):
+ data = []
+ for writer in self._framespec:
+ data.append(writer.write(self, getattr(self, writer.name)))
+ for writer in self._optionalspec:
+ try: data.append(writer.write(self, getattr(self, writer.name)))
+ except AttributeError: break
+ return ''.join(data)
+
+ def __repr__(self):
+ kw = []
+ for attr in self._framespec:
+ kw.append('%s=%r' % (attr.name, getattr(self, attr.name)))
+ for attr in self._optionalspec:
+ if hasattr(self, attr.name):
+ kw.append('%s=%r' % (attr.name, getattr(self, attr.name)))
+ return '%s(%s)' % (type(self).__name__, ', '.join(kw))
+
+
+class TextFrame(Frame):
+ """Text strings.
+
+ Text frames support casts to unicode or str objects, as well as
+ list-like indexing, extend, and append.
+
+ Iterating over a TextFrame iterates over its strings, not its
+ characters.
+
+ Text frames have a 'text' attribute which is the list of strings,
+ and an 'encoding' attribute; 0 for ISO-8859 1, 1 UTF-16, 2 for
+ UTF-16BE, and 3 for UTF-8. If you don't want to worry about
+ encodings, just set it to 3.
+ """
+
+ _framespec = [ EncodingSpec('encoding'),
+ MultiSpec('text', EncodedTextSpec('text'), sep=u'\u0000') ]
+ def __str__(self): return self.__unicode__().encode('utf-8')
+ def __unicode__(self): return u'\u0000'.join(self.text)
+ def __eq__(self, other):
+ if isinstance(other, str): return str(self) == other
+ elif isinstance(other, unicode):
+ return u'\u0000'.join(self.text) == other
+ return self.text == other
+ def __getitem__(self, item): return self.text[item]
+ def __iter__(self): return iter(self.text)
+ def append(self, value): return self.text.append(value)
+ def extend(self, value): return self.text.extend(value)
+ def _pprint(self): return " / ".join(self.text)
+
+class NumericTextFrame(TextFrame):
+ """Numerical text strings.
+
+ The numeric value of these frames can be gotten with unary plus, e.g.
+ frame = TLEN('12345')
+ length = +frame
+ """
+
+ _framespec = [ EncodingSpec('encoding'),
+ MultiSpec('text', EncodedNumericTextSpec('text'), sep=u'\u0000') ]
+
+ def __pos__(self):
+ """Return the numerical value of the string."""
+ return int(self.text[0])
+
+class NumericPartTextFrame(TextFrame):
+ """Multivalue numerical text strings.
+
+ These strings indicate 'part (e.g. track) X of Y', and unary plus
+ returns the first value:
+ frame = TRCK('4/15')
+ track = +frame # track == 4
+ """
+
+ _framespec = [ EncodingSpec('encoding'),
+ MultiSpec('text', EncodedNumericPartTextSpec('text'), sep=u'\u0000') ]
+ def __pos__(self):
+ return int(self.text[0].split("/")[0])
+
+class TimeStampTextFrame(TextFrame):
+ """A list of time stamps.
+
+ The 'text' attribute in this frame is a list of ID3TimeStamp
+ objects, not a list of strings.
+ """
+
+ _framespec = [ EncodingSpec('encoding'),
+ MultiSpec('text', TimeStampSpec('stamp'), sep=u',') ]
+ def __str__(self): return self.__unicode__().encode('utf-8')
+ def __unicode__(self): return ','.join([stamp.text for stamp in self.text])
+ def _pprint(self):
+ return " / ".join([stamp.text for stamp in self.text])
+
+class UrlFrame(Frame):
+ """A frame containing a URL string.
+
+ The ID3 specification is silent about IRIs and normalized URL
+ forms. Mutagen assumes all URLs in files are encoded as Latin 1,
+ but string conversion of this frame returns a UTF-8 representation
+ for compatibility with other string conversions.
+
+ The only sane way to handle URLs in MP3s is to restrict them to
+ ASCII.
+ """
+
+ _framespec = [ Latin1TextSpec('url') ]
+ def __str__(self): return self.url.encode('utf-8')
+ def __unicode__(self): return self.url
+ def __eq__(self, other): return self.url == other
+ def _pprint(self): return self.url
+
+class UrlFrameU(UrlFrame):
+ HashKey = property(lambda s: '%s:%s' % (s.FrameID, s.url))
+
+class TALB(TextFrame): "Album"
+class TBPM(NumericTextFrame): "Beats per minute"
+class TCOM(TextFrame): "Composer"
+
+class TCON(TextFrame):
+ """Content type (Genre)
+
+ ID3 has several ways genres can be represented; for convenience,
+ use the 'genres' property rather than the 'text' attribute.
+ """
+
+ from mutagen._constants import GENRES
+
+ def __get_genres(self):
+ genres = []
+ import re
+ genre_re = re.compile(r"((?:\((?P<id>[0-9]+|RX|CR)\))*)(?P<str>.+)?")
+ for value in self.text:
+ if value.isdigit():
+ try: genres.append(self.GENRES[int(value)])
+ except IndexError: genres.append(u"Unknown")
+ elif value == "CR": genres.append(u"Cover")
+ elif value == "RX": genres.append(u"Remix")
+ elif value:
+ newgenres = []
+ genreid, dummy, genrename = genre_re.match(value).groups()
+
+ if genreid:
+ for gid in genreid[1:-1].split(")("):
+ if gid.isdigit() and int(gid) < len(self.GENRES):
+ gid = unicode(self.GENRES[int(gid)])
+ newgenres.append(gid)
+ elif gid == "CR": newgenres.append(u"Cover")
+ elif gid == "RX": newgenres.append(u"Remix")
+ else: newgenres.append(u"Unknown")
+
+ if genrename:
+ # "Unescaping" the first parenthesis
+ if genrename.startswith("(("): genrename = genrename[1:]
+ if genrename not in newgenres: newgenres.append(genrename)
+
+ genres.extend(newgenres)
+
+ return genres
+
+ def __set_genres(self, genres):
+ if isinstance(genres, basestring): genres = [genres]
+ self.text = map(self.__decode, genres)
+
+ def __decode(self, value):
+ if isinstance(value, str):
+ enc = EncodedTextSpec._encodings[self.encoding][0]
+ return value.decode(enc)
+ else: return value
+
+ genres = property(__get_genres, __set_genres, None,
+ "A list of genres parsed from the raw text data.")
+
+ def _pprint(self):
+ return " / ".join(self.genres)
+
+class TCOP(TextFrame): "Copyright (c)"
+class TCMP(NumericTextFrame): "iTunes Compilation Flag"
+class TDAT(TextFrame): "Date of recording (DDMM)"
+class TDEN(TimeStampTextFrame): "Encoding Time"
+class TDOR(TimeStampTextFrame): "Original Release Time"
+class TDLY(NumericTextFrame): "Audio Delay (ms)"
+class TDRC(TimeStampTextFrame): "Recording Time"
+class TDRL(TimeStampTextFrame): "Release Time"
+class TDTG(TimeStampTextFrame): "Tagging Time"
+class TENC(TextFrame): "Encoder"
+class TEXT(TextFrame): "Lyricist"
+class TFLT(TextFrame): "File type"
+class TIME(TextFrame): "Time of recording (HHMM)"
+class TIT1(TextFrame): "Content group description"
+class TIT2(TextFrame): "Title"
+class TIT3(TextFrame): "Subtitle/Description refinement"
+class TKEY(TextFrame): "Starting Key"
+class TLAN(TextFrame): "Audio Languages"
+class TLEN(NumericTextFrame): "Audio Length (ms)"
+class TMED(TextFrame): "Source Media Type"
+class TMOO(TextFrame): "Mood"
+class TOAL(TextFrame): "Original Album"
+class TOFN(TextFrame): "Original Filename"
+class TOLY(TextFrame): "Original Lyricist"
+class TOPE(TextFrame): "Original Artist/Performer"
+class TORY(NumericTextFrame): "Original Release Year"
+class TOWN(TextFrame): "Owner/Licensee"
+class TPE1(TextFrame): "Lead Artist/Performer/Soloist/Group"
+class TPE2(TextFrame): "Band/Orchestra/Accompaniment"
+class TPE3(TextFrame): "Conductor"
+class TPE4(TextFrame): "Interpreter/Remixer/Modifier"
+class TPOS(NumericPartTextFrame): "Part of set"
+class TPRO(TextFrame): "Produced (P)"
+class TPUB(TextFrame): "Publisher"
+class TRCK(NumericPartTextFrame): "Track Number"
+class TRDA(TextFrame): "Recording Dates"
+class TRSN(TextFrame): "Internet Radio Station Name"
+class TRSO(TextFrame): "Internet Radio Station Owner"
+class TSIZ(NumericTextFrame): "Size of audio data (bytes)"
+class TSOA(TextFrame): "Album Sort Order key"
+class TSOP(TextFrame): "Perfomer Sort Order key"
+class TSOT(TextFrame): "Title Sort Order key"
+class TSRC(TextFrame): "International Standard Recording Code (ISRC)"
+class TSSE(TextFrame): "Encoder settings"
+class TSST(TextFrame): "Set Subtitle"
+class TYER(NumericTextFrame): "Year of recording"
+
+class TXXX(TextFrame):
+ """User-defined text data.
+
+ TXXX frames have a 'desc' attribute which is set to any Unicode
+ value (though the encoding of the text and the description must be
+ the same). Many taggers use this frame to store freeform keys.
+ """
+ _framespec = [ EncodingSpec('encoding'), EncodedTextSpec('desc'),
+ MultiSpec('text', EncodedTextSpec('text'), sep=u'\u0000') ]
+ HashKey = property(lambda s: '%s:%s' % (s.FrameID, s.desc))
+ def _pprint(self): return "%s=%s" % (self.desc, " / ".join(self.text))
+
+class WCOM(UrlFrameU): "Commercial Information"
+class WCOP(UrlFrame): "Copyright Information"
+class WOAF(UrlFrame): "Official File Information"
+class WOAR(UrlFrameU): "Official Artist/Performer Information"
+class WOAS(UrlFrame): "Official Source Information"
+class WORS(UrlFrame): "Official Internet Radio Information"
+class WPAY(UrlFrame): "Payment Information"
+class WPUB(UrlFrame): "Official Publisher Information"
+
+class WXXX(UrlFrame):
+ """User-defined URL data.
+
+ Like TXXX, this has a freeform description associated with it.
+ """
+ _framespec = [ EncodingSpec('encoding'), EncodedTextSpec('desc'),
+ Latin1TextSpec('url') ]
+ HashKey = property(lambda s: '%s:%s' % (s.FrameID, s.desc))
+
+class PairedTextFrame(Frame):
+ """Paired text strings.
+
+ Some ID3 frames pair text strings, to associate names with a more
+ specific involvement in the song. The 'people' attribute of these
+ frames contains a list of pairs:
+ [['trumpet', 'Miles Davis'], ['bass', 'Paul Chambers']]
+
+ Like text frames, these frames also have an encoding attribute.
+ """
+
+ _framespec = [ EncodingSpec('encoding'), MultiSpec('people',
+ EncodedTextSpec('involvement'), EncodedTextSpec('person')) ]
+ def __eq__(self, other):
+ return self.people == other
+
+class TIPL(PairedTextFrame): "Involved People List"
+class TMCL(PairedTextFrame): "Musicians Credits List"
+class IPLS(TIPL): "Involved People List"
+
+class MCDI(Frame):
+ """Binary dump of CD's TOC.
+
+ The 'data' attribute contains the raw byte string.
+ """
+ _framespec = [ BinaryDataSpec('data') ]
+ def __eq__(self, other): return self.data == other
+
+class ETCO(Frame):
+ """Event timing codes."""
+ _framespec = [ ByteSpec("format"), KeyEventSpec("events") ]
+ def __eq__(self, other): return self.events == other
+
+class MLLT(Frame):
+ """MPEG location lookup table.
+
+ This frame's attributes may be changed in the future based on
+ feedback from real-world use.
+ """
+ _framespec = [ SizedIntegerSpec('frames', 2),
+ SizedIntegerSpec('bytes', 3),
+ SizedIntegerSpec('milliseconds', 3),
+ ByteSpec('bits_for_bytes'),
+ ByteSpec('bits_for_milliseconds'),
+ BinaryDataSpec('data') ]
+ def __eq__(self, other): return self.data == other
+
+class SYTC(Frame):
+ """Synchronised tempo codes.
+
+ This frame's attributes may be changed in the future based on
+ feedback from real-world use.
+ """
+ _framespec = [ ByteSpec("format"), BinaryDataSpec("data") ]
+ def __eq__(self, other): return self.data == other
+
+class USLT(Frame):
+ """Unsynchronised lyrics/text transcription.
+
+ Lyrics have a three letter ISO language code ('lang'), a
+ description ('desc'), and a block of plain text ('text').
+ """
+
+ _framespec = [ EncodingSpec('encoding'), StringSpec('lang', 3),
+ EncodedTextSpec('desc'), EncodedTextSpec('text') ]
+ HashKey = property(lambda s: '%s:%s:%r' % (s.FrameID, s.desc, s.lang))
+
+ def __str__(self): return self.text.encode('utf-8')
+ def __unicode__(self): return self.text
+ def __eq__(self, other): return self.text == other
+
+class SYLT(Frame):
+ """Synchronised lyrics/text."""
+
+ _framespec = [ EncodingSpec('encoding'), StringSpec('lang', 3),
+ ByteSpec('format'), ByteSpec('type'), EncodedTextSpec('desc'),
+ SynchronizedTextSpec('text') ]
+ HashKey = property(lambda s: '%s:%s:%r' % (s.FrameID, s.desc, s.lang))
+
+ def __eq__(self, other):
+ return str(self) == other
+
+ def __str__(self):
+ return "".join([text for (text, time) in self.text]).encode('utf-8')
+
+class COMM(TextFrame):
+ """User comment.
+
+ User comment frames have a descrption, like TXXX, and also a three
+ letter ISO language code in the 'lang' attribute.
+ """
+ _framespec = [ EncodingSpec('encoding'), StringSpec('lang', 3),
+ EncodedTextSpec('desc'),
+ MultiSpec('text', EncodedTextSpec('text'), sep=u'\u0000') ]
+ HashKey = property(lambda s: '%s:%s:%r' % (s.FrameID, s.desc, s.lang))
+ def _pprint(self): return "%s=%r=%s" % (
+ self.desc, self.lang, " / ".join(self.text))
+
+class RVA2(Frame):
+ """Relative volume adjustment (2).
+
+ This frame is used to implemented volume scaling, and in
+ particular, normalization using ReplayGain.
+
+ Attributes:
+ desc -- description or context of this adjustment
+ channel -- audio channel to adjust (master is 1)
+ gain -- a + or - dB gain relative to some reference level
+ peak -- peak of the audio as a floating point number, [0, 1]
+
+ When storing ReplayGain tags, use descriptions of 'album' and
+ 'track' on channel 1.
+ """
+
+ _framespec = [ Latin1TextSpec('desc'), ChannelSpec('channel'),
+ VolumeAdjustmentSpec('gain'), VolumePeakSpec('peak') ]
+ _channels = ["Other", "Master volume", "Front right", "Front left",
+ "Back right", "Back left", "Front centre", "Back centre",
+ "Subwoofer"]
+ HashKey = property(lambda s: '%s:%s' % (s.FrameID, s.desc))
+
+ def __eq__(self, other):
+ return ((str(self) == other) or
+ (self.desc == other.desc and
+ self.channel == other.channel and
+ self.gain == other.gain and
+ self.peak == other.peak))
+
+ def __str__(self):
+ return "%s: %+0.4f dB/%0.4f" % (
+ self._channels[self.channel], self.gain, self.peak)
+
+class EQU2(Frame):
+ """Equalisation (2).
+
+ Attributes:
+ method -- interpolation method (0 = band, 1 = linear)
+ desc -- identifying description
+ adjustments -- list of (frequency, vol_adjustment) pairs
+ """
+ _framespec = [ ByteSpec("method"), Latin1TextSpec("desc"),
+ VolumeAdjustmentsSpec("adjustments") ]
+ def __eq__(self, other): return self.adjustments == other
+ HashKey = property(lambda s: '%s:%s' % (s.FrameID, s.desc))
+
+# class RVAD: unsupported
+# class EQUA: unsupported
+
+class RVRB(Frame):
+ """Reverb."""
+ _framespec = [ SizedIntegerSpec('left', 2), SizedIntegerSpec('right', 2),
+ ByteSpec('bounce_left'), ByteSpec('bounce_right'),
+ ByteSpec('feedback_ltl'), ByteSpec('feedback_ltr'),
+ ByteSpec('feedback_rtr'), ByteSpec('feedback_rtl'),
+ ByteSpec('premix_ltr'), ByteSpec('premix_rtl') ]
+
+ def __eq__(self, other): return (self.left, self.right) == other
+
+class APIC(Frame):
+ """Attached (or linked) Picture.
+
+ Attributes:
+ encoding -- text encoding for the description
+ mime -- a MIME type (e.g. image/jpeg) or '-->' if the data is a URI
+ type -- the source of the image (3 is the album front cover)
+ desc -- a text description of the image
+ data -- raw image data, as a byte string
+
+ Mutagen will automatically compress large images when saving tags.
+ """
+ _framespec = [ EncodingSpec('encoding'), Latin1TextSpec('mime'),
+ ByteSpec('type'), EncodedTextSpec('desc'), BinaryDataSpec('data') ]
+ def __eq__(self, other): return self.data == other
+ HashKey = property(lambda s: '%s:%s' % (s.FrameID, s.desc))
+ def _pprint(self):
+ return "%s (%s, %d bytes)" % (
+ self.desc, self.mime, len(self.data))
+
+class PCNT(Frame):
+ """Play counter.
+
+ The 'count' attribute contains the (recorded) number of times this
+ file has been played.
+
+ This frame is basically obsoleted by POPM.
+ """
+ _framespec = [ IntegerSpec('count') ]
+
+ def __eq__(self, other): return self.count == other
+ def __pos__(self): return self.count
+ def _pprint(self): return unicode(self.count)
+
+class POPM(Frame):
+ """Popularimeter.
+
+ This frame keys a rating (out of 255) and a play count to an email
+ address.
+
+ Attributes:
+ email -- email this POPM frame is for
+ rating -- rating from 0 to 255
+ count -- number of times the files has been played
+ """
+ _framespec = [ Latin1TextSpec('email'), ByteSpec('rating'),
+ IntegerSpec('count') ]
+ HashKey = property(lambda s: '%s:%s' % (s.FrameID, s.email))
+
+ def __eq__(self, other): return self.rating == other
+ def __pos__(self): return self.rating
+ def _pprint(self): return "%s=%s %s/255" % (
+ self.email, self.count, self.rating)
+
+class GEOB(Frame):
+ """General Encapsulated Object.
+
+ A blob of binary data, that is not a picture (those go in APIC).
+
+ Attributes:
+ encoding -- encoding of the description
+ mime -- MIME type of the data or '-->' if the data is a URI
+ filename -- suggested filename if extracted
+ desc -- text description of the data
+ data -- raw data, as a byte string
+ """
+ _framespec = [ EncodingSpec('encoding'), Latin1TextSpec('mime'),
+ EncodedTextSpec('filename'), EncodedTextSpec('desc'),
+ BinaryDataSpec('data') ]
+ HashKey = property(lambda s: '%s:%s' % (s.FrameID, s.desc))
+
+ def __eq__(self, other): return self.data == other
+
+class RBUF(FrameOpt):
+ """Recommended buffer size.
+
+ Attributes:
+ size -- recommended buffer size in bytes
+ info -- if ID3 tags may be elsewhere in the file (optional)
+ offset -- the location of the next ID3 tag, if any
+
+ Mutagen will not find the next tag itself.
+ """
+ _framespec = [ SizedIntegerSpec('size', 3) ]
+ _optionalspec = [ ByteSpec('info'), SizedIntegerSpec('offset', 4) ]
+
+ def __eq__(self, other): return self.size == other
+ def __pos__(self): return self.size
+
+class AENC(FrameOpt):
+ """Audio encryption.
+
+ Attributes:
+ owner -- key identifying this encryption type
+ preview_start -- unencrypted data block offset
+ preview_length -- number of unencrypted blocks
+ data -- data required for decryption (optional)
+
+ Mutagen cannot decrypt files.
+ """
+ _framespec = [ Latin1TextSpec('owner'),
+ SizedIntegerSpec('preview_start', 2),
+ SizedIntegerSpec('preview_length', 2) ]
+ _optionalspec = [ BinaryDataSpec('data') ]
+ HashKey = property(lambda s: '%s:%s' % (s.FrameID, s.owner))
+
+ def __str__(self): return self.owner.encode('utf-8')
+ def __unicode__(self): return self.owner
+ def __eq__(self, other): return self.owner == other
+
+class LINK(FrameOpt):
+ """Linked information.
+
+ Attributes:
+ frameid -- the ID of the linked frame
+ url -- the location of the linked frame
+ data -- further ID information for the frame
+ """
+
+ _framespec = [ StringSpec('frameid', 4), Latin1TextSpec('url') ]
+ _optionalspec = [ BinaryDataSpec('data') ]
+ def __HashKey(self):
+ try:
+ return "%s:%s:%s:%r" % (
+ self.FrameID, self.frameid, self.url, self.data)
+ except AttributeError:
+ return "%s:%s:%s" % (self.FrameID, self.frameid, self.url)
+ HashKey = property(__HashKey)
+ def __eq__(self, other):
+ try: return (self.frameid, self.url, self.data) == other
+ except AttributeError: return (self.frameid, self.url) == other
+
+class POSS(Frame):
+ """Position synchronisation frame
+
+ Attribute:
+ format -- format of the position attribute (frames or milliseconds)
+ position -- current position of the file
+ """
+ _framespec = [ ByteSpec('format'), IntegerSpec('position') ]
+
+ def __pos__(self): return self.position
+ def __eq__(self, other): return self.position == other
+
+class UFID(Frame):
+ """Unique file identifier.
+
+ Attributes:
+ owner -- format/type of identifier
+ data -- identifier
+ """
+
+ _framespec = [ Latin1TextSpec('owner'), BinaryDataSpec('data') ]
+ HashKey = property(lambda s: '%s:%s' % (s.FrameID, s.owner))
+ def __eq__(s, o):
+ if isinstance(o, UFI): return s.owner == o.owner and s.data == o.data
+ else: return s.data == o
+ def _pprint(self):
+ isascii = ord(max(self.data)) < 128
+ if isascii: return "%s=%s" % (self.owner, self.data)
+ else: return "%s (%d bytes)" % (self.owner, len(self.data))
+
+class USER(Frame):
+ """Terms of use.
+
+ Attributes:
+ encoding -- text encoding
+ lang -- ISO three letter language code
+ text -- licensing terms for the audio
+ """
+ _framespec = [ EncodingSpec('encoding'), StringSpec('lang', 3),
+ EncodedTextSpec('text') ]
+ HashKey = property(lambda s: '%s:%r' % (s.FrameID, s.lang))
+
+ def __str__(self): return self.text.encode('utf-8')
+ def __unicode__(self): return self.text
+ def __eq__(self, other): return self.text == other
+ def _pprint(self): return "%r=%s" % (self.lang, self.text)
+
+class OWNE(Frame):
+ """Ownership frame."""
+ _framespec = [ EncodingSpec('encoding'), Latin1TextSpec('price'),
+ StringSpec('date', 8), EncodedTextSpec('seller') ]
+
+ def __str__(self): return self.seller.encode('utf-8')
+ def __unicode__(self): return self.seller
+ def __eq__(self, other): return self.seller == other
+
+class COMR(FrameOpt):
+ """Commercial frame."""
+ _framespec = [ EncodingSpec('encoding'), Latin1TextSpec('price'),
+ StringSpec('valid_until', 8), Latin1TextSpec('contact'),
+ ByteSpec('format'), EncodedTextSpec('seller'),
+ EncodedTextSpec('desc')]
+ _optionalspec = [ Latin1TextSpec('mime'), BinaryDataSpec('logo') ]
+ HashKey = property(lambda s: '%s:%s' % (s.FrameID, s._writeData()))
+ def __eq__(self, other): return self._writeData() == other._writeData()
+
+class ENCR(Frame):
+ """Encryption method registration.
+
+ The standard does not allow multiple ENCR frames with the same owner
+ or the same method. Mutagen only verifies that the owner is unique.
+ """
+ _framespec = [ Latin1TextSpec('owner'), ByteSpec('method'),
+ BinaryDataSpec('data') ]
+ HashKey = property(lambda s: "%s:%s" % (s.FrameID, s.owner))
+ def __str__(self): return self.data
+ def __eq__(self, other): return self.data == other
+
+class GRID(FrameOpt):
+ """Group identification registration."""
+ _framespec = [ Latin1TextSpec('owner'), ByteSpec('group') ]
+ _optionalspec = [ BinaryDataSpec('data') ]
+ HashKey = property(lambda s: '%s:%s' % (s.FrameID, s.group))
+ def __pos__(self): return self.group
+ def __str__(self): return self.owner.encode('utf-8')
+ def __unicode__(self): return self.owner
+ def __eq__(self, other): return self.owner == other or self.group == other
+
+
+class PRIV(Frame):
+ """Private frame."""
+ _framespec = [ Latin1TextSpec('owner'), BinaryDataSpec('data') ]
+ HashKey = property(lambda s: '%s:%s:%s' % (
+ s.FrameID, s.owner, s.data.decode('latin1')))
+ def __str__(self): return self.data
+ def __eq__(self, other): return self.data == other
+ def _pprint(self):
+ isascii = ord(max(self.data)) < 128
+ if isascii: return "%s=%s" % (self.owner, self.data)
+ else: return "%s (%d bytes)" % (self.owner, len(self.data))
+
+class SIGN(Frame):
+ """Signature frame."""
+ _framespec = [ ByteSpec('group'), BinaryDataSpec('sig') ]
+ HashKey = property(lambda s: '%s:%c:%s' % (s.FrameID, s.group, s.sig))
+ def __str__(self): return self.sig
+ def __eq__(self, other): return self.sig == other
+
+class SEEK(Frame):
+ """Seek frame.
+
+ Mutagen does not find tags at seek offsets.
+ """
+ _framespec = [ IntegerSpec('offset') ]
+ def __pos__(self): return self.offset
+ def __eq__(self, other): return self.offset == other
+
+class ASPI(Frame):
+ """Audio seek point index.
+
+ Attributes: S, L, N, b, and Fi. For the meaning of these, see
+ the ID3v2.4 specification. Fi is a list of integers.
+ """
+ _framespec = [ SizedIntegerSpec("S", 4), SizedIntegerSpec("L", 4),
+ SizedIntegerSpec("N", 2), ByteSpec("b"),
+ ASPIIndexSpec("Fi") ]
+ def __eq__(self, other): return self.Fi == other
+
+Frames = dict([(k,v) for (k,v) in globals().items()
+ if len(k)==4 and isinstance(v, type) and issubclass(v, Frame)])
+"""All supported ID3v2 frames, keyed by frame name."""
+del(k); del(v)
+
+# ID3v2.2 frames
+class UFI(UFID): "Unique File Identifier"
+
+class TT1(TIT1): "Content group description"
+class TT2(TIT2): "Title"
+class TT3(TIT3): "Subtitle/Description refinement"
+class TP1(TPE1): "Lead Artist/Performer/Soloist/Group"
+class TP2(TPE2): "Band/Orchestra/Accompaniment"
+class TP3(TPE3): "Conductor"
+class TP4(TPE4): "Interpreter/Remixer/Modifier"
+class TCM(TCOM): "Composer"
+class TXT(TEXT): "Lyricist"
+class TLA(TLAN): "Audio Language(s)"
+class TCO(TCON): "Content Type (Genre)"
+class TAL(TALB): "Album"
+class TPA(TPOS): "Part of set"
+class TRK(TRCK): "Track Number"
+class TRC(TSRC): "International Standard Recording Code (ISRC)"
+class TYE(TYER): "Year of recording"
+class TDA(TDAT): "Date of recording (DDMM)"
+class TIM(TIME): "Time of recording (HHMM)"
+class TRD(TRDA): "Recording Dates"
+class TMT(TMED): "Source Media Type"
+class TFT(TFLT): "File Type"
+class TBP(TBPM): "Beats per minute"
+class TCP(TCMP): "iTunes Compilation Flag"
+class TCR(TCOP): "Copyright (C)"
+class TPB(TPUB): "Publisher"
+class TEN(TENC): "Encoder"
+class TSS(TSSE): "Encoder settings"
+class TOF(TOFN): "Original Filename"
+class TLE(TLEN): "Audio Length (ms)"
+class TSI(TSIZ): "Audio Data size (bytes)"
+class TDY(TDLY): "Audio Delay (ms)"
+class TKE(TKEY): "Starting Key"
+class TOT(TOAL): "Original Album"
+class TOA(TOPE): "Original Artist/Perfomer"
+class TOL(TOLY): "Original Lyricist"
+class TOR(TORY): "Original Release Year"
+
+class TXX(TXXX): "User-defined Text"
+
+class WAF(WOAF): "Official File Information"
+class WAR(WOAR): "Official Artist/Performer Information"
+class WAS(WOAS): "Official Source Information"
+class WCM(WCOM): "Commercial Information"
+class WCP(WCOP): "Copyright Information"
+class WPB(WPUB): "Official Publisher Information"
+
+class WXX(WXXX): "User-defined URL"
+
+class IPL(IPLS): "Involved people list"
+class MCI(MCDI): "Binary dump of CD's TOC"
+class ETC(ETCO): "Event timing codes"
+class MLL(MLLT): "MPEG location lookup table"
+class STC(SYTC): "Synced tempo codes"
+class ULT(USLT): "Unsychronised lyrics/text transcription"
+class SLT(SYLT): "Synchronised lyrics/text"
+class COM(COMM): "Comment"
+#class RVA(RVAD)
+#class EQU(EQUA)
+class REV(RVRB): "Reverb"
+class PIC(APIC):
+ """Attached Picture.
+
+ The 'mime' attribute of an ID3v2.2 attached picture must be either
+ 'PNG' or 'JPG'.
+ """
+ _framespec = [ EncodingSpec('encoding'), StringSpec('mime', 3),
+ ByteSpec('type'), EncodedTextSpec('desc'), BinaryDataSpec('data') ]
+class GEO(GEOB): "General Encapsulated Object"
+class CNT(PCNT): "Play counter"
+class POP(POPM): "Popularimeter"
+class BUF(RBUF): "Recommended buffer size"
+
+class CRM(Frame):
+ """Encrypted meta frame"""
+ _framespec = [ Latin1TextSpec('owner'), Latin1TextSpec('desc'),
+ BinaryDataSpec('data') ]
+ def __eq__(self, other): return self.data == other
+
+class CRA(AENC): "Audio encryption"
+
+class LNK(LINK):
+ """Linked information"""
+ _framespec = [ StringSpec('frameid', 3), Latin1TextSpec('url') ]
+ _optionalspec = [ BinaryDataSpec('data') ]
+
+Frames_2_2 = dict([(k,v) for (k,v) in globals().items()
+ if len(k)==3 and isinstance(v, type) and issubclass(v, Frame)])
+
+# support open(filename) as interface
+Open = ID3
+
+# ID3v1.1 support.
+def ParseID3v1(string):
+ """Parse an ID3v1 tag, returning a list of ID3v2.4 frames."""
+ from struct import error as StructError
+ frames = {}
+ try:
+ tag, title, artist, album, year, comment, track, genre = unpack(
+ "3s30s30s30s4s29sBB", string)
+ except StructError: return None
+
+ if tag != "TAG": return None
+ def fix(string):
+ return string.split("\x00")[0].strip().decode('latin1')
+ title, artist, album, year, comment = map(
+ fix, [title, artist, album, year, comment])
+
+ if title: frames["TIT2"] = TIT2(encoding=0, text=title)
+ if artist: frames["TPE1"] = TPE1(encoding=0, text=[artist])
+ if album: frames["TALB"] = TALB(encoding=0, text=album)
+ if year: frames["TDRC"] = TDRC(encoding=0, text=year)
+ if comment: frames["COMM"] = COMM(
+ encoding=0, lang="eng", desc="ID3v1 Comment", text=comment)
+ # Don't read a track number if it looks like the comment was
+ # padded with spaces instead of nulls (thanks, WinAmp).
+ if track and (track != 32 or string[-3] == '\x00'):
+ frames["TRCK"] = TRCK(encoding=0, text=str(track))
+ if genre != 255: frames["TCON"] = TCON(encoding=0, text=str(genre))
+ return frames
+
+def MakeID3v1(id3):
+ """Return an ID3v1.1 tag string from a dict of ID3v2.4 frames."""
+
+ v1 = {}
+
+ for v2id, name in {"TIT2": "title", "TPE1": "artist",
+ "TALB": "album"}.items():
+ if v2id in id3:
+ text = id3[v2id].text[0].encode('latin1', 'replace')[:30]
+ else: text = ""
+ v1[name] = text + ("\x00" * (30 - len(text)))
+
+ if "COMM" in id3:
+ cmnt = id3["COMM"].text[0].encode('latin1', 'replace')[:28]
+ else: cmnt = ""
+ v1["comment"] = cmnt + ("\x00" * (29 - len(cmnt)))
+
+ if "TRCK" in id3:
+ try: v1["track"] = chr(+id3["TRCK"])
+ except ValueError: v1["track"] = "\x00"
+ else: v1["track"] = "\x00"
+
+ if "TCON" in id3:
+ try: genre = id3["TCON"].genres[0]
+ except IndexError: pass
+ else:
+ if genre in TCON.GENRES:
+ v1["genre"] = chr(TCON.GENRES.index(genre))
+ if "genre" not in v1: v1["genre"] = "\xff"
+
+ if "TDRC" in id3: v1["year"] = str(id3["TDRC"])[:4]
+ else: v1["year"] = "\x00\x00\x00\x00"
+
+ return ("TAG%(title)s%(artist)s%(album)s%(year)s%(comment)s"
+ "%(track)s%(genre)s") % v1
+
+class ID3FileType(mutagen.FileType):
+ """An unknown type of file with ID3 tags."""
+
+ class _Info(object):
+ length = 0
+ def __init__(self, fileobj, offset): pass
+ pprint = staticmethod(lambda: "Unknown format with ID3 tag")
+
+ def score(filename, fileobj, header):
+ return header.startswith("ID3")
+ score = staticmethod(score)
+
+ def add_tags(self, ID3=ID3):
+ """Add an empty ID3 tag to the file.
+
+ A custom tag reader may be used in instead of the default
+ mutagen.id3.ID3 object, e.g. an EasyID3 reader.
+ """
+ if self.tags is None:
+ self.tags = ID3()
+ else:
+ raise error("an ID3 tag already exists")
+
+ def load(self, filename, ID3=ID3, **kwargs):
+ """Load stream and tag information from a file.
+
+ A custom tag reader may be used in instead of the default
+ mutagen.id3.ID3 object, e.g. an EasyID3 reader.
+ """
+ self.filename = filename
+ try: self.tags = ID3(filename, **kwargs)
+ except error: self.tags = None
+ if self.tags is not None:
+ try: offset = self.tags.size
+ except AttributeError: offset = None
+ else: offset = None
+ try:
+ fileobj = file(filename, "rb")
+ self.info = self._Info(fileobj, offset)
+ finally:
+ fileobj.close()
+