AlbumCover: implement manually setting cover
[nephilim.git] / nephilim / plugins / AlbumCover.py
bloba38a474306022945ea1b4536e9af56ec918352b0
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/no-cd-cover.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, read-only
99 o = None
101 # private
102 DEFAULTS = {'coverdir' : '$musicdir/$songdir', 'covername' : '.cover_nephilim_$artist_$album',
103 'fetchers': QtCore.QStringList(['local', 'Last.fm']), 'store' : True}
104 "implemented fetchers"
105 available_fetchers = None
106 "enabled fetchers, those with higher priority first"
107 __fetchers = None
108 "number of returned results from last refresh() call"
109 __results = None
110 "index/priority of current cover"
111 __index = None
112 "metadata paths"
113 __cover_dir = None
114 __cover_path = None
116 # SIGNALS
117 cover_changed = QtCore.pyqtSignal(QtGui.QPixmap)
119 #### private ####
120 def __init__(self, parent, mpclient, name):
121 Plugin.__init__(self, parent, mpclient, name)
123 self.__fetchers = []
124 self.available_fetchers = [self.FetcherLocal, self.FetcherLastfm]
126 def __new_cover_fetched(self, song, cover):
127 self.logger.info('Got new cover.')
128 self.__results += 1
130 i = self.__fetchers.index(self.sender())
131 if cover and i < self.__index:
132 if self.settings.value(self.name + '/store').toBool():
133 self.save_cover_file(cover)
134 self.__index = i
135 return self.o.set_cover(song, cover)
136 elif self.__results >= len(self.__fetchers) and not self.o.cover_loaded:
137 self.o.set_cover(song, None)
139 def __abort_fetch(self):
140 """Aborts all fetches currently in progress."""
141 for fetcher in self.__fetchers:
142 fetcher.abort()
144 class SettingsWidgetAlbumCover(Plugin.SettingsWidget):
145 coverdir = None
146 covername = None
147 store = None
148 fetcherlist = None
150 def __init__(self, plugin):
151 Plugin.SettingsWidget.__init__(self, plugin)
152 self.settings.beginGroup(self.plugin.name)
154 # store covers groupbox
155 self.store = QtGui.QGroupBox('Store covers.')
156 self.store.setToolTip('Should %s store its own copy of covers?'%common.APPNAME)
157 self.store.setCheckable(True)
158 self.store.setChecked(self.settings.value('store').toBool())
159 self.store.setLayout(QtGui.QGridLayout())
161 # paths to covers
162 self.coverdir = QtGui.QLineEdit(self.settings.value('coverdir').toString())
163 self.coverdir.setToolTip('Where should %s store covers.\n'
164 '$musicdir will be expanded to path to MPD music library (as set by user)\n'
165 '$songdir will be expanded to path to the song (relative to $musicdir\n'
166 'other tags same as in covername'
167 %common.APPNAME)
168 self.covername = QtGui.QLineEdit(self.settings.value('covername').toString())
169 self.covername.setToolTip('Filename for %s cover files.\n'
170 'All tags supported by MPD will be expanded to their\n'
171 'values for current song, e.g. $title, $track, $artist,\n'
172 '$album, $genre etc.'%common.APPNAME)
173 self.store.layout().addWidget(QtGui.QLabel('Cover directory'), 0, 0)
174 self.store.layout().addWidget(self.coverdir, 0, 1)
175 self.store.layout().addWidget(QtGui.QLabel('Cover filename'), 1, 0)
176 self.store.layout().addWidget(self.covername, 1, 1)
178 # sites list
179 fetchers = self.settings.value('fetchers').toStringList()
180 self.fetcherlist = QtGui.QListWidget(self)
181 self.fetcherlist.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
182 for site in fetchers:
183 it = QtGui.QListWidgetItem(site)
184 it.setCheckState(QtCore.Qt.Checked)
185 self.fetcherlist.addItem(it)
186 for site in self.plugin.available_fetchers:
187 if not site.name in fetchers:
188 it = QtGui.QListWidgetItem(site.name)
189 it.setCheckState(QtCore.Qt.Unchecked)
190 self.fetcherlist.addItem(it)
192 self.setLayout(QtGui.QVBoxLayout())
193 self.layout().addWidget(self.store)
194 self._add_widget(self.fetcherlist, label = 'Fetchers', tooltip = 'A list of sources used for fetching covers.\n'
195 'Use drag and drop to change their priority.')
197 self.settings.endGroup()
199 def save_settings(self):
200 self.settings.beginGroup(self.plugin.name)
201 self.settings.setValue('coverdir', QVariant(self.coverdir.text()))
202 self.settings.setValue('covername', QVariant(self.covername.text()))
203 self.settings.setValue('store', QVariant(self.store.isChecked()))
205 fetchers = QtCore.QStringList()
206 for i in range(self.fetcherlist.count()):
207 it = self.fetcherlist.item(i)
208 if it.checkState() == QtCore.Qt.Checked:
209 fetchers.append(it.text())
210 self.settings.setValue('fetchers', QVariant(fetchers))
211 self.settings.endGroup()
212 self.plugin.refresh()
214 class FetcherLastfm(common.MetadataFetcher):
215 name = 'Last.fm'
217 def fetch(self, song):
218 url = QtCore.QUrl('http://ws.audioscrobbler.com/2.0/')
219 url.setQueryItems([('api_key', 'c325945c67b3e8327e01e3afb7cdcf35'),
220 ('method', 'album.getInfo'),
221 ('artist', song.artist()),
222 ('album', song.album())])
223 self.fetch2(song, url)
224 self.srep.finished.connect(self.__handle_search_res)
226 def __handle_search_res(self):
227 url = None
228 xml = QtCore.QXmlStreamReader(self.srep)
230 while not xml.atEnd():
231 token = xml.readNext()
232 if token == QtCore.QXmlStreamReader.StartElement:
233 if xml.name() == 'image' and xml.attributes().value('size') == 'extralarge':
234 url = QtCore.QUrl() # the url is already percent-encoded
235 url.setEncodedUrl(xml.readElementText().toLatin1())
236 if xml.hasError():
237 self.logger.error('Error parsing seach results: %s'%xml.errorString())
239 if not url:
240 self.logger.info('Didn\'t find the URL in %s search results.'%self.name)
241 return self.finish()
242 self.logger.info('Found %s song URL: %s.'%(self.name, url))
244 self.mrep = self.nam.get(QtNetwork.QNetworkRequest(url))
245 self.mrep.finished.connect(self.__handle_cover)
247 def __handle_cover(self):
248 data = self.mrep.readAll()
249 pixmap = QtGui.QPixmap()
250 if pixmap.loadFromData(data):
251 self.finish(pixmap)
252 self.finish()
254 class FetcherLocal(QtCore.QObject):
255 """This fetcher tries to find cover files in the same directory as
256 current song."""
257 #public, read-only
258 name = 'local'
259 logger = None
260 settings = None
262 # SIGNALS
263 finished = QtCore.pyqtSignal('song', 'metadata')
265 def __init__(self, plugin):
266 QtCore.QObject.__init__(self, plugin)
267 self.logger = plugin.logger
268 self.settings = QtCore.QSettings()
270 def fetch(self, song):
271 self.logger.info('Trying to guess local cover name.')
272 # guess cover name
273 covers = ['cover', 'album', 'front']
275 exts = []
276 for ext in QtGui.QImageReader().supportedImageFormats():
277 exts.append('*.%s'%str(ext))
279 filter = []
280 for cover in covers:
281 for ext in exts:
282 filter.append('*.%s%s'%(cover,ext))
284 dir = QtCore.QDir('%s/%s'%(self.settings.value('MPD/music_dir').toString(),
285 os.path.dirname(song.filepath())))
286 if not dir:
287 self.logger.error('Error opening directory' + self.__cover_dir)
288 return self.finished.emit(song, None)
290 dir.setNameFilters(filter)
291 files = dir.entryList()
292 if files:
293 cover = QtGui.QPixmap(dir.filePath(files[0]))
294 if not cover.isNull():
295 self.logger.info('Found a cover: %s'%dir.filePath(files[0]))
296 return self.finished.emit(song, cover)
298 # if this failed, try any supported image
299 dir.setNameFilters(exts)
300 files = dir.entryList()
301 if files:
302 cover = QtGui.QPixmap(dir.filePath(files[0]))
303 if not cover.isNull():
304 self.logger.info('Found a cover: %s'%dir.filePath(files[0]))
305 return self.finished.emit(song, cover)
306 self.logger.info('No matching cover found')
307 self.finished.emit(song, None)
309 def abort(self):
310 pass
312 #### public ####
313 def _load(self):
314 self.o = AlbumCoverWidget(self)
315 self.mpclient.song_changed.connect(self.refresh)
316 self.refresh_fetchers()
317 def _unload(self):
318 self.o = None
319 self.mpclient.song_changed.disconnect(self.refresh)
320 def info(self):
321 return "Display the album cover of the currently playing album."
323 def refresh(self):
324 self.logger.info('Autorefreshing cover.')
325 self.__results = 0
326 self.__index = len(self.__fetchers)
327 self.o.cover_loaded = False
328 song = self.mpclient.current_song()
329 if not song:
330 self.__cover_dir = ''
331 self.__cover_path = ''
332 return self.o.set_cover(None, None)
334 (self.__cover_dir, self.__cover_path) = common.generate_metadata_path(song,
335 self.settings.value(self.name + '/coverdir').toString(),
336 self.settings.value(self.name + '/covername').toString())
337 try:
338 self.logger.info('Trying to read cover from file %s.'%self.__cover_path)
339 cover = QtGui.QPixmap(self.__cover_path)
340 if not cover.isNull():
341 return self.o.set_cover(song, cover)
342 except IOError, e:
343 self.logger.info('Error reading cover file: %s.'%e)
345 for fetcher in self.__fetchers:
346 fetcher.fetch(song)
348 def refresh_fetchers(self):
349 """Refresh the list of available fetchers."""
350 self.__fetchers = []
351 # append fetchers in order they are stored in settings
352 for name in self.settings.value('%s/fetchers'%self.name).toStringList():
353 for site in self.available_fetchers:
354 if site.name == name:
355 self.__fetchers.append(site(self))
356 self.__fetchers[-1].finished.connect(self.__new_cover_fetched)
358 def save_cover_file(self, cover, path = None):
359 """Save cover to a file specified in path.
360 If path is None, then a default value is used."""
361 self.logger.info('Saving cover...')
362 try:
363 if not path:
364 path = self.__cover_path
365 cover.save(path, 'png')
366 self.logger.info('Cover successfully saved.')
367 except IOError, e:
368 self.logger.error('Error writing cover: %s', e)
370 def delete_cover_file(self, song = None):
371 """Delete a cover file for song. If song is not specified
372 current song is used."""
373 if not song:
374 path = self.__cover_path
375 else:
376 path = common.generate_metadata_path(song, self.settings.value(self.name + '/coverdir').toString(),
377 self.settings.value(self.name + '/covername').toString())
378 if not QtCore.QFile.remove(path):
379 self.logger.error('Error removing file %s.'%path)
381 def select_cover(self):
382 """Prompt user to manually select cover file for current song."""
383 song = self.mpclient.current_song()
384 if not song:
385 return
386 self.__abort_fetch()
388 file = QtGui.QFileDialog.getOpenFileName(None,
389 'Select album cover for %s - %s'%(song.artist(), song.album()),
390 self.__cover_dir, '')
391 if not file:
392 return
394 cover = QtGui.QPixmap(file)
395 if cover.isNull():
396 self.logger.error('Error opening cover file.')
397 return
399 if self.settings.value(self.name + '/store').toBool():
400 self.save_cover_file(cover)
401 self.o.set_cover(song, cover)
403 def cover(self):
404 if not self.o:
405 return None
406 return self.o.cover if self.o.cover_loaded else None
408 def _get_dock_widget(self):
409 return self._create_dock(self.o)
411 def get_settings_widget(self):
412 return self.SettingsWidgetAlbumCover(self)