~jwiltshire/pogo/debian

« back to all changes in this revision

Viewing changes to src/modules/Covers.py

  • Committer: Jonathan Wiltshire
  • Date: 2010-12-20 23:52:57 UTC
  • Revision ID: git-v1:e24ab7d692aa9fecd89514fbd769b83b9db2dd55
Tags: upstream/0.3
Imported Upstream version 0.3

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# -*- coding: utf-8 -*-
 
2
#
 
3
# Authors: Ingelrest François (Francois.Ingelrest@gmail.com)
 
4
#          Jendrik Seipp (jendrikseipp@web.de)
 
5
#
 
6
# This program is free software; you can redistribute it and/or modify
 
7
# it under the terms of the GNU General Public License as published by
 
8
# the Free Software Foundation; either version 2 of the License, or
 
9
# (at your option) any later version.
 
10
#
 
11
# This program is distributed in the hope that it will be useful,
 
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
14
# GNU Library General Public License for more details.
 
15
#
 
16
# You should have received a copy of the GNU General Public License
 
17
# along with this program; if not, write to the Free Software
 
18
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301 USA
 
19
 
 
20
import modules, os, tools, traceback
 
21
 
 
22
from tools     import consts, prefs
 
23
from gettext   import gettext as _
 
24
from tools.log import logger
 
25
 
 
26
 
 
27
# Module information
 
28
MOD_INFO = ('Covers', _('Covers'), _('Show album covers'), [], False, True)
 
29
MOD_NAME = MOD_INFO[modules.MODINFO_NAME]
 
30
 
 
31
AS_API_KEY   = '4d7befd13245afcc73f9ed7518b6619a'   # Jendrik Seipp's Audioscrobbler API key
 
32
AS_TAG_START = '<image size="large">'               # The text that is right before the URL to the cover
 
33
AS_TAG_END   = '</image>'                           # The text that is right after the URL to the cover
 
34
 
 
35
# It seems that a non standard 'user-agent' header may cause problem, so let's cheat
 
36
USER_AGENT = 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.0.1) Gecko/2008072820 Firefox/3.0.1'
 
37
 
 
38
# We store both the paths to the thumbnail and to the full size image
 
39
(
 
40
    CVR_THUMB,
 
41
    CVR_FULL
 
42
) = range(2)
 
43
 
 
44
# Constants for thumbnails
 
45
THUMBNAIL_WIDTH   = 100  # Width allocated to thumbnails in the model
 
46
THUMBNAIL_HEIGHT  = 100  # Height allocated to thumbnails in the model
 
47
THUMBNAIL_OFFSETX =  11  # X-offset to render the thumbnail in the model
 
48
THUMBNAIL_OFFSETY =   3  # Y-offset to render the thumbnail in the model
 
49
 
 
50
# Constants for full size covers
 
51
FULL_SIZE_COVER_WIDTH  = 300
 
52
FULL_SIZE_COVER_HEIGHT = 300
 
53
 
 
54
# File formats we can read
 
55
ACCEPTED_FILE_FORMATS = {'.jpg': None, '.jpeg': None, '.png': None, '.gif': None}
 
56
 
 
57
# Default preferences
 
58
PREFS_DFT_DOWNLOAD_COVERS      = True
 
59
PREFS_DFT_PREFER_USER_COVERS   = True
 
60
PREFS_DFT_USER_COVER_FILENAMES = ['folder', 'cover', 'art', 'front', '*']
 
61
 
 
62
# Images for thumbnails
 
63
THUMBNAIL_GLOSS = os.path.join(consts.dirPix, 'cover-gloss.png')
 
64
THUMBNAIL_MODEL = os.path.join(consts.dirPix, 'cover-model.png')
 
65
 
 
66
 
 
67
class Covers(modules.ThreadedModule):
 
68
 
 
69
    def __init__(self):
 
70
        """ Constructor """
 
