diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index c57918f16..0053dd7c0 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -64,12 +64,13 @@ def temp_file_for(path): return util.bytestring_path(f.name) -def pil_resize(maxwidth, path_in, path_out=None, quality=0): +def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0): """Resize using Python Imaging Library (PIL). Return the output path of resized image. """ path_out = path_out or temp_file_for(path_in) from PIL import Image + log.debug(u'artresizer: PIL resizing {0} to {1}', util.displayable_path(path_in), util.displayable_path(path_out)) @@ -83,14 +84,43 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0): quality = -1 im.save(util.py3_path(path_out), quality=quality) - return path_out + if max_filesize > 0: + # If maximum filesize is set, we attempt to lower the quality of + # jpeg conversion by a proportional amount, up to 3 attempts + # First, set the maximum quality to either provided, or 95 + if quality > 0: + lower_qual = quality + else: + lower_qual = 95 + for i in range(5): + # 3 attempts is an abitrary choice + filesize = os.stat(util.syspath(path_out)).st_size + log.debug(u"PIL Pass {0} : Output size: {1}B", i, filesize) + if filesize <= max_filesize: + return path_out + # The relationship between filesize & quality will be + # image dependent. + lower_qual -= 10 + # Restrict quality dropping below 10 + if lower_qual < 10: + lower_qual = 10 + # Use optimize flag to improve filesize decrease + im.save( + util.py3_path(path_out), quality=lower_qual, optimize=True + ) + log.warning(u"PIL Failed to resize file to below {0}B", + max_filesize) + return path_out + + else: + return path_out except IOError: log.error(u"PIL cannot create thumbnail for '{0}'", util.displayable_path(path_in)) return path_in -def im_resize(maxwidth, path_in, path_out=None, quality=0): +def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0): """Resize using ImageMagick. Use the ``magick`` program or ``convert`` on older versions. Return @@ -103,6 +133,9 @@ def im_resize(maxwidth, path_in, path_out=None, quality=0): # "-resize WIDTHx>" shrinks images with the width larger # than the given width while maintaining the aspect ratio # with regards to the height. + + # "-define jpeg:extent=SIZEb" sets the target filesize + # for imagemagick to SIZE in bytes cmd = ArtResizer.shared.im_convert_cmd + [ util.syspath(path_in, prefix=False), '-resize', '{0}x>'.format(maxwidth), @@ -111,6 +144,9 @@ def im_resize(maxwidth, path_in, path_out=None, quality=0): if quality > 0: cmd += ['-quality', '{0}'.format(quality)] + if max_filesize > 0: + cmd += ['-define', 'jpeg:extent={0}b'.format(max_filesize)] + cmd.append(util.syspath(path_out, prefix=False)) try: @@ -131,6 +167,7 @@ BACKEND_FUNCS = { def pil_getsize(path_in): from PIL import Image + try: im = Image.open(util.syspath(path_in)) return im.size @@ -171,6 +208,7 @@ class Shareable(type): lazily-created shared instance of ``MyClass`` while calling ``MyClass()`` to construct a new object works as usual. """ + def __init__(cls, name, bases, dict): super(Shareable, cls).__init__(name, bases, dict) cls._instance = None @@ -205,7 +243,9 @@ class ArtResizer(six.with_metaclass(Shareable, object)): self.im_convert_cmd = ['magick'] self.im_identify_cmd = ['magick', 'identify'] - def resize(self, maxwidth, path_in, path_out=None, quality=0): + def resize( + self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0 + ): """Manipulate an image file according to the method, returning a new path. For PIL or IMAGEMAGIC methods, resizes the image to a temporary file and encodes with the specified quality level. @@ -213,7 +253,8 @@ class ArtResizer(six.with_metaclass(Shareable, object)): """ if self.local: func = BACKEND_FUNCS[self.method[0]] - return func(maxwidth, path_in, path_out, quality=quality) + return func(maxwidth, path_in, path_out, + quality=quality, max_filesize=max_filesize) else: return path_in diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 1bf8ad428..37db35870 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -51,6 +51,7 @@ class Candidate(object): CANDIDATE_BAD = 0 CANDIDATE_EXACT = 1 CANDIDATE_DOWNSCALE = 2 + CANDIDATE_DOWNSIZE = 3 MATCH_EXACT = 0 MATCH_FALLBACK = 1 @@ -71,12 +72,14 @@ class Candidate(object): Return `CANDIDATE_BAD` if the file is unusable. Return `CANDIDATE_EXACT` if the file is usable as-is. - Return `CANDIDATE_DOWNSCALE` if the file must be resized. + Return `CANDIDATE_DOWNSCALE` if the file must be rescaled. + Return `CANDIDATE_DOWNSIZE` if the file must be resized. """ if not self.path: return self.CANDIDATE_BAD - if not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth): + if (not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth + or plugin.max_filesize)): return self.CANDIDATE_EXACT # get_size returns None if no local imaging backend is available @@ -94,7 +97,7 @@ class Candidate(object): short_edge = min(self.size) long_edge = max(self.size) - # Check minimum size. + # Check minimum dimension. if plugin.minwidth and self.size[0] < plugin.minwidth: self._log.debug(u'image too small ({} < {})', self.size[0], plugin.minwidth) @@ -122,22 +125,44 @@ class Candidate(object): self.size[0], self.size[1]) return self.CANDIDATE_BAD - # Check maximum size. + # Check maximum dimension. + downscale = False if plugin.maxwidth and self.size[0] > plugin.maxwidth: - self._log.debug(u'image needs resizing ({} > {})', + self._log.debug(u'image needs rescaling ({} > {})', self.size[0], plugin.maxwidth) - return self.CANDIDATE_DOWNSCALE + downscale = True - return self.CANDIDATE_EXACT + # Check filesize. + filesize = os.stat(syspath(self.path)).st_size + downsize = False + if plugin.max_filesize and filesize > plugin.max_filesize: + self._log.debug(u'image needs resizing ({}B > {}B)', + filesize, plugin.max_filesize) + downsize = True + + if downscale: + return self.CANDIDATE_DOWNSCALE + elif downsize: + return self.CANDIDATE_DOWNSIZE + else: + return self.CANDIDATE_EXACT def validate(self, plugin): self.check = self._validate(plugin) return self.check def resize(self, plugin): - if plugin.maxwidth and self.check == self.CANDIDATE_DOWNSCALE: - self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path, - quality=plugin.quality) + if self.check == self.CANDIDATE_DOWNSCALE: + self.path = \ + ArtResizer.shared.resize(plugin.maxwidth, self.path, + quality=plugin.quality, + max_filesize=plugin.max_filesize) + elif self.check == self.CANDIDATE_DOWNSIZE: + # dimensions are correct, so maxwidth is set to maximum dimension + self.path = \ + ArtResizer.shared.resize(max(self.size), self.path, + quality=plugin.quality, + max_filesize=plugin.max_filesize) def _logged_get(log, *args, **kwargs): @@ -892,6 +917,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): 'minwidth': 0, 'maxwidth': 0, 'quality': 0, + 'max_filesize': 0, 'enforce_ratio': False, 'cautious': False, 'cover_names': ['cover', 'front', 'art', 'album', 'folder'], @@ -910,6 +936,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): self.minwidth = self.config['minwidth'].get(int) self.maxwidth = self.config['maxwidth'].get(int) + self.max_filesize = self.config['max_filesize'].get(int) self.quality = self.config['quality'].get(int) # allow both pixel and percentage-based margin specifications diff --git a/docs/changelog.rst b/docs/changelog.rst index e2a62e6d8..ea032bb95 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -25,9 +25,9 @@ New features: * A new :ref:`extra_tags` configuration option allows more tagged metadata to be included in MusicBrainz queries. * A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets -* :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality`` - option that controls the quality of the image output when the image is - resized. +* :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added new ``quality`` option + that controls the quality of the output when an image is resized. Additionally, + the new ``max_filesize`` option for fetchart can be used to target a maximum image filesize. * :doc:`plugins/keyfinder`: Added support for `keyfinder-cli`_ Thanks to :user:`BrainDamage`. * :doc:`plugins/fetchart`: Added a new ``high_resolution`` config option to diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index 168ca0fa0..6344c1562 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -49,6 +49,13 @@ file. The available options are: estimate the input image quality and uses 92 if it cannot be determined, and PIL defaults to 75. Default: 0 (disabled) +- **max_filesize**: The maximum size of a target piece of cover art in bytes. + When using an ImageMagick backend this sets + ``-define jpeg:extent=max_filesize``. Using PIL this will reduce JPG quality + by up to 50% to attempt to reach the target filesize. Neither method is + *guaranteed* to reach the target size, however in most cases it should + succeed. + Default: 0 (disabled) - **enforce_ratio**: Only images with a width:height ratio of 1:1 are considered as valid album art candidates if set to ``yes``. It is also possible to specify a certain deviation to the exact ratio to diff --git a/test/test_art.py b/test/test_art.py index 51e5a9fe8..d84ca4a91 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -742,14 +742,17 @@ class ArtImporterTest(UseThePlugin): class ArtForAlbumTest(UseThePlugin): - """ Tests that fetchart.art_for_album respects the size - configuration (e.g., minwidth, enforce_ratio) + """ Tests that fetchart.art_for_album respects the scale & filesize + configurations (e.g., minwidth, enforce_ratio, max_filesize) """ IMG_225x225 = os.path.join(_common.RSRC, b'abbey.jpg') IMG_348x348 = os.path.join(_common.RSRC, b'abbey-different.jpg') IMG_500x490 = os.path.join(_common.RSRC, b'abbey-similar.jpg') + IMG_225x225_SIZE = os.stat(util.syspath(IMG_225x225)).st_size + IMG_348x348_SIZE = os.stat(util.syspath(IMG_348x348)).st_size + def setUp(self): super(ArtForAlbumTest, self).setUp() @@ -839,6 +842,29 @@ class ArtForAlbumTest(UseThePlugin): self._assertImageResized(self.IMG_225x225, False) self._assertImageResized(self.IMG_348x348, True) + def test_fileresize(self): + self._require_backend() + self.plugin.max_filesize = self.IMG_225x225_SIZE // 2 + self._assertImageResized(self.IMG_225x225, True) + + def test_fileresize_if_necessary(self): + self._require_backend() + self.plugin.max_filesize = self.IMG_225x225_SIZE + self._assertImageResized(self.IMG_225x225, False) + self._assertImageIsValidArt(self.IMG_225x225, True) + + def test_fileresize_no_scale(self): + self._require_backend() + self.plugin.maxwidth = 300 + self.plugin.max_filesize = self.IMG_225x225_SIZE // 2 + self._assertImageResized(self.IMG_225x225, True) + + def test_fileresize_and_scale(self): + self._require_backend() + self.plugin.maxwidth = 200 + self.plugin.max_filesize = self.IMG_225x225_SIZE // 2 + self._assertImageResized(self.IMG_225x225, True) + class DeprecatedConfigTest(_common.TestCase): """While refactoring the plugin, the remote_priority option was deprecated, diff --git a/test/test_art_resize.py b/test/test_art_resize.py new file mode 100644 index 000000000..f7090d5e7 --- /dev/null +++ b/test/test_art_resize.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2020, David Swarbrick. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Tests for image resizing based on filesize.""" + +from __future__ import division, absolute_import, print_function + + +import unittest +import os + +from test import _common +from test.helper import TestHelper +from beets.util import syspath +from beets.util.artresizer import ( + pil_resize, + im_resize, + get_im_version, + get_pil_version, +) + + +class ArtResizerFileSizeTest(_common.TestCase, TestHelper): + """Unittest test case for Art Resizer to a specific filesize.""" + + IMG_225x225 = os.path.join(_common.RSRC, b"abbey.jpg") + IMG_225x225_SIZE = os.stat(syspath(IMG_225x225)).st_size + + def setUp(self): + """Called before each test, setting up beets.""" + self.setup_beets() + + def tearDown(self): + """Called after each test, unloading all plugins.""" + self.teardown_beets() + + def _test_img_resize(self, resize_func): + """Test resizing based on file size, given a resize_func.""" + # Check quality setting unaffected by new parameter + im_95_qual = resize_func( + 225, + self.IMG_225x225, + quality=95, + max_filesize=0, + ) + # check valid path returned - max_filesize hasn't broken resize command + self.assertExists(im_95_qual) + + # Attempt a lower filesize with same quality + im_a = resize_func( + 225, + self.IMG_225x225, + quality=95, + max_filesize=0.9 * os.stat(syspath(im_95_qual)).st_size, + ) + self.assertExists(im_a) + # target size was achieved + self.assertLess(os.stat(syspath(im_a)).st_size, + os.stat(syspath(im_95_qual)).st_size) + + # Attempt with lower initial quality + im_75_qual = resize_func( + 225, + self.IMG_225x225, + quality=75, + max_filesize=0, + ) + self.assertExists(im_75_qual) + + im_b = resize_func( + 225, + self.IMG_225x225, + quality=95, + max_filesize=0.9 * os.stat(syspath(im_75_qual)).st_size, + ) + self.assertExists(im_b) + # Check high (initial) quality still gives a smaller filesize + self.assertLess(os.stat(syspath(im_b)).st_size, + os.stat(syspath(im_75_qual)).st_size) + + @unittest.skipUnless(get_pil_version(), "PIL not available") + def test_pil_file_resize(self): + """Test PIL resize function is lowering file size.""" + self._test_img_resize(pil_resize) + + @unittest.skipUnless(get_im_version(), "ImageMagick not available") + def test_im_file_resize(self): + """Test IM resize function is lowering file size.""" + self._test_img_resize(im_resize) + + +def suite(): + """Run this suite of tests.""" + return unittest.TestLoader().loadTestsFromName(__name__) + + +if __name__ == "__main__": + unittest.main(defaultTest="suite")