diff --git a/beets/mediafile.py b/beets/mediafile.py index 0d97b9ac2..ae6385649 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -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( diff --git a/test/rsrc/full.flac b/test/rsrc/full.flac index f4c803ed7..abc18ac30 100644 Binary files a/test/rsrc/full.flac and b/test/rsrc/full.flac differ diff --git a/test/rsrc/image-2x3.tiff b/test/rsrc/image-2x3.tiff new file mode 100644 index 000000000..289fe91bf Binary files /dev/null and b/test/rsrc/image-2x3.tiff differ diff --git a/test/rsrc/image.flac b/test/rsrc/image.flac new file mode 100644 index 000000000..083482848 Binary files /dev/null and b/test/rsrc/image.flac differ diff --git a/test/rsrc/image.m4a b/test/rsrc/image.m4a new file mode 100644 index 000000000..89704d557 Binary files /dev/null and b/test/rsrc/image.m4a differ diff --git a/test/rsrc/image.mp3 b/test/rsrc/image.mp3 new file mode 100644 index 000000000..c15153729 Binary files /dev/null and b/test/rsrc/image.mp3 differ diff --git a/test/rsrc/image.ogg b/test/rsrc/image.ogg new file mode 100644 index 000000000..4852448e2 Binary files /dev/null and b/test/rsrc/image.ogg differ diff --git a/test/rsrc/image.wma b/test/rsrc/image.wma new file mode 100644 index 000000000..2158978f3 Binary files /dev/null and b/test/rsrc/image.wma differ diff --git a/test/test_mediafile.py b/test/test_mediafile.py index 79e3b7d5b..bbab249d8 100644 --- a/test/test_mediafile.py +++ b/test/test_mediafile.py @@ -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,