71
        handlers = {
 
72
                        consts.MSG_EVT_APP_QUIT:     self.onModUnloaded,
 
73
                        consts.MSG_EVT_NEW_TRACK:    self.onNewTrack,
 
74
                        consts.MSG_EVT_MOD_LOADED:   self.onModLoaded,
 
75
                        consts.MSG_EVT_APP_STARTED:  self.onModLoaded,
 
76
                        consts.MSG_EVT_MOD_UNLOADED: self.onModUnloaded,
 
77
                   }
 
78
 
 
79
        modules.ThreadedModule.__init__(self, handlers)
 
80
 
 
81
 
 
82
    def generateFullSizeCover(self, inFile, outFile, format):
 
83
        """ Resize inFile if needed, and write it to outFile (outFile and inFile may be equal) """
 
84
        import Image
 
85
 
 
86
        try:
 
87
            # Open the image
 
88
            cover = Image.open(inFile)
 
89
 
 
90
            # Resize in the best way we can
 
91
            #if cover.size[0] < FULL_SIZE_COVER_WIDTH: newWidth = cover.size[0]
 
92
            #else:                                     newWidth = FULL_SIZE_COVER_WIDTH
 
93
 
 
94
            #if cover.size[1] < FULL_SIZE_COVER_HEIGHT: newHeight = cover.size[1]
 
95
            #else:                                      newHeight = FULL_SIZE_COVER_HEIGHT
 
96
            
 
97
            width = cover.size[0]
 
98
            height = cover.size[1]
 
99
            max_width = FULL_SIZE_COVER_WIDTH
 
100
            max_height = FULL_SIZE_COVER_HEIGHT
 
101
            newWidth, newHeight = tools.resize(width, height, max_width, max_height)
 
102
            
 
103
            cover = cover.resize((newWidth, newHeight), Image.ANTIALIAS)
 
104
 
 
105
            # We're done
 
106
            cover.save(outFile, format)
 
107
        except:
 
108
            logger.error('[%s] An error occurred while generating a showable full size cover\n\n%s' % (MOD_NAME, traceback.format_exc()))
 
109
 
 
110
 
 
111
    def generateThumbnail(self, inFile, outFile, format):
 
112
        """ Generate a thumbnail from inFile (e.g., resize it) and write it to outFile (outFile and inFile may be equal) """
 
113
        import Image
 
114
 
 
115
        try:
 
116
            # Open the image
 
117
            cover = Image.open(inFile).convert('RGBA')
 
118
 
 
119
            # Resize in the best way we can
 
120
            #if cover.size[0] < THUMBNAIL_WIDTH:
 
121
            #    newWidth = cover.size[0]
 
122
            #    #offsetX  = (THUMBNAIL_WIDTH - cover.size[0]) / 2
 
123
            #else:
 
124
            #    newWidth = THUMBNAIL_WIDTH
 
125
                #offsetX  = 0
 
126
 
 
127
            #if cover.size[1] < THUMBNAIL_HEIGHT:
 
128
            #    newHeight = cover.size[1]
 
129
                #offsetY   = (THUMBNAIL_HEIGHT - cover.size[1]) / 2
 
130
            #else:
 
131
            #    newHeight = THUMBNAIL_HEIGHT
 
132
                #offsetY   = 0
 
133
                
 
134
            width = cover.size[0]
 
135
            height = cover.size[1]
 
136
            max_width = THUMBNAIL_WIDTH
 
137
            max_height = THUMBNAIL_HEIGHT
 
138
            newWidth, newHeight = tools.resize(width, height, max_width, max_height)
 
139
 
 
140
            cover = cover.resize((newWidth, newHeight), Image.ANTIALIAS)
 
141
 
 
142
            # Paste the resized cover into our model
 
143
            #model = Image.open(THUMBNAIL_MODEL).convert('RGBA')
 
144
            #model.paste(cover, (THUMBNAIL_OFFSETX + offsetX, THUMBNAIL_OFFSETY + offsetY), cover)
 
145
            #cover = model
 
146
 
 
147
            # Don't apply the gloss effect if asked to
 
148
            #if not prefs.getCmdLine()[0].no_glossy_cover:
 
