From 7f2aa44ac6fbe45c45a40c063dd86de63482a177 Mon Sep 17 00:00:00 2001 From: Fabrice Laporte Date: Sun, 28 Oct 2012 15:36:42 +0100 Subject: [PATCH 01/14] Add 'maxwidth' option to embedart and fetchart plugins. artresizer.py instances an ArtResizer object that uses internally the PIL; ImageMagick or a web proxy service to perform the resizing operations. Because embedart works on input images located on filesystem it requires PIL or ImageMagick, whereas fetchart is able to do the job with the fallback webproxy resizer. --- beets/util/artresizer.py | 172 ++++++++++++++++++++++++++++++++++++++ beetsplug/embedart.py | 18 +++- beetsplug/fetchart.py | 94 ++++++++++++--------- docs/plugins/embedart.rst | 10 ++- docs/plugins/fetchart.rst | 121 ++++++++++++++------------- 5 files changed, 314 insertions(+), 101 deletions(-) create mode 100644 beets/util/artresizer.py diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py new file mode 100644 index 000000000..b2c2c7fca --- /dev/null +++ b/beets/util/artresizer.py @@ -0,0 +1,172 @@ +# 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. + +import urllib +import subprocess +import os +import glob +import shutil +from tempfile import NamedTemporaryFile +import logging + +"""Abstraction layer to resize an image without requiring additional dependency +""" +# Resizing methods +PIL = 1 +IMAGEMAGICK = 2 +WEBPROXY = 3 + +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: + with open(os.devnull, 'w') as devnull: + return subprocess.check_output(args, stderr=devnull) + except subprocess.CalledProcessError as e: + raise ArtResizerError( + "{0} exited with status {1}".format(args[0], e.returncode) + ) + + +def resize_url(url, maxwidth): + """Return a new url with image of original url resized to maxwidth (keep + aspect ratio)""" + + PROXY_URL = 'http://images.weserv.nl/?url=%s&w=%s' + reqUrl = PROXY_URL % (url.replace('http://',''), maxwidth) + log.debug("Requesting proxy image at %s" % reqUrl) + try: + urllib.urlopen(reqUrl) + except IOError: + log.info('Cannot get resized image via web proxy. ' + 'Using original image url.') + return url + return reqUrl + + +def get_temp_file_out(path_in): + """Return an unused filename with correct extension. + """ + with NamedTemporaryFile(suffix=os.path.splitext(path_in)[1]) as f: + path_out = f.name + return path_out + + +class PilResizer(object): + def resize(self, maxwidth, path_in, path_out=None) : + """Resize using Python Imaging Library (PIL). Return the output path + of resized image. + """ + if not path_out: + path_out = get_temp_file_out(path_in) + try: + im = Image.open(path_in) + size = maxwidth, maxwidth + im.thumbnail(size, Image.ANTIALIAS) + im.save(path_out) + return path_out + except IOError: + log.error("Cannot create thumbnail for '%s'" % path) + + +class ImageMagickResizer(object): + def resize(self, maxwidth, path_in, path_out=None): + """Resize using ImageMagick command-line + tool. Return the output path of resized image. + """ + if not path_out: + path_out = get_temp_file_out(path_in) + + # widthxheight> Shrinks images with dimension(s) larger than the + # corresponding width and/or height dimension(s). + # "only shrink flag" is prefixed by ^ escape char for Windows compat. + cmd = [self.convert_path, path_in, '-resize', '%sx^>' % \ + maxwidth, path_out] + call(cmd) + return path_out + + +class ArtResizer(object): + + convert_path = None + + def __init__(self, detect=True): + """ArtResizer factory method""" + + self.method = WEBPROXY + if detect: + self.method = self.set_method() + + if self.method == PIL : + self.__class__ = PilResizer + elif self.method == IMAGEMAGICK : + self.__class__ = ImageMagickResizer + log.debug("ArtResizer method is %s" % self.__class__) + + + def set_method(self): + """Set the most appropriate resize method. Use PIL if present, else + check if ImageMagick is installed. + If none is available, use a web proxy.""" + + try: + from PIL import Image + return PIL + except ImportError as e: + pass + + for dir in os.environ['PATH'].split(os.pathsep): + if glob.glob(os.path.join(dir, 'convert*')): + convert = os.path.join(dir, 'convert') + cmd = [convert, '--version'] + try: + out = subprocess.check_output(cmd).lower() + if 'imagemagick' in out: + self.convert_path = convert + return IMAGEMAGICK + except subprocess.CalledProcessError as e: + pass # system32/convert.exe may be interfering + + return WEBPROXY + + + def resize(self, maxwidth, url, path_out=None): + """Resize using web proxy. Return the output path of resized image. + """ + + reqUrl = resize_url(url, maxwidth) + try: + fn, headers = urllib.urlretrieve(reqUrl) + except IOError: + log.debug('error fetching resized image') + return + + if not path_out: + path_out = get_temp_file_out(fn) + shutil.copy(fn, path_out) + return path_out + +# module-as-singleton instanciation +inst = ArtResizer() + + + diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 5afcebfe8..8c9e00d68 100755 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -20,13 +20,17 @@ from beets.plugins import BeetsPlugin from beets import mediafile from beets import ui from beets.ui import decargs -from beets.util import syspath, normpath +from beets.util import syspath, normpath, artresizer log = logging.getLogger('beets') def _embed(path, items): """Embed an image file, located at `path`, into each item. """ + if options['maxwidth']: + path = artresizer.inst.resize(options['maxwidth'], syspath(path)) + log.debug('Resize album art to %s before embedding' % 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 artresizer.inst.method == artresizer.WEBPROXY: + options['maxwidth'] = 0 + log.error("embedart: 'maxwidth' option ignored, " + "please install ImageMagick first") def commands(self): # Embed command. diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 19bf787e7..6f460617b 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -20,6 +20,7 @@ import logging import os from beets.plugins import BeetsPlugin +from beets.util import artresizer from beets import importer from beets import ui @@ -32,6 +33,15 @@ log = logging.getLogger('beets') # ART SOURCES ################################################################ +def do_resize_url(func): + def wrapper(url, maxwidth=None): + """Returns url pointing to resized image instead of original one""" + if maxwidth and artresizer.inst.method == artresizer.WEBPROXY : + url = artresizer.resize_url(url, maxwidth) + return func(url) + return wrapper + +@do_resize_url 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. @@ -57,11 +67,8 @@ def _fetch_image(url): 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. - """ - url = CAA_URL.format(mbid=release_id) - return _fetch_image(url) + """Return a Cover Art Archive url given a MusicBrain release ID.""" + return CAA_URL.format(mbid=release_id) # Art from Amazon. @@ -70,13 +77,15 @@ 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.""" + """Return url 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 + try: + urlopen(url) + return url + except IOError: + pass # does not exist + # AlbumArt.org scraper. @@ -85,7 +94,7 @@ 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.""" # Get the page from albumart.org. url = '%s?%s' % (AAO_URL, urllib.urlencode({'asin': asin})) try: @@ -99,7 +108,7 @@ def aao_art(asin): 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') @@ -117,11 +126,10 @@ 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) return os.path.join(path, fn) @@ -133,40 +141,41 @@ def art_in_path(path): # Try each source in turn. - -def art_for_album(album, path, local_only=False): + +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 `local_only`, then only local image files from the filesystem are returned; no network requests are made. """ + # 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 + if not local_only and not out: + url = None + # CoverArtArchive.org. + if album.mb_albumid: + log.debug('Fetching album art for MBID {0}.'.format(album.mb_albumid)) + url = caa_art(album.mb_albumid) + + # Amazon and AlbumArt.org. + if not url and album.asin: + log.debug('Fetching album art for ASIN %s.' % album.asin) + url = art_for_asin(album.asin) + if not url: + url = aao_art(album.asin) - # 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) + if not url: # All sources failed. + log.debug('No ASIN available: no art found.') + return None - # All sources failed. - log.debug('No ASIN available: no art found.') - return None + out = _fetch_image(url, maxwidth) + + if maxwidth and artresizer.inst.method != artresizer.WEBPROXY : + artresizer.inst.resize(maxwidth, out, out) + return out # PLUGIN LOGIC ############################################################### @@ -179,7 +188,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) + if path: album.set_art(path, False) message = 'found album art' @@ -201,11 +211,14 @@ 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] self.register_listener('import_task_files', self.assign_art) + # Asynchronous; after music is added to the library. def fetch_art(self, config, task): """Find art for the album being imported.""" @@ -221,7 +234,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_only=local) + if path: self.art_paths[task] = path diff --git a/docs/plugins/embedart.rst b/docs/plugins/embedart.rst index e5eb560f0..7246d2cdb 100644 --- a/docs/plugins/embedart.rst +++ b/docs/plugins/embedart.rst @@ -40,8 +40,14 @@ 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 defined to downscale images before embedding them +(source image on filesystem is not altered). The resize operation reduces image width to +``maxwidth`` pixels and height is recomputed so that aspect ratio is preserved. +The [PIL](http://www.pythonware.com/products/pil/) or [ImageMagick](www.imagemagick.org/) is required +to use the ``maxwidth`` config option. \ No newline at end of file diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index a4d2ee866..73dd2ba25 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -1,57 +1,64 @@ -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 --------------------------------- - -To automatically get album art for every album you import, just enable the -plugin by putting ``fetchart`` on your config file's ``plugins`` line (see -:doc:`/plugins/index`). - -By default, beets stores album art image files alongside the music files for an -album in a file called ``cover.jpg``. To customize the name of this file, use -the :ref:`art-filename` config option. - -To disable automatic art downloading, just put this in your configuration -file:: - - [fetchart] - autofetch: no - -Manually Fetching Album Art ---------------------------- - -Use the ``fetchart`` command to download album art after albums have already -been imported:: - - $ beet fetchart [-f] [query] - -By default, the command will only look for album art when the album doesn't -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. - -Album Art Sources ------------------ - -Currently, this plugin searches for art in the local filesystem as well as on -the Cover Art Archive, Amazon, and AlbumArt.org (in that order). - -When looking for local album art, beets checks for image files located in the -same folder as the music files you're importing. If you have an image file -called "cover," "front," "art," "album," for "folder" alongside your music, -beets will treat it as album art and skip searching any online databases. - -When you choose to apply changes during an import, beets searches all sources -for album art. For "as-is" imports (and non-autotagged imports using the ``-A`` -flag), beets only looks for art on the local filesystem. - -Embedding Album Art -------------------- - -This plugin fetches album art but does not embed images into files' tags. To do -that, use the :doc:`/plugins/embedart`. (You'll want to have both plugins -enabled.) +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 +-------------------------------- + +To automatically get album art for every album you import, just enable the +plugin by putting ``fetchart`` on your config file's ``plugins`` line (see +:doc:`/plugins/index`). + +By default, beets stores album art image files alongside the music files for an +album in a file called ``cover.jpg``. To customize the name of this file, use +the :ref:`art-filename` config option. + +A maximum image width can be defined to downscale fetched images if they are too +big. The resize operation reduces image width to ``maxwidth`` pixels and +height is recomputed so that aspect ratio is preserved. +When using ``maxwidth`` config option, please consider installing +[ImageMagick](www.imagemagick.org/) first for optimal performance. + +To disable automatic art downloading, just put this in your configuration +file:: + + [fetchart] + autofetch: no + +Manually Fetching Album Art +--------------------------- + +Use the ``fetchart`` command to download album art after albums have already +been imported:: + + $ beet fetchart [-f] [query] + +By default, the command will only look for album art when the album doesn't +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. + +Album Art Sources +----------------- + +Currently, this plugin searches for art in the local filesystem as well as on +the Cover Art Archive, Amazon, and AlbumArt.org (in that order). + +When looking for local album art, beets checks for image files located in the +same folder as the music files you're importing. If you have an image file +called "cover," "front," "art," "album," for "folder" alongside your music, +beets will treat it as album art and skip searching any online databases. + +When you choose to apply changes during an import, beets searches all sources +for album art. For "as-is" imports (and non-autotagged imports using the ``-A`` +flag), beets only looks for art on the local filesystem. + +Embedding Album Art +------------------- + +This plugin fetches album art but does not embed images into files' tags. To do +that, use the :doc:`/plugins/embedart`. (You'll want to have both plugins +enabled.) \ No newline at end of file From d271735dd20f5f61bb0d409f347a1477b5b487d9 Mon Sep 17 00:00:00 2001 From: Fabrice Laporte Date: Sun, 28 Oct 2012 17:09:39 +0100 Subject: [PATCH 02/14] fetchart: fix urlopen call --- beetsplug/fetchart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 6f460617b..a1cb7959e 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -81,7 +81,7 @@ def art_for_asin(asin): for index in AMAZON_INDICES: url = AMAZON_URL % (asin, index) try: - urlopen(url) + urllib.urlopen(url) return url except IOError: pass # does not exist From 447454a62c1c5d533db9e9d4120206968f303f53 Mon Sep 17 00:00:00 2001 From: Fabrice Laporte Date: Sun, 28 Oct 2012 23:02:45 +0100 Subject: [PATCH 03/14] fix few errors revealed by test_art.py --- beetsplug/fetchart.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index a1cb7959e..de4149580 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -149,6 +149,7 @@ def art_for_album(album, path, maxwidth=None, local_only=False): are made. """ + out = None # Local art. if isinstance(path, basestring): out = art_in_path(path) @@ -203,7 +204,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 = {} @@ -234,7 +235,7 @@ class FetchArtPlugin(BeetsPlugin): return album = config.lib.get_album(task.album_id) - path = art_for_album(album, task.path, self.maxwidth, local_only=local) + path = art_for_album(album, task.path, self.maxwidth) if path: self.art_paths[task] = path From 29c6f9c342bb77a0d50157b973f3c3464f6a702f Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 31 Oct 2012 17:52:21 -0700 Subject: [PATCH 04/14] changelog and light style fixes for #64 --- beets/util/artresizer.py | 20 ++++++++++---------- docs/changelog.rst | 3 +++ docs/plugins/embedart.rst | 13 ++++++++----- docs/plugins/fetchart.rst | 24 +++++++++++++++++------- 4 files changed, 38 insertions(+), 22 deletions(-) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index b2c2c7fca..2d7d96ba3 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -12,6 +12,9 @@ # 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 @@ -20,8 +23,6 @@ import shutil from tempfile import NamedTemporaryFile import logging -"""Abstraction layer to resize an image without requiring additional dependency -""" # Resizing methods PIL = 1 IMAGEMAGICK = 2 @@ -29,10 +30,12 @@ WEBPROXY = 3 log = logging.getLogger('beets') + class ArtResizerError(Exception): - """Raised when an error occurs during image resizing + """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 @@ -106,7 +109,8 @@ class ImageMagickResizer(object): class ArtResizer(object): - + """A singleton class that performs image resizes. + """ convert_path = None def __init__(self, detect=True): @@ -122,7 +126,6 @@ class ArtResizer(object): self.__class__ = ImageMagickResizer log.debug("ArtResizer method is %s" % self.__class__) - def set_method(self): """Set the most appropriate resize method. Use PIL if present, else check if ImageMagick is installed. @@ -148,7 +151,6 @@ class ArtResizer(object): return WEBPROXY - def resize(self, maxwidth, url, path_out=None): """Resize using web proxy. Return the output path of resized image. """ @@ -165,8 +167,6 @@ class ArtResizer(object): shutil.copy(fn, path_out) return path_out -# module-as-singleton instanciation + +# Singleton instantiation. inst = ArtResizer() - - - 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 7246d2cdb..cf2639347 100644 --- a/docs/plugins/embedart.rst +++ b/docs/plugins/embedart.rst @@ -46,8 +46,11 @@ To do so, add this to your ``~/.beetsconfig``:: [embedart] autoembed: no -A maximum image width can be defined to downscale images before embedding them -(source image on filesystem is not altered). The resize operation reduces image width to -``maxwidth`` pixels and height is recomputed so that aspect ratio is preserved. -The [PIL](http://www.pythonware.com/products/pil/) or [ImageMagick](www.imagemagick.org/) is required -to use the ``maxwidth`` config option. \ No newline at end of file +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. + +.. _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 73dd2ba25..359aa4dbe 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -16,12 +16,6 @@ By default, beets stores album art image files alongside the music files for an album in a file called ``cover.jpg``. To customize the name of this file, use the :ref:`art-filename` config option. -A maximum image width can be defined to downscale fetched images if they are too -big. The resize operation reduces image width to ``maxwidth`` pixels and -height is recomputed so that aspect ratio is preserved. -When using ``maxwidth`` config option, please consider installing -[ImageMagick](www.imagemagick.org/) first for optimal performance. - To disable automatic art downloading, just put this in your configuration file:: @@ -41,6 +35,22 @@ 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 +-------------- + +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. Since server-side resizing can +be slow, consider installing one of the two backends for better performance. + +.. _PIL: http://www.pythonware.com/products/pil/ +.. _ImageMagick: http://www.imagemagick.org/ + Album Art Sources ----------------- @@ -61,4 +71,4 @@ Embedding Album Art This plugin fetches album art but does not embed images into files' tags. To do that, use the :doc:`/plugins/embedart`. (You'll want to have both plugins -enabled.) \ No newline at end of file +enabled.) From 1169d2095e4245f35dc521195806e22da85a4889 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 31 Oct 2012 23:01:09 -0700 Subject: [PATCH 05/14] misc. minor fixes for artresizer (#64) - Safer proxy resize. The URL parameters are now properly encoded. And a spurious additional request has been removed. - Removed manual search of $PATH. Invoking "convert" without a path does this automatically. - More pyflakes-friendly test import of PIL. - Do not delete the NamedTemporaryFile -- doing so creates a race condition where the file might be created between the filename generation and the tool invocation. --- beets/util/artresizer.py | 91 +++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 52 deletions(-) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 2d7d96ba3..19a4a4ebc 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -18,7 +18,6 @@ public resizing proxy if neither is available. import urllib import subprocess import os -import glob import shutil from tempfile import NamedTemporaryFile import logging @@ -28,6 +27,8 @@ PIL = 1 IMAGEMAGICK = 2 WEBPROXY = 3 +PROXY_URL = 'http://images.weserv.nl/' + log = logging.getLogger('beets') @@ -51,36 +52,32 @@ def call(args): def resize_url(url, maxwidth): - """Return a new url with image of original url resized to maxwidth (keep - aspect ratio)""" - - PROXY_URL = 'http://images.weserv.nl/?url=%s&w=%s' - reqUrl = PROXY_URL % (url.replace('http://',''), maxwidth) - log.debug("Requesting proxy image at %s" % reqUrl) - try: - urllib.urlopen(reqUrl) - except IOError: - log.info('Cannot get resized image via web proxy. ' - 'Using original image url.') - return url - return reqUrl - - -def get_temp_file_out(path_in): - """Return an unused filename with correct extension. + """Return a proxied image URL that resizes the original image to + maxwidth (preserving aspect ratio). """ - with NamedTemporaryFile(suffix=os.path.splitext(path_in)[1]) as f: - path_out = f.name - return path_out + 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 class PilResizer(object): - def resize(self, maxwidth, path_in, path_out=None) : + def resize(self, maxwidth, path_in, path_out=None): """Resize using Python Imaging Library (PIL). Return the output path of resized image. """ + from PIL import Image if not path_out: - path_out = get_temp_file_out(path_in) + path_out = temp_file_for(path_in) try: im = Image.open(path_in) size = maxwidth, maxwidth @@ -88,7 +85,7 @@ class PilResizer(object): im.save(path_out) return path_out except IOError: - log.error("Cannot create thumbnail for '%s'" % path) + log.error("Cannot create thumbnail for '%s'" % path_in) class ImageMagickResizer(object): @@ -97,13 +94,13 @@ class ImageMagickResizer(object): tool. Return the output path of resized image. """ if not path_out: - path_out = get_temp_file_out(path_in) + path_out = temp_file_for(path_in) # widthxheight> Shrinks images with dimension(s) larger than the # corresponding width and/or height dimension(s). # "only shrink flag" is prefixed by ^ escape char for Windows compat. - cmd = [self.convert_path, path_in, '-resize', '%sx^>' % \ - maxwidth, path_out] + cmd = ['convert', path_in, '-resize', + '{0}x^>'.format(maxwidth), path_out] call(cmd) return path_out @@ -111,20 +108,15 @@ class ImageMagickResizer(object): class ArtResizer(object): """A singleton class that performs image resizes. """ - convert_path = None - - def __init__(self, detect=True): + def __init__(self): """ArtResizer factory method""" + self.method = self.set_method() - self.method = WEBPROXY - if detect: - self.method = self.set_method() - - if self.method == PIL : - self.__class__ = PilResizer - elif self.method == IMAGEMAGICK : - self.__class__ = ImageMagickResizer - log.debug("ArtResizer method is %s" % self.__class__) + if self.method == PIL: + self.__class__ = PilResizer + elif self.method == IMAGEMAGICK: + self.__class__ = ImageMagickResizer + log.debug("ArtResizer method is %s" % self.__class__) def set_method(self): """Set the most appropriate resize method. Use PIL if present, else @@ -132,22 +124,17 @@ class ArtResizer(object): If none is available, use a web proxy.""" try: - from PIL import Image + __import__('PIL', fromlist=['Image']) return PIL - except ImportError as e: + except ImportError: pass - for dir in os.environ['PATH'].split(os.pathsep): - if glob.glob(os.path.join(dir, 'convert*')): - convert = os.path.join(dir, 'convert') - cmd = [convert, '--version'] - try: - out = subprocess.check_output(cmd).lower() - if 'imagemagick' in out: - self.convert_path = convert - return IMAGEMAGICK - except subprocess.CalledProcessError as e: - pass # system32/convert.exe may be interfering + try: + out = subprocess.check_output(['convert', '--version']).lower() + if 'imagemagick' in out: + return IMAGEMAGICK + except subprocess.CalledProcessError: + pass # system32/convert.exe may be interfering return WEBPROXY From 3873c29448d8264660a4997ea49da2944b2b8e57 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 31 Oct 2012 23:33:59 -0700 Subject: [PATCH 06/14] artresizer (#64): helper functions, not classes The previous method was to change self.__class__ dynamically to make __init__ instantiate different classes. This new way, which uses bare functions instead of separate functor-like classes, instead just forwards the resize() call to a module-global implementation based on self.method. Additionally, the semantics of ArtResizer have changed. Clients now *always* call resize() and proxy_url(), regardless of method. The method makes *one* of these a no-op. This way, clients need not manually inspect which method is being used. --- beets/util/artresizer.py | 138 ++++++++++++++++++++++----------------- beetsplug/embedart.py | 7 +- beetsplug/fetchart.py | 6 +- 3 files changed, 83 insertions(+), 68 deletions(-) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 19a4a4ebc..b078a19b9 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -18,7 +18,6 @@ public resizing proxy if neither is available. import urllib import subprocess import os -import shutil from tempfile import NamedTemporaryFile import logging @@ -70,65 +69,97 @@ def temp_file_for(path): return f.name -class PilResizer(object): - def resize(self, maxwidth, path_in, path_out=None): - """Resize using Python Imaging Library (PIL). Return the output path - of resized image. - """ - from PIL import Image - if not path_out: - path_out = temp_file_for(path_in) - try: - im = Image.open(path_in) - size = maxwidth, maxwidth - im.thumbnail(size, Image.ANTIALIAS) - im.save(path_out) - return path_out - except IOError: - log.error("Cannot create thumbnail for '%s'" % path_in) - - -class ImageMagickResizer(object): - def resize(self, maxwidth, path_in, path_out=None): - """Resize using ImageMagick command-line - tool. Return the output path of resized image. - """ - if not path_out: - path_out = temp_file_for(path_in) - - # widthxheight> Shrinks images with dimension(s) larger than the - # corresponding width and/or height dimension(s). - # "only shrink flag" is prefixed by ^ escape char for Windows compat. - cmd = ['convert', path_in, '-resize', - '{0}x^>'.format(maxwidth), path_out] - call(cmd) +def pil_resize(self, maxwidth, path_in, path_out=None): + """Resize using Python Imaging Library (PIL). Return the output path + of resized image. + """ + from PIL import Image + if not path_out: + path_out = temp_file_for(path_in) + try: + im = Image.open(path_in) + size = maxwidth, maxwidth + im.thumbnail(size, Image.ANTIALIAS) + im.save(path_out) return path_out + except IOError: + log.error("Cannot create thumbnail for '%s'" % path_in) + + +def im_resize(self, maxwidth, path_in, path_out=None): + """Resize using ImageMagick's ``convert`` tool. + tool. Return the output path of resized image. + """ + if not path_out: + path_out = temp_file_for(path_in) + + # "-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. + cmd = ['convert', path_in, + '-resize', '{0}x^>'.format(maxwidth), path_out] + call(cmd) + return path_out + + +BACKEND_FUNCS = { + PIL: pil_resize, + IMAGEMAGICK: im_resize, +} class ArtResizer(object): """A singleton class that performs image resizes. """ - def __init__(self): - """ArtResizer factory method""" - self.method = self.set_method() + 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("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 - if self.method == PIL: - self.__class__ = PilResizer - elif self.method == IMAGEMAGICK: - self.__class__ = ImageMagickResizer - log.debug("ArtResizer method is %s" % self.__class__) + 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) - def set_method(self): - """Set the most appropriate resize method. Use PIL if present, else - check if ImageMagick is installed. - If none is available, use a web proxy.""" + @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: @@ -136,24 +167,9 @@ class ArtResizer(object): except subprocess.CalledProcessError: pass # system32/convert.exe may be interfering + # Fall back to Web proxy method. return WEBPROXY - def resize(self, maxwidth, url, path_out=None): - """Resize using web proxy. Return the output path of resized image. - """ - - reqUrl = resize_url(url, maxwidth) - try: - fn, headers = urllib.urlretrieve(reqUrl) - except IOError: - log.debug('error fetching resized image') - return - - if not path_out: - path_out = get_temp_file_out(fn) - shutil.copy(fn, path_out) - return path_out - # Singleton instantiation. inst = ArtResizer() diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 8c9e00d68..0a7a48403 100755 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -29,7 +29,6 @@ def _embed(path, items): """ if options['maxwidth']: path = artresizer.inst.resize(options['maxwidth'], syspath(path)) - log.debug('Resize album art to %s before embedding' % path) data = open(syspath(path), 'rb').read() kindstr = imghdr.what(None, data) @@ -64,10 +63,10 @@ class EmbedCoverArtPlugin(BeetsPlugin): options['maxwidth'] = \ int(ui.config_val(config, 'embedart', 'maxwidth', '0')) - if options['maxwidth'] and artresizer.inst.method == artresizer.WEBPROXY: + if options['maxwidth'] and not artresizer.inst.local: options['maxwidth'] = 0 - log.error("embedart: 'maxwidth' option ignored, " - "please install ImageMagick first") + 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 de4149580..0f36bdbe0 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -36,8 +36,8 @@ log = logging.getLogger('beets') def do_resize_url(func): def wrapper(url, maxwidth=None): """Returns url pointing to resized image instead of original one""" - if maxwidth and artresizer.inst.method == artresizer.WEBPROXY : - url = artresizer.resize_url(url, maxwidth) + if maxwidth: + url = artresizer.inst.proxy_url(url, maxwidth) return func(url) return wrapper @@ -174,7 +174,7 @@ def art_for_album(album, path, maxwidth=None, local_only=False): out = _fetch_image(url, maxwidth) - if maxwidth and artresizer.inst.method != artresizer.WEBPROXY : + if maxwidth: artresizer.inst.resize(maxwidth, out, out) return out From e3fe9712d5b0b5fb2183650fd1ec48bcfecfb55b Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 1 Nov 2012 00:09:35 -0700 Subject: [PATCH 07/14] fetchart fixes for image resizing (#64) Fixed a number of issues with the changes to fetchart: - Remove redundant fetches. This was making the Amazon source download every image twice even when art resizing was not enabled! - Restore local_only switch in plugin hook, which got lost in the shuffle at some point. - Don't replace the original image file in-place; use a temporary file instead. This would clobber the original source image on the filesystem with the downscaled version! --- beetsplug/fetchart.py | 81 +++++++++++++++++++------------------------ test/test_art.py | 2 +- 2 files changed, 37 insertions(+), 46 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 0f36bdbe0..dbf5f3a49 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -31,17 +31,6 @@ CONTENT_TYPES = ('image/jpeg',) log = logging.getLogger('beets') -# ART SOURCES ################################################################ - -def do_resize_url(func): - def wrapper(url, maxwidth=None): - """Returns url pointing to resized image instead of original one""" - if maxwidth: - url = artresizer.inst.proxy_url(url, maxwidth) - return func(url) - return wrapper - -@do_resize_url 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. @@ -62,12 +51,15 @@ def _fetch_image(url): log.debug('Not an image.') +# ART SOURCES ################################################################ + # Cover Art Archive. CAA_URL = 'http://coverartarchive.org/release/{mbid}/front-500.jpg' def caa_art(release_id): - """Return a Cover Art Archive url given a MusicBrain release ID.""" + """Return the Cover Art Archive URL given a MusicBrainz release ID. + """ return CAA_URL.format(mbid=release_id) @@ -77,15 +69,9 @@ AMAZON_URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg' AMAZON_INDICES = (1, 2) def art_for_asin(asin): - """Return url for an Amazon ID (ASIN) string.""" + """Generate URLs for an Amazon ID (ASIN) string.""" for index in AMAZON_INDICES: - url = AMAZON_URL % (asin, index) - try: - urllib.urlopen(url) - return url - except IOError: - pass # does not exist - + yield AMAZON_URL % (asin, index) # AlbumArt.org scraper. @@ -94,7 +80,7 @@ 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): - """Return art url 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: @@ -141,41 +127,47 @@ def art_in_path(path): # Try each source in turn. - + +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. + """ + if album.mb_albumid: + yield caa_art(album.mb_albumid) + + # Amazon and AlbumArt.org. + if album.asin: + for url in art_for_asin(album.asin): + yield url + yield aao_art(album.asin) + 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 `local_only`, then only local + 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: - url = None - # CoverArtArchive.org. - if album.mb_albumid: - log.debug('Fetching album art for MBID {0}.'.format(album.mb_albumid)) - url = caa_art(album.mb_albumid) - - # Amazon and AlbumArt.org. - if not url and album.asin: - log.debug('Fetching album art for ASIN %s.' % album.asin) - url = art_for_asin(album.asin) - if not url: - url = aao_art(album.asin) + for url in _source_urls(album): + if maxwidth: + url = artresizer.inst.proxy_url(maxwidth, url) + out = _fetch_image(url) + if out: + break - if not url: # All sources failed. - log.debug('No ASIN available: no art found.') - return None - - out = _fetch_image(url, maxwidth) - if maxwidth: - artresizer.inst.resize(maxwidth, out, out) + out = artresizer.inst.resize(maxwidth, out) return out @@ -219,7 +211,6 @@ class FetchArtPlugin(BeetsPlugin): self.import_stages = [self.fetch_art] self.register_listener('import_task_files', self.assign_art) - # Asynchronous; after music is added to the library. def fetch_art(self, config, task): """Find art for the album being imported.""" @@ -235,7 +226,7 @@ class FetchArtPlugin(BeetsPlugin): return album = config.lib.get_album(task.album_id) - path = art_for_album(album, task.path, self.maxwidth) + path = art_for_album(album, task.path, self.maxwidth, local) if path: self.art_paths[task] = path diff --git a/test/test_art.py b/test/test_art.py index a125de0c7..7d2b20bcc 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -195,7 +195,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 From ea128910c55536355d6a5e5e3154d35036db085f Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 1 Nov 2012 00:14:55 -0700 Subject: [PATCH 08/14] fix art tests for new URL interface (#64) The various source helper functions now return URLs instead of calling _fetch_image themselves. --- test/test_art.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/test/test_art.py b/test/test_art.py index 7d2b20bcc..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): From f7677a4b56910f26169bae214aa7d2726abd5da5 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 1 Nov 2012 00:19:15 -0700 Subject: [PATCH 09/14] fetchart: fix command & use maxwidth (#64) An earlier commit broke the call to art_for_album here (too few arguments). I've also now propagated the maxwidth setting for the command to match the import hook. --- beetsplug/fetchart.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index dbf5f3a49..444ca6570 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -173,7 +173,7 @@ def art_for_album(album, path, maxwidth=None, local_only=False): # 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. """ @@ -181,7 +181,7 @@ def batch_fetch_art(lib, albums, force): if album.artpath and not force: message = 'has album art' else: - path = art_for_album(album) + path = art_for_album(album, None, maxwidth) if path: album.set_art(path, False) @@ -250,6 +250,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] From 07d3f3e0663899e0e34dc3fbc51a70e05f259636 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 1 Nov 2012 11:40:53 -0700 Subject: [PATCH 10/14] lazily initialize ArtResizer singleton (#64) Searching for `convert` or PIL has non-negligible performance overhead, so it's preferable to only do it when really necessary. This way, the search is only performed when ArtResizer.shared is accessed for the first time. --- beets/util/artresizer.py | 23 +++++++++++++++++++---- beetsplug/embedart.py | 7 ++++--- beetsplug/fetchart.py | 6 +++--- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index b078a19b9..6a5c6db0b 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -109,9 +109,28 @@ BACKEND_FUNCS = { } +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. @@ -169,7 +188,3 @@ class ArtResizer(object): # Fall back to Web proxy method. return WEBPROXY - - -# Singleton instantiation. -inst = ArtResizer() diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 0a7a48403..9aaef1965 100755 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -20,7 +20,8 @@ from beets.plugins import BeetsPlugin from beets import mediafile from beets import ui from beets.ui import decargs -from beets.util import syspath, normpath, artresizer +from beets.util import syspath, normpath +from beets.util.artresizer import ArtResizer log = logging.getLogger('beets') @@ -28,7 +29,7 @@ def _embed(path, items): """Embed an image file, located at `path`, into each item. """ if options['maxwidth']: - path = artresizer.inst.resize(options['maxwidth'], syspath(path)) + path = ArtResizer.shared.resize(options['maxwidth'], syspath(path)) data = open(syspath(path), 'rb').read() kindstr = imghdr.what(None, data) @@ -63,7 +64,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): options['maxwidth'] = \ int(ui.config_val(config, 'embedart', 'maxwidth', '0')) - if options['maxwidth'] and not artresizer.inst.local: + if options['maxwidth'] and not ArtResizer.shared.local: options['maxwidth'] = 0 log.error("embedart: ImageMagick or PIL not found; " "'maxwidth' option ignored") diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 444ca6570..fb9b2e752 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -20,7 +20,7 @@ import logging import os from beets.plugins import BeetsPlugin -from beets.util import artresizer +from beets.util.artresizer import ArtResizer from beets import importer from beets import ui @@ -161,13 +161,13 @@ def art_for_album(album, path, maxwidth=None, local_only=False): if not local_only and not out: for url in _source_urls(album): if maxwidth: - url = artresizer.inst.proxy_url(maxwidth, url) + url = ArtResizer.shared.proxy_url(maxwidth, url) out = _fetch_image(url) if out: break if maxwidth: - out = artresizer.inst.resize(maxwidth, out) + out = ArtResizer.shared.resize(maxwidth, out) return out From f4fa11f8ca4d6ee99c42d83b9ad2660833442cdb Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 1 Nov 2012 11:59:02 -0700 Subject: [PATCH 11/14] don't use subprocess.check_output This function was added in Python 2.7 and we're currently targetting 2.6 as a minimum version. Replaced with a function in util. --- beets/util/__init__.py | 18 ++++++++++++++++++ beets/util/artresizer.py | 4 ++-- beetsplug/convert.py | 2 +- beetsplug/replaygain.py | 10 ++++------ 4 files changed, 25 insertions(+), 9 deletions(-) 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 index 6a5c6db0b..b97b28e2f 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -20,6 +20,7 @@ import subprocess import os from tempfile import NamedTemporaryFile import logging +from beets.util import command_output # Resizing methods PIL = 1 @@ -42,8 +43,7 @@ def call(args): command exits abnormally, a ArtResizerError is raised. """ 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 ArtResizerError( "{0} exited with status {1}".format(args[0], e.returncode) 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/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) From 45650a4b642d3cdfd0797889b38e4f6081c72eb1 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 1 Nov 2012 12:15:51 -0700 Subject: [PATCH 12/14] artresizer: fix argument lists to helper functions Also added some useful debug logs. --- beets/util/artresizer.py | 43 ++++++++++++++++++++++++---------------- beetsplug/fetchart.py | 2 +- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index b97b28e2f..22ed6359d 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -20,7 +20,7 @@ import subprocess import os from tempfile import NamedTemporaryFile import logging -from beets.util import command_output +from beets import util # Resizing methods PIL = 1 @@ -43,7 +43,7 @@ def call(args): command exits abnormally, a ArtResizerError is raised. """ try: - return command_output(args) + return util.command_output(args) except subprocess.CalledProcessError as e: raise ArtResizerError( "{0} exited with status {1}".format(args[0], e.returncode) @@ -69,37 +69,46 @@ def temp_file_for(path): return f.name -def pil_resize(self, maxwidth, path_in, path_out=None): - """Resize using Python Imaging Library (PIL). Return the output path +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 - if not path_out: - path_out = temp_file_for(path_in) + log.debug(u'artresizer: PIL resizing {0} to {1}'.format( + util.displayable_path(path_in), util.displayable_path(path_out) + )) + try: - im = Image.open(path_in) + 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("Cannot create thumbnail for '%s'" % path_in) + log.error(u"PIL cannot create thumbnail for '{0}'".format( + util.displayable_path(path_in) + )) + return path_in -def im_resize(self, maxwidth, path_in, path_out=None): +def im_resize(maxwidth, path_in, path_out=None): """Resize using ImageMagick's ``convert`` tool. tool. Return the output path of resized image. """ - if not path_out: - path_out = temp_file_for(path_in) + 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. - cmd = ['convert', path_in, - '-resize', '{0}x^>'.format(maxwidth), path_out] - call(cmd) + call([ + 'convert', util.syspath(path_in), + '-resize', '{0}x^>'.format(maxwidth), path_out + ]) return path_out @@ -136,8 +145,8 @@ class ArtResizer(object): specified, with an inferred method. """ self.method = method or self._guess_method() - log.debug("ArtResizer method is {0}".format(self.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 @@ -184,7 +193,7 @@ class ArtResizer(object): if 'imagemagick' in out: return IMAGEMAGICK except subprocess.CalledProcessError: - pass # system32/convert.exe may be interfering + pass # system32/convert.exe may be interfering # Fall back to Web proxy method. return WEBPROXY diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index fb9b2e752..ed5aaf99e 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -166,7 +166,7 @@ def art_for_album(album, path, maxwidth=None, local_only=False): if out: break - if maxwidth: + if maxwidth and out: out = ArtResizer.shared.resize(maxwidth, out) return out From d9974081a77573c9045dbf5e7abc48bf1e276686 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 1 Nov 2012 12:39:02 -0700 Subject: [PATCH 13/14] add extension for proxied images `urllib.urlretrieve` was using the correct extension in most cases -- I think when the URL ended with .jpg -- but not in every case. This was leading to files named just "cover" and not "cover.jpg" or something else sensible. In particular, proxied URLs don't have .jpg extensions. This generates the filename manually so the source image always has an extension. --- beetsplug/fetchart.py | 31 ++++++++++++++++++++++--------- docs/plugins/fetchart.rst | 6 ++++-- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index ed5aaf99e..e5ceccc72 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -18,15 +18,18 @@ 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') @@ -36,19 +39,25 @@ def _fetch_image(url): 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 ################################################################ @@ -84,10 +93,10 @@ def aao_art(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. @@ -96,7 +105,7 @@ def aao_art(asin): image_url = m.group(1) return image_url else: - log.debug('No image found on page') + log.debug('fetchart: no image found on page') # Art from the filesystem. @@ -117,12 +126,16 @@ def art_in_path(path): 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]) diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index 359aa4dbe..fbd5eb523 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -45,8 +45,10 @@ 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. Since server-side resizing can -be slow, consider installing one of the two backends for better performance. +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. .. _PIL: http://www.pythonware.com/products/pil/ .. _ImageMagick: http://www.imagemagick.org/ From 338eb6388eccf0993d6fdfdcefe579b92e846813 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 1 Nov 2012 14:25:46 -0700 Subject: [PATCH 14/14] documentation for convert.exe problem (#64) We currently just document the fact that convert.exe can interfere with finding ImageMagick's convert binary. We can solve this with a config option easily once confit is merged. This also changes the line endings for fetchart.rst back to Unix. --- beetsplug/fetchart.py | 2 +- docs/plugins/embedart.rst | 3 +- docs/plugins/fetchart.rst | 159 ++++++++++++++++++++------------------ 3 files changed, 86 insertions(+), 78 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index e5ceccc72..567d8a64b 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -218,7 +218,7 @@ class FetchArtPlugin(BeetsPlugin): self.autofetch = ui.config_val(config, 'fetchart', 'autofetch', True, bool) self.maxwidth = int(ui.config_val(config, 'fetchart', - 'maxwidth', '0')) + 'maxwidth', '0')) if self.autofetch: # Enable two import hooks when fetching is enabled. self.import_stages = [self.fetch_art] diff --git a/docs/plugins/embedart.rst b/docs/plugins/embedart.rst index cf2639347..46b66913f 100644 --- a/docs/plugins/embedart.rst +++ b/docs/plugins/embedart.rst @@ -50,7 +50,8 @@ 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. +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 fbd5eb523..3f02937e8 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -1,76 +1,83 @@ -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 --------------------------------- - -To automatically get album art for every album you import, just enable the -plugin by putting ``fetchart`` on your config file's ``plugins`` line (see -:doc:`/plugins/index`). - -By default, beets stores album art image files alongside the music files for an -album in a file called ``cover.jpg``. To customize the name of this file, use -the :ref:`art-filename` config option. - -To disable automatic art downloading, just put this in your configuration -file:: - - [fetchart] - autofetch: no - -Manually Fetching Album Art ---------------------------- - -Use the ``fetchart`` command to download album art after albums have already -been imported:: - - $ beet fetchart [-f] [query] - -By default, the command will only look for album art when the album doesn't -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 --------------- - -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. - -.. _PIL: http://www.pythonware.com/products/pil/ -.. _ImageMagick: http://www.imagemagick.org/ - -Album Art Sources ------------------ - -Currently, this plugin searches for art in the local filesystem as well as on -the Cover Art Archive, Amazon, and AlbumArt.org (in that order). - -When looking for local album art, beets checks for image files located in the -same folder as the music files you're importing. If you have an image file -called "cover," "front," "art," "album," for "folder" alongside your music, -beets will treat it as album art and skip searching any online databases. - -When you choose to apply changes during an import, beets searches all sources -for album art. For "as-is" imports (and non-autotagged imports using the ``-A`` -flag), beets only looks for art on the local filesystem. - -Embedding Album Art -------------------- - -This plugin fetches album art but does not embed images into files' tags. To do -that, use the :doc:`/plugins/embedart`. (You'll want to have both plugins -enabled.) +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 +-------------------------------- + +To automatically get album art for every album you import, just enable the +plugin by putting ``fetchart`` on your config file's ``plugins`` line (see +:doc:`/plugins/index`). + +By default, beets stores album art image files alongside the music files for an +album in a file called ``cover.jpg``. To customize the name of this file, use +the :ref:`art-filename` config option. + +To disable automatic art downloading, just put this in your configuration +file:: + + [fetchart] + autofetch: no + +Manually Fetching Album Art +--------------------------- + +Use the ``fetchart`` command to download album art after albums have already +been imported:: + + $ beet fetchart [-f] [query] + +By default, the command will only look for album art when the album doesn't +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 +----------------- + +Currently, this plugin searches for art in the local filesystem as well as on +the Cover Art Archive, Amazon, and AlbumArt.org (in that order). + +When looking for local album art, beets checks for image files located in the +same folder as the music files you're importing. If you have an image file +called "cover," "front," "art," "album," for "folder" alongside your music, +beets will treat it as album art and skip searching any online databases. + +When you choose to apply changes during an import, beets searches all sources +for album art. For "as-is" imports (and non-autotagged imports using the ``-A`` +flag), beets only looks for art on the local filesystem. + +Embedding Album Art +------------------- + +This plugin fetches album art but does not embed images into files' tags. To do +that, use the :doc:`/plugins/embedart`. (You'll want to have both plugins +enabled.)