diff --git a/beets/config_default.yaml b/beets/config_default.yaml index e0b942d82..1bc1d3a9b 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -116,3 +116,8 @@ match: required: [] track_length_grace: 10 track_length_max: 30 + +thumbnails: + force: no + auto: yes + dolphin: no diff --git a/beets/library.py b/beets/library.py index 132df4f52..9f448227f 100644 --- a/beets/library.py +++ b/beets/library.py @@ -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. diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index bce888209..983a9dd15 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -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 diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index d44095687..9117201e8 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -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) diff --git a/beetsplug/freedesktop.py b/beetsplug/freedesktop.py index c4ca24d3a..675061925 100644 --- a/beetsplug/freedesktop.py +++ b/beetsplug/freedesktop.py @@ -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") diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py new file mode 100644 index 000000000..22f37551b --- /dev/null +++ b/beetsplug/thumbnails.py @@ -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 diff --git a/docs/changelog.rst b/docs/changelog.rst index ea3d8d821..db90d4837 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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. diff --git a/docs/plugins/embedart.rst b/docs/plugins/embedart.rst index cf96c504f..f234bc057 100644 --- a/docs/plugins/embedart.rst +++ b/docs/plugins/embedart.rst @@ -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). diff --git a/docs/plugins/freedesktop.rst b/docs/plugins/freedesktop.rst index 3c91d2520..61943718e 100644 --- a/docs/plugins/freedesktop.rst +++ b/docs/plugins/freedesktop.rst @@ -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. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 2cda05e78..e301d3a7c 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -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 diff --git a/docs/plugins/thumbnails.rst b/docs/plugins/thumbnails.rst new file mode 100644 index 000000000..267c149cf --- /dev/null +++ b/docs/plugins/thumbnails.rst @@ -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`). diff --git a/setup.py b/setup.py index a87b155bc..78937b39e 100755 --- a/setup.py +++ b/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 diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py new file mode 100644 index 000000000..b2c16f0e8 --- /dev/null +++ b/test/test_thumbnails.py @@ -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') diff --git a/tox.ini b/tox.ini index a4c2be05d..57ef435c4 100644 --- a/tox.ini +++ b/tox.ini @@ -16,6 +16,8 @@ deps = pylast rarfile responses + pathlib + pyxdg commands = nosetests {posargs}