mirror of
https://github.com/beetbox/beets.git
synced 2026-02-25 16:53:17 +01:00
Merge branch 'image-tags'
Conflicts: beets/mediafile.py
This commit is contained in:
commit
610e4f14eb
9 changed files with 397 additions and 161 deletions
|
|
@ -250,6 +250,23 @@ def _sc_encode(gain, peak):
|
|||
return (u' %08X' * 10) % values
|
||||
|
||||
|
||||
def _image_mime_type(data):
|
||||
"""Return the MIME type of the image data (a bytestring).
|
||||
"""
|
||||
kind = imghdr.what(None, h=data)
|
||||
if kind in ['gif', 'jpeg', 'png', 'tiff', 'bmp']:
|
||||
return 'image/{0}'.format(kind)
|
||||
elif kind == 'pgm':
|
||||
return 'image/x-portable-graymap'
|
||||
elif kind == 'pbm':
|
||||
return 'image/x-portable-bitmap'
|
||||
elif kind == 'ppm':
|
||||
return 'image/x-portable-pixmap'
|
||||
elif kind == 'xbm':
|
||||
return 'image/x-xbitmap'
|
||||
else:
|
||||
return 'image/x-{0}'.format(kind)
|
||||
|
||||
|
||||
# StorageStyle classes describe strategies for accessing values in
|
||||
# Mutagen file objects.
|
||||
|
|
@ -257,6 +274,15 @@ def _sc_encode(gain, peak):
|
|||
class StorageStyle(object):
|
||||
"""Parameterizes the storage behavior of a single field for a
|
||||
certain tag format.
|
||||
|
||||
The ``get()`` method retrieves data from tags of a mutagen file. It
|
||||
uses the fetch method to obtain the raw mutagen value. It then uses
|
||||
the ``deserialize()`` method to convert it into a python value.
|
||||
|
||||
``set()`` uses ``serialize()`` to convert the passed value into a
|
||||
suitable mutagen type. The ``store()`` method is then used to write
|
||||
that value to the tags.
|
||||
|
||||
- key: The Mutagen key used to access the field's data.
|
||||
- as_type: Which type the value is stored as (unicode, int,
|
||||
bool, or str).
|
||||
|
|
@ -269,20 +295,26 @@ class StorageStyle(object):
|
|||
|
||||
# TODO Use mutagen file types instead of MediaFile formats
|
||||
formats = ['flac', 'opus', 'ogg', 'ape', 'wv', 'mpc']
|
||||
"""List of file format the StorageStyle can handle.
|
||||
|
||||
def __init__(self, key, as_type=unicode, suffix=None,
|
||||
float_places=2, formats=None):
|
||||
Format names correspond to those returned by ``mediafile.type``.
|
||||
"""
|
||||
|
||||
def __init__(self, key, as_type=unicode, suffix=None, float_places=2):
|
||||
self.key = key
|
||||
self.as_type = as_type
|
||||
self.suffix = suffix
|
||||
self.float_places = float_places
|
||||
if formats:
|
||||
self.formats = formats
|
||||
|
||||
# Convert suffix to correct string type.
|
||||
if self.suffix and self.as_type == unicode:
|
||||
self.suffix = self.as_type(self.suffix)
|
||||
|
||||
def get(self, mutagen_file):
|
||||
"""Fetches raw data from tags and deserializes it into a python value.
|
||||
"""
|
||||
return self.deserialize(self.fetch(mutagen_file))
|
||||
|
||||
def fetch(self, mutagen_file):
|
||||
"""Retrieve the first raw value of this tag from the mediafile."""
|
||||
try:
|
||||
|
|
@ -290,21 +322,20 @@ class StorageStyle(object):
|
|||
except KeyError:
|
||||
return None
|
||||
|
||||
def get(self, mutagen_file):
|
||||
data = self.fetch(mutagen_file)
|
||||
data = self._strip_possible_suffix(data)
|
||||
return data
|
||||
def deserialize(self, mutagen_value):
|
||||
if self.suffix and isinstance(mutagen_value, unicode) \
|
||||
and mutagen_value.endswith(self.suffix):
|
||||
return mutagen_value[:-len(self.suffix)]
|
||||
else:
|
||||
return mutagen_value
|
||||
|
||||
def set(self, mutagen_file, value):
|
||||
self.store(mutagen_file, self.serialize(value))
|
||||
|
||||
def store(self, mutagen_file, value):
|
||||
"""Stores a serialized value in the mediafile."""
|
||||
mutagen_file[self.key] = [value]
|
||||
|
||||
def set(self, mutagen_file, value):
|
||||
if value is None:
|
||||
value = self._none_value()
|
||||
value = self.serialize(value)
|
||||
self.store(mutagen_file, value)
|
||||
|
||||
def serialize(self, value):
|
||||
"""Convert value to a type that is suitable for storing in a tag."""
|
||||
if value is None:
|
||||
|
|
@ -339,13 +370,6 @@ class StorageStyle(object):
|
|||
elif self.out_type == unicode:
|
||||
return u''
|
||||
|
||||
def _strip_possible_suffix(self, data):
|
||||
if self.suffix and isinstance(data, unicode) \
|
||||
and data.endswith(self.suffix):
|
||||
return data[:-len(self.suffix)]
|
||||
else:
|
||||
return data
|
||||
|
||||
|
||||
class ListStorageStyle(StorageStyle):
|
||||
"""Abstract class that provides access to lists.
|
||||
|
|
@ -362,8 +386,7 @@ class ListStorageStyle(StorageStyle):
|
|||
return None
|
||||
|
||||
def get_list(self, mutagen_file):
|
||||
data = self.fetch(mutagen_file)
|
||||
return [self._strip_possible_suffix(item) for item in data]
|
||||
return [self.deserialize(item) for item in self.fetch(mutagen_file)]
|
||||
|
||||
def fetch(self, mutagen_file):
|
||||
try:
|
||||
|
|
@ -415,12 +438,6 @@ class MP4StorageStyle(StorageStyle):
|
|||
|
||||
formats = ['aac', 'alac']
|
||||
|
||||
def fetch(self, mutagen_file):
|
||||
try:
|
||||
return mutagen_file[self.key][0]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def serialize(self, value):
|
||||
value = super(MP4StorageStyle, self).serialize(value)
|
||||
if self.key.startswith('----:') and isinstance(value, unicode):
|
||||
|
|
@ -432,25 +449,24 @@ class MP4TupleStorageStyle(MP4StorageStyle):
|
|||
"""Store values as part of a numeric pair.
|
||||
"""
|
||||
|
||||
def __init__(self, key, pack_pos=0, **kwargs):
|
||||
def __init__(self, key, index=0, **kwargs):
|
||||
super(MP4TupleStorageStyle, self).__init__(key, **kwargs)
|
||||
self.pack_pos = pack_pos
|
||||
self.index = index
|
||||
|
||||
def _fetch_unpacked(self, mutagen_file):
|
||||
items = super(MP4TupleStorageStyle, self).fetch(mutagen_file) or []
|
||||
def deserialize(self, mutagen_value):
|
||||
items = mutagen_value or []
|
||||
packing_length = 2
|
||||
return list(items) + [0] * (packing_length - len(items))
|
||||
|
||||
def get(self, mutagen_file):
|
||||
data = self._fetch_unpacked(mutagen_file)[self.pack_pos]
|
||||
return self._strip_possible_suffix(data)
|
||||
return super(MP4TupleStorageStyle, self).get(mutagen_file)[self.index]
|
||||
|
||||
def set(self, mutagen_file, value):
|
||||
if value is None:
|
||||
value = 0
|
||||
data = self._fetch_unpacked(mutagen_file)
|
||||
data[self.pack_pos] = int(value)
|
||||
self.store(mutagen_file, data)
|
||||
items = self.deserialize(self.fetch(mutagen_file))
|
||||
items[self.index] = int(value)
|
||||
self.store(mutagen_file, items)
|
||||
|
||||
|
||||
class MP4ListStorageStyle(ListStorageStyle, MP4StorageStyle):
|
||||
|
|
@ -485,28 +501,18 @@ class MP4ImageStorageStyle(MP4ListStorageStyle):
|
|||
|
||||
def __init__(self, **kwargs):
|
||||
super(MP4ImageStorageStyle, self).__init__(key='covr', **kwargs)
|
||||
self.as_type = str
|
||||
|
||||
def store(self, mutagen_file, images):
|
||||
covers = [self._mp4_cover(image) for image in images]
|
||||
mutagen_file['covr'] = covers
|
||||
def deserialize(self, data):
|
||||
return Image(data)
|
||||
|
||||
@classmethod
|
||||
def _mp4_cover(cls, data):
|
||||
"""Make ``MP4Cover`` tag from image data.
|
||||
|
||||
Returns instance of ``mutagen.mp4.MP4Cover`` with correct cover
|
||||
format.
|
||||
"""
|
||||
kind = imghdr.what(None, h=data)
|
||||
if kind == 'png':
|
||||
def serialize(self, image):
|
||||
if image.mime_type == 'image/png':
|
||||
kind = mutagen.mp4.MP4Cover.FORMAT_PNG
|
||||
elif kind == 'jpeg':
|
||||
elif image.mime_type == 'image/jpeg':
|
||||
kind = mutagen.mp4.MP4Cover.FORMAT_JPEG
|
||||
else:
|
||||
raise ValueError('MP4 only supports PNG and JPEG images')
|
||||
|
||||
return mutagen.mp4.MP4Cover(data, kind)
|
||||
raise ValueError('The MP4 format only supports PNG and JPEG images')
|
||||
return mutagen.mp4.MP4Cover(image.data, kind)
|
||||
|
||||
|
||||
class MP3StorageStyle(StorageStyle):
|
||||
|
|
@ -612,7 +618,7 @@ class MP3SlashPackStorageStyle(MP3StorageStyle):
|
|||
self.pack_pos = pack_pos
|
||||
|
||||
def _fetch_unpacked(self, mutagen_file):
|
||||
data = super(MP3SlashPackStorageStyle, self).fetch(mutagen_file) or ''
|
||||
data = self.fetch(mutagen_file) or ''
|
||||
items = unicode(data).split('/')
|
||||
packing_length = 2
|
||||
return list(items) + [None] * (packing_length - len(items))
|
||||
|
|
@ -631,28 +637,39 @@ class MP3SlashPackStorageStyle(MP3StorageStyle):
|
|||
|
||||
|
||||
class MP3ImageStorageStyle(ListStorageStyle, MP3StorageStyle):
|
||||
"""Converts between APIC frames and ``Image`` instances.
|
||||
|
||||
The `get_list` method inherited from ``ListStorageStyle`` returns a
|
||||
list of ``Image``s. Similarily the `set_list` method accepts a
|
||||
list of ``Image``s as its ``values`` arguemnt.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(MP3ImageStorageStyle, self).__init__(key='APIC')
|
||||
self.as_type = str
|
||||
|
||||
def fetch(self, mutagen_file):
|
||||
try:
|
||||
frames = mutagen_file.tags.getall(self.key)
|
||||
return [frame.data for frame in frames]
|
||||
except IndexError:
|
||||
return None
|
||||
def deserialize(self, apic_frame):
|
||||
"""Convert APIC frame into Image."""
|
||||
return Image(data=apic_frame.data, desc=apic_frame.desc,
|
||||
type=apic_frame.type)
|
||||
|
||||
def store(self, mutagen_file, images):
|
||||
image = images[0]
|
||||
frame = mutagen.id3.APIC(
|
||||
encoding=3,
|
||||
type=3, # FrontCover
|
||||
mime=ImageField._mime(image),
|
||||
desc=u'',
|
||||
data=image
|
||||
)
|
||||
mutagen_file.tags.setall(self.key, [frame])
|
||||
def fetch(self, mutagen_file):
|
||||
return mutagen_file.tags.getall(self.key)
|
||||
|
||||
def store(self, mutagen_file, frames):
|
||||
mutagen_file.tags.setall(self.key, frames)
|
||||
|
||||
def serialize(self, image):
|
||||
"""Return an APIC frame populated with data from ``image``.
|
||||
"""
|
||||
assert isinstance(image, Image)
|
||||
frame = mutagen.id3.Frames[self.key]()
|
||||
frame.data = image.data
|
||||
frame.mime = image.mime_type
|
||||
frame.desc = (image.desc or u'').encode('utf8')
|
||||
frame.encoding = 3 # UTF-8 encoding of desc
|
||||
frame.type = image.type_index or 3 # front cover
|
||||
return frame
|
||||
|
||||
|
||||
class MP3SoundCheckStorageStyle(SoundCheckStorageStyleMixin, MP3DescStorageStyle):
|
||||
|
|
@ -668,28 +685,17 @@ class ASFImageStorageStyle(ListStorageStyle):
|
|||
|
||||
def __init__(self):
|
||||
super(ASFImageStorageStyle, self).__init__(key='WM/Picture')
|
||||
self.as_type = str
|
||||
|
||||
def fetch(self, mutagen_file):
|
||||
if 'WM/Picture' not in mutagen_file:
|
||||
return []
|
||||
def deserialize(self, asf_picture):
|
||||
mime, data, type, desc = _unpack_asf_image(asf_picture.value)
|
||||
return Image(data, desc=desc, type=type)
|
||||
|
||||
pictures = []
|
||||
for picture in mutagen_file['WM/Picture']:
|
||||
try:
|
||||
pictures.append(_unpack_asf_image(picture.value)[1])
|
||||
except:
|
||||
pass
|
||||
return pictures
|
||||
|
||||
def store(self, mutagen_file, images):
|
||||
if 'WM/Picture' in mutagen_file:
|
||||
del mutagen_file['WM/Picture']
|
||||
|
||||
for image in images:
|
||||
pic = mutagen.asf.ASFByteArrayAttribute()
|
||||
pic.value = _pack_asf_image(ImageField._mime(image), image)
|
||||
mutagen_file['WM/Picture'] = [pic]
|
||||
def serialize(self, image):
|
||||
pic = mutagen.asf.ASFByteArrayAttribute()
|
||||
pic.value = _pack_asf_image(image.mime_type, image.data,
|
||||
type=image.type_index or 3,
|
||||
description=image.desc or u'')
|
||||
return pic
|
||||
|
||||
|
||||
class VorbisImageStorageStyle(ListStorageStyle):
|
||||
|
|
@ -697,24 +703,26 @@ class VorbisImageStorageStyle(ListStorageStyle):
|
|||
formats = ['opus', 'ogg', 'ape', 'wv', 'mpc']
|
||||
|
||||
def __init__(self):
|
||||
super(VorbisImageStorageStyle, self).__init__(key='')
|
||||
super(VorbisImageStorageStyle, self).__init__(
|
||||
key='metadata_block_picture')
|
||||
self.as_type = str
|
||||
|
||||
def fetch(self, mutagen_file):
|
||||
images = []
|
||||
if 'metadata_block_picture' not in mutagen_file:
|
||||
# Try legacy COVERART tags.
|
||||
if 'coverart' in mutagen_file and mutagen_file['coverart']:
|
||||
return [base64.b64decode(data)
|
||||
for data in mutagen_file['coverart']]
|
||||
return []
|
||||
|
||||
pics = []
|
||||
if 'coverart' in mutagen_file:
|
||||
for data in mutagen_file['coverart']:
|
||||
images.append(Image(base64.b64decode(data)))
|
||||
return images
|
||||
for data in mutagen_file["metadata_block_picture"]:
|
||||
try:
|
||||
pics.append(mutagen.flac.Picture(base64.b64decode(data)).data)
|
||||
pic = mutagen.flac.Picture(base64.b64decode(data))
|
||||
except (TypeError, AttributeError):
|
||||
pass
|
||||
return pics
|
||||
continue
|
||||
images.append(Image(data=pic.data, desc=pic.desc,
|
||||
type=pic.type))
|
||||
return images
|
||||
|
||||
def store(self, mutagen_file, image_data):
|
||||
# Strip all art, including legacy COVERART.
|
||||
|
|
@ -722,43 +730,53 @@ class VorbisImageStorageStyle(ListStorageStyle):
|
|||
del mutagen_file['coverart']
|
||||
if 'coverartmime' in mutagen_file:
|
||||
del mutagen_file['coverartmime']
|
||||
super(VorbisImageStorageStyle, self).store(mutagen_file, image_data)
|
||||
|
||||
image_data = image_data[0]
|
||||
# Add new art if provided.
|
||||
if image_data is not None:
|
||||
pic = mutagen.flac.Picture()
|
||||
pic.data = image_data
|
||||
pic.mime = ImageField._mime(image_data)
|
||||
mutagen_file['metadata_block_picture'] = [
|
||||
base64.b64encode(pic.write())
|
||||
]
|
||||
|
||||
def serialize(self, image):
|
||||
"""Turn a Image into a base64 encoded FLAC picture block.
|
||||
"""
|
||||
pic = mutagen.flac.Picture()
|
||||
pic.data = image.data
|
||||
pic.type = image.type_index or 3 # Front cover
|
||||
pic.mime = image.mime_type
|
||||
pic.desc = image.desc or u''
|
||||
return base64.b64encode(pic.write())
|
||||
|
||||
|
||||
class FlacImageStorageStyle(ListStorageStyle):
|
||||
"""Converts between ``mutagen.flac.Picture`` and ``Image`` instances.
|
||||
"""
|
||||
|
||||
formats = ['flac']
|
||||
|
||||
def __init__(self):
|
||||
super(FlacImageStorageStyle, self).__init__(key='')
|
||||
self.as_type = str
|
||||
|
||||
def fetch(self, mutagen_file):
|
||||
pictures = mutagen_file.pictures
|
||||
if pictures:
|
||||
return [picture.data or None for picture in pictures]
|
||||
else:
|
||||
return []
|
||||
return mutagen_file.pictures
|
||||
|
||||
def store(self, mutagen_file, images):
|
||||
def deserialize(self, flac_picture):
|
||||
return Image(data=flac_picture.data, desc=flac_picture.desc,
|
||||
type=flac_picture.type)
|
||||
|
||||
def store(self, mutagen_file, pictures):
|
||||
"""``pictures`` is a list of mutagen.flac.Picture instances.
|
||||
"""
|
||||
mutagen_file.clear_pictures()
|
||||
|
||||
for image in images:
|
||||
pic = mutagen.flac.Picture()
|
||||
pic.data = image
|
||||
pic.type = 3 # front cover
|
||||
pic.mime = ImageField._mime(image)
|
||||
for pic in pictures:
|
||||
mutagen_file.add_picture(pic)
|
||||
|
||||
def serialize(self, image):
|
||||
"""Turn a Image into a mutagen.flac.Picture.
|
||||
"""
|
||||
pic = mutagen.flac.Picture()
|
||||
pic.data = image.data
|
||||
pic.type = image.type_index or 3 # Front cover
|
||||
pic.mime = image.mime_type
|
||||
pic.desc = image.desc or u''
|
||||
return pic
|
||||
|
||||
|
||||
|
||||
# MediaField is a descriptor that represents a single logical field. It
|
||||
|
|
@ -914,47 +932,108 @@ class DateItemField(MediaField):
|
|||
self.date_field._set_date_tuple(mediafile, *items)
|
||||
|
||||
|
||||
class ImageField(MediaField):
|
||||
"""A descriptor providing access to a file's embedded album art.
|
||||
Holds a bytestring reflecting the image data. The image should
|
||||
either be a JPEG or a PNG for cross-format compatibility. It's
|
||||
probably a bad idea to use anything but these two formats.
|
||||
class CoverArtField(MediaField):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def __get__(self, mediafile, _):
|
||||
try:
|
||||
return mediafile.images[0].data
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def __set__(self, mediafile, data):
|
||||
if data:
|
||||
mediafile.images = [Image(data=data)]
|
||||
else:
|
||||
mediafile.images = []
|
||||
|
||||
|
||||
class ImageListField(MediaField):
|
||||
"""Descriptor to access the list of images embedded in tags.
|
||||
|
||||
The getter returns a list of ``Image`` instances obtained from
|
||||
the tags. The setter accepts a list of ``Image`` instances to be
|
||||
written to the tags.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(ImageField, self).__init__(
|
||||
# The storage styles used here must implement the
|
||||
# `ListStorageStyle` interface and get and set lists of
|
||||
# `Image`s.
|
||||
super(ImageListField, self).__init__(
|
||||
MP3ImageStorageStyle(),
|
||||
MP4ImageStorageStyle(),
|
||||
ASFImageStorageStyle(),
|
||||
VorbisImageStorageStyle(),
|
||||
FlacImageStorageStyle(),
|
||||
out_type=str,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _mime(cls, data):
|
||||
"""Return the MIME type (either image/png or image/jpeg) of the
|
||||
image data (a bytestring).
|
||||
"""
|
||||
kind = imghdr.what(None, h=data)
|
||||
if kind == 'png':
|
||||
return 'image/png'
|
||||
else:
|
||||
# Currently just fall back to JPEG.
|
||||
return 'image/jpeg'
|
||||
|
||||
def __get__(self, mediafile, _):
|
||||
images = []
|
||||
for style in self.styles(mediafile):
|
||||
return style.get(mediafile.mgfile)
|
||||
images.extend(style.get_list(mediafile.mgfile))
|
||||
return images
|
||||
|
||||
def __set__(self, mediafile, data):
|
||||
if data is not None:
|
||||
if not isinstance(data, str):
|
||||
raise ValueError('value must be a byte string or None')
|
||||
def __set__(self, mediafile, images):
|
||||
for style in self.styles(mediafile):
|
||||
style.set(mediafile.mgfile, data)
|
||||
style.set_list(mediafile.mgfile, images)
|
||||
|
||||
|
||||
class Image(object):
|
||||
"""Strucuture representing image data and metadata that can be
|
||||
stored and retrieved from tags.
|
||||
|
||||
The structure has four properties.
|
||||
* ``data`` The binary data of the image
|
||||
* ``desc`` An optional descritpion of the image
|
||||
* ``type`` A string denoting the type in relation to the music.
|
||||
Must be one of the ``TYPES`` enum.
|
||||
* ``mime_type`` Read-only property that contains the mime type of
|
||||
the binary data
|
||||
"""
|
||||
|
||||
TYPES = enum([
|
||||
'other',
|
||||
'icon',
|
||||
'other icon',
|
||||
'front',
|
||||
'back',
|
||||
'leaflet',
|
||||
'media',
|
||||
'lead artist',
|
||||
'artist',
|
||||
'conductor',
|
||||
'group',
|
||||
'composer',
|
||||
'lyricist',
|
||||
'recording location',
|
||||
'recording session',
|
||||
'performance',
|
||||
'screen capture',
|
||||
'fish',
|
||||
'illustration',
|
||||
'artist logo',
|
||||
'publisher logo',
|
||||
], name='TageImage.TYPES')
|
||||
|
||||
def __init__(self, data, desc=None, type=None):
|
||||
self.data = data
|
||||
self.desc = desc
|
||||
if isinstance(type, int):
|
||||
type = self.TYPES[type]
|
||||
self.type = type
|
||||
|
||||
@property
|
||||
def mime_type(self):
|
||||
if self.data:
|
||||
return _image_mime_type(self.data)
|
||||
|
||||
@property
|
||||
def type_index(self):
|
||||
if self.type is None:
|
||||
return None
|
||||
return list(self.TYPES).index(self.type)
|
||||
|
||||
# MediaFile is a collection of fields.
|
||||
|
||||
|
|
@ -1107,7 +1186,7 @@ class MediaFile(object):
|
|||
)
|
||||
track = MediaField(
|
||||
MP3SlashPackStorageStyle('TRCK', pack_pos=0),
|
||||
MP4TupleStorageStyle('trkn', pack_pos=0),
|
||||
MP4TupleStorageStyle('trkn', index=0),
|
||||
StorageStyle('TRACK'),
|
||||
StorageStyle('TRACKNUMBER'),
|
||||
ASFStorageStyle('WM/TrackNumber'),
|
||||
|
|
@ -1115,7 +1194,7 @@ class MediaFile(object):
|
|||
)
|
||||
tracktotal = MediaField(
|
||||
MP3SlashPackStorageStyle('TRCK', pack_pos=1),
|
||||
MP4TupleStorageStyle('trkn', pack_pos=1),
|
||||
MP4TupleStorageStyle('trkn', index=1),
|
||||
StorageStyle('TRACKTOTAL'),
|
||||
StorageStyle('TRACKC'),
|
||||
StorageStyle('TOTALTRACKS'),
|
||||
|
|
@ -1124,7 +1203,7 @@ class MediaFile(object):
|
|||
)
|
||||
disc = MediaField(
|
||||
MP3SlashPackStorageStyle('TPOS', pack_pos=0),
|
||||
MP4TupleStorageStyle('disk', pack_pos=0),
|
||||
MP4TupleStorageStyle('disk', index=0),
|
||||
StorageStyle('DISC'),
|
||||
StorageStyle('DISCNUMBER'),
|
||||
ASFStorageStyle('WM/PartOfSet'),
|
||||
|
|
@ -1132,7 +1211,7 @@ class MediaFile(object):
|
|||
)
|
||||
disctotal = MediaField(
|
||||
MP3SlashPackStorageStyle('TPOS', pack_pos=1),
|
||||
MP4TupleStorageStyle('disk', pack_pos=1),
|
||||
MP4TupleStorageStyle('disk', index=1),
|
||||
StorageStyle('DISCTOTAL'),
|
||||
StorageStyle('DISCC'),
|
||||
StorageStyle('TOTALDISCS'),
|
||||
|
|
@ -1300,8 +1379,11 @@ class MediaFile(object):
|
|||
ASFStorageStyle('beets/Album Artist Credit'),
|
||||
)
|
||||
|
||||
# Album art.
|
||||
art = ImageField()
|
||||
# Legacy album art field
|
||||
art = CoverArtField()
|
||||
|
||||
# Image list
|
||||
images = ImageListField()
|
||||
|
||||
# MusicBrainz IDs.
|
||||
mb_trackid = MediaField(
|
||||
|
|
|
|||
Binary file not shown.
BIN
test/rsrc/image-2x3.tiff
Normal file
BIN
test/rsrc/image-2x3.tiff
Normal file
Binary file not shown.
BIN
test/rsrc/image.flac
Normal file
BIN
test/rsrc/image.flac
Normal file
Binary file not shown.
BIN
test/rsrc/image.m4a
Normal file
BIN
test/rsrc/image.m4a
Normal file
Binary file not shown.
BIN
test/rsrc/image.mp3
Normal file
BIN
test/rsrc/image.mp3
Normal file
Binary file not shown.
BIN
test/rsrc/image.ogg
Normal file
BIN
test/rsrc/image.ogg
Normal file
Binary file not shown.
BIN
test/rsrc/image.wma
Normal file
BIN
test/rsrc/image.wma
Normal file
Binary file not shown.
|
|
@ -23,7 +23,7 @@ import time
|
|||
|
||||
import _common
|
||||
from _common import unittest
|
||||
from beets.mediafile import MediaFile
|
||||
from beets.mediafile import MediaFile, Image
|
||||
|
||||
|
||||
class ArtTestMixin(object):
|
||||
|
|
@ -46,6 +46,14 @@ class ArtTestMixin(object):
|
|||
return self._jpg_data
|
||||
_jpg_data = None
|
||||
|
||||
@property
|
||||
def tiff_data(self):
|
||||
if not self._jpg_data:
|
||||
with open(os.path.join(_common.RSRC, 'image-2x3.tiff'), 'rb') as f:
|
||||
self._jpg_data = f.read()
|
||||
return self._jpg_data
|
||||
_jpg_data = None
|
||||
|
||||
def test_set_png_art(self):
|
||||
mediafile = self._mediafile_fixture('empty')
|
||||
mediafile.art = self.png_data
|
||||
|
|
@ -63,6 +71,140 @@ class ArtTestMixin(object):
|
|||
self.assertEqual(mediafile.art, self.jpg_data)
|
||||
|
||||
|
||||
class ImageStructureTestMixin(ArtTestMixin):
|
||||
"""Test reading and writing multiple image tags.
|
||||
|
||||
The tests use the `image` media file fixture. The tags of these files
|
||||
include two images, on in the PNG format, the other in JPEG format. If
|
||||
the tag format supports it they also include additional metadata.
|
||||
"""
|
||||
|
||||
def test_read_image_structures(self):
|
||||
mediafile = self._mediafile_fixture('image')
|
||||
|
||||
self.assertEqual(len(mediafile.images), 2)
|
||||
|
||||
image = mediafile.images[0]
|
||||
self.assertEqual(image.data, self.png_data)
|
||||
self.assertEqual(image.mime_type, 'image/png')
|
||||
self.assertExtendedImageAttributes(image, desc='album cover',
|
||||
type=Image.TYPES.front)
|
||||
|
||||
image = mediafile.images[1]
|
||||
self.assertEqual(image.data, self.jpg_data)
|
||||
self.assertEqual(image.mime_type, 'image/jpeg')
|
||||
self.assertExtendedImageAttributes(image, desc='the artist',
|
||||
type=Image.TYPES.artist)
|
||||
|
||||
def test_set_image_structure(self):
|
||||
mediafile = self._mediafile_fixture('empty')
|
||||
image = Image(data=self.png_data, desc='album cover',
|
||||
type=Image.TYPES.front)
|
||||
mediafile.images = [image]
|
||||
mediafile.save()
|
||||
|
||||
mediafile = MediaFile(mediafile.path)
|
||||
self.assertEqual(len(mediafile.images), 1)
|
||||
|
||||
image = mediafile.images[0]
|
||||
self.assertEqual(image.data, self.png_data)
|
||||
self.assertEqual(image.mime_type, 'image/png')
|
||||
self.assertExtendedImageAttributes(image, desc='album cover',
|
||||
type=Image.TYPES.front)
|
||||
|
||||
def test_add_image_structure(self):
|
||||
mediafile = self._mediafile_fixture('image')
|
||||
self.assertEqual(len(mediafile.images), 2)
|
||||
|
||||
image = Image(data=self.png_data, desc='the composer',
|
||||
type=Image.TYPES.composer)
|
||||
mediafile.images += [image]
|
||||
mediafile.save()
|
||||
|
||||
mediafile = MediaFile(mediafile.path)
|
||||
self.assertEqual(len(mediafile.images), 3)
|
||||
|
||||
# WMA does not preserve the order, so we have to work around this
|
||||
try:
|
||||
image = filter(lambda i: i.desc == 'the composer',
|
||||
mediafile.images)[0]
|
||||
except IndexError:
|
||||
image = None
|
||||
self.assertExtendedImageAttributes(image,
|
||||
desc='the composer', type=Image.TYPES.composer)
|
||||
|
||||
@unittest.skip('editing list by reference is not implemented yet')
|
||||
def test_mutate_image_structure(self):
|
||||
mediafile = self._mediafile_fixture('image')
|
||||
self.assertEqual(len(mediafile.images), 2)
|
||||
|
||||
image = mediafile.images[0]
|
||||
self.assertEqual(image.data, self.png_data)
|
||||
self.assertEqual(image.mime_type, 'image/png')
|
||||
self.assertExtendedImageAttributes(image, desc='album cover',
|
||||
type=Image.TYPES.front)
|
||||
|
||||
image.data = self.jpg_data
|
||||
image.desc = 'new description'
|
||||
image.type = Image.COMPOSER
|
||||
mediafile.save()
|
||||
|
||||
mediafile = MediaFile(mediafile.path)
|
||||
self.assertEqual(len(mediafile.images), 2)
|
||||
|
||||
image = mediafile.images[0]
|
||||
self.assertEqual(image.data, self.jpg_data)
|
||||
self.assertEqual(image.mime_type, 'image/jpeg')
|
||||
self.assertExtendedImageAttributes(image, desc='new description',
|
||||
type=Image.TYPES.composer)
|
||||
|
||||
@unittest.skip('editing list by reference is not implemented yet')
|
||||
def test_delete_image_structure(self):
|
||||
mediafile = self._mediafile_fixture('image')
|
||||
self.assertEqual(len(mediafile.images), 2)
|
||||
|
||||
del mediafile.images[0]
|
||||
mediafile.save()
|
||||
|
||||
mediafile = MediaFile(mediafile.path)
|
||||
self.assertEqual(len(mediafile.images), 1)
|
||||
self.assertEqual(image.data, self.png_data)
|
||||
self.assertEqual(image.mime_type, 'image/jpg')
|
||||
self.assertExtendedImageAttributes(image, desc='the artist',
|
||||
type='performer')
|
||||
|
||||
def assertExtendedImageAttributes(self, image, **kwargs):
|
||||
"""Ignore extended image attributes in the base tests.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ExtendedImageStructureTestMixin(ImageStructureTestMixin):
|
||||
"""Checks for additional attributes in the image structure."""
|
||||
|
||||
def assertExtendedImageAttributes(self, image, desc=None, type=None):
|
||||
self.assertEqual(image.desc, desc)
|
||||
self.assertEqual(image.type, type)
|
||||
|
||||
def test_add_tiff_image(self):
|
||||
mediafile = self._mediafile_fixture('image')
|
||||
self.assertEqual(len(mediafile.images), 2)
|
||||
|
||||
image = Image(data=self.tiff_data, desc='the composer',
|
||||
type=Image.TYPES.composer)
|
||||
mediafile.images += [image]
|
||||
mediafile.save()
|
||||
|
||||
mediafile = MediaFile(mediafile.path)
|
||||
self.assertEqual(len(mediafile.images), 3)
|
||||
|
||||
# WMA does not preserve the order, so we have to work around this
|
||||
image = filter(lambda i: i.mime_type == 'image/tiff',
|
||||
mediafile.images)[0]
|
||||
self.assertExtendedImageAttributes(image,
|
||||
desc='the composer', type=Image.TYPES.composer)
|
||||
|
||||
|
||||
# TODO include this in ReadWriteTestBase if implemented
|
||||
class LazySaveTestMixin(object):
|
||||
"""Mediafile should only write changes when tags have changed
|
||||
|
|
@ -439,7 +581,8 @@ class GenreListTestMixin(object):
|
|||
|
||||
|
||||
class MP3Test(ReadWriteTestBase, PartialTestMixin,
|
||||
GenreListTestMixin, unittest.TestCase):
|
||||
GenreListTestMixin, ExtendedImageStructureTestMixin,
|
||||
unittest.TestCase):
|
||||
extension = 'mp3'
|
||||
audio_properties = {
|
||||
'length': 1.0,
|
||||
|
|
@ -450,7 +593,8 @@ class MP3Test(ReadWriteTestBase, PartialTestMixin,
|
|||
'channels': 1,
|
||||
}
|
||||
class MP4Test(ReadWriteTestBase, PartialTestMixin,
|
||||
GenreListTestMixin, unittest.TestCase):
|
||||
GenreListTestMixin, ImageStructureTestMixin,
|
||||
unittest.TestCase):
|
||||
extension = 'm4a'
|
||||
audio_properties = {
|
||||
'length': 1.0,
|
||||
|
|
@ -460,6 +604,13 @@ class MP4Test(ReadWriteTestBase, PartialTestMixin,
|
|||
'bitdepth': 16,
|
||||
'channels': 2,
|
||||
}
|
||||
|
||||
def test_add_tiff_image_fails(self):
|
||||
mediafile = self._mediafile_fixture('empty')
|
||||
with self.assertRaises(ValueError):
|
||||
mediafile.images = [Image(data=self.tiff_data)]
|
||||
|
||||
|
||||
class AlacTest(ReadWriteTestBase, GenreListTestMixin, unittest.TestCase):
|
||||
extension = 'alac.m4a'
|
||||
audio_properties = {
|
||||
|
|
@ -480,7 +631,8 @@ class MusepackTest(ReadWriteTestBase, GenreListTestMixin, unittest.TestCase):
|
|||
'bitdepth': 0,
|
||||
'channels': 2,
|
||||
}
|
||||
class WMATest(ReadWriteTestBase, unittest.TestCase):
|
||||
class WMATest(ReadWriteTestBase, ExtendedImageStructureTestMixin,
|
||||
unittest.TestCase):
|
||||
extension = 'wma'
|
||||
audio_properties = {
|
||||
'length': 1.0,
|
||||
|
|
@ -490,7 +642,8 @@ class WMATest(ReadWriteTestBase, unittest.TestCase):
|
|||
'bitdepth': 0,
|
||||
'channels': 1,
|
||||
}
|
||||
class OggTest(ReadWriteTestBase, GenreListTestMixin, unittest.TestCase):
|
||||
class OggTest(ReadWriteTestBase, GenreListTestMixin,
|
||||
ExtendedImageStructureTestMixin, unittest.TestCase):
|
||||
extension = 'ogg'
|
||||
audio_properties = {
|
||||
'length': 1.0,
|
||||
|
|
@ -526,7 +679,8 @@ class OggTest(ReadWriteTestBase, GenreListTestMixin, unittest.TestCase):
|
|||
self.assertFalse('coverart' in mediafile.mgfile)
|
||||
|
||||
class FlacTest(ReadWriteTestBase, PartialTestMixin,
|
||||
GenreListTestMixin, unittest.TestCase):
|
||||
GenreListTestMixin, ExtendedImageStructureTestMixin,
|
||||
unittest.TestCase):
|
||||
extension = 'flac'
|
||||
audio_properties = {
|
||||
'length': 1.0,
|
||||
|
|
|
|||
Loading…
Reference in a new issue