149
            #    gloss = Image.open(THUMBNAIL_GLOSS).convert('RGBA')
 
150
            #    cover.paste(gloss, (0, 0), gloss)
 
151
 
 
152
            # We're done
 
153
            cover.save(outFile, format)
 
154
        except:
 
155
            logger.error('[%s] An error occurred while generating a thumbnail\n\n%s' % (MOD_NAME, traceback.format_exc()))
 
156
 
 
157
 
 
158
    def getUserCover(self, trackPath):
 
159
        """ Return the path to a cover file in trackPath, None if no cover found """
 
160
        # Create a dictionary with candidates
 
161
        candidates = {}
 
162
        for (file, path) in tools.listDir(trackPath, True):
 
163
            (name, ext) = os.path.splitext(file.lower())
 
164
            if ext in ACCEPTED_FILE_FORMATS:
 
165
                candidates[name] = path
 
166
 
 
167
        # Check each possible name using the its index in the list as its priority
 
168
        for name in prefs.get(__name__, 'user-cover-filenames', PREFS_DFT_USER_COVER_FILENAMES):
 
169
            if name in candidates:
 
170
                return candidates[name]
 
171
 
 
172
            if name == '*' and len(candidates) != 0:
 
173
                return candidates.values()[0]
 
174
 
 
175
        return None
 
176
 
 
177
 
 
178
    def getFromCache(self, artist, album):
 
179
        """ Return the path to the cached cover, or None if it's not cached """
 
180
        cachePath    = os.path.join(self.cacheRootPath, str(abs(hash(artist))))
 
181
        cacheIdxPath = os.path.join(cachePath, 'INDEX')
 
182
 
 
183
        try:
 
184
            cacheIdx = tools.pickleLoad(cacheIdxPath)
 
185
            cover    = os.path.join(cachePath, cacheIdx[artist + album])
 
186
            if os.path.exists(cover):
 
187
                return cover
 
188
        except:
 
189
            pass
 
190
 
 
191
        return None
 
192
 
 
193
 
 
194
    def __getFromInternet(self, artist, album):
 
195
        """
 
196
            Try to download the cover from the Internet
 
197
            If successful, add it to the cache and return the path to it
 
198
            Otherwise, return None
 
199
        """
 
200
        import socket, urllib2
 
201
 
 
202
        # Make sure to not be blocked by the request
 
203
        socket.setdefaulttimeout(consts.socketTimeout)
 
204
 
 
205
        # Request information to Last.fm
 
206
        # Beware of UTF-8 characters: we need to percent-encode all characters
 
207
        try:
 
208
            url = 'http://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=%s&artist=%s&album=%s' % (AS_API_KEY,
 
209
                tools.percentEncode(artist), tools.percentEncode(album))
 
210
            request = urllib2.Request(url, headers = {'User-Agent': USER_AGENT})
 
211
            stream = urllib2.urlopen(request)
 
212
            data = stream.read()
 
213
        except urllib2.HTTPError, err:
 
214
            if err.code == 400:
 
215
                logger.error('[%s] No known cover for %s / %s' % (MOD_NAME, artist, album))
 
216
            else:
 
217
                logger.error('[%s] Information request failed\n\n%s' % (MOD_NAME, traceback.format_exc()))
 
218
            return None
 
219
        except:
 
220
            logger.error('[%s] Information request failed\n\n%s' % (MOD_NAME, traceback.format_exc()))
 
221
            return None
 
222
 
 
223
        # Extract the URL to the cover image
 
224
        malformed = True
 
225
        startIdx  = data.find(AS_TAG_START)
 
226
        endIdx    = data.find(AS_TAG_END, startIdx)
 
227
        if startIdx != -1 and endIdx != -1:
 
228
            coverURL    = data[startIdx+len(AS_TAG_START):endIdx]
 
229
            coverFormat = os.path.splitext(coverURL)[1].lower()
 
230
            if coverURL.startswith('http://') and coverFormat in ACCEPTED_FILE_FORMATS:
 
231
                malformed = False
 
232
 
 
233
        if malformed:
 
