1# -*- coding: utf-8 -*- 2# This file is part of beets. 3# Copyright 2016, Bruno Cauet 4# 5# Permission is hereby granted, free of charge, to any person obtaining 6# a copy of this software and associated documentation files (the 7# "Software"), to deal in the Software without restriction, including 8# without limitation the rights to use, copy, modify, merge, publish, 9# distribute, sublicense, and/or sell copies of the Software, and to 10# permit persons to whom the Software is furnished to do so, subject to 11# the following conditions: 12# 13# The above copyright notice and this permission notice shall be 14# included in all copies or substantial portions of the Software. 15 16"""Create freedesktop.org-compliant thumbnails for album folders 17 18This plugin is POSIX-only. 19Spec: standards.freedesktop.org/thumbnail-spec/latest/index.html 20""" 21 22from __future__ import division, absolute_import, print_function 23 24from hashlib import md5 25import os 26import shutil 27from itertools import chain 28from pathlib import PurePosixPath 29import ctypes 30import ctypes.util 31 32from xdg import BaseDirectory 33 34from beets.plugins import BeetsPlugin 35from beets.ui import Subcommand, decargs 36from beets import util 37from beets.util.artresizer import ArtResizer, get_im_version, get_pil_version 38import six 39 40 41BASE_DIR = os.path.join(BaseDirectory.xdg_cache_home, "thumbnails") 42NORMAL_DIR = util.bytestring_path(os.path.join(BASE_DIR, "normal")) 43LARGE_DIR = util.bytestring_path(os.path.join(BASE_DIR, "large")) 44 45 46class ThumbnailsPlugin(BeetsPlugin): 47 def __init__(self): 48 super(ThumbnailsPlugin, self).__init__() 49 self.config.add({ 50 'auto': True, 51 'force': False, 52 'dolphin': False, 53 }) 54 55 self.write_metadata = None 56 if self.config['auto'] and self._check_local_ok(): 57 self.register_listener('art_set', self.process_album) 58 59 def commands(self): 60 thumbnails_command = Subcommand("thumbnails", 61 help=u"Create album thumbnails") 62 thumbnails_command.parser.add_option( 63 u'-f', u'--force', 64 dest='force', action='store_true', default=False, 65 help=u'force regeneration of thumbnails deemed fine (existing & ' 66 u'recent enough)') 67 thumbnails_command.parser.add_option( 68 u'--dolphin', dest='dolphin', action='store_true', default=False, 69 help=u"create Dolphin-compatible thumbnail information (for KDE)") 70 thumbnails_command.func = self.process_query 71 72 return [thumbnails_command] 73 74 def process_query(self, lib, opts, args): 75 self.config.set_args(opts) 76 if self._check_local_ok(): 77 for album in lib.albums(decargs(args)): 78 self.process_album(album) 79 80 def _check_local_ok(self): 81 """Check that's everythings ready: 82 - local capability to resize images 83 - thumbnail dirs exist (create them if needed) 84 - detect whether we'll use PIL or IM 85 - detect whether we'll use GIO or Python to get URIs 86 """ 87 if not ArtResizer.shared.local: 88 self._log.warning(u"No local image resizing capabilities, " 89 u"cannot generate thumbnails") 90 return False 91 92 for dir in (NORMAL_DIR, LARGE_DIR): 93 if not os.path.exists(dir): 94 os.makedirs(dir) 95 96 if get_im_version(): 97 self.write_metadata = write_metadata_im 98 tool = "IM" 99 else: 100 assert get_pil_version() # since we're local 101 self.write_metadata = write_metadata_pil 102 tool = "PIL" 103 self._log.debug(u"using {0} to write metadata", tool) 104 105 uri_getter = GioURI() 106 if not uri_getter.available: 107 uri_getter = PathlibURI() 108 self._log.debug(u"using {0.name} to compute URIs", uri_getter) 109 self.get_uri = uri_getter.uri 110 111 return True 112 113 def process_album(self, album): 114 """Produce thumbnails for the album folder. 115 """ 116 self._log.debug(u'generating thumbnail for {0}', album) 117 if not album.artpath: 118 self._log.info(u'album {0} has no art', album) 119 return 120 121 if self.config['dolphin']: 122 self.make_dolphin_cover_thumbnail(album) 123 124 size = ArtResizer.shared.get_size(album.artpath) 125 if not size: 126 self._log.warning(u'problem getting the picture size for {0}', 127 album.artpath) 128 return 129 130 wrote = True 131 if max(size) >= 256: 132 wrote &= self.make_cover_thumbnail(album, 256, LARGE_DIR) 133 wrote &= self.make_cover_thumbnail(album, 128, NORMAL_DIR) 134 135 if wrote: 136 self._log.info(u'wrote thumbnail for {0}', album) 137 else: 138 self._log.info(u'nothing to do for {0}', album) 139 140 def make_cover_thumbnail(self, album, size, target_dir): 141 """Make a thumbnail of given size for `album` and put it in 142 `target_dir`. 143 """ 144 target = os.path.join(target_dir, self.thumbnail_file_name(album.path)) 145 146 if os.path.exists(target) and \ 147 os.stat(target).st_mtime > os.stat(album.artpath).st_mtime: 148 if self.config['force']: 149 self._log.debug(u"found a suitable {1}x{1} thumbnail for {0}, " 150 u"forcing regeneration", album, size) 151 else: 152 self._log.debug(u"{1}x{1} thumbnail for {0} exists and is " 153 u"recent enough", album, size) 154 return False 155 resized = ArtResizer.shared.resize(size, album.artpath, 156 util.syspath(target)) 157 self.add_tags(album, util.syspath(resized)) 158 shutil.move(resized, target) 159 return True 160 161 def thumbnail_file_name(self, path): 162 """Compute the thumbnail file name 163 See http://standards.freedesktop.org/thumbnail-spec/latest/x227.html 164 """ 165 uri = self.get_uri(path) 166 hash = md5(uri.encode('utf-8')).hexdigest() 167 return util.bytestring_path("{0}.png".format(hash)) 168 169 def add_tags(self, album, image_path): 170 """Write required metadata to the thumbnail 171 See http://standards.freedesktop.org/thumbnail-spec/latest/x142.html 172 """ 173 mtime = os.stat(album.artpath).st_mtime 174 metadata = {"Thumb::URI": self.get_uri(album.artpath), 175 "Thumb::MTime": six.text_type(mtime)} 176 try: 177 self.write_metadata(image_path, metadata) 178 except Exception: 179 self._log.exception(u"could not write metadata to {0}", 180 util.displayable_path(image_path)) 181 182 def make_dolphin_cover_thumbnail(self, album): 183 outfilename = os.path.join(album.path, b".directory") 184 if os.path.exists(outfilename): 185 return 186 artfile = os.path.split(album.artpath)[1] 187 with open(outfilename, 'w') as f: 188 f.write('[Desktop Entry]\n') 189 f.write('Icon=./{0}'.format(artfile.decode('utf-8'))) 190 f.close() 191 self._log.debug(u"Wrote file {0}", util.displayable_path(outfilename)) 192 193 194def write_metadata_im(file, metadata): 195 """Enrich the file metadata with `metadata` dict thanks to IM.""" 196 command = ['convert', file] + \ 197 list(chain.from_iterable(('-set', k, v) 198 for k, v in metadata.items())) + [file] 199 util.command_output(command) 200 return True 201 202 203def write_metadata_pil(file, metadata): 204 """Enrich the file metadata with `metadata` dict thanks to PIL.""" 205 from PIL import Image, PngImagePlugin 206 im = Image.open(file) 207 meta = PngImagePlugin.PngInfo() 208 for k, v in metadata.items(): 209 meta.add_text(k, v, 0) 210 im.save(file, "PNG", pnginfo=meta) 211 return True 212 213 214class URIGetter(object): 215 available = False 216 name = "Abstract base" 217 218 def uri(self, path): 219 raise NotImplementedError() 220 221 222class PathlibURI(URIGetter): 223 available = True 224 name = "Python Pathlib" 225 226 def uri(self, path): 227 return PurePosixPath(path).as_uri() 228 229 230def copy_c_string(c_string): 231 """Copy a `ctypes.POINTER(ctypes.c_char)` value into a new Python 232 string and return it. The old memory is then safe to free. 233 """ 234 # This is a pretty dumb way to get a string copy, but it seems to 235 # work. A more surefire way would be to allocate a ctypes buffer and copy 236 # the data with `memcpy` or somesuch. 237 s = ctypes.cast(c_string, ctypes.c_char_p).value 238 return b'' + s 239 240 241class GioURI(URIGetter): 242 """Use gio URI function g_file_get_uri. Paths must be utf-8 encoded. 243 """ 244 name = "GIO" 245 246 def __init__(self): 247 self.libgio = self.get_library() 248 self.available = bool(self.libgio) 249 if self.available: 250 self.libgio.g_type_init() # for glib < 2.36 251 252 self.libgio.g_file_get_uri.argtypes = [ctypes.c_char_p] 253 self.libgio.g_file_new_for_path.restype = ctypes.c_void_p 254 255 self.libgio.g_file_get_uri.argtypes = [ctypes.c_void_p] 256 self.libgio.g_file_get_uri.restype = ctypes.POINTER(ctypes.c_char) 257 258 self.libgio.g_object_unref.argtypes = [ctypes.c_void_p] 259 260 def get_library(self): 261 lib_name = ctypes.util.find_library("gio-2") 262 try: 263 if not lib_name: 264 return False 265 return ctypes.cdll.LoadLibrary(lib_name) 266 except OSError: 267 return False 268 269 def uri(self, path): 270 g_file_ptr = self.libgio.g_file_new_for_path(path) 271 if not g_file_ptr: 272 raise RuntimeError(u"No gfile pointer received for {0}".format( 273 util.displayable_path(path))) 274 275 try: 276 uri_ptr = self.libgio.g_file_get_uri(g_file_ptr) 277 finally: 278 self.libgio.g_object_unref(g_file_ptr) 279 if not uri_ptr: 280 self.libgio.g_free(uri_ptr) 281 raise RuntimeError(u"No URI received from the gfile pointer for " 282 u"{0}".format(util.displayable_path(path))) 283 284 try: 285 uri = copy_c_string(uri_ptr) 286 finally: 287 self.libgio.g_free(uri_ptr) 288 289 try: 290 return uri.decode(util._fsencoding()) 291 except UnicodeDecodeError: 292 raise RuntimeError( 293 "Could not decode filename from GIO: {!r}".format(uri) 294 ) 295