mirror of
https://github.com/rembo10/headphones.git
synced 2026-07-21 08:24:00 +01:00
A little cleanup of the libs due to OCD
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
# mutagen aims to be an all purpose media tagging library
|
||||
# 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.
|
||||
|
||||
|
||||
"""Mutagen aims to be an all purpose tagging library.
|
||||
|
||||
::
|
||||
|
||||
import mutagen.[format]
|
||||
metadata = mutagen.[format].Open(filename)
|
||||
|
||||
`metadata` acts like a dictionary of tags in the file. Tags are generally a
|
||||
list of string-like values, but may have additional methods available
|
||||
depending on tag or format. They may also be entirely different objects
|
||||
for certain keys, again depending on format.
|
||||
"""
|
||||
|
||||
version = (1, 22)
|
||||
"""Version tuple."""
|
||||
|
||||
version_string = ".".join(map(str, version))
|
||||
"""Version string."""
|
||||
|
||||
|
||||
import warnings
|
||||
|
||||
import mutagen._util
|
||||
|
||||
|
||||
class Metadata(object):
|
||||
"""An abstract dict-like object.
|
||||
|
||||
Metadata is the base class for many of the tag objects in Mutagen.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if args or kwargs:
|
||||
self.load(*args, **kwargs)
|
||||
|
||||
def load(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def save(self, filename=None):
|
||||
"""Save changes to a file."""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def delete(self, filename=None):
|
||||
"""Remove tags from a file."""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class FileType(mutagen._util.DictMixin):
|
||||
"""An abstract object wrapping tags and audio stream information.
|
||||
|
||||
Attributes:
|
||||
|
||||
* info -- stream information (length, bitrate, sample rate)
|
||||
* tags -- metadata tags, if any
|
||||
|
||||
Each file format has different potential tags and stream
|
||||
information.
|
||||
|
||||
FileTypes implement an interface very similar to Metadata; the
|
||||
dict interface, save, load, and delete calls on a FileType call
|
||||
the appropriate methods on its tag data.
|
||||
"""
|
||||
|
||||
info = None
|
||||
tags = None
|
||||
filename = None
|
||||
_mimes = ["application/octet-stream"]
|
||||
|
||||
def __init__(self, filename=None, *args, **kwargs):
|
||||
if filename is None:
|
||||
warnings.warn("FileType constructor requires a filename",
|
||||
DeprecationWarning)
|
||||
else:
|
||||
self.load(filename, *args, **kwargs)
|
||||
|
||||
def load(self, filename, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""Look up a metadata tag key.
|
||||
|
||||
If the file has no tags at all, a KeyError is raised.
|
||||
"""
|
||||
|
||||
if self.tags is None:
|
||||
raise KeyError(key)
|
||||
else:
|
||||
return self.tags[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
"""Set a metadata tag.
|
||||
|
||||
If the file has no tags, an appropriate format is added (but
|
||||
not written until save is called).
|
||||
"""
|
||||
|
||||
if self.tags is None:
|
||||
self.add_tags()
|
||||
self.tags[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
"""Delete a metadata tag key.
|
||||
|
||||
If the file has no tags at all, a KeyError is raised.
|
||||
"""
|
||||
|
||||
if self.tags is None:
|
||||
raise KeyError(key)
|
||||
else:
|
||||
del(self.tags[key])
|
||||
|
||||
def keys(self):
|
||||
"""Return a list of keys in the metadata tag.
|
||||
|
||||
If the file has no tags at all, an empty list is returned.
|
||||
"""
|
||||
|
||||
if self.tags is None:
|
||||
return []
|
||||
else:
|
||||
return self.tags.keys()
|
||||
|
||||
def delete(self, filename=None):
|
||||
"""Remove tags from a file."""
|
||||
|
||||
if self.tags is not None:
|
||||
if filename is None:
|
||||
filename = self.filename
|
||||
else:
|
||||
warnings.warn(
|
||||
"delete(filename=...) is deprecated, reload the file",
|
||||
DeprecationWarning)
|
||||
return self.tags.delete(filename)
|
||||
|
||||
def save(self, filename=None, **kwargs):
|
||||
"""Save metadata tags."""
|
||||
|
||||
if filename is None:
|
||||
filename = self.filename
|
||||
else:
|
||||
warnings.warn(
|
||||
"save(filename=...) is deprecated, reload the file",
|
||||
DeprecationWarning)
|
||||
if self.tags is not None:
|
||||
return self.tags.save(filename, **kwargs)
|
||||
else:
|
||||
raise ValueError("no tags in file")
|
||||
|
||||
def pprint(self):
|
||||
"""Print stream information and comment key=value pairs."""
|
||||
|
||||
stream = "%s (%s)" % (self.info.pprint(), self.mime[0])
|
||||
try:
|
||||
tags = self.tags.pprint()
|
||||
except AttributeError:
|
||||
return stream
|
||||
else:
|
||||
return stream + ((tags and "\n" + tags) or "")
|
||||
|
||||
def add_tags(self):
|
||||
"""Adds new tags to the file.
|
||||
|
||||
Raises if tags already exist.
|
||||
"""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def mime(self):
|
||||
"""A list of mime types"""
|
||||
|
||||
mimes = []
|
||||
for Kind in type(self).__mro__:
|
||||
for mime in getattr(Kind, '_mimes', []):
|
||||
if mime not in mimes:
|
||||
mimes.append(mime)
|
||||
return mimes
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def File(filename, options=None, easy=False):
|
||||
"""Guess the type of the file and try to open it.
|
||||
|
||||
The file type is decided by several things, such as the first 128
|
||||
bytes (which usually contains a file type identifier), the
|
||||
filename extension, and the presence of existing tags.
|
||||
|
||||
If no appropriate type could be found, None is returned.
|
||||
|
||||
:param options: Sequence of :class:`FileType` implementations, defaults to
|
||||
all included ones.
|
||||
|
||||
:param easy: If the easy wrappers should be returnd if available.
|
||||
For example :class:`EasyMP3 <mp3.EasyMP3>` instead
|
||||
of :class:`MP3 <mp3.MP3>`.
|
||||
"""
|
||||
|
||||
if options is None:
|
||||
from mutagen.asf import ASF
|
||||
from mutagen.apev2 import APEv2File
|
||||
from mutagen.flac import FLAC
|
||||
if easy:
|
||||
from mutagen.easyid3 import EasyID3FileType as ID3FileType
|
||||
else:
|
||||
from mutagen.id3 import ID3FileType
|
||||
if easy:
|
||||
from mutagen.mp3 import EasyMP3 as MP3
|
||||
else:
|
||||
from mutagen.mp3 import MP3
|
||||
from mutagen.oggflac import OggFLAC
|
||||
from mutagen.oggspeex import OggSpeex
|
||||
from mutagen.oggtheora import OggTheora
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
from mutagen.oggopus import OggOpus
|
||||
if easy:
|
||||
from mutagen.trueaudio import EasyTrueAudio as TrueAudio
|
||||
else:
|
||||
from mutagen.trueaudio import TrueAudio
|
||||
from mutagen.wavpack import WavPack
|
||||
if easy:
|
||||
from mutagen.easymp4 import EasyMP4 as MP4
|
||||
else:
|
||||
from mutagen.mp4 import MP4
|
||||
from mutagen.musepack import Musepack
|
||||
from mutagen.monkeysaudio import MonkeysAudio
|
||||
from mutagen.optimfrog import OptimFROG
|
||||
options = [MP3, TrueAudio, OggTheora, OggSpeex, OggVorbis, OggFLAC,
|
||||
FLAC, APEv2File, MP4, ID3FileType, WavPack, Musepack,
|
||||
MonkeysAudio, OptimFROG, ASF, OggOpus]
|
||||
|
||||
if not options:
|
||||
return None
|
||||
|
||||
fileobj = open(filename, "rb")
|
||||
try:
|
||||
header = fileobj.read(128)
|
||||
# Sort by name after score. Otherwise import order affects
|
||||
# Kind sort order, which affects treatment of things with
|
||||
# equals scores.
|
||||
results = [(Kind.score(filename, fileobj, header), Kind.__name__)
|
||||
for Kind in options]
|
||||
finally:
|
||||
fileobj.close()
|
||||
results = zip(results, options)
|
||||
results.sort()
|
||||
(score, name), Kind = results[-1]
|
||||
if score > 0:
|
||||
return Kind(filename)
|
||||
else:
|
||||
return None
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Constants used by Mutagen."""
|
||||
|
||||
GENRES = [
|
||||
u"Blues",
|
||||
u"Classic Rock",
|
||||
u"Country",
|
||||
u"Dance",
|
||||
u"Disco",
|
||||
u"Funk",
|
||||
u"Grunge",
|
||||
u"Hip-Hop",
|
||||
u"Jazz",
|
||||
u"Metal",
|
||||
u"New Age",
|
||||
u"Oldies",
|
||||
u"Other",
|
||||
u"Pop",
|
||||
u"R&B",
|
||||
u"Rap",
|
||||
u"Reggae",
|
||||
u"Rock",
|
||||
u"Techno",
|
||||
u"Industrial",
|
||||
u"Alternative",
|
||||
u"Ska",
|
||||
u"Death Metal",
|
||||
u"Pranks",
|
||||
u"Soundtrack",
|
||||
u"Euro-Techno",
|
||||
u"Ambient",
|
||||
u"Trip-Hop",
|
||||
u"Vocal",
|
||||
u"Jazz+Funk",
|
||||
u"Fusion",
|
||||
u"Trance",
|
||||
u"Classical",
|
||||
u"Instrumental",
|
||||
u"Acid",
|
||||
u"House",
|
||||
u"Game",
|
||||
u"Sound Clip",
|
||||
u"Gospel",
|
||||
u"Noise",
|
||||
u"Alt. Rock",
|
||||
u"Bass",
|
||||
u"Soul",
|
||||
u"Punk",
|
||||
u"Space",
|
||||
u"Meditative",
|
||||
u"Instrumental Pop",
|
||||
u"Instrumental Rock",
|
||||
u"Ethnic",
|
||||
u"Gothic",
|
||||
u"Darkwave",
|
||||
u"Techno-Industrial",
|
||||
u"Electronic",
|
||||
u"Pop-Folk",
|
||||
u"Eurodance",
|
||||
u"Dream",
|
||||
u"Southern Rock",
|
||||
u"Comedy",
|
||||
u"Cult",
|
||||
u"Gangsta Rap",
|
||||
u"Top 40",
|
||||
u"Christian Rap",
|
||||
u"Pop/Funk",
|
||||
u"Jungle",
|
||||
u"Native American",
|
||||
u"Cabaret",
|
||||
u"New Wave",
|
||||
u"Psychedelic",
|
||||
u"Rave",
|
||||
u"Showtunes",
|
||||
u"Trailer",
|
||||
u"Lo-Fi",
|
||||
u"Tribal",
|
||||
u"Acid Punk",
|
||||
u"Acid Jazz",
|
||||
u"Polka",
|
||||
u"Retro",
|
||||
u"Musical",
|
||||
u"Rock & Roll",
|
||||
u"Hard Rock",
|
||||
u"Folk",
|
||||
u"Folk-Rock",
|
||||
u"National Folk",
|
||||
u"Swing",
|
||||
u"Fast-Fusion",
|
||||
u"Bebop",
|
||||
u"Latin",
|
||||
u"Revival",
|
||||
u"Celtic",
|
||||
u"Bluegrass",
|
||||
u"Avantgarde",
|
||||
u"Gothic Rock",
|
||||
u"Progressive Rock",
|
||||
u"Psychedelic Rock",
|
||||
u"Symphonic Rock",
|
||||
u"Slow Rock",
|
||||
u"Big Band",
|
||||
u"Chorus",
|
||||
u"Easy Listening",
|
||||
u"Acoustic",
|
||||
u"Humour",
|
||||
u"Speech",
|
||||
u"Chanson",
|
||||
u"Opera",
|
||||
u"Chamber Music",
|
||||
u"Sonata",
|
||||
u"Symphony",
|
||||
u"Booty Bass",
|
||||
u"Primus",
|
||||
u"Porn Groove",
|
||||
u"Satire",
|
||||
u"Slow Jam",
|
||||
u"Club",
|
||||
u"Tango",
|
||||
u"Samba",
|
||||
u"Folklore",
|
||||
u"Ballad",
|
||||
u"Power Ballad",
|
||||
u"Rhythmic Soul",
|
||||
u"Freestyle",
|
||||
u"Duet",
|
||||
u"Punk Rock",
|
||||
u"Drum Solo",
|
||||
u"A Cappella",
|
||||
u"Euro-House",
|
||||
u"Dance Hall",
|
||||
u"Goa",
|
||||
u"Drum & Bass",
|
||||
u"Club-House",
|
||||
u"Hardcore",
|
||||
u"Terror",
|
||||
u"Indie",
|
||||
u"BritPop",
|
||||
u"Afro-Punk",
|
||||
u"Polsk Punk",
|
||||
u"Beat",
|
||||
u"Christian Gangsta Rap",
|
||||
u"Heavy Metal",
|
||||
u"Black Metal",
|
||||
u"Crossover",
|
||||
u"Contemporary Christian",
|
||||
u"Christian Rock",
|
||||
u"Merengue",
|
||||
u"Salsa",
|
||||
u"Thrash Metal",
|
||||
u"Anime",
|
||||
u"JPop",
|
||||
u"Synthpop",
|
||||
u"Abstract",
|
||||
u"Art Rock",
|
||||
u"Baroque",
|
||||
u"Bhangra",
|
||||
u"Big Beat",
|
||||
u"Breakbeat",
|
||||
u"Chillout",
|
||||
u"Downtempo",
|
||||
u"Dub",
|
||||
u"EBM",
|
||||
u"Eclectic",
|
||||
u"Electro",
|
||||
u"Electroclash",
|
||||
u"Emo",
|
||||
u"Experimental",
|
||||
u"Garage",
|
||||
u"Global",
|
||||
u"IDM",
|
||||
u"Illbient",
|
||||
u"Industro-Goth",
|
||||
u"Jam Band",
|
||||
u"Krautrock",
|
||||
u"Leftfield",
|
||||
u"Lounge",
|
||||
u"Math Rock",
|
||||
u"New Romantic",
|
||||
u"Nu-Breakz",
|
||||
u"Post-Punk",
|
||||
u"Post-Rock",
|
||||
u"Psytrance",
|
||||
u"Shoegaze",
|
||||
u"Space Rock",
|
||||
u"Trop Rock",
|
||||
u"World Music",
|
||||
u"Neoclassical",
|
||||
u"Audiobook",
|
||||
u"Audio Theatre",
|
||||
u"Neue Deutsche Welle",
|
||||
u"Podcast",
|
||||
u"Indie Rock",
|
||||
u"G-Funk",
|
||||
u"Dubstep",
|
||||
u"Garage Rock",
|
||||
u"Psybient",
|
||||
]
|
||||
"""The ID3v1 genre list."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,465 @@
|
||||
# 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.
|
||||
|
||||
import struct
|
||||
from struct import unpack, pack
|
||||
from warnings import warn
|
||||
|
||||
from mutagen._id3util import ID3JunkFrameError, ID3Warning, BitPaddedInt
|
||||
|
||||
|
||||
class Spec(object):
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __hash__(self):
|
||||
raise TypeError("Spec objects are unhashable")
|
||||
|
||||
def _validate23(self, frame, value, **kwargs):
|
||||
"""Return a possibly modified value which, if written,
|
||||
results in valid id3v2.3 data.
|
||||
"""
|
||||
|
||||
return value
|
||||
|
||||
|
||||
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):
|
||||
if value is not None:
|
||||
chr(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)
|
||||
|
||||
def _validate23(self, frame, value, **kwargs):
|
||||
# only 0, 1 are valid in v2.3, default to utf-16
|
||||
return min(1, 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)
|
||||
|
||||
def _validate23(self, frame, value, **kwargs):
|
||||
if len(self.specs) != 1:
|
||||
return [[s._validate23(frame, v, **kwargs)
|
||||
for (v, s) in zip(val, self.specs)]
|
||||
for val in value]
|
||||
|
||||
spec = self.specs[0]
|
||||
|
||||
# Merge single text spec multispecs only.
|
||||
# (TimeStampSpec beeing the exception, but it's not a valid v2.3 frame)
|
||||
if not isinstance(spec, EncodedTextSpec) or \
|
||||
isinstance(spec, TimeStampSpec):
|
||||
return value
|
||||
|
||||
value = [spec._validate23(frame, v, **kwargs) for v in value]
|
||||
if kwargs.get("sep") is not None:
|
||||
return [spec.validate(frame, kwargs["sep"].join(value))]
|
||||
return 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)
|
||||
|
||||
__hash__ = object.__hash__
|
||||
|
||||
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):
|
||||
if value is not None:
|
||||
try:
|
||||
self.write(frame, value)
|
||||
except struct.error:
|
||||
raise ValueError("out of range")
|
||||
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):
|
||||
if value is not None:
|
||||
try:
|
||||
self.write(frame, value)
|
||||
except struct.error:
|
||||
raise ValueError("out of range")
|
||||
return value
|
||||
|
||||
|
||||
class SynchronizedTextSpec(EncodedTextSpec):
|
||||
def read(self, frame, data):
|
||||
texts = []
|
||||
encoding, term = self._encodings[frame.encoding]
|
||||
while data:
|
||||
l = len(term)
|
||||
try:
|
||||
value_idx = data.index(term)
|
||||
except ValueError:
|
||||
raise ID3JunkFrameError
|
||||
value = data[:value_idx].decode(encoding)
|
||||
if len(data) < value_idx + l + 4:
|
||||
raise ID3JunkFrameError
|
||||
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
|
||||
@@ -0,0 +1,176 @@
|
||||
# Copyright (C) 2005 Michael Urman
|
||||
# 2013 Christoph Reiter
|
||||
#
|
||||
# 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.
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class unsynch(object):
|
||||
@staticmethod
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
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)
|
||||
|
||||
|
||||
class _BitPaddedMixin(object):
|
||||
|
||||
def as_str(self, width=4, minwidth=4):
|
||||
return self.to_str(self, self.bits, self.bigendian, width, minwidth)
|
||||
|
||||
@staticmethod
|
||||
def to_str(value, bits=7, bigendian=True, width=4, minwidth=4):
|
||||
mask = (1 << bits) - 1
|
||||
|
||||
if width != -1:
|
||||
index = 0
|
||||
bytes_ = bytearray(width)
|
||||
try:
|
||||
while value:
|
||||
bytes_[index] = value & mask
|
||||
value >>= bits
|
||||
index += 1
|
||||
except IndexError:
|
||||
raise ValueError('Value too wide (>%d bytes)' % width)
|
||||
else:
|
||||
# PCNT and POPM use growing integers
|
||||
# of at least 4 bytes (=minwidth) as counters.
|
||||
bytes_ = bytearray()
|
||||
append = bytes_.append
|
||||
while value:
|
||||
append(value & mask)
|
||||
value >>= bits
|
||||
bytes_ = bytes_.ljust(minwidth, "\x00")
|
||||
|
||||
if bigendian:
|
||||
bytes_.reverse()
|
||||
return str(bytes_)
|
||||
|
||||
@staticmethod
|
||||
def has_valid_padding(value, bits=7):
|
||||
"""Whether the padding bits are all zero"""
|
||||
|
||||
assert bits <= 8
|
||||
|
||||
mask = (((1 << (8 - bits)) - 1) << bits)
|
||||
|
||||
if isinstance(value, (int, long)):
|
||||
while value:
|
||||
if value & mask:
|
||||
return False
|
||||
value >>= 8
|
||||
elif isinstance(value, str):
|
||||
for byte in value:
|
||||
if ord(byte) & mask:
|
||||
return False
|
||||
else:
|
||||
raise TypeError
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class BitPaddedInt(int, _BitPaddedMixin):
|
||||
|
||||
def __new__(cls, value, bits=7, bigendian=True):
|
||||
|
||||
mask = (1 << (bits)) - 1
|
||||
numeric_value = 0
|
||||
shift = 0
|
||||
|
||||
if isinstance(value, (int, long)):
|
||||
while value:
|
||||
numeric_value += (value & mask) << shift
|
||||
value >>= 8
|
||||
shift += bits
|
||||
elif isinstance(value, str):
|
||||
if bigendian:
|
||||
value = reversed(value)
|
||||
for byte in value:
|
||||
numeric_value += (ord(byte) & mask) << shift
|
||||
shift += bits
|
||||
else:
|
||||
raise TypeError
|
||||
|
||||
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
|
||||
|
||||
|
||||
class BitPaddedLong(long, _BitPaddedMixin):
|
||||
pass
|
||||
@@ -0,0 +1,349 @@
|
||||
# Copyright 2006 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.
|
||||
|
||||
"""Utility classes for Mutagen.
|
||||
|
||||
You should not rely on the interfaces here being stable. They are
|
||||
intended for internal use in Mutagen only.
|
||||
"""
|
||||
|
||||
import struct
|
||||
|
||||
from fnmatch import fnmatchcase
|
||||
|
||||
|
||||
class DictMixin(object):
|
||||
"""Implement the dict API using keys() and __*item__ methods.
|
||||
|
||||
Similar to UserDict.DictMixin, this takes a class that defines
|
||||
__getitem__, __setitem__, __delitem__, and keys(), and turns it
|
||||
into a full dict-like object.
|
||||
|
||||
UserDict.DictMixin is not suitable for this purpose because it's
|
||||
an old-style class.
|
||||
|
||||
This class is not optimized for very large dictionaries; many
|
||||
functions have linear memory requirements. I recommend you
|
||||
override some of these functions if speed is required.
|
||||
"""
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.keys())
|
||||
|
||||
def has_key(self, key):
|
||||
try:
|
||||
self[key]
|
||||
except KeyError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
__contains__ = has_key
|
||||
|
||||
iterkeys = lambda self: iter(self.keys())
|
||||
|
||||
def values(self):
|
||||
return map(self.__getitem__, self.keys())
|
||||
itervalues = lambda self: iter(self.values())
|
||||
|
||||
def items(self):
|
||||
return zip(self.keys(), self.values())
|
||||
iteritems = lambda s: iter(s.items())
|
||||
|
||||
def clear(self):
|
||||
map(self.__delitem__, self.keys())
|
||||
|
||||
def pop(self, key, *args):
|
||||
if len(args) > 1:
|
||||
raise TypeError("pop takes at most two arguments")
|
||||
try:
|
||||
value = self[key]
|
||||
except KeyError:
|
||||
if args:
|
||||
return args[0]
|
||||
else:
|
||||
raise
|
||||
del(self[key])
|
||||
return value
|
||||
|
||||
def popitem(self):
|
||||
try:
|
||||
key = self.keys()[0]
|
||||
return key, self.pop(key)
|
||||
except IndexError:
|
||||
raise KeyError("dictionary is empty")
|
||||
|
||||
def update(self, other=None, **kwargs):
|
||||
if other is None:
|
||||
self.update(kwargs)
|
||||
other = {}
|
||||
|
||||
try:
|
||||
map(self.__setitem__, other.keys(), other.values())
|
||||
except AttributeError:
|
||||
for key, value in other:
|
||||
self[key] = value
|
||||
|
||||
def setdefault(self, key, default=None):
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
self[key] = default
|
||||
return default
|
||||
|
||||
def get(self, key, default=None):
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def __repr__(self):
|
||||
return repr(dict(self.items()))
|
||||
|
||||
def __cmp__(self, other):
|
||||
if other is None:
|
||||
return 1
|
||||
else:
|
||||
return cmp(dict(self.items()), other)
|
||||
|
||||
__hash__ = object.__hash__
|
||||
|
||||
def __len__(self):
|
||||
return len(self.keys())
|
||||
|
||||
|
||||
class DictProxy(DictMixin):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.__dict = {}
|
||||
super(DictProxy, self).__init__(*args, **kwargs)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.__dict[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.__dict[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
del(self.__dict[key])
|
||||
|
||||
def keys(self):
|
||||
return self.__dict.keys()
|
||||
|
||||
|
||||
class cdata(object):
|
||||
"""C character buffer to Python numeric type conversions."""
|
||||
|
||||
from struct import error
|
||||
error = error
|
||||
|
||||
short_le = staticmethod(lambda data: struct.unpack('<h', data)[0])
|
||||
ushort_le = staticmethod(lambda data: struct.unpack('<H', data)[0])
|
||||
|
||||
short_be = staticmethod(lambda data: struct.unpack('>h', data)[0])
|
||||
ushort_be = staticmethod(lambda data: struct.unpack('>H', data)[0])
|
||||
|
||||
int_le = staticmethod(lambda data: struct.unpack('<i', data)[0])
|
||||
uint_le = staticmethod(lambda data: struct.unpack('<I', data)[0])
|
||||
|
||||
int_be = staticmethod(lambda data: struct.unpack('>i', data)[0])
|
||||
uint_be = staticmethod(lambda data: struct.unpack('>I', data)[0])
|
||||
|
||||
longlong_le = staticmethod(lambda data: struct.unpack('<q', data)[0])
|
||||
ulonglong_le = staticmethod(lambda data: struct.unpack('<Q', data)[0])
|
||||
|
||||
longlong_be = staticmethod(lambda data: struct.unpack('>q', data)[0])
|
||||
ulonglong_be = staticmethod(lambda data: struct.unpack('>Q', data)[0])
|
||||
|
||||
to_short_le = staticmethod(lambda data: struct.pack('<h', data))
|
||||
to_ushort_le = staticmethod(lambda data: struct.pack('<H', data))
|
||||
|
||||
to_short_be = staticmethod(lambda data: struct.pack('>h', data))
|
||||
to_ushort_be = staticmethod(lambda data: struct.pack('>H', data))
|
||||
|
||||
to_int_le = staticmethod(lambda data: struct.pack('<i', data))
|
||||
to_uint_le = staticmethod(lambda data: struct.pack('<I', data))
|
||||
|
||||
to_int_be = staticmethod(lambda data: struct.pack('>i', data))
|
||||
to_uint_be = staticmethod(lambda data: struct.pack('>I', data))
|
||||
|
||||
to_longlong_le = staticmethod(lambda data: struct.pack('<q', data))
|
||||
to_ulonglong_le = staticmethod(lambda data: struct.pack('<Q', data))
|
||||
|
||||
to_longlong_be = staticmethod(lambda data: struct.pack('>q', data))
|
||||
to_ulonglong_be = staticmethod(lambda data: struct.pack('>Q', data))
|
||||
|
||||
bitswap = ''.join([chr(sum([((val >> i) & 1) << (7-i) for i in range(8)]))
|
||||
for val in range(256)])
|
||||
del(i)
|
||||
del(val)
|
||||
|
||||
test_bit = staticmethod(lambda value, n: bool((value >> n) & 1))
|
||||
|
||||
|
||||
def lock(fileobj):
|
||||
"""Lock a file object 'safely'.
|
||||
|
||||
That means a failure to lock because the platform doesn't
|
||||
support fcntl or filesystem locks is not considered a
|
||||
failure. This call does block.
|
||||
|
||||
Returns whether or not the lock was successful, or
|
||||
raises an exception in more extreme circumstances (full
|
||||
lock table, invalid file).
|
||||
"""
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
return False
|
||||
else:
|
||||
try:
|
||||
fcntl.lockf(fileobj, fcntl.LOCK_EX)
|
||||
except IOError:
|
||||
# FIXME: There's possibly a lot of complicated
|
||||
# logic that needs to go here in case the IOError
|
||||
# is EACCES or EAGAIN.
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def unlock(fileobj):
|
||||
"""Unlock a file object.
|
||||
|
||||
Don't call this on a file object unless a call to lock()
|
||||
returned true.
|
||||
"""
|
||||
|
||||
# If this fails there's a mismatched lock/unlock pair,
|
||||
# so we definitely don't want to ignore errors.
|
||||
import fcntl
|
||||
fcntl.lockf(fileobj, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def insert_bytes(fobj, size, offset, BUFFER_SIZE=2**16):
|
||||
"""Insert size bytes of empty space starting at offset.
|
||||
|
||||
fobj must be an open file object, open rb+ or
|
||||
equivalent. Mutagen tries to use mmap to resize the file, but
|
||||
falls back to a significantly slower method if mmap fails.
|
||||
"""
|
||||
|
||||
assert 0 < size
|
||||
assert 0 <= offset
|
||||
locked = False
|
||||
fobj.seek(0, 2)
|
||||
filesize = fobj.tell()
|
||||
movesize = filesize - offset
|
||||
fobj.write('\x00' * size)
|
||||
fobj.flush()
|
||||
try:
|
||||
try:
|
||||
import mmap
|
||||
map = mmap.mmap(fobj.fileno(), filesize + size)
|
||||
try:
|
||||
map.move(offset + size, offset, movesize)
|
||||
finally:
|
||||
map.close()
|
||||
except (ValueError, EnvironmentError, ImportError):
|
||||
# handle broken mmap scenarios
|
||||
locked = lock(fobj)
|
||||
fobj.truncate(filesize)
|
||||
|
||||
fobj.seek(0, 2)
|
||||
padsize = size
|
||||
# Don't generate an enormous string if we need to pad
|
||||
# the file out several megs.
|
||||
while padsize:
|
||||
addsize = min(BUFFER_SIZE, padsize)
|
||||
fobj.write("\x00" * addsize)
|
||||
padsize -= addsize
|
||||
|
||||
fobj.seek(filesize, 0)
|
||||
while movesize:
|
||||
# At the start of this loop, fobj is pointing at the end
|
||||
# of the data we need to move, which is of movesize length.
|
||||
thismove = min(BUFFER_SIZE, movesize)
|
||||
# Seek back however much we're going to read this frame.
|
||||
fobj.seek(-thismove, 1)
|
||||
nextpos = fobj.tell()
|
||||
# Read it, so we're back at the end.
|
||||
data = fobj.read(thismove)
|
||||
# Seek back to where we need to write it.
|
||||
fobj.seek(-thismove + size, 1)
|
||||
# Write it.
|
||||
fobj.write(data)
|
||||
# And seek back to the end of the unmoved data.
|
||||
fobj.seek(nextpos)
|
||||
movesize -= thismove
|
||||
|
||||
fobj.flush()
|
||||
finally:
|
||||
if locked:
|
||||
unlock(fobj)
|
||||
|
||||
|
||||
def delete_bytes(fobj, size, offset, BUFFER_SIZE=2**16):
|
||||
"""Delete size bytes of empty space starting at offset.
|
||||
|
||||
fobj must be an open file object, open rb+ or
|
||||
equivalent. Mutagen tries to use mmap to resize the file, but
|
||||
falls back to a significantly slower method if mmap fails.
|
||||
"""
|
||||
|
||||
locked = False
|
||||
assert 0 < size
|
||||
assert 0 <= offset
|
||||
fobj.seek(0, 2)
|
||||
filesize = fobj.tell()
|
||||
movesize = filesize - offset - size
|
||||
assert 0 <= movesize
|
||||
try:
|
||||
if movesize > 0:
|
||||
fobj.flush()
|
||||
try:
|
||||
import mmap
|
||||
map = mmap.mmap(fobj.fileno(), filesize)
|
||||
try:
|
||||
map.move(offset, offset + size, movesize)
|
||||
finally:
|
||||
map.close()
|
||||
except (ValueError, EnvironmentError, ImportError):
|
||||
# handle broken mmap scenarios
|
||||
locked = lock(fobj)
|
||||
fobj.seek(offset + size)
|
||||
buf = fobj.read(BUFFER_SIZE)
|
||||
while buf:
|
||||
fobj.seek(offset)
|
||||
fobj.write(buf)
|
||||
offset += len(buf)
|
||||
fobj.seek(offset + size)
|
||||
buf = fobj.read(BUFFER_SIZE)
|
||||
fobj.truncate(filesize - size)
|
||||
fobj.flush()
|
||||
finally:
|
||||
if locked:
|
||||
unlock(fobj)
|
||||
|
||||
|
||||
def utf8(data):
|
||||
"""Convert a basestring to a valid UTF-8 str."""
|
||||
|
||||
if isinstance(data, str):
|
||||
return data.decode("utf-8", "replace").encode("utf-8")
|
||||
elif isinstance(data, unicode):
|
||||
return data.encode("utf-8")
|
||||
else:
|
||||
raise TypeError("only unicode/str types can be converted to UTF-8")
|
||||
|
||||
|
||||
def dict_match(d, key, default=None):
|
||||
try:
|
||||
return d[key]
|
||||
except KeyError:
|
||||
for pattern, value in d.iteritems():
|
||||
if fnmatchcase(key, pattern):
|
||||
return value
|
||||
return default
|
||||
@@ -0,0 +1,254 @@
|
||||
# Vorbis comment support for Mutagen
|
||||
# Copyright 2005-2006 Joe Wreschnig
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Read and write Vorbis comment data.
|
||||
|
||||
Vorbis comments are freeform key/value pairs; keys are
|
||||
case-insensitive ASCII and values are Unicode strings. A key may have
|
||||
multiple values.
|
||||
|
||||
The specification is at http://www.xiph.org/vorbis/doc/v-comment.html.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from cStringIO import StringIO
|
||||
|
||||
import mutagen
|
||||
from mutagen._util import DictMixin, cdata
|
||||
|
||||
|
||||
def is_valid_key(key):
|
||||
"""Return true if a string is a valid Vorbis comment key.
|
||||
|
||||
Valid Vorbis comment keys are printable ASCII between 0x20 (space)
|
||||
and 0x7D ('}'), excluding '='.
|
||||
"""
|
||||
for c in key:
|
||||
if c < " " or c > "}" or c == "=":
|
||||
return False
|
||||
else:
|
||||
return bool(key)
|
||||
|
||||
istag = is_valid_key
|
||||
|
||||
|
||||
class error(IOError):
|
||||
pass
|
||||
|
||||
|
||||
class VorbisUnsetFrameError(error):
|
||||
pass
|
||||
|
||||
|
||||
class VorbisEncodingError(error):
|
||||
pass
|
||||
|
||||
|
||||
class VComment(mutagen.Metadata, list):
|
||||
"""A Vorbis comment parser, accessor, and renderer.
|
||||
|
||||
All comment ordering is preserved. A VComment is a list of
|
||||
key/value pairs, and so any Python list method can be used on it.
|
||||
|
||||
Vorbis comments are always wrapped in something like an Ogg Vorbis
|
||||
bitstream or a FLAC metadata block, so this loads string data or a
|
||||
file-like object, not a filename.
|
||||
|
||||
Attributes:
|
||||
vendor -- the stream 'vendor' (i.e. writer); default 'Mutagen'
|
||||
"""
|
||||
|
||||
vendor = u"Mutagen " + mutagen.version_string
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
# Collect the args to pass to load, this lets child classes
|
||||
# override just load and get equivalent magic for the
|
||||
# constructor.
|
||||
if data is not None:
|
||||
if isinstance(data, str):
|
||||
data = StringIO(data)
|
||||
elif not hasattr(data, 'read'):
|
||||
raise TypeError("VComment requires string data or a file-like")
|
||||
self.load(data, *args, **kwargs)
|
||||
|
||||
def load(self, fileobj, errors='replace', framing=True):
|
||||
"""Parse a Vorbis comment from a file-like object.
|
||||
|
||||
Keyword arguments:
|
||||
errors:
|
||||
'strict', 'replace', or 'ignore'. This affects Unicode decoding
|
||||
and how other malformed content is interpreted.
|
||||
framing -- if true, fail if a framing bit is not present
|
||||
|
||||
Framing bits are required by the Vorbis comment specification,
|
||||
but are not used in FLAC Vorbis comment blocks.
|
||||
|
||||
"""
|
||||
try:
|
||||
vendor_length = cdata.uint_le(fileobj.read(4))
|
||||
self.vendor = fileobj.read(vendor_length).decode('utf-8', errors)
|
||||
count = cdata.uint_le(fileobj.read(4))
|
||||
for i in xrange(count):
|
||||
length = cdata.uint_le(fileobj.read(4))
|
||||
try:
|
||||
string = fileobj.read(length).decode('utf-8', errors)
|
||||
except (OverflowError, MemoryError):
|
||||
raise error("cannot read %d bytes, too large" % length)
|
||||
try:
|
||||
tag, value = string.split('=', 1)
|
||||
except ValueError, err:
|
||||
if errors == "ignore":
|
||||
continue
|
||||
elif errors == "replace":
|
||||
tag, value = u"unknown%d" % i, string
|
||||
else:
|
||||
raise VorbisEncodingError, err, sys.exc_info()[2]
|
||||
try:
|
||||
tag = tag.encode('ascii', errors)
|
||||
except UnicodeEncodeError:
|
||||
raise VorbisEncodingError("invalid tag name %r" % tag)
|
||||
else:
|
||||
if is_valid_key(tag):
|
||||
self.append((tag, value))
|
||||
if framing and not ord(fileobj.read(1)) & 0x01:
|
||||
raise VorbisUnsetFrameError("framing bit was unset")
|
||||
except (cdata.error, TypeError):
|
||||
raise error("file is not a valid Vorbis comment")
|
||||
|
||||
def validate(self):
|
||||
"""Validate keys and values.
|
||||
|
||||
Check to make sure every key used is a valid Vorbis key, and
|
||||
that every value used is a valid Unicode or UTF-8 string. If
|
||||
any invalid keys or values are found, a ValueError is raised.
|
||||
"""
|
||||
|
||||
if not isinstance(self.vendor, unicode):
|
||||
try:
|
||||
self.vendor.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
raise ValueError
|
||||
|
||||
for key, value in self:
|
||||
try:
|
||||
if not is_valid_key(key):
|
||||
raise ValueError
|
||||
except:
|
||||
raise ValueError("%r is not a valid key" % key)
|
||||
if not isinstance(value, unicode):
|
||||
try:
|
||||
value.encode("utf-8")
|
||||
except:
|
||||
raise ValueError("%r is not a valid value" % value)
|
||||
else:
|
||||
return True
|
||||
|
||||
def clear(self):
|
||||
"""Clear all keys from the comment."""
|
||||
del(self[:])
|
||||
|
||||
def write(self, framing=True):
|
||||
"""Return a string representation of the data.
|
||||
|
||||
Validation is always performed, so calling this function on
|
||||
invalid data may raise a ValueError.
|
||||
|
||||
Keyword arguments:
|
||||
framing -- if true, append a framing bit (see load)
|
||||
"""
|
||||
|
||||
self.validate()
|
||||
|
||||
f = StringIO()
|
||||
f.write(cdata.to_uint_le(len(self.vendor.encode('utf-8'))))
|
||||
f.write(self.vendor.encode('utf-8'))
|
||||
f.write(cdata.to_uint_le(len(self)))
|
||||
for tag, value in self:
|
||||
comment = "%s=%s" % (tag, value.encode('utf-8'))
|
||||
f.write(cdata.to_uint_le(len(comment)))
|
||||
f.write(comment)
|
||||
if framing:
|
||||
f.write("\x01")
|
||||
return f.getvalue()
|
||||
|
||||
def pprint(self):
|
||||
return "\n".join(["%s=%s" % (k.lower(), v) for k, v in self])
|
||||
|
||||
|
||||
class VCommentDict(VComment, DictMixin):
|
||||
"""A VComment that looks like a dictionary.
|
||||
|
||||
This object differs from a dictionary in two ways. First,
|
||||
len(comment) will still return the number of values, not the
|
||||
number of keys. Secondly, iterating through the object will
|
||||
iterate over (key, value) pairs, not keys. Since a key may have
|
||||
multiple values, the same value may appear multiple times while
|
||||
iterating.
|
||||
|
||||
Since Vorbis comment keys are case-insensitive, all keys are
|
||||
normalized to lowercase ASCII.
|
||||
"""
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""A list of values for the key.
|
||||
|
||||
This is a copy, so comment['title'].append('a title') will not
|
||||
work.
|
||||
|
||||
"""
|
||||
key = key.lower().encode('ascii')
|
||||
values = [value for (k, value) in self if k.lower() == key]
|
||||
if not values:
|
||||
raise KeyError(key)
|
||||
else:
|
||||
return values
|
||||
|
||||
def __delitem__(self, key):
|
||||
"""Delete all values associated with the key."""
|
||||
key = key.lower().encode('ascii')
|
||||
to_delete = filter(lambda x: x[0].lower() == key, self)
|
||||
if not to_delete:
|
||||
raise KeyError(key)
|
||||
else:
|
||||
map(self.remove, to_delete)
|
||||
|
||||
def __contains__(self, key):
|
||||
"""Return true if the key has any values."""
|
||||
key = key.lower().encode('ascii')
|
||||
for k, value in self:
|
||||
if k.lower() == key:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def __setitem__(self, key, values):
|
||||
"""Set a key's value or values.
|
||||
|
||||
Setting a value overwrites all old ones. The value may be a
|
||||
list of Unicode or UTF-8 strings, or a single Unicode or UTF-8
|
||||
string.
|
||||
|
||||
"""
|
||||
|
||||
key = key.encode('ascii')
|
||||
if not isinstance(values, list):
|
||||
values = [values]
|
||||
try:
|
||||
del(self[key])
|
||||
except KeyError:
|
||||
pass
|
||||
for value in values:
|
||||
self.append((key, value))
|
||||
|
||||
def keys(self):
|
||||
"""Return all keys in the comment."""
|
||||
return self and list(set([k.lower() for k, v in self]))
|
||||
|
||||
def as_dict(self):
|
||||
"""Return a copy of the comment data in a real dict."""
|
||||
return dict([(key, self[key]) for key in self.keys()])
|
||||
@@ -0,0 +1,525 @@
|
||||
# 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.
|
||||
|
||||
"""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
|
||||
|
||||
from mutagen import Metadata, FileType
|
||||
from mutagen._util import DictMixin, cdata, utf8, delete_bytes
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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")
|
||||
|
||||
# exclude the footer from size
|
||||
if self.footer is not None:
|
||||
self.size -= 32
|
||||
|
||||
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 = open(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_data = fileobj.read(4)
|
||||
# someone writes wrong item counts
|
||||
if not size_data:
|
||||
break
|
||||
size = cdata.uint_le(size_data)
|
||||
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)
|
||||
key = key.encode('ascii')
|
||||
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)
|
||||
key = key.encode('ascii')
|
||||
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)
|
||||
key = key.encode('ascii')
|
||||
|
||||
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:
|
||||
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 = open(filename, "r+b")
|
||||
except IOError:
|
||||
fileobj = open(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 = open(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)
|
||||
|
||||
__hash__ = _APEValue.__hash__
|
||||
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def pprint():
|
||||
return "Unknown format with APEv2 tag."
|
||||
|
||||
def load(self, filename):
|
||||
self.filename = filename
|
||||
self.info = self._Info(open(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))
|
||||
|
||||
@staticmethod
|
||||
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"))
|
||||
@@ -0,0 +1,704 @@
|
||||
# Copyright 2006-2007 Lukas Lalinsky
|
||||
# Copyright 2005-2006 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.
|
||||
|
||||
"""Read and write ASF (Window Media Audio) files."""
|
||||
|
||||
__all__ = ["ASF", "Open"]
|
||||
|
||||
import struct
|
||||
from mutagen import FileType, Metadata
|
||||
from mutagen._util import insert_bytes, delete_bytes, DictMixin
|
||||
|
||||
|
||||
class error(IOError):
|
||||
pass
|
||||
|
||||
|
||||
class ASFError(error):
|
||||
pass
|
||||
|
||||
|
||||
class ASFHeaderError(error):
|
||||
pass
|
||||
|
||||
|
||||
class ASFInfo(object):
|
||||
"""ASF stream information."""
|
||||
|
||||
def __init__(self):
|
||||
self.length = 0.0
|
||||
self.sample_rate = 0
|
||||
self.bitrate = 0
|
||||
self.channels = 0
|
||||
|
||||
def pprint(self):
|
||||
s = "Windows Media Audio %d bps, %s Hz, %d channels, %.2f seconds" % (
|
||||
self.bitrate, self.sample_rate, self.channels, self.length)
|
||||
return s
|
||||
|
||||
|
||||
class ASFTags(list, DictMixin, Metadata):
|
||||
"""Dictionary containing ASF attributes."""
|
||||
|
||||
def pprint(self):
|
||||
return "\n".join(["%s=%s" % (k, v) for k, v in self])
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""A list of values for the key.
|
||||
|
||||
This is a copy, so comment['title'].append('a title') will not
|
||||
work.
|
||||
|
||||
"""
|
||||
values = [value for (k, value) in self if k == key]
|
||||
if not values:
|
||||
raise KeyError(key)
|
||||
else:
|
||||
return values
|
||||
|
||||
def __delitem__(self, key):
|
||||
"""Delete all values associated with the key."""
|
||||
to_delete = filter(lambda x: x[0] == key, self)
|
||||
if not to_delete:
|
||||
raise KeyError(key)
|
||||
else:
|
||||
map(self.remove, to_delete)
|
||||
|
||||
def __contains__(self, key):
|
||||
"""Return true if the key has any values."""
|
||||
for k, value in self:
|
||||
if k == key:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def __setitem__(self, key, values):
|
||||
"""Set a key's value or values.
|
||||
|
||||
Setting a value overwrites all old ones. The value may be a
|
||||
list of Unicode or UTF-8 strings, or a single Unicode or UTF-8
|
||||
string.
|
||||
|
||||
"""
|
||||
if not isinstance(values, list):
|
||||
values = [values]
|
||||
try:
|
||||
del(self[key])
|
||||
except KeyError:
|
||||
pass
|
||||
for value in values:
|
||||
if key in _standard_attribute_names:
|
||||
value = unicode(value)
|
||||
elif not isinstance(value, ASFBaseAttribute):
|
||||
if isinstance(value, basestring):
|
||||
value = ASFUnicodeAttribute(value)
|
||||
elif isinstance(value, bool):
|
||||
value = ASFBoolAttribute(value)
|
||||
elif isinstance(value, int):
|
||||
value = ASFDWordAttribute(value)
|
||||
elif isinstance(value, long):
|
||||
value = ASFQWordAttribute(value)
|
||||
self.append((key, value))
|
||||
|
||||
def keys(self):
|
||||
"""Return all keys in the comment."""
|
||||
return self and set(zip(*self)[0])
|
||||
|
||||
def as_dict(self):
|
||||
"""Return a copy of the comment data in a real dict."""
|
||||
d = {}
|
||||
for key, value in self:
|
||||
d.setdefault(key, []).append(value)
|
||||
return d
|
||||
|
||||
|
||||
class ASFBaseAttribute(object):
|
||||
"""Generic attribute."""
|
||||
TYPE = None
|
||||
|
||||
def __init__(self, value=None, data=None, language=None,
|
||||
stream=None, **kwargs):
|
||||
self.language = language
|
||||
self.stream = stream
|
||||
if data:
|
||||
self.value = self.parse(data, **kwargs)
|
||||
else:
|
||||
self.value = value
|
||||
|
||||
def data_size(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def __repr__(self):
|
||||
name = "%s(%r" % (type(self).__name__, self.value)
|
||||
if self.language:
|
||||
name += ", language=%d" % self.language
|
||||
if self.stream:
|
||||
name += ", stream=%d" % self.stream
|
||||
name += ")"
|
||||
return name
|
||||
|
||||
def render(self, name):
|
||||
name = name.encode("utf-16-le") + "\x00\x00"
|
||||
data = self._render()
|
||||
return (struct.pack("<H", len(name)) + name +
|
||||
struct.pack("<HH", self.TYPE, len(data)) + data)
|
||||
|
||||
def render_m(self, name):
|
||||
name = name.encode("utf-16-le") + "\x00\x00"
|
||||
if self.TYPE == 2:
|
||||
data = self._render(dword=False)
|
||||
else:
|
||||
data = self._render()
|
||||
return (struct.pack("<HHHHI", 0, self.stream or 0, len(name),
|
||||
self.TYPE, len(data)) + name + data)
|
||||
|
||||
def render_ml(self, name):
|
||||
name = name.encode("utf-16-le") + "\x00\x00"
|
||||
if self.TYPE == 2:
|
||||
data = self._render(dword=False)
|
||||
else:
|
||||
data = self._render()
|
||||
return (struct.pack("<HHHHI", self.language or 0, self.stream or 0,
|
||||
len(name), self.TYPE, len(data)) + name + data)
|
||||
|
||||
|
||||
class ASFUnicodeAttribute(ASFBaseAttribute):
|
||||
"""Unicode string attribute."""
|
||||
TYPE = 0x0000
|
||||
|
||||
def parse(self, data):
|
||||
return data.decode("utf-16-le").strip("\x00")
|
||||
|
||||
def _render(self):
|
||||
return self.value.encode("utf-16-le") + "\x00\x00"
|
||||
|
||||
def data_size(self):
|
||||
return len(self._render())
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
def __cmp__(self, other):
|
||||
return cmp(unicode(self), other)
|
||||
|
||||
__hash__ = ASFBaseAttribute.__hash__
|
||||
|
||||
|
||||
class ASFByteArrayAttribute(ASFBaseAttribute):
|
||||
"""Byte array attribute."""
|
||||
TYPE = 0x0001
|
||||
|
||||
def parse(self, data):
|
||||
return data
|
||||
|
||||
def _render(self):
|
||||
return self.value
|
||||
|
||||
def data_size(self):
|
||||
return len(self.value)
|
||||
|
||||
def __str__(self):
|
||||
return "[binary data (%s bytes)]" % len(self.value)
|
||||
|
||||
def __cmp__(self, other):
|
||||
return cmp(str(self), other)
|
||||
|
||||
__hash__ = ASFBaseAttribute.__hash__
|
||||
|
||||
|
||||
class ASFBoolAttribute(ASFBaseAttribute):
|
||||
"""Bool attribute."""
|
||||
TYPE = 0x0002
|
||||
|
||||
def parse(self, data, dword=True):
|
||||
if dword:
|
||||
return struct.unpack("<I", data)[0] == 1
|
||||
else:
|
||||
return struct.unpack("<H", data)[0] == 1
|
||||
|
||||
def _render(self, dword=True):
|
||||
if dword:
|
||||
return struct.pack("<I", int(self.value))
|
||||
else:
|
||||
return struct.pack("<H", int(self.value))
|
||||
|
||||
def data_size(self):
|
||||
return 4
|
||||
|
||||
def __bool__(self):
|
||||
return self.value
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
def __cmp__(self, other):
|
||||
return cmp(bool(self), other)
|
||||
|
||||
__hash__ = ASFBaseAttribute.__hash__
|
||||
|
||||
|
||||
class ASFDWordAttribute(ASFBaseAttribute):
|
||||
"""DWORD attribute."""
|
||||
TYPE = 0x0003
|
||||
|
||||
def parse(self, data):
|
||||
return struct.unpack("<L", data)[0]
|
||||
|
||||
def _render(self):
|
||||
return struct.pack("<L", self.value)
|
||||
|
||||
def data_size(self):
|
||||
return 4
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
def __cmp__(self, other):
|
||||
return cmp(int(self), other)
|
||||
|
||||
__hash__ = ASFBaseAttribute.__hash__
|
||||
|
||||
|
||||
class ASFQWordAttribute(ASFBaseAttribute):
|
||||
"""QWORD attribute."""
|
||||
TYPE = 0x0004
|
||||
|
||||
def parse(self, data):
|
||||
return struct.unpack("<Q", data)[0]
|
||||
|
||||
def _render(self):
|
||||
return struct.pack("<Q", self.value)
|
||||
|
||||
def data_size(self):
|
||||
return 8
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
def __cmp__(self, other):
|
||||
return cmp(int(self), other)
|
||||
|
||||
__hash__ = ASFBaseAttribute.__hash__
|
||||
|
||||
|
||||
class ASFWordAttribute(ASFBaseAttribute):
|
||||
"""WORD attribute."""
|
||||
TYPE = 0x0005
|
||||
|
||||
def parse(self, data):
|
||||
return struct.unpack("<H", data)[0]
|
||||
|
||||
def _render(self):
|
||||
return struct.pack("<H", self.value)
|
||||
|
||||
def data_size(self):
|
||||
return 2
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
def __cmp__(self, other):
|
||||
return cmp(int(self), other)
|
||||
|
||||
__hash__ = ASFBaseAttribute.__hash__
|
||||
|
||||
|
||||
class ASFGUIDAttribute(ASFBaseAttribute):
|
||||
"""GUID attribute."""
|
||||
TYPE = 0x0006
|
||||
|
||||
def parse(self, data):
|
||||
return data
|
||||
|
||||
def _render(self):
|
||||
return self.value
|
||||
|
||||
def data_size(self):
|
||||
return len(self.value)
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
def __cmp__(self, other):
|
||||
return cmp(str(self), other)
|
||||
|
||||
__hash__ = ASFBaseAttribute.__hash__
|
||||
|
||||
|
||||
UNICODE = ASFUnicodeAttribute.TYPE
|
||||
BYTEARRAY = ASFByteArrayAttribute.TYPE
|
||||
BOOL = ASFBoolAttribute.TYPE
|
||||
DWORD = ASFDWordAttribute.TYPE
|
||||
QWORD = ASFQWordAttribute.TYPE
|
||||
WORD = ASFWordAttribute.TYPE
|
||||
GUID = ASFGUIDAttribute.TYPE
|
||||
|
||||
|
||||
def ASFValue(value, kind, **kwargs):
|
||||
for t, c in _attribute_types.items():
|
||||
if kind == t:
|
||||
return c(value=value, **kwargs)
|
||||
raise ValueError("Unknown value type")
|
||||
|
||||
|
||||
_attribute_types = {
|
||||
ASFUnicodeAttribute.TYPE: ASFUnicodeAttribute,
|
||||
ASFByteArrayAttribute.TYPE: ASFByteArrayAttribute,
|
||||
ASFBoolAttribute.TYPE: ASFBoolAttribute,
|
||||
ASFDWordAttribute.TYPE: ASFDWordAttribute,
|
||||
ASFQWordAttribute.TYPE: ASFQWordAttribute,
|
||||
ASFWordAttribute.TYPE: ASFWordAttribute,
|
||||
ASFGUIDAttribute.TYPE: ASFGUIDAttribute,
|
||||
}
|
||||
|
||||
|
||||
_standard_attribute_names = [
|
||||
"Title",
|
||||
"Author",
|
||||
"Copyright",
|
||||
"Description",
|
||||
"Rating"
|
||||
]
|
||||
|
||||
|
||||
class BaseObject(object):
|
||||
"""Base ASF object."""
|
||||
GUID = None
|
||||
|
||||
def parse(self, asf, data, fileobj, size):
|
||||
self.data = data
|
||||
|
||||
def render(self, asf):
|
||||
data = self.GUID + struct.pack("<Q", len(self.data) + 24) + self.data
|
||||
return data
|
||||
|
||||
|
||||
class UnknownObject(BaseObject):
|
||||
"""Unknown ASF object."""
|
||||
def __init__(self, guid):
|
||||
self.GUID = guid
|
||||
|
||||
|
||||
class HeaderObject(object):
|
||||
"""ASF header."""
|
||||
GUID = "\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C"
|
||||
|
||||
|
||||
class ContentDescriptionObject(BaseObject):
|
||||
"""Content description."""
|
||||
GUID = "\x33\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C"
|
||||
|
||||
def parse(self, asf, data, fileobj, size):
|
||||
super(ContentDescriptionObject, self).parse(asf, data, fileobj, size)
|
||||
asf.content_description_obj = self
|
||||
lengths = struct.unpack("<HHHHH", data[:10])
|
||||
texts = []
|
||||
pos = 10
|
||||
for length in lengths:
|
||||
end = pos + length
|
||||
if length > 0:
|
||||
texts.append(data[pos:end].decode("utf-16-le").strip("\x00"))
|
||||
else:
|
||||
texts.append(None)
|
||||
pos = end
|
||||
title, author, copyright, desc, rating = texts
|
||||
for key, value in dict(
|
||||
Title=title,
|
||||
Author=author,
|
||||
Copyright=copyright,
|
||||
Description=desc,
|
||||
Rating=rating
|
||||
).items():
|
||||
if value is not None:
|
||||
asf.tags[key] = value
|
||||
|
||||
def render(self, asf):
|
||||
def render_text(name):
|
||||
value = asf.tags.get(name, [])
|
||||
if value:
|
||||
return value[0].encode("utf-16-le") + "\x00\x00"
|
||||
else:
|
||||
return ""
|
||||
texts = map(render_text, _standard_attribute_names)
|
||||
data = struct.pack("<HHHHH", *map(len, texts)) + "".join(texts)
|
||||
return self.GUID + struct.pack("<Q", 24 + len(data)) + data
|
||||
|
||||
|
||||
class ExtendedContentDescriptionObject(BaseObject):
|
||||
"""Extended content description."""
|
||||
GUID = "\x40\xA4\xD0\xD2\x07\xE3\xD2\x11\x97\xF0\x00\xA0\xC9\x5E\xA8\x50"
|
||||
|
||||
def parse(self, asf, data, fileobj, size):
|
||||
super(ExtendedContentDescriptionObject, self).parse(
|
||||
asf, data, fileobj, size)
|
||||
asf.extended_content_description_obj = self
|
||||
num_attributes, = struct.unpack("<H", data[0:2])
|
||||
pos = 2
|
||||
for i in range(num_attributes):
|
||||
name_length, = struct.unpack("<H", data[pos:pos+2])
|
||||
pos += 2
|
||||
name = data[pos:pos+name_length].decode("utf-16-le").strip("\x00")
|
||||
pos += name_length
|
||||
value_type, value_length = struct.unpack("<HH", data[pos:pos+4])
|
||||
pos += 4
|
||||
value = data[pos:pos+value_length]
|
||||
pos += value_length
|
||||
attr = _attribute_types[value_type](data=value)
|
||||
asf.tags.append((name, attr))
|
||||
|
||||
def render(self, asf):
|
||||
attrs = asf.to_extended_content_description.items()
|
||||
data = "".join([attr.render(name) for (name, attr) in attrs])
|
||||
data = struct.pack("<QH", 26 + len(data), len(attrs)) + data
|
||||
return self.GUID + data
|
||||
|
||||
|
||||
class FilePropertiesObject(BaseObject):
|
||||
"""File properties."""
|
||||
GUID = "\xA1\xDC\xAB\x8C\x47\xA9\xCF\x11\x8E\xE4\x00\xC0\x0C\x20\x53\x65"
|
||||
|
||||
def parse(self, asf, data, fileobj, size):
|
||||
super(FilePropertiesObject, self).parse(asf, data, fileobj, size)
|
||||
length, _, preroll = struct.unpack("<QQQ", data[40:64])
|
||||
asf.info.length = length / 10000000.0 - preroll / 1000.0
|
||||
|
||||
|
||||
class StreamPropertiesObject(BaseObject):
|
||||
"""Stream properties."""
|
||||
GUID = "\x91\x07\xDC\xB7\xB7\xA9\xCF\x11\x8E\xE6\x00\xC0\x0C\x20\x53\x65"
|
||||
|
||||
def parse(self, asf, data, fileobj, size):
|
||||
super(StreamPropertiesObject, self).parse(asf, data, fileobj, size)
|
||||
channels, sample_rate, bitrate = struct.unpack("<HII", data[56:66])
|
||||
asf.info.channels = channels
|
||||
asf.info.sample_rate = sample_rate
|
||||
asf.info.bitrate = bitrate * 8
|
||||
|
||||
|
||||
class HeaderExtensionObject(BaseObject):
|
||||
"""Header extension."""
|
||||
GUID = "\xb5\x03\xbf_.\xa9\xcf\x11\x8e\xe3\x00\xc0\x0c Se"
|
||||
|
||||
def parse(self, asf, data, fileobj, size):
|
||||
super(HeaderExtensionObject, self).parse(asf, data, fileobj, size)
|
||||
asf.header_extension_obj = self
|
||||
datasize, = struct.unpack("<I", data[18:22])
|
||||
datapos = 0
|
||||
self.objects = []
|
||||
while datapos < datasize:
|
||||
guid, size = struct.unpack("<16sQ", data[22+datapos:22+datapos+24])
|
||||
if guid in _object_types:
|
||||
obj = _object_types[guid]()
|
||||
else:
|
||||
obj = UnknownObject(guid)
|
||||
obj.parse(asf, data[22+datapos+24:22+datapos+size], fileobj, size)
|
||||
self.objects.append(obj)
|
||||
datapos += size
|
||||
|
||||
def render(self, asf):
|
||||
data = "".join([obj.render(asf) for obj in self.objects])
|
||||
return (self.GUID + struct.pack("<Q", 24 + 16 + 6 + len(data)) +
|
||||
"\x11\xD2\xD3\xAB\xBA\xA9\xcf\x11" +
|
||||
"\x8E\xE6\x00\xC0\x0C\x20\x53\x65" +
|
||||
"\x06\x00" + struct.pack("<I", len(data)) + data)
|
||||
|
||||
|
||||
class MetadataObject(BaseObject):
|
||||
"""Metadata description."""
|
||||
GUID = "\xea\xcb\xf8\xc5\xaf[wH\x84g\xaa\x8cD\xfaL\xca"
|
||||
|
||||
def parse(self, asf, data, fileobj, size):
|
||||
super(MetadataObject, self).parse(asf, data, fileobj, size)
|
||||
asf.metadata_obj = self
|
||||
num_attributes, = struct.unpack("<H", data[0:2])
|
||||
pos = 2
|
||||
for i in range(num_attributes):
|
||||
(reserved, stream, name_length, value_type,
|
||||
value_length) = struct.unpack("<HHHHI", data[pos:pos+12])
|
||||
pos += 12
|
||||
name = data[pos:pos+name_length].decode("utf-16-le").strip("\x00")
|
||||
pos += name_length
|
||||
value = data[pos:pos+value_length]
|
||||
pos += value_length
|
||||
args = {'data': value, 'stream': stream}
|
||||
if value_type == 2:
|
||||
args['dword'] = False
|
||||
attr = _attribute_types[value_type](**args)
|
||||
asf.tags.append((name, attr))
|
||||
|
||||
def render(self, asf):
|
||||
attrs = asf.to_metadata.items()
|
||||
data = "".join([attr.render_m(name) for (name, attr) in attrs])
|
||||
return (self.GUID + struct.pack("<QH", 26 + len(data), len(attrs)) +
|
||||
data)
|
||||
|
||||
|
||||
class MetadataLibraryObject(BaseObject):
|
||||
"""Metadata library description."""
|
||||
GUID = "\x94\x1c#D\x98\x94\xd1I\xa1A\x1d\x13NEpT"
|
||||
|
||||
def parse(self, asf, data, fileobj, size):
|
||||
super(MetadataLibraryObject, self).parse(asf, data, fileobj, size)
|
||||
asf.metadata_library_obj = self
|
||||
num_attributes, = struct.unpack("<H", data[0:2])
|
||||
pos = 2
|
||||
for i in range(num_attributes):
|
||||
(language, stream, name_length, value_type,
|
||||
value_length) = struct.unpack("<HHHHI", data[pos:pos+12])
|
||||
pos += 12
|
||||
name = data[pos:pos+name_length].decode("utf-16-le").strip("\x00")
|
||||
pos += name_length
|
||||
value = data[pos:pos+value_length]
|
||||
pos += value_length
|
||||
args = {'data': value, 'language': language, 'stream': stream}
|
||||
if value_type == 2:
|
||||
args['dword'] = False
|
||||
attr = _attribute_types[value_type](**args)
|
||||
asf.tags.append((name, attr))
|
||||
|
||||
def render(self, asf):
|
||||
attrs = asf.to_metadata_library
|
||||
data = "".join([attr.render_ml(name) for (name, attr) in attrs])
|
||||
return (self.GUID + struct.pack("<QH", 26 + len(data), len(attrs)) +
|
||||
data)
|
||||
|
||||
|
||||
_object_types = {
|
||||
ExtendedContentDescriptionObject.GUID: ExtendedContentDescriptionObject,
|
||||
ContentDescriptionObject.GUID: ContentDescriptionObject,
|
||||
FilePropertiesObject.GUID: FilePropertiesObject,
|
||||
StreamPropertiesObject.GUID: StreamPropertiesObject,
|
||||
HeaderExtensionObject.GUID: HeaderExtensionObject,
|
||||
MetadataLibraryObject.GUID: MetadataLibraryObject,
|
||||
MetadataObject.GUID: MetadataObject,
|
||||
}
|
||||
|
||||
|
||||
class ASF(FileType):
|
||||
"""An ASF file, probably containing WMA or WMV."""
|
||||
|
||||
_mimes = ["audio/x-ms-wma", "audio/x-ms-wmv", "video/x-ms-asf",
|
||||
"audio/x-wma", "video/x-wmv"]
|
||||
|
||||
def load(self, filename):
|
||||
self.filename = filename
|
||||
fileobj = open(filename, "rb")
|
||||
try:
|
||||
self.size = 0
|
||||
self.size1 = 0
|
||||
self.size2 = 0
|
||||
self.offset1 = 0
|
||||
self.offset2 = 0
|
||||
self.num_objects = 0
|
||||
self.info = ASFInfo()
|
||||
self.tags = ASFTags()
|
||||
self.__read_file(fileobj)
|
||||
finally:
|
||||
fileobj.close()
|
||||
|
||||
def save(self):
|
||||
# Move attributes to the right objects
|
||||
self.to_extended_content_description = {}
|
||||
self.to_metadata = {}
|
||||
self.to_metadata_library = []
|
||||
for name, value in self.tags:
|
||||
if name in _standard_attribute_names:
|
||||
continue
|
||||
library_only = (value.data_size() > 0xFFFF or value.TYPE == GUID)
|
||||
if (value.language is None and value.stream is None and
|
||||
name not in self.to_extended_content_description and
|
||||
not library_only):
|
||||
self.to_extended_content_description[name] = value
|
||||
elif (value.language is None and value.stream is not None and
|
||||
name not in self.to_metadata and not library_only):
|
||||
self.to_metadata[name] = value
|
||||
else:
|
||||
self.to_metadata_library.append((name, value))
|
||||
|
||||
# Add missing objects
|
||||
if not self.content_description_obj:
|
||||
self.content_description_obj = \
|
||||
ContentDescriptionObject()
|
||||
self.objects.append(self.content_description_obj)
|
||||
if not self.extended_content_description_obj:
|
||||
self.extended_content_description_obj = \
|
||||
ExtendedContentDescriptionObject()
|
||||
self.objects.append(self.extended_content_description_obj)
|
||||
if not self.header_extension_obj:
|
||||
self.header_extension_obj = \
|
||||
HeaderExtensionObject()
|
||||
self.objects.append(self.header_extension_obj)
|
||||
if not self.metadata_obj:
|
||||
self.metadata_obj = \
|
||||
MetadataObject()
|
||||
self.header_extension_obj.objects.append(self.metadata_obj)
|
||||
if not self.metadata_library_obj:
|
||||
self.metadata_library_obj = \
|
||||
MetadataLibraryObject()
|
||||
self.header_extension_obj.objects.append(self.metadata_library_obj)
|
||||
|
||||
# Render the header
|
||||
data = "".join([obj.render(self) for obj in self.objects])
|
||||
data = (HeaderObject.GUID +
|
||||
struct.pack("<QL", len(data) + 30, len(self.objects)) +
|
||||
"\x01\x02" + data)
|
||||
|
||||
fileobj = open(self.filename, "rb+")
|
||||
try:
|
||||
size = len(data)
|
||||
if size > self.size:
|
||||
insert_bytes(fileobj, size - self.size, self.size)
|
||||
if size < self.size:
|
||||
delete_bytes(fileobj, self.size - size, 0)
|
||||
fileobj.seek(0)
|
||||
fileobj.write(data)
|
||||
finally:
|
||||
fileobj.close()
|
||||
|
||||
self.size = size
|
||||
self.num_objects = len(self.objects)
|
||||
|
||||
def __read_file(self, fileobj):
|
||||
header = fileobj.read(30)
|
||||
if len(header) != 30 or header[:16] != HeaderObject.GUID:
|
||||
raise ASFHeaderError("Not an ASF file.")
|
||||
|
||||
self.extended_content_description_obj = None
|
||||
self.content_description_obj = None
|
||||
self.header_extension_obj = None
|
||||
self.metadata_obj = None
|
||||
self.metadata_library_obj = None
|
||||
|
||||
self.size, self.num_objects = struct.unpack("<QL", header[16:28])
|
||||
self.objects = []
|
||||
for i in range(self.num_objects):
|
||||
self.__read_object(fileobj)
|
||||
|
||||
def __read_object(self, fileobj):
|
||||
guid, size = struct.unpack("<16sQ", fileobj.read(24))
|
||||
if guid in _object_types:
|
||||
obj = _object_types[guid]()
|
||||
else:
|
||||
obj = UnknownObject(guid)
|
||||
data = fileobj.read(size - 24)
|
||||
obj.parse(self, data, fileobj, size)
|
||||
self.objects.append(obj)
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return header.startswith(HeaderObject.GUID) * 2
|
||||
|
||||
Open = ASF
|
||||
@@ -0,0 +1,504 @@
|
||||
# Simpler (but far more limited) API for ID3 editing
|
||||
# Copyright 2006 Joe Wreschnig
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Easier access to ID3 tags.
|
||||
|
||||
EasyID3 is a wrapper around mutagen.id3.ID3 to make ID3 tags appear
|
||||
more like Vorbis or APEv2 tags.
|
||||
"""
|
||||
|
||||
import mutagen.id3
|
||||
|
||||
from mutagen import Metadata
|
||||
from mutagen._util import DictMixin, dict_match
|
||||
from mutagen.id3 import ID3, error, delete, ID3FileType
|
||||
|
||||
|
||||
__all__ = ['EasyID3', 'Open', 'delete']
|
||||
|
||||
|
||||
class EasyID3KeyError(KeyError, ValueError, error):
|
||||
"""Raised when trying to get/set an invalid key.
|
||||
|
||||
Subclasses both KeyError and ValueError for API compatibility,
|
||||
catching KeyError is preferred.
|
||||
"""
|
||||
|
||||
|
||||
class EasyID3(DictMixin, Metadata):
|
||||
"""A file with an ID3 tag.
|
||||
|
||||
Like Vorbis comments, EasyID3 keys are case-insensitive ASCII
|
||||
strings. Only a subset of ID3 frames are supported by default. Use
|
||||
EasyID3.RegisterKey and its wrappers to support more.
|
||||
|
||||
You can also set the GetFallback, SetFallback, and DeleteFallback
|
||||
to generic key getter/setter/deleter functions, which are called
|
||||
if no specific handler is registered for a key. Additionally,
|
||||
ListFallback can be used to supply an arbitrary list of extra
|
||||
keys. These can be set on EasyID3 or on individual instances after
|
||||
creation.
|
||||
|
||||
To use an EasyID3 class with mutagen.mp3.MP3::
|
||||
|
||||
from mutagen.mp3 import EasyMP3 as MP3
|
||||
MP3(filename)
|
||||
|
||||
Because many of the attributes are constructed on the fly, things
|
||||
like the following will not work::
|
||||
|
||||
ezid3["performer"].append("Joe")
|
||||
|
||||
Instead, you must do::
|
||||
|
||||
values = ezid3["performer"]
|
||||
values.append("Joe")
|
||||
ezid3["performer"] = values
|
||||
|
||||
"""
|
||||
|
||||
Set = {}
|
||||
Get = {}
|
||||
Delete = {}
|
||||
List = {}
|
||||
|
||||
# For compatibility.
|
||||
valid_keys = Get
|
||||
|
||||
GetFallback = None
|
||||
SetFallback = None
|
||||
DeleteFallback = None
|
||||
ListFallback = None
|
||||
|
||||
@classmethod
|
||||
def RegisterKey(cls, key,
|
||||
getter=None, setter=None, deleter=None, lister=None):
|
||||
"""Register a new key mapping.
|
||||
|
||||
A key mapping is four functions, a getter, setter, deleter,
|
||||
and lister. The key may be either a string or a glob pattern.
|
||||
|
||||
The getter, deleted, and lister receive an ID3 instance and
|
||||
the requested key name. The setter also receives the desired
|
||||
value, which will be a list of strings.
|
||||
|
||||
The getter, setter, and deleter are used to implement __getitem__,
|
||||
__setitem__, and __delitem__.
|
||||
|
||||
The lister is used to implement keys(). It should return a
|
||||
list of keys that are actually in the ID3 instance, provided
|
||||
by its associated getter.
|
||||
"""
|
||||
key = key.lower()
|
||||
if getter is not None:
|
||||
cls.Get[key] = getter
|
||||
if setter is not None:
|
||||
cls.Set[key] = setter
|
||||
if deleter is not None:
|
||||
cls.Delete[key] = deleter
|
||||
if lister is not None:
|
||||
cls.List[key] = lister
|
||||
|
||||
@classmethod
|
||||
def RegisterTextKey(cls, key, frameid):
|
||||
"""Register a text key.
|
||||
|
||||
If the key you need to register is a simple one-to-one mapping
|
||||
of ID3 frame name to EasyID3 key, then you can use this
|
||||
function::
|
||||
|
||||
EasyID3.RegisterTextKey("title", "TIT2")
|
||||
"""
|
||||
def getter(id3, key):
|
||||
return list(id3[frameid])
|
||||
|
||||
def setter(id3, key, value):
|
||||
try:
|
||||
frame = id3[frameid]
|
||||
except KeyError:
|
||||
id3.add(mutagen.id3.Frames[frameid](encoding=3, text=value))
|
||||
else:
|
||||
frame.encoding = 3
|
||||
frame.text = value
|
||||
|
||||
def deleter(id3, key):
|
||||
del(id3[frameid])
|
||||
|
||||
cls.RegisterKey(key, getter, setter, deleter)
|
||||
|
||||
@classmethod
|
||||
def RegisterTXXXKey(cls, key, desc):
|
||||
"""Register a user-defined text frame key.
|
||||
|
||||
Some ID3 tags are stored in TXXX frames, which allow a
|
||||
freeform 'description' which acts as a subkey,
|
||||
e.g. TXXX:BARCODE.::
|
||||
|
||||
EasyID3.RegisterTXXXKey('barcode', 'BARCODE').
|
||||
"""
|
||||
frameid = "TXXX:" + desc
|
||||
|
||||
def getter(id3, key):
|
||||
return list(id3[frameid])
|
||||
|
||||
def setter(id3, key, value):
|
||||
try:
|
||||
frame = id3[frameid]
|
||||
except KeyError:
|
||||
enc = 0
|
||||
# Store 8859-1 if we can, per MusicBrainz spec.
|
||||
for v in value:
|
||||
if v and max(v) > u'\x7f':
|
||||
enc = 3
|
||||
id3.add(mutagen.id3.TXXX(encoding=enc, text=value, desc=desc))
|
||||
else:
|
||||
frame.text = value
|
||||
|
||||
def deleter(id3, key):
|
||||
del(id3[frameid])
|
||||
|
||||
cls.RegisterKey(key, getter, setter, deleter)
|
||||
|
||||
def __init__(self, filename=None):
|
||||
self.__id3 = ID3()
|
||||
if filename is not None:
|
||||
self.load(filename)
|
||||
|
||||
load = property(lambda s: s.__id3.load,
|
||||
lambda s, v: setattr(s.__id3, 'load', v))
|
||||
|
||||
save = property(lambda s: s.__id3.save,
|
||||
lambda s, v: setattr(s.__id3, 'save', v))
|
||||
|
||||
delete = property(lambda s: s.__id3.delete,
|
||||
lambda s, v: setattr(s.__id3, 'delete', v))
|
||||
|
||||
filename = property(lambda s: s.__id3.filename,
|
||||
lambda s, fn: setattr(s.__id3, 'filename', fn))
|
||||
|
||||
size = property(lambda s: s.__id3.size,
|
||||
lambda s, fn: setattr(s.__id3, 'size', s))
|
||||
|
||||
def __getitem__(self, key):
|
||||
key = key.lower()
|
||||
func = dict_match(self.Get, key, self.GetFallback)
|
||||
if func is not None:
|
||||
return func(self.__id3, key)
|
||||
else:
|
||||
raise EasyID3KeyError("%r is not a valid key" % key)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
key = key.lower()
|
||||
if isinstance(value, basestring):
|
||||
value = [value]
|
||||
func = dict_match(self.Set, key, self.SetFallback)
|
||||
if func is not None:
|
||||
return func(self.__id3, key, value)
|
||||
else:
|
||||
raise EasyID3KeyError("%r is not a valid key" % key)
|
||||
|
||||
def __delitem__(self, key):
|
||||
key = key.lower()
|
||||
func = dict_match(self.Delete, key, self.DeleteFallback)
|
||||
if func is not None:
|
||||
return func(self.__id3, key)
|
||||
else:
|
||||
raise EasyID3KeyError("%r is not a valid key" % key)
|
||||
|
||||
def keys(self):
|
||||
keys = []
|
||||
for key in self.Get.keys():
|
||||
if key in self.List:
|
||||
keys.extend(self.List[key](self.__id3, key))
|
||||
elif key in self:
|
||||
keys.append(key)
|
||||
if self.ListFallback is not None:
|
||||
keys.extend(self.ListFallback(self.__id3, ""))
|
||||
return keys
|
||||
|
||||
def pprint(self):
|
||||
"""Print tag key=value pairs."""
|
||||
strings = []
|
||||
for key in sorted(self.keys()):
|
||||
values = self[key]
|
||||
for value in values:
|
||||
strings.append("%s=%s" % (key, value))
|
||||
return "\n".join(strings)
|
||||
|
||||
|
||||
Open = EasyID3
|
||||
|
||||
|
||||
def genre_get(id3, key):
|
||||
return id3["TCON"].genres
|
||||
|
||||
|
||||
def genre_set(id3, key, value):
|
||||
try:
|
||||
frame = id3["TCON"]
|
||||
except KeyError:
|
||||
id3.add(mutagen.id3.TCON(encoding=3, text=value))
|
||||
else:
|
||||
frame.encoding = 3
|
||||
frame.genres = value
|
||||
|
||||
|
||||
def genre_delete(id3, key):
|
||||
del(id3["TCON"])
|
||||
|
||||
|
||||
def date_get(id3, key):
|
||||
return [stamp.text for stamp in id3["TDRC"].text]
|
||||
|
||||
|
||||
def date_set(id3, key, value):
|
||||
id3.add(mutagen.id3.TDRC(encoding=3, text=value))
|
||||
|
||||
|
||||
def date_delete(id3, key):
|
||||
del(id3["TDRC"])
|
||||
|
||||
|
||||
def performer_get(id3, key):
|
||||
people = []
|
||||
wanted_role = key.split(":", 1)[1]
|
||||
try:
|
||||
mcl = id3["TMCL"]
|
||||
except KeyError:
|
||||
raise KeyError(key)
|
||||
for role, person in mcl.people:
|
||||
if role == wanted_role:
|
||||
people.append(person)
|
||||
if people:
|
||||
return people
|
||||
else:
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
def performer_set(id3, key, value):
|
||||
wanted_role = key.split(":", 1)[1]
|
||||
try:
|
||||
mcl = id3["TMCL"]
|
||||
except KeyError:
|
||||
mcl = mutagen.id3.TMCL(encoding=3, people=[])
|
||||
id3.add(mcl)
|
||||
mcl.encoding = 3
|
||||
people = [p for p in mcl.people if p[0] != wanted_role]
|
||||
for v in value:
|
||||
people.append((wanted_role, v))
|
||||
mcl.people = people
|
||||
|
||||
|
||||
def performer_delete(id3, key):
|
||||
wanted_role = key.split(":", 1)[1]
|
||||
try:
|
||||
mcl = id3["TMCL"]
|
||||
except KeyError:
|
||||
raise KeyError(key)
|
||||
people = [p for p in mcl.people if p[0] != wanted_role]
|
||||
if people == mcl.people:
|
||||
raise KeyError(key)
|
||||
elif people:
|
||||
mcl.people = people
|
||||
else:
|
||||
del(id3["TMCL"])
|
||||
|
||||
|
||||
def performer_list(id3, key):
|
||||
try:
|
||||
mcl = id3["TMCL"]
|
||||
except KeyError:
|
||||
return []
|
||||
else:
|
||||
return list(set("performer:" + p[0] for p in mcl.people))
|
||||
|
||||
|
||||
def musicbrainz_trackid_get(id3, key):
|
||||
return [id3["UFID:http://musicbrainz.org"].data.decode('ascii')]
|
||||
|
||||
|
||||
def musicbrainz_trackid_set(id3, key, value):
|
||||
if len(value) != 1:
|
||||
raise ValueError("only one track ID may be set per song")
|
||||
value = value[0].encode('ascii')
|
||||
try:
|
||||
frame = id3["UFID:http://musicbrainz.org"]
|
||||
except KeyError:
|
||||
frame = mutagen.id3.UFID(owner="http://musicbrainz.org", data=value)
|
||||
id3.add(frame)
|
||||
else:
|
||||
frame.data = value
|
||||
|
||||
|
||||
def musicbrainz_trackid_delete(id3, key):
|
||||
del(id3["UFID:http://musicbrainz.org"])
|
||||
|
||||
|
||||
def website_get(id3, key):
|
||||
urls = [frame.url for frame in id3.getall("WOAR")]
|
||||
if urls:
|
||||
return urls
|
||||
else:
|
||||
raise EasyID3KeyError(key)
|
||||
|
||||
|
||||
def website_set(id3, key, value):
|
||||
id3.delall("WOAR")
|
||||
for v in value:
|
||||
id3.add(mutagen.id3.WOAR(url=v))
|
||||
|
||||
|
||||
def website_delete(id3, key):
|
||||
id3.delall("WOAR")
|
||||
|
||||
|
||||
def gain_get(id3, key):
|
||||
try:
|
||||
frame = id3["RVA2:" + key[11:-5]]
|
||||
except KeyError:
|
||||
raise EasyID3KeyError(key)
|
||||
else:
|
||||
return [u"%+f dB" % frame.gain]
|
||||
|
||||
|
||||
def gain_set(id3, key, value):
|
||||
if len(value) != 1:
|
||||
raise ValueError(
|
||||
"there must be exactly one gain value, not %r.", value)
|
||||
gain = float(value[0].split()[0])
|
||||
try:
|
||||
frame = id3["RVA2:" + key[11:-5]]
|
||||
except KeyError:
|
||||
frame = mutagen.id3.RVA2(desc=key[11:-5], gain=0, peak=0, channel=1)
|
||||
id3.add(frame)
|
||||
frame.gain = gain
|
||||
|
||||
|
||||
def gain_delete(id3, key):
|
||||
try:
|
||||
frame = id3["RVA2:" + key[11:-5]]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if frame.peak:
|
||||
frame.gain = 0.0
|
||||
else:
|
||||
del(id3["RVA2:" + key[11:-5]])
|
||||
|
||||
|
||||
def peak_get(id3, key):
|
||||
try:
|
||||
frame = id3["RVA2:" + key[11:-5]]
|
||||
except KeyError:
|
||||
raise EasyID3KeyError(key)
|
||||
else:
|
||||
return [u"%f" % frame.peak]
|
||||
|
||||
|
||||
def peak_set(id3, key, value):
|
||||
if len(value) != 1:
|
||||
raise ValueError(
|
||||
"there must be exactly one peak value, not %r.", value)
|
||||
peak = float(value[0])
|
||||
if peak >= 2 or peak < 0:
|
||||
raise ValueError("peak must be => 0 and < 2.")
|
||||
try:
|
||||
frame = id3["RVA2:" + key[11:-5]]
|
||||
except KeyError:
|
||||
frame = mutagen.id3.RVA2(desc=key[11:-5], gain=0, peak=0, channel=1)
|
||||
id3.add(frame)
|
||||
frame.peak = peak
|
||||
|
||||
|
||||
def peak_delete(id3, key):
|
||||
try:
|
||||
frame = id3["RVA2:" + key[11:-5]]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if frame.gain:
|
||||
frame.peak = 0.0
|
||||
else:
|
||||
del(id3["RVA2:" + key[11:-5]])
|
||||
|
||||
|
||||
def peakgain_list(id3, key):
|
||||
keys = []
|
||||
for frame in id3.getall("RVA2"):
|
||||
keys.append("replaygain_%s_gain" % frame.desc)
|
||||
keys.append("replaygain_%s_peak" % frame.desc)
|
||||
return keys
|
||||
|
||||
for frameid, key in {
|
||||
"TALB": "album",
|
||||
"TBPM": "bpm",
|
||||
"TCMP": "compilation", # iTunes extension
|
||||
"TCOM": "composer",
|
||||
"TCOP": "copyright",
|
||||
"TENC": "encodedby",
|
||||
"TEXT": "lyricist",
|
||||
"TLEN": "length",
|
||||
"TMED": "media",
|
||||
"TMOO": "mood",
|
||||
"TIT2": "title",
|
||||
"TIT3": "version",
|
||||
"TPE1": "artist",
|
||||
"TPE2": "performer",
|
||||
"TPE3": "conductor",
|
||||
"TPE4": "arranger",
|
||||
"TPOS": "discnumber",
|
||||
"TPUB": "organization",
|
||||
"TRCK": "tracknumber",
|
||||
"TOLY": "author",
|
||||
"TSO2": "albumartistsort", # iTunes extension
|
||||
"TSOA": "albumsort",
|
||||
"TSOC": "composersort", # iTunes extension
|
||||
"TSOP": "artistsort",
|
||||
"TSOT": "titlesort",
|
||||
"TSRC": "isrc",
|
||||
"TSST": "discsubtitle",
|
||||
}.iteritems():
|
||||
EasyID3.RegisterTextKey(key, frameid)
|
||||
|
||||
EasyID3.RegisterKey("genre", genre_get, genre_set, genre_delete)
|
||||
EasyID3.RegisterKey("date", date_get, date_set, date_delete)
|
||||
EasyID3.RegisterKey(
|
||||
"performer:*", performer_get, performer_set, performer_delete,
|
||||
performer_list)
|
||||
EasyID3.RegisterKey("musicbrainz_trackid", musicbrainz_trackid_get,
|
||||
musicbrainz_trackid_set, musicbrainz_trackid_delete)
|
||||
EasyID3.RegisterKey("website", website_get, website_set, website_delete)
|
||||
EasyID3.RegisterKey("website", website_get, website_set, website_delete)
|
||||
EasyID3.RegisterKey(
|
||||
"replaygain_*_gain", gain_get, gain_set, gain_delete, peakgain_list)
|
||||
EasyID3.RegisterKey("replaygain_*_peak", peak_get, peak_set, peak_delete)
|
||||
|
||||
# At various times, information for this came from
|
||||
# http://musicbrainz.org/docs/specs/metadata_tags.html
|
||||
# http://bugs.musicbrainz.org/ticket/1383
|
||||
# http://musicbrainz.org/doc/MusicBrainzTag
|
||||
for desc, key in {
|
||||
u"MusicBrainz Artist Id": "musicbrainz_artistid",
|
||||
u"MusicBrainz Album Id": "musicbrainz_albumid",
|
||||
u"MusicBrainz Album Artist Id": "musicbrainz_albumartistid",
|
||||
u"MusicBrainz TRM Id": "musicbrainz_trmid",
|
||||
u"MusicIP PUID": "musicip_puid",
|
||||
u"MusicMagic Fingerprint": "musicip_fingerprint",
|
||||
u"MusicBrainz Album Status": "musicbrainz_albumstatus",
|
||||
u"MusicBrainz Album Type": "musicbrainz_albumtype",
|
||||
u"MusicBrainz Album Release Country": "releasecountry",
|
||||
u"MusicBrainz Disc Id": "musicbrainz_discid",
|
||||
u"ASIN": "asin",
|
||||
u"ALBUMARTISTSORT": "albumartistsort",
|
||||
u"BARCODE": "barcode",
|
||||
}.iteritems():
|
||||
EasyID3.RegisterTXXXKey(key, desc)
|
||||
|
||||
|
||||
class EasyID3FileType(ID3FileType):
|
||||
"""Like ID3FileType, but uses EasyID3 for tags."""
|
||||
ID3 = EasyID3
|
||||
@@ -0,0 +1,266 @@
|
||||
# Copyright 2009 Joe Wreschnig
|
||||
#
|
||||
# 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.
|
||||
|
||||
from mutagen import Metadata
|
||||
from mutagen._util import DictMixin, dict_match, utf8
|
||||
from mutagen.mp4 import MP4, MP4Tags, error, delete
|
||||
|
||||
__all__ = ["EasyMP4Tags", "EasyMP4", "delete", "error"]
|
||||
|
||||
|
||||
class EasyMP4KeyError(error, KeyError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class EasyMP4Tags(DictMixin, Metadata):
|
||||
"""A file with MPEG-4 iTunes metadata.
|
||||
|
||||
Like Vorbis comments, EasyMP4Tags keys are case-insensitive ASCII
|
||||
strings, and values are a list of Unicode strings (and these lists
|
||||
are always of length 0 or 1).
|
||||
|
||||
If you need access to the full MP4 metadata feature set, you should use
|
||||
MP4, not EasyMP4.
|
||||
"""
|
||||
|
||||
Set = {}
|
||||
Get = {}
|
||||
Delete = {}
|
||||
List = {}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.__mp4 = MP4Tags(*args, **kwargs)
|
||||
self.load = self.__mp4.load
|
||||
self.save = self.__mp4.save
|
||||
self.delete = self.__mp4.delete
|
||||
|
||||
filename = property(lambda s: s.__mp4.filename,
|
||||
lambda s, fn: setattr(s.__mp4, 'filename', fn))
|
||||
|
||||
@classmethod
|
||||
def RegisterKey(cls, key,
|
||||
getter=None, setter=None, deleter=None, lister=None):
|
||||
"""Register a new key mapping.
|
||||
|
||||
A key mapping is four functions, a getter, setter, deleter,
|
||||
and lister. The key may be either a string or a glob pattern.
|
||||
|
||||
The getter, deleted, and lister receive an MP4Tags instance
|
||||
and the requested key name. The setter also receives the
|
||||
desired value, which will be a list of strings.
|
||||
|
||||
The getter, setter, and deleter are used to implement __getitem__,
|
||||
__setitem__, and __delitem__.
|
||||
|
||||
The lister is used to implement keys(). It should return a
|
||||
list of keys that are actually in the MP4 instance, provided
|
||||
by its associated getter.
|
||||
"""
|
||||
key = key.lower()
|
||||
if getter is not None:
|
||||
cls.Get[key] = getter
|
||||
if setter is not None:
|
||||
cls.Set[key] = setter
|
||||
if deleter is not None:
|
||||
cls.Delete[key] = deleter
|
||||
if lister is not None:
|
||||
cls.List[key] = lister
|
||||
|
||||
@classmethod
|
||||
def RegisterTextKey(cls, key, atomid):
|
||||
"""Register a text key.
|
||||
|
||||
If the key you need to register is a simple one-to-one mapping
|
||||
of MP4 atom name to EasyMP4Tags key, then you can use this
|
||||
function::
|
||||
|
||||
EasyMP4Tags.RegisterTextKey("artist", "\xa9ART")
|
||||
"""
|
||||
def getter(tags, key):
|
||||
return tags[atomid]
|
||||
|
||||
def setter(tags, key, value):
|
||||
tags[atomid] = value
|
||||
|
||||
def deleter(tags, key):
|
||||
del(tags[atomid])
|
||||
|
||||
cls.RegisterKey(key, getter, setter, deleter)
|
||||
|
||||
@classmethod
|
||||
def RegisterIntKey(cls, key, atomid, min_value=0, max_value=2**16-1):
|
||||
"""Register a scalar integer key.
|
||||
"""
|
||||
|
||||
def getter(tags, key):
|
||||
return map(unicode, tags[atomid])
|
||||
|
||||
def setter(tags, key, value):
|
||||
clamp = lambda x: int(min(max(min_value, x), max_value))
|
||||
tags[atomid] = map(clamp, map(int, value))
|
||||
|
||||
def deleter(tags, key):
|
||||
del(tags[atomid])
|
||||
|
||||
cls.RegisterKey(key, getter, setter, deleter)
|
||||
|
||||
@classmethod
|
||||
def RegisterIntPairKey(cls, key, atomid, min_value=0, max_value=2**16-1):
|
||||
def getter(tags, key):
|
||||
ret = []
|
||||
for (track, total) in tags[atomid]:
|
||||
if total:
|
||||
ret.append(u"%d/%d" % (track, total))
|
||||
else:
|
||||
ret.append(unicode(track))
|
||||
return ret
|
||||
|
||||
def setter(tags, key, value):
|
||||
clamp = lambda x: int(min(max(min_value, x), max_value))
|
||||
data = []
|
||||
for v in value:
|
||||
try:
|
||||
tracks, total = v.split("/")
|
||||
tracks = clamp(int(tracks))
|
||||
total = clamp(int(total))
|
||||
except (ValueError, TypeError):
|
||||
tracks = clamp(int(v))
|
||||
total = min_value
|
||||
data.append((tracks, total))
|
||||
tags[atomid] = data
|
||||
|
||||
def deleter(tags, key):
|
||||
del(tags[atomid])
|
||||
|
||||
cls.RegisterKey(key, getter, setter, deleter)
|
||||
|
||||
@classmethod
|
||||
def RegisterFreeformKey(cls, key, name, mean="com.apple.iTunes"):
|
||||
"""Register a text key.
|
||||
|
||||
If the key you need to register is a simple one-to-one mapping
|
||||
of MP4 freeform atom (----) and name to EasyMP4Tags key, then
|
||||
you can use this function::
|
||||
|
||||
EasyMP4Tags.RegisterFreeformKey(
|
||||
"musicbrainz_artistid", "MusicBrainz Artist Id")
|
||||
"""
|
||||
atomid = "----:%s:%s" % (mean, name)
|
||||
|
||||
def getter(tags, key):
|
||||
return [s.decode("utf-8", "replace") for s in tags[atomid]]
|
||||
|
||||
def setter(tags, key, value):
|
||||
tags[atomid] = map(utf8, value)
|
||||
|
||||
def deleter(tags, key):
|
||||
del(tags[atomid])
|
||||
|
||||
cls.RegisterKey(key, getter, setter, deleter)
|
||||
|
||||
def __getitem__(self, key):
|
||||
key = key.lower()
|
||||
func = dict_match(self.Get, key)
|
||||
if func is not None:
|
||||
return func(self.__mp4, key)
|
||||
else:
|
||||
raise EasyMP4KeyError("%r is not a valid key" % key)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
key = key.lower()
|
||||
if isinstance(value, basestring):
|
||||
value = [value]
|
||||
func = dict_match(self.Set, key)
|
||||
if func is not None:
|
||||
return func(self.__mp4, key, value)
|
||||
else:
|
||||
raise EasyMP4KeyError("%r is not a valid key" % key)
|
||||
|
||||
def __delitem__(self, key):
|
||||
key = key.lower()
|
||||
func = dict_match(self.Delete, key)
|
||||
if func is not None:
|
||||
return func(self.__mp4, key)
|
||||
else:
|
||||
raise EasyMP4KeyError("%r is not a valid key" % key)
|
||||
|
||||
def keys(self):
|
||||
keys = []
|
||||
for key in self.Get.keys():
|
||||
if key in self.List:
|
||||
keys.extend(self.List[key](self.__mp4, key))
|
||||
elif key in self:
|
||||
keys.append(key)
|
||||
return keys
|
||||
|
||||
def pprint(self):
|
||||
"""Print tag key=value pairs."""
|
||||
strings = []
|
||||
for key in sorted(self.keys()):
|
||||
values = self[key]
|
||||
for value in values:
|
||||
strings.append("%s=%s" % (key, value))
|
||||
return "\n".join(strings)
|
||||
|
||||
for atomid, key in {
|
||||
'\xa9nam': 'title',
|
||||
'\xa9alb': 'album',
|
||||
'\xa9ART': 'artist',
|
||||
'aART': 'albumartist',
|
||||
'\xa9day': 'date',
|
||||
'\xa9cmt': 'comment',
|
||||
'desc': 'description',
|
||||
'\xa9grp': 'grouping',
|
||||
'\xa9gen': 'genre',
|
||||
'cprt': 'copyright',
|
||||
'soal': 'albumsort',
|
||||
'soaa': 'albumartistsort',
|
||||
'soar': 'artistsort',
|
||||
'sonm': 'titlesort',
|
||||
'soco': 'composersort',
|
||||
}.items():
|
||||
EasyMP4Tags.RegisterTextKey(key, atomid)
|
||||
|
||||
for name, key in {
|
||||
'MusicBrainz Artist Id': 'musicbrainz_artistid',
|
||||
'MusicBrainz Track Id': 'musicbrainz_trackid',
|
||||
'MusicBrainz Album Id': 'musicbrainz_albumid',
|
||||
'MusicBrainz Album Artist Id': 'musicbrainz_albumartistid',
|
||||
'MusicIP PUID': 'musicip_puid',
|
||||
'MusicBrainz Album Status': 'musicbrainz_albumstatus',
|
||||
'MusicBrainz Album Type': 'musicbrainz_albumtype',
|
||||
'MusicBrainz Release Country': 'releasecountry',
|
||||
}.items():
|
||||
EasyMP4Tags.RegisterFreeformKey(key, name)
|
||||
|
||||
for name, key in {
|
||||
"tmpo": "bpm",
|
||||
}.items():
|
||||
EasyMP4Tags.RegisterIntKey(key, name)
|
||||
|
||||
for name, key in {
|
||||
"trkn": "tracknumber",
|
||||
"disk": "discnumber",
|
||||
}.items():
|
||||
EasyMP4Tags.RegisterIntPairKey(key, name)
|
||||
|
||||
|
||||
class EasyMP4(MP4):
|
||||
"""Like :class:`MP4 <mutagen.mp4.MP4>`,
|
||||
but uses :class:`EasyMP4Tags` for tags.
|
||||
|
||||
:ivar info: :class:`MP4Info <mutagen.mp4.MP4Info>`
|
||||
:ivar tags: :class:`EasyMP4Tags`
|
||||
"""
|
||||
|
||||
MP4Tags = EasyMP4Tags
|
||||
|
||||
Get = EasyMP4Tags.Get
|
||||
Set = EasyMP4Tags.Set
|
||||
Delete = EasyMP4Tags.Delete
|
||||
List = EasyMP4Tags.List
|
||||
RegisterTextKey = EasyMP4Tags.RegisterTextKey
|
||||
RegisterKey = EasyMP4Tags.RegisterKey
|
||||
@@ -0,0 +1,833 @@
|
||||
# FLAC comment support for Mutagen
|
||||
# Copyright 2005 Joe Wreschnig
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Read and write FLAC Vorbis comments and stream information.
|
||||
|
||||
Read more about FLAC at http://flac.sourceforge.net.
|
||||
|
||||
FLAC supports arbitrary metadata blocks. The two most interesting ones
|
||||
are the FLAC stream information block, and the Vorbis comment block;
|
||||
these are also the only ones Mutagen can currently read.
|
||||
|
||||
This module does not handle Ogg FLAC files.
|
||||
|
||||
Based off documentation available at
|
||||
http://flac.sourceforge.net/format.html
|
||||
"""
|
||||
|
||||
__all__ = ["FLAC", "Open", "delete"]
|
||||
|
||||
import struct
|
||||
from cStringIO import StringIO
|
||||
from _vorbis import VCommentDict
|
||||
from mutagen import FileType
|
||||
from mutagen._util import insert_bytes
|
||||
from mutagen.id3 import BitPaddedInt
|
||||
import sys
|
||||
if sys.version_info >= (2, 6):
|
||||
from functools import reduce
|
||||
|
||||
|
||||
class error(IOError):
|
||||
pass
|
||||
|
||||
|
||||
class FLACNoHeaderError(error):
|
||||
pass
|
||||
|
||||
|
||||
class FLACVorbisError(ValueError, error):
|
||||
pass
|
||||
|
||||
|
||||
def to_int_be(string):
|
||||
"""Convert an arbitrarily-long string to a long using big-endian
|
||||
byte order."""
|
||||
return reduce(lambda a, b: (a << 8) + ord(b), string, 0L)
|
||||
|
||||
|
||||
class StrictFileObject(object):
|
||||
"""Wraps a file-like object and raises an exception if the requested
|
||||
amount of data to read isn't returned."""
|
||||
|
||||
def __init__(self, fileobj):
|
||||
self._fileobj = fileobj
|
||||
for m in ["close", "tell", "seek", "write", "name"]:
|
||||
if hasattr(fileobj, m):
|
||||
setattr(self, m, getattr(fileobj, m))
|
||||
|
||||
def read(self, size=-1):
|
||||
data = self._fileobj.read(size)
|
||||
if size >= 0 and len(data) != size:
|
||||
raise error("file said %d bytes, read %d bytes" % (
|
||||
size, len(data)))
|
||||
return data
|
||||
|
||||
def tryread(self, *args):
|
||||
return self._fileobj.read(*args)
|
||||
|
||||
|
||||
class MetadataBlock(object):
|
||||
"""A generic block of FLAC metadata.
|
||||
|
||||
This class is extended by specific used as an ancestor for more specific
|
||||
blocks, and also as a container for data blobs of unknown blocks.
|
||||
|
||||
Attributes:
|
||||
|
||||
* data -- raw binary data for this block
|
||||
"""
|
||||
|
||||
_distrust_size = False
|
||||
|
||||
def __init__(self, data):
|
||||
"""Parse the given data string or file-like as a metadata block.
|
||||
The metadata header should not be included."""
|
||||
if data is not None:
|
||||
if not isinstance(data, StrictFileObject):
|
||||
if isinstance(data, str):
|
||||
data = StringIO(data)
|
||||
elif not hasattr(data, 'read'):
|
||||
raise TypeError(
|
||||
"StreamInfo requires string data or a file-like")
|
||||
data = StrictFileObject(data)
|
||||
self.load(data)
|
||||
|
||||
def load(self, data):
|
||||
self.data = data.read()
|
||||
|
||||
def write(self):
|
||||
return self.data
|
||||
|
||||
@staticmethod
|
||||
def writeblocks(blocks):
|
||||
"""Render metadata block as a byte string."""
|
||||
data = []
|
||||
codes = [[block.code, block.write()] for block in blocks]
|
||||
codes[-1][0] |= 128
|
||||
for code, datum in codes:
|
||||
byte = chr(code)
|
||||
if len(datum) > 2**24:
|
||||
raise error("block is too long to write")
|
||||
length = struct.pack(">I", len(datum))[-3:]
|
||||
data.append(byte + length + datum)
|
||||
return "".join(data)
|
||||
|
||||
@staticmethod
|
||||
def group_padding(blocks):
|
||||
"""Consolidate FLAC padding metadata blocks.
|
||||
|
||||
The overall size of the rendered blocks does not change, so
|
||||
this adds several bytes of padding for each merged block."""
|
||||
paddings = filter(lambda x: isinstance(x, Padding), blocks)
|
||||
map(blocks.remove, paddings)
|
||||
# total padding size is the sum of padding sizes plus 4 bytes
|
||||
# per removed header.
|
||||
size = sum([padding.length for padding in paddings])
|
||||
padding = Padding()
|
||||
padding.length = size + 4 * (len(paddings) - 1)
|
||||
blocks.append(padding)
|
||||
|
||||
|
||||
class StreamInfo(MetadataBlock):
|
||||
"""FLAC stream information.
|
||||
|
||||
This contains information about the audio data in the FLAC file.
|
||||
Unlike most stream information objects in Mutagen, changes to this
|
||||
one will rewritten to the file when it is saved. Unless you are
|
||||
actually changing the audio stream itself, don't change any
|
||||
attributes of this block.
|
||||
|
||||
Attributes:
|
||||
|
||||
* min_blocksize -- minimum audio block size
|
||||
* max_blocksize -- maximum audio block size
|
||||
* sample_rate -- audio sample rate in Hz
|
||||
* channels -- audio channels (1 for mono, 2 for stereo)
|
||||
* bits_per_sample -- bits per sample
|
||||
* total_samples -- total samples in file
|
||||
* length -- audio length in seconds
|
||||
"""
|
||||
|
||||
code = 0
|
||||
|
||||
def __eq__(self, other):
|
||||
try:
|
||||
return (self.min_blocksize == other.min_blocksize and
|
||||
self.max_blocksize == other.max_blocksize and
|
||||
self.sample_rate == other.sample_rate and
|
||||
self.channels == other.channels and
|
||||
self.bits_per_sample == other.bits_per_sample and
|
||||
self.total_samples == other.total_samples)
|
||||
except:
|
||||
return False
|
||||
|
||||
__hash__ = MetadataBlock.__hash__
|
||||
|
||||
def load(self, data):
|
||||
self.min_blocksize = int(to_int_be(data.read(2)))
|
||||
self.max_blocksize = int(to_int_be(data.read(2)))
|
||||
self.min_framesize = int(to_int_be(data.read(3)))
|
||||
self.max_framesize = int(to_int_be(data.read(3)))
|
||||
# first 16 bits of sample rate
|
||||
sample_first = to_int_be(data.read(2))
|
||||
# last 4 bits of sample rate, 3 of channels, first 1 of bits/sample
|
||||
sample_channels_bps = to_int_be(data.read(1))
|
||||
# last 4 of bits/sample, 36 of total samples
|
||||
bps_total = to_int_be(data.read(5))
|
||||
|
||||
sample_tail = sample_channels_bps >> 4
|
||||
self.sample_rate = int((sample_first << 4) + sample_tail)
|
||||
if not self.sample_rate:
|
||||
raise error("A sample rate value of 0 is invalid")
|
||||
self.channels = int(((sample_channels_bps >> 1) & 7) + 1)
|
||||
bps_tail = bps_total >> 36
|
||||
bps_head = (sample_channels_bps & 1) << 4
|
||||
self.bits_per_sample = int(bps_head + bps_tail + 1)
|
||||
self.total_samples = bps_total & 0xFFFFFFFFFL
|
||||
self.length = self.total_samples / float(self.sample_rate)
|
||||
|
||||
self.md5_signature = to_int_be(data.read(16))
|
||||
|
||||
def write(self):
|
||||
f = StringIO()
|
||||
f.write(struct.pack(">I", self.min_blocksize)[-2:])
|
||||
f.write(struct.pack(">I", self.max_blocksize)[-2:])
|
||||
f.write(struct.pack(">I", self.min_framesize)[-3:])
|
||||
f.write(struct.pack(">I", self.max_framesize)[-3:])
|
||||
|
||||
# first 16 bits of sample rate
|
||||
f.write(struct.pack(">I", self.sample_rate >> 4)[-2:])
|
||||
# 4 bits sample, 3 channel, 1 bps
|
||||
byte = (self.sample_rate & 0xF) << 4
|
||||
byte += ((self.channels - 1) & 7) << 1
|
||||
byte += ((self.bits_per_sample - 1) >> 4) & 1
|
||||
f.write(chr(byte))
|
||||
# 4 bits of bps, 4 of sample count
|
||||
byte = ((self.bits_per_sample - 1) & 0xF) << 4
|
||||
byte += (self.total_samples >> 32) & 0xF
|
||||
f.write(chr(byte))
|
||||
# last 32 of sample count
|
||||
f.write(struct.pack(">I", self.total_samples & 0xFFFFFFFFL))
|
||||
# MD5 signature
|
||||
sig = self.md5_signature
|
||||
f.write(struct.pack(
|
||||
">4I", (sig >> 96) & 0xFFFFFFFFL, (sig >> 64) & 0xFFFFFFFFL,
|
||||
(sig >> 32) & 0xFFFFFFFFL, sig & 0xFFFFFFFFL))
|
||||
return f.getvalue()
|
||||
|
||||
def pprint(self):
|
||||
return "FLAC, %.2f seconds, %d Hz" % (self.length, self.sample_rate)
|
||||
|
||||
|
||||
class SeekPoint(tuple):
|
||||
"""A single seek point in a FLAC file.
|
||||
|
||||
Placeholder seek points have first_sample of 0xFFFFFFFFFFFFFFFFL,
|
||||
and byte_offset and num_samples undefined. Seek points must be
|
||||
sorted in ascending order by first_sample number. Seek points must
|
||||
be unique by first_sample number, except for placeholder
|
||||
points. Placeholder points must occur last in the table and there
|
||||
may be any number of them.
|
||||
|
||||
Attributes:
|
||||
|
||||
* first_sample -- sample number of first sample in the target frame
|
||||
* byte_offset -- offset from first frame to target frame
|
||||
* num_samples -- number of samples in target frame
|
||||
"""
|
||||
|
||||
def __new__(cls, first_sample, byte_offset, num_samples):
|
||||
return super(cls, SeekPoint).__new__(
|
||||
cls, (first_sample, byte_offset, num_samples))
|
||||
|
||||
first_sample = property(lambda self: self[0])
|
||||
byte_offset = property(lambda self: self[1])
|
||||
num_samples = property(lambda self: self[2])
|
||||
|
||||
|
||||
class SeekTable(MetadataBlock):
|
||||
"""Read and write FLAC seek tables.
|
||||
|
||||
Attributes:
|
||||
|
||||
* seekpoints -- list of SeekPoint objects
|
||||
"""
|
||||
|
||||
__SEEKPOINT_FORMAT = '>QQH'
|
||||
__SEEKPOINT_SIZE = struct.calcsize(__SEEKPOINT_FORMAT)
|
||||
|
||||
code = 3
|
||||
|
||||
def __init__(self, data):
|
||||
self.seekpoints = []
|
||||
super(SeekTable, self).__init__(data)
|
||||
|
||||
def __eq__(self, other):
|
||||
try:
|
||||
return (self.seekpoints == other.seekpoints)
|
||||
except (AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
__hash__ = MetadataBlock.__hash__
|
||||
|
||||
def load(self, data):
|
||||
self.seekpoints = []
|
||||
sp = data.tryread(self.__SEEKPOINT_SIZE)
|
||||
while len(sp) == self.__SEEKPOINT_SIZE:
|
||||
self.seekpoints.append(SeekPoint(
|
||||
*struct.unpack(self.__SEEKPOINT_FORMAT, sp)))
|
||||
sp = data.tryread(self.__SEEKPOINT_SIZE)
|
||||
|
||||
def write(self):
|
||||
f = StringIO()
|
||||
for seekpoint in self.seekpoints:
|
||||
packed = struct.pack(
|
||||
self.__SEEKPOINT_FORMAT,
|
||||
seekpoint.first_sample, seekpoint.byte_offset,
|
||||
seekpoint.num_samples)
|
||||
f.write(packed)
|
||||
return f.getvalue()
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s seekpoints=%r>" % (type(self).__name__, self.seekpoints)
|
||||
|
||||
|
||||
class VCFLACDict(VCommentDict):
|
||||
"""Read and write FLAC Vorbis comments.
|
||||
|
||||
FLACs don't use the framing bit at the end of the comment block.
|
||||
So this extends VCommentDict to not use the framing bit.
|
||||
"""
|
||||
|
||||
code = 4
|
||||
_distrust_size = True
|
||||
|
||||
def load(self, data, errors='replace', framing=False):
|
||||
super(VCFLACDict, self).load(data, errors=errors, framing=framing)
|
||||
|
||||
def write(self, framing=False):
|
||||
return super(VCFLACDict, self).write(framing=framing)
|
||||
|
||||
|
||||
class CueSheetTrackIndex(tuple):
|
||||
"""Index for a track in a cuesheet.
|
||||
|
||||
For CD-DA, an index_number of 0 corresponds to the track
|
||||
pre-gap. The first index in a track must have a number of 0 or 1,
|
||||
and subsequently, index_numbers must increase by 1. Index_numbers
|
||||
must be unique within a track. And index_offset must be evenly
|
||||
divisible by 588 samples.
|
||||
|
||||
Attributes:
|
||||
|
||||
* index_number -- index point number
|
||||
* index_offset -- offset in samples from track start
|
||||
"""
|
||||
|
||||
def __new__(cls, index_number, index_offset):
|
||||
return super(cls, CueSheetTrackIndex).__new__(
|
||||
cls, (index_number, index_offset))
|
||||
|
||||
index_number = property(lambda self: self[0])
|
||||
index_offset = property(lambda self: self[1])
|
||||
|
||||
|
||||
class CueSheetTrack(object):
|
||||
"""A track in a cuesheet.
|
||||
|
||||
For CD-DA, track_numbers must be 1-99, or 170 for the
|
||||
lead-out. Track_numbers must be unique within a cue sheet. There
|
||||
must be atleast one index in every track except the lead-out track
|
||||
which must have none.
|
||||
|
||||
Attributes:
|
||||
|
||||
* track_number -- track number
|
||||
* start_offset -- track offset in samples from start of FLAC stream
|
||||
* isrc -- ISRC code
|
||||
* type -- 0 for audio, 1 for digital data
|
||||
* pre_emphasis -- true if the track is recorded with pre-emphasis
|
||||
* indexes -- list of CueSheetTrackIndex objects
|
||||
"""
|
||||
|
||||
def __init__(self, track_number, start_offset, isrc='', type_=0,
|
||||
pre_emphasis=False):
|
||||
self.track_number = track_number
|
||||
self.start_offset = start_offset
|
||||
self.isrc = isrc
|
||||
self.type = type_
|
||||
self.pre_emphasis = pre_emphasis
|
||||
self.indexes = []
|
||||
|
||||
def __eq__(self, other):
|
||||
try:
|
||||
return (self.track_number == other.track_number and
|
||||
self.start_offset == other.start_offset and
|
||||
self.isrc == other.isrc and
|
||||
self.type == other.type and
|
||||
self.pre_emphasis == other.pre_emphasis and
|
||||
self.indexes == other.indexes)
|
||||
except (AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
__hash__ = object.__hash__
|
||||
|
||||
def __repr__(self):
|
||||
return ("<%s number=%r, offset=%d, isrc=%r, type=%r, "
|
||||
"pre_emphasis=%r, indexes=%r)>") % (
|
||||
type(self).__name__, self.track_number, self.start_offset,
|
||||
self.isrc, self.type, self.pre_emphasis, self.indexes)
|
||||
|
||||
|
||||
class CueSheet(MetadataBlock):
|
||||
"""Read and write FLAC embedded cue sheets.
|
||||
|
||||
Number of tracks should be from 1 to 100. There should always be
|
||||
exactly one lead-out track and that track must be the last track
|
||||
in the cue sheet.
|
||||
|
||||
Attributes:
|
||||
|
||||
* media_catalog_number -- media catalog number in ASCII
|
||||
* lead_in_samples -- number of lead-in samples
|
||||
* compact_disc -- true if the cuesheet corresponds to a compact disc
|
||||
* tracks -- list of CueSheetTrack objects
|
||||
* lead_out -- lead-out as CueSheetTrack or None if lead-out was not found
|
||||
"""
|
||||
|
||||
__CUESHEET_FORMAT = '>128sQB258xB'
|
||||
__CUESHEET_SIZE = struct.calcsize(__CUESHEET_FORMAT)
|
||||
__CUESHEET_TRACK_FORMAT = '>QB12sB13xB'
|
||||
__CUESHEET_TRACK_SIZE = struct.calcsize(__CUESHEET_TRACK_FORMAT)
|
||||
__CUESHEET_TRACKINDEX_FORMAT = '>QB3x'
|
||||
__CUESHEET_TRACKINDEX_SIZE = struct.calcsize(__CUESHEET_TRACKINDEX_FORMAT)
|
||||
|
||||
code = 5
|
||||
|
||||
media_catalog_number = ''
|
||||
lead_in_samples = 88200
|
||||
compact_disc = True
|
||||
|
||||
def __init__(self, data):
|
||||
self.tracks = []
|
||||
super(CueSheet, self).__init__(data)
|
||||
|
||||
def __eq__(self, other):
|
||||
try:
|
||||
return (self.media_catalog_number == other.media_catalog_number and
|
||||
self.lead_in_samples == other.lead_in_samples and
|
||||
self.compact_disc == other.compact_disc and
|
||||
self.tracks == other.tracks)
|
||||
except (AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
__hash__ = MetadataBlock.__hash__
|
||||
|
||||
def load(self, data):
|
||||
header = data.read(self.__CUESHEET_SIZE)
|
||||
media_catalog_number, lead_in_samples, flags, num_tracks = \
|
||||
struct.unpack(self.__CUESHEET_FORMAT, header)
|
||||
self.media_catalog_number = media_catalog_number.rstrip('\0')
|
||||
self.lead_in_samples = lead_in_samples
|
||||
self.compact_disc = bool(flags & 0x80)
|
||||
self.tracks = []
|
||||
for i in range(num_tracks):
|
||||
track = data.read(self.__CUESHEET_TRACK_SIZE)
|
||||
start_offset, track_number, isrc_padded, flags, num_indexes = \
|
||||
struct.unpack(self.__CUESHEET_TRACK_FORMAT, track)
|
||||
isrc = isrc_padded.rstrip('\0')
|
||||
type_ = (flags & 0x80) >> 7
|
||||
pre_emphasis = bool(flags & 0x40)
|
||||
val = CueSheetTrack(
|
||||
track_number, start_offset, isrc, type_, pre_emphasis)
|
||||
for j in range(num_indexes):
|
||||
index = data.read(self.__CUESHEET_TRACKINDEX_SIZE)
|
||||
index_offset, index_number = struct.unpack(
|
||||
self.__CUESHEET_TRACKINDEX_FORMAT, index)
|
||||
val.indexes.append(
|
||||
CueSheetTrackIndex(index_number, index_offset))
|
||||
self.tracks.append(val)
|
||||
|
||||
def write(self):
|
||||
f = StringIO()
|
||||
flags = 0
|
||||
if self.compact_disc:
|
||||
flags |= 0x80
|
||||
packed = struct.pack(
|
||||
self.__CUESHEET_FORMAT, self.media_catalog_number,
|
||||
self.lead_in_samples, flags, len(self.tracks))
|
||||
f.write(packed)
|
||||
for track in self.tracks:
|
||||
track_flags = 0
|
||||
track_flags |= (track.type & 1) << 7
|
||||
if track.pre_emphasis:
|
||||
track_flags |= 0x40
|
||||
track_packed = struct.pack(
|
||||
self.__CUESHEET_TRACK_FORMAT, track.start_offset,
|
||||
track.track_number, track.isrc, track_flags,
|
||||
len(track.indexes))
|
||||
f.write(track_packed)
|
||||
for index in track.indexes:
|
||||
index_packed = struct.pack(
|
||||
self.__CUESHEET_TRACKINDEX_FORMAT,
|
||||
index.index_offset, index.index_number)
|
||||
f.write(index_packed)
|
||||
return f.getvalue()
|
||||
|
||||
def __repr__(self):
|
||||
return ("<%s media_catalog_number=%r, lead_in=%r, compact_disc=%r, "
|
||||
"tracks=%r>") % (
|
||||
type(self).__name__, self.media_catalog_number,
|
||||
self.lead_in_samples, self.compact_disc, self.tracks)
|
||||
|
||||
|
||||
class Picture(MetadataBlock):
|
||||
"""Read and write FLAC embed pictures.
|
||||
|
||||
Attributes:
|
||||
|
||||
* type -- picture type (same as types for ID3 APIC frames)
|
||||
* mime -- MIME type of the picture
|
||||
* desc -- picture's description
|
||||
* width -- width in pixels
|
||||
* height -- height in pixels
|
||||
* depth -- color depth in bits-per-pixel
|
||||
* colors -- number of colors for indexed palettes (like GIF),
|
||||
0 for non-indexed
|
||||
* data -- picture data
|
||||
"""
|
||||
|
||||
code = 6
|
||||
_distrust_size = True
|
||||
|
||||
def __init__(self, data=None):
|
||||
self.type = 0
|
||||
self.mime = u''
|
||||
self.desc = u''
|
||||
self.width = 0
|
||||
self.height = 0
|
||||
self.depth = 0
|
||||
self.colors = 0
|
||||
self.data = ''
|
||||
super(Picture, self).__init__(data)
|
||||
|
||||
def __eq__(self, other):
|
||||
try:
|
||||
return (self.type == other.type and
|
||||
self.mime == other.mime and
|
||||
self.desc == other.desc and
|
||||
self.width == other.width and
|
||||
self.height == other.height and
|
||||
self.depth == other.depth and
|
||||
self.colors == other.colors and
|
||||
self.data == other.data)
|
||||
except (AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
__hash__ = MetadataBlock.__hash__
|
||||
|
||||
def load(self, data):
|
||||
self.type, length = struct.unpack('>2I', data.read(8))
|
||||
self.mime = data.read(length).decode('UTF-8', 'replace')
|
||||
length, = struct.unpack('>I', data.read(4))
|
||||
self.desc = data.read(length).decode('UTF-8', 'replace')
|
||||
(self.width, self.height, self.depth,
|
||||
self.colors, length) = struct.unpack('>5I', data.read(20))
|
||||
self.data = data.read(length)
|
||||
|
||||
def write(self):
|
||||
f = StringIO()
|
||||
mime = self.mime.encode('UTF-8')
|
||||
f.write(struct.pack('>2I', self.type, len(mime)))
|
||||
f.write(mime)
|
||||
desc = self.desc.encode('UTF-8')
|
||||
f.write(struct.pack('>I', len(desc)))
|
||||
f.write(desc)
|
||||
f.write(struct.pack('>5I', self.width, self.height, self.depth,
|
||||
self.colors, len(self.data)))
|
||||
f.write(self.data)
|
||||
return f.getvalue()
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s '%s' (%d bytes)>" % (type(self).__name__, self.mime,
|
||||
len(self.data))
|
||||
|
||||
|
||||
class Padding(MetadataBlock):
|
||||
"""Empty padding space for metadata blocks.
|
||||
|
||||
To avoid rewriting the entire FLAC file when editing comments,
|
||||
metadata is often padded. Padding should occur at the end, and no
|
||||
more than one padding block should be in any FLAC file. Mutagen
|
||||
handles this with MetadataBlock.group_padding.
|
||||
"""
|
||||
|
||||
code = 1
|
||||
|
||||
def __init__(self, data=""):
|
||||
super(Padding, self).__init__(data)
|
||||
|
||||
def load(self, data):
|
||||
self.length = len(data.read())
|
||||
|
||||
def write(self):
|
||||
try:
|
||||
return "\x00" * self.length
|
||||
# On some 64 bit platforms this won't generate a MemoryError
|
||||
# or OverflowError since you might have enough RAM, but it
|
||||
# still generates a ValueError. On other 64 bit platforms,
|
||||
# this will still succeed for extremely large values.
|
||||
# Those should never happen in the real world, and if they
|
||||
# do, writeblocks will catch it.
|
||||
except (OverflowError, ValueError, MemoryError):
|
||||
raise error("cannot write %d bytes" % self.length)
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, Padding) and self.length == other.length
|
||||
|
||||
__hash__ = MetadataBlock.__hash__
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s (%d bytes)>" % (type(self).__name__, self.length)
|
||||
|
||||
|
||||
class FLAC(FileType):
|
||||
"""A FLAC audio file.
|
||||
|
||||
Attributes:
|
||||
|
||||
* info -- stream information (length, bitrate, sample rate)
|
||||
* tags -- metadata tags, if any
|
||||
* cuesheet -- CueSheet object, if any
|
||||
* seektable -- SeekTable object, if any
|
||||
* pictures -- list of embedded pictures
|
||||
"""
|
||||
|
||||
_mimes = ["audio/x-flac", "application/x-flac"]
|
||||
|
||||
METADATA_BLOCKS = [StreamInfo, Padding, None, SeekTable, VCFLACDict,
|
||||
CueSheet, Picture]
|
||||
"""Known metadata block types, indexed by ID."""
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return (header.startswith("fLaC") +
|
||||
filename.lower().endswith(".flac") * 3)
|
||||
|
||||
def __read_metadata_block(self, fileobj):
|
||||
byte = ord(fileobj.read(1))
|
||||
size = to_int_be(fileobj.read(3))
|
||||
code = byte & 0x7F
|
||||
last_block = bool(byte & 0x80)
|
||||
|
||||
try:
|
||||
block_type = self.METADATA_BLOCKS[code] or MetadataBlock
|
||||
except IndexError:
|
||||
block_type = MetadataBlock
|
||||
|
||||
if block_type._distrust_size:
|
||||
# Some jackass is writing broken Metadata block length
|
||||
# for Vorbis comment blocks, and the FLAC reference
|
||||
# implementaton can parse them (mostly by accident),
|
||||
# so we have to too. Instead of parsing the size
|
||||
# given, parse an actual Vorbis comment, leaving
|
||||
# fileobj in the right position.
|
||||
# http://code.google.com/p/mutagen/issues/detail?id=52
|
||||
# ..same for the Picture block:
|
||||
# http://code.google.com/p/mutagen/issues/detail?id=106
|
||||
block = block_type(fileobj)
|
||||
else:
|
||||
data = fileobj.read(size)
|
||||
block = block_type(data)
|
||||
block.code = code
|
||||
|
||||
if block.code == VCFLACDict.code:
|
||||
if self.tags is None:
|
||||
self.tags = block
|
||||
else:
|
||||
raise FLACVorbisError("> 1 Vorbis comment block found")
|
||||
elif block.code == CueSheet.code:
|
||||
if self.cuesheet is None:
|
||||
self.cuesheet = block
|
||||
else:
|
||||
raise error("> 1 CueSheet block found")
|
||||
elif block.code == SeekTable.code:
|
||||
if self.seektable is None:
|
||||
self.seektable = block
|
||||
else:
|
||||
raise error("> 1 SeekTable block found")
|
||||
self.metadata_blocks.append(block)
|
||||
return not last_block
|
||||
|
||||
def add_tags(self):
|
||||
"""Add a Vorbis comment block to the file."""
|
||||
if self.tags is None:
|
||||
self.tags = VCFLACDict()
|
||||
self.metadata_blocks.append(self.tags)
|
||||
else:
|
||||
raise FLACVorbisError("a Vorbis comment already exists")
|
||||
|
||||
add_vorbiscomment = add_tags
|
||||
|
||||
def delete(self, filename=None):
|
||||
"""Remove Vorbis comments from a file.
|
||||
|
||||
If no filename is given, the one most recently loaded is used.
|
||||
"""
|
||||
if filename is None:
|
||||
filename = self.filename
|
||||
for s in list(self.metadata_blocks):
|
||||
if isinstance(s, VCFLACDict):
|
||||
self.metadata_blocks.remove(s)
|
||||
self.tags = None
|
||||
self.save()
|
||||
break
|
||||
|
||||
vc = property(lambda s: s.tags, doc="Alias for tags; don't use this.")
|
||||
|
||||
def load(self, filename):
|
||||
"""Load file information from a filename."""
|
||||
|
||||
self.metadata_blocks = []
|
||||
self.tags = None
|
||||
self.cuesheet = None
|
||||
self.seektable = None
|
||||
self.filename = filename
|
||||
fileobj = StrictFileObject(open(filename, "rb"))
|
||||
try:
|
||||
self.__check_header(fileobj)
|
||||
while self.__read_metadata_block(fileobj):
|
||||
pass
|
||||
finally:
|
||||
fileobj.close()
|
||||
|
||||
try:
|
||||
self.metadata_blocks[0].length
|
||||
except (AttributeError, IndexError):
|
||||
raise FLACNoHeaderError("Stream info block not found")
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return self.metadata_blocks[0]
|
||||
|
||||
def add_picture(self, picture):
|
||||
"""Add a new picture to the file."""
|
||||
self.metadata_blocks.append(picture)
|
||||
|
||||
def clear_pictures(self):
|
||||
"""Delete all pictures from the file."""
|
||||
self.metadata_blocks = filter(lambda b: b.code != Picture.code,
|
||||
self.metadata_blocks)
|
||||
|
||||
@property
|
||||
def pictures(self):
|
||||
"""List of embedded pictures"""
|
||||
return filter(lambda b: b.code == Picture.code, self.metadata_blocks)
|
||||
|
||||
def save(self, filename=None, deleteid3=False):
|
||||
"""Save metadata blocks to a file.
|
||||
|
||||
If no filename is given, the one most recently loaded is used.
|
||||
"""
|
||||
|
||||
if filename is None:
|
||||
filename = self.filename
|
||||
f = open(filename, 'rb+')
|
||||
|
||||
try:
|
||||
# Ensure we've got padding at the end, and only at the end.
|
||||
# If adding makes it too large, we'll scale it down later.
|
||||
self.metadata_blocks.append(Padding('\x00' * 1020))
|
||||
MetadataBlock.group_padding(self.metadata_blocks)
|
||||
|
||||
header = self.__check_header(f)
|
||||
# "fLaC" and maybe ID3
|
||||
available = self.__find_audio_offset(f) - header
|
||||
data = MetadataBlock.writeblocks(self.metadata_blocks)
|
||||
|
||||
# Delete ID3v2
|
||||
if deleteid3 and header > 4:
|
||||
available += header - 4
|
||||
header = 4
|
||||
|
||||
if len(data) > available:
|
||||
# If we have too much data, see if we can reduce padding.
|
||||
padding = self.metadata_blocks[-1]
|
||||
newlength = padding.length - (len(data) - available)
|
||||
if newlength > 0:
|
||||
padding.length = newlength
|
||||
data = MetadataBlock.writeblocks(self.metadata_blocks)
|
||||
assert len(data) == available
|
||||
|
||||
elif len(data) < available:
|
||||
# If we have too little data, increase padding.
|
||||
self.metadata_blocks[-1].length += (available - len(data))
|
||||
data = MetadataBlock.writeblocks(self.metadata_blocks)
|
||||
assert len(data) == available
|
||||
|
||||
if len(data) != available:
|
||||
# We couldn't reduce the padding enough.
|
||||
diff = (len(data) - available)
|
||||
insert_bytes(f, diff, header)
|
||||
|
||||
f.seek(header - 4)
|
||||
f.write("fLaC" + data)
|
||||
|
||||
# Delete ID3v1
|
||||
if deleteid3:
|
||||
try:
|
||||
f.seek(-128, 2)
|
||||
except IOError:
|
||||
pass
|
||||
else:
|
||||
if f.read(3) == "TAG":
|
||||
f.seek(-128, 2)
|
||||
f.truncate()
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
def __find_audio_offset(self, fileobj):
|
||||
byte = 0x00
|
||||
while not (byte & 0x80):
|
||||
byte = ord(fileobj.read(1))
|
||||
size = to_int_be(fileobj.read(3))
|
||||
try:
|
||||
block_type = self.METADATA_BLOCKS[byte & 0x7F]
|
||||
except IndexError:
|
||||
block_type = None
|
||||
|
||||
if block_type and block_type._distrust_size:
|
||||
# See comments in read_metadata_block; the size can't
|
||||
# be trusted for Vorbis comment blocks and Picture block
|
||||
block_type(fileobj)
|
||||
else:
|
||||
fileobj.read(size)
|
||||
return fileobj.tell()
|
||||
|
||||
def __check_header(self, fileobj):
|
||||
size = 4
|
||||
header = fileobj.read(4)
|
||||
if header != "fLaC":
|
||||
size = None
|
||||
if header[:3] == "ID3":
|
||||
size = 14 + BitPaddedInt(fileobj.read(6)[2:])
|
||||
fileobj.seek(size - 4)
|
||||
if fileobj.read(4) != "fLaC":
|
||||
size = None
|
||||
if size is None:
|
||||
raise FLACNoHeaderError(
|
||||
"%r is not a valid FLAC file" % fileobj.name)
|
||||
return size
|
||||
|
||||
|
||||
Open = FLAC
|
||||
|
||||
|
||||
def delete(filename):
|
||||
"""Remove tags from a file."""
|
||||
FLAC(filename).delete()
|
||||
@@ -0,0 +1,919 @@
|
||||
# id3 support for mutagen
|
||||
# Copyright (C) 2005 Michael Urman
|
||||
# 2006 Lukas Lalinsky
|
||||
# 2013 Christoph Reiter
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""ID3v2 reading and writing.
|
||||
|
||||
This is based off of the following references:
|
||||
|
||||
* http://id3.org/id3v2.4.0-structure
|
||||
* http://id3.org/id3v2.4.0-frames
|
||||
* http://id3.org/id3v2.3.0
|
||||
* http://id3.org/id3v2-00
|
||||
* http://id3.org/ID3v1
|
||||
|
||||
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 :class:`ID3` class to start with.
|
||||
"""
|
||||
|
||||
__all__ = ['ID3', 'ID3FileType', 'Frames', 'Open', 'delete']
|
||||
|
||||
import struct
|
||||
|
||||
from struct import unpack, pack, error as StructError
|
||||
|
||||
import mutagen
|
||||
from mutagen._util import insert_bytes, delete_bytes, DictProxy
|
||||
|
||||
from mutagen._id3util import *
|
||||
from mutagen._id3frames import *
|
||||
from mutagen._id3specs import *
|
||||
|
||||
|
||||
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
|
||||
__unknown_version = None
|
||||
|
||||
_V24 = (2, 4, 0)
|
||||
_V23 = (2, 3, 0)
|
||||
_V22 = (2, 2, 0)
|
||||
_V11 = (1, 1)
|
||||
|
||||
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, v2_version=4):
|
||||
"""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.3/4 internally. If you
|
||||
intend to save, this must be true or you have to
|
||||
call update_to_v23() / update_to_v24() manually.
|
||||
* v2_version -- if update_to_v23 or update_to_v24 get called (3 or 4)
|
||||
|
||||
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)
|
||||
"""
|
||||
|
||||
if not v2_version in (3, 4):
|
||||
raise ValueError("Only 3 and 4 possible for v2_version")
|
||||
|
||||
from os.path import getsize
|
||||
|
||||
self.filename = filename
|
||||
self.__known_frames = known_frames
|
||||
self.__fileobj = open(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 = self._V11
|
||||
map(self.add, frames.values())
|
||||
else:
|
||||
raise err, None, stack
|
||||
else:
|
||||
frames = self.__known_frames
|
||||
if frames is None:
|
||||
if self._V23 <= self.version:
|
||||
frames = Frames
|
||||
elif self._V22 <= 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)
|
||||
self.__unknown_version = self.version
|
||||
finally:
|
||||
self.__fileobj.close()
|
||||
del self.__fileobj
|
||||
del self.__filesize
|
||||
if translate:
|
||||
if v2_version == 3:
|
||||
self.update_to_v23()
|
||||
else:
|
||||
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``
|
||||
"""
|
||||
frames = list(map(Frame.pprint, self.values()))
|
||||
frames.sort()
|
||||
return "\n".join(frames)
|
||||
|
||||
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 not BitPaddedInt.has_valid_padding(size):
|
||||
raise ValueError("Header size not synchsafe")
|
||||
|
||||
if self._V24 <= self.version and (flags & 0x0f):
|
||||
raise ValueError("'%s' has invalid flags %#02x" % (fn, flags))
|
||||
elif self._V23 <= self.version < self._V24 and (flags & 0x1f):
|
||||
raise ValueError("'%s' has invalid flags %#02x" % (fn, flags))
|
||||
|
||||
if self.f_extended:
|
||||
extsize = self.__fullread(4)
|
||||
if extsize in Frames:
|
||||
# Some tagger sets the extended header flag but
|
||||
# doesn't write an extended header; in this case, the
|
||||
# ID3 data follows immediately. Since no extended
|
||||
# header is going to be long enough to actually match
|
||||
# a frame, and if it's *not* a frame we're going to be
|
||||
# completely lost anyway, this seems to be the most
|
||||
# correct check.
|
||||
# http://code.google.com/p/quodlibet/issues/detail?id=126
|
||||
self.__flags ^= 0x40
|
||||
self.__extsize = 0
|
||||
self.__fileobj.seek(-4, 1)
|
||||
self.__readbytes -= 4
|
||||
elif self.version >= self._V24:
|
||||
# "Where the 'Extended header size' is the size of the whole
|
||||
# extended header, stored as a 32 bit synchsafe integer."
|
||||
self.__extsize = BitPaddedInt(extsize) - 4
|
||||
if self.PEDANTIC:
|
||||
if not BitPaddedInt.has_valid_padding(extsize):
|
||||
raise ValueError("Extended header size not synchsafe")
|
||||
else:
|
||||
# "Where the 'Extended header size', currently 6 or 10 bytes,
|
||||
# excludes itself."
|
||||
self.__extsize = unpack('>L', extsize)[0]
|
||||
if self.__extsize:
|
||||
self.__extdata = self.__fullread(self.__extsize)
|
||||
else:
|
||||
self.__extdata = ""
|
||||
|
||||
def __determine_bpi(self, data, frames, EMPTY="\x00" * 10):
|
||||
if self.version < self._V24:
|
||||
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:
|
||||
part = data[o:o + 10]
|
||||
if part == EMPTY:
|
||||
bpioff = -((len(data) - o) % 10)
|
||||
break
|
||||
name, size, flags = unpack('>4sLH', part)
|
||||
size = BitPaddedInt(size)
|
||||
o += 10 + size
|
||||
if name in frames:
|
||||
asbpi += 1
|
||||
else:
|
||||
bpioff = o - len(data)
|
||||
|
||||
# count number of tags found as int and how far past
|
||||
o = 0
|
||||
asint = 0
|
||||
while o < len(data) - 10:
|
||||
part = data[o:o + 10]
|
||||
if part == EMPTY:
|
||||
intoff = -((len(data) - o) % 10)
|
||||
break
|
||||
name, size, flags = unpack('>4sLH', part)
|
||||
o += 10 + size
|
||||
if name in frames:
|
||||
asint += 1
|
||||
else:
|
||||
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 < self._V24 and self.f_unsynch:
|
||||
try:
|
||||
data = unsynch.decode(data)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if self._V23 <= 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 self._V22 <= 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, v2_version=4, v23_sep='/'):
|
||||
"""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
|
||||
v2 -- version of ID3v2 tags (3 or 4).
|
||||
|
||||
By default Mutagen saves ID3v2.4 tags. If you want to save ID3v2.3
|
||||
tags, you must call method update_to_v23 before saving the file.
|
||||
|
||||
v23_sep -- the separator used to join multiple text values
|
||||
if v2_version == 3. Defaults to '/' but if it's None
|
||||
will be the ID3v2v2.4 null separator.
|
||||
|
||||
The lack of a way to update only an ID3v1 tag is intentional.
|
||||
"""
|
||||
|
||||
if v2_version == 3:
|
||||
version = self._V23
|
||||
elif v2_version == 4:
|
||||
version = self._V24
|
||||
else:
|
||||
raise ValueError("Only 3 or 4 allowed for v2_version")
|
||||
|
||||
# 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, version=version, v23_sep=v23_sep)
|
||||
for (key, frame) in frames]
|
||||
|
||||
# only write unknown frames if they were loaded from the version
|
||||
# we are saving with or upgraded to it
|
||||
if self.__unknown_version == version:
|
||||
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', v2_version, 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:
|
||||
# If the file is too small, that's OK - it just means
|
||||
# we're certain it doesn't have a v1 tag.
|
||||
from errno import EINVAL
|
||||
if err.errno != EINVAL:
|
||||
# If we failed to see for some other reason, bail out.
|
||||
raise
|
||||
# Since we're sure this isn't a v1 tag, don't read it.
|
||||
f.seek(0, 2)
|
||||
|
||||
data = f.read(128)
|
||||
try:
|
||||
idx = data.index("TAG")
|
||||
except ValueError:
|
||||
offset = 0
|
||||
has_v1 = False
|
||||
else:
|
||||
offset = idx - len(data)
|
||||
has_v1 = True
|
||||
|
||||
f.seek(offset, 2)
|
||||
if v1 == 1 and has_v1 or v1 == 2:
|
||||
f.write(MakeID3v1(self))
|
||||
else:
|
||||
f.truncate()
|
||||
|
||||
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, name=None, version=_V24, v23_sep=None):
|
||||
flags = 0
|
||||
if self.PEDANTIC and isinstance(frame, TextFrame):
|
||||
if len(str(frame)) == 0:
|
||||
return ''
|
||||
|
||||
if version == self._V23:
|
||||
framev23 = frame._get_v23_frame(sep=v23_sep)
|
||||
framedata = framev23._writeData()
|
||||
else:
|
||||
framedata = frame._writeData()
|
||||
|
||||
usize = len(framedata)
|
||||
if usize > 2048:
|
||||
# Disabled as this causes iTunes and other programs
|
||||
# to fail to find these frames, which usually includes
|
||||
# e.g. APIC.
|
||||
#framedata = BitPaddedInt.to_str(usize) + framedata.encode('zlib')
|
||||
#flags |= Frame.FLAG24_COMPRESS | Frame.FLAG24_DATALEN
|
||||
pass
|
||||
|
||||
if version == self._V24:
|
||||
bits = 7
|
||||
elif version == self._V23:
|
||||
bits = 8
|
||||
else:
|
||||
raise ValueError
|
||||
|
||||
datasize = BitPaddedInt.to_str(len(framedata), width=4, bits=bits)
|
||||
header = pack('>4s4sH', name or type(frame).__name__, datasize, flags)
|
||||
return header + framedata
|
||||
|
||||
def __update_common(self):
|
||||
"""Updates done by both v23 and v24 update"""
|
||||
|
||||
if "TCON" in self:
|
||||
# Get rid of "(xx)Foobr" format.
|
||||
self["TCON"].genres = self["TCON"].genres
|
||||
|
||||
if self.version < self._V23:
|
||||
# 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")
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
self.__update_common()
|
||||
|
||||
if self.__unknown_version == (2, 3, 0):
|
||||
# convert unknown 2.3 frames (flags/size) to 2.4
|
||||
converted = []
|
||||
for frame in self.unknown_frames:
|
||||
try:
|
||||
name, size, flags = unpack('>4sLH', frame[:10])
|
||||
frame = BinaryFrame.fromData(self, flags, frame[10:])
|
||||
except (struct.error, error):
|
||||
continue
|
||||
converted.append(self.__save_frame(frame, name=name))
|
||||
self.unknown_frames[:] = converted
|
||||
self.__unknown_version = (2, 4, 0)
|
||||
|
||||
# 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))
|
||||
|
||||
# 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 update_to_v23(self):
|
||||
"""Convert older (and newer) tags into an ID3v2.3 tag.
|
||||
|
||||
This updates incompatible ID3v2 frames to ID3v2.3 ones. If you
|
||||
intend to save tags as ID3v2.3, you must call this function
|
||||
at some point.
|
||||
|
||||
If you want to to go off spec and include some v2.4 frames
|
||||
in v2.3, remove them before calling this and add them back afterwards.
|
||||
"""
|
||||
|
||||
self.__update_common()
|
||||
|
||||
# we could downgrade unknown v2.4 frames here, but given that
|
||||
# the main reason to save v2.3 is compatibility and this
|
||||
# might increase the chance of some parser breaking.. better not
|
||||
|
||||
# TMCL, TIPL -> TIPL
|
||||
if "TIPL" in self or "TMCL" in self:
|
||||
people = []
|
||||
if "TIPL" in self:
|
||||
f = self.pop("TIPL")
|
||||
people.extend(f.people)
|
||||
if "TMCL" in self:
|
||||
f = self.pop("TMCL")
|
||||
people.extend(f.people)
|
||||
if "IPLS" not in self:
|
||||
self.add(IPLS(encoding=f.encoding, people=people))
|
||||
|
||||
# TDOR -> TORY
|
||||
if "TDOR" in self:
|
||||
f = self.pop("TDOR")
|
||||
if f.text:
|
||||
d = f.text[0]
|
||||
if d.year and "TORY" not in self:
|
||||
self.add(TORY(encoding=f.encoding, text="%04d" % d.year))
|
||||
|
||||
# TDRC -> TYER, TDAT, TIME
|
||||
if "TDRC" in self:
|
||||
f = self.pop("TDRC")
|
||||
if f.text:
|
||||
d = f.text[0]
|
||||
if d.year and "TYER" not in self:
|
||||
self.add(TYER(encoding=f.encoding, text="%04d" % d.year))
|
||||
if d.month and d.day and "TDAT" not in self:
|
||||
self.add(TDAT(encoding=f.encoding,
|
||||
text="%02d%02d" % (d.day, d.month)))
|
||||
if d.hour and d.minute and "TIME" not in self:
|
||||
self.add(TIME(encoding=f.encoding,
|
||||
text="%02d%02d" % (d.hour, d.minute)))
|
||||
|
||||
# New frames added in v2.4
|
||||
v24_frames = [
|
||||
'ASPI', 'EQU2', 'RVA2', 'SEEK', 'SIGN', 'TDEN', 'TDOR',
|
||||
'TDRC', 'TDRL', 'TDTG', 'TIPL', 'TMCL', 'TMOO', 'TPRO',
|
||||
'TSOA', 'TSOP', 'TSOT', 'TSST',
|
||||
]
|
||||
|
||||
for key in v24_frames:
|
||||
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)
|
||||
|
||||
|
||||
# support open(filename) as interface
|
||||
Open = ID3
|
||||
|
||||
|
||||
# ID3v1.1 support.
|
||||
def ParseID3v1(string):
|
||||
"""Parse an ID3v1 tag, returning a list of ID3v2.4 frames."""
|
||||
|
||||
try:
|
||||
string = string[string.index("TAG"):]
|
||||
except ValueError:
|
||||
return None
|
||||
if 128 < len(string) or len(string) < 124:
|
||||
return None
|
||||
|
||||
# Issue #69 - Previous versions of Mutagen, when encountering
|
||||
# out-of-spec TDRC and TYER frames of less than four characters,
|
||||
# wrote only the characters available - e.g. "1" or "" - into the
|
||||
# year field. To parse those, reduce the size of the year field.
|
||||
# Amazingly, "0s" works as a struct format string.
|
||||
unpack_fmt = "3s30s30s30s%ds29sBB" % (len(string) - 124)
|
||||
|
||||
try:
|
||||
tag, title, artist, album, year, comment, track, genre = unpack(
|
||||
unpack_fmt, 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])
|
||||
|
||||
frames = {}
|
||||
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:
|
||||
year = str(id3["TDRC"])
|
||||
elif "TYER" in id3:
|
||||
year = str(id3["TYER"])
|
||||
else:
|
||||
year = ""
|
||||
v1["year"] = (year + "\x00\x00\x00\x00")[:4]
|
||||
|
||||
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."""
|
||||
|
||||
ID3 = ID3
|
||||
|
||||
class _Info(object):
|
||||
length = 0
|
||||
|
||||
def __init__(self, fileobj, offset):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def pprint():
|
||||
return "Unknown format with ID3 tag"
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return header.startswith("ID3")
|
||||
|
||||
def add_tags(self, ID3=None):
|
||||
"""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 ID3 is None:
|
||||
ID3 = self.ID3
|
||||
if self.tags is None:
|
||||
self.ID3 = ID3
|
||||
self.tags = ID3()
|
||||
else:
|
||||
raise error("an ID3 tag already exists")
|
||||
|
||||
def load(self, filename, ID3=None, **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.
|
||||
"""
|
||||
|
||||
if ID3 is None:
|
||||
ID3 = self.ID3
|
||||
else:
|
||||
# If this was initialized with EasyID3, remember that for
|
||||
# when tags are auto-instantiated in add_tags.
|
||||
self.ID3 = ID3
|
||||
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 = open(filename, "rb")
|
||||
self.info = self._Info(fileobj, offset)
|
||||
finally:
|
||||
fileobj.close()
|
||||
@@ -0,0 +1,538 @@
|
||||
# Copyright 2006 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.
|
||||
|
||||
"""Read and write MPEG-4 audio files with iTunes metadata.
|
||||
|
||||
This module will read MPEG-4 audio information and metadata,
|
||||
as found in Apple's M4A (aka MP4, M4B, M4P) files.
|
||||
|
||||
There is no official specification for this format. The source code
|
||||
for TagLib, FAAD, and various MPEG specifications at
|
||||
http://developer.apple.com/documentation/QuickTime/QTFF/,
|
||||
http://www.geocities.com/xhelmboyx/quicktime/formats/mp4-layout.txt,
|
||||
and http://wiki.multimedia.cx/index.php?title=Apple_QuickTime were all
|
||||
consulted.
|
||||
|
||||
This module does not support 64 bit atom sizes, and so will not
|
||||
work on metadata over 4GB.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from cStringIO import StringIO
|
||||
|
||||
from mutagen import FileType, Metadata
|
||||
from mutagen._constants import GENRES
|
||||
from mutagen._util import cdata, insert_bytes, delete_bytes, DictProxy
|
||||
|
||||
|
||||
class error(IOError):
|
||||
pass
|
||||
|
||||
|
||||
class M4AMetadataError(error):
|
||||
pass
|
||||
|
||||
|
||||
class M4AStreamInfoError(error):
|
||||
pass
|
||||
|
||||
|
||||
class M4AMetadataValueError(ValueError, M4AMetadataError):
|
||||
pass
|
||||
|
||||
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"mutagen.m4a is deprecated; use mutagen.mp4 instead.", DeprecationWarning)
|
||||
|
||||
|
||||
# This is not an exhaustive list of container atoms, but just the
|
||||
# ones this module needs to peek inside.
|
||||
_CONTAINERS = ["moov", "udta", "trak", "mdia", "meta", "ilst",
|
||||
"stbl", "minf", "stsd"]
|
||||
_SKIP_SIZE = {"meta": 4}
|
||||
|
||||
__all__ = ['M4A', 'Open', 'delete', 'M4ACover']
|
||||
|
||||
|
||||
class M4ACover(str):
|
||||
"""A cover artwork.
|
||||
|
||||
Attributes:
|
||||
imageformat -- format of the image (either FORMAT_JPEG or FORMAT_PNG)
|
||||
"""
|
||||
FORMAT_JPEG = 0x0D
|
||||
FORMAT_PNG = 0x0E
|
||||
|
||||
def __new__(cls, data, imageformat=None):
|
||||
self = str.__new__(cls, data)
|
||||
if imageformat is None:
|
||||
imageformat = M4ACover.FORMAT_JPEG
|
||||
self.imageformat = imageformat
|
||||
try:
|
||||
self.format
|
||||
except AttributeError:
|
||||
self.format = imageformat
|
||||
return self
|
||||
|
||||
|
||||
class Atom(object):
|
||||
"""An individual atom.
|
||||
|
||||
Attributes:
|
||||
children -- list child atoms (or None for non-container atoms)
|
||||
length -- length of this atom, including length and name
|
||||
name -- four byte name of the atom, as a str
|
||||
offset -- location in the constructor-given fileobj of this atom
|
||||
|
||||
This structure should only be used internally by Mutagen.
|
||||
"""
|
||||
|
||||
children = None
|
||||
|
||||
def __init__(self, fileobj):
|
||||
self.offset = fileobj.tell()
|
||||
self.length, self.name = struct.unpack(">I4s", fileobj.read(8))
|
||||
if self.length == 1:
|
||||
raise error("64 bit atom sizes are not supported")
|
||||
elif self.length < 8:
|
||||
return
|
||||
|
||||
if self.name in _CONTAINERS:
|
||||
self.children = []
|
||||
fileobj.seek(_SKIP_SIZE.get(self.name, 0), 1)
|
||||
while fileobj.tell() < self.offset + self.length:
|
||||
self.children.append(Atom(fileobj))
|
||||
else:
|
||||
fileobj.seek(self.offset + self.length, 0)
|
||||
|
||||
@staticmethod
|
||||
def render(name, data):
|
||||
"""Render raw atom data."""
|
||||
# this raises OverflowError if Py_ssize_t can't handle the atom data
|
||||
size = len(data) + 8
|
||||
if size <= 0xFFFFFFFF:
|
||||
return struct.pack(">I4s", size, name) + data
|
||||
else:
|
||||
return struct.pack(">I4sQ", 1, name, size + 8) + data
|
||||
|
||||
def __getitem__(self, remaining):
|
||||
"""Look up a child atom, potentially recursively.
|
||||
|
||||
e.g. atom['udta', 'meta'] => <Atom name='meta' ...>
|
||||
"""
|
||||
if not remaining:
|
||||
return self
|
||||
elif self.children is None:
|
||||
raise KeyError("%r is not a container" % self.name)
|
||||
for child in self.children:
|
||||
if child.name == remaining[0]:
|
||||
return child[remaining[1:]]
|
||||
else:
|
||||
raise KeyError("%r not found" % remaining[0])
|
||||
|
||||
def __repr__(self):
|
||||
klass = self.__class__.__name__
|
||||
if self.children is None:
|
||||
return "<%s name=%r length=%r offset=%r>" % (
|
||||
klass, self.name, self.length, self.offset)
|
||||
else:
|
||||
children = "\n".join([" " + line for child in self.children
|
||||
for line in repr(child).splitlines()])
|
||||
return "<%s name=%r length=%r offset=%r\n%s>" % (
|
||||
klass, self.name, self.length, self.offset, children)
|
||||
|
||||
|
||||
class Atoms(object):
|
||||
"""Root atoms in a given file.
|
||||
|
||||
Attributes:
|
||||
atoms -- a list of top-level atoms as Atom objects
|
||||
|
||||
This structure should only be used internally by Mutagen.
|
||||
"""
|
||||
def __init__(self, fileobj):
|
||||
self.atoms = []
|
||||
fileobj.seek(0, 2)
|
||||
end = fileobj.tell()
|
||||
fileobj.seek(0)
|
||||
while fileobj.tell() < end:
|
||||
self.atoms.append(Atom(fileobj))
|
||||
|
||||
def path(self, *names):
|
||||
"""Look up and return the complete path of an atom.
|
||||
|
||||
For example, atoms.path('moov', 'udta', 'meta') will return a
|
||||
list of three atoms, corresponding to the moov, udta, and meta
|
||||
atoms.
|
||||
"""
|
||||
path = [self]
|
||||
for name in names:
|
||||
path.append(path[-1][name, ])
|
||||
return path[1:]
|
||||
|
||||
def __getitem__(self, names):
|
||||
"""Look up a child atom.
|
||||
|
||||
'names' may be a list of atoms (['moov', 'udta']) or a string
|
||||
specifying the complete path ('moov.udta').
|
||||
"""
|
||||
if isinstance(names, basestring):
|
||||
names = names.split(".")
|
||||
for child in self.atoms:
|
||||
if child.name == names[0]:
|
||||
return child[names[1:]]
|
||||
else:
|
||||
raise KeyError("%s not found" % names[0])
|
||||
|
||||
def __repr__(self):
|
||||
return "\n".join([repr(child) for child in self.atoms])
|
||||
|
||||
|
||||
class M4ATags(DictProxy, Metadata):
|
||||
"""Dictionary containing Apple iTunes metadata list key/values.
|
||||
|
||||
Keys are four byte identifiers, except for freeform ('----')
|
||||
keys. Values are usually unicode strings, but some atoms have a
|
||||
special structure:
|
||||
cpil -- boolean
|
||||
trkn, disk -- tuple of 16 bit ints (current, total)
|
||||
tmpo -- 16 bit int
|
||||
covr -- list of M4ACover objects (which are tagged strs)
|
||||
gnre -- not supported. Use '\\xa9gen' instead.
|
||||
|
||||
The freeform '----' frames use a key in the format '----:mean:name'
|
||||
where 'mean' is usually 'com.apple.iTunes' and 'name' is a unique
|
||||
identifier for this frame. The value is a str, but is probably
|
||||
text that can be decoded as UTF-8.
|
||||
|
||||
M4A tag data cannot exist outside of the structure of an M4A file,
|
||||
so this class should not be manually instantiated.
|
||||
|
||||
Unknown non-text tags are removed.
|
||||
"""
|
||||
|
||||
def load(self, atoms, fileobj):
|
||||
try:
|
||||
ilst = atoms["moov.udta.meta.ilst"]
|
||||
except KeyError, key:
|
||||
raise M4AMetadataError(key)
|
||||
for atom in ilst.children:
|
||||
fileobj.seek(atom.offset + 8)
|
||||
data = fileobj.read(atom.length - 8)
|
||||
parse = self.__atoms.get(atom.name, (M4ATags.__parse_text,))[0]
|
||||
parse(self, atom, data)
|
||||
|
||||
@staticmethod
|
||||
def __key_sort(item1, item2):
|
||||
(key1, v1) = item1
|
||||
(key2, v2) = item2
|
||||
# iTunes always writes the tags in order of "relevance", try
|
||||
# to copy it as closely as possible.
|
||||
order = ["\xa9nam", "\xa9ART", "\xa9wrt", "\xa9alb",
|
||||
"\xa9gen", "gnre", "trkn", "disk",
|
||||
"\xa9day", "cpil", "tmpo", "\xa9too",
|
||||
"----", "covr", "\xa9lyr"]
|
||||
order = dict(zip(order, range(len(order))))
|
||||
last = len(order)
|
||||
# If there's no key-based way to distinguish, order by length.
|
||||
# If there's still no way, go by string comparison on the
|
||||
# values, so we at least have something determinstic.
|
||||
return (cmp(order.get(key1[:4], last), order.get(key2[:4], last)) or
|
||||
cmp(len(v1), len(v2)) or cmp(v1, v2))
|
||||
|
||||
def save(self, filename):
|
||||
"""Save the metadata to the given filename."""
|
||||
values = []
|
||||
items = self.items()
|
||||
items.sort(self.__key_sort)
|
||||
for key, value in items:
|
||||
render = self.__atoms.get(
|
||||
key[:4], (None, M4ATags.__render_text))[1]
|
||||
values.append(render(self, key, value))
|
||||
data = Atom.render("ilst", "".join(values))
|
||||
|
||||
# Find the old atoms.
|
||||
fileobj = open(filename, "rb+")
|
||||
try:
|
||||
atoms = Atoms(fileobj)
|
||||
|
||||
moov = atoms["moov"]
|
||||
|
||||
if moov != atoms.atoms[-1]:
|
||||
# "Free" the old moov block. Something in the mdat
|
||||
# block is not happy when its offset changes and it
|
||||
# won't play back. So, rather than try to figure that
|
||||
# out, just move the moov atom to the end of the file.
|
||||
offset = self.__move_moov(fileobj, moov)
|
||||
else:
|
||||
offset = 0
|
||||
|
||||
try:
|
||||
path = atoms.path("moov", "udta", "meta", "ilst")
|
||||
except KeyError:
|
||||
self.__save_new(fileobj, atoms, data, offset)
|
||||
else:
|
||||
self.__save_existing(fileobj, atoms, path, data, offset)
|
||||
finally:
|
||||
fileobj.close()
|
||||
|
||||
def __move_moov(self, fileobj, moov):
|
||||
fileobj.seek(moov.offset)
|
||||
data = fileobj.read(moov.length)
|
||||
fileobj.seek(moov.offset)
|
||||
free = Atom.render("free", "\x00" * (moov.length - 8))
|
||||
fileobj.write(free)
|
||||
fileobj.seek(0, 2)
|
||||
# Figure out how far we have to shift all our successive
|
||||
# seek calls, relative to what the atoms say.
|
||||
old_end = fileobj.tell()
|
||||
fileobj.write(data)
|
||||
return old_end - moov.offset
|
||||
|
||||
def __save_new(self, fileobj, atoms, ilst, offset):
|
||||
hdlr = Atom.render("hdlr", "\x00" * 8 + "mdirappl" + "\x00" * 9)
|
||||
meta = Atom.render("meta", "\x00\x00\x00\x00" + hdlr + ilst)
|
||||
moov, udta = atoms.path("moov", "udta")
|
||||
insert_bytes(fileobj, len(meta), udta.offset + offset + 8)
|
||||
fileobj.seek(udta.offset + offset + 8)
|
||||
fileobj.write(meta)
|
||||
self.__update_parents(fileobj, [moov, udta], len(meta), offset)
|
||||
|
||||
def __save_existing(self, fileobj, atoms, path, data, offset):
|
||||
# Replace the old ilst atom.
|
||||
ilst = path.pop()
|
||||
delta = len(data) - ilst.length
|
||||
fileobj.seek(ilst.offset + offset)
|
||||
if delta > 0:
|
||||
insert_bytes(fileobj, delta, ilst.offset + offset)
|
||||
elif delta < 0:
|
||||
delete_bytes(fileobj, -delta, ilst.offset + offset)
|
||||
fileobj.seek(ilst.offset + offset)
|
||||
fileobj.write(data)
|
||||
self.__update_parents(fileobj, path, delta, offset)
|
||||
|
||||
def __update_parents(self, fileobj, path, delta, offset):
|
||||
# Update all parent atoms with the new size.
|
||||
for atom in path:
|
||||
fileobj.seek(atom.offset + offset)
|
||||
size = cdata.uint_be(fileobj.read(4)) + delta
|
||||
fileobj.seek(atom.offset + offset)
|
||||
fileobj.write(cdata.to_uint_be(size))
|
||||
|
||||
def __render_data(self, key, flags, data):
|
||||
data = struct.pack(">2I", flags, 0) + data
|
||||
return Atom.render(key, Atom.render("data", data))
|
||||
|
||||
def __parse_freeform(self, atom, data):
|
||||
try:
|
||||
fileobj = StringIO(data)
|
||||
mean_length = cdata.uint_be(fileobj.read(4))
|
||||
# skip over 8 bytes of atom name, flags
|
||||
mean = fileobj.read(mean_length - 4)[8:]
|
||||
name_length = cdata.uint_be(fileobj.read(4))
|
||||
name = fileobj.read(name_length - 4)[8:]
|
||||
value_length = cdata.uint_be(fileobj.read(4))
|
||||
# Name, flags, and reserved bytes
|
||||
value = fileobj.read(value_length - 4)[12:]
|
||||
except struct.error:
|
||||
# Some ---- atoms have no data atom, I have no clue why
|
||||
# they actually end up in the file.
|
||||
pass
|
||||
else:
|
||||
self["%s:%s:%s" % (atom.name, mean, name)] = value
|
||||
|
||||
def __render_freeform(self, key, value):
|
||||
dummy, mean, name = key.split(":", 2)
|
||||
mean = struct.pack(">I4sI", len(mean) + 12, "mean", 0) + mean
|
||||
name = struct.pack(">I4sI", len(name) + 12, "name", 0) + name
|
||||
value = struct.pack(">I4s2I", len(value) + 16, "data", 0x1, 0) + value
|
||||
final = mean + name + value
|
||||
return Atom.render("----", final)
|
||||
|
||||
def __parse_pair(self, atom, data):
|
||||
self[atom.name] = struct.unpack(">2H", data[18:22])
|
||||
|
||||
def __render_pair(self, key, value):
|
||||
track, total = value
|
||||
if 0 <= track < 1 << 16 and 0 <= total < 1 << 16:
|
||||
data = struct.pack(">4H", 0, track, total, 0)
|
||||
return self.__render_data(key, 0, data)
|
||||
else:
|
||||
raise M4AMetadataValueError("invalid numeric pair %r" % (value,))
|
||||
|
||||
def __render_pair_no_trailing(self, key, value):
|
||||
track, total = value
|
||||
if 0 <= track < 1 << 16 and 0 <= total < 1 << 16:
|
||||
data = struct.pack(">3H", 0, track, total)
|
||||
return self.__render_data(key, 0, data)
|
||||
else:
|
||||
raise M4AMetadataValueError("invalid numeric pair %r" % (value,))
|
||||
|
||||
def __parse_genre(self, atom, data):
|
||||
# Translate to a freeform genre.
|
||||
genre = cdata.short_be(data[16:18])
|
||||
if "\xa9gen" not in self:
|
||||
try:
|
||||
self["\xa9gen"] = GENRES[genre - 1]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
def __parse_tempo(self, atom, data):
|
||||
self[atom.name] = cdata.short_be(data[16:18])
|
||||
|
||||
def __render_tempo(self, key, value):
|
||||
if 0 <= value < 1 << 16:
|
||||
return self.__render_data(key, 0x15, cdata.to_ushort_be(value))
|
||||
else:
|
||||
raise M4AMetadataValueError("invalid short integer %r" % value)
|
||||
|
||||
def __parse_compilation(self, atom, data):
|
||||
try:
|
||||
self[atom.name] = bool(ord(data[16:17]))
|
||||
except TypeError:
|
||||
self[atom.name] = False
|
||||
|
||||
def __render_compilation(self, key, value):
|
||||
return self.__render_data(key, 0x15, chr(bool(value)))
|
||||
|
||||
def __parse_cover(self, atom, data):
|
||||
length, name, imageformat = struct.unpack(">I4sI", data[:12])
|
||||
if name != "data":
|
||||
raise M4AMetadataError(
|
||||
"unexpected atom %r inside 'covr'" % name)
|
||||
if imageformat not in (M4ACover.FORMAT_JPEG, M4ACover.FORMAT_PNG):
|
||||
imageformat = M4ACover.FORMAT_JPEG
|
||||
self[atom.name] = M4ACover(data[16:length], imageformat)
|
||||
|
||||
def __render_cover(self, key, value):
|
||||
try:
|
||||
imageformat = value.imageformat
|
||||
except AttributeError:
|
||||
imageformat = M4ACover.FORMAT_JPEG
|
||||
data = Atom.render("data", struct.pack(">2I", imageformat, 0) + value)
|
||||
return Atom.render(key, data)
|
||||
|
||||
def __parse_text(self, atom, data):
|
||||
flags = cdata.uint_be(data[8:12])
|
||||
if flags == 1:
|
||||
self[atom.name] = data[16:].decode('utf-8', 'replace')
|
||||
|
||||
def __render_text(self, key, value):
|
||||
return self.__render_data(key, 0x1, value.encode('utf-8'))
|
||||
|
||||
def delete(self, filename):
|
||||
self.clear()
|
||||
self.save(filename)
|
||||
|
||||
__atoms = {
|
||||
"----": (__parse_freeform, __render_freeform),
|
||||
"trkn": (__parse_pair, __render_pair),
|
||||
"disk": (__parse_pair, __render_pair_no_trailing),
|
||||
"gnre": (__parse_genre, None),
|
||||
"tmpo": (__parse_tempo, __render_tempo),
|
||||
"cpil": (__parse_compilation, __render_compilation),
|
||||
"covr": (__parse_cover, __render_cover),
|
||||
}
|
||||
|
||||
def pprint(self):
|
||||
values = []
|
||||
for key, value in self.iteritems():
|
||||
key = key.decode('latin1')
|
||||
try:
|
||||
values.append("%s=%s" % (key, value))
|
||||
except UnicodeDecodeError:
|
||||
values.append("%s=[%d bytes of data]" % (key, len(value)))
|
||||
return "\n".join(values)
|
||||
|
||||
|
||||
class M4AInfo(object):
|
||||
"""MPEG-4 stream information.
|
||||
|
||||
Attributes:
|
||||
bitrate -- bitrate in bits per second, as an int
|
||||
length -- file length in seconds, as a float
|
||||
"""
|
||||
|
||||
bitrate = 0
|
||||
|
||||
def __init__(self, atoms, fileobj):
|
||||
hdlr = atoms["moov.trak.mdia.hdlr"]
|
||||
fileobj.seek(hdlr.offset)
|
||||
if "soun" not in fileobj.read(hdlr.length):
|
||||
raise M4AStreamInfoError("track has no audio data")
|
||||
|
||||
mdhd = atoms["moov.trak.mdia.mdhd"]
|
||||
fileobj.seek(mdhd.offset)
|
||||
data = fileobj.read(mdhd.length)
|
||||
if ord(data[8]) == 0:
|
||||
offset = 20
|
||||
fmt = ">2I"
|
||||
else:
|
||||
offset = 28
|
||||
fmt = ">IQ"
|
||||
end = offset + struct.calcsize(fmt)
|
||||
unit, length = struct.unpack(fmt, data[offset:end])
|
||||
self.length = float(length) / unit
|
||||
|
||||
try:
|
||||
atom = atoms["moov.trak.mdia.minf.stbl.stsd"]
|
||||
fileobj.seek(atom.offset)
|
||||
data = fileobj.read(atom.length)
|
||||
self.bitrate = cdata.uint_be(data[-17:-13])
|
||||
except (ValueError, KeyError):
|
||||
# Bitrate values are optional.
|
||||
pass
|
||||
|
||||
def pprint(self):
|
||||
return "MPEG-4 audio, %.2f seconds, %d bps" % (
|
||||
self.length, self.bitrate)
|
||||
|
||||
|
||||
class M4A(FileType):
|
||||
"""An MPEG-4 audio file, probably containing AAC.
|
||||
|
||||
If more than one track is present in the file, the first is used.
|
||||
Only audio ('soun') tracks will be read.
|
||||
"""
|
||||
|
||||
_mimes = ["audio/mp4", "audio/x-m4a", "audio/mpeg4", "audio/aac"]
|
||||
|
||||
def load(self, filename):
|
||||
self.filename = filename
|
||||
fileobj = open(filename, "rb")
|
||||
try:
|
||||
atoms = Atoms(fileobj)
|
||||
try:
|
||||
self.info = M4AInfo(atoms, fileobj)
|
||||
except StandardError, err:
|
||||
raise M4AStreamInfoError, err, sys.exc_info()[2]
|
||||
try:
|
||||
self.tags = M4ATags(atoms, fileobj)
|
||||
except M4AMetadataError:
|
||||
self.tags = None
|
||||
except StandardError, err:
|
||||
raise M4AMetadataError, err, sys.exc_info()[2]
|
||||
finally:
|
||||
fileobj.close()
|
||||
|
||||
def add_tags(self):
|
||||
self.tags = M4ATags()
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return ("ftyp" in header) + ("mp4" in header)
|
||||
|
||||
|
||||
Open = M4A
|
||||
|
||||
|
||||
def delete(filename):
|
||||
"""Remove tags from a file."""
|
||||
|
||||
M4A(filename).delete()
|
||||
@@ -0,0 +1,84 @@
|
||||
# A Monkey's Audio (APE) reader/tagger
|
||||
#
|
||||
# Copyright 2006 Lukas Lalinsky <lalinsky@gmail.com>
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Monkey's Audio streams with APEv2 tags.
|
||||
|
||||
Monkey's Audio is a very efficient lossless audio compressor developed
|
||||
by Matt Ashland.
|
||||
|
||||
For more information, see http://www.monkeysaudio.com/.
|
||||
"""
|
||||
|
||||
__all__ = ["MonkeysAudio", "Open", "delete"]
|
||||
|
||||
import struct
|
||||
|
||||
from mutagen.apev2 import APEv2File, error, delete
|
||||
from mutagen._util import cdata
|
||||
|
||||
|
||||
class MonkeysAudioHeaderError(error):
|
||||
pass
|
||||
|
||||
|
||||
class MonkeysAudioInfo(object):
|
||||
"""Monkey's Audio stream information.
|
||||
|
||||
Attributes:
|
||||
|
||||
* channels -- number of audio channels
|
||||
* length -- file length in seconds, as a float
|
||||
* sample_rate -- audio sampling rate in Hz
|
||||
* bits_per_sample -- bits per sample
|
||||
* version -- Monkey's Audio stream version, as a float (eg: 3.99)
|
||||
"""
|
||||
|
||||
def __init__(self, fileobj):
|
||||
header = fileobj.read(76)
|
||||
if len(header) != 76 or not header.startswith("MAC "):
|
||||
raise MonkeysAudioHeaderError("not a Monkey's Audio file")
|
||||
self.version = cdata.ushort_le(header[4:6])
|
||||
if self.version >= 3980:
|
||||
(blocks_per_frame, final_frame_blocks, total_frames,
|
||||
self.bits_per_sample, self.channels,
|
||||
self.sample_rate) = struct.unpack("<IIIHHI", header[56:76])
|
||||
else:
|
||||
compression_level = cdata.ushort_le(header[6:8])
|
||||
self.channels, self.sample_rate = struct.unpack(
|
||||
"<HI", header[10:16])
|
||||
total_frames, final_frame_blocks = struct.unpack(
|
||||
"<II", header[24:32])
|
||||
if self.version >= 3950:
|
||||
blocks_per_frame = 73728 * 4
|
||||
elif self.version >= 3900 or (self.version >= 3800 and
|
||||
compression_level == 4):
|
||||
blocks_per_frame = 73728
|
||||
else:
|
||||
blocks_per_frame = 9216
|
||||
self.version /= 1000.0
|
||||
self.length = 0.0
|
||||
if self.sample_rate != 0 and total_frames > 0:
|
||||
total_blocks = ((total_frames - 1) * blocks_per_frame +
|
||||
final_frame_blocks)
|
||||
self.length = float(total_blocks) / self.sample_rate
|
||||
|
||||
def pprint(self):
|
||||
return "Monkey's Audio %.2f, %.2f seconds, %d Hz" % (
|
||||
self.version, self.length, self.sample_rate)
|
||||
|
||||
|
||||
class MonkeysAudio(APEv2File):
|
||||
_Info = MonkeysAudioInfo
|
||||
_mimes = ["audio/ape", "audio/x-ape"]
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return header.startswith("MAC ") + filename.lower().endswith(".ape")
|
||||
|
||||
|
||||
Open = MonkeysAudio
|
||||
@@ -0,0 +1,275 @@
|
||||
# MP3 stream header information support for Mutagen.
|
||||
# Copyright 2006 Joe Wreschnig
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""MPEG audio stream information and tags."""
|
||||
|
||||
import os
|
||||
import struct
|
||||
|
||||
from mutagen.id3 import ID3FileType, BitPaddedInt, delete
|
||||
|
||||
__all__ = ["MP3", "Open", "delete", "MP3"]
|
||||
|
||||
|
||||
class error(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class HeaderNotFoundError(error, IOError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMPEGHeader(error, IOError):
|
||||
pass
|
||||
|
||||
|
||||
# Mode values.
|
||||
STEREO, JOINTSTEREO, DUALCHANNEL, MONO = range(4)
|
||||
|
||||
|
||||
class MPEGInfo(object):
|
||||
"""MPEG audio stream information
|
||||
|
||||
Parse information about an MPEG audio file. This also reads the
|
||||
Xing VBR header format.
|
||||
|
||||
This code was implemented based on the format documentation at
|
||||
http://mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm.
|
||||
|
||||
Useful attributes:
|
||||
|
||||
* length -- audio length, in seconds
|
||||
* bitrate -- audio bitrate, in bits per second
|
||||
* sketchy -- if true, the file may not be valid MPEG audio
|
||||
|
||||
Useless attributes:
|
||||
|
||||
* version -- MPEG version (1, 2, 2.5)
|
||||
* layer -- 1, 2, or 3
|
||||
* mode -- One of STEREO, JOINTSTEREO, DUALCHANNEL, or MONO (0-3)
|
||||
* protected -- whether or not the file is "protected"
|
||||
* padding -- whether or not audio frames are padded
|
||||
* sample_rate -- audio sample rate, in Hz
|
||||
"""
|
||||
|
||||
# Map (version, layer) tuples to bitrates.
|
||||
__BITRATE = {
|
||||
(1, 1): range(0, 480, 32),
|
||||
(1, 2): [0, 32, 48, 56, 64, 80, 96, 112, 128,
|
||||
160, 192, 224, 256, 320, 384],
|
||||
(1, 3): [0, 32, 40, 48, 56, 64, 80, 96, 112,
|
||||
128, 160, 192, 224, 256, 320],
|
||||
(2, 1): [0, 32, 48, 56, 64, 80, 96, 112, 128,
|
||||
144, 160, 176, 192, 224, 256],
|
||||
(2, 2): [0, 8, 16, 24, 32, 40, 48, 56, 64,
|
||||
80, 96, 112, 128, 144, 160],
|
||||
}
|
||||
|
||||
__BITRATE[(2, 3)] = __BITRATE[(2, 2)]
|
||||
for i in range(1, 4):
|
||||
__BITRATE[(2.5, i)] = __BITRATE[(2, i)]
|
||||
|
||||
# Map version to sample rates.
|
||||
__RATES = {
|
||||
1: [44100, 48000, 32000],
|
||||
2: [22050, 24000, 16000],
|
||||
2.5: [11025, 12000, 8000]
|
||||
}
|
||||
|
||||
sketchy = False
|
||||
|
||||
def __init__(self, fileobj, offset=None):
|
||||
"""Parse MPEG stream information from a file-like object.
|
||||
|
||||
If an offset argument is given, it is used to start looking
|
||||
for stream information and Xing headers; otherwise, ID3v2 tags
|
||||
will be skipped automatically. A correct offset can make
|
||||
loading files significantly faster.
|
||||
"""
|
||||
|
||||
try:
|
||||
size = os.path.getsize(fileobj.name)
|
||||
except (IOError, OSError, AttributeError):
|
||||
fileobj.seek(0, 2)
|
||||
size = fileobj.tell()
|
||||
|
||||
# If we don't get an offset, try to skip an ID3v2 tag.
|
||||
if offset is None:
|
||||
fileobj.seek(0, 0)
|
||||
idata = fileobj.read(10)
|
||||
try:
|
||||
id3, insize = struct.unpack('>3sxxx4s', idata)
|
||||
except struct.error:
|
||||
id3, insize = '', 0
|
||||
insize = BitPaddedInt(insize)
|
||||
if id3 == 'ID3' and insize > 0:
|
||||
offset = insize + 10
|
||||
else:
|
||||
offset = 0
|
||||
|
||||
# Try to find two valid headers (meaning, very likely MPEG data)
|
||||
# at the given offset, 30% through the file, 60% through the file,
|
||||
# and 90% through the file.
|
||||
for i in [offset, 0.3 * size, 0.6 * size, 0.9 * size]:
|
||||
try:
|
||||
self.__try(fileobj, int(i), size - offset)
|
||||
except error:
|
||||
pass
|
||||
else:
|
||||
break
|
||||
# If we can't find any two consecutive frames, try to find just
|
||||
# one frame back at the original offset given.
|
||||
else:
|
||||
self.__try(fileobj, offset, size - offset, False)
|
||||
self.sketchy = True
|
||||
|
||||
def __try(self, fileobj, offset, real_size, check_second=True):
|
||||
# This is going to be one really long function; bear with it,
|
||||
# because there's not really a sane point to cut it up.
|
||||
fileobj.seek(offset, 0)
|
||||
|
||||
# We "know" we have an MPEG file if we find two frames that look like
|
||||
# valid MPEG data. If we can't find them in 32k of reads, something
|
||||
# is horribly wrong (the longest frame can only be about 4k). This
|
||||
# is assuming the offset didn't lie.
|
||||
data = fileobj.read(32768)
|
||||
|
||||
frame_1 = data.find("\xff")
|
||||
while 0 <= frame_1 <= len(data) - 4:
|
||||
frame_data = struct.unpack(">I", data[frame_1:frame_1 + 4])[0]
|
||||
if (frame_data >> 16) & 0xE0 != 0xE0:
|
||||
frame_1 = data.find("\xff", frame_1 + 2)
|
||||
else:
|
||||
version = (frame_data >> 19) & 0x3
|
||||
layer = (frame_data >> 17) & 0x3
|
||||
protection = (frame_data >> 16) & 0x1
|
||||
bitrate = (frame_data >> 12) & 0xF
|
||||
sample_rate = (frame_data >> 10) & 0x3
|
||||
padding = (frame_data >> 9) & 0x1
|
||||
#private = (frame_data >> 8) & 0x1
|
||||
self.mode = (frame_data >> 6) & 0x3
|
||||
#mode_extension = (frame_data >> 4) & 0x3
|
||||
#copyright = (frame_data >> 3) & 0x1
|
||||
#original = (frame_data >> 2) & 0x1
|
||||
#emphasis = (frame_data >> 0) & 0x3
|
||||
if (version == 1 or layer == 0 or sample_rate == 0x3 or
|
||||
bitrate == 0 or bitrate == 0xF):
|
||||
frame_1 = data.find("\xff", frame_1 + 2)
|
||||
else:
|
||||
break
|
||||
else:
|
||||
raise HeaderNotFoundError("can't sync to an MPEG frame")
|
||||
|
||||
# There is a serious problem here, which is that many flags
|
||||
# in an MPEG header are backwards.
|
||||
self.version = [2.5, None, 2, 1][version]
|
||||
self.layer = 4 - layer
|
||||
self.protected = not protection
|
||||
self.padding = bool(padding)
|
||||
|
||||
self.bitrate = self.__BITRATE[(self.version, self.layer)][bitrate]
|
||||
self.bitrate *= 1000
|
||||
self.sample_rate = self.__RATES[self.version][sample_rate]
|
||||
|
||||
if self.layer == 1:
|
||||
frame_length = (12 * self.bitrate / self.sample_rate + padding) * 4
|
||||
frame_size = 384
|
||||
elif self.version >= 2 and self.layer == 3:
|
||||
frame_length = 72 * self.bitrate / self.sample_rate + padding
|
||||
frame_size = 576
|
||||
else:
|
||||
frame_length = 144 * self.bitrate / self.sample_rate + padding
|
||||
frame_size = 1152
|
||||
|
||||
if check_second:
|
||||
possible = frame_1 + frame_length
|
||||
if possible > len(data) + 4:
|
||||
raise HeaderNotFoundError("can't sync to second MPEG frame")
|
||||
try:
|
||||
frame_data = struct.unpack(
|
||||
">H", data[possible:possible + 2])[0]
|
||||
except struct.error:
|
||||
raise HeaderNotFoundError("can't sync to second MPEG frame")
|
||||
if frame_data & 0xFFE0 != 0xFFE0:
|
||||
raise HeaderNotFoundError("can't sync to second MPEG frame")
|
||||
|
||||
self.length = 8 * real_size / float(self.bitrate)
|
||||
|
||||
# Try to find/parse the Xing header, which trumps the above length
|
||||
# and bitrate calculation.
|
||||
fileobj.seek(offset, 0)
|
||||
data = fileobj.read(32768)
|
||||
try:
|
||||
xing = data[:-4].index("Xing")
|
||||
except ValueError:
|
||||
# Try to find/parse the VBRI header, which trumps the above length
|
||||
# calculation.
|
||||
try:
|
||||
vbri = data[:-24].index("VBRI")
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
# If a VBRI header was found, this is definitely MPEG audio.
|
||||
self.sketchy = False
|
||||
vbri_version = struct.unpack('>H', data[vbri + 4:vbri + 6])[0]
|
||||
if vbri_version == 1:
|
||||
frame_count = struct.unpack(
|
||||
'>I', data[vbri + 14:vbri + 18])[0]
|
||||
samples = float(frame_size * frame_count)
|
||||
self.length = (samples / self.sample_rate) or self.length
|
||||
else:
|
||||
# If a Xing header was found, this is definitely MPEG audio.
|
||||
self.sketchy = False
|
||||
flags = struct.unpack('>I', data[xing + 4:xing + 8])[0]
|
||||
if flags & 0x1:
|
||||
frame_count = struct.unpack('>I', data[xing + 8:xing + 12])[0]
|
||||
samples = float(frame_size * frame_count)
|
||||
self.length = (samples / self.sample_rate) or self.length
|
||||
if flags & 0x2:
|
||||
bytes = struct.unpack('>I', data[xing + 12:xing + 16])[0]
|
||||
self.bitrate = int((bytes * 8) // self.length)
|
||||
|
||||
def pprint(self):
|
||||
s = "MPEG %s layer %d, %d bps, %s Hz, %.2f seconds" % (
|
||||
self.version, self.layer, self.bitrate, self.sample_rate,
|
||||
self.length)
|
||||
if self.sketchy:
|
||||
s += " (sketchy)"
|
||||
return s
|
||||
|
||||
|
||||
class MP3(ID3FileType):
|
||||
"""An MPEG audio (usually MPEG-1 Layer 3) file.
|
||||
|
||||
:ivar info: :class:`MPEGInfo`
|
||||
:ivar tags: :class:`ID3 <mutagen.id3.ID3>`
|
||||
"""
|
||||
|
||||
_Info = MPEGInfo
|
||||
_mimes = ["audio/mp3", "audio/x-mp3", "audio/mpeg", "audio/mpg",
|
||||
"audio/x-mpeg"]
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
filename = filename.lower()
|
||||
return (header.startswith("ID3") * 2 + filename.endswith(".mp3") +
|
||||
filename.endswith(".mp2") + filename.endswith(".mpg") +
|
||||
filename.endswith(".mpeg"))
|
||||
|
||||
|
||||
Open = MP3
|
||||
|
||||
|
||||
class EasyMP3(MP3):
|
||||
"""Like MP3, but uses EasyID3 for tags.
|
||||
|
||||
:ivar info: :class:`MPEGInfo`
|
||||
:ivar tags: :class:`EasyID3 <mutagen.easyid3.EasyID3>`
|
||||
"""
|
||||
|
||||
from mutagen.easyid3 import EasyID3 as ID3
|
||||
ID3 = ID3
|
||||
@@ -0,0 +1,822 @@
|
||||
# Copyright 2006 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.
|
||||
|
||||
"""Read and write MPEG-4 audio files with iTunes metadata.
|
||||
|
||||
This module will read MPEG-4 audio information and metadata,
|
||||
as found in Apple's MP4 (aka M4A, M4B, M4P) files.
|
||||
|
||||
There is no official specification for this format. The source code
|
||||
for TagLib, FAAD, and various MPEG specifications at
|
||||
|
||||
* http://developer.apple.com/documentation/QuickTime/QTFF/
|
||||
* http://www.geocities.com/xhelmboyx/quicktime/formats/mp4-layout.txt
|
||||
* http://standards.iso.org/ittf/PubliclyAvailableStandards/\
|
||||
c041828_ISO_IEC_14496-12_2005(E).zip
|
||||
* http://wiki.multimedia.cx/index.php?title=Apple_QuickTime
|
||||
|
||||
were all consulted.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from mutagen import FileType, Metadata
|
||||
from mutagen._constants import GENRES
|
||||
from mutagen._util import cdata, insert_bytes, DictProxy, utf8
|
||||
|
||||
|
||||
class error(IOError):
|
||||
pass
|
||||
|
||||
|
||||
class MP4MetadataError(error):
|
||||
pass
|
||||
|
||||
|
||||
class MP4StreamInfoError(error):
|
||||
pass
|
||||
|
||||
|
||||
class MP4MetadataValueError(ValueError, MP4MetadataError):
|
||||
pass
|
||||
|
||||
|
||||
# This is not an exhaustive list of container atoms, but just the
|
||||
# ones this module needs to peek inside.
|
||||
_CONTAINERS = ["moov", "udta", "trak", "mdia", "meta", "ilst",
|
||||
"stbl", "minf", "moof", "traf"]
|
||||
_SKIP_SIZE = {"meta": 4}
|
||||
|
||||
__all__ = ['MP4', 'Open', 'delete', 'MP4Cover', 'MP4FreeForm']
|
||||
|
||||
|
||||
class MP4Cover(str):
|
||||
"""A cover artwork.
|
||||
|
||||
Attributes:
|
||||
|
||||
* imageformat -- format of the image (either FORMAT_JPEG or FORMAT_PNG)
|
||||
"""
|
||||
FORMAT_JPEG = 0x0D
|
||||
FORMAT_PNG = 0x0E
|
||||
|
||||
def __new__(cls, data, *args, **kwargs):
|
||||
return str.__new__(cls, data)
|
||||
|
||||
def __init__(self, data, imageformat=FORMAT_JPEG):
|
||||
self.imageformat = imageformat
|
||||
try:
|
||||
self.format
|
||||
except AttributeError:
|
||||
self.format = imageformat
|
||||
|
||||
|
||||
class MP4FreeForm(str):
|
||||
"""A freeform value.
|
||||
|
||||
Attributes:
|
||||
|
||||
* dataformat -- format of the data (either FORMAT_TEXT or FORMAT_DATA)
|
||||
"""
|
||||
|
||||
FORMAT_DATA = 0x0
|
||||
FORMAT_TEXT = 0x1
|
||||
|
||||
def __new__(cls, data, *args, **kwargs):
|
||||
return str.__new__(cls, data)
|
||||
|
||||
def __init__(self, data, dataformat=FORMAT_TEXT):
|
||||
self.dataformat = dataformat
|
||||
|
||||
|
||||
class Atom(object):
|
||||
"""An individual atom.
|
||||
|
||||
Attributes:
|
||||
children -- list child atoms (or None for non-container atoms)
|
||||
length -- length of this atom, including length and name
|
||||
name -- four byte name of the atom, as a str
|
||||
offset -- location in the constructor-given fileobj of this atom
|
||||
|
||||
This structure should only be used internally by Mutagen.
|
||||
"""
|
||||
|
||||
children = None
|
||||
|
||||
def __init__(self, fileobj, level=0):
|
||||
self.offset = fileobj.tell()
|
||||
self.length, self.name = struct.unpack(">I4s", fileobj.read(8))
|
||||
if self.length == 1:
|
||||
self.length, = struct.unpack(">Q", fileobj.read(8))
|
||||
if self.length < 16:
|
||||
raise MP4MetadataError(
|
||||
"64 bit atom length can only be 16 and higher")
|
||||
elif self.length == 0:
|
||||
if level != 0:
|
||||
raise MP4MetadataError(
|
||||
"only a top-level atom can have zero length")
|
||||
# Only the last atom is supposed to have a zero-length, meaning it
|
||||
# extends to the end of file.
|
||||
fileobj.seek(0, 2)
|
||||
self.length = fileobj.tell() - self.offset
|
||||
fileobj.seek(self.offset + 8, 0)
|
||||
elif self.length < 8:
|
||||
raise MP4MetadataError(
|
||||
"atom length can only be 0, 1 or 8 and higher")
|
||||
|
||||
if self.name in _CONTAINERS:
|
||||
self.children = []
|
||||
fileobj.seek(_SKIP_SIZE.get(self.name, 0), 1)
|
||||
while fileobj.tell() < self.offset + self.length:
|
||||
self.children.append(Atom(fileobj, level + 1))
|
||||
else:
|
||||
fileobj.seek(self.offset + self.length, 0)
|
||||
|
||||
@staticmethod
|
||||
def render(name, data):
|
||||
"""Render raw atom data."""
|
||||
# this raises OverflowError if Py_ssize_t can't handle the atom data
|
||||
size = len(data) + 8
|
||||
if size <= 0xFFFFFFFF:
|
||||
return struct.pack(">I4s", size, name) + data
|
||||
else:
|
||||
return struct.pack(">I4sQ", 1, name, size + 8) + data
|
||||
|
||||
def findall(self, name, recursive=False):
|
||||
"""Recursively find all child atoms by specified name."""
|
||||
if self.children is not None:
|
||||
for child in self.children:
|
||||
if child.name == name:
|
||||
yield child
|
||||
if recursive:
|
||||
for atom in child.findall(name, True):
|
||||
yield atom
|
||||
|
||||
def __getitem__(self, remaining):
|
||||
"""Look up a child atom, potentially recursively.
|
||||
|
||||
e.g. atom['udta', 'meta'] => <Atom name='meta' ...>
|
||||
"""
|
||||
if not remaining:
|
||||
return self
|
||||
elif self.children is None:
|
||||
raise KeyError("%r is not a container" % self.name)
|
||||
for child in self.children:
|
||||
if child.name == remaining[0]:
|
||||
return child[remaining[1:]]
|
||||
else:
|
||||
raise KeyError("%r not found" % remaining[0])
|
||||
|
||||
def __repr__(self):
|
||||
klass = self.__class__.__name__
|
||||
if self.children is None:
|
||||
return "<%s name=%r length=%r offset=%r>" % (
|
||||
klass, self.name, self.length, self.offset)
|
||||
else:
|
||||
children = "\n".join([" " + line for child in self.children
|
||||
for line in repr(child).splitlines()])
|
||||
return "<%s name=%r length=%r offset=%r\n%s>" % (
|
||||
klass, self.name, self.length, self.offset, children)
|
||||
|
||||
|
||||
class Atoms(object):
|
||||
"""Root atoms in a given file.
|
||||
|
||||
Attributes:
|
||||
atoms -- a list of top-level atoms as Atom objects
|
||||
|
||||
This structure should only be used internally by Mutagen.
|
||||
"""
|
||||
|
||||
def __init__(self, fileobj):
|
||||
self.atoms = []
|
||||
fileobj.seek(0, 2)
|
||||
end = fileobj.tell()
|
||||
fileobj.seek(0)
|
||||
while fileobj.tell() + 8 <= end:
|
||||
self.atoms.append(Atom(fileobj))
|
||||
|
||||
def path(self, *names):
|
||||
"""Look up and return the complete path of an atom.
|
||||
|
||||
For example, atoms.path('moov', 'udta', 'meta') will return a
|
||||
list of three atoms, corresponding to the moov, udta, and meta
|
||||
atoms.
|
||||
"""
|
||||
|
||||
path = [self]
|
||||
for name in names:
|
||||
path.append(path[-1][name, ])
|
||||
return path[1:]
|
||||
|
||||
def __contains__(self, names):
|
||||
try:
|
||||
self[names]
|
||||
except KeyError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def __getitem__(self, names):
|
||||
"""Look up a child atom.
|
||||
|
||||
'names' may be a list of atoms (['moov', 'udta']) or a string
|
||||
specifying the complete path ('moov.udta').
|
||||
"""
|
||||
|
||||
if isinstance(names, basestring):
|
||||
names = names.split(".")
|
||||
for child in self.atoms:
|
||||
if child.name == names[0]:
|
||||
return child[names[1:]]
|
||||
else:
|
||||
raise KeyError("%s not found" % names[0])
|
||||
|
||||
def __repr__(self):
|
||||
return "\n".join([repr(child) for child in self.atoms])
|
||||
|
||||
|
||||
class MP4Tags(DictProxy, Metadata):
|
||||
r"""Dictionary containing Apple iTunes metadata list key/values.
|
||||
|
||||
Keys are four byte identifiers, except for freeform ('----')
|
||||
keys. Values are usually unicode strings, but some atoms have a
|
||||
special structure:
|
||||
|
||||
Text values (multiple values per key are supported):
|
||||
|
||||
* '\\xa9nam' -- track title
|
||||
* '\\xa9alb' -- album
|
||||
* '\\xa9ART' -- artist
|
||||
* 'aART' -- album artist
|
||||
* '\\xa9wrt' -- composer
|
||||
* '\\xa9day' -- year
|
||||
* '\\xa9cmt' -- comment
|
||||
* 'desc' -- description (usually used in podcasts)
|
||||
* 'purd' -- purchase date
|
||||
* '\\xa9grp' -- grouping
|
||||
* '\\xa9gen' -- genre
|
||||
* '\\xa9lyr' -- lyrics
|
||||
* 'purl' -- podcast URL
|
||||
* 'egid' -- podcast episode GUID
|
||||
* 'catg' -- podcast category
|
||||
* 'keyw' -- podcast keywords
|
||||
* '\\xa9too' -- encoded by
|
||||
* 'cprt' -- copyright
|
||||
* 'soal' -- album sort order
|
||||
* 'soaa' -- album artist sort order
|
||||
* 'soar' -- artist sort order
|
||||
* 'sonm' -- title sort order
|
||||
* 'soco' -- composer sort order
|
||||
* 'sosn' -- show sort order
|
||||
* 'tvsh' -- show name
|
||||
|
||||
Boolean values:
|
||||
|
||||
* 'cpil' -- part of a compilation
|
||||
* 'pgap' -- part of a gapless album
|
||||
* 'pcst' -- podcast (iTunes reads this only on import)
|
||||
|
||||
Tuples of ints (multiple values per key are supported):
|
||||
|
||||
* 'trkn' -- track number, total tracks
|
||||
* 'disk' -- disc number, total discs
|
||||
|
||||
Others:
|
||||
|
||||
* 'tmpo' -- tempo/BPM, 16 bit int
|
||||
* 'covr' -- cover artwork, list of MP4Cover objects (which are
|
||||
tagged strs)
|
||||
* 'gnre' -- ID3v1 genre. Not supported, use '\\xa9gen' instead.
|
||||
|
||||
The freeform '----' frames use a key in the format '----:mean:name'
|
||||
where 'mean' is usually 'com.apple.iTunes' and 'name' is a unique
|
||||
identifier for this frame. The value is a str, but is probably
|
||||
text that can be decoded as UTF-8. Multiple values per key are
|
||||
supported.
|
||||
|
||||
MP4 tag data cannot exist outside of the structure of an MP4 file,
|
||||
so this class should not be manually instantiated.
|
||||
|
||||
Unknown non-text tags are removed.
|
||||
"""
|
||||
|
||||
def load(self, atoms, fileobj):
|
||||
try:
|
||||
ilst = atoms["moov.udta.meta.ilst"]
|
||||
except KeyError, key:
|
||||
raise MP4MetadataError(key)
|
||||
for atom in ilst.children:
|
||||
fileobj.seek(atom.offset + 8)
|
||||
data = fileobj.read(atom.length - 8)
|
||||
if len(data) != atom.length - 8:
|
||||
raise MP4MetadataError("Not enough data")
|
||||
|
||||
if atom.name in self.__atoms:
|
||||
info = self.__atoms[atom.name]
|
||||
info[0](self, atom, data, *info[2:])
|
||||
else:
|
||||
# unknown atom, try as text and skip if it fails
|
||||
# FIXME: keep them somehow
|
||||
try:
|
||||
self.__parse_text(atom, data)
|
||||
except MP4MetadataError:
|
||||
continue
|
||||
|
||||
@classmethod
|
||||
def _can_load(cls, atoms):
|
||||
return "moov.udta.meta.ilst" in atoms
|
||||
|
||||
@staticmethod
|
||||
def __key_sort(item1, item2):
|
||||
(key1, v1) = item1
|
||||
(key2, v2) = item2
|
||||
# iTunes always writes the tags in order of "relevance", try
|
||||
# to copy it as closely as possible.
|
||||
order = ["\xa9nam", "\xa9ART", "\xa9wrt", "\xa9alb",
|
||||
"\xa9gen", "gnre", "trkn", "disk",
|
||||
"\xa9day", "cpil", "pgap", "pcst", "tmpo",
|
||||
"\xa9too", "----", "covr", "\xa9lyr"]
|
||||
order = dict(zip(order, range(len(order))))
|
||||
last = len(order)
|
||||
# If there's no key-based way to distinguish, order by length.
|
||||
# If there's still no way, go by string comparison on the
|
||||
# values, so we at least have something determinstic.
|
||||
return (cmp(order.get(key1[:4], last), order.get(key2[:4], last)) or
|
||||
cmp(len(v1), len(v2)) or cmp(v1, v2))
|
||||
|
||||
def save(self, filename):
|
||||
"""Save the metadata to the given filename."""
|
||||
values = []
|
||||
items = self.items()
|
||||
items.sort(self.__key_sort)
|
||||
for key, value in items:
|
||||
info = self.__atoms.get(key[:4], (None, type(self).__render_text))
|
||||
try:
|
||||
values.append(info[1](self, key, value, *info[2:]))
|
||||
except (TypeError, ValueError), s:
|
||||
raise MP4MetadataValueError, s, sys.exc_info()[2]
|
||||
data = Atom.render("ilst", "".join(values))
|
||||
|
||||
# Find the old atoms.
|
||||
fileobj = open(filename, "rb+")
|
||||
try:
|
||||
atoms = Atoms(fileobj)
|
||||
try:
|
||||
path = atoms.path("moov", "udta", "meta", "ilst")
|
||||
except KeyError:
|
||||
self.__save_new(fileobj, atoms, data)
|
||||
else:
|
||||
self.__save_existing(fileobj, atoms, path, data)
|
||||
finally:
|
||||
fileobj.close()
|
||||
|
||||
def __pad_ilst(self, data, length=None):
|
||||
if length is None:
|
||||
length = ((len(data) + 1023) & ~1023) - len(data)
|
||||
return Atom.render("free", "\x00" * length)
|
||||
|
||||
def __save_new(self, fileobj, atoms, ilst):
|
||||
hdlr = Atom.render("hdlr", "\x00" * 8 + "mdirappl" + "\x00" * 9)
|
||||
meta = Atom.render(
|
||||
"meta", "\x00\x00\x00\x00" + hdlr + ilst + self.__pad_ilst(ilst))
|
||||
try:
|
||||
path = atoms.path("moov", "udta")
|
||||
except KeyError:
|
||||
# moov.udta not found -- create one
|
||||
path = atoms.path("moov")
|
||||
meta = Atom.render("udta", meta)
|
||||
offset = path[-1].offset + 8
|
||||
insert_bytes(fileobj, len(meta), offset)
|
||||
fileobj.seek(offset)
|
||||
fileobj.write(meta)
|
||||
self.__update_parents(fileobj, path, len(meta))
|
||||
self.__update_offsets(fileobj, atoms, len(meta), offset)
|
||||
|
||||
def __save_existing(self, fileobj, atoms, path, data):
|
||||
# Replace the old ilst atom.
|
||||
ilst = path.pop()
|
||||
offset = ilst.offset
|
||||
length = ilst.length
|
||||
|
||||
# Check for padding "free" atoms
|
||||
meta = path[-1]
|
||||
index = meta.children.index(ilst)
|
||||
try:
|
||||
prev = meta.children[index-1]
|
||||
if prev.name == "free":
|
||||
offset = prev.offset
|
||||
length += prev.length
|
||||
except IndexError:
|
||||
pass
|
||||
try:
|
||||
next = meta.children[index+1]
|
||||
if next.name == "free":
|
||||
length += next.length
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
delta = len(data) - length
|
||||
if delta > 0 or (delta < 0 and delta > -8):
|
||||
data += self.__pad_ilst(data)
|
||||
delta = len(data) - length
|
||||
insert_bytes(fileobj, delta, offset)
|
||||
elif delta < 0:
|
||||
data += self.__pad_ilst(data, -delta - 8)
|
||||
delta = 0
|
||||
|
||||
fileobj.seek(offset)
|
||||
fileobj.write(data)
|
||||
self.__update_parents(fileobj, path, delta)
|
||||
self.__update_offsets(fileobj, atoms, delta, offset)
|
||||
|
||||
def __update_parents(self, fileobj, path, delta):
|
||||
"""Update all parent atoms with the new size."""
|
||||
for atom in path:
|
||||
fileobj.seek(atom.offset)
|
||||
size = cdata.uint_be(fileobj.read(4))
|
||||
if size == 1: # 64bit
|
||||
# skip name (4B) and read size (8B)
|
||||
size = cdata.ulonglong_be(fileobj.read(12)[4:])
|
||||
fileobj.seek(atom.offset + 8)
|
||||
fileobj.write(cdata.to_ulonglong_be(size + delta))
|
||||
else: # 32bit
|
||||
fileobj.seek(atom.offset)
|
||||
fileobj.write(cdata.to_uint_be(size + delta))
|
||||
|
||||
def __update_offset_table(self, fileobj, fmt, atom, delta, offset):
|
||||
"""Update offset table in the specified atom."""
|
||||
if atom.offset > offset:
|
||||
atom.offset += delta
|
||||
fileobj.seek(atom.offset + 12)
|
||||
data = fileobj.read(atom.length - 12)
|
||||
fmt = fmt % cdata.uint_be(data[:4])
|
||||
offsets = struct.unpack(fmt, data[4:])
|
||||
offsets = [o + (0, delta)[offset < o] for o in offsets]
|
||||
fileobj.seek(atom.offset + 16)
|
||||
fileobj.write(struct.pack(fmt, *offsets))
|
||||
|
||||
def __update_tfhd(self, fileobj, atom, delta, offset):
|
||||
if atom.offset > offset:
|
||||
atom.offset += delta
|
||||
fileobj.seek(atom.offset + 9)
|
||||
data = fileobj.read(atom.length - 9)
|
||||
flags = cdata.uint_be("\x00" + data[:3])
|
||||
if flags & 1:
|
||||
o = cdata.ulonglong_be(data[7:15])
|
||||
if o > offset:
|
||||
o += delta
|
||||
fileobj.seek(atom.offset + 16)
|
||||
fileobj.write(cdata.to_ulonglong_be(o))
|
||||
|
||||
def __update_offsets(self, fileobj, atoms, delta, offset):
|
||||
"""Update offset tables in all 'stco' and 'co64' atoms."""
|
||||
if delta == 0:
|
||||
return
|
||||
moov = atoms["moov"]
|
||||
for atom in moov.findall('stco', True):
|
||||
self.__update_offset_table(fileobj, ">%dI", atom, delta, offset)
|
||||
for atom in moov.findall('co64', True):
|
||||
self.__update_offset_table(fileobj, ">%dQ", atom, delta, offset)
|
||||
try:
|
||||
for atom in atoms["moof"].findall('tfhd', True):
|
||||
self.__update_tfhd(fileobj, atom, delta, offset)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def __parse_data(self, atom, data):
|
||||
pos = 0
|
||||
while pos < atom.length - 8:
|
||||
length, name, flags = struct.unpack(">I4sI", data[pos:pos+12])
|
||||
if name != "data":
|
||||
raise MP4MetadataError(
|
||||
"unexpected atom %r inside %r" % (name, atom.name))
|
||||
yield flags, data[pos+16:pos+length]
|
||||
pos += length
|
||||
|
||||
def __render_data(self, key, flags, value):
|
||||
return Atom.render(key, "".join([
|
||||
Atom.render("data", struct.pack(">2I", flags, 0) + data)
|
||||
for data in value]))
|
||||
|
||||
def __parse_freeform(self, atom, data):
|
||||
length = cdata.uint_be(data[:4])
|
||||
mean = data[12:length]
|
||||
pos = length
|
||||
length = cdata.uint_be(data[pos:pos+4])
|
||||
name = data[pos+12:pos+length]
|
||||
pos += length
|
||||
value = []
|
||||
while pos < atom.length - 8:
|
||||
length, atom_name = struct.unpack(">I4s", data[pos:pos+8])
|
||||
if atom_name != "data":
|
||||
raise MP4MetadataError(
|
||||
"unexpected atom %r inside %r" % (atom_name, atom.name))
|
||||
|
||||
version = ord(data[pos+8])
|
||||
if version != 0:
|
||||
raise MP4MetadataError("Unsupported version: %r" % version)
|
||||
|
||||
flags = struct.unpack(">I", "\x00" + data[pos+9:pos+12])[0]
|
||||
value.append(MP4FreeForm(data[pos+16:pos+length],
|
||||
dataformat=flags))
|
||||
pos += length
|
||||
if value:
|
||||
self["%s:%s:%s" % (atom.name, mean, name)] = value
|
||||
|
||||
def __render_freeform(self, key, value):
|
||||
dummy, mean, name = key.split(":", 2)
|
||||
mean = struct.pack(">I4sI", len(mean) + 12, "mean", 0) + mean
|
||||
name = struct.pack(">I4sI", len(name) + 12, "name", 0) + name
|
||||
if isinstance(value, basestring):
|
||||
value = [value]
|
||||
data = ""
|
||||
for v in value:
|
||||
flags = MP4FreeForm.FORMAT_TEXT
|
||||
if isinstance(v, MP4FreeForm):
|
||||
flags = v.dataformat
|
||||
data += struct.pack(">I4s2I", len(v) + 16, "data", flags, 0)
|
||||
data += v
|
||||
return Atom.render("----", mean + name + data)
|
||||
|
||||
def __parse_pair(self, atom, data):
|
||||
self[atom.name] = [struct.unpack(">2H", d[2:6]) for
|
||||
flags, d in self.__parse_data(atom, data)]
|
||||
|
||||
def __render_pair(self, key, value):
|
||||
data = []
|
||||
for (track, total) in value:
|
||||
if 0 <= track < 1 << 16 and 0 <= total < 1 << 16:
|
||||
data.append(struct.pack(">4H", 0, track, total, 0))
|
||||
else:
|
||||
raise MP4MetadataValueError(
|
||||
"invalid numeric pair %r" % ((track, total),))
|
||||
return self.__render_data(key, 0, data)
|
||||
|
||||
def __render_pair_no_trailing(self, key, value):
|
||||
data = []
|
||||
for (track, total) in value:
|
||||
if 0 <= track < 1 << 16 and 0 <= total < 1 << 16:
|
||||
data.append(struct.pack(">3H", 0, track, total))
|
||||
else:
|
||||
raise MP4MetadataValueError(
|
||||
"invalid numeric pair %r" % ((track, total),))
|
||||
return self.__render_data(key, 0, data)
|
||||
|
||||
def __parse_genre(self, atom, data):
|
||||
# Translate to a freeform genre.
|
||||
genre = cdata.short_be(data[16:18])
|
||||
if "\xa9gen" not in self:
|
||||
try:
|
||||
self["\xa9gen"] = [GENRES[genre - 1]]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
def __parse_tempo(self, atom, data):
|
||||
self[atom.name] = [cdata.ushort_be(value[1]) for
|
||||
value in self.__parse_data(atom, data)]
|
||||
|
||||
def __render_tempo(self, key, value):
|
||||
try:
|
||||
if len(value) == 0:
|
||||
return self.__render_data(key, 0x15, "")
|
||||
|
||||
if min(value) < 0 or max(value) >= 2**16:
|
||||
raise MP4MetadataValueError(
|
||||
"invalid 16 bit integers: %r" % value)
|
||||
except TypeError:
|
||||
raise MP4MetadataValueError(
|
||||
"tmpo must be a list of 16 bit integers")
|
||||
|
||||
values = map(cdata.to_ushort_be, value)
|
||||
return self.__render_data(key, 0x15, values)
|
||||
|
||||
def __parse_bool(self, atom, data):
|
||||
try:
|
||||
self[atom.name] = bool(ord(data[16:17]))
|
||||
except TypeError:
|
||||
self[atom.name] = False
|
||||
|
||||
def __render_bool(self, key, value):
|
||||
return self.__render_data(key, 0x15, [chr(bool(value))])
|
||||
|
||||
def __parse_cover(self, atom, data):
|
||||
self[atom.name] = []
|
||||
pos = 0
|
||||
while pos < atom.length - 8:
|
||||
length, name, imageformat = struct.unpack(">I4sI",
|
||||
data[pos:pos+12])
|
||||
if name != "data":
|
||||
if name == "name":
|
||||
pos += length
|
||||
continue
|
||||
raise MP4MetadataError(
|
||||
"unexpected atom %r inside 'covr'" % name)
|
||||
if imageformat not in (MP4Cover.FORMAT_JPEG, MP4Cover.FORMAT_PNG):
|
||||
imageformat = MP4Cover.FORMAT_JPEG
|
||||
cover = MP4Cover(data[pos+16:pos+length], imageformat)
|
||||
self[atom.name].append(cover)
|
||||
pos += length
|
||||
|
||||
def __render_cover(self, key, value):
|
||||
atom_data = []
|
||||
for cover in value:
|
||||
try:
|
||||
imageformat = cover.imageformat
|
||||
except AttributeError:
|
||||
imageformat = MP4Cover.FORMAT_JPEG
|
||||
atom_data.append(Atom.render(
|
||||
"data", struct.pack(">2I", imageformat, 0) + cover))
|
||||
return Atom.render(key, "".join(atom_data))
|
||||
|
||||
def __parse_text(self, atom, data, expected_flags=1):
|
||||
value = [text.decode('utf-8', 'replace') for flags, text
|
||||
in self.__parse_data(atom, data)
|
||||
if flags == expected_flags]
|
||||
if value:
|
||||
self[atom.name] = value
|
||||
|
||||
def __render_text(self, key, value, flags=1):
|
||||
if isinstance(value, basestring):
|
||||
value = [value]
|
||||
return self.__render_data(
|
||||
key, flags, map(utf8, value))
|
||||
|
||||
def delete(self, filename):
|
||||
"""Remove the metadata from the given filename."""
|
||||
|
||||
self.clear()
|
||||
self.save(filename)
|
||||
|
||||
__atoms = {
|
||||
"----": (__parse_freeform, __render_freeform),
|
||||
"trkn": (__parse_pair, __render_pair),
|
||||
"disk": (__parse_pair, __render_pair_no_trailing),
|
||||
"gnre": (__parse_genre, None),
|
||||
"tmpo": (__parse_tempo, __render_tempo),
|
||||
"cpil": (__parse_bool, __render_bool),
|
||||
"pgap": (__parse_bool, __render_bool),
|
||||
"pcst": (__parse_bool, __render_bool),
|
||||
"covr": (__parse_cover, __render_cover),
|
||||
"purl": (__parse_text, __render_text, 0),
|
||||
"egid": (__parse_text, __render_text, 0),
|
||||
}
|
||||
|
||||
# the text atoms we know about which should make loading fail if parsing
|
||||
# any of them fails
|
||||
for name in ["\xa9nam", "\xa9alb", "\xa9ART", "aART", "\xa9wrt", "\xa9day",
|
||||
"\xa9cmt", "desc", "purd", "\xa9grp", "\xa9gen", "\xa9lyr",
|
||||
"catg", "keyw", "\xa9too", "cprt", "soal", "soaa", "soar",
|
||||
"sonm", "soco", "sosn", "tvsh"]:
|
||||
__atoms[name] = (__parse_text, __render_text)
|
||||
|
||||
def pprint(self):
|
||||
values = []
|
||||
for key, value in self.iteritems():
|
||||
key = key.decode('latin1')
|
||||
if key == "covr":
|
||||
values.append("%s=%s" % (key, ", ".join(
|
||||
["[%d bytes of data]" % len(data) for data in value])))
|
||||
elif isinstance(value, list):
|
||||
values.append("%s=%s" % (key, " / ".join(map(unicode, value))))
|
||||
else:
|
||||
values.append("%s=%s" % (key, value))
|
||||
return "\n".join(values)
|
||||
|
||||
|
||||
class MP4Info(object):
|
||||
"""MPEG-4 stream information.
|
||||
|
||||
Attributes:
|
||||
|
||||
* bitrate -- bitrate in bits per second, as an int
|
||||
* length -- file length in seconds, as a float
|
||||
* channels -- number of audio channels
|
||||
* sample_rate -- audio sampling rate in Hz
|
||||
* bits_per_sample -- bits per sample
|
||||
"""
|
||||
|
||||
bitrate = 0
|
||||
channels = 0
|
||||
sample_rate = 0
|
||||
bits_per_sample = 0
|
||||
|
||||
def __init__(self, atoms, fileobj):
|
||||
for trak in list(atoms["moov"].findall("trak")):
|
||||
hdlr = trak["mdia", "hdlr"]
|
||||
fileobj.seek(hdlr.offset)
|
||||
data = fileobj.read(hdlr.length)
|
||||
if data[16:20] == "soun":
|
||||
break
|
||||
else:
|
||||
raise MP4StreamInfoError("track has no audio data")
|
||||
|
||||
mdhd = trak["mdia", "mdhd"]
|
||||
fileobj.seek(mdhd.offset)
|
||||
data = fileobj.read(mdhd.length)
|
||||
if ord(data[8]) == 0:
|
||||
offset = 20
|
||||
fmt = ">2I"
|
||||
else:
|
||||
offset = 28
|
||||
fmt = ">IQ"
|
||||
end = offset + struct.calcsize(fmt)
|
||||
unit, length = struct.unpack(fmt, data[offset:end])
|
||||
self.length = float(length) / unit
|
||||
|
||||
try:
|
||||
atom = trak["mdia", "minf", "stbl", "stsd"]
|
||||
fileobj.seek(atom.offset)
|
||||
data = fileobj.read(atom.length)
|
||||
if data[20:24] == "mp4a":
|
||||
length = cdata.uint_be(data[16:20])
|
||||
(self.channels, self.bits_per_sample, _,
|
||||
self.sample_rate) = struct.unpack(">3HI", data[40:50])
|
||||
# ES descriptor type
|
||||
if data[56:60] == "esds" and ord(data[64:65]) == 0x03:
|
||||
pos = 65
|
||||
# skip extended descriptor type tag, length, ES ID
|
||||
# and stream priority
|
||||
if data[pos:pos+3] == "\x80\x80\x80":
|
||||
pos += 3
|
||||
pos += 4
|
||||
# decoder config descriptor type
|
||||
if ord(data[pos]) == 0x04:
|
||||
pos += 1
|
||||
# skip extended descriptor type tag, length,
|
||||
# object type ID, stream type, buffer size
|
||||
# and maximum bitrate
|
||||
if data[pos:pos+3] == "\x80\x80\x80":
|
||||
pos += 3
|
||||
pos += 10
|
||||
# average bitrate
|
||||
self.bitrate = cdata.uint_be(data[pos:pos+4])
|
||||
except (ValueError, KeyError):
|
||||
# stsd atoms are optional
|
||||
pass
|
||||
|
||||
def pprint(self):
|
||||
return "MPEG-4 audio, %.2f seconds, %d bps" % (
|
||||
self.length, self.bitrate)
|
||||
|
||||
|
||||
class MP4(FileType):
|
||||
"""An MPEG-4 audio file, probably containing AAC.
|
||||
|
||||
If more than one track is present in the file, the first is used.
|
||||
Only audio ('soun') tracks will be read.
|
||||
|
||||
:ivar info: :class:`MP4Info`
|
||||
:ivar tags: :class:`MP4Tags`
|
||||
"""
|
||||
|
||||
MP4Tags = MP4Tags
|
||||
|
||||
_mimes = ["audio/mp4", "audio/x-m4a", "audio/mpeg4", "audio/aac"]
|
||||
|
||||
def load(self, filename):
|
||||
self.filename = filename
|
||||
fileobj = open(filename, "rb")
|
||||
try:
|
||||
atoms = Atoms(fileobj)
|
||||
|
||||
# ftyp is always the first atom in a valid MP4 file
|
||||
if not atoms.atoms or atoms.atoms[0].name != "ftyp":
|
||||
raise error("Not a MP4 file")
|
||||
|
||||
try:
|
||||
self.info = MP4Info(atoms, fileobj)
|
||||
except StandardError, err:
|
||||
raise MP4StreamInfoError, err, sys.exc_info()[2]
|
||||
|
||||
if not MP4Tags._can_load(atoms):
|
||||
self.tags = None
|
||||
else:
|
||||
try:
|
||||
self.tags = self.MP4Tags(atoms, fileobj)
|
||||
except StandardError, err:
|
||||
raise MP4MetadataError, err, sys.exc_info()[2]
|
||||
finally:
|
||||
fileobj.close()
|
||||
|
||||
def add_tags(self):
|
||||
if self.tags is None:
|
||||
self.tags = self.MP4Tags()
|
||||
else:
|
||||
raise error("an MP4 tag already exists")
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return ("ftyp" in header) + ("mp4" in header)
|
||||
|
||||
|
||||
Open = MP4
|
||||
|
||||
|
||||
def delete(filename):
|
||||
"""Remove tags from a file."""
|
||||
|
||||
MP4(filename).delete()
|
||||
@@ -0,0 +1,257 @@
|
||||
# A Musepack reader/tagger
|
||||
#
|
||||
# Copyright 2006 Lukas Lalinsky <lalinsky@gmail.com>
|
||||
# Copyright 2012 Christoph Reiter <christoph.reiter@gmx.at>
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Musepack audio streams with APEv2 tags.
|
||||
|
||||
Musepack is an audio format originally based on the MPEG-1 Layer-2
|
||||
algorithms. Stream versions 4 through 7 are supported.
|
||||
|
||||
For more information, see http://www.musepack.net/.
|
||||
"""
|
||||
|
||||
__all__ = ["Musepack", "Open", "delete"]
|
||||
|
||||
import struct
|
||||
|
||||
from mutagen.apev2 import APEv2File, error, delete
|
||||
from mutagen.id3 import BitPaddedInt
|
||||
from mutagen._util import cdata
|
||||
|
||||
|
||||
class MusepackHeaderError(error):
|
||||
pass
|
||||
|
||||
|
||||
RATES = [44100, 48000, 37800, 32000]
|
||||
|
||||
|
||||
def _parse_sv8_int(fileobj, limit=9):
|
||||
"""Reads (max limit) bytes from fileobj until the MSB is zero.
|
||||
All 7 LSB will be merged to a big endian uint.
|
||||
|
||||
Raises ValueError in case not MSB is zero, or EOFError in
|
||||
case the file ended before limit is reached.
|
||||
|
||||
Returns (parsed number, number of bytes read)
|
||||
"""
|
||||
|
||||
num = 0
|
||||
for i in xrange(limit):
|
||||
c = fileobj.read(1)
|
||||
if len(c) != 1:
|
||||
raise EOFError
|
||||
num = (num << 7) | (ord(c) & 0x7F)
|
||||
if not ord(c) & 0x80:
|
||||
return num, i + 1
|
||||
if limit > 0:
|
||||
raise ValueError
|
||||
return 0, 0
|
||||
|
||||
|
||||
def _calc_sv8_gain(gain):
|
||||
# 64.82 taken from mpcdec
|
||||
return 64.82 - gain / 256.0
|
||||
|
||||
|
||||
def _calc_sv8_peak(peak):
|
||||
return (10 ** (peak / (256.0 * 20.0)) / 65535.0)
|
||||
|
||||
|
||||
class MusepackInfo(object):
|
||||
"""Musepack stream information.
|
||||
|
||||
Attributes:
|
||||
|
||||
* channels -- number of audio channels
|
||||
* length -- file length in seconds, as a float
|
||||
* sample_rate -- audio sampling rate in Hz
|
||||
* bitrate -- audio bitrate, in bits per second
|
||||
* version -- Musepack stream version
|
||||
|
||||
Optional Attributes:
|
||||
|
||||
* title_gain, title_peak -- Replay Gain and peak data for this song
|
||||
* album_gain, album_peak -- Replay Gain and peak data for this album
|
||||
|
||||
These attributes are only available in stream version 7/8. The
|
||||
gains are a float, +/- some dB. The peaks are a percentage [0..1] of
|
||||
the maximum amplitude. This means to get a number comparable to
|
||||
VorbisGain, you must multiply the peak by 2.
|
||||
"""
|
||||
|
||||
def __init__(self, fileobj):
|
||||
header = fileobj.read(4)
|
||||
if len(header) != 4:
|
||||
raise MusepackHeaderError("not a Musepack file")
|
||||
|
||||
# Skip ID3v2 tags
|
||||
if header[:3] == "ID3":
|
||||
header = fileobj.read(6)
|
||||
if len(header) != 6:
|
||||
raise MusepackHeaderError("not a Musepack file")
|
||||
size = 10 + BitPaddedInt(header[2:6])
|
||||
fileobj.seek(size)
|
||||
header = fileobj.read(4)
|
||||
if len(header) != 4:
|
||||
raise MusepackHeaderError("not a Musepack file")
|
||||
|
||||
if header.startswith("MPCK"):
|
||||
self.__parse_sv8(fileobj)
|
||||
else:
|
||||
self.__parse_sv467(fileobj)
|
||||
|
||||
if not self.bitrate and self.length != 0:
|
||||
fileobj.seek(0, 2)
|
||||
self.bitrate = int(round(fileobj.tell() * 8 / self.length))
|
||||
|
||||
def __parse_sv8(self, fileobj):
|
||||
#SV8 http://trac.musepack.net/trac/wiki/SV8Specification
|
||||
|
||||
key_size = 2
|
||||
mandatory_packets = ["SH", "RG"]
|
||||
|
||||
def check_frame_key(key):
|
||||
if len(frame_type) != key_size or not 'AA' <= frame_type <= 'ZZ':
|
||||
raise MusepackHeaderError("Invalid frame key.")
|
||||
|
||||
frame_type = fileobj.read(key_size)
|
||||
check_frame_key(frame_type)
|
||||
|
||||
while frame_type not in ("AP", "SE") and mandatory_packets:
|
||||
try:
|
||||
frame_size, slen = _parse_sv8_int(fileobj)
|
||||
except (EOFError, ValueError):
|
||||
raise MusepackHeaderError("Invalid packet size.")
|
||||
data_size = frame_size - key_size - slen
|
||||
|
||||
if frame_type == "SH":
|
||||
mandatory_packets.remove(frame_type)
|
||||
self.__parse_stream_header(fileobj, data_size)
|
||||
elif frame_type == "RG":
|
||||
mandatory_packets.remove(frame_type)
|
||||
self.__parse_replaygain_packet(fileobj, data_size)
|
||||
else:
|
||||
fileobj.seek(data_size, 1)
|
||||
|
||||
frame_type = fileobj.read(key_size)
|
||||
check_frame_key(frame_type)
|
||||
|
||||
if mandatory_packets:
|
||||
raise MusepackHeaderError("Missing mandatory packets: %s."
|
||||
% ", ".join(mandatory_packets))
|
||||
|
||||
self.length = float(self.samples) / self.sample_rate
|
||||
self.bitrate = 0
|
||||
|
||||
def __parse_stream_header(self, fileobj, data_size):
|
||||
fileobj.seek(4, 1)
|
||||
try:
|
||||
self.version = ord(fileobj.read(1))
|
||||
except TypeError:
|
||||
raise MusepackHeaderError("SH packet ended unexpectedly.")
|
||||
try:
|
||||
samples, l1 = _parse_sv8_int(fileobj)
|
||||
samples_skip, l2 = _parse_sv8_int(fileobj)
|
||||
except (EOFError, ValueError):
|
||||
raise MusepackHeaderError(
|
||||
"SH packet: Invalid sample counts.")
|
||||
left_size = data_size - 5 - l1 - l2
|
||||
if left_size != 2:
|
||||
raise MusepackHeaderError("Invalid SH packet size.")
|
||||
data = fileobj.read(left_size)
|
||||
if len(data) != left_size:
|
||||
raise MusepackHeaderError("SH packet ended unexpectedly.")
|
||||
self.sample_rate = RATES[ord(data[-2]) >> 5]
|
||||
self.channels = (ord(data[-1]) >> 4) + 1
|
||||
self.samples = samples - samples_skip
|
||||
|
||||
def __parse_replaygain_packet(self, fileobj, data_size):
|
||||
data = fileobj.read(data_size)
|
||||
if data_size != 9:
|
||||
raise MusepackHeaderError("Invalid RG packet size.")
|
||||
if len(data) != data_size:
|
||||
raise MusepackHeaderError("RG packet ended unexpectedly.")
|
||||
title_gain = cdata.short_be(data[1:3])
|
||||
title_peak = cdata.short_be(data[3:5])
|
||||
album_gain = cdata.short_be(data[5:7])
|
||||
album_peak = cdata.short_be(data[7:9])
|
||||
if title_gain:
|
||||
self.title_gain = _calc_sv8_gain(title_gain)
|
||||
if title_peak:
|
||||
self.title_peak = _calc_sv8_peak(title_peak)
|
||||
if album_gain:
|
||||
self.album_gain = _calc_sv8_gain(album_gain)
|
||||
if album_peak:
|
||||
self.album_peak = _calc_sv8_peak(album_peak)
|
||||
|
||||
def __parse_sv467(self, fileobj):
|
||||
fileobj.seek(-4, 1)
|
||||
header = fileobj.read(32)
|
||||
if len(header) != 32:
|
||||
raise MusepackHeaderError("not a Musepack file")
|
||||
|
||||
# SV7
|
||||
if header.startswith("MP+"):
|
||||
self.version = ord(header[3]) & 0xF
|
||||
if self.version < 7:
|
||||
raise MusepackHeaderError("not a Musepack file")
|
||||
frames = cdata.uint_le(header[4:8])
|
||||
flags = cdata.uint_le(header[8:12])
|
||||
|
||||
self.title_peak, self.title_gain = struct.unpack(
|
||||
"<Hh", header[12:16])
|
||||
self.album_peak, self.album_gain = struct.unpack(
|
||||
"<Hh", header[16:20])
|
||||
self.title_gain /= 100.0
|
||||
self.album_gain /= 100.0
|
||||
self.title_peak /= 65535.0
|
||||
self.album_peak /= 65535.0
|
||||
|
||||
self.sample_rate = RATES[(flags >> 16) & 0x0003]
|
||||
self.bitrate = 0
|
||||
# SV4-SV6
|
||||
else:
|
||||
header_dword = cdata.uint_le(header[0:4])
|
||||
self.version = (header_dword >> 11) & 0x03FF
|
||||
if self.version < 4 or self.version > 6:
|
||||
raise MusepackHeaderError("not a Musepack file")
|
||||
self.bitrate = (header_dword >> 23) & 0x01FF
|
||||
self.sample_rate = 44100
|
||||
if self.version >= 5:
|
||||
frames = cdata.uint_le(header[4:8])
|
||||
else:
|
||||
frames = cdata.ushort_le(header[6:8])
|
||||
if self.version < 6:
|
||||
frames -= 1
|
||||
self.channels = 2
|
||||
self.length = float(frames * 1152 - 576) / self.sample_rate
|
||||
|
||||
def pprint(self):
|
||||
rg_data = []
|
||||
if hasattr(self, "title_gain"):
|
||||
rg_data.append("%+0.2f (title)" % self.title_gain)
|
||||
if hasattr(self, "album_gain"):
|
||||
rg_data.append("%+0.2f (album)" % self.album_gain)
|
||||
rg_data = (rg_data and ", Gain: " + ", ".join(rg_data)) or ""
|
||||
|
||||
return "Musepack SV%d, %.2f seconds, %d Hz, %d bps%s" % (
|
||||
self.version, self.length, self.sample_rate, self.bitrate, rg_data)
|
||||
|
||||
|
||||
class Musepack(APEv2File):
|
||||
_Info = MusepackInfo
|
||||
_mimes = ["audio/x-musepack", "audio/x-mpc"]
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return (header.startswith("MP+") + header.startswith("MPCK") +
|
||||
filename.lower().endswith(".mpc"))
|
||||
|
||||
|
||||
Open = Musepack
|
||||
@@ -0,0 +1,507 @@
|
||||
# Copyright 2006 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.
|
||||
|
||||
"""Read and write Ogg bitstreams and pages.
|
||||
|
||||
This module reads and writes a subset of the Ogg bitstream format
|
||||
version 0. It does *not* read or write Ogg Vorbis files! For that,
|
||||
you should use mutagen.oggvorbis.
|
||||
|
||||
This implementation is based on the RFC 3533 standard found at
|
||||
http://www.xiph.org/ogg/doc/rfc3533.txt.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
|
||||
from cStringIO import StringIO
|
||||
|
||||
from mutagen import FileType
|
||||
from mutagen._util import cdata, insert_bytes, delete_bytes
|
||||
|
||||
|
||||
class error(IOError):
|
||||
"""Ogg stream parsing errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class OggPage(object):
|
||||
"""A single Ogg page (not necessarily a single encoded packet).
|
||||
|
||||
A page is a header of 26 bytes, followed by the length of the
|
||||
data, followed by the data.
|
||||
|
||||
The constructor is givin a file-like object pointing to the start
|
||||
of an Ogg page. After the constructor is finished it is pointing
|
||||
to the start of the next page.
|
||||
|
||||
Attributes:
|
||||
|
||||
* version -- stream structure version (currently always 0)
|
||||
* position -- absolute stream position (default -1)
|
||||
* serial -- logical stream serial number (default 0)
|
||||
* sequence -- page sequence number within logical stream (default 0)
|
||||
* offset -- offset this page was read from (default None)
|
||||
* complete -- if the last packet on this page is complete (default True)
|
||||
* packets -- list of raw packet data (default [])
|
||||
|
||||
Note that if 'complete' is false, the next page's 'continued'
|
||||
property must be true (so set both when constructing pages).
|
||||
|
||||
If a file-like object is supplied to the constructor, the above
|
||||
attributes will be filled in based on it.
|
||||
"""
|
||||
|
||||
version = 0
|
||||
__type_flags = 0
|
||||
position = 0L
|
||||
serial = 0
|
||||
sequence = 0
|
||||
offset = None
|
||||
complete = True
|
||||
|
||||
def __init__(self, fileobj=None):
|
||||
self.packets = []
|
||||
|
||||
if fileobj is None:
|
||||
return
|
||||
|
||||
self.offset = fileobj.tell()
|
||||
|
||||
header = fileobj.read(27)
|
||||
if len(header) == 0:
|
||||
raise EOFError
|
||||
|
||||
try:
|
||||
(oggs, self.version, self.__type_flags, self.position,
|
||||
self.serial, self.sequence, crc, segments) = struct.unpack(
|
||||
"<4sBBqIIiB", header)
|
||||
except struct.error:
|
||||
raise error("unable to read full header; got %r" % header)
|
||||
|
||||
if oggs != "OggS":
|
||||
raise error("read %r, expected %r, at 0x%x" % (
|
||||
oggs, "OggS", fileobj.tell() - 27))
|
||||
|
||||
if self.version != 0:
|
||||
raise error("version %r unsupported" % self.version)
|
||||
|
||||
total = 0
|
||||
lacings = []
|
||||
lacing_bytes = fileobj.read(segments)
|
||||
if len(lacing_bytes) != segments:
|
||||
raise error("unable to read %r lacing bytes" % segments)
|
||||
for c in map(ord, lacing_bytes):
|
||||
total += c
|
||||
if c < 255:
|
||||
lacings.append(total)
|
||||
total = 0
|
||||
if total:
|
||||
lacings.append(total)
|
||||
self.complete = False
|
||||
|
||||
self.packets = map(fileobj.read, lacings)
|
||||
if map(len, self.packets) != lacings:
|
||||
raise error("unable to read full data")
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Two Ogg pages are the same if they write the same data."""
|
||||
try:
|
||||
return (self.write() == other.write())
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
__hash__ = object.__hash__
|
||||
|
||||
def __repr__(self):
|
||||
attrs = ['version', 'position', 'serial', 'sequence', 'offset',
|
||||
'complete', 'continued', 'first', 'last']
|
||||
values = ["%s=%r" % (attr, getattr(self, attr)) for attr in attrs]
|
||||
return "<%s %s, %d bytes in %d packets>" % (
|
||||
type(self).__name__, " ".join(values), sum(map(len, self.packets)),
|
||||
len(self.packets))
|
||||
|
||||
def write(self):
|
||||
"""Return a string encoding of the page header and data.
|
||||
|
||||
A ValueError is raised if the data is too big to fit in a
|
||||
single page.
|
||||
"""
|
||||
|
||||
data = [
|
||||
struct.pack("<4sBBqIIi", "OggS", self.version, self.__type_flags,
|
||||
self.position, self.serial, self.sequence, 0)
|
||||
]
|
||||
|
||||
lacing_data = []
|
||||
for datum in self.packets:
|
||||
quot, rem = divmod(len(datum), 255)
|
||||
lacing_data.append("\xff" * quot + chr(rem))
|
||||
lacing_data = "".join(lacing_data)
|
||||
if not self.complete and lacing_data.endswith("\x00"):
|
||||
lacing_data = lacing_data[:-1]
|
||||
data.append(chr(len(lacing_data)))
|
||||
data.append(lacing_data)
|
||||
data.extend(self.packets)
|
||||
data = "".join(data)
|
||||
|
||||
# Python's CRC is swapped relative to Ogg's needs.
|
||||
# crc32 returns uint prior to py2.6 on some platforms, so force uint
|
||||
crc = (~zlib.crc32(data.translate(cdata.bitswap), -1)) & 0xffffffff
|
||||
# Although we're using to_uint_be, this actually makes the CRC
|
||||
# a proper le integer, since Python's CRC is byteswapped.
|
||||
crc = cdata.to_uint_be(crc).translate(cdata.bitswap)
|
||||
data = data[:22] + crc + data[26:]
|
||||
return data
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
"""Total frame size."""
|
||||
|
||||
size = 27 # Initial header size
|
||||
for datum in self.packets:
|
||||
quot, rem = divmod(len(datum), 255)
|
||||
size += quot + 1
|
||||
if not self.complete and rem == 0:
|
||||
# Packet contains a multiple of 255 bytes and is not
|
||||
# terminated, so we don't have a \x00 at the end.
|
||||
size -= 1
|
||||
size += sum(map(len, self.packets))
|
||||
return size
|
||||
|
||||
def __set_flag(self, bit, val):
|
||||
mask = 1 << bit
|
||||
if val:
|
||||
self.__type_flags |= mask
|
||||
else:
|
||||
self.__type_flags &= ~mask
|
||||
|
||||
continued = property(
|
||||
lambda self: cdata.test_bit(self.__type_flags, 0),
|
||||
lambda self, v: self.__set_flag(0, v),
|
||||
doc="The first packet is continued from the previous page.")
|
||||
|
||||
first = property(
|
||||
lambda self: cdata.test_bit(self.__type_flags, 1),
|
||||
lambda self, v: self.__set_flag(1, v),
|
||||
doc="This is the first page of a logical bitstream.")
|
||||
|
||||
last = property(
|
||||
lambda self: cdata.test_bit(self.__type_flags, 2),
|
||||
lambda self, v: self.__set_flag(2, v),
|
||||
doc="This is the last page of a logical bitstream.")
|
||||
|
||||
@classmethod
|
||||
def renumber(klass, fileobj, serial, start):
|
||||
"""Renumber pages belonging to a specified logical stream.
|
||||
|
||||
fileobj must be opened with mode r+b or w+b.
|
||||
|
||||
Starting at page number 'start', renumber all pages belonging
|
||||
to logical stream 'serial'. Other pages will be ignored.
|
||||
|
||||
fileobj must point to the start of a valid Ogg page; any
|
||||
occuring after it and part of the specified logical stream
|
||||
will be numbered. No adjustment will be made to the data in
|
||||
the pages nor the granule position; only the page number, and
|
||||
so also the CRC.
|
||||
|
||||
If an error occurs (e.g. non-Ogg data is found), fileobj will
|
||||
be left pointing to the place in the stream the error occured,
|
||||
but the invalid data will be left intact (since this function
|
||||
does not change the total file size).
|
||||
"""
|
||||
|
||||
number = start
|
||||
while True:
|
||||
try:
|
||||
page = OggPage(fileobj)
|
||||
except EOFError:
|
||||
break
|
||||
else:
|
||||
if page.serial != serial:
|
||||
# Wrong stream, skip this page.
|
||||
continue
|
||||
# Changing the number can't change the page size,
|
||||
# so seeking back based on the current size is safe.
|
||||
fileobj.seek(-page.size, 1)
|
||||
page.sequence = number
|
||||
fileobj.write(page.write())
|
||||
fileobj.seek(page.offset + page.size, 0)
|
||||
number += 1
|
||||
|
||||
@classmethod
|
||||
def to_packets(klass, pages, strict=False):
|
||||
"""Construct a list of packet data from a list of Ogg pages.
|
||||
|
||||
If strict is true, the first page must start a new packet,
|
||||
and the last page must end the last packet.
|
||||
"""
|
||||
|
||||
serial = pages[0].serial
|
||||
sequence = pages[0].sequence
|
||||
packets = []
|
||||
|
||||
if strict:
|
||||
if pages[0].continued:
|
||||
raise ValueError("first packet is continued")
|
||||
if not pages[-1].complete:
|
||||
raise ValueError("last packet does not complete")
|
||||
elif pages and pages[0].continued:
|
||||
packets.append([""])
|
||||
|
||||
for page in pages:
|
||||
if serial != page.serial:
|
||||
raise ValueError("invalid serial number in %r" % page)
|
||||
elif sequence != page.sequence:
|
||||
raise ValueError("bad sequence number in %r" % page)
|
||||
else:
|
||||
sequence += 1
|
||||
|
||||
if page.continued:
|
||||
packets[-1].append(page.packets[0])
|
||||
else:
|
||||
packets.append([page.packets[0]])
|
||||
packets.extend([[p] for p in page.packets[1:]])
|
||||
|
||||
return ["".join(p) for p in packets]
|
||||
|
||||
@classmethod
|
||||
def from_packets(klass, packets, sequence=0,
|
||||
default_size=4096, wiggle_room=2048):
|
||||
"""Construct a list of Ogg pages from a list of packet data.
|
||||
|
||||
The algorithm will generate pages of approximately
|
||||
default_size in size (rounded down to the nearest multiple of
|
||||
255). However, it will also allow pages to increase to
|
||||
approximately default_size + wiggle_room if allowing the
|
||||
wiggle room would finish a packet (only one packet will be
|
||||
finished in this way per page; if the next packet would fit
|
||||
into the wiggle room, it still starts on a new page).
|
||||
|
||||
This method reduces packet fragmentation when packet sizes are
|
||||
slightly larger than the default page size, while still
|
||||
ensuring most pages are of the average size.
|
||||
|
||||
Pages are numbered started at 'sequence'; other information is
|
||||
uninitialized.
|
||||
"""
|
||||
|
||||
chunk_size = (default_size // 255) * 255
|
||||
|
||||
pages = []
|
||||
|
||||
page = OggPage()
|
||||
page.sequence = sequence
|
||||
|
||||
for packet in packets:
|
||||
page.packets.append("")
|
||||
while packet:
|
||||
data, packet = packet[:chunk_size], packet[chunk_size:]
|
||||
if page.size < default_size and len(page.packets) < 255:
|
||||
page.packets[-1] += data
|
||||
else:
|
||||
# If we've put any packet data into this page yet,
|
||||
# we need to mark it incomplete. However, we can
|
||||
# also have just started this packet on an already
|
||||
# full page, in which case, just start the new
|
||||
# page with this packet.
|
||||
if page.packets[-1]:
|
||||
page.complete = False
|
||||
if len(page.packets) == 1:
|
||||
page.position = -1L
|
||||
else:
|
||||
page.packets.pop(-1)
|
||||
pages.append(page)
|
||||
page = OggPage()
|
||||
page.continued = not pages[-1].complete
|
||||
page.sequence = pages[-1].sequence + 1
|
||||
page.packets.append(data)
|
||||
|
||||
if len(packet) < wiggle_room:
|
||||
page.packets[-1] += packet
|
||||
packet = ""
|
||||
|
||||
if page.packets:
|
||||
pages.append(page)
|
||||
|
||||
return pages
|
||||
|
||||
@classmethod
|
||||
def replace(klass, fileobj, old_pages, new_pages):
|
||||
"""Replace old_pages with new_pages within fileobj.
|
||||
|
||||
old_pages must have come from reading fileobj originally.
|
||||
new_pages are assumed to have the 'same' data as old_pages,
|
||||
and so the serial and sequence numbers will be copied, as will
|
||||
the flags for the first and last pages.
|
||||
|
||||
fileobj will be resized and pages renumbered as necessary. As
|
||||
such, it must be opened r+b or w+b.
|
||||
"""
|
||||
|
||||
# Number the new pages starting from the first old page.
|
||||
first = old_pages[0].sequence
|
||||
for page, seq in zip(new_pages, range(first, first + len(new_pages))):
|
||||
page.sequence = seq
|
||||
page.serial = old_pages[0].serial
|
||||
|
||||
new_pages[0].first = old_pages[0].first
|
||||
new_pages[0].last = old_pages[0].last
|
||||
new_pages[0].continued = old_pages[0].continued
|
||||
|
||||
new_pages[-1].first = old_pages[-1].first
|
||||
new_pages[-1].last = old_pages[-1].last
|
||||
new_pages[-1].complete = old_pages[-1].complete
|
||||
if not new_pages[-1].complete and len(new_pages[-1].packets) == 1:
|
||||
new_pages[-1].position = -1L
|
||||
|
||||
new_data = "".join(map(klass.write, new_pages))
|
||||
|
||||
# Make room in the file for the new data.
|
||||
delta = len(new_data)
|
||||
fileobj.seek(old_pages[0].offset, 0)
|
||||
insert_bytes(fileobj, delta, old_pages[0].offset)
|
||||
fileobj.seek(old_pages[0].offset, 0)
|
||||
fileobj.write(new_data)
|
||||
new_data_end = old_pages[0].offset + delta
|
||||
|
||||
# Go through the old pages and delete them. Since we shifted
|
||||
# the data down the file, we need to adjust their offsets. We
|
||||
# also need to go backwards, so we don't adjust the deltas of
|
||||
# the other pages.
|
||||
old_pages.reverse()
|
||||
for old_page in old_pages:
|
||||
adj_offset = old_page.offset + delta
|
||||
delete_bytes(fileobj, old_page.size, adj_offset)
|
||||
|
||||
# Finally, if there's any discrepency in length, we need to
|
||||
# renumber the pages for the logical stream.
|
||||
if len(old_pages) != len(new_pages):
|
||||
fileobj.seek(new_data_end, 0)
|
||||
serial = new_pages[-1].serial
|
||||
sequence = new_pages[-1].sequence + 1
|
||||
klass.renumber(fileobj, serial, sequence)
|
||||
|
||||
@classmethod
|
||||
def find_last(klass, fileobj, serial):
|
||||
"""Find the last page of the stream 'serial'.
|
||||
|
||||
If the file is not multiplexed this function is fast. If it is,
|
||||
it must read the whole the stream.
|
||||
|
||||
This finds the last page in the actual file object, or the last
|
||||
page in the stream (with eos set), whichever comes first.
|
||||
"""
|
||||
|
||||
# For non-muxed streams, look at the last page.
|
||||
try:
|
||||
fileobj.seek(-256*256, 2)
|
||||
except IOError:
|
||||
# The file is less than 64k in length.
|
||||
fileobj.seek(0)
|
||||
data = fileobj.read()
|
||||
try:
|
||||
index = data.rindex("OggS")
|
||||
except ValueError:
|
||||
raise error("unable to find final Ogg header")
|
||||
stringobj = StringIO(data[index:])
|
||||
best_page = None
|
||||
try:
|
||||
page = OggPage(stringobj)
|
||||
except error:
|
||||
pass
|
||||
else:
|
||||
if page.serial == serial:
|
||||
if page.last:
|
||||
return page
|
||||
else:
|
||||
best_page = page
|
||||
else:
|
||||
best_page = None
|
||||
|
||||
# The stream is muxed, so use the slow way.
|
||||
fileobj.seek(0)
|
||||
try:
|
||||
page = OggPage(fileobj)
|
||||
while not page.last:
|
||||
page = OggPage(fileobj)
|
||||
while page.serial != serial:
|
||||
page = OggPage(fileobj)
|
||||
best_page = page
|
||||
return page
|
||||
except error:
|
||||
return best_page
|
||||
except EOFError:
|
||||
return best_page
|
||||
|
||||
|
||||
class OggFileType(FileType):
|
||||
"""An generic Ogg file."""
|
||||
|
||||
_Info = None
|
||||
_Tags = None
|
||||
_Error = None
|
||||
_mimes = ["application/ogg", "application/x-ogg"]
|
||||
|
||||
def load(self, filename):
|
||||
"""Load file information from a filename."""
|
||||
|
||||
self.filename = filename
|
||||
fileobj = open(filename, "rb")
|
||||
try:
|
||||
try:
|
||||
self.info = self._Info(fileobj)
|
||||
self.tags = self._Tags(fileobj, self.info)
|
||||
self.info._post_tags(fileobj)
|
||||
except error, e:
|
||||
raise self._Error, e, sys.exc_info()[2]
|
||||
except EOFError:
|
||||
raise self._Error, "no appropriate stream found"
|
||||
finally:
|
||||
fileobj.close()
|
||||
|
||||
def delete(self, filename=None):
|
||||
"""Remove tags from a file.
|
||||
|
||||
If no filename is given, the one most recently loaded is used.
|
||||
"""
|
||||
|
||||
if filename is None:
|
||||
filename = self.filename
|
||||
|
||||
self.tags.clear()
|
||||
fileobj = open(filename, "rb+")
|
||||
try:
|
||||
try:
|
||||
self.tags._inject(fileobj)
|
||||
except error, e:
|
||||
raise self._Error, e, sys.exc_info()[2]
|
||||
except EOFError:
|
||||
raise self._Error, "no appropriate stream found"
|
||||
finally:
|
||||
fileobj.close()
|
||||
|
||||
def save(self, filename=None):
|
||||
"""Save a tag to a file.
|
||||
|
||||
If no filename is given, the one most recently loaded is used.
|
||||
"""
|
||||
|
||||
if filename is None:
|
||||
filename = self.filename
|
||||
fileobj = open(filename, "rb+")
|
||||
try:
|
||||
try:
|
||||
self.tags._inject(fileobj)
|
||||
except error, e:
|
||||
raise self._Error, e, sys.exc_info()[2]
|
||||
except EOFError:
|
||||
raise self._Error, "no appropriate stream found"
|
||||
finally:
|
||||
fileobj.close()
|
||||
@@ -0,0 +1,147 @@
|
||||
# Ogg FLAC support.
|
||||
#
|
||||
# Copyright 2006 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.
|
||||
|
||||
"""Read and write Ogg FLAC comments.
|
||||
|
||||
This module handles FLAC files wrapped in an Ogg bitstream. The first
|
||||
FLAC stream found is used. For 'naked' FLACs, see mutagen.flac.
|
||||
|
||||
This module is based off the specification at
|
||||
http://flac.sourceforge.net/ogg_mapping.html.
|
||||
"""
|
||||
|
||||
__all__ = ["OggFLAC", "Open", "delete"]
|
||||
|
||||
import struct
|
||||
|
||||
from cStringIO import StringIO
|
||||
|
||||
from mutagen.flac import StreamInfo, VCFLACDict, StrictFileObject
|
||||
from mutagen.ogg import OggPage, OggFileType, error as OggError
|
||||
|
||||
|
||||
class error(OggError):
|
||||
pass
|
||||
|
||||
|
||||
class OggFLACHeaderError(error):
|
||||
pass
|
||||
|
||||
|
||||
class OggFLACStreamInfo(StreamInfo):
|
||||
"""Ogg FLAC general header and stream info.
|
||||
|
||||
This encompasses the Ogg wrapper for the FLAC STREAMINFO metadata
|
||||
block, as well as the Ogg codec setup that precedes it.
|
||||
|
||||
Attributes (in addition to StreamInfo's):
|
||||
|
||||
* packets -- number of metadata packets
|
||||
* serial -- Ogg logical stream serial number
|
||||
"""
|
||||
|
||||
packets = 0
|
||||
serial = 0
|
||||
|
||||
def load(self, data):
|
||||
# Ogg expects file objects that don't raise on read
|
||||
if isinstance(data, StrictFileObject):
|
||||
data = data._fileobj
|
||||
|
||||
page = OggPage(data)
|
||||
while not page.packets[0].startswith("\x7FFLAC"):
|
||||
page = OggPage(data)
|
||||
major, minor, self.packets, flac = struct.unpack(
|
||||
">BBH4s", page.packets[0][5:13])
|
||||
if flac != "fLaC":
|
||||
raise OggFLACHeaderError("invalid FLAC marker (%r)" % flac)
|
||||
elif (major, minor) != (1, 0):
|
||||
raise OggFLACHeaderError(
|
||||
"unknown mapping version: %d.%d" % (major, minor))
|
||||
self.serial = page.serial
|
||||
|
||||
# Skip over the block header.
|
||||
stringobj = StrictFileObject(StringIO(page.packets[0][17:]))
|
||||
super(OggFLACStreamInfo, self).load(stringobj)
|
||||
|
||||
def _post_tags(self, fileobj):
|
||||
if self.length:
|
||||
return
|
||||
page = OggPage.find_last(fileobj, self.serial)
|
||||
self.length = page.position / float(self.sample_rate)
|
||||
|
||||
def pprint(self):
|
||||
return "Ogg " + super(OggFLACStreamInfo, self).pprint()
|
||||
|
||||
|
||||
class OggFLACVComment(VCFLACDict):
|
||||
def load(self, data, info, errors='replace'):
|
||||
# data should be pointing at the start of an Ogg page, after
|
||||
# the first FLAC page.
|
||||
pages = []
|
||||
complete = False
|
||||
while not complete:
|
||||
page = OggPage(data)
|
||||
if page.serial == info.serial:
|
||||
pages.append(page)
|
||||
complete = page.complete or (len(page.packets) > 1)
|
||||
comment = StringIO(OggPage.to_packets(pages)[0][4:])
|
||||
super(OggFLACVComment, self).load(comment, errors=errors)
|
||||
|
||||
def _inject(self, fileobj):
|
||||
"""Write tag data into the FLAC Vorbis comment packet/page."""
|
||||
|
||||
# Ogg FLAC has no convenient data marker like Vorbis, but the
|
||||
# second packet - and second page - must be the comment data.
|
||||
fileobj.seek(0)
|
||||
page = OggPage(fileobj)
|
||||
while not page.packets[0].startswith("\x7FFLAC"):
|
||||
page = OggPage(fileobj)
|
||||
|
||||
first_page = page
|
||||
while not (page.sequence == 1 and page.serial == first_page.serial):
|
||||
page = OggPage(fileobj)
|
||||
|
||||
old_pages = [page]
|
||||
while not (old_pages[-1].complete or len(old_pages[-1].packets) > 1):
|
||||
page = OggPage(fileobj)
|
||||
if page.serial == first_page.serial:
|
||||
old_pages.append(page)
|
||||
|
||||
packets = OggPage.to_packets(old_pages, strict=False)
|
||||
|
||||
# Set the new comment block.
|
||||
data = self.write()
|
||||
data = packets[0][0] + struct.pack(">I", len(data))[-3:] + data
|
||||
packets[0] = data
|
||||
|
||||
new_pages = OggPage.from_packets(packets, old_pages[0].sequence)
|
||||
OggPage.replace(fileobj, old_pages, new_pages)
|
||||
|
||||
|
||||
class OggFLAC(OggFileType):
|
||||
"""An Ogg FLAC file."""
|
||||
|
||||
_Info = OggFLACStreamInfo
|
||||
_Tags = OggFLACVComment
|
||||
_Error = OggFLACHeaderError
|
||||
_mimes = ["audio/x-oggflac"]
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return (header.startswith("OggS") * (
|
||||
("FLAC" in header) + ("fLaC" in header)))
|
||||
|
||||
|
||||
Open = OggFLAC
|
||||
|
||||
|
||||
def delete(filename):
|
||||
"""Remove tags from a file."""
|
||||
|
||||
OggFLAC(filename).delete()
|
||||
@@ -0,0 +1,125 @@
|
||||
# Copyright 2012 Christoph Reiter
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Read and write Ogg Opus comments.
|
||||
|
||||
This module handles Opus files wrapped in an Ogg bitstream. The
|
||||
first Opus stream found is used.
|
||||
|
||||
Based on http://tools.ietf.org/html/draft-terriberry-oggopus-01
|
||||
"""
|
||||
|
||||
__all__ = ["OggOpus", "Open", "delete"]
|
||||
|
||||
import struct
|
||||
|
||||
from mutagen._vorbis import VCommentDict
|
||||
from mutagen.ogg import OggPage, OggFileType, error as OggError
|
||||
|
||||
|
||||
class error(OggError):
|
||||
pass
|
||||
|
||||
|
||||
class OggOpusHeaderError(error):
|
||||
pass
|
||||
|
||||
|
||||
class OggOpusInfo(object):
|
||||
"""Ogg Opus stream information.
|
||||
|
||||
Attributes:
|
||||
|
||||
* length - file length in seconds, as a float
|
||||
* channels - number of channels
|
||||
"""
|
||||
|
||||
length = 0
|
||||
|
||||
def __init__(self, fileobj):
|
||||
page = OggPage(fileobj)
|
||||
while not page.packets[0].startswith("OpusHead"):
|
||||
page = OggPage(fileobj)
|
||||
|
||||
self.serial = page.serial
|
||||
|
||||
if not page.first:
|
||||
raise OggOpusHeaderError(
|
||||
"page has ID header, but doesn't start a stream")
|
||||
|
||||
(version, self.channels, pre_skip, orig_sample_rate, output_gain,
|
||||
channel_map) = struct.unpack("<BBHIhB", page.packets[0][8:19])
|
||||
|
||||
self.__pre_skip = pre_skip
|
||||
|
||||
# only the higher 4 bits change on incombatible changes
|
||||
major, minor = version >> 4, version & 0xF
|
||||
if major != 0:
|
||||
raise OggOpusHeaderError("version %r unsupported" % major)
|
||||
|
||||
def _post_tags(self, fileobj):
|
||||
page = OggPage.find_last(fileobj, self.serial)
|
||||
self.length = (page.position - self.__pre_skip) / float(48000)
|
||||
|
||||
def pprint(self):
|
||||
return "Ogg Opus, %.2f seconds" % (self.length)
|
||||
|
||||
|
||||
class OggOpusVComment(VCommentDict):
|
||||
"""Opus comments embedded in an Ogg bitstream."""
|
||||
|
||||
def __get_comment_pages(self, fileobj, info):
|
||||
# find the first tags page with the right serial
|
||||
page = OggPage(fileobj)
|
||||
while info.serial != page.serial or \
|
||||
not page.packets[0].startswith("OpusTags"):
|
||||
page = OggPage(fileobj)
|
||||
|
||||
# get all comment pages
|
||||
pages = [page]
|
||||
while not (pages[-1].complete or len(pages[-1].packets) > 1):
|
||||
page = OggPage(fileobj)
|
||||
if page.serial == pages[0].serial:
|
||||
pages.append(page)
|
||||
|
||||
return pages
|
||||
|
||||
def __init__(self, fileobj, info):
|
||||
pages = self.__get_comment_pages(fileobj, info)
|
||||
data = OggPage.to_packets(pages)[0][8:] # Strip OpusTags
|
||||
super(OggOpusVComment, self).__init__(data, framing=False)
|
||||
|
||||
def _inject(self, fileobj):
|
||||
fileobj.seek(0)
|
||||
info = OggOpusInfo(fileobj)
|
||||
old_pages = self.__get_comment_pages(fileobj, info)
|
||||
|
||||
packets = OggPage.to_packets(old_pages)
|
||||
packets[0] = "OpusTags" + self.write(framing=False)
|
||||
new_pages = OggPage.from_packets(packets, old_pages[0].sequence)
|
||||
OggPage.replace(fileobj, old_pages, new_pages)
|
||||
|
||||
|
||||
class OggOpus(OggFileType):
|
||||
"""An Ogg Opus file."""
|
||||
|
||||
_Info = OggOpusInfo
|
||||
_Tags = OggOpusVComment
|
||||
_Error = OggOpusHeaderError
|
||||
_mimes = ["audio/ogg", "audio/ogg; codecs=opus"]
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return (header.startswith("OggS") * ("OpusHead" in header))
|
||||
|
||||
|
||||
Open = OggOpus
|
||||
|
||||
|
||||
def delete(filename):
|
||||
"""Remove tags from a file."""
|
||||
|
||||
OggOpus(filename).delete()
|
||||
@@ -0,0 +1,137 @@
|
||||
# Ogg Speex support.
|
||||
#
|
||||
# Copyright 2006 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.
|
||||
|
||||
"""Read and write Ogg Speex comments.
|
||||
|
||||
This module handles Speex files wrapped in an Ogg bitstream. The
|
||||
first Speex stream found is used.
|
||||
|
||||
Read more about Ogg Speex at http://www.speex.org/. This module is
|
||||
based on the specification at http://www.speex.org/manual2/node7.html
|
||||
and clarifications after personal communication with Jean-Marc,
|
||||
http://lists.xiph.org/pipermail/speex-dev/2006-July/004676.html.
|
||||
"""
|
||||
|
||||
__all__ = ["OggSpeex", "Open", "delete"]
|
||||
|
||||
from mutagen._vorbis import VCommentDict
|
||||
from mutagen.ogg import OggPage, OggFileType, error as OggError
|
||||
from mutagen._util import cdata
|
||||
|
||||
|
||||
class error(OggError):
|
||||
pass
|
||||
|
||||
|
||||
class OggSpeexHeaderError(error):
|
||||
pass
|
||||
|
||||
|
||||
class OggSpeexInfo(object):
|
||||
"""Ogg Speex stream information.
|
||||
|
||||
Attributes:
|
||||
|
||||
* bitrate - nominal bitrate in bits per second
|
||||
* channels - number of channels
|
||||
* length - file length in seconds, as a float
|
||||
|
||||
The reference encoder does not set the bitrate; in this case,
|
||||
the bitrate will be 0.
|
||||
"""
|
||||
|
||||
length = 0
|
||||
|
||||
def __init__(self, fileobj):
|
||||
page = OggPage(fileobj)
|
||||
while not page.packets[0].startswith("Speex "):
|
||||
page = OggPage(fileobj)
|
||||
if not page.first:
|
||||
raise OggSpeexHeaderError(
|
||||
"page has ID header, but doesn't start a stream")
|
||||
self.sample_rate = cdata.uint_le(page.packets[0][36:40])
|
||||
self.channels = cdata.uint_le(page.packets[0][48:52])
|
||||
self.bitrate = max(0, cdata.int_le(page.packets[0][52:56]))
|
||||
self.serial = page.serial
|
||||
|
||||
def _post_tags(self, fileobj):
|
||||
page = OggPage.find_last(fileobj, self.serial)
|
||||
self.length = page.position / float(self.sample_rate)
|
||||
|
||||
def pprint(self):
|
||||
return "Ogg Speex, %.2f seconds" % self.length
|
||||
|
||||
|
||||
class OggSpeexVComment(VCommentDict):
|
||||
"""Speex comments embedded in an Ogg bitstream."""
|
||||
|
||||
def __init__(self, fileobj, info):
|
||||
pages = []
|
||||
complete = False
|
||||
while not complete:
|
||||
page = OggPage(fileobj)
|
||||
if page.serial == info.serial:
|
||||
pages.append(page)
|
||||
complete = page.complete or (len(page.packets) > 1)
|
||||
data = OggPage.to_packets(pages)[0] + "\x01"
|
||||
super(OggSpeexVComment, self).__init__(data, framing=False)
|
||||
|
||||
def _inject(self, fileobj):
|
||||
"""Write tag data into the Speex comment packet/page."""
|
||||
|
||||
fileobj.seek(0)
|
||||
|
||||
# Find the first header page, with the stream info.
|
||||
# Use it to get the serial number.
|
||||
page = OggPage(fileobj)
|
||||
while not page.packets[0].startswith("Speex "):
|
||||
page = OggPage(fileobj)
|
||||
|
||||
# Look for the next page with that serial number, it'll start
|
||||
# the comment packet.
|
||||
serial = page.serial
|
||||
page = OggPage(fileobj)
|
||||
while page.serial != serial:
|
||||
page = OggPage(fileobj)
|
||||
|
||||
# Then find all the pages with the comment packet.
|
||||
old_pages = [page]
|
||||
while not (old_pages[-1].complete or len(old_pages[-1].packets) > 1):
|
||||
page = OggPage(fileobj)
|
||||
if page.serial == old_pages[0].serial:
|
||||
old_pages.append(page)
|
||||
|
||||
packets = OggPage.to_packets(old_pages, strict=False)
|
||||
|
||||
# Set the new comment packet.
|
||||
packets[0] = self.write(framing=False)
|
||||
|
||||
new_pages = OggPage.from_packets(packets, old_pages[0].sequence)
|
||||
OggPage.replace(fileobj, old_pages, new_pages)
|
||||
|
||||
|
||||
class OggSpeex(OggFileType):
|
||||
"""An Ogg Speex file."""
|
||||
|
||||
_Info = OggSpeexInfo
|
||||
_Tags = OggSpeexVComment
|
||||
_Error = OggSpeexHeaderError
|
||||
_mimes = ["audio/x-speex"]
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return (header.startswith("OggS") * ("Speex " in header))
|
||||
|
||||
|
||||
Open = OggSpeex
|
||||
|
||||
|
||||
def delete(filename):
|
||||
"""Remove tags from a file."""
|
||||
|
||||
OggSpeex(filename).delete()
|
||||
@@ -0,0 +1,130 @@
|
||||
# Ogg Theora support.
|
||||
#
|
||||
# Copyright 2006 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.
|
||||
|
||||
"""Read and write Ogg Theora comments.
|
||||
|
||||
This module handles Theora files wrapped in an Ogg bitstream. The
|
||||
first Theora stream found is used.
|
||||
|
||||
Based on the specification at http://theora.org/doc/Theora_I_spec.pdf.
|
||||
"""
|
||||
|
||||
__all__ = ["OggTheora", "Open", "delete"]
|
||||
|
||||
import struct
|
||||
|
||||
from mutagen._vorbis import VCommentDict
|
||||
from mutagen._util import cdata
|
||||
from mutagen.ogg import OggPage, OggFileType, error as OggError
|
||||
|
||||
|
||||
class error(OggError):
|
||||
pass
|
||||
|
||||
|
||||
class OggTheoraHeaderError(error):
|
||||
pass
|
||||
|
||||
|
||||
class OggTheoraInfo(object):
|
||||
"""Ogg Theora stream information.
|
||||
|
||||
Attributes:
|
||||
|
||||
* length - file length in seconds, as a float
|
||||
* fps - video frames per second, as a float
|
||||
"""
|
||||
|
||||
length = 0
|
||||
|
||||
def __init__(self, fileobj):
|
||||
page = OggPage(fileobj)
|
||||
while not page.packets[0].startswith("\x80theora"):
|
||||
page = OggPage(fileobj)
|
||||
if not page.first:
|
||||
raise OggTheoraHeaderError(
|
||||
"page has ID header, but doesn't start a stream")
|
||||
data = page.packets[0]
|
||||
vmaj, vmin = struct.unpack("2B", data[7:9])
|
||||
if (vmaj, vmin) != (3, 2):
|
||||
raise OggTheoraHeaderError(
|
||||
"found Theora version %d.%d != 3.2" % (vmaj, vmin))
|
||||
fps_num, fps_den = struct.unpack(">2I", data[22:30])
|
||||
self.fps = fps_num / float(fps_den)
|
||||
self.bitrate = cdata.uint_be("\x00" + data[37:40])
|
||||
self.granule_shift = (cdata.ushort_be(data[40:42]) >> 5) & 0x1F
|
||||
self.serial = page.serial
|
||||
|
||||
def _post_tags(self, fileobj):
|
||||
page = OggPage.find_last(fileobj, self.serial)
|
||||
position = page.position
|
||||
mask = (1 << self.granule_shift) - 1
|
||||
frames = (position >> self.granule_shift) + (position & mask)
|
||||
self.length = frames / float(self.fps)
|
||||
|
||||
def pprint(self):
|
||||
return "Ogg Theora, %.2f seconds, %d bps" % (self.length, self.bitrate)
|
||||
|
||||
|
||||
class OggTheoraCommentDict(VCommentDict):
|
||||
"""Theora comments embedded in an Ogg bitstream."""
|
||||
|
||||
def __init__(self, fileobj, info):
|
||||
pages = []
|
||||
complete = False
|
||||
while not complete:
|
||||
page = OggPage(fileobj)
|
||||
if page.serial == info.serial:
|
||||
pages.append(page)
|
||||
complete = page.complete or (len(page.packets) > 1)
|
||||
data = OggPage.to_packets(pages)[0][7:]
|
||||
super(OggTheoraCommentDict, self).__init__(data + "\x01")
|
||||
|
||||
def _inject(self, fileobj):
|
||||
"""Write tag data into the Theora comment packet/page."""
|
||||
|
||||
fileobj.seek(0)
|
||||
page = OggPage(fileobj)
|
||||
while not page.packets[0].startswith("\x81theora"):
|
||||
page = OggPage(fileobj)
|
||||
|
||||
old_pages = [page]
|
||||
while not (old_pages[-1].complete or len(old_pages[-1].packets) > 1):
|
||||
page = OggPage(fileobj)
|
||||
if page.serial == old_pages[0].serial:
|
||||
old_pages.append(page)
|
||||
|
||||
packets = OggPage.to_packets(old_pages, strict=False)
|
||||
|
||||
packets[0] = "\x81theora" + self.write(framing=False)
|
||||
|
||||
new_pages = OggPage.from_packets(packets, old_pages[0].sequence)
|
||||
OggPage.replace(fileobj, old_pages, new_pages)
|
||||
|
||||
|
||||
class OggTheora(OggFileType):
|
||||
"""An Ogg Theora file."""
|
||||
|
||||
_Info = OggTheoraInfo
|
||||
_Tags = OggTheoraCommentDict
|
||||
_Error = OggTheoraHeaderError
|
||||
_mimes = ["video/x-theora"]
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return (header.startswith("OggS") *
|
||||
(("\x80theora" in header) + ("\x81theora" in header)))
|
||||
|
||||
|
||||
Open = OggTheora
|
||||
|
||||
|
||||
def delete(filename):
|
||||
"""Remove tags from a file."""
|
||||
|
||||
OggTheora(filename).delete()
|
||||
@@ -0,0 +1,137 @@
|
||||
# Ogg Vorbis support.
|
||||
#
|
||||
# Copyright 2006 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.
|
||||
|
||||
"""Read and write Ogg Vorbis comments.
|
||||
|
||||
This module handles Vorbis files wrapped in an Ogg bitstream. The
|
||||
first Vorbis stream found is used.
|
||||
|
||||
Read more about Ogg Vorbis at http://vorbis.com/. This module is based
|
||||
on the specification at http://www.xiph.org/vorbis/doc/Vorbis_I_spec.html.
|
||||
"""
|
||||
|
||||
__all__ = ["OggVorbis", "Open", "delete"]
|
||||
|
||||
import struct
|
||||
|
||||
from mutagen._vorbis import VCommentDict
|
||||
from mutagen.ogg import OggPage, OggFileType, error as OggError
|
||||
|
||||
|
||||
class error(OggError):
|
||||
pass
|
||||
|
||||
|
||||
class OggVorbisHeaderError(error):
|
||||
pass
|
||||
|
||||
|
||||
class OggVorbisInfo(object):
|
||||
"""Ogg Vorbis stream information.
|
||||
|
||||
Attributes:
|
||||
|
||||
* length - file length in seconds, as a float
|
||||
* bitrate - nominal ('average') bitrate in bits per second, as an int
|
||||
"""
|
||||
|
||||
length = 0
|
||||
|
||||
def __init__(self, fileobj):
|
||||
page = OggPage(fileobj)
|
||||
while not page.packets[0].startswith("\x01vorbis"):
|
||||
page = OggPage(fileobj)
|
||||
if not page.first:
|
||||
raise OggVorbisHeaderError(
|
||||
"page has ID header, but doesn't start a stream")
|
||||
(self.channels, self.sample_rate, max_bitrate, nominal_bitrate,
|
||||
min_bitrate) = struct.unpack("<B4i", page.packets[0][11:28])
|
||||
self.serial = page.serial
|
||||
|
||||
max_bitrate = max(0, max_bitrate)
|
||||
min_bitrate = max(0, min_bitrate)
|
||||
nominal_bitrate = max(0, nominal_bitrate)
|
||||
|
||||
if nominal_bitrate == 0:
|
||||
self.bitrate = (max_bitrate + min_bitrate) // 2
|
||||
elif max_bitrate and max_bitrate < nominal_bitrate:
|
||||
# If the max bitrate is less than the nominal, we know
|
||||
# the nominal is wrong.
|
||||
self.bitrate = max_bitrate
|
||||
elif min_bitrate > nominal_bitrate:
|
||||
self.bitrate = min_bitrate
|
||||
else:
|
||||
self.bitrate = nominal_bitrate
|
||||
|
||||
def _post_tags(self, fileobj):
|
||||
page = OggPage.find_last(fileobj, self.serial)
|
||||
self.length = page.position / float(self.sample_rate)
|
||||
|
||||
def pprint(self):
|
||||
return "Ogg Vorbis, %.2f seconds, %d bps" % (self.length, self.bitrate)
|
||||
|
||||
|
||||
class OggVCommentDict(VCommentDict):
|
||||
"""Vorbis comments embedded in an Ogg bitstream."""
|
||||
|
||||
def __init__(self, fileobj, info):
|
||||
pages = []
|
||||
complete = False
|
||||
while not complete:
|
||||
page = OggPage(fileobj)
|
||||
if page.serial == info.serial:
|
||||
pages.append(page)
|
||||
complete = page.complete or (len(page.packets) > 1)
|
||||
data = OggPage.to_packets(pages)[0][7:] # Strip off "\x03vorbis".
|
||||
super(OggVCommentDict, self).__init__(data)
|
||||
|
||||
def _inject(self, fileobj):
|
||||
"""Write tag data into the Vorbis comment packet/page."""
|
||||
|
||||
# Find the old pages in the file; we'll need to remove them,
|
||||
# plus grab any stray setup packet data out of them.
|
||||
fileobj.seek(0)
|
||||
page = OggPage(fileobj)
|
||||
while not page.packets[0].startswith("\x03vorbis"):
|
||||
page = OggPage(fileobj)
|
||||
|
||||
old_pages = [page]
|
||||
while not (old_pages[-1].complete or len(old_pages[-1].packets) > 1):
|
||||
page = OggPage(fileobj)
|
||||
if page.serial == old_pages[0].serial:
|
||||
old_pages.append(page)
|
||||
|
||||
packets = OggPage.to_packets(old_pages, strict=False)
|
||||
|
||||
# Set the new comment packet.
|
||||
packets[0] = "\x03vorbis" + self.write()
|
||||
|
||||
new_pages = OggPage.from_packets(packets, old_pages[0].sequence)
|
||||
OggPage.replace(fileobj, old_pages, new_pages)
|
||||
|
||||
|
||||
class OggVorbis(OggFileType):
|
||||
"""An Ogg Vorbis file."""
|
||||
|
||||
_Info = OggVorbisInfo
|
||||
_Tags = OggVCommentDict
|
||||
_Error = OggVorbisHeaderError
|
||||
_mimes = ["audio/vorbis", "audio/x-vorbis"]
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return (header.startswith("OggS") * ("\x01vorbis" in header))
|
||||
|
||||
|
||||
Open = OggVorbis
|
||||
|
||||
|
||||
def delete(filename):
|
||||
"""Remove tags from a file."""
|
||||
|
||||
OggVorbis(filename).delete()
|
||||
@@ -0,0 +1,71 @@
|
||||
# OptimFROG reader/tagger
|
||||
#
|
||||
# Copyright 2006 Lukas Lalinsky <lalinsky@gmail.com>
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""OptimFROG audio streams with APEv2 tags.
|
||||
|
||||
OptimFROG is a lossless audio compression program. Its main goal is to
|
||||
reduce at maximum the size of audio files, while permitting bit
|
||||
identical restoration for all input. It is similar with the ZIP
|
||||
compression, but it is highly specialized to compress audio data.
|
||||
|
||||
Only versions 4.5 and higher are supported.
|
||||
|
||||
For more information, see http://www.losslessaudio.org/
|
||||
"""
|
||||
|
||||
__all__ = ["OptimFROG", "Open", "delete"]
|
||||
|
||||
import struct
|
||||
|
||||
from mutagen.apev2 import APEv2File, error, delete
|
||||
|
||||
|
||||
class OptimFROGHeaderError(error):
|
||||
pass
|
||||
|
||||
|
||||
class OptimFROGInfo(object):
|
||||
"""OptimFROG stream information.
|
||||
|
||||
Attributes:
|
||||
|
||||
* channels - number of audio channels
|
||||
* length - file length in seconds, as a float
|
||||
* sample_rate - audio sampling rate in Hz
|
||||
"""
|
||||
|
||||
def __init__(self, fileobj):
|
||||
header = fileobj.read(76)
|
||||
if (len(header) != 76 or not header.startswith("OFR ") or
|
||||
struct.unpack("<I", header[4:8])[0] not in [12, 15]):
|
||||
raise OptimFROGHeaderError("not an OptimFROG file")
|
||||
(total_samples, total_samples_high, sample_type, self.channels,
|
||||
self.sample_rate) = struct.unpack("<IHBBI", header[8:20])
|
||||
total_samples += total_samples_high << 32
|
||||
self.channels += 1
|
||||
if self.sample_rate:
|
||||
self.length = float(total_samples) / (self.channels *
|
||||
self.sample_rate)
|
||||
else:
|
||||
self.length = 0.0
|
||||
|
||||
def pprint(self):
|
||||
return "OptimFROG, %.2f seconds, %d Hz" % (self.length,
|
||||
self.sample_rate)
|
||||
|
||||
|
||||
class OptimFROG(APEv2File):
|
||||
_Info = OptimFROGInfo
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
filename = filename.lower()
|
||||
return (header.startswith("OFR") + filename.endswith(".ofr") +
|
||||
filename.endswith(".ofs"))
|
||||
|
||||
Open = OptimFROG
|
||||
@@ -0,0 +1,81 @@
|
||||
# True Audio support for Mutagen
|
||||
# Copyright 2006 Joe Wreschnig
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""True Audio audio stream information and tags.
|
||||
|
||||
True Audio is a lossless format designed for real-time encoding and
|
||||
decoding. This module is based on the documentation at
|
||||
http://www.true-audio.com/TTA_Lossless_Audio_Codec\_-_Format_Description
|
||||
|
||||
True Audio files use ID3 tags.
|
||||
"""
|
||||
|
||||
__all__ = ["TrueAudio", "Open", "delete", "EasyTrueAudio"]
|
||||
|
||||
from mutagen.id3 import ID3FileType, delete
|
||||
from mutagen._util import cdata
|
||||
|
||||
|
||||
class error(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class TrueAudioHeaderError(error, IOError):
|
||||
pass
|
||||
|
||||
|
||||
class TrueAudioInfo(object):
|
||||
"""True Audio stream information.
|
||||
|
||||
Attributes:
|
||||
|
||||
* length - audio length, in seconds
|
||||
* sample_rate - audio sample rate, in Hz
|
||||
"""
|
||||
|
||||
def __init__(self, fileobj, offset):
|
||||
fileobj.seek(offset or 0)
|
||||
header = fileobj.read(18)
|
||||
if len(header) != 18 or not header.startswith("TTA"):
|
||||
raise TrueAudioHeaderError("TTA header not found")
|
||||
self.sample_rate = cdata.int_le(header[10:14])
|
||||
samples = cdata.uint_le(header[14:18])
|
||||
self.length = float(samples) / self.sample_rate
|
||||
|
||||
def pprint(self):
|
||||
return "True Audio, %.2f seconds, %d Hz." % (
|
||||
self.length, self.sample_rate)
|
||||
|
||||
|
||||
class TrueAudio(ID3FileType):
|
||||
"""A True Audio file.
|
||||
|
||||
:ivar info: :class:`TrueAudioInfo`
|
||||
:ivar tags: :class:`ID3 <mutagen.id3.ID3>`
|
||||
"""
|
||||
|
||||
_Info = TrueAudioInfo
|
||||
_mimes = ["audio/x-tta"]
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return (header.startswith("ID3") + header.startswith("TTA") +
|
||||
filename.lower().endswith(".tta") * 2)
|
||||
|
||||
|
||||
Open = TrueAudio
|
||||
|
||||
|
||||
class EasyTrueAudio(TrueAudio):
|
||||
"""Like MP3, but uses EasyID3 for tags.
|
||||
|
||||
:ivar info: :class:`TrueAudioInfo`
|
||||
:ivar tags: :class:`EasyID3 <mutagen.easyid3.EasyID3>`
|
||||
"""
|
||||
|
||||
from mutagen.easyid3 import EasyID3 as ID3
|
||||
ID3 = ID3
|
||||
@@ -0,0 +1,63 @@
|
||||
# A WavPack reader/tagger
|
||||
#
|
||||
# Copyright 2006 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.
|
||||
|
||||
"""WavPack reading and writing.
|
||||
|
||||
WavPack is a lossless format that uses APEv2 tags. Read
|
||||
http://www.wavpack.com/ for more information.
|
||||
"""
|
||||
|
||||
__all__ = ["WavPack", "Open", "delete"]
|
||||
|
||||
from mutagen.apev2 import APEv2File, error, delete
|
||||
from mutagen._util import cdata
|
||||
|
||||
|
||||
class WavPackHeaderError(error):
|
||||
pass
|
||||
|
||||
RATES = [6000, 8000, 9600, 11025, 12000, 16000, 22050, 24000, 32000, 44100,
|
||||
48000, 64000, 88200, 96000, 192000]
|
||||
|
||||
|
||||
class WavPackInfo(object):
|
||||
"""WavPack stream information.
|
||||
|
||||
Attributes:
|
||||
|
||||
* channels - number of audio channels (1 or 2)
|
||||
* length - file length in seconds, as a float
|
||||
* sample_rate - audio sampling rate in Hz
|
||||
* version - WavPack stream version
|
||||
"""
|
||||
|
||||
def __init__(self, fileobj):
|
||||
header = fileobj.read(28)
|
||||
if len(header) != 28 or not header.startswith("wvpk"):
|
||||
raise WavPackHeaderError("not a WavPack file")
|
||||
samples = cdata.uint_le(header[12:16])
|
||||
flags = cdata.uint_le(header[24:28])
|
||||
self.version = cdata.short_le(header[8:10])
|
||||
self.channels = bool(flags & 4) or 2
|
||||
self.sample_rate = RATES[(flags >> 23) & 0xF]
|
||||
self.length = float(samples) / self.sample_rate
|
||||
|
||||
def pprint(self):
|
||||
return "WavPack, %.2f seconds, %d Hz" % (self.length, self.sample_rate)
|
||||
|
||||
|
||||
class WavPack(APEv2File):
|
||||
_Info = WavPackInfo
|
||||
_mimes = ["audio/x-wavpack"]
|
||||
|
||||
@staticmethod
|
||||
def score(filename, fileobj, header):
|
||||
return header.startswith("wvpk") * 2
|
||||
|
||||
|
||||
Open = WavPack
|
||||
Reference in New Issue
Block a user