234
            ## Do not show the data in the log every time no cover is found
 
235
            if coverURL:
 
236
                logger.error('[%s] Received malformed data\n\n%s' % (MOD_NAME, data))
 
237
            return None
 
238
 
 
239
        # Download the cover image
 
240
        try:
 
241
            request = urllib2.Request(coverURL, headers = {'User-Agent': USER_AGENT})
 
242
            stream  = urllib2.urlopen(request)
 
243
            data    = stream.read()
 
244
 
 
245
            if len(data) < 1024:
 
246
                raise Exception, 'The cover image seems incorrect (%u bytes is too small)' % len(data)
 
247
        except:
 
248
            logger.error('[%s] Cover image request failed\n\n%s' % (MOD_NAME, traceback.format_exc()))
 
249
            return None
 
250
 
 
251
        # So far, so good: let's cache the image
 
252
        cachePath    = os.path.join(self.cacheRootPath, str(abs(hash(artist))))
 
253
        cacheIdxPath = os.path.join(cachePath, 'INDEX')
 
254
 
 
255
        if not os.path.exists(cachePath):
 
256
            os.mkdir(cachePath)
 
257
 
 
258
        try:    cacheIdx = tools.pickleLoad(cacheIdxPath)
 
259
        except: cacheIdx = {}
 
260
 
 
261
        nextInt   = len(cacheIdx) + 1
 
262
        filename  = str(nextInt) + coverFormat
 
263
        coverPath = os.path.join(cachePath, filename)
 
264
 
 
265
        cacheIdx[artist + album] = filename
 
266
        tools.pickleSave(cacheIdxPath, cacheIdx)
 
267
 
 
268
        try:
 
269
            output = open(coverPath, 'wb')
 
270
            output.write(data)
 
271
            output.close()
 
272
            return coverPath
 
273
        except:
 
274
            logger.error('[%s] Could not save the downloaded cover\n\n%s' % (MOD_NAME, traceback.format_exc()))
 
275
 
 
276
        return None
 
277
 
 
278
 
 
279
    def getFromInternet(self, artist, album):
 
280
        """ Wrapper for __getFromInternet(), manage blacklist """
 
281
        # If we already tried without success, don't try again
 
282
        if (artist, album) in self.coverBlacklist:
 
283
            return None
 
284
 
 
285
        # Otherwise, try to download the cover
 
286
        cover = self.__getFromInternet(artist, album)
 
287
 
 
288
        # If the download failed, blacklist the album
 
289
        if cover is None:
 
290
            self.coverBlacklist[(artist, album)] = None
 
291
 
 
292
        return cover
 
293
 
 
294
 
 
295
    # --== Message handlers ==--
 
296
 
 
297
 
 
298
    def onModLoaded(self):
 
299
        """ The module has been loaded """
 
300
        self.cfgWin         = None                                   # Configuration window
 
301
        self.coverMap       = {}                                     # Store covers previously requested
 
302
        self.currTrack      = None                                   # The current track being played, if any
 
303
        self.cacheRootPath  = os.path.join(consts.dirCfg, MOD_NAME)  # Local cache for Internet covers
 
304
        self.coverBlacklist = {}                                     # When a cover cannot be downloaded, avoid requesting it again
 
305
 
 
306
        if not os.path.exists(self.cacheRootPath):
 
307
            os.mkdir(self.cacheRootPath)
 
308
 
 
309
 
 
310
    def onModUnloaded(self):
 
311
        """ The module has been unloaded """
 
312
        if self.currTrack is not None:
 
313
            modules.postMsg(consts.MSG_CMD_SET_COVER, {'track': self.currTrack, 'pathThumbnail': None, 'pathFullSize': None})
 
314
 
 
315
        # Delete covers that have been generated by this module
 
316
        for covers in self.coverMap.itervalues():
 
317
            if os.path.exists(covers[CVR_THUMB]):
 
318
                os.remove(covers[CVR_THUMB])
 
319
            if os.path.exists(covers[CVR_FULL]):
 
320
                os.remove(covers[CVR_FULL])
 
