common: split MetadataFetcher into its own file
[nephilim.git] / nephilim / plugins / Lyrics.py
blob30e18fa0f04bfc04e61804939b594e69fa3b6c45
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
20 import os
21 import re
22 from lxml import etree
24 from ..plugin import Plugin
25 from .. import common, metadata_fetcher
26 from .. import icons
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(':icons/refresh.png'), 'Refresh lyrics', self.plugin.refresh)
60 edit = self.__toolbar.addAction(QtGui.QIcon(':icons/edit.png'), 'Edit lyrics')
61 edit.setCheckable(True)
62 edit.toggled.connect(self.__toggle_editable)
64 self.__toolbar.addAction(QtGui.QIcon(':icons/save.png'), 'Save lyrics', self.__save_lyrics)
65 self.__toolbar.addAction(QtGui.QIcon(':icons/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(self.__text_view.toPlainText())
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 (unicode string) 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.cur_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)
98 self.lyrics_loaded = True
99 else:
100 self.logger.info('Lyrics not found.')
101 self.__text_view.insertPlainText('Lyrics not found.')
103 class Lyrics(Plugin):
104 # public, const
105 info = 'Show (and fetch) the lyrics of the currently playing song.'
107 # public, read-only
108 o = None
110 # private
111 DEFAULTS = {'fetchers' : ['Lyricwiki', 'Animelyrics'], 'lyricdir' : '${musicdir}/${songdir}',
112 'lyricname' : '.lyrics_%s_${artist}_${album}_${title}'%common.APPNAME, 'store' : 1}
113 "implemented fetchers"
114 available_fetchers = None #XXX SettingsWidget currently uses it
115 "enabled fetchers, those with higher priority first"
116 __fetchers = None
117 "number of returned results from last refresh() call"
118 __results = None
119 "index/priority of current lyrics"
120 __index = None
121 "metadata paths"
122 __lyrics_dir = None
123 __lyrics_path = None
125 #### private ####
126 def __init__(self, parent, mpclient, name):
127 Plugin.__init__(self, parent, mpclient, name)
129 self.__fetchers = []
130 self.available_fetchers = [FetchLyricwiki, FetchAnimelyrics]
132 def __new_lyrics_fetched(self, song, lyrics):
133 self.logger.info('Got new lyrics.')
134 self.__results += 1
136 i = self.__fetchers.index(self.sender())
137 if lyrics and i < self.__index:
138 if int(self.settings.value(self.name + '/store')) and self.__lyrics_path:
139 self.save_lyrics_file(lyrics)
140 self.__index = i
141 return self.o.set_lyrics(song, lyrics)
142 elif self.__results >= len(self.__fetchers) and not self.o.lyrics_loaded:
143 self.o.set_lyrics(song, None)
145 class SettingsWidgetLyrics(Plugin.SettingsWidget):
146 # private
147 lyricdir = None
148 lyricname = None
149 store = None
150 fetcherlist = None
152 def __init__(self, plugin):
153 Plugin.SettingsWidget.__init__(self, plugin)
154 self.settings.beginGroup(self.plugin.name)
157 # store lyrics groupbox
158 self.store = QtGui.QGroupBox('Store lyrics.')
159 self.store.setToolTip('Should %s store its own copy of lyrics?'%common.APPNAME)
160 self.store.setCheckable(True)
161 self.store.setChecked(int(self.settings.value('store')))
162 self.store.setLayout(QtGui.QGridLayout())
164 # paths to lyrics
165 self.lyricdir = QtGui.QLineEdit(self.settings.value('lyricdir'))
166 self.lyricdir.setToolTip('Where should %s store lyrics.\n'
167 '${musicdir} will be expanded to path to MPD music library (as set by user)\n'
168 '${songdir} will be expanded to path to the song (relative to ${musicdir}\n'
169 'other tags same as in lyricname'
170 %common.APPNAME)
171 self.lyricname = QtGui.QLineEdit(self.settings.value('lyricname'))
172 self.lyricname.setToolTip('Filename for %s lyricsfiles.\n'
173 'All tags supported by MPD will be expanded to their\n'
174 'values for current song, e.g. ${title}, ${track}, ${artist},\n'
175 '${album}, ${genre} etc.'%common.APPNAME)
176 self.store.layout().addWidget(QtGui.QLabel('Lyrics directory'), 0, 0)
177 self.store.layout().addWidget(self.lyricdir, 0, 1)
178 self.store.layout().addWidget(QtGui.QLabel('Lyrics filename'), 1, 0)
179 self.store.layout().addWidget(self.lyricname, 1, 1)
181 # fetchers list
182 fetchers = self.settings.value('fetchers')
183 self.fetcherlist = QtGui.QListWidget(self)
184 self.fetcherlist.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
185 for fetcher in fetchers:
186 it = QtGui.QListWidgetItem(fetcher)
187 it.setCheckState(QtCore.Qt.Checked)
188 self.fetcherlist.addItem(it)
189 for fetcher in self.plugin.available_fetchers:
190 if not fetcher.name in fetchers:
191 it = QtGui.QListWidgetItem(fetcher.name)
192 it.setCheckState(QtCore.Qt.Unchecked)
193 self.fetcherlist.addItem(it)
195 self.setLayout(QtGui.QVBoxLayout())
196 self.layout().addWidget(self.store)
197 self._add_widget(self.fetcherlist, label = 'Sites', tooltip = 'A list of sources used for fetching lyrics.\n'
198 'Use drag and drop to change their priority.')
200 self.settings.endGroup()
202 def save_settings(self):
203 self.settings.beginGroup(self.plugin.name)
204 self.settings.setValue('lyricdir', self.lyricdir.text())
205 self.settings.setValue('lyricname', self.lyricname.text())
206 self.settings.setValue('store', int(self.store.isChecked()))
208 fetchers = []
209 for i in range(self.fetcherlist.count()):
210 it = self.fetcherlist.item(i)
211 if it.checkState() == QtCore.Qt.Checked:
212 fetchers.append(it.text())
213 self.settings.setValue('fetchers', fetchers)
215 self.settings.endGroup()
216 self.plugin.refresh_fetchers()
217 self.plugin.refresh()
219 #### public ####
220 def _load(self):
221 self.refresh_fetchers()
222 self.o = LyricsWidget(self)
223 self.mpclient.song_changed.connect(self.refresh)
225 self.refresh()
226 def _unload(self):
227 self.o = None
228 self.__fetchers = None
229 self.mpclient.song_changed.disconnect(self.refresh)
230 def _get_dock_widget(self):
231 return self._create_dock(self.o)
233 def refresh(self):
234 """Attempt to automatically get lyrics first from a file, then from the internet."""
235 self.logger.info('Autorefreshing lyrics.')
236 self.__results = 0
237 self.__index = len(self.__fetchers)
238 self.o.lyrics_loaded = False
239 song = self.mpclient.cur_song
240 if not song:
241 self.__lyrics_dir = ''
242 self.__lyrics_path = ''
243 return self.o.set_lyrics(None, None)
245 (self.__lyrics_dir, self.__lyrics_path) = common.generate_metadata_path(song,
246 self.settings.value(self.name + '/lyricdir'),
247 self.settings.value(self.name + '/lyricname'))
248 try:
249 self.logger.info('Trying to read lyrics from file %s.'%self.__lyrics_path)
250 file = open(self.__lyrics_path, 'r')
251 lyrics = file.read().decode('utf-8')
252 file.close()
253 if lyrics:
254 return self.o.set_lyrics(song, lyrics)
255 except IOError, e:
256 self.logger.info('Error reading lyrics file: %s.'%e)
258 for fetcher in self.__fetchers:
259 fetcher.fetch(song)
261 def save_lyrics_file(self, lyrics, path = None):
262 """Save lyrics (unicode string) to a file specified in path.
263 If path is None, then a default value is used."""
264 self.logger.info('Saving lyrics...')
265 try:
266 if path:
267 file = open(path, 'w')
268 else:
269 file = open(self.__lyrics_path, 'w')
270 file.write(lyrics.encode('utf-8'))
271 file.close()
272 self.logger.info('Lyrics successfully saved.')
273 except IOError, e:
274 self.logger.error('Error writing lyrics: %s', e)
276 def del_lyrics_file(self, song = None):
277 """Delete a lyrics file for song. If song is not specified
278 current song is used."""
279 if not song:
280 path = self.__lyrics_path
281 else:
282 path = common.generate_metadata_path(song, self.settings.value(self.name + '/lyricdir'),
283 self.settings.value(self.name + '/lyricname'))
285 try:
286 os.remove(path)
287 except (IOError, OSError), e:
288 self.logger.error('Error removing lyrics file %s: %s'%(path, e))
290 def get_settings_widget(self):
291 return self.SettingsWidgetLyrics(self)
293 def refresh_fetchers(self):
294 """Refresh the list of available fetchers."""
295 self.__fetchers = []
296 # append fetchers in order they are stored in settings
297 for name in self.settings.value('%s/fetchers'%self.name):
298 for fetcher in self.available_fetchers:
299 if fetcher.name == name:
300 self.__fetchers.append(fetcher(self))
301 self.__fetchers[-1].finished.connect(self.__new_lyrics_fetched)
303 class FetchLyricwiki(metadata_fetcher.MetadataFetcher):
304 name = 'Lyricwiki'
306 __apiaddress = 'http://lyrics.wikia.com/api.php'
308 def fetch(self, song):
309 url = QtCore.QUrl(self.__apiaddress)
310 url.setQueryItems([('func', 'getArtist'), ('artist', song['?artist']),
311 ('fmt', 'xml'), ('action', 'lyrics')])
312 self.fetch2(song, url)
313 self.rep.finished.connect(self.__handle_artist_res)
315 def __handle_artist_res(self):
316 artist = None
317 xml = QtCore.QXmlStreamReader(self.rep)
318 while not xml.atEnd():
319 token = xml.readNext()
320 if token == QtCore.QXmlStreamReader.StartElement:
321 if xml.name() == 'artist':
322 artist = xml.readElementText()
323 xml.clear()
324 if not artist:
325 self.logger.info('Didn\'t find artist in %s artist search results.'%self.name)
326 return self.finish()
327 self.logger.info('Found artist: %s'%artist)
329 url = QtCore.QUrl(self.__apiaddress)
330 url.setQueryItems([('action', 'lyrics'), ('func', 'getSong'), ('artist', artist),
331 ('song', self.song['?title']), ('fmt', 'xml')])
332 self.rep = self.nam.get(QtNetwork.QNetworkRequest(url))
333 self.rep.finished.connect(self.__handle_search_res)
334 self.rep.error.connect(self.handle_error)
336 def __handle_search_res(self):
337 url = None
339 # the page is borked utf-8 as of nov 2009, qxmlstreamreader chokes
340 # on it => use regexps
341 match = re.search('<url>(.*)</url>', str(self.rep.readAll()).decode('utf-8', 'replace'),
342 re.DOTALL|re.IGNORECASE)
343 if match and not 'action=edit' in match.group(1):
344 url = QtCore.QUrl() # the url is already percent-encoded
345 url.setEncodedUrl(match.group(1))
347 if not url:
348 self.logger.info('Didn\'t find the song on Lyricwiki.')
349 return self.finish()
350 self.logger.info('Found Lyricwiki song URL: %s.'%url.toString())
352 req = QtNetwork.QNetworkRequest(url)
353 self.rep = self.nam.get(req)
354 self.rep.finished.connect(self.__handle_lyrics)
355 self.rep.error.connect(self.handle_error)
357 def __handle_lyrics(self):
358 # the page isn't valid xml, so use regexps
359 lyrics = ''
360 for it in re.finditer('<div class=\'lyricbox\'>(?:<div.*?>.*?</div>)?(.*?)(?:<div.*?>.*?</div>)?</div>',
361 str(self.rep.readAll()).decode('utf-8'), re.DOTALL):
362 gr = re.sub('<br />', '\n', it.group(1))
363 gr = re.sub(re.compile('<.*>', re.DOTALL), '', gr)
364 lyrics += gr + '\n'
365 self.finish(common.decode_htmlentities(lyrics))
367 class FetchAnimelyrics(metadata_fetcher.MetadataFetcher):
368 name = 'Animelyrics'
370 def fetch(self, song):
371 url = QtCore.QUrl('http://www.animelyrics.com/search.php')
372 url.setQueryItems([('t', 'performer'), ('q', song['?artist'])])
373 self.fetch2(song, url)
374 self.rep.finished.connect(self.__handle_search_res)
376 def __handle_search_res(self):
377 # TODO use Qt xml functions
378 try:
379 tree = etree.HTML(unicode(self.rep.readAll(), encoding = 'utf-8', errors='ignore'))
380 except etree.XMLSyntaxError, e:
381 self.logger.error('Error parsing lyrics: %s' %e)
382 return self.finish()
384 url = None
385 for elem in tree.iterfind('.//a'):
386 if ('href' in elem.attrib) and elem.text and (self.song['?title'] in elem.text):
387 url = QtCore.QUrl('http://www.animelyrics.com/%s'%elem.get('href'))
389 if not url:
390 self.logger.info('Didn\'t find the URL in Animelyrics search results.')
391 return self.finish()
392 self.logger.info('Found Animelyrics song URL: %s.'%url)
394 self.rep = self.nam.get(QtNetwork.QNetworkRequest(url))
395 self.rep.finished.connect(self.__handle_lyrics)
396 self.rep.error.connect(self.handle_error)
398 def __handle_lyrics(self):
399 lyrics = ''
400 try:
401 tree = etree.HTML(unicode(self.rep.readAll(), encoding = 'utf-8'))
402 except etree.XMLSyntaxError, e:
403 self.logger.error('Error parsing lyrics: %s' %e)
404 return self.finish()
405 for elem in tree.iterfind('.//pre'):
406 if elem.get('class') == 'lyrics':
407 lyrics += '%s\n\n'%etree.tostring(elem, method = 'text', encoding = 'utf-8')
409 self.finish(lyrics)