Lyrics: disconnect NetworkReply's signals before abort()ing.
[nephilim.git] / nephilim / plugins / Lyrics.py
blob4eb696f53ee1fa5a84475a3f48bdce5537a28d0c
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 = {'fetchers' : QtCore.QStringList(['Lyricwiki', 'Animelyrics']), 'lyricdir' : '$musicdir/$songdir',
110 'lyricname' : '.lyrics_nephilim_$artist_$album_$title', 'store' : True}
111 "implemented fetchers"
112 available_fetchers = None #XXX SettingsWidget currently uses it
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 paths"
120 __lyrics_dir = None
121 __lyrics_path = None
123 #### private ####
124 def __init__(self, parent, mpclient, name):
125 Plugin.__init__(self, parent, mpclient, name)
127 self.__fetchers = []
128 self.available_fetchers = [self.FetchLyricwiki, self.FetchAnimelyrics]
130 def __new_lyrics_fetched(self, song, lyrics):
131 self.logger.info('Got new lyrics.')
132 self.__results += 1
134 i = self.__fetchers.index(self.sender())
135 if lyrics and i < self.__index:
136 if self.settings.value(self.name + '/store').toBool():
137 self.save_lyrics_file(lyrics)
138 self.__index = i
139 return self.o.set_lyrics(song, lyrics)
140 elif self.__results >= len(self.__fetchers) and not self.o.lyrics_loaded:
141 self.o.set_lyrics(song, None)
143 class Fetcher(QtCore.QObject):
144 """A basic class for lyrics fetchers. Provides a fetch(song) function,
145 emits a finished(song, lyrics) signal when done; lyrics is either a QString,
146 Python unicode string or None if not found."""
147 #public, read-only
148 logger = None
149 name = ''
151 #private
152 nam = None # NetworkAccessManager
153 srep = None # search results NetworkReply
154 lrep = None # lyrics page NetworkReply
155 song = None # current song
157 #### private ####
158 def __init__(self, plugin):
159 QtCore.QObject.__init__(self, plugin)
161 self.nam = QtNetwork.QNetworkAccessManager()
162 self.logger = plugin.logger
164 def fetch2(self, song, url):
165 """A private convenience function to initiate fetch process."""
166 # abort any existing connections
167 if self.srep:
168 self.srep.finished.disconnect()
169 self.srep.abort()
170 self.srep = None
171 if self.lrep:
172 self.lrep.finished.disconnect()
173 self.lrep.abort()
174 self.lrep = None
175 self.song = song
177 self.logger.info('Searching %s: %s.'%(self. name, url))
178 self.srep = self.nam.get(QtNetwork.QNetworkRequest(url))
180 def finish(self, lyrics = None):
181 """A private convenience function to clean up and emit finished().
182 Feel free to reimplement/not use it."""
183 self.srep = None
184 self.lrep = None
185 self.emit(QtCore.SIGNAL('finished'), self.song, lyrics)
186 self.song = None
188 #### public ####
189 def fetch(self, song):
190 """Reimplement this in subclasses."""
191 pass
193 class FetchLyricwiki(Fetcher):
194 name = 'Lyricwiki'
196 def fetch(self, song):
197 url = QtCore.QUrl('http://lyricwiki.org/api.php')
198 url.setQueryItems([('func', 'getSong'), ('artist', song.artist()),
199 ('song', song.title()), ('fmt', 'xml')])
200 self.fetch2(song, url)
201 self.srep.finished.connect(self.__handle_search_res)
203 def __handle_search_res(self):
204 url = None
205 xml = QtCore.QXmlStreamReader(self.srep)
206 while not xml.atEnd():
207 token = xml.readNext()
208 if token == QtCore.QXmlStreamReader.StartElement:
209 if xml.name() == 'url':
210 url = QtCore.QUrl() # the url is already percent-encoded
211 url.setEncodedUrl(xml.readElementText().toLatin1())
212 elif xml.name() == 'lyrics' and xml.readElementText() == 'Not found':
213 xml.clear()
214 return self.finish()
215 if xml.hasError():
216 self.logger.error('Error parsing seach results.%s'%xml.errorString())
218 if not url:
219 self.logger.error('Didn\'t find the URL in Lyricwiki search results.')
220 return self.finish()
221 self.logger.info('Found Lyricwiki song URL: %s.'%url)
223 self.lrep = self.nam.get(QtNetwork.QNetworkRequest(url))
224 self.lrep.finished.connect(self.__handle_lyrics)
226 def __handle_lyrics(self):
227 #TODO this should use Qt xml functions too
228 lyrics = ''
229 page = unicode(self.lrep.readAll(), encoding = 'utf-8')
230 page = re.sub('<br>|<br/>|<br />', '\n', page)
231 try:
232 html = etree.HTML(page)
233 except etree.XMLSyntaxError, e:
234 self.logger.error('Error parsing lyrics: %s' %e)
235 return self.finish()
237 for elem in html.iterfind('.//div'):
238 if elem.get('class') == 'lyricbox':
239 lyrics += etree.tostring(elem, method = 'text', encoding = 'utf-8')
240 self.finish(lyrics)
242 class FetchAnimelyrics(Fetcher):
243 name = 'Animelyrics'
245 def fetch(self, song):
246 url = QtCore.QUrl('http://www.animelyrics.com/search.php')
247 url.setQueryItems([('t', 'performer'), ('q', song.artist())])
248 self.fetch2(song, url)
249 self.connect(self.srep, QtCore.SIGNAL('finished()'), self.__handle_search_res)
251 def __handle_search_res(self):
252 # TODO use Qt xml functions
253 try:
254 tree = etree.HTML(unicode(self.srep.readAll(), encoding = 'utf-8', errors='ignore'))
255 except etree.XMLSyntaxError, e:
256 self.logger.error('Error parsing lyrics: %s' %e)
257 return self.finish()
259 url = None
260 for elem in tree.iterfind('.//a'):
261 if ('href' in elem.attrib) and elem.text and (self.song.title() in elem.text):
262 url = QtCore.QUrl('http://www.animelyrics.com/%s'%elem.get('href'))
264 if not url:
265 self.logger.info('Didn\'t find the URL in Animelyrics search results.')
266 return self.finish()
267 self.logger.info('Found Animelyrics song URL: %s.'%url)
269 self.lrep = self.nam.get(QtNetwork.QNetworkRequest(url))
270 self.connect(self.lrep, QtCore.SIGNAL('finished()'), self.__handle_lyrics)
272 def __handle_lyrics(self):
273 lyrics = ''
274 try:
275 tree = etree.HTML(unicode(self.lrep.readAll(), encoding = 'utf-8'))
276 except etree.XMLSyntaxError, e:
277 self.logger.error('Error parsing lyrics: %s' %e)
278 return self.finish()
279 for elem in tree.iterfind('.//pre'):
280 if elem.get('class') == 'lyrics':
281 lyrics += '%s\n\n'%etree.tostring(elem, method = 'text', encoding = 'utf-8')
283 self.finish(lyrics)
285 class SettingsWidgetLyrics(Plugin.SettingsWidget):
286 # private
287 lyricdir = None
288 lyricname = None
289 store = None
290 fetcherlist = None
292 def __init__(self, plugin):
293 Plugin.SettingsWidget.__init__(self, plugin)
294 self.settings.beginGroup(self.plugin.name)
297 # store lyrics groupbox
298 self.store = QtGui.QGroupBox('Store lyrics.')
299 self.store.setToolTip('Should %s store its own copy of lyrics?'%misc.APPNAME)
300 self.store.setCheckable(True)
301 self.store.setChecked(self.settings.value('store').toBool())
302 self.store.setLayout(QtGui.QGridLayout())
304 # paths to lyrics
305 self.lyricdir = QtGui.QLineEdit(self.settings.value('lyricdir').toString())
306 self.lyricdir.setToolTip('Where should %s store lyrics.\n'
307 '$musicdir will be expanded to path to MPD music library (as set by user)\n'
308 '$songdir will be expanded to path to the song (relative to $musicdir\n'
309 'other tags same as in lyricname'
310 %misc.APPNAME)
311 self.lyricname = QtGui.QLineEdit(self.settings.value('lyricname').toString())
312 self.lyricname.setToolTip('Filename for %s lyricsfiles.\n'
313 'All tags supported by MPD will be expanded to their\n'
314 'values for current song, e.g. $title, $track, $artist,\n'
315 '$album, $genre etc.'%misc.APPNAME)
316 self.store.layout().addWidget(QtGui.QLabel('Lyrics directory'), 0, 0)
317 self.store.layout().addWidget(self.lyricdir, 0, 1)
318 self.store.layout().addWidget(QtGui.QLabel('Lyrics filename'), 1, 0)
319 self.store.layout().addWidget(self.lyricname, 1, 1)
321 # fetchers list
322 fetchers = self.settings.value('fetchers').toStringList()
323 self.fetcherlist = QtGui.QListWidget(self)
324 self.fetcherlist.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
325 for fetcher in fetchers:
326 it = QtGui.QListWidgetItem(fetcher)
327 it.setCheckState(QtCore.Qt.Checked)
328 self.fetcherlist.addItem(it)
329 for fetcher in self.plugin.available_fetchers:
330 if not fetcher.name in fetchers:
331 it = QtGui.QListWidgetItem(fetcher.name)
332 it.setCheckState(QtCore.Qt.Unchecked)
333 self.fetcherlist.addItem(it)
335 self.setLayout(QtGui.QVBoxLayout())
336 self.layout().addWidget(self.store)
337 self._add_widget(self.fetcherlist, label = 'Sites', tooltip = 'A list of sources used for fetching lyrics.\n'
338 'Use drag and drop to change their priority.')
340 self.settings.endGroup()
342 def save_settings(self):
343 self.settings.beginGroup(self.plugin.name)
344 self.settings.setValue('lyricdir', QVariant(self.lyricdir.text()))
345 self.settings.setValue('lyricname', QVariant(self.lyricname.text()))
346 self.settings.setValue('store', QVariant(self.store.isChecked()))
348 fetchers = QtCore.QStringList()
349 for i in range(self.fetcherlist.count()):
350 it = self.fetcherlist.item(i)
351 if it.checkState() == QtCore.Qt.Checked:
352 fetchers.append(it.text())
353 self.settings.setValue('fetchers', QVariant(fetchers))
355 self.settings.endGroup()
356 self.plugin.refresh_fetchers()
357 self.plugin.refresh()
359 #### public ####
360 def _load(self):
361 self.refresh_fetchers()
362 self.o = LyricsWidget(self)
363 self.connect(self.mpclient, QtCore.SIGNAL('song_changed'), self.refresh)
364 def _unload(self):
365 self.o = None
366 self.__fetchers = None
367 self.disconnect(self.mpclient, QtCore.SIGNAL('song_changed'), self.refresh)
368 def info(self):
369 return "Show (and fetch) the lyrics of the currently playing song."
371 def _get_dock_widget(self):
372 return self._create_dock(self.o)
374 def refresh(self):
375 """Attempt to automatically get lyrics first from a file, then from the internet."""
376 self.logger.info('Autorefreshing lyrics.')
377 self.__results = 0
378 self.__index = len(self.__fetchers)
379 self.o.lyrics_loaded = False
380 song = self.mpclient.current_song()
381 if not song:
382 self.__lyrics_dir = ''
383 self.__lyrics_path = ''
384 return self.o.set_lyrics(None, None)
386 (self.__lyrics_dir, self.__lyrics_path) = misc.generate_metadata_path(song,
387 self.settings.value(self.name + '/lyricdir').toString(),
388 self.settings.value(self.name + '/lyricname').toString())
389 try:
390 self.logger.info('Trying to read lyrics from file %s.'%self.__lyrics_path)
391 file = open(self.__lyrics_path, 'r')
392 lyrics = file.read()
393 file.close()
394 if lyrics:
395 return self.o.set_lyrics(song, lyrics)
396 except IOError, e:
397 self.logger.info('Error reading lyrics file: %s.'%e)
399 for fetcher in self.__fetchers:
400 fetcher.fetch(song)
402 def save_lyrics_file(self, lyrics, path = None):
403 """Save lyrics to a file specified in path.
404 If path is None, then a default value is used."""
405 self.logger.info('Saving lyrics...')
406 try:
407 if path:
408 file = open(path, 'w')
409 else:
410 file = open(self.__lyrics_path, 'w')
411 file.write(lyrics)
412 file.close()
413 self.logger.info('Lyrics successfully saved.')
414 except IOError, e:
415 self.logger.error('Error writing lyrics: %s', e)
417 def del_lyrics_file(self, song = None):
418 """Delete a lyrics file for song. If song is not specified
419 current song is used."""
420 if not song:
421 path = self.__lyrics_path
422 else:
423 path = misc.generate_metadata_path(song, self.settings.value(self.name + '/lyricdir').toString(),
424 self.settings.value(self.name + '/lyricname').toString())
426 try:
427 os.remove(path)
428 except IOError, e:
429 self.logger.error('Error removing lyrics file %s: %s'%(path, e))
431 def get_settings_widget(self):
432 return self.SettingsWidgetLyrics(self)
434 def refresh_fetchers(self):
435 """Refresh the list of available fetchers."""
436 self.__fetchers = []
437 # append fetchers in order they are stored in settings
438 for name in self.settings.value('%s/fetchers'%self.name).toStringList():
439 for fetcher in self.available_fetchers:
440 if fetcher.name == name:
441 self.__fetchers.append(fetcher(self))
442 self.connect(self.__fetchers[-1], QtCore.SIGNAL('finished'), self.__new_lyrics_fetched)