From 5b8a846d1fd074b92324ff9ed078a4270d4812f0 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Tue, 27 Jan 2015 16:18:14 +0100 Subject: [PATCH 01/36] Add a new plugin: thumbnails Generate thumbnails for albums, based on freedesktop specification. This plugin is POSIX-only. Require Python Imaging Library or Image Magick --- beetsplug/thumbnails.py | 96 +++++++++++++++++++++++++++++++++++++++++ setup.py | 1 + 2 files changed, 97 insertions(+) create mode 100644 beetsplug/thumbnails.py diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py new file mode 100644 index 000000000..6963c76d7 --- /dev/null +++ b/beetsplug/thumbnails.py @@ -0,0 +1,96 @@ +# -*- 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""" + +from __future__ import (division, absolute_import, print_function, + unicode_literals) + +from hashlib import md5 +import os +import shutil +from pathlib import PurePosixPath + +from xdg import BaseDirectory + +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand, decargs +from beets.util import syspath, displayable_path +from beets.util.artresizer import ArtResizer + + +class ThumbnailsPlugin(BeetsPlugin): + def __init__(self): + super(ThumbnailsPlugin, self).__init__() + if self._check_local_ok(): + self.register_listener('album_imported', self.imported) + + def commands(self): + thumbnails_command = Subcommand("thumbnails", + help="Create album thumbnails") + thumbnails_command.func = self.process_query + return [thumbnails_command] + + def imported(self, lib, album): + self.process_album(album) + + def process_query(self, lib, opts, args): + if self._check_local_ok(): + for album in lib.albums(decargs(args)): + self.process_album(album) + + def _check_local_ok(self): + if not ArtResizer.local: + self._log.warning("No local image resizing capabilities, " + "cannot generate thumbnails") + return False + return True + + def process_album(self, album): + """Produce a thumbnail for the album folder + + The thumbnail is a PNG of resolution either 256x256 or lower than + 128x128 + """ + if not album.artpath: + self._log.info(u'album {0} has no art', album) + return + + # FIXME should create dirs if needed? + + # FIXME should handle covers smaller than 256x256 + # see http://standards.freedesktop.org/thumbnail-spec/latest/x122.html + + target = os.path.join(BaseDirectory.xdg_cache_home, + "thumbnails", "large", + self.thumbnail_file_name(album.path)) + + resized = ArtResizer.shared.resize(256, + syspath(album.artpath), + syspath(target)) + + # FIXME should add tags + # see http://standards.freedesktop.org/thumbnail-spec/latest/x142.html + + shutil.move(resized, target) + self._log.info(u'wrote thumbnail for {0}', album) + self._log.debug(u'thumbnail is at {0}', displayable_path(target)) + + @staticmethod + def thumbnail_file_name(path): + # http://standards.freedesktop.org/thumbnail-spec/latest/x227.html + uri = PurePosixPath(path).as_uri() + hash = md5(uri).hexdigest() + return "{0}.png".format(hash) diff --git a/setup.py b/setup.py index 76669c62c..215593146 100755 --- a/setup.py +++ b/setup.py @@ -106,6 +106,7 @@ setup( 'mpdstats': ['python-mpd'], 'web': ['flask', 'flask-cors'], 'import': ['rarfile'], + 'thumbnails': ['pathlib'], }, # Non-Python/non-PyPI plugin dependencies: # replaygain: mp3gain || aacgain From 55fd2d28866f7aa1c8542ec1e44fa9958c3ae248 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Tue, 27 Jan 2015 16:22:51 +0100 Subject: [PATCH 02/36] thumbnails: create thumbnail dirs if needed --- beetsplug/thumbnails.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 6963c76d7..40943bde1 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -31,6 +31,11 @@ from beets.util import syspath, displayable_path from beets.util.artresizer import ArtResizer +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__() @@ -56,6 +61,11 @@ class ThumbnailsPlugin(BeetsPlugin): 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) + return True def process_album(self, album): @@ -68,8 +78,6 @@ class ThumbnailsPlugin(BeetsPlugin): self._log.info(u'album {0} has no art', album) return - # FIXME should create dirs if needed? - # FIXME should handle covers smaller than 256x256 # see http://standards.freedesktop.org/thumbnail-spec/latest/x122.html From 2d62032a61677b2a213541b5122a7ed5646cba5b Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Tue, 27 Jan 2015 16:48:20 +0100 Subject: [PATCH 03/36] ArtResizer can fetch an image file's size Useful for the thumbnails plugin --- beets/util/artresizer.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index b1920d8ac..62f700981 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -107,6 +107,35 @@ 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', util.syspath(path_in)]) + except subprocess.CalledProcessError: + log.warn(u'IM cannot compute size of {0}', + util.displayable_path(path_in)) + return + try: + return out.split(' ')[-7].split('x') + 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 @@ -165,6 +194,16 @@ 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""" From 5453cf96c8b65f4a82e08e18050324d9e955a8c2 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Tue, 27 Jan 2015 16:49:14 +0100 Subject: [PATCH 04/36] =?UTF-8?q?thumbnails=20plugin=20handles=20128=C2=B2?= =?UTF-8?q?=20and=20256=C2=B2=20thumbnails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the source is good enough both will be produced, otherwise the former only will be created. This is warranted by the spec: http://standards.freedesktop.org/thumbnail-spec/latest/x122.html --- beetsplug/thumbnails.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 40943bde1..9baa9ca90 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -27,7 +27,7 @@ from xdg import BaseDirectory from beets.plugins import BeetsPlugin from beets.ui import Subcommand, decargs -from beets.util import syspath, displayable_path +from beets.util import syspath from beets.util.artresizer import ArtResizer @@ -69,32 +69,37 @@ class ThumbnailsPlugin(BeetsPlugin): return True def process_album(self, album): - """Produce a thumbnail for the album folder - - The thumbnail is a PNG of resolution either 256x256 or lower than - 128x128 + """Produce thumbnails for the album folder. """ if not album.artpath: self._log.info(u'album {0} has no art', album) return - # FIXME should handle covers smaller than 256x256 - # see http://standards.freedesktop.org/thumbnail-spec/latest/x122.html + size = ArtResizer.shared.get_size(album.artpath) + if not size: + self._log.warning('Problem getting the picture size for {0}', + album.artpath) + return - target = os.path.join(BaseDirectory.xdg_cache_home, - "thumbnails", "large", - self.thumbnail_file_name(album.path)) + if max(size): + self.make_cover_thumbnail(album, 256, LARGE_DIR) + self.make_cover_thumbnail(album, 128, NORMAL_DIR) - resized = ArtResizer.shared.resize(256, - syspath(album.artpath), + self._log.info(u'wrote thumbnail 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`. + """ + self._log.debug("Building thumbnail to put on {0}", album.path) + target = os.path.join(target_dir, self.thumbnail_file_name(album.path)) + resized = ArtResizer.shared.resize(size, album.artpath, syspath(target)) # FIXME should add tags # see http://standards.freedesktop.org/thumbnail-spec/latest/x142.html shutil.move(resized, target) - self._log.info(u'wrote thumbnail for {0}', album) - self._log.debug(u'thumbnail is at {0}', displayable_path(target)) @staticmethod def thumbnail_file_name(path): From a200bb325649b92b31b1eacd16fc58d4e0f6917e Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Tue, 27 Jan 2015 17:25:28 +0100 Subject: [PATCH 05/36] ArtResizer: fix IM get image size output parsing --- beets/util/artresizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 62f700981..d811e1524 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -125,7 +125,7 @@ def im_getsize(path_in): util.displayable_path(path_in)) return try: - return out.split(' ')[-7].split('x') + return tuple(map(int, out.split(b' ')[-7].split(b'x'))) except IndexError: log.warn(u'Could not understand IM output: {0!r}', out) From d842286726472b846f878e946d6fedd69ec5c1e3 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 28 Jan 2015 11:10:53 +0100 Subject: [PATCH 06/36] Improve artresizer.im_getsize() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specify format on the command line → no problem parsing the output --- beets/util/artresizer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index d811e1524..ce6243647 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -119,13 +119,14 @@ def pil_getsize(path_in): def im_getsize(path_in): try: - out = util.command_output(['identify', util.syspath(path_in)]) + 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' ')[-7].split(b'x'))) + return tuple(map(int, out.split(b' '))) except IndexError: log.warn(u'Could not understand IM output: {0!r}', out) From 17d6d6529e15997f6e9cfed4c5b199b27ace751f Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 28 Jan 2015 11:12:55 +0100 Subject: [PATCH 07/36] thumbails: improve logging, update dependencies --- beetsplug/thumbnails.py | 5 +++-- setup.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 9baa9ca90..aec7749a9 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -71,13 +71,14 @@ class ThumbnailsPlugin(BeetsPlugin): 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 size = ArtResizer.shared.get_size(album.artpath) if not size: - self._log.warning('Problem getting the picture size for {0}', + self._log.warning('problem getting the picture size for {0}', album.artpath) return @@ -91,7 +92,7 @@ class ThumbnailsPlugin(BeetsPlugin): """Make a thumbnail of given size for `album` and put it in `target_dir`. """ - self._log.debug("Building thumbnail to put on {0}", album.path) + self._log.debug("building thumbnail to put on {0}", album.path) target = os.path.join(target_dir, self.thumbnail_file_name(album.path)) resized = ArtResizer.shared.resize(size, album.artpath, syspath(target)) diff --git a/setup.py b/setup.py index 215593146..0cbb8f9ef 100755 --- a/setup.py +++ b/setup.py @@ -106,7 +106,7 @@ setup( 'mpdstats': ['python-mpd'], 'web': ['flask', 'flask-cors'], 'import': ['rarfile'], - 'thumbnails': ['pathlib'], + 'thumbnails': ['pathlib', 'pyxdg'], }, # Non-Python/non-PyPI plugin dependencies: # replaygain: mp3gain || aacgain From 4227e3eabe307dc34ad466d818615478232af01a Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 28 Jan 2015 16:24:05 +0100 Subject: [PATCH 08/36] beets.util.artresizer offers has_PIL() and has_IM() has_PIL() determines Python Imaging Library version (or None) and has_IM() does the same for Image Magick. ArtResizer._check_method() relies on those functions. It also does not accept a "method" parameter anymore, and neither does ArtResizer(). It was unused. --- beets/util/artresizer.py | 75 ++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index ce6243647..e33b2decf 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -159,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() @@ -211,41 +210,43 @@ class ArtResizer(object): 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(['identify', '--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(['identify', '--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 From 143e74942623209be1530037d17e9ac5058e3e5e Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 28 Jan 2015 16:25:54 +0100 Subject: [PATCH 09/36] thumbnails plugin writes standard metadata to the thumbnail Write Thumb::URI and Thumb::MTime as requred by the spec: http://standards.freedesktop.org/thumbnail-spec/latest/x142.html --- beetsplug/thumbnails.py | 48 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index aec7749a9..b6bcb0d9a 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -21,14 +21,15 @@ from __future__ import (division, absolute_import, print_function, from hashlib import md5 import os import shutil +from itertools import chain from pathlib import PurePosixPath from xdg import BaseDirectory from beets.plugins import BeetsPlugin from beets.ui import Subcommand, decargs -from beets.util import syspath -from beets.util.artresizer import ArtResizer +from beets import util +from beets.util.artresizer import ArtResizer, has_IM, has_PIL BASE_DIR = os.path.join(BaseDirectory.xdg_cache_home, "thumbnails") @@ -39,6 +40,8 @@ LARGE_DIR = os.path.join(BASE_DIR, "large") class ThumbnailsPlugin(BeetsPlugin): def __init__(self): super(ThumbnailsPlugin, self).__init__() + + self.write_metadata = None if self._check_local_ok(): self.register_listener('album_imported', self.imported) @@ -66,6 +69,12 @@ class ThumbnailsPlugin(BeetsPlugin): if not os.path.exists(dir): os.makedirs(dir) + if has_IM(): + self.write_metadata = write_metadata_im + else: + assert has_PIL() # since we're local + self.write_metadata = write_metadata_pil + return True def process_album(self, album): @@ -95,10 +104,9 @@ class ThumbnailsPlugin(BeetsPlugin): self._log.debug("building thumbnail to put on {0}", album.path) target = os.path.join(target_dir, self.thumbnail_file_name(album.path)) resized = ArtResizer.shared.resize(size, album.artpath, - syspath(target)) + util.syspath(target)) - # FIXME should add tags - # see http://standards.freedesktop.org/thumbnail-spec/latest/x142.html + self.add_tags(album, util.syspath(resized)) shutil.move(resized, target) @@ -108,3 +116,33 @@ class ThumbnailsPlugin(BeetsPlugin): uri = PurePosixPath(path).as_uri() hash = md5(uri).hexdigest() return "{0}.png".format(hash) + + def add_tags(self, album, image_path): + # http://standards.freedesktop.org/thumbnail-spec/latest/x142.html + metadata = {"Thumb::URI": PurePosixPath(album.artpath).as_uri(), + "Thumb::MTime": 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 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 From 180a3ece3bcf5527c61d1f987a268128b8670721 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 28 Jan 2015 16:31:49 +0100 Subject: [PATCH 10/36] Improve thumbnails docstrings --- beetsplug/thumbnails.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index b6bcb0d9a..b1322e7e3 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -13,7 +13,11 @@ # 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""" +"""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) @@ -60,6 +64,11 @@ class ThumbnailsPlugin(BeetsPlugin): 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 + """ if not ArtResizer.local: self._log.warning("No local image resizing capabilities, " "cannot generate thumbnails") @@ -112,13 +121,17 @@ class ThumbnailsPlugin(BeetsPlugin): @staticmethod def thumbnail_file_name(path): - # http://standards.freedesktop.org/thumbnail-spec/latest/x227.html + """Compute the thumbnail file name + See http://standards.freedesktop.org/thumbnail-spec/latest/x227.html + """ uri = PurePosixPath(path).as_uri() hash = md5(uri).hexdigest() return "{0}.png".format(hash) def add_tags(self, album, image_path): - # http://standards.freedesktop.org/thumbnail-spec/latest/x142.html + """Write required metadata to the thumbnail + See http://standards.freedesktop.org/thumbnail-spec/latest/x142.html + """ metadata = {"Thumb::URI": PurePosixPath(album.artpath).as_uri(), "Thumb::MTime": os.stat(album.artpath).st_mtime} try: From 540cae0de453de0373958669eda3ddfba10c89c0 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 28 Jan 2015 16:34:27 +0100 Subject: [PATCH 11/36] thumbnails: fix detection of local resizing capabilities --- beetsplug/thumbnails.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index b1322e7e3..9b22fd357 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -69,7 +69,7 @@ class ThumbnailsPlugin(BeetsPlugin): - thumbnail dirs exist (create them if needed) - detect whether we'll use PIL or IM """ - if not ArtResizer.local: + if not ArtResizer.shared.local: self._log.warning("No local image resizing capabilities, " "cannot generate thumbnails") return False From e8370044c9cb58704b112efd2f5b4f04efe15ef8 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 28 Jan 2015 16:48:34 +0100 Subject: [PATCH 12/36] thumbnails: add --force (-f) to force regeneration It compares thumb mtime vs cover mtime when it should compare Thumb::mtime thumb metadata vs cover mtime. --- beets/config_default.yaml | 3 +++ beetsplug/thumbnails.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 1d3c4ad7a..6e3918c0c 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -108,3 +108,6 @@ match: required: [] track_length_grace: 10 track_length_max: 30 + +thumbnails: + force: no diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 9b22fd357..31bf7f5d5 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -44,6 +44,9 @@ LARGE_DIR = os.path.join(BASE_DIR, "large") class ThumbnailsPlugin(BeetsPlugin): def __init__(self): super(ThumbnailsPlugin, self).__init__() + self.config.add({ + 'force': False, + }) self.write_metadata = None if self._check_local_ok(): @@ -52,6 +55,10 @@ class ThumbnailsPlugin(BeetsPlugin): 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.func = self.process_query return [thumbnails_command] @@ -112,6 +119,16 @@ class ThumbnailsPlugin(BeetsPlugin): """ self._log.debug("building thumbnail to put on {0}", album.path) 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.force: + self._log.debug("found a suitable thumbnail for {0}, " + "forcing regeneration", album) + else: + self._log.info("thumbnail for {0} exists and is recent enough", + album) + return resized = ArtResizer.shared.resize(size, album.artpath, util.syspath(target)) From a39ab5163f9a61dae2dea080d14d2027f2ac0def Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 28 Jan 2015 18:02:54 +0100 Subject: [PATCH 13/36] thumbnails: add "auto" config option default: True --- beets/config_default.yaml | 1 + beetsplug/thumbnails.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 6e3918c0c..295245edf 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -111,3 +111,4 @@ match: thumbnails: force: no + auto: yes diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 31bf7f5d5..b467bdb02 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -45,11 +45,12 @@ class ThumbnailsPlugin(BeetsPlugin): def __init__(self): super(ThumbnailsPlugin, self).__init__() self.config.add({ + 'auto': True, 'force': False, }) self.write_metadata = None - if self._check_local_ok(): + if self.config['auto'] and self._check_local_ok(): self.register_listener('album_imported', self.imported) def commands(self): From 3d64440ef2dca5fa5216387414fa0fc1326c2d51 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 28 Jan 2015 18:06:03 +0100 Subject: [PATCH 14/36] Write thumbnails doc & add changelog entry --- docs/changelog.rst | 2 ++ docs/plugins/index.rst | 2 ++ docs/plugins/thumbnails.rst | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 docs/plugins/thumbnails.rst diff --git a/docs/changelog.rst b/docs/changelog.rst index b18b05081..8ad20865a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -28,6 +28,8 @@ 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. Core changes: diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 3a79af4d7..1bf2f504a 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -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 @@ -156,6 +157,7 @@ Miscellaneous on regular expressions. * :doc:`spotify`: Create Spotify playlists from the Beets library. * :doc:`types`: Declare types for flexible attributes. +* :doc:`thumbnails`: Get thumbnails with the cover art on your album folders. * :doc:`web`: An experimental Web-based GUI for beets. .. _MPD: http://www.musicpd.org/ diff --git a/docs/plugins/thumbnails.rst b/docs/plugins/thumbnails.rst new file mode 100644 index 000000000..e73d0a584 --- /dev/null +++ b/docs/plugins/thumbnails.rst @@ -0,0 +1,35 @@ +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`. As with :doc:`/plugins/embedart`, be sure to +put ``fetchart`` before ``thumbnails`` in the ``plugins`` section of your +config. 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``. + +Usage +----- + +The ``thumbnails`` command provided by this plugin creates a thumbnail for +albums that match a query (see :doc:`/reference/query`). From c09b01ce74b48b7f37b893d57a1baebe8121e9b4 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Thu, 29 Jan 2015 11:17:52 +0100 Subject: [PATCH 15/36] Add tests for thumbnails plugin --- test/test_thumbnails.py | 225 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 test/test_thumbnails.py diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py new file mode 100644 index 000000000..4b207f138 --- /dev/null +++ b/test/test_thumbnails.py @@ -0,0 +1,225 @@ +# 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 test._common import unittest +from test.helper import TestHelper + +from beetsplug.thumbnails import (ThumbnailsPlugin, NORMAL_DIR, LARGE_DIR, + write_metadata_im, write_metadata_pil) + + +class ThumbnailsTest(unittest.TestCase, TestHelper): + def setUp(self): + self.setup_beets() + + def tearDown(self): + self.teardown_beets() + + @patch('beetsplug.thumbnails.BaseDirectory') + def test_thumbnail_filter_name(self, mock_basedir): + filename = b"/home/jens/photos/me.png" + thumbnail = ThumbnailsPlugin.thumbnail_file_name(filename) + self.assertEqual(thumbnail, b"c6ee772d9e49320e97ec29a7eb5b1697.png") + + @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() + album = Mock(artpath=b"/path/to/cover") + plugin.add_tags(album, b"/path/to/thumbnail") + + metadata = {"Thumb::URI": b"file:///path/to/cover", + "Thumb::MTime": mock_stat.return_value.st_mtime} + 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') + def test_check_local_ok(self, 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()) + + @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.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') + @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() + + # no art + album = Mock(artpath=None) + plugin.process_album(album) + self.assertEqual(get_size.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) + + # 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.imported(None, album) + plugin.process_album.assert_called_once_with(album) + + plugin.process_album.reset_mock() + lib = Mock() + album2 = Mock() + lib.albums.return_value = [album, album2] + plugin.process_query(lib, None, None) + lib.albums.assert_called_once_with(mock_decargs.return_value) + plugin.process_album.has_calls([call(album), call(album2)], + any_order=True) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == b'__main__': + unittest.main(defaultTest='suite') From 946aa8e4b37df21f53f044312d61c8357b25b9c8 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Thu, 29 Jan 2015 11:18:17 +0100 Subject: [PATCH 16/36] Fix detection of thumbails to generate --- beetsplug/thumbnails.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index b467bdb02..1d9b50e88 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -108,7 +108,7 @@ class ThumbnailsPlugin(BeetsPlugin): album.artpath) return - if max(size): + if max(size) >= 256: self.make_cover_thumbnail(album, 256, LARGE_DIR) self.make_cover_thumbnail(album, 128, NORMAL_DIR) From bea5ad3f97a56e80c286448a4360a29ed2c6163a Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Thu, 29 Jan 2015 11:39:24 +0100 Subject: [PATCH 17/36] thumbnails: fix --force option management --- beetsplug/thumbnails.py | 3 ++- test/test_thumbnails.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 1d9b50e88..a92779b3c 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -67,6 +67,7 @@ class ThumbnailsPlugin(BeetsPlugin): self.process_album(album) def process_query(self, lib, opts, args): + self.config['force'] = opts.force if self._check_local_ok(): for album in lib.albums(decargs(args)): self.process_album(album) @@ -123,7 +124,7 @@ class ThumbnailsPlugin(BeetsPlugin): if os.path.exists(target) and \ os.stat(target).st_mtime > os.stat(album.artpath).st_mtime: - if self.force: + if self.config['force']: self._log.debug("found a suitable thumbnail for {0}, " "forcing regeneration", album) else: diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py index 4b207f138..6e2ebb06e 100644 --- a/test/test_thumbnails.py +++ b/test/test_thumbnails.py @@ -161,7 +161,7 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): self.assertEqual(resize.call_count, 0) # and with force - plugin.force = True + 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") @@ -212,7 +212,7 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): lib = Mock() album2 = Mock() lib.albums.return_value = [album, album2] - plugin.process_query(lib, None, None) + 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) From fa00a8ab230301c15785f5b5addf5dd631607c2d Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Thu, 29 Jan 2015 11:47:20 +0100 Subject: [PATCH 18/36] Update setup.py for tox --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 0cbb8f9ef..3e651c623 100755 --- a/setup.py +++ b/setup.py @@ -94,6 +94,8 @@ setup( 'pylast', 'rarfile', 'responses', + 'pyxdg', + 'pathlib', ], # Plugin (optional) dependencies: From a78cc65826295daa4034067f5257294dedc774da Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Thu, 29 Jan 2015 13:06:26 +0100 Subject: [PATCH 19/36] Merge freedesktop plugin into thumbnails Add test for that new code, update docs, update the changelog. --- beets/config_default.yaml | 1 + beetsplug/freedesktop.py | 53 +++++++----------------------------- beetsplug/thumbnails.py | 20 +++++++++++++- docs/changelog.rst | 4 ++- docs/plugins/freedesktop.rst | 25 ++--------------- docs/plugins/thumbnails.rst | 4 +++ test/test_thumbnails.py | 31 +++++++++++++++++++++ 7 files changed, 71 insertions(+), 67 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 295245edf..479edcc10 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -112,3 +112,4 @@ match: thumbnails: force: no auto: yes + dolphin: no 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 index a92779b3c..e994cefe4 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -47,6 +47,7 @@ class ThumbnailsPlugin(BeetsPlugin): self.config.add({ 'auto': True, 'force': False, + 'dolphin': False, }) self.write_metadata = None @@ -60,14 +61,18 @@ class ThumbnailsPlugin(BeetsPlugin): '-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 imported(self, lib, album): self.process_album(album) def process_query(self, lib, opts, args): - self.config['force'] = opts.force + self.config.set_args(opts) if self._check_local_ok(): for album in lib.albums(decargs(args)): self.process_album(album) @@ -103,6 +108,9 @@ class ThumbnailsPlugin(BeetsPlugin): 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}', @@ -159,6 +167,16 @@ class ThumbnailsPlugin(BeetsPlugin): 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.""" diff --git a/docs/changelog.rst b/docs/changelog.rst index 8ad20865a..70fe5085a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -29,7 +29,9 @@ Features: * :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. + album folders for all freedesktop.org-compliant file managers. This replaces + the :doc:`/plugins/freedesktop` which only worked with the Dolphin file + manager. Core changes: 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/thumbnails.rst b/docs/plugins/thumbnails.rst index e73d0a584..c91c97910 100644 --- a/docs/plugins/thumbnails.rst +++ b/docs/plugins/thumbnails.rst @@ -27,6 +27,10 @@ file. The available options are - **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 ----- diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py index 6e2ebb06e..6bee3c081 100644 --- a/test/test_thumbnails.py +++ b/test/test_thumbnails.py @@ -17,6 +17,8 @@ from __future__ import (division, absolute_import, print_function, 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 @@ -166,6 +168,24 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): 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, _): @@ -173,11 +193,13 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): plugin = ThumbnailsPlugin() make_cover = plugin.make_cover_thumbnail = Mock() + 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" @@ -186,6 +208,15 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): 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) From 9bdeb01689fab33939359ad045c8db276bf603b8 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Thu, 29 Jan 2015 13:08:31 +0100 Subject: [PATCH 20/36] Improve thumbnails logging No info message printed twice per cover art (once for the normal thumbnail and once for the big one) --- beetsplug/thumbnails.py | 19 +++++++++++-------- test/test_thumbnails.py | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index e994cefe4..086df0f57 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -117,11 +117,15 @@ class ThumbnailsPlugin(BeetsPlugin): album.artpath) return + wrote = True if max(size) >= 256: - self.make_cover_thumbnail(album, 256, LARGE_DIR) - self.make_cover_thumbnail(album, 128, NORMAL_DIR) + wrote &= self.make_cover_thumbnail(album, 256, LARGE_DIR) + wrote &= self.make_cover_thumbnail(album, 128, NORMAL_DIR) - self._log.info(u'wrote thumbnail for {0}', album) + 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 @@ -136,15 +140,14 @@ class ThumbnailsPlugin(BeetsPlugin): self._log.debug("found a suitable thumbnail for {0}, " "forcing regeneration", album) else: - self._log.info("thumbnail for {0} exists and is recent enough", - album) - return + self._log.debug("thumbnail for {0} exists and is recent " + "enough", album) + 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 @staticmethod def thumbnail_file_name(path): diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py index 6bee3c081..235b0d88d 100644 --- a/test/test_thumbnails.py +++ b/test/test_thumbnails.py @@ -192,7 +192,7 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): get_size = mock_artresizer.shared.get_size plugin = ThumbnailsPlugin() - make_cover = plugin.make_cover_thumbnail = Mock() + make_cover = plugin.make_cover_thumbnail = Mock(return_value=True) make_dolphin = plugin.make_dolphin_cover_thumbnail = Mock() # no art From 39183cd5398318b0947bc5619e0b03daca04dadb Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Thu, 29 Jan 2015 13:17:51 +0100 Subject: [PATCH 21/36] Add pyxdg and pathlib to tox.ini testenv --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index c1556533a..1ee37c67e 100644 --- a/tox.ini +++ b/tox.ini @@ -16,6 +16,8 @@ deps = pylast rarfile responses + pathlib + pyxdg commands = nosetests -v {posargs} From d299f40a7279661dd36bc4ed84bef58276226f4f Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Thu, 29 Jan 2015 15:49:18 +0100 Subject: [PATCH 22/36] Fix PNG metadata type: string only instead of int --- beetsplug/thumbnails.py | 2 +- test/test_thumbnails.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 086df0f57..5ba6da57f 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -163,7 +163,7 @@ class ThumbnailsPlugin(BeetsPlugin): See http://standards.freedesktop.org/thumbnail-spec/latest/x142.html """ metadata = {"Thumb::URI": PurePosixPath(album.artpath).as_uri(), - "Thumb::MTime": os.stat(album.artpath).st_mtime} + "Thumb::MTime": unicode(os.stat(album.artpath).st_mtime)} try: self.write_metadata(image_path, metadata) except Exception: diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py index 235b0d88d..8a417ada8 100644 --- a/test/test_thumbnails.py +++ b/test/test_thumbnails.py @@ -57,10 +57,12 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): plugin = ThumbnailsPlugin() plugin.write_metadata = Mock() 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"file:///path/to/cover", - "Thumb::MTime": mock_stat.return_value.st_mtime} + "Thumb::MTime": "12345"} plugin.write_metadata.assert_called_once_with(b"/path/to/thumbnail", metadata) mock_stat.assert_called_once_with(album.artpath) From 533af4edaa35b04476c1647f1f5b04a1c988a470 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Thu, 29 Jan 2015 18:15:42 +0100 Subject: [PATCH 23/36] Remove 'freedesktop' from plugins index + move thumbnails in the category where freedesktop stood. --- docs/plugins/index.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1bf2f504a..f9c76d4af 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -41,7 +41,6 @@ Each plugin has its own set of options that can be defined in a section bearing echonest embedart fetchart - freedesktop fromfilename ftintitle fuzzy @@ -127,7 +126,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. @@ -135,6 +133,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 @@ -157,7 +156,6 @@ Miscellaneous on regular expressions. * :doc:`spotify`: Create Spotify playlists from the Beets library. * :doc:`types`: Declare types for flexible attributes. -* :doc:`thumbnails`: Get thumbnails with the cover art on your album folders. * :doc:`web`: An experimental Web-based GUI for beets. .. _MPD: http://www.musicpd.org/ From 0a37c4652e735eb3e230d4284b8ee81e52da5812 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Fri, 30 Jan 2015 10:32:41 +0100 Subject: [PATCH 24/36] docs: restore freedesktop in plugins toctree Every file *has* to be in a toctree --- docs/plugins/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index f9c76d4af..c924e6e85 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -44,6 +44,7 @@ Each plugin has its own set of options that can be defined in a section bearing fromfilename ftintitle fuzzy + freedesktop ihate importadded importfeeds From a72ae5991f9f563517fdb43d4dab1b966ebbdeec Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Sat, 31 Jan 2015 13:55:13 +0100 Subject: [PATCH 25/36] Add send_art event for embedart and thumbnails Album.set_art() sends a 'art_sent' event, with the album as a parameter. embedart and thumbnails listen to that event, instead of listening to 'album imported'. Consequences: - 'embedart' and 'thumbnails' don't have to be after 'fetchart' on the plugins config line. - embedart and thumbnails work event when a "beets fetchart" command is issued. - if another plugin ever set art then embedart and thumbnails will "just work" with it. --- beets/library.py | 4 ++++ beetsplug/embedart.py | 8 ++++---- beetsplug/thumbnails.py | 5 +---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/beets/library.py b/beets/library.py index 33aa314bf..5764d4bb5 100644 --- a/beets/library.py +++ b/beets/library.py @@ -960,6 +960,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 @@ -983,6 +985,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/beetsplug/embedart.py b/beetsplug/embedart.py index e84b39737..07dfe066d 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. @@ -106,10 +106,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/thumbnails.py b/beetsplug/thumbnails.py index 5ba6da57f..5ab1656b2 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -52,7 +52,7 @@ class ThumbnailsPlugin(BeetsPlugin): self.write_metadata = None if self.config['auto'] and self._check_local_ok(): - self.register_listener('album_imported', self.imported) + self.register_listener('art_set', self.process_album) def commands(self): thumbnails_command = Subcommand("thumbnails", @@ -68,9 +68,6 @@ class ThumbnailsPlugin(BeetsPlugin): return [thumbnails_command] - def imported(self, lib, album): - self.process_album(album) - def process_query(self, lib, opts, args): self.config.set_args(opts) if self._check_local_ok(): From 60a89d3a7b84460e0557603c3012c446e36ba9c2 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Fri, 30 Jan 2015 21:34:24 +0100 Subject: [PATCH 26/36] Fix thumbnails test --- test/test_thumbnails.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py index 8a417ada8..fe149a913 100644 --- a/test/test_thumbnails.py +++ b/test/test_thumbnails.py @@ -236,10 +236,7 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): def test_invokations(self, mock_decargs, _): plugin = ThumbnailsPlugin() plugin.process_album = Mock() - album = Mock() - plugin.imported(None, album) - plugin.process_album.assert_called_once_with(album) plugin.process_album.reset_mock() lib = Mock() From df4c7952d47d95af9e0aa65d73ccfc357a70b5cf Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Sat, 31 Jan 2015 21:36:12 +0100 Subject: [PATCH 27/36] Update doc: liberal plugin ordering for fetchart --- docs/plugins/embedart.rst | 2 +- docs/plugins/thumbnails.rst | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/plugins/embedart.rst b/docs/plugins/embedart.rst index 273046979..0c6464667 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/thumbnails.rst b/docs/plugins/thumbnails.rst index c91c97910..267c149cf 100644 --- a/docs/plugins/thumbnails.rst +++ b/docs/plugins/thumbnails.rst @@ -6,9 +6,8 @@ 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`. As with :doc:`/plugins/embedart`, be sure to -put ``fetchart`` before ``thumbnails`` in the ``plugins`` section of your -config. You'll need 2 additional python packages: `pyxdg` and `pathlib`. +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`_. From 11a47772193d3f6d0b232674205f41e86ae5482d Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 25 Mar 2015 19:18:59 +0100 Subject: [PATCH 28/36] URI: use gio/gio.h's g_file_get_uri if available --- beetsplug/thumbnails.py | 73 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 5ab1656b2..fef549c7d 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -27,6 +27,8 @@ import os import shutil from itertools import chain from pathlib import PurePosixPath +import ctypes +import ctypes.util from xdg import BaseDirectory @@ -79,6 +81,7 @@ class ThumbnailsPlugin(BeetsPlugin): - 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, " @@ -95,6 +98,11 @@ class ThumbnailsPlugin(BeetsPlugin): assert has_PIL() # since we're local self.write_metadata = write_metadata_pil + uri_getter = GioURI() + if not uri_getter.available: + uri_getter = PathlibURI() + self.get_uri = uri_getter.uri + return True def process_album(self, album): @@ -146,12 +154,11 @@ class ThumbnailsPlugin(BeetsPlugin): shutil.move(resized, target) return True - @staticmethod - def thumbnail_file_name(path): + def thumbnail_file_name(self, path): """Compute the thumbnail file name See http://standards.freedesktop.org/thumbnail-spec/latest/x227.html """ - uri = PurePosixPath(path).as_uri() + uri = self.get_uri(path) hash = md5(uri).hexdigest() return "{0}.png".format(hash) @@ -159,7 +166,7 @@ class ThumbnailsPlugin(BeetsPlugin): """Write required metadata to the thumbnail See http://standards.freedesktop.org/thumbnail-spec/latest/x142.html """ - metadata = {"Thumb::URI": PurePosixPath(album.artpath).as_uri(), + metadata = {"Thumb::URI": self.get_uri(album.artpath), "Thumb::MTime": unicode(os.stat(album.artpath).st_mtime)} try: self.write_metadata(image_path, metadata) @@ -181,8 +188,8 @@ class ThumbnailsPlugin(BeetsPlugin): 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] + list(chain.from_iterable(('-set', k, v) + for k, v in metadata.items())) + [file] util.command_output(command) return True @@ -196,3 +203,57 @@ def write_metadata_pil(file, metadata): meta.add_text(k, v, 0) im.save(file, "PNG", pnginfo=meta) return True + + +class URIGetter(object): + available = False + + def uri(self, path): + raise NotImplementedError() + + +class PathlibURI(URIGetter): + available = True + + 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. + """ + def __init__(self): + self.libgio = self.get_library() + self.available = bool(self.libgio) + + 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 From 4b349771ff74bb7270c5ec9adebc959535a25efe Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 25 Mar 2015 19:22:09 +0100 Subject: [PATCH 29/36] thumbnails plugin setup: log tools chosen --- beetsplug/thumbnails.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index fef549c7d..3b9cee7a7 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -94,13 +94,17 @@ class ThumbnailsPlugin(BeetsPlugin): 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 @@ -207,6 +211,7 @@ def write_metadata_pil(file, metadata): class URIGetter(object): available = False + name = "Abstract base" def uri(self, path): raise NotImplementedError() @@ -214,6 +219,7 @@ class URIGetter(object): class PathlibURI(URIGetter): available = True + name = "Python Pathlib" def uri(self, path): return PurePosixPath(path).as_uri() @@ -222,6 +228,8 @@ class PathlibURI(URIGetter): 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) From be5f80d51a24539fe3e6de7b2c1b9f5a5bbe541d Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 25 Mar 2015 19:36:10 +0100 Subject: [PATCH 30/36] Update thumbnails tests Still missing: testing the specific output of GioURI.uri() --- test/test_thumbnails.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py index fe149a913..f67b70e61 100644 --- a/test/test_thumbnails.py +++ b/test/test_thumbnails.py @@ -24,7 +24,8 @@ 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) + write_metadata_im, write_metadata_pil, + PathlibURI, GioURI) class ThumbnailsTest(unittest.TestCase, TestHelper): @@ -37,8 +38,10 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): @patch('beetsplug.thumbnails.BaseDirectory') def test_thumbnail_filter_name(self, mock_basedir): filename = b"/home/jens/photos/me.png" - thumbnail = ThumbnailsPlugin.thumbnail_file_name(filename) - self.assertEqual(thumbnail, b"c6ee772d9e49320e97ec29a7eb5b1697.png") + plug = ThumbnailsPlugin() + plug.get_uri = PathlibURI().uri + self.assertEqual(plug.thumbnail_file_name(filename), + b"c6ee772d9e49320e97ec29a7eb5b1697.png") @patch('beetsplug.thumbnails.util') def test_write_metadata_im(self, mock_util): @@ -56,12 +59,14 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): 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"file:///path/to/cover", + metadata = {"Thumb::URI": b"COVER_URI", "Thumb::MTime": "12345"} plugin.write_metadata.assert_called_once_with(b"/path/to/thumbnail", metadata) @@ -71,7 +76,9 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): @patch('beetsplug.thumbnails.ArtResizer') @patch('beetsplug.thumbnails.has_IM') @patch('beetsplug.thumbnails.has_PIL') - def test_check_local_ok(self, mock_pil, mock_im, mock_artresizer, mock_os): + @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() @@ -109,6 +116,14 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): 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') From 07ee9343527efc4193936b54694f8dadbd1a568a Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 25 Mar 2015 19:40:28 +0100 Subject: [PATCH 31/36] Thumbnails: improve log readability --- beetsplug/thumbnails.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 3b9cee7a7..58f8d3071 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -140,17 +140,16 @@ class ThumbnailsPlugin(BeetsPlugin): """Make a thumbnail of given size for `album` and put it in `target_dir`. """ - self._log.debug("building thumbnail to put on {0}", album.path) 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 thumbnail for {0}, " - "forcing regeneration", album) + self._log.debug("found a suitable {1}x{1} thumbnail for {0}, " + "forcing regeneration", album, size) else: - self._log.debug("thumbnail for {0} exists and is recent " - "enough", album) + 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)) From 35ff1f821d8b03e7e844fa17ad00f51857dff658 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 25 Mar 2015 20:31:09 +0100 Subject: [PATCH 32/36] Test GIO URI results. Complete PR #1277! --- test/test_thumbnails.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py index f67b70e61..ee32b5e22 100644 --- a/test/test_thumbnails.py +++ b/test/test_thumbnails.py @@ -35,14 +35,6 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): def tearDown(self): self.teardown_beets() - @patch('beetsplug.thumbnails.BaseDirectory') - def test_thumbnail_filter_name(self, mock_basedir): - filename = b"/home/jens/photos/me.png" - plug = ThumbnailsPlugin() - plug.get_uri = PathlibURI().uri - self.assertEqual(plug.thumbnail_file_name(filename), - b"c6ee772d9e49320e97ec29a7eb5b1697.png") - @patch('beetsplug.thumbnails.util') def test_write_metadata_im(self, mock_util): metadata = {"a": "A", "b": "B"} @@ -262,6 +254,30 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): 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__) From f41ec9bbf6d210b1f66210f3bea9b8327fe16831 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Fri, 27 Mar 2015 14:00:13 +0100 Subject: [PATCH 33/36] ctpyes: set argtypes & restype --- beetsplug/thumbnails.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 58f8d3071..6ec629118 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -232,6 +232,10 @@ class GioURI(URIGetter): def __init__(self): self.libgio = self.get_library() self.available = bool(self.libgio) + if self.available: + 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") From 2e5803cfad7c7818b5941bc2b5cad7d63d47cb6e Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Fri, 27 Mar 2015 14:00:22 +0100 Subject: [PATCH 34/36] =?UTF-8?q?Thumbnails:=20unicode=20=E2=86=92=20bytes?= =?UTF-8?q?=20filename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- beetsplug/thumbnails.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 6ec629118..bcb6a7443 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -163,7 +163,7 @@ class ThumbnailsPlugin(BeetsPlugin): """ uri = self.get_uri(path) hash = md5(uri).hexdigest() - return "{0}.png".format(hash) + return b"{0}.png".format(hash) def add_tags(self, album, image_path): """Write required metadata to the thumbnail From 0b50dc91ef2544e9de722699b93f73022c75e93e Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Fri, 27 Mar 2015 14:57:45 +0100 Subject: [PATCH 35/36] Fix flake8 error --- test/test_thumbnails.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py index ee32b5e22..b2c16f0e8 100644 --- a/test/test_thumbnails.py +++ b/test/test_thumbnails.py @@ -51,8 +51,8 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): 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__) + 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 From e707342bf9e9ca8ff763e88c4e3612cdfcb3db8e Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Fri, 27 Mar 2015 16:33:51 +0100 Subject: [PATCH 36/36] Add call to g_type_init() for glib < 2.36 --- beetsplug/thumbnails.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index bcb6a7443..22f37551b 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -233,6 +233,7 @@ class GioURI(URIGetter): 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]