mirror of
https://github.com/beetbox/beets.git
synced 2026-01-02 14:03:12 +01:00
Merge branch 'master' of github.com:sampsyo/beets
This commit is contained in:
commit
e953e6bdcb
14 changed files with 712 additions and 109 deletions
|
|
@ -116,3 +116,8 @@ match:
|
|||
required: []
|
||||
track_length_grace: 10
|
||||
track_length_max: 30
|
||||
|
||||
thumbnails:
|
||||
force: no
|
||||
auto: yes
|
||||
dolphin: no
|
||||
|
|
|
|||
|
|
@ -1023,6 +1023,8 @@ class Album(LibModel):
|
|||
"""Sets the album's cover art to the image at the given path.
|
||||
The image is copied (or moved) into place, replacing any
|
||||
existing art.
|
||||
|
||||
Sends an 'art_set' event with `self` as the sole argument.
|
||||
"""
|
||||
path = bytestring_path(path)
|
||||
oldart = self.artpath
|
||||
|
|
@ -1046,6 +1048,8 @@ class Album(LibModel):
|
|||
util.move(path, artdest)
|
||||
self.artpath = artdest
|
||||
|
||||
plugins.send('art_set', album=self)
|
||||
|
||||
def store(self):
|
||||
"""Update the database with the album information. The album's
|
||||
tracks are also updated.
|
||||
|
|
|
|||
|
|
@ -107,6 +107,36 @@ BACKEND_FUNCS = {
|
|||
}
|
||||
|
||||
|
||||
def pil_getsize(path_in):
|
||||
from PIL import Image
|
||||
try:
|
||||
im = Image.open(util.syspath(path_in))
|
||||
return im.size
|
||||
except IOError:
|
||||
log.error(u"PIL cannot compute size of '{0}'",
|
||||
util.displayable_path(path_in))
|
||||
|
||||
|
||||
def im_getsize(path_in):
|
||||
try:
|
||||
out = util.command_output(['identify', '-format', '%w %h',
|
||||
util.syspath(path_in)])
|
||||
except subprocess.CalledProcessError:
|
||||
log.warn(u'IM cannot compute size of {0}',
|
||||
util.displayable_path(path_in))
|
||||
return
|
||||
try:
|
||||
return tuple(map(int, out.split(b' ')))
|
||||
except IndexError:
|
||||
log.warn(u'Could not understand IM output: {0!r}', out)
|
||||
|
||||
|
||||
BACKEND_GET_SIZE = {
|
||||
PIL: pil_getsize,
|
||||
IMAGEMAGICK: im_getsize,
|
||||
}
|
||||
|
||||
|
||||
class Shareable(type):
|
||||
"""A pseudo-singleton metaclass that allows both shared and
|
||||
non-shared instances. The ``MyClass.shared`` property holds a
|
||||
|
|
@ -129,11 +159,10 @@ class ArtResizer(object):
|
|||
"""
|
||||
__metaclass__ = Shareable
|
||||
|
||||
def __init__(self, method=None):
|
||||
"""Create a resizer object for the given method or, if none is
|
||||
specified, with an inferred method.
|
||||
def __init__(self):
|
||||
"""Create a resizer object with an inferred method.
|
||||
"""
|
||||
self.method = self._check_method(method)
|
||||
self.method = self._check_method()
|
||||
log.debug(u"artresizer: method is {0}", self.method)
|
||||
self.can_compare = self._can_compare()
|
||||
|
||||
|
|
@ -165,47 +194,59 @@ class ArtResizer(object):
|
|||
"""
|
||||
return self.method[0] in BACKEND_FUNCS
|
||||
|
||||
def get_size(self, path_in):
|
||||
"""Return the size of an image file as an int couple (width, height)
|
||||
in pixels.
|
||||
|
||||
Only available locally
|
||||
"""
|
||||
if self.local:
|
||||
func = BACKEND_GET_SIZE[self.method[0]]
|
||||
return func(path_in)
|
||||
|
||||
def _can_compare(self):
|
||||
"""A boolean indicating whether image comparison is available"""
|
||||
|
||||
return self.method[0] == IMAGEMAGICK and self.method[1] > (6, 8, 7)
|
||||
|
||||
@staticmethod
|
||||
def _check_method(method=None):
|
||||
"""A tuple indicating whether current method is available and its
|
||||
version. If no method is given, it returns a supported one.
|
||||
"""
|
||||
# Guess available method
|
||||
if not method:
|
||||
for m in [IMAGEMAGICK, PIL]:
|
||||
_, version = ArtResizer._check_method(m)
|
||||
if version:
|
||||
return (m, version)
|
||||
return (WEBPROXY, (0))
|
||||
def _check_method():
|
||||
"""Return a tuple indicating an available method and its version."""
|
||||
version = has_IM()
|
||||
if version:
|
||||
return IMAGEMAGICK, version
|
||||
|
||||
if method == IMAGEMAGICK:
|
||||
version = has_PIL()
|
||||
if version:
|
||||
return PIL, version
|
||||
|
||||
# Try invoking ImageMagick's "convert".
|
||||
try:
|
||||
out = util.command_output([b'identify', b'--version'])
|
||||
return WEBPROXY, (0)
|
||||
|
||||
if 'imagemagick' in out.lower():
|
||||
pattern = r".+ (\d+)\.(\d+)\.(\d+).*"
|
||||
match = re.search(pattern, out)
|
||||
if match:
|
||||
return (IMAGEMAGICK,
|
||||
(int(match.group(1)),
|
||||
int(match.group(2)),
|
||||
int(match.group(3))))
|
||||
return (IMAGEMAGICK, (0))
|
||||
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return (IMAGEMAGICK, None)
|
||||
def has_IM():
|
||||
"""Return Image Magick version or None if it is unavailable
|
||||
Try invoking ImageMagick's "convert"."""
|
||||
try:
|
||||
out = util.command_output([b'identify', b'--version'])
|
||||
|
||||
if method == PIL:
|
||||
# Try importing PIL.
|
||||
try:
|
||||
__import__('PIL', fromlist=['Image'])
|
||||
return (PIL, (0))
|
||||
except ImportError:
|
||||
return (PIL, None)
|
||||
if 'imagemagick' in out.lower():
|
||||
pattern = r".+ (\d+)\.(\d+)\.(\d+).*"
|
||||
match = re.search(pattern, out)
|
||||
if match:
|
||||
return (int(match.group(1)),
|
||||
int(match.group(2)),
|
||||
int(match.group(3)))
|
||||
return (0,)
|
||||
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def has_PIL():
|
||||
"""Return Image Magick version or None if it is unavailable
|
||||
Try importing PIL."""
|
||||
try:
|
||||
__import__('PIL', fromlist=['Image'])
|
||||
return (0,)
|
||||
except ImportError:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
self._log.warning(u"ImageMagick 6.8.7 or higher not installed; "
|
||||
u"'compare_threshold' option ignored")
|
||||
|
||||
self.register_listener('album_imported', self.album_imported)
|
||||
self.register_listener('art_set', self.process_album)
|
||||
|
||||
def commands(self):
|
||||
# Embed command.
|
||||
|
|
@ -125,10 +125,10 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
|
||||
return [embed_cmd, extract_cmd, clear_cmd]
|
||||
|
||||
def album_imported(self, lib, album):
|
||||
"""Automatically embed art into imported albums.
|
||||
def process_album(self, album):
|
||||
"""Automatically embed art after art has been set
|
||||
"""
|
||||
if album.artpath and self.config['auto']:
|
||||
if self.config['auto']:
|
||||
max_width = self.config['maxwidth'].get(int)
|
||||
self.embed_album(album, max_width, True)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,51 +19,18 @@ from __future__ import (division, absolute_import, print_function,
|
|||
unicode_literals)
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand
|
||||
from beets.ui import decargs
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def create_file(albumpath, artfile):
|
||||
file_contents = "[Desktop Entry]\nIcon=./" + artfile
|
||||
outfilename = os.path.join(albumpath, ".directory")
|
||||
|
||||
if not os.path.exists(outfilename):
|
||||
file = open(outfilename, 'w')
|
||||
file.write(file_contents)
|
||||
file.close()
|
||||
from beets import ui
|
||||
|
||||
|
||||
class FreedesktopPlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(FreedesktopPlugin, self).__init__()
|
||||
self.config.add({
|
||||
'auto': False
|
||||
})
|
||||
self.register_listener('album_imported', self.imported)
|
||||
|
||||
def commands(self):
|
||||
freedesktop_command = Subcommand("freedesktop",
|
||||
help="Create .directory files")
|
||||
freedesktop_command.func = self.process_query
|
||||
return [freedesktop_command]
|
||||
deprecated = ui.Subcommand("freedesktop", help="Print a message to "
|
||||
"redirect to thumbnails --dolphin")
|
||||
deprecated.func = self.deprecation_message
|
||||
return [deprecated]
|
||||
|
||||
def imported(self, lib, album):
|
||||
automatic = self.config['auto'].get(bool)
|
||||
if not automatic:
|
||||
return
|
||||
self.process_album(album)
|
||||
|
||||
def process_query(self, lib, opts, args):
|
||||
for album in lib.albums(decargs(args)):
|
||||
self.process_album(album)
|
||||
|
||||
def process_album(self, album):
|
||||
albumpath = album.item_dir()
|
||||
if album.artpath:
|
||||
fullartpath = album.artpath
|
||||
artfile = os.path.split(fullartpath)[1]
|
||||
create_file(albumpath, artfile)
|
||||
else:
|
||||
self._log.debug(u'album has no art')
|
||||
def deprecation_message(self, lib, opts, args):
|
||||
ui.print_("This plugin is deprecated. Its functionality is superseded "
|
||||
"by the 'thumbnails' plugin")
|
||||
ui.print_("'thumbnails --dolphin' replaces freedesktop. See doc & "
|
||||
"changelog for more information")
|
||||
|
|
|
|||
271
beetsplug/thumbnails.py
Normal file
271
beetsplug/thumbnails.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2015, Bruno Cauet
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Create freedesktop.org-compliant thumnails for album folders
|
||||
|
||||
This plugin is POSIX-only.
|
||||
Spec: standards.freedesktop.org/thumbnail-spec/latest/index.html
|
||||
"""
|
||||
|
||||
from __future__ import (division, absolute_import, print_function,
|
||||
unicode_literals)
|
||||
|
||||
from hashlib import md5
|
||||
import os
|
||||
import shutil
|
||||
from itertools import chain
|
||||
from pathlib import PurePosixPath
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
|
||||
from xdg import BaseDirectory
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand, decargs
|
||||
from beets import util
|
||||
from beets.util.artresizer import ArtResizer, has_IM, has_PIL
|
||||
|
||||
|
||||
BASE_DIR = os.path.join(BaseDirectory.xdg_cache_home, "thumbnails")
|
||||
NORMAL_DIR = os.path.join(BASE_DIR, "normal")
|
||||
LARGE_DIR = os.path.join(BASE_DIR, "large")
|
||||
|
||||
|
||||
class ThumbnailsPlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(ThumbnailsPlugin, self).__init__()
|
||||
self.config.add({
|
||||
'auto': True,
|
||||
'force': False,
|
||||
'dolphin': False,
|
||||
})
|
||||
|
||||
self.write_metadata = None
|
||||
if self.config['auto'] and self._check_local_ok():
|
||||
self.register_listener('art_set', self.process_album)
|
||||
|
||||
def commands(self):
|
||||
thumbnails_command = Subcommand("thumbnails",
|
||||
help="Create album thumbnails")
|
||||
thumbnails_command.parser.add_option(
|
||||
'-f', '--force', dest='force', action='store_true', default=False,
|
||||
help='force regeneration of thumbnails deemed fine (existing & '
|
||||
'recent enough)')
|
||||
thumbnails_command.parser.add_option(
|
||||
'--dolphin', dest='dolphin', action='store_true', default=False,
|
||||
help="create Dolphin-compatible thumbnail information (for KDE)")
|
||||
thumbnails_command.func = self.process_query
|
||||
|
||||
return [thumbnails_command]
|
||||
|
||||
def process_query(self, lib, opts, args):
|
||||
self.config.set_args(opts)
|
||||
if self._check_local_ok():
|
||||
for album in lib.albums(decargs(args)):
|
||||
self.process_album(album)
|
||||
|
||||
def _check_local_ok(self):
|
||||
"""Check that's everythings ready:
|
||||
- local capability to resize images
|
||||
- thumbnail dirs exist (create them if needed)
|
||||
- detect whether we'll use PIL or IM
|
||||
- detect whether we'll use GIO or Python to get URIs
|
||||
"""
|
||||
if not ArtResizer.shared.local:
|
||||
self._log.warning("No local image resizing capabilities, "
|
||||
"cannot generate thumbnails")
|
||||
return False
|
||||
|
||||
for dir in (NORMAL_DIR, LARGE_DIR):
|
||||
if not os.path.exists(dir):
|
||||
os.makedirs(dir)
|
||||
|
||||
if has_IM():
|
||||
self.write_metadata = write_metadata_im
|
||||
tool = "IM"
|
||||
else:
|
||||
assert has_PIL() # since we're local
|
||||
self.write_metadata = write_metadata_pil
|
||||
tool = "PIL"
|
||||
self._log.debug("using {0} to write metadata", tool)
|
||||
|
||||
uri_getter = GioURI()
|
||||
if not uri_getter.available:
|
||||
uri_getter = PathlibURI()
|
||||
self._log.debug("using {0.name} to compute URIs", uri_getter)
|
||||
self.get_uri = uri_getter.uri
|
||||
|
||||
return True
|
||||
|
||||
def process_album(self, album):
|
||||
"""Produce thumbnails for the album folder.
|
||||
"""
|
||||
self._log.debug(u'generating thumbnail for {0}', album)
|
||||
if not album.artpath:
|
||||
self._log.info(u'album {0} has no art', album)
|
||||
return
|
||||
|
||||
if self.config['dolphin']:
|
||||
self.make_dolphin_cover_thumbnail(album)
|
||||
|
||||
size = ArtResizer.shared.get_size(album.artpath)
|
||||
if not size:
|
||||
self._log.warning('problem getting the picture size for {0}',
|
||||
album.artpath)
|
||||
return
|
||||
|
||||
wrote = True
|
||||
if max(size) >= 256:
|
||||
wrote &= self.make_cover_thumbnail(album, 256, LARGE_DIR)
|
||||
wrote &= self.make_cover_thumbnail(album, 128, NORMAL_DIR)
|
||||
|
||||
if wrote:
|
||||
self._log.info('wrote thumbnail for {0}', album)
|
||||
else:
|
||||
self._log.info('nothing to do for {0}', album)
|
||||
|
||||
def make_cover_thumbnail(self, album, size, target_dir):
|
||||
"""Make a thumbnail of given size for `album` and put it in
|
||||
`target_dir`.
|
||||
"""
|
||||
target = os.path.join(target_dir, self.thumbnail_file_name(album.path))
|
||||
|
||||
if os.path.exists(target) and \
|
||||
os.stat(target).st_mtime > os.stat(album.artpath).st_mtime:
|
||||
if self.config['force']:
|
||||
self._log.debug("found a suitable {1}x{1} thumbnail for {0}, "
|
||||
"forcing regeneration", album, size)
|
||||
else:
|
||||
self._log.debug("{1}x{1} thumbnail for {0} exists and is "
|
||||
"recent enough", album, size)
|
||||
return False
|
||||
resized = ArtResizer.shared.resize(size, album.artpath,
|
||||
util.syspath(target))
|
||||
self.add_tags(album, util.syspath(resized))
|
||||
shutil.move(resized, target)
|
||||
return True
|
||||
|
||||
def thumbnail_file_name(self, path):
|
||||
"""Compute the thumbnail file name
|
||||
See http://standards.freedesktop.org/thumbnail-spec/latest/x227.html
|
||||
"""
|
||||
uri = self.get_uri(path)
|
||||
hash = md5(uri).hexdigest()
|
||||
return b"{0}.png".format(hash)
|
||||
|
||||
def add_tags(self, album, image_path):
|
||||
"""Write required metadata to the thumbnail
|
||||
See http://standards.freedesktop.org/thumbnail-spec/latest/x142.html
|
||||
"""
|
||||
metadata = {"Thumb::URI": self.get_uri(album.artpath),
|
||||
"Thumb::MTime": unicode(os.stat(album.artpath).st_mtime)}
|
||||
try:
|
||||
self.write_metadata(image_path, metadata)
|
||||
except Exception:
|
||||
self._log.exception("could not write metadata to {0}",
|
||||
util.displayable_path(image_path))
|
||||
|
||||
def make_dolphin_cover_thumbnail(self, album):
|
||||
outfilename = os.path.join(album.path, b".directory")
|
||||
if os.path.exists(outfilename):
|
||||
return
|
||||
artfile = os.path.split(album.artpath)[1]
|
||||
with open(outfilename, 'w') as f:
|
||||
f.write(b"[Desktop Entry]\nIcon=./{0}".format(artfile))
|
||||
f.close()
|
||||
self._log.debug("Wrote file {0}", util.displayable_path(outfilename))
|
||||
|
||||
|
||||
def write_metadata_im(file, metadata):
|
||||
"""Enrich the file metadata with `metadata` dict thanks to IM."""
|
||||
command = ['convert', file] + \
|
||||
list(chain.from_iterable(('-set', k, v)
|
||||
for k, v in metadata.items())) + [file]
|
||||
util.command_output(command)
|
||||
return True
|
||||
|
||||
|
||||
def write_metadata_pil(file, metadata):
|
||||
"""Enrich the file metadata with `metadata` dict thanks to PIL."""
|
||||
from PIL import Image, PngImagePlugin
|
||||
im = Image.open(file)
|
||||
meta = PngImagePlugin.PngInfo()
|
||||
for k, v in metadata.items():
|
||||
meta.add_text(k, v, 0)
|
||||
im.save(file, "PNG", pnginfo=meta)
|
||||
return True
|
||||
|
||||
|
||||
class URIGetter(object):
|
||||
available = False
|
||||
name = "Abstract base"
|
||||
|
||||
def uri(self, path):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class PathlibURI(URIGetter):
|
||||
available = True
|
||||
name = "Python Pathlib"
|
||||
|
||||
def uri(self, path):
|
||||
return PurePosixPath(path).as_uri()
|
||||
|
||||
|
||||
class GioURI(URIGetter):
|
||||
"""Use gio URI function g_file_get_uri. Paths must be utf-8 encoded.
|
||||
"""
|
||||
name = "GIO"
|
||||
|
||||
def __init__(self):
|
||||
self.libgio = self.get_library()
|
||||
self.available = bool(self.libgio)
|
||||
if self.available:
|
||||
self.libgio.g_type_init() # for glib < 2.36
|
||||
self.libgio.g_file_new_for_path.restype = ctypes.c_void_p
|
||||
self.libgio.g_file_get_uri.argtypes = [ctypes.c_void_p]
|
||||
self.libgio.g_object_unref.argtypes = [ctypes.c_void_p]
|
||||
|
||||
def get_library(self):
|
||||
lib_name = ctypes.util.find_library("gio-2")
|
||||
try:
|
||||
return ctypes.cdll.LoadLibrary(lib_name)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def uri(self, path):
|
||||
g_file_ptr = self.libgio.g_file_new_for_path(path)
|
||||
if not g_file_ptr:
|
||||
raise RuntimeError("No gfile pointer received for {0}".format(
|
||||
util.displayable_path(path)))
|
||||
|
||||
try:
|
||||
uri_ptr = self.libgio.g_file_get_uri(g_file_ptr)
|
||||
except:
|
||||
raise
|
||||
finally:
|
||||
self.libgio.g_object_unref(g_file_ptr)
|
||||
if not uri_ptr:
|
||||
self.libgio.g_free(uri_ptr)
|
||||
raise RuntimeError("No URI received from the gfile pointer for "
|
||||
"{0}".format(util.displayable_path(path)))
|
||||
|
||||
try:
|
||||
uri = ctypes.c_char_p(uri_ptr).value
|
||||
except:
|
||||
raise
|
||||
finally:
|
||||
self.libgio.g_free(uri_ptr)
|
||||
return uri
|
||||
|
|
@ -57,6 +57,10 @@ Features:
|
|||
``art_filename`` configuration option. :bug:`1258`
|
||||
* :doc:`/plugins/fetchart`: There's a new Wikipedia image source that uses
|
||||
DBpedia to find albums. Thanks to Tom Jaspers. :bug:`1194`
|
||||
* A new :doc:`/plugins/thumbnails` generates thumbnails with cover art for
|
||||
album folders for all freedesktop.org-compliant file managers. This replaces
|
||||
the :doc:`/plugins/freedesktop` which only worked with the Dolphin file
|
||||
manager.
|
||||
* :doc:`/plugins/info`: New options ``-i`` to display only given
|
||||
properties. :bug:`1287`
|
||||
* A new ``filesize`` field on items indicates the number of bytes in the file.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Embedding Art Automatically
|
|||
To automatically embed discovered album art into imported files, just enable
|
||||
the ``embedart`` plugin (see :doc:`/plugins/index`). You'll also want to enable the
|
||||
:doc:`/plugins/fetchart` to obtain the images to be embedded. Art will be
|
||||
embedded after each album is added to the library.
|
||||
embedded after each album has its cover art set.
|
||||
|
||||
This behavior can be disabled with the ``auto`` config option (see below).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,6 @@
|
|||
Freedesktop Plugin
|
||||
==================
|
||||
|
||||
The ``freedesktop`` plugin creates .directory files in your album folders.
|
||||
This lets Freedesktop.org compliant file managers such as Dolphin or Nautilus
|
||||
to use an album's cover art as the folder's thumbnail.
|
||||
|
||||
To use the ``freedesktop`` plugin, enable it (see :doc:`/plugins/index`).
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
To configure the plugin, make a ``freedesktop:`` section in your configuration
|
||||
file. The only available option is:
|
||||
|
||||
- **auto**: Create .directory files automatically during import.
|
||||
Default: ``no``.
|
||||
|
||||
Creating .directory Files Manually
|
||||
----------------------------------
|
||||
|
||||
The ``freedesktop`` command provided by this plugin creates .directory files
|
||||
for albums that match a query (see :doc:`/reference/query`). For example, ``beet
|
||||
freedesktop man the animal cannon`` will create the .directory file for the
|
||||
folder containing the album Man the Animal Cannon.
|
||||
The ``freedesktop`` plugin created .directory files in your album folders.
|
||||
This plugin is now deprecated and replaced by the :doc:`/plugins/thumbnails`
|
||||
with the `dolphin` option enabled.
|
||||
|
|
|
|||
|
|
@ -41,10 +41,10 @@ Each plugin has its own set of options that can be defined in a section bearing
|
|||
echonest
|
||||
embedart
|
||||
fetchart
|
||||
freedesktop
|
||||
fromfilename
|
||||
ftintitle
|
||||
fuzzy
|
||||
freedesktop
|
||||
ihate
|
||||
importadded
|
||||
importfeeds
|
||||
|
|
@ -70,6 +70,7 @@ Each plugin has its own set of options that can be defined in a section bearing
|
|||
smartplaylist
|
||||
spotify
|
||||
the
|
||||
thumbnails
|
||||
types
|
||||
web
|
||||
zero
|
||||
|
|
@ -126,7 +127,6 @@ Path Formats
|
|||
Interoperability
|
||||
----------------
|
||||
|
||||
* :doc:`freedesktop`: Create .directory files in album folders.
|
||||
* :doc:`importfeeds`: Keep track of imported files via ``.m3u`` playlist file(s) or symlinks.
|
||||
* :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library
|
||||
changes.
|
||||
|
|
@ -134,6 +134,7 @@ Interoperability
|
|||
* :doc:`plexupdate`: Automatically notifies `Plex`_ whenever the beets library
|
||||
changes.
|
||||
* :doc:`smartplaylist`: Generate smart playlists based on beets queries.
|
||||
* :doc:`thumbnails`: Get thumbnails with the cover art on your album folders.
|
||||
|
||||
|
||||
.. _Plex: http://plex.tv
|
||||
|
|
|
|||
38
docs/plugins/thumbnails.rst
Normal file
38
docs/plugins/thumbnails.rst
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
Thumbnails Plugin
|
||||
==================
|
||||
|
||||
The ``thumbnails`` plugin creates thumbnails your for album folders with the
|
||||
album cover. This works on freedesktop.org-compliant file managers such as
|
||||
Nautilus or Thunar, and is therefore POSIX-only.
|
||||
|
||||
To use the ``thumbnails`` plugin, enable it (see :doc:`/plugins/index`) as well
|
||||
as the :doc:`/plugins/fetchart`. You'll need 2 additional python packages:
|
||||
`pyxdg` and `pathlib`.
|
||||
|
||||
``thumbnails`` needs to resize the covers, and therefore requires either
|
||||
`ImageMagick`_ or `PIL`_.
|
||||
|
||||
.. _PIL: http://www.pythonware.com/products/pil/
|
||||
.. _ImageMagick: http://www.imagemagick.org/
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
To configure the plugin, make a ``thumbnails`` section in your configuration
|
||||
file. The available options are
|
||||
|
||||
- **auto**: Whether the thumbnail should be automatically set on import.
|
||||
Default: ``yes``.
|
||||
- **force**: Generate the thumbnail even when there's one that seems fine (more
|
||||
recent than the cover art).
|
||||
Default: ``no``.
|
||||
- **dolphin**: Generate dolphin-compatible thumbnails. Dolphin (KDE file
|
||||
explorer) does not respect freedesktop.org's standard on thumbnails. This
|
||||
functionality replaces the :doc:`/plugins/freedesktop`
|
||||
Default: ``no``
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
The ``thumbnails`` command provided by this plugin creates a thumbnail for
|
||||
albums that match a query (see :doc:`/reference/query`).
|
||||
3
setup.py
3
setup.py
|
|
@ -93,6 +93,8 @@ setup(
|
|||
'pylast',
|
||||
'rarfile',
|
||||
'responses',
|
||||
'pyxdg',
|
||||
'pathlib',
|
||||
],
|
||||
|
||||
# Plugin (optional) dependencies:
|
||||
|
|
@ -105,6 +107,7 @@ setup(
|
|||
'mpdstats': ['python-mpd'],
|
||||
'web': ['flask', 'flask-cors'],
|
||||
'import': ['rarfile'],
|
||||
'thumbnails': ['pathlib', 'pyxdg'],
|
||||
},
|
||||
# Non-Python/non-PyPI plugin dependencies:
|
||||
# replaygain: mp3gain || aacgain
|
||||
|
|
|
|||
286
test/test_thumbnails.py
Normal file
286
test/test_thumbnails.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2015, Bruno Cauet
|
||||
#
|
||||
# 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.
|
||||
|
||||
from __future__ import (division, absolute_import, print_function,
|
||||
unicode_literals)
|
||||
|
||||
import os.path
|
||||
from mock import Mock, patch, call
|
||||
from tempfile import mkdtemp
|
||||
from shutil import rmtree
|
||||
|
||||
from test._common import unittest
|
||||
from test.helper import TestHelper
|
||||
|
||||
from beetsplug.thumbnails import (ThumbnailsPlugin, NORMAL_DIR, LARGE_DIR,
|
||||
write_metadata_im, write_metadata_pil,
|
||||
PathlibURI, GioURI)
|
||||
|
||||
|
||||
class ThumbnailsTest(unittest.TestCase, TestHelper):
|
||||
def setUp(self):
|
||||
self.setup_beets()
|
||||
|
||||
def tearDown(self):
|
||||
self.teardown_beets()
|
||||
|
||||
@patch('beetsplug.thumbnails.util')
|
||||
def test_write_metadata_im(self, mock_util):
|
||||
metadata = {"a": "A", "b": "B"}
|
||||
write_metadata_im("foo", metadata)
|
||||
try:
|
||||
command = "convert foo -set a A -set b B foo".split(' ')
|
||||
mock_util.command_output.assert_called_once_with(command)
|
||||
except AssertionError:
|
||||
command = "convert foo -set b B -set a A foo".split(' ')
|
||||
mock_util.command_output.assert_called_once_with(command)
|
||||
|
||||
@patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok')
|
||||
@patch('beetsplug.thumbnails.os.stat')
|
||||
def test_add_tags(self, mock_stat, _):
|
||||
plugin = ThumbnailsPlugin()
|
||||
plugin.write_metadata = Mock()
|
||||
plugin.get_uri = Mock(side_effect={b"/path/to/cover":
|
||||
"COVER_URI"}.__getitem__)
|
||||
album = Mock(artpath=b"/path/to/cover")
|
||||
mock_stat.return_value.st_mtime = 12345
|
||||
|
||||
plugin.add_tags(album, b"/path/to/thumbnail")
|
||||
|
||||
metadata = {"Thumb::URI": b"COVER_URI",
|
||||
"Thumb::MTime": "12345"}
|
||||
plugin.write_metadata.assert_called_once_with(b"/path/to/thumbnail",
|
||||
metadata)
|
||||
mock_stat.assert_called_once_with(album.artpath)
|
||||
|
||||
@patch('beetsplug.thumbnails.os')
|
||||
@patch('beetsplug.thumbnails.ArtResizer')
|
||||
@patch('beetsplug.thumbnails.has_IM')
|
||||
@patch('beetsplug.thumbnails.has_PIL')
|
||||
@patch('beetsplug.thumbnails.GioURI')
|
||||
def test_check_local_ok(self, mock_giouri, mock_pil, mock_im,
|
||||
mock_artresizer, mock_os):
|
||||
# test local resizing capability
|
||||
mock_artresizer.shared.local = False
|
||||
plugin = ThumbnailsPlugin()
|
||||
self.assertFalse(plugin._check_local_ok())
|
||||
|
||||
# test dirs creation
|
||||
mock_artresizer.shared.local = True
|
||||
|
||||
def exists(path):
|
||||
if path == NORMAL_DIR:
|
||||
return False
|
||||
if path == LARGE_DIR:
|
||||
return True
|
||||
raise ValueError("unexpected path {0!r}".format(path))
|
||||
mock_os.path.exists = exists
|
||||
plugin = ThumbnailsPlugin()
|
||||
mock_os.makedirs.assert_called_once_with(NORMAL_DIR)
|
||||
self.assertTrue(plugin._check_local_ok())
|
||||
|
||||
# test metadata writer function
|
||||
mock_os.path.exists = lambda _: True
|
||||
mock_pil.return_value = False
|
||||
mock_im.return_value = False
|
||||
with self.assertRaises(AssertionError):
|
||||
ThumbnailsPlugin()
|
||||
|
||||
mock_pil.return_value = True
|
||||
self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_pil)
|
||||
|
||||
mock_im.return_value = True
|
||||
self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_im)
|
||||
|
||||
mock_pil.return_value = False
|
||||
self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_im)
|
||||
|
||||
self.assertTrue(ThumbnailsPlugin()._check_local_ok())
|
||||
|
||||
# test URI getter function
|
||||
giouri_inst = mock_giouri.return_value
|
||||
giouri_inst.available = True
|
||||
self.assertEqual(ThumbnailsPlugin().get_uri, giouri_inst.uri)
|
||||
|
||||
giouri_inst.available = False
|
||||
self.assertEqual(ThumbnailsPlugin().get_uri.im_class, PathlibURI)
|
||||
|
||||
@patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok')
|
||||
@patch('beetsplug.thumbnails.ArtResizer')
|
||||
@patch('beetsplug.thumbnails.util')
|
||||
@patch('beetsplug.thumbnails.os')
|
||||
@patch('beetsplug.thumbnails.shutil')
|
||||
def test_make_cover_thumbnail(self, mock_shutils, mock_os, mock_util,
|
||||
mock_artresizer, _):
|
||||
mock_os.path.join = os.path.join # don't mock that function
|
||||
plugin = ThumbnailsPlugin()
|
||||
plugin.add_tags = Mock()
|
||||
|
||||
album = Mock(artpath=b"/path/to/art")
|
||||
mock_util.syspath.side_effect = lambda x: x
|
||||
plugin.thumbnail_file_name = Mock(return_value="md5")
|
||||
mock_os.path.exists.return_value = False
|
||||
|
||||
def os_stat(target):
|
||||
if target == b"/thumbnail/dir/md5":
|
||||
return Mock(st_mtime=1)
|
||||
elif target == b"/path/to/art":
|
||||
return Mock(st_mtime=2)
|
||||
else:
|
||||
raise ValueError("invalid target {0}".format(target))
|
||||
mock_os.stat.side_effect = os_stat
|
||||
|
||||
plugin.make_cover_thumbnail(album, 12345, b"/thumbnail/dir")
|
||||
|
||||
mock_os.path.exists.assert_called_once_with(b"/thumbnail/dir/md5")
|
||||
mock_os.stat.has_calls([call(b"/thumbnail/dir/md5"),
|
||||
call(b"/path/to/art")], any_order=True)
|
||||
|
||||
resize = mock_artresizer.shared.resize
|
||||
resize.assert_called_once_with(12345, b"/path/to/art",
|
||||
b"/thumbnail/dir/md5")
|
||||
plugin.add_tags.assert_called_once_with(album, resize.return_value)
|
||||
mock_shutils.move.assert_called_once_with(resize.return_value,
|
||||
b"/thumbnail/dir/md5")
|
||||
|
||||
# now test with recent thumbnail & with force
|
||||
mock_os.path.exists.return_value = True
|
||||
plugin.force = False
|
||||
resize.reset_mock()
|
||||
|
||||
def os_stat(target):
|
||||
if target == b"/thumbnail/dir/md5":
|
||||
return Mock(st_mtime=3)
|
||||
elif target == b"/path/to/art":
|
||||
return Mock(st_mtime=2)
|
||||
else:
|
||||
raise ValueError("invalid target {0}".format(target))
|
||||
mock_os.stat.side_effect = os_stat
|
||||
|
||||
plugin.make_cover_thumbnail(album, 12345, b"/thumbnail/dir")
|
||||
self.assertEqual(resize.call_count, 0)
|
||||
|
||||
# and with force
|
||||
plugin.config['force'] = True
|
||||
plugin.make_cover_thumbnail(album, 12345, b"/thumbnail/dir")
|
||||
resize.assert_called_once_with(12345, b"/path/to/art",
|
||||
b"/thumbnail/dir/md5")
|
||||
|
||||
@patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok')
|
||||
def test_make_dolphin_cover_thumbnail(self, _):
|
||||
plugin = ThumbnailsPlugin()
|
||||
tmp = mkdtemp()
|
||||
album = Mock(path=tmp,
|
||||
artpath=os.path.join(tmp, b"cover.jpg"))
|
||||
plugin.make_dolphin_cover_thumbnail(album)
|
||||
with open(os.path.join(tmp, b".directory"), "rb") as f:
|
||||
self.assertEqual(f.read(), b"[Desktop Entry]\nIcon=./cover.jpg")
|
||||
|
||||
# not rewritten when it already exists (yup that's a big limitation)
|
||||
album.artpath = b"/my/awesome/art.tiff"
|
||||
plugin.make_dolphin_cover_thumbnail(album)
|
||||
with open(os.path.join(tmp, b".directory"), "rb") as f:
|
||||
self.assertEqual(f.read(), b"[Desktop Entry]\nIcon=./cover.jpg")
|
||||
|
||||
rmtree(tmp)
|
||||
|
||||
@patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok')
|
||||
@patch('beetsplug.thumbnails.ArtResizer')
|
||||
def test_process_album(self, mock_artresizer, _):
|
||||
get_size = mock_artresizer.shared.get_size
|
||||
|
||||
plugin = ThumbnailsPlugin()
|
||||
make_cover = plugin.make_cover_thumbnail = Mock(return_value=True)
|
||||
make_dolphin = plugin.make_dolphin_cover_thumbnail = Mock()
|
||||
|
||||
# no art
|
||||
album = Mock(artpath=None)
|
||||
plugin.process_album(album)
|
||||
self.assertEqual(get_size.call_count, 0)
|
||||
self.assertEqual(make_dolphin.call_count, 0)
|
||||
|
||||
# cannot get art size
|
||||
album.artpath = b"/path/to/art"
|
||||
get_size.return_value = None
|
||||
plugin.process_album(album)
|
||||
get_size.assert_called_once_with(b"/path/to/art")
|
||||
self.assertEqual(make_cover.call_count, 0)
|
||||
|
||||
# dolphin tests
|
||||
plugin.config['dolphin'] = False
|
||||
plugin.process_album(album)
|
||||
self.assertEqual(make_dolphin.call_count, 0)
|
||||
|
||||
plugin.config['dolphin'] = True
|
||||
plugin.process_album(album)
|
||||
make_dolphin.assert_called_once_with(album)
|
||||
|
||||
# small art
|
||||
get_size.return_value = 200, 200
|
||||
plugin.process_album(album)
|
||||
make_cover.assert_called_once_with(album, 128, NORMAL_DIR)
|
||||
|
||||
# big art
|
||||
make_cover.reset_mock()
|
||||
get_size.return_value = 500, 500
|
||||
plugin.process_album(album)
|
||||
make_cover.has_calls([call(album, 128, NORMAL_DIR),
|
||||
call(album, 256, LARGE_DIR)], any_order=True)
|
||||
|
||||
@patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok')
|
||||
@patch('beetsplug.thumbnails.decargs')
|
||||
def test_invokations(self, mock_decargs, _):
|
||||
plugin = ThumbnailsPlugin()
|
||||
plugin.process_album = Mock()
|
||||
album = Mock()
|
||||
|
||||
plugin.process_album.reset_mock()
|
||||
lib = Mock()
|
||||
album2 = Mock()
|
||||
lib.albums.return_value = [album, album2]
|
||||
plugin.process_query(lib, Mock(), None)
|
||||
lib.albums.assert_called_once_with(mock_decargs.return_value)
|
||||
plugin.process_album.has_calls([call(album), call(album2)],
|
||||
any_order=True)
|
||||
|
||||
@patch('beetsplug.thumbnails.BaseDirectory')
|
||||
def test_thumbnail_file_name(self, mock_basedir):
|
||||
plug = ThumbnailsPlugin()
|
||||
plug.get_uri = Mock(return_value="file:///my/uri")
|
||||
self.assertEqual(plug.thumbnail_file_name("idontcare"),
|
||||
b"9488f5797fbe12ffb316d607dfd93d04.png")
|
||||
|
||||
def test_uri(self):
|
||||
gio = GioURI()
|
||||
plib = PathlibURI()
|
||||
if not gio.available:
|
||||
self.skip("GIO library not found")
|
||||
|
||||
self.assertEqual(gio.uri("/foo"), b"file:///") # silent fail
|
||||
self.assertEqual(gio.uri(b"/foo"), b"file:///foo")
|
||||
self.assertEqual(gio.uri(b"/foo!"), b"file:///foo!")
|
||||
self.assertEqual(plib.uri(b"/foo!"), b"file:///foo%21")
|
||||
self.assertEqual(
|
||||
gio.uri(b'/music/\xec\x8b\xb8\xec\x9d\xb4'),
|
||||
b'file:///music/%EC%8B%B8%EC%9D%B4')
|
||||
self.assertEqual(
|
||||
plib.uri(b'/music/\xec\x8b\xb8\xec\x9d\xb4'),
|
||||
b'file:///music/%EC%8B%B8%EC%9D%B4')
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
if __name__ == b'__main__':
|
||||
unittest.main(defaultTest='suite')
|
||||
2
tox.ini
2
tox.ini
|
|
@ -16,6 +16,8 @@ deps =
|
|||
pylast
|
||||
rarfile
|
||||
responses
|
||||
pathlib
|
||||
pyxdg
|
||||
commands =
|
||||
nosetests {posargs}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue