merge with master

This commit is contained in:
Adrian Sampson 2012-11-01 14:28:25 -07:00
commit a8383b03e9
10 changed files with 361 additions and 79 deletions

View file

@ -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

199
beets/util/artresizer.py Normal file
View file

@ -0,0 +1,199 @@
# This file is part of beets.
# Copyright 2012, Fabrice Laporte
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Abstraction layer to resize images using PIL, ImageMagick, or a
public resizing proxy if neither is available.
"""
import urllib
import subprocess
import os
from tempfile import NamedTemporaryFile
import logging
from beets import util
# Resizing methods
PIL = 1
IMAGEMAGICK = 2
WEBPROXY = 3
PROXY_URL = 'http://images.weserv.nl/'
log = logging.getLogger('beets')
class ArtResizerError(Exception):
"""Raised when an error occurs during image resizing.
"""
def call(args):
"""Execute the command indicated by `args` (a list of strings) and
return the command's output. The stderr stream is ignored. If the
command exits abnormally, a ArtResizerError is raised.
"""
try:
return util.command_output(args)
except subprocess.CalledProcessError as e:
raise ArtResizerError(
"{0} exited with status {1}".format(args[0], e.returncode)
)
def resize_url(url, maxwidth):
"""Return a proxied image URL that resizes the original image to
maxwidth (preserving aspect ratio).
"""
return '{0}?{1}'.format(PROXY_URL, urllib.urlencode({
'url': url.replace('http://',''),
'w': str(maxwidth),
}))
def temp_file_for(path):
"""Return an unused filename with the same extension as the
specified path.
"""
ext = os.path.splitext(path)[1]
with NamedTemporaryFile(suffix=ext, delete=False) as f:
return f.name
def pil_resize(maxwidth, path_in, path_out=None):
"""Resize using Python Imaging Library (PIL). Return the output path
of resized image.
"""
path_out = path_out or temp_file_for(path_in)
from PIL import Image
log.debug(u'artresizer: PIL resizing {0} to {1}'.format(
util.displayable_path(path_in), util.displayable_path(path_out)
))
try:
im = Image.open(util.syspath(path_in))
size = maxwidth, maxwidth
im.thumbnail(size, Image.ANTIALIAS)
im.save(path_out)
return path_out
except IOError:
log.error(u"PIL cannot create thumbnail for '{0}'".format(
util.displayable_path(path_in)
))
return path_in
def im_resize(maxwidth, path_in, path_out=None):
"""Resize using ImageMagick's ``convert`` tool.
tool. Return the output path of resized image.
"""
path_out = path_out or temp_file_for(path_in)
log.debug(u'artresizer: ImageMagick resizing {0} to {1}'.format(
util.displayable_path(path_in), util.displayable_path(path_out)
))
# "-resize widthxheight>" shrinks images with dimension(s) larger
# than the corresponding width and/or height dimension(s). The >
# "only shrink" flag is prefixed by ^ escape char for Windows
# compatability.
call([
'convert', util.syspath(path_in),
'-resize', '{0}x^>'.format(maxwidth), path_out
])
return path_out
BACKEND_FUNCS = {
PIL: pil_resize,
IMAGEMAGICK: im_resize,
}
class Shareable(type):
"""A pseudo-singleton metaclass that allows both shared and
non-shared instances. The ``MyClass.shared`` property holds a
lazily-created shared instance of ``MyClass`` while calling
``MyClass()`` to construct a new object works as usual.
"""
def __init__(cls, name, bases, dict):
super(Shareable, cls).__init__(name, bases, dict)
cls._instance = None
@property
def shared(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
class ArtResizer(object):
"""A singleton class that performs image resizes.
"""
__metaclass__ = Shareable
def __init__(self, method=None):
"""Create a resizer object for the given method or, if none is
specified, with an inferred method.
"""
self.method = method or self._guess_method()
log.debug(u"artresizer: method is {0}".format(self.method))
def resize(self, maxwidth, path_in, path_out=None):
"""Manipulate an image file according to the method, returning a
new path. For PIL or IMAGEMAGIC methods, resizes the image to a
temporary file. For WEBPROXY, returns `path_in` unmodified.
"""
if self.local:
func = BACKEND_FUNCS[self.method]
return func(maxwidth, path_in, path_out)
else:
return path_in
def proxy_url(self, maxwidth, url):
"""Modifies an image URL according the method, returning a new
URL. For WEBPROXY, a URL on the proxy server is returned.
Otherwise, the URL is returned unmodified.
"""
if self.local:
return url
else:
return resize_url(url, maxwidth)
@property
def local(self):
"""A boolean indicating whether the resizing method is performed
locally (i.e., PIL or IMAGEMAGICK).
"""
return self.method in BACKEND_FUNCS
@staticmethod
def _guess_method():
"""Determine which resizing method to use. Returns PIL,
IMAGEMAGICK, or WEBPROXY depending on available dependencies.
"""
# Try importing PIL.
try:
__import__('PIL', fromlist=['Image'])
return PIL
except ImportError:
pass
# Try invoking ImageMagick's "convert".
try:
out = subprocess.check_output(['convert', '--version']).lower()
if 'imagemagick' in out:
return IMAGEMAGICK
except subprocess.CalledProcessError:
pass # system32/convert.exe may be interfering
# Fall back to Web proxy method.
return WEBPROXY

View file

@ -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')

View file

@ -21,12 +21,16 @@ from beets import mediafile
from beets import ui
from beets.ui import decargs
from beets.util import syspath, normpath
from beets.util.artresizer import ArtResizer
log = logging.getLogger('beets')
def _embed(path, items):
"""Embed an image file, located at `path`, into each item.
"""
if options['maxwidth']:
path = ArtResizer.shared.resize(options['maxwidth'], syspath(path))
data = open(syspath(path), 'rb').read()
kindstr = imghdr.what(None, data)
if kindstr not in ('jpeg', 'png'):
@ -35,6 +39,7 @@ def _embed(path, items):
# Add art to each file.
log.debug('Embedding album art.')
for item in items:
try:
f = mediafile.MediaFile(syspath(item.path))
@ -48,12 +53,21 @@ def _embed(path, items):
options = {
'autoembed': True,
'maxwidth': 0,
}
class EmbedCoverArtPlugin(BeetsPlugin):
"""Allows albumart to be embedded into the actual files."""
"""Allows albumart to be embedded into the actual files.
"""
def configure(self, config):
options['autoembed'] = \
ui.config_val(config, 'embedart', 'autoembed', True, bool)
options['maxwidth'] = \
int(ui.config_val(config, 'embedart', 'maxwidth', '0'))
if options['maxwidth'] and not ArtResizer.shared.local:
options['maxwidth'] = 0
log.error("embedart: ImageMagick or PIL not found; "
"'maxwidth' option ignored")
def commands(self):
# Embed command.

View file

@ -18,50 +18,58 @@ import urllib
import re
import logging
import os
import tempfile
from beets.plugins import BeetsPlugin
from beets.util.artresizer import ArtResizer
from beets import importer
from beets import ui
from beets import util
IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg']
COVER_NAMES = ['cover', 'front', 'art', 'album', 'folder']
CONTENT_TYPES = ('image/jpeg',)
DOWNLOAD_EXTENSION = '.jpg'
log = logging.getLogger('beets')
# ART SOURCES ################################################################
def _fetch_image(url):
"""Downloads an image from a URL and checks whether it seems to
actually be an image. If so, returns a path to the downloaded image.
Otherwise, returns None.
"""
log.debug('Downloading art: %s' % url)
# Generate a temporary filename with the correct extension.
fd, fn = tempfile.mkstemp(suffix=DOWNLOAD_EXTENSION)
os.close(fd)
log.debug(u'fetchart: downloading art: {0}'.format(url))
try:
fn, headers = urllib.urlretrieve(url)
_, headers = urllib.urlretrieve(url, filename=fn)
except IOError:
log.debug('error fetching art')
return
# Make sure it's actually an image.
if headers.gettype() in CONTENT_TYPES:
log.debug('Downloaded art to: %s' % fn)
log.debug(u'fetchart: downloaded art to: {0}'.format(
util.displayable_path(fn)
))
return fn
else:
log.debug('Not an image.')
log.debug(u'fetchart: not an image')
# ART SOURCES ################################################################
# Cover Art Archive.
CAA_URL = 'http://coverartarchive.org/release/{mbid}/front-500.jpg'
def caa_art(release_id):
"""Fetch album art from the Cover Art Archive given a MusicBrainz
release ID.
"""Return the Cover Art Archive URL given a MusicBrainz release ID.
"""
url = CAA_URL.format(mbid=release_id)
return _fetch_image(url)
return CAA_URL.format(mbid=release_id)
# Art from Amazon.
@ -70,13 +78,9 @@ AMAZON_URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg'
AMAZON_INDICES = (1, 2)
def art_for_asin(asin):
"""Fetch art for an Amazon ID (ASIN) string."""
"""Generate URLs for an Amazon ID (ASIN) string."""
for index in AMAZON_INDICES:
# Fetch the image.
url = AMAZON_URL % (asin, index)
fn = _fetch_image(url)
if fn:
return fn
yield AMAZON_URL % (asin, index)
# AlbumArt.org scraper.
@ -85,23 +89,23 @@ AAO_URL = 'http://www.albumart.org/index_detail.php'
AAO_PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"'
def aao_art(asin):
"""Fetch art from AlbumArt.org."""
"""Return art URL from AlbumArt.org given an ASIN."""
# Get the page from albumart.org.
url = '%s?%s' % (AAO_URL, urllib.urlencode({'asin': asin}))
try:
log.debug('Scraping art URL: %s' % url)
log.debug(u'fetchart: scraping art URL: {0}'.format(url))
page = urllib.urlopen(url).read()
except IOError:
log.debug('Error scraping art page')
log.debug(u'fetchart: error scraping art page')
return
# Search the page for the image URL.
m = re.search(AAO_PAT, page)
if m:
image_url = m.group(1)
return _fetch_image(image_url)
return image_url
else:
log.debug('No image found on page')
log.debug('fetchart: no image found on page')
# Art from the filesystem.
@ -117,61 +121,72 @@ def art_in_path(path):
for ext in IMAGE_EXTENSIONS:
if fn.lower().endswith('.' + ext):
images.append(fn)
images.sort()
# Look for "preferred" filenames.
for name in COVER_NAMES:
for fn in images:
for fn in images:
for name in COVER_NAMES:
if fn.lower().startswith(name):
log.debug('Using well-named art file %s' % fn)
log.debug('fetchart: using well-named art file {0}'.format(
util.displayable_path(fn)
))
return os.path.join(path, fn)
# Fall back to any image in the folder.
if images:
log.debug('Using fallback art file %s' % images[0])
log.debug('fetchart: using fallback art file {0}'.format(
util.displayable_path(images[0])
))
return os.path.join(path, images[0])
# Try each source in turn.
def art_for_album(album, path, local_only=False):
"""Given an Album object, returns a path to downloaded art for the
album (or None if no art is found). If `local_only`, then only local
image files from the filesystem are returned; no network requests
are made.
def _source_urls(album):
"""Generate possible source URLs for an album's art. The URLs are
not guaranteed to work so they each need to be attempted in turn.
This allows the main `art_for_album` function to abort iteration
through this sequence early to avoid the cost of scraping when not
necessary.
"""
# Local art.
if isinstance(path, basestring):
out = art_in_path(path)
if out:
return out
if local_only:
# Abort without trying Web sources.
return
# CoverArtArchive.org.
if album.mb_albumid:
log.debug('Fetching album art for MBID {0}.'.format(album.mb_albumid))
out = caa_art(album.mb_albumid)
if out:
return out
yield caa_art(album.mb_albumid)
# Amazon and AlbumArt.org.
if album.asin:
log.debug('Fetching album art for ASIN %s.' % album.asin)
out = art_for_asin(album.asin)
if out:
return out
return aao_art(album.asin)
for url in art_for_asin(album.asin):
yield url
yield aao_art(album.asin)
# All sources failed.
log.debug('No ASIN available: no art found.')
return None
def art_for_album(album, path, maxwidth=None, local_only=False):
"""Given an Album object, returns a path to downloaded art for the
album (or None if no art is found). If `maxwidth`, then images are
resized to this maximum pixel size. If `local_only`, then only local
image files from the filesystem are returned; no network requests
are made.
"""
out = None
# Local art.
if isinstance(path, basestring):
out = art_in_path(path)
# Web art sources.
if not local_only and not out:
for url in _source_urls(album):
if maxwidth:
url = ArtResizer.shared.proxy_url(maxwidth, url)
out = _fetch_image(url)
if out:
break
if maxwidth and out:
out = ArtResizer.shared.resize(maxwidth, out)
return out
# PLUGIN LOGIC ###############################################################
def batch_fetch_art(lib, albums, force):
def batch_fetch_art(lib, albums, force, maxwidth=None):
"""Fetch album art for each of the albums. This implements the manual
fetchart CLI command.
"""
@ -179,7 +194,8 @@ def batch_fetch_art(lib, albums, force):
if album.artpath and not force:
message = 'has album art'
else:
path = art_for_album(album, None)
path = art_for_album(album, None, maxwidth)
if path:
album.set_art(path, False)
message = 'found album art'
@ -193,7 +209,7 @@ class FetchArtPlugin(BeetsPlugin):
super(FetchArtPlugin, self).__init__()
self.autofetch = True
self.maxwidth = 0
# Holds paths to downloaded images between fetching them and
# placing them in the filesystem.
self.art_paths = {}
@ -201,6 +217,8 @@ class FetchArtPlugin(BeetsPlugin):
def configure(self, config):
self.autofetch = ui.config_val(config, 'fetchart',
'autofetch', True, bool)
self.maxwidth = int(ui.config_val(config, 'fetchart',
'maxwidth', '0'))
if self.autofetch:
# Enable two import hooks when fetching is enabled.
self.import_stages = [self.fetch_art]
@ -221,7 +239,8 @@ class FetchArtPlugin(BeetsPlugin):
return
album = config.lib.get_album(task.album_id)
path = art_for_album(album, task.path, local_only=local)
path = art_for_album(album, task.path, self.maxwidth, local)
if path:
self.art_paths[task] = path
@ -244,6 +263,7 @@ class FetchArtPlugin(BeetsPlugin):
action='store_true', default=False,
help='re-download art when already present')
def func(lib, config, opts, args):
batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force)
batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force,
self.maxwidth)
cmd.func = func
return [cmd]

View file

@ -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)

View file

@ -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.

View file

@ -40,8 +40,18 @@ embedded album art:
Configuring
-----------
The plugin has one configuration option, ``autoembed``, which lets you disable
automatic album art embedding. To do so, add this to your ``~/.beetsconfig``::
``autoembed`` option lets you disable automatic album art embedding.
To do so, add this to your ``~/.beetsconfig``::
[embedart]
autoembed: no
A maximum image width can be configured as ``maxwidth`` to downscale images
before embedding them (the original image file is not altered). The resize
operation reduces image width to ``maxwidth`` pixels. The height is recomputed
so that the aspect ratio is preserved. `PIL`_ or `ImageMagick`_ is required to
use the ``maxwidth`` config option. See also :ref:`image-resizing` for further
caveats about image resizing.
.. _PIL: http://www.pythonware.com/products/pil/
.. _ImageMagick: http://www.imagemagick.org/

View file

@ -4,6 +4,7 @@ FetchArt Plugin
The ``fetchart`` plugin retrieves album art images from various sources on the
Web and stores them as image files.
Fetching Album Art During Import
--------------------------------
@ -34,6 +35,31 @@ already have it; the ``-f`` or ``--force`` switch makes it search for art
regardless. If you specify a query, only matching albums will be processed;
otherwise, the command processes every album in your library.
.. _image-resizing:
Image Resizing
--------------
A maximum image width can be configured as ``maxwidth`` to downscale fetched
images if they are too big. The resize operation reduces image width to
``maxwidth`` pixels. The height is recomputed so that the aspect ratio is
preserved.
Beets can resize images using `PIL`_, `ImageMagick`_, or a server-side resizing
proxy. If either PIL or ImageMagick is installed, beets will use those;
otherwise, it falls back to the resizing proxy. If the resizing proxy is used,
no resizing is performed for album art found on the filesystem---only downloaded
art is resized. Server-side resizing can also be slower than local resizing, so
consider installing one of the two backends for better performance.
When using ImageMagic, beets looks for the ``convert`` executable in your path.
On some versions Windows, the program can be shadowed by a system-provided
``convert.exe``. On these systems, you may need to modify your ``%PATH%``
environment variable so that ImageMagick comes first or use PIL instead.
.. _PIL: http://www.pythonware.com/products/pil/
.. _ImageMagick: http://www.imagemagick.org/
Album Art Sources
-----------------

View file

@ -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):
<img src="http://www.albumart.org/images/zoom-icon.jpg" alt="View larger image" width="17" height="15" border="0"/></a>
"""
res = fetchart.aao_art('x')
self.assertEqual(self.retriever.fetched, 'TARGET_URL')
self.assertEqual(res, 'somepath')
self.assertEqual(res, 'TARGET_URL')
def test_aao_scraper_returns_none_when_no_image_present(self):
self.page_text = "blah blah"
res = fetchart.aao_art('x')
self.assertEqual(self.retriever.fetched, None)
self.assertEqual(res, None)
class ArtImporterTest(unittest.TestCase, _common.ExtraAsserts):
@ -195,7 +189,7 @@ class ArtImporterTest(unittest.TestCase, _common.ExtraAsserts):
_common.touch(self.art_file)
self.old_afa = fetchart.art_for_album
self.afa_response = self.art_file
def art_for_album(i, p, local_only=False):
def art_for_album(i, p, maxwidth=None, local_only=False):
return self.afa_response
fetchart.art_for_album = art_for_album