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