From 0b578a3384f21dc81ea924aff5b05ef7870bd5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arsen=20Arsenovi=C4=87?= Date: Tue, 2 Nov 2021 12:51:27 +0100 Subject: [PATCH 1/3] fetchart: add option to force cover format --- beets/util/artresizer.py | 107 ++++++++++++++++++++++++++++++++++++++- beetsplug/fetchart.py | 27 +++++++++- 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index f9381f6c44..8683e2287f 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -18,6 +18,7 @@ import subprocess import os +import os.path import re from tempfile import NamedTemporaryFile from urllib.parse import urlencode @@ -234,6 +235,72 @@ def im_deinterlace(path_in, path_out=None): } +def im_get_format(filepath): + cmd = ArtResizer.shared.im_identify_cmd + [ + '-format', '%[magick]', + util.syspath(filepath) + ] + + try: + return util.command_output(cmd).stdout + except subprocess.CalledProcessError: + return None + + +def pil_get_format(filepath): + from PIL import Image, UnidentifiedImageError + + try: + with Image.open(util.syspath(filepath)) as im: + return im.format + except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError): + log.exception("failed to detect image format for {}", filepath) + return None + + +BACKEND_GET_FORMAT = { + PIL: pil_get_format, + IMAGEMAGICK: im_get_format, +} + + +def im_convert_format(source, target, deinterlaced): + cmd = ArtResizer.shared.im_convert_cmd + [ + util.syspath(source), + *(["-interlace", "none"] if deinterlaced else []), + util.syspath(target), + ] + + try: + subprocess.check_call( + cmd, + stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL + ) + return target + except subprocess.CalledProcessError: + return source + + +def pil_convert_format(source, target, deinterlaced): + from PIL import Image, UnidentifiedImageError + + try: + with Image.open(util.syspath(source)) as im: + im.save(util.py3_path(target), progressive=not deinterlaced) + return target + except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError, + OSError): + log.exception("failed to convert image {} -> {}", source, target) + return source + + +BACKEND_CONVERT_IMAGE_FORMAT = { + PIL: pil_convert_format, + IMAGEMAGICK: im_convert_format, +} + + class Shareable(type): """A pseudo-singleton metaclass that allows both shared and non-shared instances. The ``MyClass.shared`` property holds a @@ -318,12 +385,50 @@ def get_size(self, path_in): """Return the size of an image file as an int couple (width, height) in pixels. - Only available locally + Only available locally. """ if self.local: func = BACKEND_GET_SIZE[self.method[0]] return func(path_in) + def get_format(self, path_in): + """Returns the format of the image as a string. + + Only available locally. + """ + if self.local: + func = BACKEND_GET_FORMAT[self.method[0]] + return func(path_in) + + def reformat(self, path_in, new_format, deinterlaced=True): + """Converts image to desired format, updating its extension, but + keeping the same filename. + + Only available locally. + """ + if not self.local: + return path_in + + new_format = new_format.lower() + # A nonexhaustive map of image "types" to extensions overrides + new_format = { + 'jpeg': 'jpg', + }.get(new_format, new_format) + + fname, ext = os.path.splitext(path_in) + path_new = fname + b'.' + new_format.encode('utf8') + func = BACKEND_CONVERT_IMAGE_FORMAT[self.method[0]] + + # allows the exception to propagate, while still making sure a changed + # file path was removed + result_path = path_in + try: + result_path = func(path_in, path_new, deinterlaced) + finally: + if result_path != path_in: + os.unlink(path_in) + return result_path + def _can_compare(self): """A boolean indicating whether image comparison is available""" diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 574e8dae13..f2c1e5a7aa 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -50,6 +50,7 @@ class Candidate: CANDIDATE_DOWNSCALE = 2 CANDIDATE_DOWNSIZE = 3 CANDIDATE_DEINTERLACE = 4 + CANDIDATE_REFORMAT = 5 MATCH_EXACT = 0 MATCH_FALLBACK = 1 @@ -74,12 +75,14 @@ def _validate(self, plugin): Return `CANDIDATE_DOWNSIZE` if the file must be resized, and possibly also rescaled. Return `CANDIDATE_DEINTERLACE` if the file must be deinterlaced. + Return `CANDIDATE_REFORMAT` if the file has to be converted. """ if not self.path: return self.CANDIDATE_BAD if (not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth - or plugin.max_filesize or plugin.deinterlace)): + or plugin.max_filesize or plugin.deinterlace + or plugin.cover_format)): return self.CANDIDATE_EXACT # get_size returns None if no local imaging backend is available @@ -142,12 +145,23 @@ def _validate(self, plugin): filesize, plugin.max_filesize) downsize = True + # Check image format + reformat = False + if plugin.cover_format: + fmt = ArtResizer.shared.get_format(self.path) + reformat = fmt != plugin.cover_format + if reformat: + self._log.debug('image needs reformatting: {} -> {}', + fmt, plugin.cover_format) + if downscale: return self.CANDIDATE_DOWNSCALE elif downsize: return self.CANDIDATE_DOWNSIZE elif plugin.deinterlace: return self.CANDIDATE_DEINTERLACE + elif reformat: + return self.CANDIDATE_REFORMAT else: return self.CANDIDATE_EXACT @@ -169,6 +183,12 @@ def resize(self, plugin): max_filesize=plugin.max_filesize) elif self.check == self.CANDIDATE_DEINTERLACE: self.path = ArtResizer.shared.deinterlace(self.path) + elif self.check == self.CANDIDATE_REFORMAT: + self.path = ArtResizer.shared.reformat( + self.path, + plugin.cover_format, + deinterlaced=plugin.deinterlace, + ) def _logged_get(log, *args, **kwargs): @@ -923,6 +943,7 @@ def __init__(self): 'store_source': False, 'high_resolution': False, 'deinterlace': False, + 'cover_format': None, }) self.config['google_key'].redact = True self.config['fanarttv_key'].redact = True @@ -959,6 +980,10 @@ def __init__(self): self.src_removed = (config['import']['delete'].get(bool) or config['import']['move'].get(bool)) + self.cover_format = self.config['cover_format'].get( + confuse.Optional(str) + ) + if self.config['auto']: # Enable two import hooks when fetching is enabled. self.import_stages = [self.fetch_art] From 96be1840e3c7f8f757520528601ddc82ab82cbd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arsen=20Arsenovi=C4=87?= Date: Tue, 2 Nov 2021 13:27:25 +0100 Subject: [PATCH 2/3] docs: add fetchart cover_format option --- docs/plugins/fetchart.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index 5df6c6e34c..997cf24974 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -90,6 +90,10 @@ file. The available options are: instructed to store cover art as non-progressive JPEG. You might need this if you use DAPs that don't support progressive images. Default: ``no``. +- **cover_format**: If enabled, forced the cover image into the specified + format. Most often, this will be either ``JPEG`` or ``PNG`` [#imgformats]_. + Also respects ``deinterlace``. + Default: None (leave unchanged). Note: ``maxwidth`` and ``enforce_ratio`` options require either `ImageMagick`_ or `Pillow`_. @@ -105,6 +109,12 @@ or `Pillow`_. .. _beets custom search engine: https://cse.google.com.au:443/cse/publicurl?cx=001442825323518660753:hrh5ch1gjzm .. _Pillow: https://github.com/python-pillow/Pillow .. _ImageMagick: https://www.imagemagick.org/ +.. [#imgformats] Other image formats are available, though the full list + depends on your system and what backend you are using. If you're using the + ImageMagick backend, you can use ``magick identify -list format`` to get a + full list of all supported formats, and you can use the Python function + PIL.features.pilinfo() to print a list of all supported formats in Pillow + (``python3 -c 'import PIL.features as f; f.pilinfo()'``). Here's an example that makes plugin select only images that contain ``front`` or ``back`` keywords in their filenames and prioritizes the iTunes source over From 3de657403a18c33c817bd88758da6c7b5cb4cea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arsen=20Arsenovi=C4=87?= Date: Tue, 2 Nov 2021 13:30:12 +0100 Subject: [PATCH 3/3] changelog: add entry about fetch_art cover_format --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7a6e1a4b63..1b4590f94c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -49,6 +49,8 @@ Other new things: * :doc:`/plugins/fetchart`: A new option to store cover art as non-progressive image. Useful for DAPs that support progressive images. Set ``deinterlace: yes`` in your configuration to enable. +* :doc:`/plugins/fetchart`: A new option to change cover art format. Useful for + DAPs that do not support some image formats. For plugin developers: