Repository: beetbox/beets

Issue:
Non-determinism of MediaFile.art and embedart plugin
@mried work on #1328 brought to light the fact that the `extractart` plugin relies on `MediaFile.art` which is as "legacy album art field", the "first image on a file". However this seems non-deterministic (sometime returns a 2nd+ image) especially on python 2.6 (sigh).

Moreover `extractart` works with this field, while it could work with `images`, extracting all arts from a file (`MediaFile.images`).

The questions raised are:
- how does MediaFile determines the `art` property?
- should the `art` property be kept? It seems used by the plugins `scrub` and `embedart`
- shouldn't `embedart` manage all art files?



Discussion:
I believe the images embedded into mp3 can be tagged with image types like 'Front cover' or 'Back cover' or ... Maybe `embedart` should take care of this type? (I don't know if `MediaFile` is able to read the types.)

The images do have a description, which can be used to determine the main art file (= front cover). `MediaFile` knows about it, as well as the mime type. 

Given that the `art` property is used in some `beets` core, I think determining the main art should be the responsibility of `beets.mediafile`.

`MP3ImageStorageStyle.fetch()` is not deterministic in the order of the images returned: it works on a dict (`mutagen_file.tags`) and returns a list, we cannot predict what will be the first image (which is to be the art).

Thanks for uncovering this. Yes, the `art` field should be deterministic; and I agree with the approach of using the front cover.

I don't mind keeping the `art` property currently. Barring a sweeping change for supporting multiple art files everywhere, the status quo seems fine (and "legacy" is perhaps too strong a label, which we applied prematurely).


Resolving patch:
diff --git a/beets/mediafile.py b/beets/mediafile.py
--- a/beets/mediafile.py
+++ b/beets/mediafile.py
@@ -1246,18 +1246,31 @@ def __delete__(self, mediafile):
 
 class CoverArtField(MediaField):
     """A descriptor that provides access to the *raw image data* for the
-    first image on a file. This is used for backwards compatibility: the
+    cover image on a file. This is used for backwards compatibility: the
     full `ImageListField` provides richer `Image` objects.
+
+    When there are multiple images we try to pick the most likely to be a front
+    cover.
     """
     def __init__(self):
         pass
 
     def __get__(self, mediafile, _):
-        try:
-            return mediafile.images[0].data
-        except IndexError:
+        candidates = mediafile.images
+        if candidates:
+            return self.guess_cover_image(candidates).data
+        else:
             return None
 
+    @staticmethod
+    def guess_cover_image(candidates):
+        if len(candidates) == 1:
+            return candidates[0]
+        try:
+            return next(c for c in candidates if c.type == ImageType.front)
+        except StopIteration:
+            return candidates[0]
+
     def __set__(self, mediafile, data):
         if data:
             mediafile.images = [Image(data=data)]


Test patch:
diff --git a/test/test_embedart.py b/test/test_embedart.py
--- a/test/test_embedart.py
+++ b/test/test_embedart.py
@@ -124,11 +124,8 @@ def test_non_ascii_album_path(self):
 
         self.run_command('extractart', '-n', 'extracted')
 
-        path = os.path.join(albumpath.decode('utf-8'), 'extracted.')
-        found = any(map(lambda ext: os.path.exists(path + ext),
-                        ['png', 'jpeg', 'jpg']))
-
-        self.assertTrue(found, 'Extracted image was not found!')
+        self.assertExists(os.path.join(albumpath.decode('utf-8'),
+                                       'extracted.png'))
 
 
 class EmbedartTest(unittest.TestCase):
diff --git a/test/test_mediafile.py b/test/test_mediafile.py
--- a/test/test_mediafile.py
+++ b/test/test_mediafile.py
@@ -28,7 +28,7 @@
 from test._common import unittest
 from beets.mediafile import MediaFile, MediaField, Image, \
     MP3DescStorageStyle, StorageStyle, MP4StorageStyle, \
-    ASFStorageStyle, ImageType
+    ASFStorageStyle, ImageType, CoverArtField
 from beets.library import Item
 from beets.plugins import BeetsPlugin
 
@@ -161,6 +161,13 @@ def test_delete_image_structures(self):
         mediafile = MediaFile(mediafile.path)
         self.assertEqual(len(mediafile.images), 0)
 
+    def test_guess_cover(self):
+        mediafile = self._mediafile_fixture('image')
+        self.assertEqual(len(mediafile.images), 2)
+        cover = CoverArtField.guess_cover_image(mediafile.images)
+        self.assertEqual(cover.desc, 'album cover')
+        self.assertEqual(mediafile.art, cover.data)
+
     def assertExtendedImageAttributes(self, image, **kwargs):
         """Ignore extended image attributes in the base tests.
         """
@@ -662,7 +669,7 @@ def assertTags(self, mediafile, tags):
                 errors.append('Tag %s does not exist' % key)
             else:
                 if value2 != value:
-                    errors.append('Tag %s: %s != %s' % (key, value2, value))
+                    errors.append('Tag %s: %r != %r' % (key, value2, value))
         if any(errors):
             errors = ['Tags did not match'] + errors
             self.fail('\n  '.join(errors))
@@ -758,6 +765,10 @@ def test_add_tiff_image_fails(self):
         with self.assertRaises(ValueError):
             mediafile.images = [Image(data=self.tiff_data)]
 
+    def test_guess_cover(self):
+        # There is no metadata associated with images, we pick one at random
+        pass
+
 
 class AlacTest(ReadWriteTestBase, unittest.TestCase):
     extension = 'alac.m4a'