1'''thumbnail.py - Thumbnail module for MComix implementing (most of) the
2freedesktop.org "standard" at http://jens.triq.net/thumbnail-spec/
3'''
4
5import os
6import re
7import shutil
8import tempfile
9import threading
10import traceback
11import PIL.Image as Image
12from urllib.request import pathname2url
13
14from hashlib import md5
15
16from mcomix import archive_extractor
17from mcomix import archive_tools
18from mcomix import callback
19from mcomix import constants
20from mcomix import i18n
21from mcomix import image_tools
22from mcomix import log
23from mcomix import mimetypes
24from mcomix import portability
25from mcomix import tools
26from mcomix.lib import reader
27from mcomix.preferences import prefs
28
29
30class Thumbnailer(object):
31    ''' The Thumbnailer class is responsible for managing MComix
32    internal thumbnail creation. Depending on its settings,
33    it either stores thumbnails on disk and retrieves them later,
34    or simply creates new thumbnails each time it is called. '''
35
36    def __init__(self, dst_dir=constants.THUMBNAIL_PATH, store_on_disk=None,
37                 size=None, force_recreation=False, archive_support=False):
38        '''
39        <dst_dir> set the thumbnailer's storage directory.
40
41        If <store_on_disk> on disk is True, it changes the thumbnailer's
42        behaviour to store files on disk, or just create new thumbnails each
43        time it was called when set to False. Defaults to the 'create
44        thumbnails' preference if not set.
45
46        The dimensions for the created thumbnails is set by <size>, a (width,
47        height) tupple. Defaults to the 'thumbnail size' preference if not set.
48
49        If <force_recreation> is True, thumbnails stored on disk
50        will always be re-created instead of being re-used.
51
52        If <archive_support> is True, support for archive thumbnail creation
53        (based on cover detection) is enabled. Otherwise, only image files are
54        supported.
55        '''
56        self.dst_dir = dst_dir
57        if store_on_disk is None:
58            self.store_on_disk = prefs['create thumbnails']
59        else:
60            self.store_on_disk = store_on_disk
61        if size is None:
62            self.width = self.height = prefs['thumbnail size']
63            self.default_sizes = True
64        else:
65            self.width, self.height = size
66            self.default_sizes = False
67        self.force_recreation = force_recreation
68        self.archive_support = archive_support
69
70    def thumbnail(self, filepath, mt=False):
71        ''' Returns a thumbnail pixbuf for <filepath>, transparently handling
72        both normal image files and archives. If a thumbnail file already exists,
73        it is re-used. Otherwise, a new thumbnail is created from <filepath>.
74
75        Returns None if thumbnail creation failed, or if the thumbnail creation
76        is run asynchrounosly. '''
77
78        # Update width and height from preferences if they haven't been set explicitly
79        if self.default_sizes:
80            self.width = prefs['thumbnail size']
81            self.height = prefs['thumbnail size']
82
83        if self._thumbnail_exists(filepath):
84            thumbpath = self._path_to_thumbpath(filepath)
85            pixbuf = image_tools.load_pixbuf(thumbpath)
86            self.thumbnail_finished(filepath, pixbuf)
87            return pixbuf
88
89        else:
90            if mt:
91                thread = threading.Thread(target=self._create_thumbnail, args=(filepath,))
92                thread.name += '-thumbnailer'
93                thread.daemon=True
94                thread.start()
95                return None
96            else:
97                return self._create_thumbnail(filepath)
98
99    @callback.Callback
100    def thumbnail_finished(self, filepath, pixbuf):
101        ''' Called every time a thumbnail has been completed.
102        <filepath> is the file that was used as source, <pixbuf> is the
103        resulting thumbnail. '''
104
105        pass
106
107    def delete(self, filepath):
108        ''' Deletes the thumbnail for <filepath> (if it exists) '''
109        thumbpath = self._path_to_thumbpath(filepath)
110        if os.path.isfile(thumbpath):
111            try:
112                os.remove(thumbpath)
113            except IOError as error:
114                log.error(_('! Could not remove file "%s"'), thumbpath)
115                log.error(error)
116
117    def _create_thumbnail_pixbuf(self, filepath):
118        ''' Creates a thumbnail pixbuf from <filepath>, and returns it as a
119        tuple along with a file metadata dictionary: (pixbuf, tEXt_data) '''
120
121        if self.archive_support:
122            mime = archive_tools.archive_mime_type(filepath)
123        else:
124            mime = None
125        if mime is not None:
126            if not archive_tools.is_archive_file(filepath):
127                return None, None
128            with archive_tools.get_recursive_archive_handler(
129                    filepath, type=mime,
130                    prefix='mcomix_archive_thumb.') as archive:
131                if archive is None:
132                    return None, None
133                if archive.is_encrypted:
134                    image_path=tools.pkg_path('images','encrypted-book.png')
135                else:
136                    files = archive.list_contents(decrypt=False)
137                    wanted = self._guess_cover(files)
138                    if wanted is None:
139                        return None, None
140
141                    image_path = archive.extract(wanted)
142                    if not os.path.isfile(image_path):
143                        return None, None
144
145                pixbuf = image_tools.load_pixbuf_size(image_path, self.width, self.height)
146                if self.store_on_disk:
147                    tEXt_data = self._get_text_data(image_path)
148                    # Use the archive's mTime instead of the extracted file's mtime
149                    tEXt_data['tEXt::Thumb::MTime'] = str(os.stat(filepath).st_mtime)
150                else:
151                    tEXt_data = None
152
153                return pixbuf, tEXt_data
154
155        elif image_tools.is_image_file(filepath, check_mimetype=True):
156            pixbuf = image_tools.load_pixbuf_size(filepath, self.width, self.height)
157            if self.store_on_disk:
158                tEXt_data = self._get_text_data(filepath)
159            else:
160                tEXt_data = None
161
162            return pixbuf, tEXt_data
163        else:
164            return None, None
165
166    def _create_thumbnail(self, filepath):
167        ''' Creates the thumbnail pixbuf for <filepath>, and saves the pixbuf
168        to disk if necessary. Returns the created pixbuf, or None, if creation failed. '''
169
170        pixbuf, tEXt_data = self._create_thumbnail_pixbuf(filepath)
171        self.thumbnail_finished(filepath, pixbuf)
172
173        if pixbuf and self.store_on_disk:
174            thumbpath = self._path_to_thumbpath(filepath)
175            self._save_thumbnail(pixbuf, thumbpath, tEXt_data)
176
177        return pixbuf
178
179    def _get_text_data(self, filepath):
180        ''' Creates a tEXt dictionary for <filepath>. '''
181        mime = mimetypes.guess_type(filepath)[0] or 'unknown/mime'
182        uri = portability.uri_prefix() + pathname2url(i18n.to_unicode(os.path.normpath(filepath)))
183        stat = os.stat(filepath)
184        # MTime could be floating point number, so convert to long first to have a fixed point number
185        mtime = str(stat.st_mtime)
186        size = str(stat.st_size)
187        format, width, height = image_tools.get_image_info(filepath)
188        return {
189            'tEXt::Thumb::URI':           uri,
190            'tEXt::Thumb::MTime':         mtime,
191            'tEXt::Thumb::Size':          size,
192            'tEXt::Thumb::Mimetype':      mime,
193            'tEXt::Thumb::Image::Width':  str(width),
194            'tEXt::Thumb::Image::Height': str(height),
195            'tEXt::Software':             'MComix %s' % constants.VERSION
196        }
197
198    def _save_thumbnail(self, pixbuf, thumbpath, tEXt_data):
199        ''' Saves <pixbuf> as <thumbpath>, with additional metadata
200        from <tEXt_data>. If <thumbpath> already exists, it is overwritten. '''
201
202        try:
203            directory = os.path.dirname(thumbpath)
204            if not os.path.isdir(directory):
205                os.makedirs(directory, 0o700)
206            if os.path.isfile(thumbpath):
207                os.remove(thumbpath)
208
209            option_keys = []
210            option_values = []
211            for key, value in tEXt_data.items():
212                option_keys.append(key)
213                option_values.append(value)
214            pixbuf.savev(thumbpath, 'png', option_keys, option_values)
215            os.chmod(thumbpath, 0o600)
216
217        except Exception as ex:
218            log.warning( _('! Could not save thumbnail "%(thumbpath)s": %(error)s'),
219                { 'thumbpath' : thumbpath, 'error' : ex } )
220
221    def _thumbnail_exists(self, filepath):
222        ''' Checks if the thumbnail for <filepath> already exists.
223        This function will return False if the thumbnail exists
224        and it's mTime doesn't match the mTime of <filepath>,
225        it's size is different from the one specified in the thumbnailer,
226        or if <force_recreation> is True. '''
227
228        if not self.force_recreation:
229            thumbpath = self._path_to_thumbpath(filepath)
230
231            if os.path.isfile(thumbpath):
232                # Check the thumbnail's stored mTime
233                try:
234                    with reader.LockedFileIO(thumbpath) as fio:
235                        with Image.open(fio) as img:
236                            info = img.info
237                            stored_mtime = float(info['Thumb::MTime'])
238                            # The source file might no longer exist
239                            file_mtime = os.path.isfile(filepath) and os.stat(filepath).st_mtime or stored_mtime
240                            return stored_mtime == file_mtime and \
241                                max(*img.size) == max(self.width, self.height)
242                except IOError:
243                    return False
244            else:
245                return False
246        else:
247            return False
248
249    def _path_to_thumbpath(self, filepath):
250        ''' Converts <path> to an URI for the thumbnail in <dst_dir>. '''
251        uri = portability.uri_prefix() + pathname2url(i18n.to_unicode(os.path.normpath(filepath)))
252        return self._uri_to_thumbpath(uri)
253
254    def _uri_to_thumbpath(self, uri):
255        ''' Return the full path to the thumbnail for <uri> with <dst_dir>
256        being the base thumbnail directory. '''
257        md5hash = md5(uri.encode()).hexdigest()
258        thumbpath = os.path.join(self.dst_dir, md5hash + '.png')
259        return thumbpath
260
261    def _guess_cover(self, files):
262        '''Return the filename within <files> that is the most likely to be the
263        cover of an archive using some simple heuristics.
264        '''
265        # Ignore MacOSX meta files.
266        files = filter(lambda filename:
267                       '__MACOSX' not in os.path.normpath(filename).split(os.sep),
268                       files)
269        # Ignore credit files if possible.
270        files = filter(lambda filename:
271                       'credit' not in os.path.basename(filename).lower(), files)
272
273        images = [f for f in files if image_tools.is_image_file(f)]
274
275        tools.alphanumeric_sort(images)
276
277        front_re = re.compile('(cover|front)', re.I)
278        candidates = filter(front_re.search, images)
279        candidates = [c for c in candidates if 'back' not in c.lower()]
280
281        if candidates:
282            return candidates[0]
283
284        if images:
285            return images[0]
286
287        return None
288
289# vim: expandtab:sw=4:ts=4
290