From 7f2aa44ac6fbe45c45a40c063dd86de63482a177 Mon Sep 17 00:00:00 2001 From: Fabrice Laporte Date: Sun, 28 Oct 2012 15:36:42 +0100 Subject: [PATCH] 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