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