Lyrics: fix typo.
[nephilim.git] / nephilim / plugins / Lyrics.py
blob64f3eb9e540e6cf94b4152e762cafa64123c0ed6
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.abort()
169 self.srep = None
170 if self.lrep:
171 self.lrep.abort()
172 self.lrep = None
173 self.song = song
175 self.logger.info('Searching %s: %s.'%(self. name, url))
176 self.srep = self.nam.get(QtNetwork.QNetworkRequest(url))
178 def finish(self, lyrics = None):
179 """A private convenience function to clean up and emit finished().
180 Feel free to reimplement/not use it."""
181 self.srep = None
182 self.lrep = None
183 self.emit(QtCore.SIGNAL('finished'), self.song, lyrics)
184 self.song = None
186 #### public ####
187 def fetch(self, song):
188 """Reimplement this in subclasses."""
189 pass
191 class FetchLyricwiki(Fetcher):
192 name = 'Lyricwiki'
194 def fetch(self, song):
195 url = QtCore.QUrl('http://lyricwiki.org/api.php')
196 url.setQueryItems([('func', 'getSong'), ('artist', song.artist()),
197 ('song', song.title()), ('fmt', 'xml')])
198 self.fetch2(song, url)
199 self.connect(self.srep, QtCore.SIGNAL('finished()'), self.__handle_search_res)
201 def __handle_search_res(self):
202 url = None
203 xml = QtCore.QXmlStreamReader(self.srep)
204 while not xml.atEnd():
205 token = xml.readNext()
206 if token == QtCore.QXmlStreamReader.StartElement:
207 if xml.name() == 'url':
208 url = QtCore.QUrl() # the url is already percent-encoded
209 url.setEncodedUrl(xml.readElementText().toLatin1())
210 elif xml.name() == 'lyrics' and xml.readElementText() == 'Not found':
211 xml.clear()
212 return self.finish()
213 if xml.hasError():
214 self.logger.error('Error parsing seach results.%s'%xml.errorString())
216 if not url:
217 self.logger.error('Didn\'t find the URL in Lyricwiki search results.')
218 return self.finish()
219 self.logger.info('Found Lyricwiki song URL: %s.'%url)
221 self.lrep = self.nam.get(QtNetwork.QNetworkRequest(url))
222 self.connect(self.lrep, QtCore.SIGNAL('finished()'), self.__handle_lyrics)
224 def __handle_lyrics(self):
225 #TODO this should use Qt xml functions too
226 lyrics = ''
227 page = unicode(self.lrep.readAll(), encoding = 'utf-8')
228 page = re.sub('<br>|<br/>|<br />', '\n', page)
229 try:
230 html = etree.HTML(page)
231 except etree.XMLSyntaxError, e:
232 self.logger.error('Error parsing lyrics: %s' %e)
233 return self.finish()
235 for elem in html.iterfind('.//div'):
236 if elem.get('class') == 'lyricbox':
237 lyrics += etree.tostring(elem, method = 'text', encoding = 'utf-8')
238 self.finish(lyrics)
240 class FetchAnimelyrics(Fetcher):
241 name = 'Animelyrics'
243 def fetch(self, song):
244 url = QtCore.QUrl('http://www.animelyrics.com/search.php')
245 url.setQueryItems([('t', 'performer'), ('q', song.artist())])
246 self.fetch2(song, url)
247 self.connect(self.srep, QtCore.SIGNAL('finished()'), self.__handle_search_res)
249 def __handle_search_res(self):
250 # TODO use Qt xml functions
251 try:
252 tree = etree.HTML(unicode(self.srep.readAll(), encoding = 'utf-8', errors='ignore'))
253 except etree.XMLSyntaxError, e:
254 self.logger.error('Error parsing lyrics: %s' %e)
255 return self.finish()
257 url = None
258 for elem in tree.iterfind('.//a'):
259 if ('href' in elem.attrib) and elem.text and (self.song.title() in elem.text):
260 url = QtCore.QUrl('http://www.animelyrics.com/%s'%elem.get('href'))
262 if not url:
263 self.logger.info('Didn\'t find the URL in Animelyrics search results.')
264 return self.finish()
265 self.logger.info('Found Animelyrics song URL: %s.'%url)
267 self.lrep = self.nam.get(QtNetwork.QNetworkRequest(url))
268 self.connect(self.lrep, QtCore.SIGNAL('finished()'), self.__handle_lyrics)
270 def __handle_lyrics(self):
271 lyrics = ''
272 try:
273 tree = etree.HTML(unicode(self.lrep.readAll(), encoding = 'utf-8'))
274 except etree.XMLSyntaxError, e:
275 self.logger.error('Error parsing lyrics: %s' %e)
276 return self.finish()
277 for elem in tree.iterfind('.//pre'):
278 if elem.get('class') == 'lyrics':
279 lyrics += '%s\n\n'%etree.tostring(elem, method = 'text', encoding = 'utf-8')
281 self.finish(lyrics)
283 class SettingsWidgetLyrics(Plugin.SettingsWidget):
284 # private
285 lyricdir = None
286 lyricname = None
287 store = None
288 fetcherlist = None
290 def __init__(self, plugin):
291 Plugin.SettingsWidget.__init__(self, plugin)
292 self.settings.beginGroup(self.plugin.name)
295 # store lyrics groupbox
296 self.store = QtGui.QGroupBox('Store lyrics.')
297 self.store.setToolTip('Should %s store its own copy of lyrics?'%misc.APPNAME)
298 self.store.setCheckable(True)
299 self.store.setChecked(self.settings.value('store').toBool())
300 self.store.setLayout(QtGui.QGridLayout())
302 # paths to lyrics
303 self.lyricdir = QtGui.QLineEdit(self.settings.value('lyricdir').toString())
304 self.lyricdir.setToolTip('Where should %s store lyrics.\n'
305 '$musicdir will be expanded to path to MPD music library (as set by user)\n'
306 '$songdir will be expanded to path to the song (relative to $musicdir\n'
307 'other tags same as in lyricname'
308 %misc.APPNAME)
309 self.lyricname = QtGui.QLineEdit(self.settings.value('lyricname').toString())
310 self.lyricname.setToolTip('Filename for %s lyricsfiles.\n'
311 'All tags supported by MPD will be expanded to their\n'
312 'values for current song, e.g. $title, $track, $artist,\n'
313 '$album, $genre etc.'%misc.APPNAME)
314 self.store.layout().addWidget(QtGui.QLabel('Lyrics directory'), 0, 0)
315 self.store.layout().addWidget(self.lyricdir, 0, 1)
316 self.store.layout().addWidget(QtGui.QLabel('Lyrics filename'), 1, 0)
317 self.store.layout().addWidget(self.lyricname, 1, 1)
319 # fetchers list
320 fetchers = self.settings.value('fetchers').toStringList()
321 self.fetcherlist = QtGui.QListWidget(self)
322 self.fetcherlist.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
323 for fetcher in fetchers:
324 it = QtGui.QListWidgetItem(fetcher)
325 it.setCheckState(QtCore.Qt.Checked)
326 self.fetcherlist.addItem(it)
327 for fetcher in self.plugin.available_fetchers:
328 if not fetcher.name in fetchers:
329 it = QtGui.QListWidgetItem(fetcher.name)
330 it.setCheckState(QtCore.Qt.Unchecked)
331 self.fetcherlist.addItem(it)
333 self.setLayout(QtGui.QVBoxLayout())
334 self.layout().addWidget(self.store)
335 self._add_widget(self.fetcherlist, label = 'Sites', tooltip = 'A list of sources used for fetching lyrics.\n'
336 'Use drag and drop to change their priority.')
338 self.settings.endGroup()
340 def save_settings(self):
341 self.settings.beginGroup(self.plugin.name)
342 self.settings.setValue('lyricdir', QVariant(self.lyricdir.text()))
343 self.settings.setValue('lyricname', QVariant(self.lyricname.text()))
344 self.settings.setValue('store', QVariant(self.store.isChecked()))
346 fetchers = QtCore.QStringList()
347 for i in range(self.fetcherlist.count()):
348 it = self.fetcherlist.item(i)
349 if it.checkState() == QtCore.Qt.Checked:
350 fetchers.append(it.text())
351 self.settings.setValue('fetchers', QVariant(fetchers))
353 self.settings.endGroup()
354 self.plugin.refresh_fetchers()
355 self.plugin.refresh()
357 #### public ####
358 def _load(self):
359 self.refresh_fetchers()
360 self.o = LyricsWidget(self)
361 self.connect(self.mpclient, QtCore.SIGNAL('song_changed'), self.refresh)
362 def _unload(self):
363 self.o = None
364 self.__fetchers = None
365 self.disconnect(self.mpclient, QtCore.SIGNAL('song_changed'), self.refresh)
366 def info(self):
367 return "Show (and fetch) the lyrics of the currently playing song."
369 def _get_dock_widget(self):
370 return self._create_dock(self.o)
372 def refresh(self):
373 """Attempt to automatically get lyrics first from a file, then from the internet."""
374 self.logger.info('Autorefreshing lyrics.')
375 self.__results = 0
376 self.__index = len(self.__fetchers)
377 self.o.lyrics_loaded = False
378 song = self.mpclient.current_song()
379 if not song:
380 self.__lyrics_dir = ''
381 self.__lyrics_path = ''
382 return self.o.set_lyrics(None, None)
384 (self.__lyrics_dir, self.__lyrics_path) = misc.generate_metadata_path(song,
385 self.settings.value(self.name + '/lyricdir').toString(),
386 self.settings.value(self.name + '/lyricname').toString())
387 try:
388 self.logger.info('Trying to read lyrics from file %s.'%self.__lyrics_path)
389 file = open(self.__lyrics_path, 'r')
390 lyrics = file.read()
391 file.close()
392 if lyrics:
393 return self.o.set_lyrics(song, lyrics)
394 except IOError, e:
395 self.logger.info('Error reading lyrics file: %s.'%e)
397 for fetcher in self.__fetchers:
398 fetcher.fetch(song)
400 def save_lyrics_file(self, lyrics, path = None):
401 """Save lyrics to a file specified in path.
402 If path is None, then a default value is used."""
403 self.logger.info('Saving lyrics...')
404 try:
405 if path:
406 file = open(path, 'w')
407 else:
408 file = open(self.__lyrics_path, 'w')
409 file.write(lyrics)
410 file.close()
411 self.logger.info('Lyrics successfully saved.')
412 except IOError, e:
413 self.logger.error('Error writing lyrics: %s', e)
415 def del_lyrics_file(self, song = None):
416 """Delete a lyrics file for song. If song is not specified
417 current song is used."""
418 if not song:
419 path = self.__lyrics_path
420 else:
421 path = misc.generate_metadata_path(song, self.settings.value(self.name + '/lyricdir').toString(),
422 self.settings.value(self.name + '/lyricname').toString())
424 try:
425 os.remove(path)
426 except IOError, e:
427 self.logger.error('Error removing lyrics file %s: %s'%(path, e))
429 def get_settings_widget(self):
430 return self.SettingsWidgetLyrics(self)
432 def refresh_fetchers(self):
433 """Refresh the list of available fetchers."""
434 self.__fetchers = []
435 # append fetchers in order they are stored in settings
436 for name in self.settings.value('%s/fetchers'%self.name).toStringList():
437 for fetcher in self.available_fetchers:
438 if fetcher.name == name:
439 self.__fetchers.append(fetcher(self))
440 self.connect(self.__fetchers[-1], QtCore.SIGNAL('finished'), self.__new_lyrics_fetched)