root/branches/0.3/src/plugins/audioscrobbler.py

Revision 675, 31.6 kB (checked in by nicfit, 2 years ago)

Some plugin cleanup and logger fixes

Line 
1 # -*- coding: utf-8 -*-
2 ################################################################################
3 #  Copyright (C) 2006  Travis Shirk <travis@pobox.com>
4 #
5 #  This program is free software; you can redistribute it and/or modify
6 #  it under the terms of the GNU General Public License as published by
7 #  the Free Software Foundation; either version 2 of the License, or
8 #  (at your option) any later version.
9 #
10 #  This program is distributed in the hope that it will be useful,
11 #  but WITHOUT ANY WARRANTY; without even the implied warranty of
12 #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 #  GNU General Public License for more details.
14 #
15 #  You should have received a copy of the GNU General Public License
16 #  along with this program; if not, write to the Free Software
17 #  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
18 #
19 ################################################################################
20 import os, sys, md5
21 import urllib, urllib2
22 import time, datetime
23 import threading
24 import pickle
25 import gtk, gtk.glade, gobject
26
27 import mesk
28 import mesk.plugin
29 from mesk.i18n import _
30 from mesk.plugin.plugin import PluginInfo, Plugin
31 from mesk.plugin.interfaces import AudioControlListener, ViewMenuProvider
32
33 # AudioScrobbler plugin vaiables
34 PROTOCOL_VERSION = '1.1'
35 APP              = 'mes'
36 VERSION          = '0.1'
37
38 HANDSHAKE_CODES  = ('UPTODATE', 'UPDATE', 'FAILED', 'BADUSER')
39 SUBMISSION_CODES = ('FAILED', 'BADAUTH', 'OK')
40 HANDSHAKE_URL = 'http://post.audioscrobbler.com/?hs=true&p=%s&c=%s&v=%s&u=%s'
41
42 NAME = 'audioscrobbler'
43 CONFIG_SECTION = NAME
44 MAX_SUBMIT = 10
45
46 class AudioscrobblerPlugin(Plugin, AudioControlListener, ViewMenuProvider):
47
48     def __init__(self):
49         Plugin.__init__(self, PLUGIN_INFO)
50
51         self.user = ''
52         self.passwd = ''
53         self.submit_tracks = True
54         self.md5_passwd = None
55         self.last_handshake = None
56         self.md5_challenge = None
57         self.submit_url = None
58
59         # Status label values
60         self.status_state = 'Auth Required'
61         self.status_submitted = 0
62         self.status_queued = 0
63         self.status_last_submit = 'None'
64         self.status_labels = {}
65
66         self.queue_lock = threading.Lock()
67         self.queue = []
68         profile = ''
69         if mesk.config.profile:
70             profile = '_%s' % mesk.config.profile
71         self.queue_filename = mesk.MESK_DIR + os.sep + 'tmp' + os.sep + \
72                               ('%s%s.queue' % (NAME, profile))
73
74         self.may_submit = threading.Event()
75         self.handshake_task = None
76         self.submit_task = None
77
78         if mesk.config.has_section(CONFIG_SECTION):
79             self.user = mesk.config.get(CONFIG_SECTION, 'username')
80             self.passwd = mesk.config.get(CONFIG_SECTION, 'password')
81             self.queue_filename = mesk.config.get(CONFIG_SECTION, 'queue_file')
82             self.submit_tracks = mesk.config.getboolean(CONFIG_SECTION,
83                                                         'submit_tracks', True)
84             self.md5_passwd = self.get_md5(self.passwd)
85             self.handshake()
86         else:
87             mesk.config.add_section(CONFIG_SECTION)
88             mesk.config.set(CONFIG_SECTION, 'username', self.user)
89             mesk.config.set(CONFIG_SECTION, 'password', self.passwd)
90             mesk.config.set(CONFIG_SECTION, 'submit_tracks', self.submit_tracks)
91             mesk.config.set(CONFIG_SECTION, 'queue_file', self.queue_filename)
92
93         q_dir = os.path.dirname(self.queue_filename)
94         if os.path.exists(self.queue_filename):
95             self.queue = pickle.load(open(self.queue_filename, 'r'))
96             self.status_queued = len(self.queue)
97         elif not os.path.exists(q_dir):
98             # Create tmp dir for queue
99             self.log.verbose('Creating queue file directory')
100             os.makedirs(q_dir)
101
102         self.bad_auth_dialog = None
103
104     def shutdown(self):
105         self.log.debug('Shutting down...')
106         self._cancel_handshake()
107         self._cancel_submit()
108
109         # Save pending queue
110         self.log.verbose(_('Saving %d queued items') % len(self.queue))
111         pickle.dump(self.queue, open(self.queue_filename, 'w'))
112
113     def is_configurable(self):
114         return True
115
116     def get_config_widget(self, parent):
117         self.config_parent = parent
118         from mesk.gtk_utils import default_linkbutton_callback
119         self.config_glade = gtk.glade.XML('./plugins/plugins_gui.glade',
120                                           'audioscrobbler_config_vbox', 'mesk')
121         self.config_glade.signal_autoconnect(self)
122         self.config_widget = \
123             self.config_glade.get_widget('audioscrobbler_config_vbox')
124         # Add linkbuttons since glade2 does not support them yet
125         join_url = 'http://www.last.fm/signup.php'
126         group_url = 'http://www.last.fm/group/Mesk%2BUsers/join/'
127         join_button = gtk.LinkButton(join_url, 'Join Last.FM')
128         join_button.connect('clicked', default_linkbutton_callback)
129         group_button = gtk.LinkButton(group_url,
130                                       'Join the Mesk Last.FM group')
131         group_button.connect('clicked', default_linkbutton_callback)
132         hbox = self.config_glade.get_widget('linkbutton_hbox')
133         hbox.pack_start(join_button)
134         hbox.pack_start(group_button)
135         hbox.show_all()
136
137         self.status_labels = {
138             'state': self.config_glade.get_widget('state_label'),
139             'submitted': self.config_glade.get_widget('submitted_label'),
140             'queued': self.config_glade.get_widget('queued_label'),
141             'last': self.config_glade.get_widget('last_submit_label'),
142             }
143
144         # Populate current config values
145         self.config_glade.get_widget('username_entry').set_text(self.user)
146         self.config_glade.get_widget('password_entry').set_text(self.passwd)
147         self.config_glade.get_widget('password_verify_entry')\
148                          .set_text(self.passwd)
149
150         self._update_status()
151
152         enabled_checkbutton = self.config_glade.get_widget('submit_checkbutton')
153         enabled_checkbutton.set_active(self.submit_tracks)
154         enabled_checkbutton.emit('toggled')
155
156         return self.config_widget
157
158     def _update_status(self, state=None, submit_count=None, queue_count=None,
159                        last_submit=None):
160         if state is not None:
161             self.status_state = state
162         if submit_count is not None:
163             self.status_submitted = submit_count
164         if queue_count is not None:
165             self.status_queued = queue_count
166         if last_submit is not None:
167             self.status_last_submit = last_submit
168
169         def idle_update():
170             # This must always execute on the gui thread
171             if not self.status_labels:
172                 return
173             self.status_labels['state'].set_text(self.status_state)
174             self.status_labels['submitted'].set_text(str(self.status_submitted))
175             self.status_labels['queued'].set_text(str(self.status_queued))
176             self.status_labels['last'].set_text(self.status_last_submit)
177         gobject.idle_add(idle_update)
178
179     def _on_enable_checkbutton_toggled(self, checkbutton):
180         # Set all table children's sensitivity based on enabled state
181         table = self.config_glade.get_widget('profile_info_table')
182         for child in table.get_children():
183             child.set_sensitive(checkbutton.get_active())
184
185     def config_ok(self):
186         username = self.config_glade.get_widget('username_entry').get_text()
187         pw = self.config_glade.get_widget('password_entry').get_text()
188         pw_verify = \
189             self.config_glade.get_widget('password_verify_entry').get_text()
190         active = self.config_glade.get_widget('submit_checkbutton').get_active()
191
192         # Validate input
193         if not username or not pw or not pw_verify:
194             d = gtk.MessageDialog(self.config_parent, gtk.DIALOG_MODAL,
195                                   type=gtk.MESSAGE_ERROR,
196                                   buttons=gtk.BUTTONS_OK,
197                                   message_format=_('Username and password '
198                                                    'required'))
199             d.run()
200             d.destroy()
201             return
202         elif pw != pw_verify:
203             d = gtk.MessageDialog(self.config_parent, gtk.DIALOG_MODAL,
204                                   type=gtk.MESSAGE_ERROR,
205                                   buttons=gtk.BUTTONS_OK,
206                                   message_format=_('Passwords do not match'))
207             d.run()
208             d.destroy()
209             return
210
211         # Update state
212         mesk.config.set(CONFIG_SECTION, 'username', username)
213         mesk.config.set(CONFIG_SECTION, 'password', pw)
214         mesk.config.set(CONFIG_SECTION, 'submit_tracks', active)
215         self.user = username
216         self.passwd = pw
217         self.md5_passwd = self.get_md5(self.passwd)
218         self.submit_tracks = active
219
220         # Rehandshake with new creds
221         self.handshake()
222
223     def _on_dialog_close(self, dialog, response):
224         self.bad_auth_dialog.destroy()
225         self.bad_auth_dialog = None
226
227     def get_md5(self, s):
228         hash = md5.new()
229         hash.update(s)
230         return hash.hexdigest()
231
232     def handshake(self, delay = 0.1):
233         # Cancel any pending
234         self._cancel_handshake()
235
236         self.may_submit.clear()
237         self.md5_challenge = self.submit_url = None
238         # Async handshake
239         self.handshake_task = threading.Timer(delay, self._handshake)
240         self.handshake_task.start()
241
242     def _handshake(self):
243         # Update user/passwd from config if necessary
244         if not self.user or not self.passwd:
245             self.user = mesk.config.get(CONFIG_SECTION, 'username', '')
246             self.passwd = mesk.config.get(CONFIG_SECTION, 'password', '')
247             self.md5_passwd = self.get_md5(self.passwd)
248             if not self.user or not self.passwd:
249                 self.log.critical('Add a username and password to the '
250                                   'audioscrobbler plugin preferences.')
251                 self._update_status(state=_('No username and/or password'))
252                 return
253
254         # Open handshake URL
255         hs_url = HANDSHAKE_URL % (PROTOCOL_VERSION, APP, VERSION, self.user)
256         url_data = None
257         try:
258             self.log.debug('Handshake: %s' % hs_url)
259             url_data = urllib2.urlopen(hs_url)
260         except Exception, ex:
261             retry = 10 # minutes
262             self._update_status(state=_('Cannot connect to Last.FM'))
263             self.log.warning('Handshake error, retry in %d minutes: %s' %
264                              (retry, str(ex)))
265             # Fatal failure, retry in 10 minutes
266             self.handshake(retry * 60)
267             return
268
269         # Parse response
270         try:
271             response = url_data.read()
272             url_data.close()
273             response = response.split('\n')
274             self.log.debug('Handshake response: %s' % str(response))
275             response_head = response[0].split(' ', 1)
276             response_code = response_head[0]
277             response_extra = ''
278             if len(response_head) == 2:
279                 response_extra = response_head[1]
280
281             if response_code not in HANDSHAKE_CODES:
282                 self.log.warning('Malformed response: %s' % str(response))
283                 self._update_status(state=_('Last.FM error'))
284                 return
285
286             # XXX: Workaround bug in server.
287             #      See http://www.audioscrobbler.net/forum/21716/_/93936
288             if response_code == 'BADUSER':
289                 while '' in response:
290                     response.remove('')
291
292             if response_code == 'FAILED' or response_code == 'BADUSER':
293                 interval = int(response[1].split()[1])
294                 self.log.warning(_('Handshake failure \'%s\', retrying in %d '
295                                    'seconds: %s') % (response_code,
296                                                      interval * 60,
297                                                      response_extra))
298
299                 if response_code == 'BADUSER':
300                     self._update_status(state=_('Invalid username'))
301                 else:
302                     self._update_status(state=_('Authentication failed'))
303
304                 # Retry handshake in interval seconds
305                 self.handshake(interval * 60)
306                 return
307
308             if response_code == 'UPDATE':
309                 self.log.info(_('Plugin update available here, please see %s') \
310                               % response_extra)
311
312             self.md5_challenge = response[1]
313             self.submit_url = response[2]
314             interval = int(response[3].split()[1])
315         except IndexError, ex:
316             self.log.warning('Invalid response: %s' % str(response))
317             self._update_status(state=_('Last.FM error'))
318             return
319
320         self._update_status(state=_('Authenticated'))
321         self.log.debug('Handshake success: %s' % self.md5_challenge)
322
323         self.may_submit.set()
324         # Flush anything queued
325         self.submit(interval = interval * 60)
326
327     def _submit_post(self, post_data):
328         try:
329             self.log.debug('Submitting to %s: %s' % (self.submit_url,
330                                                      post_data))
331             url_data = urllib2.urlopen(self.submit_url, post_data)
332         except Exception, ex:
333             self.log.warning(_('Submit error: %s') % str(ex))
334             self._update_status(state=_('Cannot connect to Last.FM'))
335             return False
336
337         try:
338             response = url_data.read().split('\n')
339             url_data.close()
340             self.log.debug('Submit response: %s' % response)
341             response_head = response[0].split(' ', 1)
342             response_code = response_head[0]
343             response_extra = ''
344             if len(response_head) == 2:
345                 response_extra = response_head[1]
346
347             if response_code not in SUBMISSION_CODES:
348                 self.log.warning(_('Malformed response: %s') % str(response))
349                 self._update_status(state=_('Last.FM error'))
350                 return False
351
352             interval = int(response[1].split()[1])
353         except IndexError:
354             self.log.warning(_('Invalid response: %s') % str(response))
355             self._update_status(state=_('Last.FM error'))
356             return False
357
358         if response_code == 'FAILED':
359             self.log.warning(_('Submit failure: %s') % (response_extra,))
360             self._update_status(state=_('Last.FM error'))
361             self.submit(interval = interval * 60)
362             return False
363         elif response_code== 'BADAUTH':
364             self.log.warning(_('BADAUTH failure, hanshake required'))
365             self._update_status(state=_('Authentication required'))
366             self.handshake(interval * 60)
367             return
368
369         return True
370
371     def _submit(self, audio_src):
372         audio_src_data = self.get_submit_data(audio_src)
373
374         self.queue_lock.acquire()
375
376         if not self.may_submit.isSet():
377             if audio_src_data:
378                 self.log.debug('Handshake required, queueing: %s' % \
379                                str(audio_src_data))
380                 self._update_status(state=_('Authentication required'))
381                 self.queue.append(audio_src_data)
382                 self._update_status(queue_count=len(self.queue))
383             self.queue_lock.release()
384             return
385
386         secret = self.get_md5(self.md5_passwd + self.md5_challenge)
387
388         if audio_src_data:
389             self.queue.append(audio_src_data)
390
391         # Process queue
392         done = (len(self.queue) == 0)
393         while not done:
394             submit_dict = {'u': self.user.encode('utf-8'),
395                            's': secret}
396             submit_count = min(MAX_SUBMIT, len(self.queue))
397             for i in range(submit_count):
398                 queue_dict = self.queue[i]
399                 for name, value in queue_dict.items():
400                     submit_dict[name % {'index': i}] = value
401             submit_str = urllib.urlencode(submit_dict)
402
403             if not self._submit_post(submit_str):
404                 done = True
405                 continue
406             else:
407                 self.status_submitted += submit_count
408                 self.status_last_submit = time.asctime()
409
410             self.queue = self.queue[submit_count:]
411             if len(self.queue) == 0:
412                 done = True
413
414         self.queue_lock.release()
415         self.status_queued = len(self.queue)
416         self._update_status()
417
418     def _cancel_handshake(self):
419         if self.handshake_task:
420             self.handshake_task.cancel()
421             self.handshake_task = None
422     def _cancel_submit(self):
423         if self.submit_task:
424             self.submit_task.cancel()
425             self.submit_task = None
426
427     ## AudioControlListener interface ###
428     def on_plugin_audio_play(self, audio_src): pass
429     def on_plugin_audio_pause(self, audio_src): pass
430     def on_plugin_audio_stop(self, audio_src):
431         self._cancel_submit()
432     def on_plugin_audio_seek(self, audio_src):
433         self._cancel_submit()
434
435     def on_plugin_source_started(self, audio_src):
436         self.log.debug('on_plugin_source_started: %s' % \
437                        os.path.basename(audio_src.uri.path))
438
439         if self.submit_tracks:
440             # Cancel any pending submits since source changed
441             self._cancel_submit()
442             self.submit(audio_src)
443
444     def on_plugin_source_ended(self, audio_src):
445         self.log.debug('on_plugin_source_ended: %s' % \
446                        os.path.basename(audio_src.uri.path))
447
448     ## ViewMenuProvider interface ###
449     def plugin_view_menu_items(self):
450         item = gtk.ImageMenuItem(_('Open Last.FM User Page'))
451         item.set_image(gtk.image_new_from_stock(gtk.STOCK_HOME,
452                                                 gtk.ICON_SIZE_MENU))
453         def user_page_activate(widget):
454             mesk.utils.load_web_page('http://www.last.fm/user/%s' %
455                                      mesk.uri.escape_path(self.user))
456         item.connect('activate', user_page_activate)
457         return [item]
458
459     def submit(self, audio_src = None, interval = 0.1):
460         # The audio_src is optional to allow flushing the queue
461         if audio_src:
462             # Half the song or 240s, whichever is shorter
463             src_len = audio_src.meta_data.time_secs
464             if src_len < 30:
465                 self.log.info(_('Source length %s < 30s, skipping') % \
466                               str(src_len))
467                 return
468             interval = min(src_len / 2, 240)
469         # Schedule submit task
470         self.submit_task = threading.Timer(interval, self._submit,
471                                            args=[audio_src])
472         self.submit_task.start()
473
474     def time_stamp(self):
475         return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
476
477     def get_submit_data(self, audio_src):
478         '''Returns a dictionary of name value pairs.  The %(index)d is
479         meant to be replaced with % formatting'''
480         if not audio_src:
481             return None
482
483         artist = audio_src.meta_data.artist
484         title = audio_src.meta_data.title
485         if not artist or not title:
486             self.log.warning(_('Source %s is missing artist and/or title: ') % \
487                              audio_src.uri.path)
488             return None
489         album = audio_src.meta_data.album
490
491         post = {}
492     &n