Lyrics: fix lyricwiki. yet again.
[nephilim.git] / nephilim / plugins / Lyrics.py
blobd558165dfadce501be9a7e4c112ea82573fa175d
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(self.__text_view.toPlainText())
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 (unicode string) 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)
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' : ['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() and self.__lyrics_path:
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 __apiaddress = 'http://lyrics.wikia.com/api.php'
151 def fetch(self, song):
152 url = QtCore.QUrl(self.__apiaddress)
153 url.setQueryItems([('func', 'getArtist'), ('artist', song['artist']),
154 ('fmt', 'xml'), ('action', 'lyrics')])
155 self.fetch2(song, url)
156 self.rep.finished.connect(self.__handle_artist_res)
158 def __handle_artist_res(self):
159 artist = None
160 xml = QtCore.QXmlStreamReader(self.rep)
161 while not xml.atEnd():
162 token = xml.readNext()
163 if token == QtCore.QXmlStreamReader.StartElement:
164 if xml.name() == 'artist':
165 artist = xml.readElementText()
166 xml.clear()
167 if not artist:
168 self.logger.info('Didn\'t find artist in %s artist search results.'%self.name)
169 return self.finish()
170 self.logger.info('Found artist: %s'%artist)
172 url = QtCore.QUrl(self.__apiaddress)
173 url.setQueryItems([('action', 'lyrics'), ('func', 'getSong'), ('artist', artist),
174 ('song', self.song['title']), ('fmt', 'xml')])
175 self.rep = self.nam.get(QtNetwork.QNetworkRequest(url))
176 self.rep.finished.connect(self.__handle_search_res)
177 self.rep.error.connect(self.handle_error)
179 def __handle_search_res(self):
180 url = None
182 # the page is borked utf-8 as of nov 2009, qxmlstreamreader chokes
183 # on it => use regexps
184 match = re.search('<url>(.*)</url>', str(self.rep.readAll()).decode('utf-8', 'replace'),
185 re.DOTALL|re.IGNORECASE)
186 if match and not 'action=edit' in match.group(1):
187 url = QtCore.QUrl() # the url is already percent-encoded
188 url.setEncodedUrl(match.group(1))
190 if not url:
191 self.logger.info('Didn\'t find the song on Lyricwiki.')
192 return self.finish()
193 self.logger.info('Found Lyricwiki song URL: %s.'%url.toString())
195 req = QtNetwork.QNetworkRequest(url)
196 self.rep = self.nam.get(req)
197 self.rep.finished.connect(self.__handle_lyrics)
198 self.rep.error.connect(self.handle_error)
200 def __handle_lyrics(self):
201 # the page isn't valid xml, so use regexps
202 lyrics = ''
203 for it in re.finditer('<div class=\'lyricbox\'>(?:<div.*?>.*?</div>)?(.*?)(?:<div.*?>.*?</div>)?</div>',
204 str(self.rep.readAll()).decode('utf-8'), re.DOTALL):
205 gr = re.sub('<br />', '\n', it.group(1))
206 gr = re.sub(re.compile('<.*>', re.DOTALL), '', gr)
207 lyrics += gr + '\n'
208 self.finish(common.decode_htmlentities(lyrics))
210 class FetchAnimelyrics(common.MetadataFetcher):
211 name = 'Animelyrics'
213 def fetch(self, song):
214 url = QtCore.QUrl('http://www.animelyrics.com/search.php')
215 url.setQueryItems([('t', 'performer'), ('q', song['artist'])])
216 self.fetch2(song, url)
217 self.rep.finished.connect(self.__handle_search_res)
219 def __handle_search_res(self):
220 # TODO use Qt xml functions
221 try:
222 tree = etree.HTML(unicode(self.rep.readAll(), encoding = 'utf-8', errors='ignore'))
223 except etree.XMLSyntaxError, e:
224 self.logger.error('Error parsing lyrics: %s' %e)
225 return self.finish()
227 url = None
228 for elem in tree.iterfind('.//a'):
229 if ('href' in elem.attrib) and elem.text and (self.song['title'] in elem.text):
230 url = QtCore.QUrl('http://www.animelyrics.com/%s'%elem.get('href'))
232 if not url:
233 self.logger.info('Didn\'t find the URL in Animelyrics search results.')
234 return self.finish()
235 self.logger.info('Found Animelyrics song URL: %s.'%url)
237 self.rep = self.nam.get(QtNetwork.QNetworkRequest(url))
238 self.rep.finished.connect(self.__handle_lyrics)
239 self.rep.error.connect(self.handle_error)
241 def __handle_lyrics(self):
242 lyrics = ''
243 try:
244 tree = etree.HTML(unicode(self.rep.readAll(), encoding = 'utf-8'))
245 except etree.XMLSyntaxError, e:
246 self.logger.error('Error parsing lyrics: %s' %e)
247 return self.finish()
248 for elem in tree.iterfind('.//pre'):
249 if elem.get('class') == 'lyrics':
250 lyrics += '%s\n\n'%etree.tostring(elem, method = 'text', encoding = 'utf-8')
252 self.finish(lyrics)
254 class SettingsWidgetLyrics(Plugin.SettingsWidget):
255 # private
256 lyricdir = None
257 lyricname = None
258 store = None
259 fetcherlist = None
261 def __init__(self, plugin):
262 Plugin.SettingsWidget.__init__(self, plugin)
263 self.settings.beginGroup(self.plugin.name)
266 # store lyrics groupbox
267 self.store = QtGui.QGroupBox('Store lyrics.')
268 self.store.setToolTip('Should %s store its own copy of lyrics?'%common.APPNAME)
269 self.store.setCheckable(True)
270 self.store.setChecked(self.settings.value('store').toBool())
271 self.store.setLayout(QtGui.QGridLayout())
273 # paths to lyrics
274 self.lyricdir = QtGui.QLineEdit(self.settings.value('lyricdir').toString())
275 self.lyricdir.setToolTip('Where should %s store lyrics.\n'
276 '$musicdir will be expanded to path to MPD music library (as set by user)\n'
277 '$songdir will be expanded to path to the song (relative to $musicdir\n'
278 'other tags same as in lyricname'
279 %common.APPNAME)
280 self.lyricname = QtGui.QLineEdit(self.settings.value('lyricname').toString())
281 self.lyricname.setToolTip('Filename for %s lyricsfiles.\n'
282 'All tags supported by MPD will be expanded to their\n'
283 'values for current song, e.g. $title, $track, $artist,\n'
284 '$album, $genre etc.'%common.APPNAME)
285 self.store.layout().addWidget(QtGui.QLabel('Lyrics directory'), 0, 0)
286 self.store.layout().addWidget(self.lyricdir, 0, 1)
287 self.store.layout().addWidget(QtGui.QLabel('Lyrics filename'), 1, 0)
288 self.store.layout().addWidget(self.lyricname, 1, 1)
290 # fetchers list
291 fetchers = self.settings.value('fetchers').toStringList()
292 self.fetcherlist = QtGui.QListWidget(self)
293 self.fetcherlist.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
294 for fetcher in fetchers:
295 it = QtGui.QListWidgetItem(fetcher)
296 it.setCheckState(QtCore.Qt.Checked)
297 self.fetcherlist.addItem(it)
298 for fetcher in self.plugin.available_fetchers:
299 if not fetcher.name in fetchers:
300 it = QtGui.QListWidgetItem(fetcher.name)
301 it.setCheckState(QtCore.Qt.Unchecked)
302 self.fetcherlist.addItem(it)
304 self.setLayout(QtGui.QVBoxLayout())
305 self.layout().addWidget(self.store)
306 self._add_widget(self.fetcherlist, label = 'Sites', tooltip = 'A list of sources used for fetching lyrics.\n'
307 'Use drag and drop to change their priority.')
309 self.settings.endGroup()
311 def save_settings(self):
312 self.settings.beginGroup(self.plugin.name)
313 self.settings.setValue('lyricdir', QVariant(self.lyricdir.text()))
314 self.settings.setValue('lyricname', QVariant(self.lyricname.text()))
315 self.settings.setValue('store', QVariant(self.store.isChecked()))
317 fetchers = []
318 for i in range(self.fetcherlist.count()):
319 it = self.fetcherlist.item(i)
320 if it.checkState() == QtCore.Qt.Checked:
321 fetchers.append(it.text())
322 self.settings.setValue('fetchers', QVariant(fetchers))
324 self.settings.endGroup()
325 self.plugin.refresh_fetchers()
326 self.plugin.refresh()
328 #### public ####
329 def _load(self):
330 self.refresh_fetchers()
331 self.o = LyricsWidget(self)
332 self.mpclient.song_changed.connect(self.refresh)
334 self.refresh()
335 def _unload(self):
336 self.o = None
337 self.__fetchers = None
338 self.mpclient.song_changed.disconnect(self.refresh)
339 def _get_dock_widget(self):
340 return self._create_dock(self.o)
342 def refresh(self):
343 """Attempt to automatically get lyrics first from a file, then from the internet."""
344 self.logger.info('Autorefreshing lyrics.')
345 self.__results = 0
346 self.__index = len(self.__fetchers)
347 self.o.lyrics_loaded = False
348 song = self.mpclient.current_song()
349 if not song:
350 self.__lyrics_dir = ''
351 self.__lyrics_path = ''
352 return self.o.set_lyrics(None, None)
354 (self.__lyrics_dir, self.__lyrics_path) = common.generate_metadata_path(song,
355 self.settings.value(self.name + '/lyricdir').toString(),
356 self.settings.value(self.name + '/lyricname').toString())
357 try:
358 self.logger.info('Trying to read lyrics from file %s.'%self.__lyrics_path)
359 file = open(self.__lyrics_path, 'r')
360 lyrics = file.read().decode('utf-8')
361 file.close()
362 if lyrics:
363 return self.o.set_lyrics(song, lyrics)
364 except IOError, e:
365 self.logger.info('Error reading lyrics file: %s.'%e)
367 for fetcher in self.__fetchers:
368 fetcher.fetch(song)
370 def save_lyrics_file(self, lyrics, path = None):
371 """Save lyrics (unicode string) to a file specified in path.
372 If path is None, then a default value is used."""
373 self.logger.info('Saving lyrics...')
374 try:
375 if path:
376 file = open(path, 'w')
377 else:
378 file = open(self.__lyrics_path, 'w')
379 file.write(lyrics.encode('utf-8'))
380 file.close()
381 self.logger.info('Lyrics successfully saved.')
382 except IOError, e:
383 self.logger.error('Error writing lyrics: %s', e)
385 def del_lyrics_file(self, song = None):
386 """Delete a lyrics file for song. If song is not specified
387 current song is used."""
388 if not song:
389 path = self.__lyrics_path
390 else:
391 path = common.generate_metadata_path(song, self.settings.value(self.name + '/lyricdir').toString(),
392 self.settings.value(self.name + '/lyricname').toString())
394 try:
395 os.remove(path)
396 except (IOError, OSError), e:
397 self.logger.error('Error removing lyrics file %s: %s'%(path, e))
399 def get_settings_widget(self):
400 return self.SettingsWidgetLyrics(self)
402 def refresh_fetchers(self):
403 """Refresh the list of available fetchers."""
404 self.__fetchers = []
405 # append fetchers in order they are stored in settings
406 for name in self.settings.value('%s/fetchers'%self.name).toStringList():
407 for fetcher in self.available_fetchers:
408 if fetcher.name == name:
409 self.__fetchers.append(fetcher(self))
410 self.__fetchers[-1].finished.connect(self.__new_lyrics_fetched)