Lyrics: fix saving unicode lyrics.
[nephilim.git] / nephilim / plugins / Lyrics.py
blob08cc10dcfb8314992e2ce6b57f84135f73ae0ef3
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 from lxml import etree
24 from ..plugin import Plugin
25 from .. import common
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().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 (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.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)
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_nephilim_$artist_$album_$title', 'store' : True}
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 = [self.FetchLyricwiki, self.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 self.settings.value(self.name + '/store').toBool():
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 FetchLyricwiki(common.MetadataFetcher):
146 name = 'Lyricwiki'
148 def fetch(self, song):
149 url = QtCore.QUrl('http://lyricwiki.org/api.php')
150 url.setQueryItems([('func', 'getArtist'), ('artist', song['artist']),
151 ('fmt', 'xml')])
152 self.fetch2(song, url)
153 self.rep.finished.connect(self.__handle_artist_res)
155 def __handle_artist_res(self):
156 artist = None
157 xml = QtCore.QXmlStreamReader(self.rep)
158 while not xml.atEnd():
159 token = xml.readNext()
160 if token == QtCore.QXmlStreamReader.StartElement:
161 if xml.name() == 'artist':
162 artist = xml.readElementText()
163 xml.clear()
164 if not artist:
165 self.logger.info('Didn\'t find artist in %s artist search results.'%self.name)
166 return self.finish()
168 url = QtCore.QUrl('http://lyricwiki.org/api.php')
169 url.setQueryItems([('func', 'getSong'), ('artist', artist),
170 ('song', self.song['title']), ('fmt', 'xml')])
171 self.rep = self.nam.get(QtNetwork.QNetworkRequest(url))
172 self.rep.finished.connect(self.__handle_search_res)
173 self.rep.error.connect(self.handle_error)
175 def __handle_search_res(self):
176 url = None
177 xml = QtCore.QXmlStreamReader(self.rep)
178 while not xml.atEnd():
179 token = xml.readNext()
180 if token == QtCore.QXmlStreamReader.StartElement:
181 if xml.name() == 'url':
182 url = QtCore.QUrl() # the url is already percent-encoded
183 try:
184 url.setEncodedUrl(xml.readElementText())
185 except TypeError: # no text
186 url = None
187 elif xml.name() == 'lyrics' and xml.readElementText() == 'Not found':
188 xml.clear()
189 return self.finish()
190 if xml.hasError():
191 self.logger.error('Error parsing seach results: %s'%xml.errorString())
193 if not url:
194 self.logger.warning('Didn\'t find the URL in Lyricwiki search results.')
195 return self.finish()
196 self.logger.info('Found Lyricwiki song URL: %s.'%url)
198 # XXX temporary hack to work around lyricwiki.org -> lyrics.wikia.org transition
199 url.setHost('lyrics.wikia.com')
200 url.setPath('/lyrics%s'%url.path())
201 req = QtNetwork.QNetworkRequest(url)
202 self.rep = self.nam.get(req)
203 self.rep.finished.connect(self.__handle_lyrics)
204 self.rep.error.connect(self.handle_error)
206 def __handle_lyrics(self):
207 lyrics = ''
208 xml = QtCore.QXmlStreamReader(self.rep)
209 while not xml.atEnd():
210 token = xml.readNext()
211 if token == QtCore.QXmlStreamReader.StartElement:
212 if xml.name() == 'div' and xml.attributes().value('class') == 'lyricbox':
213 while not xml.atEnd():
214 token = xml.readNext()
215 if token == QtCore.QXmlStreamReader.EndElement and xml.name() == 'div':
216 break
217 elif token == QtCore.QXmlStreamReader.StartElement and xml.name() == 'br':
218 lyrics += '\n'
219 elif token == QtCore.QXmlStreamReader.Characters:
220 lyrics += xml.text()
221 if xml.hasError():
222 self.logger.warning('Error parsing lyrics: %s'%xml.errorString())
224 self.finish(lyrics)
226 class FetchAnimelyrics(common.MetadataFetcher):
227 name = 'Animelyrics'
229 def fetch(self, song):
230 url = QtCore.QUrl('http://www.animelyrics.com/search.php')
231 url.setQueryItems([('t', 'performer'), ('q', song['artist'])])
232 self.fetch2(song, url)
233 self.rep.finished.connect(self.__handle_search_res)
235 def __handle_search_res(self):
236 # TODO use Qt xml functions
237 try:
238 tree = etree.HTML(unicode(self.rep.readAll(), encoding = 'utf-8', errors='ignore'))
239 except etree.XMLSyntaxError, e:
240 self.logger.error('Error parsing lyrics: %s' %e)
241 return self.finish()
243 url = None
244 for elem in tree.iterfind('.//a'):
245 if ('href' in elem.attrib) and elem.text and (self.song['title'] in elem.text):
246 url = QtCore.QUrl('http://www.animelyrics.com/%s'%elem.get('href'))
248 if not url:
249 self.logger.info('Didn\'t find the URL in Animelyrics search results.')
250 return self.finish()
251 self.logger.info('Found Animelyrics song URL: %s.'%url)
253 self.rep = self.nam.get(QtNetwork.QNetworkRequest(url))
254 self.rep.finished.connect(self.__handle_lyrics)
255 self.rep.error.connect(self.handle_error)
257 def __handle_lyrics(self):
258 lyrics = ''
259 try:
260 tree = etree.HTML(unicode(self.rep.readAll(), encoding = 'utf-8'))
261 except etree.XMLSyntaxError, e:
262 self.logger.error('Error parsing lyrics: %s' %e)
263 return self.finish()
264 for elem in tree.iterfind('.//pre'):
265 if elem.get('class') == 'lyrics':
266 lyrics += '%s\n\n'%etree.tostring(elem, method = 'text', encoding = 'utf-8')
268 self.finish(lyrics)
270 class SettingsWidgetLyrics(Plugin.SettingsWidget):
271 # private
272 lyricdir = None
273 lyricname = None
274 store = None
275 fetcherlist = None
277 def __init__(self, plugin):
278 Plugin.SettingsWidget.__init__(self, plugin)
279 self.settings.beginGroup(self.plugin.name)
282 # store lyrics groupbox
283 self.store = QtGui.QGroupBox('Store lyrics.')
284 self.store.setToolTip('Should %s store its own copy of lyrics?'%common.APPNAME)
285 self.store.setCheckable(True)
286 self.store.setChecked(self.settings.value('store').toBool())
287 self.store.setLayout(QtGui.QGridLayout())
289 # paths to lyrics
290 self.lyricdir = QtGui.QLineEdit(self.settings.value('lyricdir').toString())
291 self.lyricdir.setToolTip('Where should %s store lyrics.\n'
292 '$musicdir will be expanded to path to MPD music library (as set by user)\n'
293 '$songdir will be expanded to path to the song (relative to $musicdir\n'
294 'other tags same as in lyricname'
295 %common.APPNAME)
296 self.lyricname = QtGui.QLineEdit(self.settings.value('lyricname').toString())
297 self.lyricname.setToolTip('Filename for %s lyricsfiles.\n'
298 'All tags supported by MPD will be expanded to their\n'
299 'values for current song, e.g. $title, $track, $artist,\n'
300 '$album, $genre etc.'%common.APPNAME)
301 self.store.layout().addWidget(QtGui.QLabel('Lyrics directory'), 0, 0)
302 self.store.layout().addWidget(self.lyricdir, 0, 1)
303 self.store.layout().addWidget(QtGui.QLabel('Lyrics filename'), 1, 0)
304 self.store.layout().addWidget(self.lyricname, 1, 1)
306 # fetchers list
307 fetchers = self.settings.value('fetchers').toStringList()
308 self.fetcherlist = QtGui.QListWidget(self)
309 self.fetcherlist.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
310 for fetcher in fetchers:
311 it = QtGui.QListWidgetItem(fetcher)
312 it.setCheckState(QtCore.Qt.Checked)
313 self.fetcherlist.addItem(it)
314 for fetcher in self.plugin.available_fetchers:
315 if not fetcher.name in fetchers:
316 it = QtGui.QListWidgetItem(fetcher.name)
317 it.setCheckState(QtCore.Qt.Unchecked)
318 self.fetcherlist.addItem(it)
320 self.setLayout(QtGui.QVBoxLayout())
321 self.layout().addWidget(self.store)
322 self._add_widget(self.fetcherlist, label = 'Sites', tooltip = 'A list of sources used for fetching lyrics.\n'
323 'Use drag and drop to change their priority.')
325 self.settings.endGroup()
327 def save_settings(self):
328 self.settings.beginGroup(self.plugin.name)
329 self.settings.setValue('lyricdir', QVariant(self.lyricdir.text()))
330 self.settings.setValue('lyricname', QVariant(self.lyricname.text()))
331 self.settings.setValue('store', QVariant(self.store.isChecked()))
333 fetchers = []
334 for i in range(self.fetcherlist.count()):
335 it = self.fetcherlist.item(i)
336 if it.checkState() == QtCore.Qt.Checked:
337 fetchers.append(it.text())
338 self.settings.setValue('fetchers', QVariant(fetchers))
340 self.settings.endGroup()
341 self.plugin.refresh_fetchers()
342 self.plugin.refresh()
344 #### public ####
345 def _load(self):
346 self.refresh_fetchers()
347 self.o = LyricsWidget(self)
348 self.mpclient.song_changed.connect(self.refresh)
350 self.refresh()
351 def _unload(self):
352 self.o = None
353 self.__fetchers = None
354 self.mpclient.song_changed.disconnect(self.refresh)
355 def _get_dock_widget(self):
356 return self._create_dock(self.o)
358 def refresh(self):
359 """Attempt to automatically get lyrics first from a file, then from the internet."""
360 self.logger.info('Autorefreshing lyrics.')
361 self.__results = 0
362 self.__index = len(self.__fetchers)
363 self.o.lyrics_loaded = False
364 song = self.mpclient.current_song()
365 if not song:
366 self.__lyrics_dir = ''
367 self.__lyrics_path = ''
368 return self.o.set_lyrics(None, None)
370 (self.__lyrics_dir, self.__lyrics_path) = common.generate_metadata_path(song,
371 self.settings.value(self.name + '/lyricdir').toString(),
372 self.settings.value(self.name + '/lyricname').toString())
373 try:
374 self.logger.info('Trying to read lyrics from file %s.'%self.__lyrics_path)
375 file = open(self.__lyrics_path, 'r')
376 lyrics = file.read().decode('utf-8')
377 file.close()
378 if lyrics:
379 return self.o.set_lyrics(song, lyrics)
380 except IOError, e:
381 self.logger.info('Error reading lyrics file: %s.'%e)
383 for fetcher in self.__fetchers:
384 fetcher.fetch(song)
386 def save_lyrics_file(self, lyrics, path = None):
387 """Save lyrics (unicode string) to a file specified in path.
388 If path is None, then a default value is used."""
389 self.logger.info('Saving lyrics...')
390 try:
391 if path:
392 file = open(path, 'w')
393 else:
394 file = open(self.__lyrics_path, 'w')
395 file.write(lyrics.encode('utf-8'))
396 file.close()
397 self.logger.info('Lyrics successfully saved.')
398 except IOError, e:
399 self.logger.error('Error writing lyrics: %s', e)
401 def del_lyrics_file(self, song = None):
402 """Delete a lyrics file for song. If song is not specified
403 current song is used."""
404 if not song:
405 path = self.__lyrics_path
406 else:
407 path = common.generate_metadata_path(song, self.settings.value(self.name + '/lyricdir').toString(),
408 self.settings.value(self.name + '/lyricname').toString())
410 try:
411 os.remove(path)
412 except IOError, e:
413 self.logger.error('Error removing lyrics file %s: %s'%(path, e))
415 def get_settings_widget(self):
416 return self.SettingsWidgetLyrics(self)
418 def refresh_fetchers(self):
419 """Refresh the list of available fetchers."""
420 self.__fetchers = []
421 # append fetchers in order they are stored in settings
422 for name in self.settings.value('%s/fetchers'%self.name).toStringList():
423 for fetcher in self.available_fetchers:
424 if fetcher.name == name:
425 self.__fetchers.append(fetcher(self))
426 self.__fetchers[-1].finished.connect(self.__new_lyrics_fetched)