321
        self.coverMap = None
 
322
 
 
323
        # Delete blacklist
 
324
        self.coverBlacklist = None
 
325
 
 
326
 
 
327
    def onNewTrack(self, track):
 
328
        """ A new track is being played, try to retrieve the corresponding cover """
 
329
        # Make sure we have enough information
 
330
        if track.getArtist() == consts.UNKNOWN_ARTIST or track.getAlbum() == consts.UNKNOWN_ALBUM:
 
331
            modules.postMsg(consts.MSG_CMD_SET_COVER, {'track': track, 'pathThumbnail': None, 'pathFullSize': None})
 
332
            return
 
333
 
 
334
        album          = track.getAlbum().lower()
 
335
        artist         = track.getArtist().lower()
 
336
        rawCover       = None
 
337
        self.currTrack = track
 
338
 
 
339
        # Let's see whether we already have the cover
 
340
        if (artist, album) in self.coverMap:
 
341
            covers        = self.coverMap[(artist, album)]
 
342
            pathFullSize  = covers[CVR_FULL]
 
343
            pathThumbnail = covers[CVR_THUMB]
 
344
 
 
345
            # Make sure the files are still there
 
346
            if os.path.exists(pathThumbnail) and os.path.exists(pathFullSize):
 
347
                modules.postMsg(consts.MSG_CMD_SET_COVER, {'track': track, 'pathThumbnail': pathThumbnail, 'pathFullSize': pathFullSize})
 
348
                return
 
349
 
 
350
        # Should we check for a user cover?
 
351
        if not prefs.get(__name__, 'download-covers', PREFS_DFT_DOWNLOAD_COVERS)        \
 
352
            or prefs.get(__name__, 'prefer-user-covers', PREFS_DFT_PREFER_USER_COVERS):
 
353
                rawCover = self.getUserCover(os.path.dirname(track.getFilePath()))
 
354
 
 
355
        # Is it in our cache?
 
356
        if rawCover is None:
 
357
            rawCover = self.getFromCache(artist, album)
 
358
 
 
359
        # If we still don't have a cover, maybe we can try to download it
 
360
        if rawCover is None:
 
361
            modules.postMsg(consts.MSG_CMD_SET_COVER, {'track': track, 'pathThumbnail': None, 'pathFullSize': None})
 
362
 
 
363
            if prefs.get(__name__, 'download-covers', PREFS_DFT_DOWNLOAD_COVERS):
 
364
                rawCover = self.getFromInternet(artist, album)
 
365
 
 
366
        # If we still don't have a cover, too bad
 
367
        # Otherwise, generate a thumbnail and a full size cover, and add it to our cover map
 
368
        if rawCover is not None:
 
369
            import tempfile
 
370
 
 
371
            thumbnail     = tempfile.mktemp() + '.png'
 
372
            fullSizeCover = tempfile.mktemp() + '.png'
 
373
            self.generateThumbnail(rawCover, thumbnail, 'PNG')
 
374
            self.generateFullSizeCover(rawCover, fullSizeCover, 'PNG')
 
375
            if os.path.exists(thumbnail) and os.path.exists(fullSizeCover):
 
376
                self.coverMap[(artist, album)] = (thumbnail, fullSizeCover)
 
377
                modules.postMsg(consts.MSG_CMD_SET_COVER, {'track': track, 'pathThumbnail': thumbnail, 'pathFullSize': fullSizeCover})
 
378
            else:
 
379
                modules.postMsg(consts.MSG_CMD_SET_COVER, {'track': track, 'pathThumbnail': None, 'pathFullSize': None})
 
380
 
 
381
 
 
382
    # --== Configuration ==--
 
383
 
 
384
 
 
385
    def configure(self, parent):
 
386
        """ Show the configuration window """
 
387
        if self.cfgWin is None:
 
388
            from gui.window import Window
 
389
 
 
390
            self.cfgWin = Window('Covers.glade', 'vbox1', __name__, MOD_INFO[modules.MODINFO_L10N], 320, 265)
 
