Lyrics: cosmetics - group private/public functions.
[nephilim.git] / nephilim / plugins / Lyrics.py
blob33ea36910da30d66d9dc98329f2619e48223acd4
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
107 """A dict of { site name : fetcher object }. The fetcher object provides a
108 fetch(song)Function takes a song and emits finished(song, lyrics) signal
109 when finished. Lyrics is either a python unicode string, QString
110 or None if not found."""
112 # private
113 DEFAULTS = {'sites' : QtCore.QStringList(['lyricwiki', 'animelyrics']), 'lyricdir' : '$musicdir/$songdir',
114 'lyricname' : '.lyrics_nephilim_$artist_$album_$title', 'store' : True}
115 __available_sites = {}
116 __fetchers = {}
117 __results = 0
118 __lyrics_dir = None
119 __lyrics_path = None
121 #### private ####
122 def __init__(self, parent, mpclient, name):
123 Plugin.__init__(self, parent, mpclient, name)
125 self.__available_sites['lyricwiki'] = self.FetchLyricwiki
126 self.__available_sites['animelyrics'] = self.FetchAnimelyrics
128 def __new_lyrics_fetched(self, song, lyrics):
129 self.logger.info('Got new lyrics.')
130 self.__results += 1
131 if lyrics and self.settings().value(self.name + '/store').toBool():
132 self.save_lyrics_file(lyrics)
133 return self.o.set_lyrics(song, lyrics)
134 elif self.__results >= len(self.__fetchers) and not self.o.lyrics_loaded:
135 self.o.set_lyrics(song, None)
137 class Fetcher(QtCore.QObject):
138 """A basic class for lyrics fetchers. Provides a fetch(song) function,
139 emits a finished(song, lyrics) signal when done; lyrics is either a QString,
140 Python unicode string or None if not found."""
141 #public, read-only
142 logger = None
144 #private
145 nam = None # NetworkAccessManager
146 srep = None # search results NetworkReply
147 lrep = None # lyrics page NetworkReply
148 song = None # current song
150 def __init__(self, plugin):
151 QtCore.QObject.__init__(self, plugin)
153 self.nam = QtNetwork.QNetworkAccessManager()
154 self.logger = plugin.logger
155 def fetch(self, song):
156 """Reimplement this in subclasses."""
157 pass
159 def finish(self, lyrics = None):
160 """A private convenience function to clean up and emit finished().
161 Feel free to reimplement/not use it."""
162 self.srep = None
163 self.lrep = None
164 self.emit(QtCore.SIGNAL('finished'), self.song, lyrics)
165 self.song = None
167 class FetchLyricwiki(Fetcher):
169 def fetch(self, song):
170 # abort any existing connections
171 if self.srep:
172 self.srep.abort()
173 self.srep = None
174 if self.lrep:
175 self.lrep.abort()
176 self.lrep = None
177 self.song = song
179 url = QtCore.QUrl('http://lyricwiki.org/api.php')
180 url.setQueryItems([('func', 'getSong'), ('artist', song.artist()),
181 ('song', song.title()), ('fmt', 'xml')])
183 self.logger.info('Searching Lyricwiki: %s.'%url)
184 self.srep = self.nam.get(QtNetwork.QNetworkRequest(url))
185 self.connect(self.srep, QtCore.SIGNAL('finished()'), self.__handle_search_res)
187 def __handle_search_res(self):
188 url = None
189 xml = QtCore.QXmlStreamReader(self.srep)
190 while not xml.atEnd():
191 token = xml.readNext()
192 if token == QtCore.QXmlStreamReader.StartElement:
193 if xml.name() == 'url':
194 url = QtCore.QUrl() # the url is already percent-encoded
195 url.setEncodedUrl(xml.readElementText().toLatin1())
196 elif xml.name() == 'lyrics' and xml.readElementText() == 'Not found':
197 xml.clear()
198 return self.finish()
199 if xml.hasError():
200 self.logger.error('Error parsing seach results.%s'%xml.errorString())
202 if not url:
203 self.logger.error('Didn\'t find the URL in Lyricwiki search results.')
204 return self.finish()
205 self.logger.info('Found Lyricwiki song URL: %s.'%url)
207 self.lrep = self.nam.get(QtNetwork.QNetworkRequest(url))
208 self.connect(self.lrep, QtCore.SIGNAL('finished()'), self.__handle_lyrics)
210 def __handle_lyrics(self):
211 #TODO this should use Qt xml functions too
212 lyrics = ''
213 page = unicode(self.lrep.readAll(), encoding = 'utf-8')
214 page = re.sub('<br>|<br/>|<br />', '\n', page)
215 html = etree.HTML(page)
216 for elem in html.iterfind('.//div'):
217 if elem.get('class') == 'lyricbox':
218 lyrics += etree.tostring(elem, method = 'text', encoding = 'utf-8')
219 self.finish(lyrics)
221 class FetchAnimelyrics(Fetcher):
223 def fetch(self, song):
224 # abort any existing connections
225 if self.srep:
226 self.srep.abort()
227 self.srep = None
228 if self.lrep:
229 self.lrep.abort()
230 self.lrep = None
231 self.song = song
233 url = QtCore.QUrl('http://www.animelyrics.com/search.php')
234 url.setQueryItems([('t', 'performer'), ('q', self.song.artist())])
236 self.logger.info('Searching Animelyrics: %s.'%url)
237 self.srep = self.nam.get(QtNetwork.QNetworkRequest(url))
238 self.connect(self.srep, QtCore.SIGNAL('finished()'), self.__handle_search_res)
240 def __handle_search_res(self):
241 # TODO use Qt xml functions
242 tree = etree.HTML(unicode(self.srep.readAll(), encoding = 'utf-8', errors='ignore'))
243 self.srep = None
245 url = None
246 for elem in tree.iterfind('.//a'):
247 if ('href' in elem.attrib) and elem.text and (self.song.title() in elem.text):
248 url = QtCore.QUrl('http://www.animelyrics.com/%s'%elem.get('href'))
250 if not url:
251 self.logger.info('Didn\'t find the URL in Animelyrics search results.')
252 return self.finish()
253 self.logger.info('Found Animelyrics song URL: %s.'%url)
255 self.lrep = self.nam.get(QtNetwork.QNetworkRequest(url))
256 self.connect(self.lrep, QtCore.SIGNAL('finished()'), self.__handle_lyrics)
258 def __handle_lyrics(self):
259 lyrics = ''
260 tree = etree.HTML(unicode(self.lrep.readAll(), encoding = 'utf-8'))
261 for elem in tree.iterfind('.//pre'):
262 if elem.get('class') == 'lyrics':
263 lyrics += '%s\n\n'%etree.tostring(elem, method = 'text', encoding = 'utf-8')
265 self.finish(lyrics)
267 class SettingsWidgetLyrics(Plugin.SettingsWidget):
268 lyricdir = None
269 lyricname = None
270 store = None
272 def __init__(self, plugin):
273 Plugin.SettingsWidget.__init__(self, plugin)
274 self.settings().beginGroup(self.plugin.name)
277 # store lyrics groupbox
278 self.store = QtGui.QGroupBox('Store lyrics.')
279 self.store.setToolTip('Should %s store its own copy of lyrics?'%misc.APPNAME)
280 self.store.setCheckable(True)
281 self.store.setChecked(self.settings().value('store').toBool())
282 self.store.setLayout(QtGui.QGridLayout())
284 # paths to lyrics
285 self.lyricdir = QtGui.QLineEdit(self.settings().value('lyricdir').toString())
286 self.lyricdir.setToolTip('Where should %s store lyrics.\n'
287 '$musicdir will be expanded to path to MPD music library (as set by user)\n'
288 '$songdir will be expanded to path to the song (relative to $musicdir\n'
289 'other tags same as in lyricname'
290 %misc.APPNAME)
291 self.lyricname = QtGui.QLineEdit(self.settings().value('lyricname').toString())
292 self.lyricname.setToolTip('Filename for %s lyricsfiles.\n'
293 'All tags supported by MPD will be expanded to their\n'
294 'values for current song, e.g. $title, $track, $artist,\n'
295 '$album, $genre etc.'%misc.APPNAME)
296 self.store.layout().addWidget(QtGui.QLabel('Lyrics directory'), 0, 0)
297 self.store.layout().addWidget(self.lyricdir, 0, 1)
298 self.store.layout().addWidget(QtGui.QLabel('Lyrics filename'), 1, 0)
299 self.store.layout().addWidget(self.lyricname, 1, 1)
301 self.setLayout(QtGui.QVBoxLayout())
302 self.layout().addWidget(self.store)
304 self.settings().endGroup()
306 def save_settings(self):
307 self.settings().beginGroup(self.plugin.name)
308 self.settings().setValue('lyricdir', QVariant(self.lyricdir.text()))
309 self.settings().setValue('lyricname', QVariant(self.lyricname.text()))
310 self.settings().setValue('store', QVariant(self.store.isChecked()))
311 self.settings().endGroup()
312 self.plugin.refresh()
314 #### public ####
315 def _load(self):
316 for site in self.__available_sites:
317 if site in self.settings().value('%s/sites'%self.name).toStringList():
318 self.__fetchers[site] = self.__available_sites[site](self)
319 self.o = LyricsWidget(self)
320 for fetcher in self.__fetchers:
321 self.connect(self.__fetchers[fetcher], QtCore.SIGNAL('finished'), self.__new_lyrics_fetched)
322 self.connect(self.mpclient, QtCore.SIGNAL('song_changed'), self.refresh)
323 def _unload(self):
324 self.o = None
325 self.sites = {}
326 self.disconnect(self.mpclient, QtCore.SIGNAL('song_changed'), self.refresh)
327 def info(self):
328 return "Show (and fetch) the lyrics of the currently playing song."
330 def _get_dock_widget(self):
331 return self._create_dock(self.o)
333 def refresh(self):
334 """Attempt to automatically get lyrics first from a file, then from the internet."""
335 self.logger.info('Autorefreshing lyrics.')
336 self.__results = 0
337 self.o.lyrics_loaded = False
338 song = self.mpclient.current_song()
339 if not song:
340 self.__lyrics_dir = ''
341 self.__lyrics_path = ''
342 return self.o.set_lyrics(None, None)
344 (self.__lyrics_dir, self.__lyrics_path) = misc.generate_metadata_path(song,
345 self.settings().value(self.name + '/lyricdir').toString(),
346 self.settings().value(self.name + '/lyricname').toString())
347 try:
348 self.logger.info('Trying to read lyrics from file %s.'%self.__lyrics_path)
349 file = open(self.__lyrics_path, 'r')
350 lyrics = file.read()
351 file.close()
352 if lyrics:
353 return self.o.set_lyrics(song, lyrics)
354 except IOError, e:
355 self.logger.info('Error reading lyrics file: %s.'%e)
357 for fetcher in self.__fetchers.values():
358 fetcher.fetch(song)
360 def save_lyrics_file(self, lyrics, path = None):
361 """Save lyrics to a file specified in path.
362 If path is None, then a default value is used."""
363 self.logger.info('Saving lyrics...')
364 try:
365 if path:
366 file = open(path, 'w')
367 else:
368 file = open(self.__lyrics_path, 'w')
369 file.write(lyrics)
370 file.close()
371 self.logger.info('Lyrics successfully saved.')
372 except IOError, e:
373 self.logger.error('Error writing lyrics: %s', e)
375 def del_lyrics_file(self, song = None):
376 """Delete a lyrics file for song. If song is not specified
377 current song is used."""
378 if not song:
379 path = self.__lyrics_path
380 else:
381 path = misc.generate_metadata_path(song, self.settings().value(self.name + '/lyricdir').toString(),
382 self.settings().value(self.name + '/lyricname').toString())
384 try:
385 os.remove(path)
386 except IOError, e:
387 self.logger.error('Error removing lyrics file %s: %s'%(path, e))
389 def get_settings_widget(self):
390 return self.SettingsWidgetLyrics(self)