diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 6074c557b..3448d104f 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -22,6 +22,7 @@ import shutil import fnmatch from collections import defaultdict import traceback +import subprocess MAX_FILENAME_LENGTH = 200 WINDOWS_MAGIC_PREFIX = u'\\\\?\\' @@ -573,3 +574,20 @@ def cpu_count(): return num else: return 1 + +def command_output(cmd): + """Wraps the `subprocess` module to invoke a command (given as a + list of arguments starting with the command name) and collect + stdout. The stderr stream is ignored. May raise + `subprocess.CalledProcessError` or an `OSError`. + + This replaces `subprocess.check_output`, which isn't available in + Python 2.6 and which can have problems if lots of output is sent to + stderr. + """ + with open(os.devnull, 'w') as devnull: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=devnull) + stdout, _ = proc.communicate() + if proc.returncode: + raise subprocess.CalledProcessError(proc.returncode, cmd) + return stdout diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py new file mode 100644 index 000000000..22ed6359d --- /dev/null +++ b/beets/util/artresizer.py @@ -0,0 +1,199 @@ +# This file is part of beets. +# Copyright 2012, Fabrice Laporte +# +# 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. + +"""Abstraction layer to resize images using PIL, ImageMagick, or a +public resizing proxy if neither is available. +""" +import urllib +import subprocess +import os +from tempfile import NamedTemporaryFile +import logging +from beets import util + +# Resizing methods +PIL = 1 +IMAGEMAGICK = 2 +WEBPROXY = 3 + +PROXY_URL = 'http://images.weserv.nl/' + +log = logging.getLogger('beets') + + +class ArtResizerError(Exception): + """Raised when an error occurs during image resizing. + """ + + +def call(args): + """Execute the command indicated by `args` (a list of strings) and + return the command's output. The stderr stream is ignored. If the + command exits abnormally, a ArtResizerError is raised. + """ + try: + return util.command_output(args) + except subprocess.CalledProcessError as e: + raise ArtResizerError( + "{0} exited with status {1}".format(args[0], e.returncode) + ) + + +def resize_url(url, maxwidth): + """Return a proxied image URL that resizes the original image to + maxwidth (preserving aspect ratio). + """ + return '{0}?{1}'.format(PROXY_URL, urllib.urlencode({ + 'url': url.replace('http://',''), + 'w': str(maxwidth), + })) + + +def temp_file_for(path): + """Return an unused filename with the same extension as the + specified path. + """ + ext = os.path.splitext(path)[1] + with NamedTemporaryFile(suffix=ext, delete=False) as f: + return f.name + + +def pil_resize(maxwidth, path_in, path_out=None): + """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}'.format( + util.displayable_path(path_in), util.displayable_path(path_out) + )) + + try: + im = Image.open(util.syspath(path_in)) + size = maxwidth, maxwidth + im.thumbnail(size, Image.ANTIALIAS) + im.save(path_out) + return path_out + except IOError: + log.error(u"PIL cannot create thumbnail for '{0}'".format( + util.displayable_path(path_in) + )) + return path_in + + +def im_resize(maxwidth, path_in, path_out=None): + """Resize using ImageMagick's ``convert`` tool. + tool. Return the output path of resized image. + """ + path_out = path_out or temp_file_for(path_in) + log.debug(u'artresizer: ImageMagick resizing {0} to {1}'.format( + util.displayable_path(path_in), util.displayable_path(path_out) + )) + + # "-resize widthxheight>" shrinks images with dimension(s) larger + # than the corresponding width and/or height dimension(s). The > + # "only shrink" flag is prefixed by ^ escape char for Windows + # compatability. + call([ + 'convert', util.syspath(path_in), + '-resize', '{0}x^>'.format(maxwidth), path_out + ]) + return path_out + + +BACKEND_FUNCS = { + PIL: pil_resize, + IMAGEMAGICK: im_resize, +} + + +class Shareable(type): + """A pseudo-singleton metaclass that allows both shared and + non-shared instances. The ``MyClass.shared`` property holds a + 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 + + @property + def shared(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + +class ArtResizer(object): + """A singleton class that performs image resizes. + """ + __metaclass__ = Shareable + + def __init__(self, method=None): + """Create a resizer object for the given method or, if none is + specified, with an inferred method. + """ + self.method = method or self._guess_method() + log.debug(u"artresizer: method is {0}".format(self.method)) + + def resize(self, maxwidth, path_in, path_out=None): + """Manipulate an image file according to the method, returning a + new path. For PIL or IMAGEMAGIC methods, resizes the image to a + temporary file. For WEBPROXY, returns `path_in` unmodified. + """ + if self.local: + func = BACKEND_FUNCS[self.method] + return func(maxwidth, path_in, path_out) + else: + return path_in + + def proxy_url(self, maxwidth, url): + """Modifies an image URL according the method, returning a new + URL. For WEBPROXY, a URL on the proxy server is returned. + Otherwise, the URL is returned unmodified. + """ + if self.local: + return url + else: + return resize_url(url, maxwidth) + + @property + def local(self): + """A boolean indicating whether the resizing method is performed + locally (i.e., PIL or IMAGEMAGICK). + """ + return self.method in BACKEND_FUNCS + + @staticmethod + def _guess_method(): + """Determine which resizing method to use. Returns PIL, + IMAGEMAGICK, or WEBPROXY depending on available dependencies. + """ + # Try importing PIL. + try: + __import__('PIL', fromlist=['Image']) + return PIL + except ImportError: + pass + + # Try invoking ImageMagick's "convert". + try: + out = subprocess.check_output(['convert', '--version']).lower() + if 'imagemagick' in out: + return IMAGEMAGICK + except subprocess.CalledProcessError: + pass # system32/convert.exe may be interfering + + # Fall back to Web proxy method. + return WEBPROXY diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 4ea3b60e3..2afa658a7 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -21,7 +21,7 @@ import threading from subprocess import Popen, PIPE from beets.plugins import BeetsPlugin -from beets import ui, library, util +from beets import ui, util from beetsplug.embedart import _embed log = logging.getLogger('beets') diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 5afcebfe8..9aaef1965 100755 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -21,12 +21,16 @@ from beets import mediafile from beets import ui from beets.ui import decargs from beets.util import syspath, normpath +from beets.util.artresizer import ArtResizer log = logging.getLogger('beets') def _embed(path, items): """Embed an image file, located at `path`, into each item. """ + if options['maxwidth']: + path = ArtResizer.shared.resize(options['maxwidth'], syspath(path)) + data = open(syspath(path), 'rb').read() kindstr = imghdr.what(None, data) if kindstr not in ('jpeg', 'png'): @@ -35,6 +39,7 @@ def _embed(path, items): # Add art to each file. log.debug('Embedding album art.') + for item in items: try: f = mediafile.MediaFile(syspath(item.path)) @@ -48,12 +53,21 @@ def _embed(path, items): options = { 'autoembed': True, + 'maxwidth': 0, } class EmbedCoverArtPlugin(BeetsPlugin): - """Allows albumart to be embedded into the actual files.""" + """Allows albumart to be embedded into the actual files. + """ def configure(self, config): options['autoembed'] = \ ui.config_val(config, 'embedart', 'autoembed', True, bool) + options['maxwidth'] = \ + int(ui.config_val(config, 'embedart', 'maxwidth', '0')) + + if options['maxwidth'] and not ArtResizer.shared.local: + options['maxwidth'] = 0 + log.error("embedart: ImageMagick or PIL not found; " + "'maxwidth' option ignored") def commands(self): # Embed command. diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 19bf787e7..567d8a64b 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -18,50 +18,58 @@ import urllib import re import logging import os +import tempfile from beets.plugins import BeetsPlugin +from beets.util.artresizer import ArtResizer from beets import importer from beets import ui +from beets import util IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg'] COVER_NAMES = ['cover', 'front', 'art', 'album', 'folder'] CONTENT_TYPES = ('image/jpeg',) +DOWNLOAD_EXTENSION = '.jpg' log = logging.getLogger('beets') -# ART SOURCES ################################################################ - def _fetch_image(url): """Downloads an image from a URL and checks whether it seems to actually be an image. If so, returns a path to the downloaded image. Otherwise, returns None. """ - log.debug('Downloading art: %s' % url) + # Generate a temporary filename with the correct extension. + fd, fn = tempfile.mkstemp(suffix=DOWNLOAD_EXTENSION) + os.close(fd) + + log.debug(u'fetchart: downloading art: {0}'.format(url)) try: - fn, headers = urllib.urlretrieve(url) + _, headers = urllib.urlretrieve(url, filename=fn) except IOError: log.debug('error fetching art') return # Make sure it's actually an image. if headers.gettype() in CONTENT_TYPES: - log.debug('Downloaded art to: %s' % fn) + log.debug(u'fetchart: downloaded art to: {0}'.format( + util.displayable_path(fn) + )) return fn else: - log.debug('Not an image.') + log.debug(u'fetchart: not an image') +# ART SOURCES ################################################################ + # Cover Art Archive. CAA_URL = 'http://coverartarchive.org/release/{mbid}/front-500.jpg' def caa_art(release_id): - """Fetch album art from the Cover Art Archive given a MusicBrainz - release ID. + """Return the Cover Art Archive URL given a MusicBrainz release ID. """ - url = CAA_URL.format(mbid=release_id) - return _fetch_image(url) + return CAA_URL.format(mbid=release_id) # Art from Amazon. @@ -70,13 +78,9 @@ AMAZON_URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg' AMAZON_INDICES = (1, 2) def art_for_asin(asin): - """Fetch art for an Amazon ID (ASIN) string.""" + """Generate URLs for an Amazon ID (ASIN) string.""" for index in AMAZON_INDICES: - # Fetch the image. - url = AMAZON_URL % (asin, index) - fn = _fetch_image(url) - if fn: - return fn + yield AMAZON_URL % (asin, index) # AlbumArt.org scraper. @@ -85,23 +89,23 @@ AAO_URL = 'http://www.albumart.org/index_detail.php' AAO_PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"' def aao_art(asin): - """Fetch art from AlbumArt.org.""" + """Return art URL from AlbumArt.org given an ASIN.""" # Get the page from albumart.org. url = '%s?%s' % (AAO_URL, urllib.urlencode({'asin': asin})) try: - log.debug('Scraping art URL: %s' % url) + log.debug(u'fetchart: scraping art URL: {0}'.format(url)) page = urllib.urlopen(url).read() except IOError: - log.debug('Error scraping art page') + log.debug(u'fetchart: error scraping art page') return # Search the page for the image URL. m = re.search(AAO_PAT, page) if m: image_url = m.group(1) - return _fetch_image(image_url) + return image_url else: - log.debug('No image found on page') + log.debug('fetchart: no image found on page') # Art from the filesystem. @@ -117,61 +121,72 @@ def art_in_path(path): for ext in IMAGE_EXTENSIONS: if fn.lower().endswith('.' + ext): images.append(fn) - images.sort() # Look for "preferred" filenames. - for name in COVER_NAMES: - for fn in images: + for fn in images: + for name in COVER_NAMES: if fn.lower().startswith(name): - log.debug('Using well-named art file %s' % fn) + log.debug('fetchart: using well-named art file {0}'.format( + util.displayable_path(fn) + )) return os.path.join(path, fn) # Fall back to any image in the folder. if images: - log.debug('Using fallback art file %s' % images[0]) + log.debug('fetchart: using fallback art file {0}'.format( + util.displayable_path(images[0]) + )) return os.path.join(path, images[0]) # Try each source in turn. -def art_for_album(album, path, local_only=False): - """Given an Album object, returns a path to downloaded art for the - album (or None if no art is found). If `local_only`, then only local - image files from the filesystem are returned; no network requests - are made. +def _source_urls(album): + """Generate possible source URLs for an album's art. The URLs are + not guaranteed to work so they each need to be attempted in turn. + This allows the main `art_for_album` function to abort iteration + through this sequence early to avoid the cost of scraping when not + necessary. """ - # Local art. - if isinstance(path, basestring): - out = art_in_path(path) - if out: - return out - if local_only: - # Abort without trying Web sources. - return - - # CoverArtArchive.org. if album.mb_albumid: - log.debug('Fetching album art for MBID {0}.'.format(album.mb_albumid)) - out = caa_art(album.mb_albumid) - if out: - return out + yield caa_art(album.mb_albumid) # Amazon and AlbumArt.org. if album.asin: - log.debug('Fetching album art for ASIN %s.' % album.asin) - out = art_for_asin(album.asin) - if out: - return out - return aao_art(album.asin) + for url in art_for_asin(album.asin): + yield url + yield aao_art(album.asin) - # All sources failed. - log.debug('No ASIN available: no art found.') - return None +def art_for_album(album, path, maxwidth=None, local_only=False): + """Given an Album object, returns a path to downloaded art for the + album (or None if no art is found). If `maxwidth`, then images are + resized to this maximum pixel size. If `local_only`, then only local + image files from the filesystem are returned; no network requests + are made. + """ + out = None + + # Local art. + if isinstance(path, basestring): + out = art_in_path(path) + + # Web art sources. + if not local_only and not out: + for url in _source_urls(album): + if maxwidth: + url = ArtResizer.shared.proxy_url(maxwidth, url) + out = _fetch_image(url) + if out: + break + + if maxwidth and out: + out = ArtResizer.shared.resize(maxwidth, out) + return out # PLUGIN LOGIC ############################################################### -def batch_fetch_art(lib, albums, force): +def batch_fetch_art(lib, albums, force, maxwidth=None): """Fetch album art for each of the albums. This implements the manual fetchart CLI command. """ @@ -179,7 +194,8 @@ def batch_fetch_art(lib, albums, force): if album.artpath and not force: message = 'has album art' else: - path = art_for_album(album, None) + path = art_for_album(album, None, maxwidth) + if path: album.set_art(path, False) message = 'found album art' @@ -193,7 +209,7 @@ class FetchArtPlugin(BeetsPlugin): super(FetchArtPlugin, self).__init__() self.autofetch = True - + self.maxwidth = 0 # Holds paths to downloaded images between fetching them and # placing them in the filesystem. self.art_paths = {} @@ -201,6 +217,8 @@ class FetchArtPlugin(BeetsPlugin): def configure(self, config): self.autofetch = ui.config_val(config, 'fetchart', 'autofetch', True, bool) + self.maxwidth = int(ui.config_val(config, 'fetchart', + 'maxwidth', '0')) if self.autofetch: # Enable two import hooks when fetching is enabled. self.import_stages = [self.fetch_art] @@ -221,7 +239,8 @@ class FetchArtPlugin(BeetsPlugin): return album = config.lib.get_album(task.album_id) - path = art_for_album(album, task.path, local_only=local) + path = art_for_album(album, task.path, self.maxwidth, local) + if path: self.art_paths[task] = path @@ -244,6 +263,7 @@ class FetchArtPlugin(BeetsPlugin): action='store_true', default=False, help='re-download art when already present') def func(lib, config, opts, args): - batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force) + batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force, + self.maxwidth) cmd.func = func return [cmd] diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 1f03a4ed8..f7e041634 100755 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -18,7 +18,7 @@ import os from beets import ui from beets.plugins import BeetsPlugin -from beets.util import syspath +from beets.util import syspath, command_output from beets.ui import commands log = logging.getLogger('beets') @@ -30,13 +30,11 @@ class ReplayGainError(Exception): """ def call(args): - """Execute the command indicated by `args` (a list of strings) and - return the command's output. The stderr stream is ignored. If the - command exits abnormally, a ReplayGainError is raised. + """Execute the command and return its output or raise a + ReplayGainError on failure. """ try: - with open(os.devnull, 'w') as devnull: - return subprocess.check_output(args, stderr=devnull) + return command_output(args) except subprocess.CalledProcessError as e: raise ReplayGainError( "{0} exited with status {1}".format(args[0], e.returncode) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6f21b72c2..48de872b3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -20,6 +20,9 @@ Changelog * :doc:`/plugins/replaygain`: This plugin has been completely overhauled to use the `mp3gain`_ or `aacgain`_ command-line tools instead of the failure-prone Gstreamer ReplayGain implementation. Thanks to Fabrice Laporte. +* :doc:`/plugins/fetchart` and :doc:`/plugins/embedart`: Both plugins can now + resize album art to avoid excessively large images. Thanks to + Fabrice Laporte. * :doc:`/plugins/scrub`: Scrubbing now removes *all* types of tags from a file rather than just one. For example, if your FLAC file has both ordinary FLAC tags and ID3 tags, the ID3 tags are now also removed. diff --git a/docs/plugins/embedart.rst b/docs/plugins/embedart.rst index e5eb560f0..46b66913f 100644 --- a/docs/plugins/embedart.rst +++ b/docs/plugins/embedart.rst @@ -40,8 +40,18 @@ embedded album art: Configuring ----------- -The plugin has one configuration option, ``autoembed``, which lets you disable -automatic album art embedding. To do so, add this to your ``~/.beetsconfig``:: +``autoembed`` option lets you disable automatic album art embedding. +To do so, add this to your ``~/.beetsconfig``:: [embedart] autoembed: no + +A maximum image width can be configured as ``maxwidth`` to downscale images +before embedding them (the original image file is not altered). The resize +operation reduces image width to ``maxwidth`` pixels. The height is recomputed +so that the aspect ratio is preserved. `PIL`_ or `ImageMagick`_ is required to +use the ``maxwidth`` config option. See also :ref:`image-resizing` for further +caveats about image resizing. + +.. _PIL: http://www.pythonware.com/products/pil/ +.. _ImageMagick: http://www.imagemagick.org/ diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index a4d2ee866..3f02937e8 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -4,6 +4,7 @@ FetchArt Plugin The ``fetchart`` plugin retrieves album art images from various sources on the Web and stores them as image files. + Fetching Album Art During Import -------------------------------- @@ -34,6 +35,31 @@ already have it; the ``-f`` or ``--force`` switch makes it search for art regardless. If you specify a query, only matching albums will be processed; otherwise, the command processes every album in your library. +.. _image-resizing: + +Image Resizing +-------------- + +A maximum image width can be configured as ``maxwidth`` to downscale fetched +images if they are too big. The resize operation reduces image width to +``maxwidth`` pixels. The height is recomputed so that the aspect ratio is +preserved. + +Beets can resize images using `PIL`_, `ImageMagick`_, or a server-side resizing +proxy. If either PIL or ImageMagick is installed, beets will use those; +otherwise, it falls back to the resizing proxy. If the resizing proxy is used, +no resizing is performed for album art found on the filesystem---only downloaded +art is resized. Server-side resizing can also be slower than local resizing, so +consider installing one of the two backends for better performance. + +When using ImageMagic, beets looks for the ``convert`` executable in your path. +On some versions Windows, the program can be shadowed by a system-provided +``convert.exe``. On these systems, you may need to modify your ``%PATH%`` +environment variable so that ImageMagick comes first or use PIL instead. + +.. _PIL: http://www.pythonware.com/products/pil/ +.. _ImageMagick: http://www.imagemagick.org/ + Album Art Sources ----------------- diff --git a/test/test_art.py b/test/test_art.py index a125de0c7..fb9e5258b 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -38,15 +38,15 @@ class MockUrlRetrieve(object): self.fetched = url return self.pathval, self.headers -class AmazonArtTest(unittest.TestCase): +class FetchImageTest(unittest.TestCase): def test_invalid_type_returns_none(self): fetchart.urllib.urlretrieve = MockUrlRetrieve('path', '') - artpath = fetchart.art_for_asin('xxxx') + artpath = fetchart._fetch_image('http://example.com') self.assertEqual(artpath, None) def test_jpeg_type_returns_path(self): fetchart.urllib.urlretrieve = MockUrlRetrieve('somepath', 'image/jpeg') - artpath = fetchart.art_for_asin('xxxx') + artpath = fetchart._fetch_image('http://example.com') self.assertEqual(artpath, 'somepath') class FSArtTest(unittest.TestCase): @@ -160,14 +160,10 @@ class CombinedTest(unittest.TestCase): class AAOTest(unittest.TestCase): def setUp(self): self.old_urlopen = fetchart.urllib.urlopen - self.old_urlretrieve = fetchart.urllib.urlretrieve fetchart.urllib.urlopen = self._urlopen - self.retriever = MockUrlRetrieve('somepath', 'image/jpeg') - fetchart.urllib.urlretrieve = self.retriever self.page_text = '' def tearDown(self): fetchart.urllib.urlopen = self.old_urlopen - fetchart.urllib.urlretrieve = self.old_urlretrieve def _urlopen(self, url): return StringIO.StringIO(self.page_text) @@ -179,13 +175,11 @@ class AAOTest(unittest.TestCase): View larger image """ res = fetchart.aao_art('x') - self.assertEqual(self.retriever.fetched, 'TARGET_URL') - self.assertEqual(res, 'somepath') + self.assertEqual(res, 'TARGET_URL') def test_aao_scraper_returns_none_when_no_image_present(self): self.page_text = "blah blah" res = fetchart.aao_art('x') - self.assertEqual(self.retriever.fetched, None) self.assertEqual(res, None) class ArtImporterTest(unittest.TestCase, _common.ExtraAsserts): @@ -195,7 +189,7 @@ class ArtImporterTest(unittest.TestCase, _common.ExtraAsserts): _common.touch(self.art_file) self.old_afa = fetchart.art_for_album self.afa_response = self.art_file - def art_for_album(i, p, local_only=False): + def art_for_album(i, p, maxwidth=None, local_only=False): return self.afa_response fetchart.art_for_album = art_for_album