mirror of
https://github.com/beetbox/beets.git
synced 2025-12-28 03:22:39 +01:00
merge with master
This commit is contained in:
commit
a8383b03e9
10 changed files with 361 additions and 79 deletions
|
|
@ -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
199
beets/util/artresizer.py
Normal 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-----------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue