AlbumCover: use Nephilim logo when there's no cover.
[nephilim.git] / nephilim / plugins / AlbumCover.py
blob55ff1a139e3356f5b2d2ac5881c6e714cf69fbf2
2 # Copyright (C) 2009 Anton Khirnov <wyskas@gmail.com>
4 # Nephilim is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
9 # Nephilim is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
14 # You should have received a copy of the GNU General Public License
15 # along with Nephilim. If not, see <http://www.gnu.org/licenses/>.
18 from PyQt4 import QtGui, QtCore, QtNetwork
19 from PyQt4.QtCore import QVariant
21 import os
23 from ..plugin import Plugin
24 from .. import common
27 class AlbumCoverWidget(QtGui.QLabel):
28 "cover - QPixmap or None"
29 cover = None
30 "is there a (non-default) cover loaded?"
31 cover_loaded = False
32 "plugin object"
33 plugin = None
34 "logger"
35 logger = None
37 _menu = None # popup menu
39 def __init__(self, plugin):
40 QtGui.QLabel.__init__(self)
41 self.plugin = plugin
42 self.logger = plugin.logger
43 self.setAlignment(QtCore.Qt.AlignCenter)
45 # popup menu
46 self._menu = QtGui.QMenu('album')
47 self._menu.addAction('&Select cover file...', self.plugin.select_cover)
48 self._menu.addAction('&Refresh cover.', self.plugin.refresh)
49 self._menu.addAction('&View in a separate window.', self.__view_cover)
50 self._menu.addAction('Save cover &as...', self.__save_cover)
51 self._menu.addAction('&Clear cover.', self.__clear_cover)
53 def contextMenuEvent(self, event):
54 event.accept()
55 self._menu.popup(event.globalPos())
57 def set_cover(self, song, cover):
58 """Set cover for current song."""
59 self.logger.info('Setting cover')
60 if not cover or cover.isNull():
61 self.cover = None
62 self.cover_loaded = False
63 self.setPixmap(QtGui.QPixmap('gfx/nephilim.png'))
64 self.plugin.cover_changed.emit(QtGui.QPixmap())
65 return
67 if song != self.plugin.mpclient.current_song():
68 return
70 self.cover = cover
71 self.cover_loaded = True
72 self.setPixmap(self.cover.scaled(self.size(), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))
73 self.plugin.cover_changed.emit(self.cover)
74 self.logger.info('Cover set.')
76 def __view_cover(self):
77 if not self.cover_loaded:
78 return
79 win = QtGui.QLabel(self, QtCore.Qt.Window)
80 win.setScaledContents(True)
81 win.setPixmap(self.cover)
82 win.show()
84 def __save_cover(self):
85 if not self.cover_loaded:
86 return
87 cover = self.cover
88 file = QtGui.QFileDialog.getSaveFileName(None, '', QtCore.QDir.homePath())
89 if file:
90 self.plugin.save_cover_file(cover, file)
92 def __clear_cover(self):
93 self.plugin.delete_cover_file()
94 self.set_cover(None, None)
95 self.plugin.refresh()
97 class AlbumCover(Plugin):
98 # public, constant
99 info = 'Display the album cover of the currently playing album.'
101 # public, read-only
102 o = None
104 # private
105 DEFAULTS = {'coverdir' : '$musicdir/$songdir', 'covername' : '.cover_nephilim_$artist_$album',
106 'fetchers': QtCore.QStringList(['local', 'Last.fm']), 'store' : True}
107 "implemented fetchers"
108 available_fetchers = None
109 "enabled fetchers, those with higher priority first"
110 __fetchers = None
111 "number of returned results from last refresh() call"
112 __results = None
113 "index/priority of current cover"
114 __index = None
115 "metadata paths"
116 __cover_dir = None
117 __cover_path = None
119 # SIGNALS
120 cover_changed = QtCore.pyqtSignal(QtGui.QPixmap)
122 #### private ####
123 def __init__(self, parent, mpclient, name):
124 Plugin.__init__(self, parent, mpclient, name)
126 self.__fetchers = []
127 self.available_fetchers = [self.FetcherLocal, self.FetcherLastfm]
129 def __new_cover_fetched(self, song, cover):
130 self.logger.info('Got new cover.')
131 self.__results += 1
133 i = self.__fetchers.index(self.sender())
134 if cover and i < self.__index:
135 if self.settings.value(self.name + '/store').toBool():
136 self.save_cover_file(cover)
137 self.__index = i
138 return self.o.set_cover(song, cover)
139 elif self.__results >= len(self.__fetchers) and not self.o.cover_loaded:
140 self.o.set_cover(song, None)
142 def __abort_fetch(self):
143 """Aborts all fetches currently in progress."""
144 for fetcher in self.__fetchers:
145 fetcher.abort()
147 class SettingsWidgetAlbumCover(Plugin.SettingsWidget):
148 coverdir = None
149 covername = None
150 store = None
151 fetcherlist = None
153 def __init__(self, plugin):
154 Plugin.SettingsWidget.__init__(self, plugin)
155 self.settings.beginGroup(self.plugin.name)
157 # store covers groupbox
158 self.store = QtGui.QGroupBox('Store covers.')
159 self.store.setToolTip('Should %s store its own copy of covers?'%common.APPNAME)
160 self.store.setCheckable(True)
161 self.store.setChecked(self.settings.value('store').toBool())
162 self.store.setLayout(QtGui.QGridLayout())
164 # paths to covers
165 self.coverdir = QtGui.QLineEdit(self.settings.value('coverdir').toString())
166 self.coverdir.setToolTip('Where should %s store covers.\n'
167 '$musicdir will be expanded to path to MPD music library (as set by user)\n'
168 '$songdir will be expanded to path to the song (relative to $musicdir\n'
169 'other tags same as in covername'
170 %common.APPNAME)
171 self.covername = QtGui.QLineEdit(self.settings.value('covername').toString())
172 self.covername.setToolTip('Filename for %s cover files.\n'
173 'All tags supported by MPD will be expanded to their\n'
174 'values for current song, e.g. $title, $track, $artist,\n'
175 '$album, $genre etc.'%common.APPNAME)
176 self.store.layout().addWidget(QtGui.QLabel('Cover directory'), 0, 0)
177 self.store.layout().addWidget(self.coverdir, 0, 1)
178 self.store.layout().addWidget(QtGui.QLabel('Cover filename'), 1, 0)
179 self.store.layout().addWidget(self.covername, 1, 1)
181 # sites list
182 fetchers = self.settings.value('fetchers').toStringList()
183 self.fetcherlist = QtGui.QListWidget(self)
184 self.fetcherlist.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
185 for site in fetchers:
186 it = QtGui.QListWidgetItem(site)
187 it.setCheckState(QtCore.Qt.Checked)
188 self.fetcherlist.addItem(it)
189 for site in self.plugin.available_fetchers:
190 if not site.name in fetchers:
191 it = QtGui.QListWidgetItem(site.name)
192 it.setCheckState(QtCore.Qt.Unchecked)
193 self.fetcherlist.addItem(it)
195 self.setLayout(QtGui.QVBoxLayout())
196 self.layout().addWidget(self.store)
197 self._add_widget(self.fetcherlist, label = 'Fetchers', tooltip = 'A list of sources used for fetching covers.\n'
198 'Use drag and drop to change their priority.')
200 self.settings.endGroup()
202 def save_settings(self):
203 self.settings.beginGroup(self.plugin.name)
204 self.settings.setValue('coverdir', QVariant(self.coverdir.text()))
205 self.settings.setValue('covername', QVariant(self.covername.text()))
206 self.settings.setValue('store', QVariant(self.store.isChecked()))
208 fetchers = QtCore.QStringList()
209 for i in range(self.fetcherlist.count()):
210 it = self.fetcherlist.item(i)
211 if it.checkState() == QtCore.Qt.Checked:
212 fetchers.append(it.text())
213 self.settings.setValue('fetchers', QVariant(fetchers))
214 self.settings.endGroup()
215 self.plugin.refresh()
217 class FetcherLastfm(common.MetadataFetcher):
218 name = 'Last.fm'
220 def fetch(self, song):
221 url = QtCore.QUrl('http://ws.audioscrobbler.com/2.0/')
222 url.setQueryItems([('api_key', 'c325945c67b3e8327e01e3afb7cdcf35'),
223 ('method', 'album.getInfo'),
224 ('artist', song['artist']),
225 ('album', song['album']),
226 ('mbid', song['MUSICBRAINZ_ALBUMID'])])
227 self.fetch2(song, url)
228 self.rep.finished.connect(self.__handle_search_res)
230 def __handle_search_res(self):
231 url = None
232 xml = QtCore.QXmlStreamReader(self.rep)
234 while not xml.atEnd():
235 token = xml.readNext()
236 if token == QtCore.QXmlStreamReader.StartElement:
237 if xml.name() == 'image' and xml.attributes().value('size') == 'extralarge':
238 url = QtCore.QUrl() # the url is already percent-encoded
239 url.setEncodedUrl(xml.readElementText().toLatin1())
240 if xml.hasError():
241 self.logger.error('Error parsing seach results: %s'%xml.errorString())
243 if not url:
244 self.logger.info('Didn\'t find the URL in %s search results.'%self.name)
245 return self.finish()
246 self.logger.info('Found %s song URL: %s.'%(self.name, url))
248 self.rep = self.nam.get(QtNetwork.QNetworkRequest(url))
249 self.rep.finished.connect(self.__handle_cover)
251 def __handle_cover(self):
252 data = self.rep.readAll()
253 pixmap = QtGui.QPixmap()
254 if pixmap.loadFromData(data):
255 self.finish(pixmap)
256 self.finish()
258 class FetcherLocal(QtCore.QObject):
259 """This fetcher tries to find cover files in the same directory as
260 current song."""
261 #public, read-only
262 name = 'local'
263 logger = None
264 settings = None
266 # SIGNALS
267 finished = QtCore.pyqtSignal('song', 'metadata')
269 def __init__(self, plugin):
270 QtCore.QObject.__init__(self, plugin)
271 self.logger = plugin.logger
272 self.settings = QtCore.QSettings()
274 def fetch(self, song):
275 self.logger.info('Trying to guess local cover name.')
276 # guess cover name
277 covers = ['cover', 'album', 'front']
279 exts = []
280 for ext in QtGui.QImageReader().supportedImageFormats():
281 exts.append('*.%s'%str(ext))
283 filter = []
284 for cover in covers:
285 for ext in exts:
286 filter.append('*.%s%s'%(cover,ext))
288 dirname, filename = common.generate_metadata_path(song, '$musicdir/$songdir', '')
289 dir = QtCore.QDir(dirname)
290 if not dir:
291 self.logger.error('Error opening directory %s.'%dirname)
292 return self.finished.emit(song, None)
294 dir.setNameFilters(filter)
295 files = dir.entryList()
296 if files:
297 cover = QtGui.QPixmap(dir.filePath(files[0]))
298 if not cover.isNull():
299 self.logger.info('Found a cover: %s'%dir.filePath(files[0]))
300 return self.finished.emit(song, cover)
302 # if this failed, try any supported image
303 dir.setNameFilters(exts)
304 files = dir.entryList()
305 if files:
306 cover = QtGui.QPixmap(dir.filePath(files[0]))
307 if not cover.isNull():
308 self.logger.info('Found a cover: %s'%dir.filePath(files[0]))
309 return self.finished.emit(song, cover)
310 self.logger.info('No matching cover found')
311 self.finished.emit(song, None)
313 def abort(self):
314 pass
316 #### public ####
317 def _load(self):
318 self.o = AlbumCoverWidget(self)
319 self.mpclient.song_changed.connect(self.refresh)
320 self.refresh_fetchers()
321 def _unload(self):
322 self.o = None
323 self.mpclient.song_changed.disconnect(self.refresh)
325 def refresh(self):
326 self.logger.info('Autorefreshing cover.')
327 self.__results = 0
328 self.__index = len(self.__fetchers)
329 self.o.cover_loaded = False
330 song = self.mpclient.current_song()
331 if not song:
332 self.__cover_dir = ''
333 self.__cover_path = ''
334 return self.o.set_cover(None, None)
336 (self.__cover_dir, self.__cover_path) = common.generate_metadata_path(song,
337 self.settings.value(self.name + '/coverdir').toString(),
338 self.settings.value(self.name + '/covername').toString())
339 try:
340 self.logger.info('Trying to read cover from file %s.'%self.__cover_path)
341 cover = QtGui.QPixmap(self.__cover_path)
342 if not cover.isNull():
343 return self.o.set_cover(song, cover)
344 except IOError, e:
345 self.logger.info('Error reading cover file: %s.'%e)
347 for fetcher in self.__fetchers:
348 fetcher.fetch(song)
350 def refresh_fetchers(self):
351 """Refresh the list of available fetchers."""
352 self.__fetchers = []
353 # append fetchers in order they are stored in settings
354 for name in self.settings.value('%s/fetchers'%self.name).toStringList():
355 for site in self.available_fetchers:
356 if site.name == name:
357 self.__fetchers.append(site(self))
358 self.__fetchers[-1].finished.connect(self.__new_cover_fetched)
360 def save_cover_file(self, cover, path = None):
361 """Save cover to a file specified in path.
362 If path is None, then a default value is used."""
363 self.logger.info('Saving cover...')
364 try:
365 if not path:
366 path = self.__cover_path
367 cover.save(path, 'png')
368 self.logger.info('Cover successfully saved.')
369 except IOError, e:
370 self.logger.error('Error writing cover: %s', e)
372 def delete_cover_file(self, song = None):
373 """Delete a cover file for song. If song is not specified
374 current song is used."""
375 if not song:
376 path = self.__cover_path
377 else:
378 path = common.generate_metadata_path(song, self.settings.value(self.name + '/coverdir').toString(),
379 self.settings.value(self.name + '/covername').toString())
380 if not QtCore.QFile.remove(path):
381 self.logger.error('Error removing file %s.'%path)
383 def select_cover(self):
384 """Prompt user to manually select cover file for current song."""
385 song = self.mpclient.current_song()
386 if not song:
387 return
388 self.__abort_fetch()
390 file = QtGui.QFileDialog.getOpenFileName(None,
391 'Select album cover for %s - %s'%(song['artist'], song['album']),
392 self.__cover_dir, '')
393 if not file:
394 return
396 cover = QtGui.QPixmap(file)
397 if cover.isNull():
398 self.logger.error('Error opening cover file.')
399 return
401 if self.settings.value(self.name + '/store').toBool():
402 self.save_cover_file(cover)
403 self.o.set_cover(song, cover)
405 def cover(self):
406 if not self.o:
407 return None
408 return self.o.cover if self.o.cover_loaded else None
410 def _get_dock_widget(self):
411 return self._create_dock(self.o)
413 def get_settings_widget(self):
414 return self.SettingsWidgetAlbumCover(self)