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
23 from lxml
import etree
25 from ..plugin
import Plugin
29 class LyricsWidget(QtGui
.QWidget
):
34 plugin
= None # plugin
38 __text_view
= None # text-object
43 def __init__(self
, plugin
):
44 QtGui
.QWidget
.__init
__(self
)
46 self
.logger
= plugin
.logger
49 self
.__label
= QtGui
.QLabel(self
)
50 self
.__label
.setWordWrap(True)
53 self
.__text
_view
= QtGui
.QTextEdit(self
)
54 self
.__text
_view
.setReadOnly(True)
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
)
82 def set_lyrics(self
, song
, lyrics
, flags
= 0):
83 """Set currently displayed lyrics (unicode string) for song. flags parameter is
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():
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']))
97 self
.logger
.info('Setting new lyrics.')
98 self
.__text
_view
.insertPlainText(lyrics
)
99 self
.lyrics_loaded
= True
101 self
.logger
.info('Lyrics not found.')
102 self
.__text
_view
.insertPlainText('Lyrics not found.')
104 class Lyrics(Plugin
):
106 info
= 'Show (and fetch) the lyrics of the currently playing song.'
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"
118 "number of returned results from last refresh() call"
120 "index/priority of current lyrics"
127 def __init__(self
, parent
, mpclient
, name
):
128 Plugin
.__init
__(self
, parent
, mpclient
, name
)
131 self
.available_fetchers
= [self
.FetchLyricwiki
, self
.FetchAnimelyrics
]
133 def __new_lyrics_fetched(self
, song
, lyrics
):
134 self
.logger
.info('Got new lyrics.')
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
)
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
):
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
):
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()
168 self
.logger
.info('Didn\'t find artist in %s artist search results.'%self
.name
)
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
):
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))
191 self
.logger
.info('Didn\'t find the song on Lyricwiki.')
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
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
)
208 self
.finish(common
.decode_htmlentities(lyrics
))
210 class FetchAnimelyrics(common
.MetadataFetcher
):
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
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)
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'))
233 self
.logger
.info('Didn\'t find the URL in Animelyrics search results.')
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
):
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)
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')
254 class SettingsWidgetLyrics(Plugin
.SettingsWidget
):
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())
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'
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)
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()))
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()
330 self
.refresh_fetchers()
331 self
.o
= LyricsWidget(self
)
332 self
.mpclient
.song_changed
.connect(self
.refresh
)
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
)
343 """Attempt to automatically get lyrics first from a file, then from the internet."""
344 self
.logger
.info('Autorefreshing lyrics.')
346 self
.__index
= len(self
.__fetchers
)
347 self
.o
.lyrics_loaded
= False
348 song
= self
.mpclient
.current_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())
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')
363 return self
.o
.set_lyrics(song
, lyrics
)
365 self
.logger
.info('Error reading lyrics file: %s.'%e)
367 for fetcher
in self
.__fetchers
:
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...')
376 file = open(path
, 'w')
378 file = open(self
.__lyrics
_path
, 'w')
379 file.write(lyrics
.encode('utf-8'))
381 self
.logger
.info('Lyrics successfully saved.')
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."""
389 path
= self
.__lyrics
_path
391 path
= common
.generate_metadata_path(song
, self
.settings
.value(self
.name
+ '/lyricdir').toString(),
392 self
.settings
.value(self
.name
+ '/lyricname').toString())
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."""
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
)