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