1
# -*- coding: utf-8 -*-
3
# Authors: Ingelrest François (Francois.Ingelrest@gmail.com)
4
# Jendrik Seipp (jendrikseipp@web.de)
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.
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.
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
20
import modules, os, tools, traceback
22
from tools import consts, prefs
23
from gettext import gettext as _
24
from tools.log import logger
28
MOD_INFO = ('Covers', _('Covers'), _('Show album covers'), [], False, True)
29
MOD_NAME = MOD_INFO[modules.MODINFO_NAME]
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
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'
38
# We store both the paths to the thumbnail and to the full size image
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
50
# Constants for full size covers
51
FULL_SIZE_COVER_WIDTH = 300
52
FULL_SIZE_COVER_HEIGHT = 300
54
# File formats we can read
55
ACCEPTED_FILE_FORMATS = {'.jpg': None, '.jpeg': None, '.png': None, '.gif': None}
58
PREFS_DFT_DOWNLOAD_COVERS = True
59
PREFS_DFT_PREFER_USER_COVERS = True
60
PREFS_DFT_USER_COVER_FILENAMES = ['folder', 'cover', 'art', 'front', '*']
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')
67
class Covers(modules.ThreadedModule):
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,
79
modules.ThreadedModule.__init__(self, handlers)
82
def generateFullSizeCover(self, inFile, outFile, format):
83
""" Resize inFile if needed, and write it to outFile (outFile and inFile may be equal) """
88
cover = Image.open(inFile)
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
94
#if cover.size[1] < FULL_SIZE_COVER_HEIGHT: newHeight = cover.size[1]
95
#else: newHeight = FULL_SIZE_COVER_HEIGHT
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)
103
cover = cover.resize((newWidth, newHeight), Image.ANTIALIAS)
106
cover.save(outFile, format)
108
logger.error('[%s] An error occurred while generating a showable full size cover\n\n%s' % (MOD_NAME, traceback.format_exc()))
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) """
117
cover = Image.open(inFile).convert('RGBA')
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
124
# newWidth = THUMBNAIL_WIDTH
127
#if cover.size[1] < THUMBNAIL_HEIGHT:
128
# newHeight = cover.size[1]
129
#offsetY = (THUMBNAIL_HEIGHT - cover.size[1]) / 2
131
# newHeight = THUMBNAIL_HEIGHT
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)
140
cover = cover.resize((newWidth, newHeight), Image.ANTIALIAS)
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)
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)
153
cover.save(outFile, format)
155
logger.error('[%s] An error occurred while generating a thumbnail\n\n%s' % (MOD_NAME, traceback.format_exc()))
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
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
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]
172
if name == '*' and len(candidates) != 0:
173
return candidates.values()[0]
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')
184
cacheIdx = tools.pickleLoad(cacheIdxPath)
185
cover = os.path.join(cachePath, cacheIdx[artist + album])
186
if os.path.exists(cover):
194
def __getFromInternet(self, artist, album):
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
200
import socket, urllib2
202
# Make sure to not be blocked by the request
203
socket.setdefaulttimeout(consts.socketTimeout)
205
# Request information to Last.fm
206
# Beware of UTF-8 characters: we need to percent-encode all characters
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)
213
except urllib2.HTTPError, err:
215
logger.error('[%s] No known cover for %s / %s' % (MOD_NAME, artist, album))
217
logger.error('[%s] Information request failed\n\n%s' % (MOD_NAME, traceback.format_exc()))
220
logger.error('[%s] Information request failed\n\n%s' % (MOD_NAME, traceback.format_exc()))
223
# Extract the URL to the cover image
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:
234
## Do not show the data in the log every time no cover is found
236
logger.error('[%s] Received malformed data\n\n%s' % (MOD_NAME, data))
239
# Download the cover image
241
request = urllib2.Request(coverURL, headers = {'User-Agent': USER_AGENT})
242
stream = urllib2.urlopen(request)
246
raise Exception, 'The cover image seems incorrect (%u bytes is too small)' % len(data)
248
logger.error('[%s] Cover image request failed\n\n%s' % (MOD_NAME, traceback.format_exc()))
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')
255
if not os.path.exists(cachePath):
258
try: cacheIdx = tools.pickleLoad(cacheIdxPath)
259
except: cacheIdx = {}
261
nextInt = len(cacheIdx) + 1
262
filename = str(nextInt) + coverFormat
263
coverPath = os.path.join(cachePath, filename)
265
cacheIdx[artist + album] = filename
266
tools.pickleSave(cacheIdxPath, cacheIdx)
269
output = open(coverPath, 'wb')
274
logger.error('[%s] Could not save the downloaded cover\n\n%s' % (MOD_NAME, traceback.format_exc()))
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:
285
# Otherwise, try to download the cover
286
cover = self.__getFromInternet(artist, album)
288
# If the download failed, blacklist the album
290
self.coverBlacklist[(artist, album)] = None
295
# --== Message handlers ==--
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
306
if not os.path.exists(self.cacheRootPath):
307
os.mkdir(self.cacheRootPath)
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})
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])
324
self.coverBlacklist = None
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})
334
album = track.getAlbum().lower()
335
artist = track.getArtist().lower()
337
self.currTrack = track
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]
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})
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()))
355
# Is it in our cache?
357
rawCover = self.getFromCache(artist, album)
359
# If we still don't have a cover, maybe we can try to download it
361
modules.postMsg(consts.MSG_CMD_SET_COVER, {'track': track, 'pathThumbnail': None, 'pathFullSize': None})
363
if prefs.get(__name__, 'download-covers', PREFS_DFT_DOWNLOAD_COVERS):
364
rawCover = self.getFromInternet(artist, album)
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:
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})
379
modules.postMsg(consts.MSG_CMD_SET_COVER, {'track': track, 'pathThumbnail': None, 'pathFullSize': None})
382
# --== Configuration ==--
385
def configure(self, parent):
386
""" Show the configuration window """
387
if self.cfgWin is None:
388
from gui.window import Window
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())
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)
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)
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(',')]
417
prefs.set(__name__, 'download-covers', downloadCovers)
418
prefs.set(__name__, 'prefer-user-covers', preferUserCovers)
419
prefs.set(__name__, 'user-cover-filenames', userCoverFilenames)
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())
429
def onBtnHelp(self, btn):
430
""" Display a small help message box """
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)