Lyrics: user-selectable site priority.
[nephilim.git] / nephilim / plugins / Lyrics.py
blobe4aa7239659402c5435a25155f9cd047970d011f
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
22 import re
23 from lxml import etree
25 from ..plugin import Plugin
26 from .. import misc
28 class LyricsWidget(QtGui.QWidget):
29 #public
30 lyrics_loaded = None
32 # public, read-only
33 plugin = None # plugin
34 logger = None
36 # private
37 __text_view = None # text-object
38 __toolbar = None
39 __label = None
41 #### private
42 def __init__(self, plugin):
43 QtGui.QWidget.__init__(self)
44 self.plugin = plugin
45 self.logger = plugin.logger
46 self.curLyrics = ''
48 self.__label = QtGui.QLabel(self)
49 self.__label.setWordWrap(True)
51 # add text area
52 self.__text_view = QtGui.QTextEdit(self)
53 self.__text_view.setReadOnly(True)
55 # add toolbar
56 self.__toolbar = QtGui.QToolBar('Lyrics toolbar', self)
57 self.__toolbar.setOrientation(QtCore.Qt.Vertical)
59 self.__toolbar.addAction(QtGui.QIcon('gfx/refresh.png'), 'Refresh lyrics', self.plugin.refresh)
60 edit = self.__toolbar.addAction(QtGui.QIcon('gfx/edit.png'), 'Edit lyrics')
61 edit.setCheckable(True)
62 edit.connect(edit, QtCore.SIGNAL('toggled(bool)'), self.__toggle_editable)
64 self.__toolbar.addAction(QtGui.QIcon('gfx/save.png'), 'Save lyrics', self.__save_lyrics)
65 self.__toolbar.addAction(QtGui.QIcon('gfx/delete.png'), 'Delete stored file', self.plugin.del_lyrics_file)
67 self.setLayout(QtGui.QGridLayout())
68 self.layout().setSpacing(0)
69 self.layout().setMargin(0)
70 self.layout().addWidget(self.__toolbar, 0, 0, -1, 1, QtCore.Qt.AlignTop)
71 self.layout().addWidget(self.__label, 0, 1)
72 self.layout().addWidget(self.__text_view, 1, 1)
74 def __save_lyrics(self):
75 self.plugin.save_lyrics_file(unicode(self.__text_view.toPlainText()).encode('utf-8'))
77 def __toggle_editable(self, val):
78 self.__text_view.setReadOnly(not val)
80 #### public ####
81 def set_lyrics(self, song, lyrics, flags = 0):
82 """Set currently displayed lyrics for song. flags parameter is
83 unused now."""
84 if not song:
85 self.__label.clear()
86 return self.__text_view.clear()
88 # a late thread might call this for a previous song
89 if song != self.plugin.mpclient.current_song():
90 return
92 self.__text_view.clear()
93 self.__label.setText('<b>%s</b> by <u>%s</u> on <u>%s</u>'\
94 %(song.title(), song.artist(), song.album()))
95 if lyrics:
96 self.logger.info('Setting new lyrics.')
97 self.__text_view.insertPlainText(lyrics.decode('utf-8'))
98 self.lyrics_loaded = True
99 else:
100 self.logger.info('Lyrics not found.')
101 self.__text_view.insertPlainText('Lyrics not found.')
104 class Lyrics(Plugin):
105 # public, read-only
106 o = None
108 # private
109 DEFAULTS = {'sites' : QtCore.QStringList(['Lyricwiki', 'Animelyrics']), 'lyricdir' : '$musicdir/$songdir',
110 'lyricname' : '.lyrics_nephilim_$artist_$album_$title', 'store' : True}
111 "implemented fetchers"
112 available_sites = None
113 "enabled fetchers, those with higher priority first"
114 __fetchers = None
115 "number of returned results from last refresh() call"
116 __results = None
117 "index/priority of current lyrics"
118 __index = None
119 "metadata"
120 __lyrics_dir = None
121 "paths"
122 __lyrics_path = None
124 #### private ####
125 def __init__(self, parent, mpclient, name):
126 Plugin.__init__(self, parent, mpclient, name)
128 self.__fetchers = []
129 self.available_sites = [self.FetchLyricwiki, self.FetchAnimelyrics]
131 def __new_lyrics_fetched(self, song, lyrics):
132 self.logger.info('Got new lyrics.')
133 self.__results += 1
135 i = self.__fetchers.index(self.sender())
136 if lyrics and i < self.__index:
137 if self.settings.value(self.name + '/store').toBool():
138 self.save_lyrics_file(lyrics)
139 self.__index = i
140 return self.o.set_lyrics(song, lyrics)
141 elif self.__results >= len(self.__fetchers) and not self.o.lyrics_loaded:
142 self.o.set_lyrics(song, None)
144 class Fetcher(QtCore.QObject):
145 """A basic class for lyrics fetchers. Provides a fetch(song) function,
146 emits a finished(song, lyrics) signal when done; lyrics is either a QString,
147 Python unicode string or None if not found."""
148 #public, read-only
149 logger = None
150 name = ''
152 #private
153 nam = None # NetworkAccessManager
154 srep = None # search results NetworkReply
155 lrep = None # lyrics page NetworkReply
156 song = None # current song
158 #### private ####
159 def __init__(self, plugin):
160 QtCore.QObject.__init__(self, plugin)
162 self.nam = QtNetwork.QNetworkAccessManager()
163 self.logger = plugin.logger
165 def fetch2(self, song, url):
166 """A private convenience function to initiate fetch process."""
167 # abort any existing connections
168 if self.srep:
169 self.srep.abort()
170 self.srep = None
171 if self.lrep:
172 self.lrep.abort()
173 self.lrep = None
174 self.song = song
176 self.logger.info('Searching %s: %s.'%(self. name, url))
177 self.srep = self.nam.get(QtNetwork.QNetworkRequest(url))
179 def finish(self, lyrics = None):
180 """A private convenience function to clean up and emit finished().
181 Feel free to reimplement/not use it."""
182 self.srep = None
183 self.lrep = None
184 self.emit(QtCore.SIGNAL('finished'), self.song, lyrics)
185 self.song = None
187 #### public ####
188 def fetch(self, song):
189 """Reimplement this in subclasses."""
190 pass
192 class FetchLyricwiki(Fetcher):
193 name = 'Lyricwiki'
195 def fetch(self, song):
196 url = QtCore.QUrl('http://lyricwiki.org/api.php')
197 url.setQueryItems([('func', 'getSong'), ('artist', song.artist()),
198 ('song', song.title()), ('fmt', 'xml')])
199 self.fetch2(song, url)
200 self.connect(self.srep, QtCore.SIGNAL('finished()'), self.__handle_search_res)
202 def __handle_search_res(self):
203 url = None
204 xml = QtCore.QXmlStreamReader(self.srep)
205 while not xml.atEnd():
206 token = xml.readNext()
207 if token == QtCore.QXmlStreamReader.StartElement:
208 if xml.name() == 'url':
209 url = QtCore.QUrl() # the url is already percent-encoded
210 url.setEncodedUrl(xml.readElementText().toLatin1())
211 elif xml.name() == 'lyrics' and xml.readElementText() == 'Not found':
212 xml.clear()
213 return self.finish()
214 if xml.hasError():
215 self.logger.error('Error parsing seach results.%s'%xml.errorString())
217 if not url:
218 self.logger.error('Didn\'t find the URL in Lyricwiki search results.')
219 return self.finish()
220 self.logger.info('Found Lyricwiki song URL: %s.'%url)
222 self.lrep = self.nam.get(QtNetwork.QNetworkRequest(url))
223 self.connect(self.lrep, QtCore.SIGNAL('finished()'), self.__handle_lyrics)
225 def __handle_lyrics(self):
226 #TODO this should use Qt xml functions too
227 lyrics = ''
228 page = unicode(self.lrep.readAll(), encoding = 'utf-8')
229 page = re.sub('<br>|<br/>|<br />', '\n', page)
230 html = etree.HTML(page)
231 for elem in html.iterfind('.//div'):
232 if elem.get('class') == 'lyricbox':
233 lyrics += etree.tostring(elem, method = 'text', encoding = 'utf-8')
234 self.finish(lyrics)
236 class FetchAnimelyrics(Fetcher):
237 name = 'Animelyrics'
239 def fetch(self, song):
240 url = QtCore.QUrl('http://www.animelyrics.com/search.php')
241 url.setQueryItems([('t', 'performer'), ('q', song.artist())])
242 self.fetch2(song, url)
243 self.connect(self.srep, QtCore.SIGNAL('finished()'), self.__handle_search_res)
245 def __handle_search_res(self):
246 # TODO use Qt xml functions
247 tree = etree.HTML(unicode(self.srep.readAll(), encoding = 'utf-8', errors='ignore'))
248 self.srep = None
250 url = None
251 for elem in tree.iterfind('.//a'):
252 if ('href' in elem.attrib) and elem.text and (self.song.title() in elem.text):
253 url = QtCore.QUrl('http://www.animelyrics.com/%s'%elem.get('href'))
255 if not url:
256 self.logger.info('Didn\'t find the URL in Animelyrics search results.')
257 return self.finish()
258 self.logger.info('Found Animelyrics song URL: %s.'%url)
260 self.lrep = self.nam.get(QtNetwork.QNetworkRequest(url))
261 self.connect(self.lrep, QtCore.SIGNAL('finished()'), self.__handle_lyrics)
263 def __handle_lyrics(self):
264 lyrics = ''
265 tree = etree.HTML(unicode(self.lrep.readAll(), encoding = 'utf-8'))
266 for elem in tree.iterfind('.//pre'):
267 if elem.get('class') == 'lyrics':
268 lyrics += '%s\n\n'%etree.tostring(elem, method = 'text', encoding = 'utf-8')
270 self.finish(lyrics)
272 class SettingsWidgetLyrics(Plugin.SettingsWidget):
273 # private
274 lyricdir = None
275 lyricname = None
276 store = None
277 sitelist = None
279 def __init__(self, plugin):
280 Plugin.SettingsWidget.__init__(self, plugin)
281 self.settings.beginGroup(self.plugin.name)
284 # store lyrics groupbox
285 self.store = QtGui.QGroupBox('Store lyrics.')
286 self.store.setToolTip('Should %s store its own copy of lyrics?'%misc.APPNAME)
287 self.store.setCheckable(True)
288 self.store.setChecked(self.settings.value('store').toBool())
289 self.store.setLayout(QtGui.QGridLayout())
291 # paths to lyrics
292 self.lyricdir = QtGui.QLineEdit(self.settings.value('lyricdir').toString())
293 self.lyricdir.setToolTip('Where should %s store lyrics.\n'
294 '$musicdir will be expanded to path to MPD music library (as set by user)\n'
295 '$songdir will be expanded to path to the song (relative to $musicdir\n'
296 'other tags same as in lyricname'
297 %misc.APPNAME)
298 self.lyricname = QtGui.QLineEdit(self.settings.value('lyricname').toString())
299 self.lyricname.setToolTip('Filename for %s lyricsfiles.\n'
300 'All tags supported by MPD will be expanded to their\n'
301 'values for current song, e.g. $title, $track, $artist,\n'
302 '$album, $genre etc.'%misc.APPNAME)
303 self.store.layout().addWidget(QtGui.QLabel('Lyrics directory'), 0, 0)
304 self.store.layout().addWidget(self.lyricdir, 0, 1)
305 self.store.layout().addWidget(QtGui.QLabel('Lyrics filename'), 1, 0)
306 self.store.layout().addWidget(self.lyricname, 1, 1)
308 # sites list
309 sites = self.settings.value('sites').toStringList()
310 self.sitelist = QtGui.QListWidget(self)
311 self.sitelist.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
312 for site in sites:
313 it = QtGui.QListWidgetItem(site)
314 it.setCheckState(QtCore.Qt.Checked)
315 self.sitelist.addItem(it)
316 for site in self.plugin.available_sites:
317 if not site.name in sites:
318 it = QtGui.QListWidgetItem(site.name)
319 it.setCheckState(QtCore.Qt.Unchecked)
320 self.sitelist.addItem(it)
322 self.setLayout(QtGui.QVBoxLayout())
323 self.layout().addWidget(self.store)
324 self._add_widget(self.sitelist, label = 'Sites', tooltip = 'A list of sources used for fetching lyrics.\n'
325 'Use drag and drop to change their priority.')
327 self.settings.endGroup()
329 def save_settings(self):
330 self.settings.beginGroup(self.plugin.name)
331 self.settings.setValue('lyricdir', QVariant(self.lyricdir.text()))
332 self.settings.setValue('lyricname', QVariant(self.lyricname.text()))
333 self.settings.setValue('store', QVariant(self.store.isChecked()))
335 sites = QtCore.QStringList()
336 for i in range(self.sitelist.count()):
337 it = self.sitelist.item(i)
338 if it.checkState() == QtCore.Qt.Checked:
339 sites.append(it.text())
340 self.settings.setValue('sites', QVariant(sites))
342 self.settings.endGroup()
343 self.plugin.refresh_fetchers()
344 self.plugin.refresh()
346 #### public ####
347 def _load(self):
348 self.refresh_fetchers()
349 self.o = LyricsWidget(self)
350 self.connect(self.mpclient, QtCore.SIGNAL('song_changed'), self.refresh)
351 def _unload(self):
352 self.o = None
353 self.__fetchers = None
354 self.disconnect(self.mpclient, QtCore.SIGNAL('song_changed'), self.refresh)
355 def info(self):
356 return "Show (and fetch) the lyrics of the currently playing song."
358 def _get_dock_widget(self):
359 return self._create_dock(self.o)
361 def refresh(self):
362 """Attempt to automatically get lyrics first from a file, then from the internet."""
363 self.logger.info('Autorefreshing lyrics.')
364 self.__results = 0
365 self.__index = len(self.__fetchers)
366 self.o.lyrics_loaded = False
367 song = self.mpclient.current_song()
368 if not song:
369 self.__lyrics_dir = ''
370 self.__lyrics_path = ''
371 return self.o.set_lyrics(None, None)
373 (self.__lyrics_dir, self.__lyrics_path) = misc.generate_metadata_path(song,
374 self.settings.value(self.name + '/lyricdir').toString(),
375 self.settings.value(self.name + '/lyricname').toString())
376 try:
377 self.logger.info('Trying to read lyrics from file %s.'%self.__lyrics_path)
378 file = open(self.__lyrics_path, 'r')
379 lyrics = file.read()
380 file.close()
381 if lyrics:
382 return self.o.set_lyrics(song, lyrics)
383 except IOError, e:
384 self.logger.info('Error reading lyrics file: %s.'%e)
386 for fetcher in self.__fetchers:
387 fetcher.fetch(song)
389 def save_lyrics_file(self, lyrics, path = None):
390 """Save lyrics to a file specified in path.
391 If path is None, then a default value is used."""
392 self.logger.info('Saving lyrics...')
393 try:
394 if path:
395 file = open(path, 'w')
396 else:
397 file = open(self.__lyrics_path, 'w')
398 file.write(lyrics)
399 file.close()
400 self.logger.info('Lyrics successfully saved.')
401 except IOError, e:
402 self.logger.error('Error writing lyrics: %s', e)
404 def del_lyrics_file(self, song = None):
405 """Delete a lyrics file for song. If song is not specified
406 current song is used."""
407 if not song:
408 path = self.__lyrics_path
409 else:
410 path = misc.generate_metadata_path(song, self.settings.value(self.name + '/lyricdir').toString(),
411 self.settings.value(self.name + '/lyricname').toString())
413 try:
414 os.remove(path)
415 except IOError, e:
416 self.logger.error('Error removing lyrics file %s: %s'%(path, e))
418 def get_settings_widget(self):
419 return self.SettingsWidgetLyrics(self)
421 def refresh_fetchers(self):
422 """Refresh the list of available fetchers."""
423 self.__fetchers = []
424 # append fetchers in order they are stored in settings
425 for name in self.settings.value('%s/sites'%self.name).toStringList():
426 for site in self.available_sites:
427 if site.name == name:
428 self.__fetchers.append(site(self))
429 self.connect(self.__fetchers[-1], QtCore.SIGNAL('finished'), self.__new_lyrics_fetched)