Merge branch 'master' of github.com:sampsyo/beets

This commit is contained in:
Adrian Sampson 2015-03-29 14:28:22 -07:00
commit e953e6bdcb
14 changed files with 712 additions and 109 deletions

View file

@ -116,3 +116,8 @@ match:
required: []
track_length_grace: 10
track_length_max: 30
thumbnails:
force: no
auto: yes
dolphin: no

View file

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

View file

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

View file

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

View file

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

View file

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

View 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).

View file

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

View file

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

View 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`).

View file

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

View file

@ -16,6 +16,8 @@ deps =
pylast
rarfile
responses
pathlib
pyxdg
commands =
nosetests {posargs}