391
            self.cfgWin.getWidget('btn-ok').connect('clicked', self.onBtnOk)
 
392
            self.cfgWin.getWidget('img-lastfm').set_from_file(os.path.join(consts.dirPix, 'audioscrobbler.png'))
 
393
            self.cfgWin.getWidget('btn-help').connect('clicked', self.onBtnHelp)
 
394
            self.cfgWin.getWidget('chk-downloadCovers').connect('toggled', self.onDownloadCoversToggled)
 
395
            self.cfgWin.getWidget('btn-cancel').connect('clicked', lambda btn: self.cfgWin.hide())
 
396
 
 
397
        if not self.cfgWin.isVisible():
 
398
            downloadCovers     = prefs.get(__name__, 'download-covers',      PREFS_DFT_DOWNLOAD_COVERS)
 
399
            preferUserCovers   = prefs.get(__name__, 'prefer-user-covers',   PREFS_DFT_PREFER_USER_COVERS)
 
400
            userCoverFilenames = prefs.get(__name__, 'user-cover-filenames', PREFS_DFT_USER_COVER_FILENAMES)
 
401
 
 
402
            self.cfgWin.getWidget('btn-ok').grab_focus()
 
403
            self.cfgWin.getWidget('txt-filenames').set_text(', '.join(userCoverFilenames))
 
404
            self.cfgWin.getWidget('chk-downloadCovers').set_active(downloadCovers)
 
405
            self.cfgWin.getWidget('chk-preferUserCovers').set_active(preferUserCovers)
 
406
            self.cfgWin.getWidget('chk-preferUserCovers').set_sensitive(downloadCovers)
 
407
 
 
408
        self.cfgWin.show()
 
409
 
 
410
 
 
411
    def onBtnOk(self, btn):
 
412
        """ Save configuration """
 
413
        downloadCovers     = self.cfgWin.getWidget('chk-downloadCovers').get_active()
 
414
        preferUserCovers   = self.cfgWin.getWidget('chk-preferUserCovers').get_active()
 
415
        userCoverFilenames = [word.strip() for word in self.cfgWin.getWidget('txt-filenames').get_text().split(',')]
 
416
 
 
417
        prefs.set(__name__, 'download-covers',      downloadCovers)
 
418
        prefs.set(__name__, 'prefer-user-covers',   preferUserCovers)
 
419
        prefs.set(__name__, 'user-cover-filenames', userCoverFilenames)
 
420
 
 
421
        self.cfgWin.hide()
 
422
 
 
423
 
 
424
    def onDownloadCoversToggled(self, downloadCovers):
 
425
        """ Toggle the "prefer user covers" checkbox according to the state of the "download covers" one """
 
426
        self.cfgWin.getWidget('chk-preferUserCovers').set_sensitive(downloadCovers.get_active())
 
427
 
 
428
 
 
429
    def onBtnHelp(self, btn):
 
430
        """ Display a small help message box """
 
431
        from gui import help
 
432
 
 
433
        helpDlg = help.HelpDlg(MOD_INFO[modules.MODINFO_L10N])
 
434
        helpDlg.addSection(_('Description'),
 
435
                           _('This module displays the cover of the album the current track comes from. Covers '
 
436
                              'may be loaded from local pictures, located in the same directory as the current '
 
437
                              'track, or may be downloaded from the Internet.'))
 
438
        helpDlg.addSection(_('User Covers'),
 
439
                           _('A user cover is a picture located in the same directory as the current track. '
 
440
                             'When specifying filenames, you do not need to provide file extensions, supported '
 
441
                             'file formats (%s) are automatically used.' % ', '.join(ACCEPTED_FILE_FORMATS.iterkeys())))
 
442
        helpDlg.addSection(_('Internet Covers'),
 
443
                           _('Covers may be downloaded from the Internet, based on the tags of the current track. '
 
444
                             'You can ask to always prefer user covers to Internet ones. In this case, if a user '
 
445
                             'cover exists for the current track, it is used. If there is none, the cover is downloaded.'))
 
446
        helpDlg.show(self.cfgWin)