Lyrics: refresh() on load()
[nephilim.git] / nephilim / plugins / Lyrics.py
blob1efce0a038673bb4adb11f360a86a83a2f6509b7
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 common
27 from .. import icons
29 class LyricsWidget(QtGui.QWidget):
30 #public
31 lyrics_loaded = None
33 # public, read-only
34 plugin = None # plugin
35 logger = None
37 # private
38 __text_view = None # text-object
39 __toolbar = None
40 __label = None
42 #### private
43 def __init__(self, plugin):
44 QtGui.QWidget.__init__(self)
45 self.plugin = plugin
46 self.logger = plugin.logger
47 self.curLyrics = ''
49 self.__label = QtGui.QLabel(self)
50 self.__label.setWordWrap(True)
52 # add text area
53 self.__text_view = QtGui.QTextEdit(self)
54 self.__text_view.setReadOnly(True)
56 # add toolbar
57 self.__toolbar = QtGui.QToolBar('Lyrics toolbar', self)
58 self.__toolbar.setOrientation(QtCore.Qt.Vertical)
60 self.__toolbar.addAction(QtGui.QIcon(':icons/refresh.png'), 'Refresh lyrics', self.plugin.refresh)
61 edit = self.__toolbar.addAction(QtGui.QIcon(':icons/edit.png'), 'Edit lyrics')
62 edit.setCheckable(True)
63 edit.toggled.connect(self.__toggle_editable)
65 self.__toolbar.addAction(QtGui.QIcon(':icons/save.png'), 'Save lyrics', self.__save_lyrics)
66 self.__toolbar.addAction(QtGui.QIcon(':icons/delete.png'), 'Delete stored file', self.plugin.del_lyrics_file)
68 self.setLayout(QtGui.QGridLayout())
69 self.layout().setSpacing(0)
70 self.layout().setMargin(0)
71 self.layout().addWidget(self.__toolbar, 0, 0, -1, 1, QtCore.Qt.AlignTop)
72 self.layout().addWidget(self.__label, 0, 1)
73 self.layout().addWidget(self.__text_view, 1, 1)
75 def __save_lyrics(self):
76 self.plugin.save_lyrics_file(unicode(self.__text_view.toPlainText()).encode('utf-8'))
78 def __toggle_editable(self, val):
79 self.__text_view.setReadOnly(not val)
81 #### public ####
82 def set_lyrics(self, song, lyrics, flags = 0):
83 """Set currently displayed lyrics for song. flags parameter is
84 unused now."""
85 if not song:
86 self.__label.clear()
87 return self.__text_view.clear()
89 # a late thread might call this for a previous song
90 if song != self.plugin.mpclient.current_song():
91 return
93 self.__text_view.clear()
94 self.__label.setText('<b>%s</b> by <u>%s</u> on <u>%s</u>'\
95 %(song['title'], song['artist'], song['album']))
96 if lyrics:
97 self.logger.info('Setting new lyrics.')
98 self.__text_view.insertPlainText(lyrics.decode('utf-8'))
99 self.lyrics_loaded = True
100 else:
101 self.logger.info('Lyrics not found.')
102 self.__text_view.insertPlainText('Lyrics not found.')
104 class Lyrics(Plugin):
105 # public, const
106 info = 'Show (and fetch) the lyrics of the currently playing song.'
108 # public, read-only
109 o = None
111 # private
112 DEFAULTS = {'fetchers' : QtCore.QStringList(['Lyricwiki', 'Animelyrics']), 'lyricdir' : '$musicdir/$songdir',
113 'lyricname' : '.lyrics_nephilim_$artist_$album_$title', 'store' : True}
114 "implemented fetchers"
115 available_fetchers = None #XXX SettingsWidget currently uses it
116 "enabled fetchers, those with higher priority first"
117 __fetchers = None
118 "number of returned results from last refresh() call"
119 __results = None
120 "index/priority of current lyrics"
121 __index = None
122 "metadata paths"
123 __lyrics_dir = None
124 __lyrics_path = None
126 #### private ####
127 def __init__(self, parent, mpclient, name):
128 Plugin.__init__(self, parent, mpclient, name)
130 self.__fetchers = []
131 self.available_fetchers = [self.FetchLyricwiki, self.FetchAnimelyrics]
133 def __new_lyrics_fetched(self, song, lyrics):
134 self.logger.info('Got new lyrics.')
135 self.__results += 1
137 i = self.__fetchers.index(self.sender())
138 if lyrics and i < self.__index:
139 if self.settings.value(self.name + '/store').toBool():
140 self.save_lyrics_file(lyrics)
141 self.__index = i
142 return self.o.set_lyrics(song, lyrics)
143 elif self.__results >= len(self.__fetchers) and not self.o.lyrics_loaded:
144 self.o.set_lyrics(song, None)
146 class FetchLyricwiki(common.MetadataFetcher):
147 name = 'Lyricwiki'
149 def fetch(self, song):
150 url = QtCore.QUrl('http://lyricwiki.org/api.php')
151 url.setQueryItems([('func', 'getArtist'), ('artist', song['artist']),
152 ('fmt', 'xml')])
153 self.fetch2(song, url)
154 self.rep.finished.connect(self.__handle_artist_res)
156 def __handle_artist_res(self):
157 artist = None
158 xml = QtCore.QXmlStreamReader(self.rep)
159 while not xml.atEnd():
160 token = xml.readNext()
161 if token == QtCore.QXmlStreamReader.StartElement:
162 if xml.name() == 'artist':
163 artist = xml.readElementText()
164 xml.clear()
165 if not artist:
166 self.logger.error('Didn\'t find artist in %s artist search results.'%self.name)
167 return self.finish()
169 url = QtCore.QUrl('http://lyricwiki.org/api.php')
170 url.setQueryItems([('func', 'getSong'), ('artist', artist),
171 ('song', self.song['title']), ('fmt', 'xml')])
172 self.rep = self.nam.get(QtNetwork.QNetworkRequest(url))
173 self.rep.finished.connect(self.__handle_search_res)
175 def __handle_search_res(self):
176 url = None
177 xml = QtCore.QXmlStreamReader(self.rep)
178 while not xml.atEnd():
179 token = xml.readNext()
180 if token == QtCore.QXmlStreamReader.StartElement:
181 if xml.name() == 'url':
182 url = QtCore.QUrl() # the url is already percent-encoded
183 url.setEncodedUrl(xml.readElementText().toLatin1())
184 elif xml.name() == 'lyrics' and xml.readElementText() == 'Not found':
185 xml.clear()
186 return self.finish()
187 if xml.hasError():
188 self.logger.error('Error parsing seach results: %s'%xml.errorString())
190 if not url:
191 self.logger.error('Didn\'t find the URL in Lyricwiki search results.')
192 return self.finish()
193 self.logger.info('Found Lyricwiki song URL: %s.'%url)
195 self.rep = self.nam.get(QtNetwork.QNetworkRequest(url))
196 self.rep.finished.connect(self.__handle_lyrics)
198 def __handle_lyrics(self):
199 #TODO this should use Qt xml functions too
200 lyrics = ''
201 page = unicode(self.rep.readAll(), encoding = 'utf-8')
202 page = re.sub('<br>|<br/>|<br />', '\n', page)
203 try:
204 html = etree.HTML(page)
205 except etree.XMLSyntaxError, e:
206 self.logger.error('Error parsing lyrics: %s' %e)
207 return self.finish()
209 for elem in html.iterfind('.//div'):
210 if elem.get('class') == 'lyricbox':
211 lyrics += etree.tostring(elem, method = 'text', encoding = 'utf-8')
212 self.finish(lyrics)
214 class FetchAnimelyrics(common.MetadataFetcher):
215 name = 'Animelyrics'
217 def fetch(self, song):
218 url = QtCore.QUrl('http://www.animelyrics.com/search.php')
219 url.setQueryItems([('t', 'performer'), ('q', song['artist'])])
220 self.fetch2(song, url)
221 self.rep.finished.connect(self.__handle_search_res)
223 def __handle_search_res(self):
224 # TODO use Qt xml functions
225 try:
226 tree = etree.HTML(unicode(self.rep.readAll(), encoding = 'utf-8', errors='ignore'))
227 except etree.XMLSyntaxError, e:
228 self.logger.error('Error parsing lyrics: %s' %e)
229 return self.finish()
231 url = None
232 for elem in tree.iterfind('.//a'):
233 if ('href' in elem.attrib) and elem.text and (self.song['title'] in elem.text):
234 url = QtCore.QUrl('http://www.animelyrics.com/%s'%elem.get('href'))
236 if not url:
237 self.logger.info('Didn\'t find the URL in Animelyrics search results.')
238 return self.finish()
239 self.logger.info('Found Animelyrics song URL: %s.'%url)
241 self.rep = self.nam.get(QtNetwork.QNetworkRequest(url))
242 self.rep.finished.connect(self.__handle_lyrics)
244 def __handle_lyrics(self):
245 lyrics = ''
246 try:
247 tree = etree.HTML(unicode(self.rep.readAll(), encoding = 'utf-8'))
248 except etree.XMLSyntaxError, e:
249 self.logger.error('Error parsing lyrics: %s' %e)
250 return self.finish()
251 for elem in tree.iterfind('.//pre'):
252 if elem.get('class') == 'lyrics':
253 lyrics += '%s\n\n'%etree.tostring(elem, method = 'text', encoding = 'utf-8')
255 self.finish(lyrics)
257 class SettingsWidgetLyrics(Plugin.SettingsWidget):
258 # private
259 lyricdir = None
260 lyricname = None
261 store = None
262 fetcherlist = None
264 def __init__(self, plugin):
265 Plugin.SettingsWidget.__init__(self, plugin)
266 self.settings.beginGroup(self.plugin.name)
269 # store lyrics groupbox
270 self.store = QtGui.QGroupBox('Store lyrics.')
271 self.store.setToolTip('Should %s store its own copy of lyrics?'%common.APPNAME)
272 self.store.setCheckable(True)
273 self.store.setChecked(self.settings.value('store').toBool())
274 self.store.setLayout(QtGui.QGridLayout())
276 # paths to lyrics
277 self.lyricdir = QtGui.QLineEdit(self.settings.value('lyricdir').toString())
278 self.lyricdir.setToolTip('Where should %s store lyrics.\n'
279 '$musicdir will be expanded to path to MPD music library (as set by user)\n'
280 '$songdir will be expanded to path to the song (relative to $musicdir\n'
281 'other tags same as in lyricname'
282 %common.APPNAME)
283 self.lyricname = QtGui.QLineEdit(self.settings.value('lyricname').toString())
284 self.lyricname.setToolTip('Filename for %s lyricsfiles.\n'
285 'All tags supported by MPD will be expanded to their\n'
286 'values for current song, e.g. $title, $track, $artist,\n'
287 '$album, $genre etc.'%common.APPNAME)
288 self.store.layout().addWidget(QtGui.QLabel('Lyrics directory'), 0, 0)
289 self.store.layout().addWidget(self.lyricdir, 0, 1)
290 self.store.layout().addWidget(QtGui.QLabel('Lyrics filename'), 1, 0)
291 self.store.layout().addWidget(self.lyricname, 1, 1)
293 # fetchers list
294 fetchers = self.settings.value('fetchers').toStringList()
295 self.fetcherlist = QtGui.QListWidget(self)
296 self.fetcherlist.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
297 for fetcher in fetchers:
298 it = QtGui.QListWidgetItem(fetcher)
299 it.setCheckState(QtCore.Qt.Checked)
300 self.fetcherlist.addItem(it)
301 for fetcher in self.plugin.available_fetchers:
302 if not fetcher.name in fetchers:
303 it = QtGui.QListWidgetItem(fetcher.name)
304 it.setCheckState(QtCore.Qt.Unchecked)
305 self.fetcherlist.addItem(it)
307 self.setLayout(QtGui.QVBoxLayout())
308 self.layout().addWidget(self.store)
309 self._add_widget(self.fetcherlist, label = 'Sites', tooltip = 'A list of sources used for fetching lyrics.\n'
310 'Use drag and drop to change their priority.')
312 self.settings.endGroup()
314 def save_settings(self):
315 self.settings.beginGroup(self.plugin.name)
316 self.settings.setValue('lyricdir', QVariant(self.lyricdir.text()))
317 self.settings.setValue('lyricname', QVariant(self.lyricname.text()))
318 self.settings.setValue('store', QVariant(self.store.isChecked()))
320 fetchers = QtCore.QStringList()
321 for i in range(self.fetcherlist.count()):
322 it = self.fetcherlist.item(i)
323 if it.checkState() == QtCore.Qt.Checked:
324 fetchers.append(it.text())
325 self.settings.setValue('fetchers', QVariant(fetchers))
327 self.settings.endGroup()
328 self.plugin.refresh_fetchers()
329 self.plugin.refresh()
331 #### public ####
332 def _load(self):
333 self.refresh_fetchers()
334 self.o = LyricsWidget(self)
335 self.mpclient.song_changed.connect(self.refresh)
337 self.refresh()
338 def _unload(self):
339 self.o = None
340 self.__fetchers = None
341 self.mpclient.song_changed.disconnect(self.refresh)
342 def _get_dock_widget(self):
343 return self._create_dock(self.o)
345 def refresh(self):
346 """Attempt to automatically get lyrics first from a file, then from the internet."""
347 self.logger.info('Autorefreshing lyrics.')
348 self.__results = 0
349 self.__index = len(self.__fetchers)
350 self.o.lyrics_loaded = False
351 song = self.mpclient.current_song()
352 if not song:
353 self.__lyrics_dir = ''
354 self.__lyrics_path = ''
355 return self.o.set_lyrics(None, None)
357 (self.__lyrics_dir, self.__lyrics_path) = common.generate_metadata_path(song,
358 self.settings.value(self.name + '/lyricdir').toString(),
359 self.settings.value(self.name + '/lyricname').toString())
360 try:
361 self.logger.info('Trying to read lyrics from file %s.'%self.__lyrics_path)
362 file = open(self.__lyrics_path, 'r')
363 lyrics = file.read()
364 file.close()
365 if lyrics:
366 return self.o.set_lyrics(song, lyrics)
367 except IOError, e:
368 self.logger.info('Error reading lyrics file: %s.'%e)
370 for fetcher in self.__fetchers:
371 fetcher.fetch(song)
373 def save_lyrics_file(self, lyrics, path = None):
374 """Save lyrics to a file specified in path.
375 If path is None, then a default value is used."""
376 self.logger.info('Saving lyrics...')
377 try:
378 if path:
379 file = open(path, 'w')
380 else:
381 file = open(self.__lyrics_path, 'w')
382 file.write(lyrics)
383 file.close()
384 self.logger.info('Lyrics successfully saved.')
385 except IOError, e:
386 self.logger.error('Error writing lyrics: %s', e)
388 def del_lyrics_file(self, song = None):
389 """Delete a lyrics file for song. If song is not specified
390 current song is used."""
391 if not song:
392 path = self.__lyrics_path
393 else:
394 path = common.generate_metadata_path(song, self.settings.value(self.name + '/lyricdir').toString(),
395 self.settings.value(self.name + '/lyricname').toString())
397 try:
398 os.remove(path)
399 except IOError, e:
400 self.logger.error('Error removing lyrics file %s: %s'%(path, e))
402 def get_settings_widget(self):
403 return self.SettingsWidgetLyrics(self)
405 def refresh_fetchers(self):
406 """Refresh the list of available fetchers."""
407 self.__fetchers = []
408 # append fetchers in order they are stored in settings
409 for name in self.settings.value('%s/fetchers'%self.name).toStringList():
410 for fetcher in self.available_fetchers:
411 if fetcher.name == name:
412 self.__fetchers.append(fetcher(self))
413 self.__fetchers[-1].finished.connect(self.__new_lyrics_fetched)