pithos_0.3.17/000077500000000000000000000000001175056731700132065ustar00rootroot00000000000000pithos_0.3.17/CHANGELOG000066400000000000000000000142231175056731700144220ustar00rootroot00000000000000$$title: Changelog ### 0.3.17 2012-05-03 * Switch to JSON API to work around XMLRPC API update. Thanks to Christopher Eby for the patch. [(lp:988395)](https://bugs.launchpad.net/pithos/+bug/988395) ### 0.3.16 2012-04-19 * Numerous bugfixes * [(lp:905204)](https://bugs.launchpad.net/pithos/+bug/905204) * [(lp:876984)](https://bugs.launchpad.net/pithos/+bug/876984) * [(lp:976327)](https://bugs.launchpad.net/pithos/+bug/976327) * [(lp:883610)](https://bugs.launchpad.net/pithos/+bug/883610) * [(lp:891225)](https://bugs.launchpad.net/pithos/+bug/891225) * [(lp:880142)](https://bugs.launchpad.net/pithos/+bug/880142) * [(lp:836393)](https://bugs.launchpad.net/pithos/+bug/836393) * [(lp:907278)](https://bugs.launchpad.net/pithos/+bug/907278) * [(lp:812626)](https://bugs.launchpad.net/pithos/+bug/812626) * Minor User Interface improvements * [(lp:904589)](https://bugs.launchpad.net/pithos/+bug/904589) * [(lp:909198)](https://bugs.launchpad.net/pithos/+bug/909198) Thanks to Adam Porter, Derek Ditch, Martin Langhoff, and Malcolm Lewis, who contributed to this release. ### 0.3.14 2011-12-14 * Use misc.sync method to get Pandora time offset. Fixes "You have no chance to survive make your time" [(lp:743198)](https://bugs.launchpad.net/pithos/+bug/743198) (thanks Matt Harrison, Adam Haile) * Improve error message and make it clear which messages come from Pandora * Fix media key support on Ubuntu 11.10 [(lp:902322)](https://bugs.launchpad.net/pithos/+bug/902322) (thanks RJP Computing) ### 0.3.13 2011-11-09 * Use SSL for login - fixes AUTH_WEB_LOGIN_NOT_ALLOWED error from Pandora change 2 minutes after 0.3.12 release ### 0.3.12 2011-11-09 * Pandora protocol v33 * Less confusing error dialog for the next Pandora update * Notify plugin: escape song info so album names with special characters display properly on non-Ubuntu Gnome. ### 0.3.11 2011-09-22 * Pandora protocol v32 * Setting for audio format (by Stefan Nelson-Lindall) ### 0.3.10 2011-07-09 * Pandora protocol v31; No key change, minor change to createStation ### 0.3.9 2011-04-27 * New Pandora encryption keys [(lp:771804)](https://bugs.launchpad.net/pithos/+bug/771804) * Enable gstreamer progressive download to improve stream quality and work around [gstreamer bug 648786](https://bugzilla.gnome.org/show_bug.cgi?id=648786) on Ubuntu 11.04. [(lp:705271)](https://bugs.launchpad.net/pithos/+bug/705271) [(lp:759699)](https://bugs.launchpad.net/pithos/+bug/759699) ### 0.3.8 2011-04-12 * CVE-2011-1500: Fix password leak to local users through file permissions (by Luke Faraone) [(lp:733307)](https://bugs.launchpad.net/pithos/+bug/733307) * Correctly handle hour-long songs (by Luke Faraone) [(lp:734962)](https://bugs.launchpad.net/pithos/+bug/734962) * Fix "TypeError: could not convert argument to correct param type" (by Rick Spencer) [(lp:706681)](https://bugs.launchpad.net/pithos/+bug/706681) ### 0.3.7 2011-01-09 * Allow feedback to be removed from songs (by Christopher Eby) [(lp:659581)](https://bugs.launchpad.net/pithos/+bug/659581) * Don't save Pandora feedback to last.fm [(lp:636600)](https://bugs.launchpad.net/pithos/+bug/636600) * Minor bugfixes [(lp:670131)](https://bugs.launchpad.net/pithos/+bug/670131) [(lp:658230)](https://bugs.launchpad.net/pithos/+bug/658230) ### 0.3.6 2010-11-06 * New Pandora encryption keys [(lp:671265)](https://bugs.launchpad.net/pithos/+bug/671265) ### 0.3.5 2010-10-30 * Minor bugfixes [(lp:625095)](https://bugs.launchpad.net/pithos/+bug/625095) [(lp:628923)](https://bugs.launchpad.net/pithos/+bug/628923) [(lp:626980)](https://bugs.launchpad.net/pithos/+bug/626980) * Run properly on multi-user systems [(lp:667896)](https://bugs.launchpad.net/pithos/+bug/667896) * Screensaver pause plugin (by Matthew Gregg) * Integrate volume control with PulseAudio [(lp:650515)](https://bugs.launchpad.net/pithos/+bug/650515) * New Pandora encryption keys [(lp:655507)](https://bugs.launchpad.net/pithos/+bug/625095) ### 0.3.1 2010-08-26 * Send song ratings to Last.fm [(lp:621360)](https://bugs.launchpad.net/pithos/+bug/621360) * Fixes to keybinder media key support * Better error messages * Dump debug log and XML to /tmp/pithos.debug.log for better debugging and bug reports * Don't crash if QuickMix is empty [(lp:622864)](https://bugs.launchpad.net/pithos/+bug/622864) ### 0.3 2010-08-19 * Last.fm Scrobbling support [(lp:609246)](https://bugs.launchpad.net/pithos/+bug/609246) * Rewrite Libpiano in Python to avoid C dependency * Raise existing window if pithos command is run when already open [(lp:610574)](https://bugs.launchpad.net/pithos/+bug/610574) * Support bookmarking of songs and artists * Volume control (by Stephen Ostrow) [(lp:617597)](https://bugs.launchpad.net/pithos/+bug/617597) * Show song info in window title (by Stephen Ostrow) [(lp:614851)](https://bugs.launchpad.net/pithos/+bug/614851) * Double click a future song to play it (by Stephen Ostrow) [(lp:614852)](https://bugs.launchpad.net/pithos/+bug/614852) * Make stations dialog sortable (by Stephen Ostrow) [(lp:615415)](https://bugs.launchpad.net/pithos/+bug/615415) * Notification shown when play/pause media keys are pressed [(lp:614821)](https://bugs.launchpad.net/pithos/+bug/614821) * Other bugfixes [(lp:615160)](https://bugs.launchpad.net/pithos/+bug/615160) [(lp:614850)](https://bugs.launchpad.net/pithos/+bug/614850) ### 0.2.1 2010-08-02 * Ellipsize long song titles / artist names [(lp:610859)](https://bugs.launchpad.net/pithos/+bug/610859) * Request Email instead of Username [(lp:609265)](https://bugs.launchpad.net/pithos/+bug/609265) ### 0.2 2010-07-24 * Notification icon (by Vince Spicer and Vinod Khare) * User interface improvements * DBUS API ### 0.1 January 2010 * Play / Pause / Next Song * Switching stations * Remembering user name and password * Cover Art * Thumbs Up / Thumbs Down / Tired of this song * Notification popup with song info * Launching pandora.com song info page and station page * Reconnecting when pandora session times out * Editing QuickMix * Creating stations * Media Key support * Proxy support pithos_0.3.17/README.md000066400000000000000000000007431175056731700144710ustar00rootroot00000000000000Pithos ------ Pithos is a native Pandora Radio client for Linux. It's much more lightweight than the Pandora.com web client, and integrates with desktop features such as media keys, notifications, and the sound menu. For screenshots, install instructions and more, see [the Pithos home page](http://kevinmehall.net/p/pithos). [Bugs are tracked on Launchpad](http://bugs.launchpad.net/pithos). License: GNU GPLv3+ Pithos is not affiliated with or endorsed by Pandora Media, Inc. pithos_0.3.17/bin/000077500000000000000000000000001175056731700137565ustar00rootroot00000000000000pithos_0.3.17/bin/pithos000077500000000000000000001006411175056731700152140ustar00rootroot00000000000000#!/usr/bin/python # -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE import sys import os, time import gtk, gobject, pango # optional Launchpad integration # this shouldn't crash if not found as it is simply used for bug reporting try: import LaunchpadIntegration launchpad_available = True except: launchpad_available = False import gst import cgi import webbrowser import os import urllib import dbus from dbus.mainloop.glib import DBusGMainLoop DBusGMainLoop(set_as_default=True) # Check if we are working in the source tree or from the installed # package and mangle the python path accordingly realPath = os.path.realpath(sys.argv[0]) # If this file is run from a symlink, it needs to follow the symlink if os.path.dirname(realPath) != ".": if realPath[0] == "/": fullPath = os.path.dirname(realPath) else: fullPath = os.getcwd() + "/" + os.path.dirname(realPath) else: fullPath = os.getcwd() sys.path.insert(0, os.path.dirname(fullPath)) from pithos import AboutPithosDialog, PreferencesPithosDialog, StationsDialog from pithos.pithosconfig import get_data_file, getdatapath, VERSION from pithos.gobject_worker import GObjectWorker from pithos.plugin import load_plugins from pithos.dbus_service import PithosDBusProxy, try_to_raise from pithos.sound_menu import PithosSoundMenu from pithos.pandora import * def openBrowser(url): print "Opening %s"%url webbrowser.open(url) try: os.wait() # workaround for http://bugs.python.org/issue5993 except: pass def buttonMenu(button, menu): def cb(button): allocation = button.get_allocation() x, y = button.window.get_origin() x += allocation.x y += allocation.y + allocation.height menu.popup(None, None, (lambda *ignore: (x, y, True)), 1, gtk.get_current_event_time()) button.connect('clicked', cb) ALBUM_ART_SIZE = 96 ALBUM_ART_X_PAD = 6 class CellRendererAlbumArt(gtk.GenericCellRenderer): def __init__(self): self.__gobject_init__() self.icon = None self.pixbuf = None self.rate_bg = gtk.gdk.pixbuf_new_from_file(os.path.join(getdatapath(), 'media', 'rate_bg.png')) __gproperties__ = { 'icon': (str, 'icon', 'icon', '', gobject.PARAM_READWRITE), 'pixbuf': (gtk.gdk.Pixbuf, 'pixmap', 'pixmap', gobject.PARAM_READWRITE) } def do_set_property(self, pspec, value): setattr(self, pspec.name, value) def do_get_property(self, pspec): return getattr(self, pspec.name) def on_get_size(self, widget, cell_area): return (0, 0, ALBUM_ART_SIZE + ALBUM_ART_X_PAD, ALBUM_ART_SIZE) def on_render(self, window, widget, background_area, cell_area, expose_area, flags): if self.pixbuf: window.draw_pixbuf(None, self.pixbuf, 0, 0, cell_area.x, cell_area.y, width=-1, height=-1, dither=gtk.gdk.RGB_DITHER_NORMAL, x_dither=0, y_dither=0) if self.icon: x = cell_area.x+(cell_area.width-self.rate_bg.get_width()) - ALBUM_ART_X_PAD # right y = cell_area.y+(cell_area.height-self.rate_bg.get_height()) # bottom window.draw_pixbuf(None, self.rate_bg, 0, 0, x, y, width=-1, height=-1, dither=gtk.gdk.RGB_DITHER_NORMAL, x_dither=0, y_dither=0) icon = widget.style.lookup_icon_set(self.icon) pixbuf = icon.render_icon(widget.style, widget.get_direction(), gtk.STATE_ACTIVE, gtk.ICON_SIZE_MENU, widget, detail=None) x = cell_area.x+(cell_area.width-pixbuf.get_width())-5 - ALBUM_ART_X_PAD # right y = cell_area.y+(cell_area.height-pixbuf.get_height())-5 # bottom window.draw_pixbuf(None, pixbuf, 0, 0, x, y, width=-1, height=-1, dither=gtk.gdk.RGB_DITHER_NORMAL, x_dither=0, y_dither=0) def get_album_art(url, proxy, *extra): proxies = {"http": proxy} if proxy else {} content = urllib.urlopen(url, proxies=proxies).read() l = gtk.gdk.PixbufLoader() l.set_size(ALBUM_ART_SIZE, ALBUM_ART_SIZE) l.write(content) l.close() return (l.get_pixbuf(),) + extra class PithosWindow(gtk.Window): __gtype_name__ = "PithosWindow" __gsignals__ = { "song-changed": (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), "song-ended": (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), "song-rating-changed": (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), "play-state-changed": (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, (gobject.TYPE_BOOLEAN,)), "user-changed-play-state": (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, (gobject.TYPE_BOOLEAN,)), } def __init__(self): """__init__ - This function is typically not called directly. Creation a PithosWindow requires redeading the associated ui file and parsing the ui definition extrenally, and then calling PithosWindow.finish_initializing(). Use the convenience function NewPithosWindow to create PithosWindow object. """ pass def finish_initializing(self, builder, cmdopts): """finish_initalizing should be called after parsing the ui definition and creating a PithosWindow object with it in order to finish initializing the start of the new PithosWindow instance. """ self.cmdopts = cmdopts #get a reference to the builder and set up the signals self.builder = builder self.builder.connect_signals(self) global launchpad_available if False and launchpad_available: # Disable this # see https://wiki.ubuntu.com/UbuntuDevelopment/Internationalisation/Coding for more information # about LaunchpadIntegration helpmenu = self.builder.get_object('menu_options') if helpmenu: LaunchpadIntegration.set_sourcepackagename('pithos') LaunchpadIntegration.add_items(helpmenu, 0, False, True) else: launchpad_available = False self.prefs_dlg = PreferencesPithosDialog.NewPreferencesPithosDialog() self.preferences = self.prefs_dlg.get_preferences() if self.prefs_dlg.fix_perms(): # Changes were made, save new config variable self.prefs_dlg.save() self.init_core() self.init_ui() self.plugins = {} load_plugins(self) self.dbus_service = PithosDBusProxy(self) self.sound_menu = PithosSoundMenu(self) if not self.preferences['username']: self.show_preferences(is_startup=True) self.set_proxy() self.set_audio_format() self.pandora_connect() def init_core(self): # Song object display text icon album art self.songs_model = gtk.ListStore(gobject.TYPE_PYOBJECT, str, str, gtk.gdk.Pixbuf) # Station object station name self.stations_model = gtk.ListStore(gobject.TYPE_PYOBJECT, str) self.player = gst.element_factory_make("playbin2", "player") self.player.props.flags |= (1 << 7) # enable progressive download (GST_PLAY_FLAG_DOWNLOAD) bus = self.player.get_bus() bus.add_signal_watch() bus.connect("message::eos", self.on_gst_eos) bus.connect("message::buffering", self.on_gst_buffering) bus.connect("message::error", self.on_gst_error) self.time_format = gst.Format(gst.FORMAT_TIME) self.stations_dlg = None self.playing = False self.current_song_index = None self.current_station = None self.current_station_id = self.preferences['last_station_id'] self.buffer_percent = 100 self.auto_retrying_auth = False self.have_stations = False self.playcount = 0 self.gstreamer_errorcount_1 = 0 self.gstreamer_errorcount_2 = 0 self.gstreamer_error = '' self.waiting_for_playlist = False self.start_new_playlist = False self.pandora = make_pandora(self.cmdopts.test) self.worker = GObjectWorker() self.art_worker = GObjectWorker() aa = gtk.gdk.pixbuf_new_from_file(os.path.join(getdatapath(), 'media', 'album_default.png')) self.default_album_art = aa.scale_simple(ALBUM_ART_SIZE, ALBUM_ART_SIZE, gtk.gdk.INTERP_BILINEAR) def init_ui(self): gobject.set_application_name("Pithos") gtk.window_set_default_icon_name('pithos') os.environ['PULSE_PROP_media.role'] = 'music' self.playpause_button = self.builder.get_object('playpause_button') self.volume = self.builder.get_object('volume') self.volume.set_property("value", float(self.preferences['volume'])) self.statusbar = self.builder.get_object('statusbar1') self.song_menu = self.builder.get_object('song_menu') self.song_menu_love = self.builder.get_object('menuitem_love') self.song_menu_unlove = self.builder.get_object('menuitem_unlove') self.song_menu_ban = self.builder.get_object('menuitem_ban') self.song_menu_unban = self.builder.get_object('menuitem_unban') self.songs_treeview = self.builder.get_object('songs_treeview') self.songs_treeview.set_model(self.songs_model) title_col = gtk.TreeViewColumn() def bgcolor_data_func(column, cell, model, iter): if model.get_value(iter, 0) is self.current_song: bgcolor = column.get_tree_view().get_style().mid[gtk.STATE_NORMAL] else: bgcolor = column.get_tree_view().get_style().base[gtk.STATE_NORMAL] cell.set_property("cell-background-gdk", bgcolor) render_icon = CellRendererAlbumArt() title_col.pack_start(render_icon, expand=False) title_col.add_attribute(render_icon, "icon", 2) title_col.add_attribute(render_icon, "pixbuf", 3) title_col.set_cell_data_func(render_icon, bgcolor_data_func) render_text = gtk.CellRendererText() render_text.props.ellipsize = pango.ELLIPSIZE_END title_col.pack_start(render_text, expand=True) title_col.add_attribute(render_text, "markup", 1) title_col.set_cell_data_func(render_text, bgcolor_data_func) self.songs_treeview.append_column(title_col) self.songs_treeview.connect('button_press_event', self.on_treeview_button_press_event) self.stations_combo = self.builder.get_object('stations') self.stations_combo.set_model(self.stations_model) render_text = gtk.CellRendererText() self.stations_combo.pack_start(render_text, expand=True) self.stations_combo.add_attribute(render_text, "text", 1) self.stations_combo.set_row_separator_func(lambda model, iter: model.get_value(iter, 0) is None) buttonMenu(self.builder.get_object("toolbutton_options"), self.builder.get_object("menu_options")) def worker_run(self, fn, args=(), callback=None, message=None, context='net'): if context and message: self.statusbar.push(self.statusbar.get_context_id(context), message) if isinstance(fn,str): fn = getattr(self.pandora, fn) def cb(v): if context: self.statusbar.pop(self.statusbar.get_context_id(context)) if callback: callback(v) def eb(e): if context and message: self.statusbar.pop(self.statusbar.get_context_id(context)) def retry_cb(): self.auto_retrying_auth = False if fn is not self.pandora.connect: self.worker_run(fn, args, callback, message, context) if isinstance(e, PandoraAuthTokenInvalid) and not self.auto_retrying_auth: self.auto_retrying_auth = True logging.info("Automatic reconnect after invalid auth token") self.pandora_connect("Reconnecting...", retry_cb) elif isinstance(e, PandoraAPIVersionError): self.api_update_dialog() elif isinstance(e, PandoraError): self.error_dialog(e.message, retry_cb, submsg=e.submsg) else: logging.warn(e.traceback) self.worker.send(fn, args, cb, eb) def set_proxy(self): self.worker_run('set_proxy', (self.preferences['proxy'],)) def set_audio_format(self): self.worker_run('set_audio_format', (self.preferences['audio_format'],)) def pandora_connect(self, message="Logging in...", callback=None): args = (self.preferences['username'], self.preferences['password']) def pandora_ready(*ignore): logging.info("Pandora connected") self.process_stations(self) if callback: callback() self.worker_run('connect', args, pandora_ready, message, 'login') def process_stations(self, *ignore): self.stations_model.clear() self.current_station = None selected = None for i in self.pandora.stations: if i.isQuickMix and i.isCreator: self.stations_model.append((i, "QuickMix")) self.stations_model.append((None, 'sep')) for i in self.pandora.stations: if not (i.isQuickMix and i.isCreator): self.stations_model.append((i, i.name)) if i.id == self.current_station_id: logging.info("Restoring saved station: id = %s"%(i.id)) selected = i if not selected: selected=self.stations_model[0][0] self.station_changed(selected, reconnecting = self.have_stations) self.have_stations = True @property def current_song(self): if self.current_song_index is not None: return self.songs_model[self.current_song_index][0] def start_song(self, song_index): songs_remaining = len(self.songs_model) - song_index if songs_remaining <= 0: # We don't have this song yet. Get a new playlist. return self.get_playlist(start = True) elif songs_remaining == 1: # Preload next playlist so there's no delay self.get_playlist() prev = self.current_song self.stop() self.current_song_index = song_index if prev: self.update_song_row(prev) if not self.current_song.is_still_valid(): self.current_song.message = "Playlist expired" self.update_song_row() return self.next_song() if self.current_song.tired or self.current_song.rating == RATE_BAN: return self.next_song() logging.info("Starting song: index = %i"%(song_index)) self.buffer_percent = 100 self.player.set_property("uri", self.current_song.audioUrl) self.play() self.playcount += 1 self.current_song.start_time = time.time() self.songs_treeview.scroll_to_cell(song_index, use_align=True, row_align = 1.0) self.songs_treeview.set_cursor(song_index, None, 0) self.set_title("Pithos - %s by %s" % (self.current_song.title, self.current_song.artist)) self.emit('song-changed', self.current_song) def next_song(self, *ignore): self.start_song(self.current_song_index + 1) def user_play(self, *ignore): self.play() self.emit('user-changed-play-state', True) def play(self): if not self.playing: self.playing = True self.player.set_state(gst.STATE_PLAYING) gobject.timeout_add_seconds(1, self.update_song_row) self.playpause_button.set_stock_id(gtk.STOCK_MEDIA_PAUSE) self.update_song_row() self.emit('play-state-changed', True) def user_pause(self, *ignore): self.pause() self.emit('user-changed-play-state', False) def pause(self): self.playing = False self.player.set_state(gst.STATE_PAUSED) self.playpause_button.set_stock_id(gtk.STOCK_MEDIA_PLAY) self.update_song_row() self.emit('play-state-changed', False) def stop(self): prev = self.current_song if prev and prev.start_time: prev.finished = True try: prev.duration = self.player.query_duration(self.time_format, None)[0] / 1000000000 prev.position = self.player.query_position(self.time_format, None)[0] / 1000000000 except gst.QueryError: prev.duration = prev.position = None self.emit("song-ended", prev) self.playing = False self.player.set_state(gst.STATE_NULL) self.emit('play-state-changed', False) def playpause(self, *ignore): if self.playing: self.pause() else: self.play() def playpause_notify(self, *ignore): if self.playing: self.user_pause() else: self.user_play() def get_playlist(self, start = False): self.start_new_playlist = self.start_new_playlist or start if self.waiting_for_playlist: return if self.gstreamer_errorcount_1 >= self.playcount and self.gstreamer_errorcount_2 >=1: logging.warn("Too many gstreamer errors. Not retrying") self.waiting_for_playlist = 1 self.error_dialog(self.gstreamer_error, self.get_playlist) return def art_callback(t): pixbuf, song, index = t if index%s\nby %s\nfrom %s\n%s"%(title, artist, album, msg) def song_icon(self, song): if song.tired: return gtk.STOCK_JUMP_TO if song.rating == RATE_LOVE: return gtk.STOCK_ABOUT if song.rating == RATE_BAN: return gtk.STOCK_CANCEL def update_song_row(self, song = None): if song is None: song = self.current_song if song: self.songs_model[song.index][1] = self.song_text(song) self.songs_model[song.index][2] = self.song_icon(song) return self.playing def stations_combo_changed(self, widget): index = widget.get_active() if index>=0: self.station_changed(self.stations_model[index][0]) def format_time(self, time_int): time_int = time_int / 1000000000 s = time_int % 60 time_int /= 60 m = time_int % 60 time_int /= 60 h = time_int if h: return "%i:%02i:%02i"%(h,m,s) else: return "%i:%02i"%(m,s) def selected_song(self): sel = self.songs_treeview.get_selection().get_selected() if sel: return self.songs_treeview.get_model().get_value(sel[1], 0) def love_song(self, song=None): song = song or self.current_song def callback(l): self.update_song_row(song) self.emit('song-rating-changed', song) self.worker_run(song.rate, (RATE_LOVE,), callback, "Loving song...") def ban_song(self, song=None): song = song or self.current_song def callback(l): self.update_song_row(song) self.emit('song-rating-changed', song) self.worker_run(song.rate, (RATE_BAN,), callback, "Banning song...") if song is self.current_song: self.next_song() def unrate_song(self, song=None): song = song or self.current_song def callback(l): self.update_song_row(song) self.emit('song-rating-changed', song) self.worker_run(song.rate, (RATE_NONE,), callback, "Removing song rating...") def tired_song(self, song=None): song = song or self.current_song def callback(l): self.update_song_row(song) self.emit('song-rating-changed', song) self.worker_run(song.set_tired, (), callback, "Putting song on shelf...") if song is self.current_song: self.next_song() def bookmark_song(self, song=None): song = song or self.current_song self.worker_run(song.bookmark, (), None, "Bookmarking...") def bookmark_song_artist(self, song=None): song = song or self.current_song self.worker_run(song.bookmark_artist, (), None, "Bookmarking...") def on_menuitem_love(self, widget): self.love_song(self.selected_song()) def on_menuitem_ban(self, widget): self.ban_song(self.selected_song()) def on_menuitem_unrate(self, widget): self.unrate_song(self.selected_song()) def on_menuitem_tired(self, widget): self.tired_song(self.selected_song()) def on_menuitem_info(self, widget): song = self.selected_song() openBrowser(song.songDetailURL) def on_menuitem_bookmark_song(self, widget): self.bookmark_song(self.selected_song()) def on_menuitem_bookmark_artist(self, widget): self.bookmark_song_artist(self.selected_song()) def on_treeview_button_press_event(self, treeview, event): x = int(event.x) y = int(event.y) time = event.time pthinfo = treeview.get_path_at_pos(x, y) if pthinfo is not None: path, col, cellx, celly = pthinfo treeview.grab_focus() treeview.set_cursor( path, col, 0) if event.button == 3: rating = self.selected_song().rating self.song_menu_love.set_property("visible", rating != RATE_LOVE); self.song_menu_unlove.set_property("visible", rating == RATE_LOVE); self.song_menu_ban.set_property("visible", rating != RATE_BAN); self.song_menu_unban.set_property("visible", rating == RATE_BAN); self.song_menu.popup( None, None, None, event.button, time) return True if event.button == 1 and event.type == gtk.gdk._2BUTTON_PRESS: logging.info("Double clicked on song %s", self.selected_song().index) if self.selected_song().index <= self.current_song_index: return False self.start_song(self.selected_song().index) def on_volume_change_event(self, volumebutton, value): self.player.set_property("volume", value) self.preferences['volume'] = value def station_properties(self, *ignore): openBrowser(self.current_station.info_url) def open_web_site(self, *ignore): openBrowser("http://kevinmehall.net/p/pithos?utm_source=pithos&utm_medium=app&utm_campaign=%s"%VERSION) def report_bug(self, *ignore): openBrowser("https://bugs.launchpad.net/pithos") def about(self, widget, data=None): """about - display the about box for pithos """ about = AboutPithosDialog.NewAboutPithosDialog() about.set_version(VERSION) response = about.run() about.destroy() def show_preferences(self, widget=None, data=None, is_startup=False): """preferences - display the preferences window for pithos """ old_prefs = dict(self.preferences) response = self.prefs_dlg.run() self.prefs_dlg.hide() if response == gtk.RESPONSE_OK: self.preferences = self.prefs_dlg.get_preferences() if not is_startup: if self.preferences['proxy'] != old_prefs['proxy']: self.set_proxy() if self.preferences['audio_format'] != old_prefs['audio_format']: self.set_audio_format() if ( self.preferences['username'] != old_prefs['username'] or self.preferences['password'] != old_prefs['password']): self.pandora_connect() load_plugins(self) def stations_dialog(self, *ignore): if self.stations_dlg: self.stations_dlg.present() else: self.stations_dlg = StationsDialog.NewStationsDialog(self) self.stations_dlg.show_all() def refresh_stations(self, *ignore): self.worker_run(self.pandora.get_stations, (), self.process_stations, "Refreshing stations...") def bring_to_top(self, *ignore): self.show() self.present() def quit(self, widget=None, data=None): """quit - signal handler for closing the PithosWindow""" self.destroy() def on_destroy(self, widget, data=None): """on_destroy - called when the PithosWindow is close. """ self.stop() self.preferences['last_station_id'] = self.current_station_id self.prefs_dlg.save() gtk.main_quit() def NewPithosWindow(options): """NewPithosWindow - returns a fully instantiated PithosWindow object. Use this function rather than creating a PithosWindow directly. """ #look for the ui file that describes the ui ui_filename = os.path.join(getdatapath(), 'ui', 'PithosWindow.ui') if not os.path.exists(ui_filename): ui_filename = None builder = gtk.Builder() builder.add_from_file(ui_filename) window = builder.get_object("pithos_window") window.finish_initializing(builder, options) return window if __name__ == "__main__": import logging, optparse parser = optparse.OptionParser(version="Pithos %s"%(VERSION)) parser.add_option("-v", "--verbose", action="store_true", dest="verbose", help="Show debug messages") parser.add_option("-t", "--test", action="store_true", dest="test", help="Use a mock web interface instead of connecting to the real Pandora server") (options, args) = parser.parse_args() if not options.test and try_to_raise(): print "Raised existing Pithos instance" else: #set the logging level to show debug messages if options.verbose: logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(module)s:%(funcName)s:%(lineno)d - %(message)s') else: logging.basicConfig(level=logging.WARNING) logging.info("Pithos %s"%VERSION) window = NewPithosWindow(options) window.show() gtk.main() pithos_0.3.17/data/000077500000000000000000000000001175056731700141175ustar00rootroot00000000000000pithos_0.3.17/data/icons/000077500000000000000000000000001175056731700152325ustar00rootroot00000000000000pithos_0.3.17/data/icons/scalable/000077500000000000000000000000001175056731700170005ustar00rootroot00000000000000pithos_0.3.17/data/icons/scalable/apps/000077500000000000000000000000001175056731700177435ustar00rootroot00000000000000pithos_0.3.17/data/icons/scalable/apps/pithos-mono.svg000066400000000000000000000350411175056731700227430ustar00rootroot00000000000000 image/svg+xml pithos_0.3.17/data/icons/scalable/apps/pithos.svg000066400000000000000000000315621175056731700220010ustar00rootroot00000000000000 pithos_0.3.17/data/media/000077500000000000000000000000001175056731700151765ustar00rootroot00000000000000pithos_0.3.17/data/media/album_default.png000066400000000000000000000100151175056731700205050ustar00rootroot00000000000000‰PNG  IHDR‚‚ŠýsBIT|dˆ pHYs × ×B(›xtEXtSoftwarewww.inkscape.org›î<ŠIDATxœí]Y‚ì¦=ªx¯ÙÃÛ{ZïÃ4q Ý}¤ „ÀHhÂÕ—þþûŒÂƒ~z …_€£ô Ÿ^@áw \C@Y„BÃÁe ”k(8ªˆP*F(4 r …r …†r ÀÁå (‹Ph(E(¨»†BC]C”E(4TŒPPŠPh¨:B@Y„BCÝ5Ô‹)…†zU­ b„BC¹†€  u×PPw …†  ªÄ\h(‹PPŠPh¨`± ÒÇBC” ªÄ\h¨`± ®¡ å *X,4TŒPP1¥ÐP®¡ \C¡¡î ꮡÐP¡ b„BC)B@½³Xh(‹PP¥BC•˜ Ê5Ê5Ô‹)…†zU­ b„BCÝ5ÇO/àUüiÙ/ÿÒèüW]CÿiB}Ï<ãw(Ï·¹† ø¥‡åç±³w/nÞ[]ß{¢wþ{Uuµ÷;JrÛ",'¼Åéü´¦}jþï܆’,c„ï9á?-Ü߀;{ð^¥¾áýx?ã?×Y<çׯþ9Eé·¯éÙë’¹#Ü;k¥o¶8|guwžy›íŠéœÉÍ`ñ¹M}å· _Å«ë)ÒVÖu©,3&¼ºkøl¹»azì¿Å%xXîîK²«Og¤o÷wyW »Â¾/à‹FœÒÙ}~-ÌÕÞfJ’Í¡ù-ÓÇÙÍ)].hÍÇÒ½_/ù<9î]q;ߺúåËÁn*‰"31ÂÕ¯„~-œ5ÁŽpŸÜüÉ’ãT@–¶3âZix«¨”ÉÁ8è‘~MèÙ˜¿õÀý9ö—ó9$ë½´1Q$¢5Ÿ+ý[¹WÕ¼àWf}6çLØóxtžº®„ºRª[ó¿Ó}™ÑgÒÛIç“qD4“ÎãvhØÂtè ÅxÚbldCÞŸæ]rtCü3ÛØË 5‘b®,4ˆÃ<™*hž”kHOæ†vŠrÒì2EÑ OãèBAÉ Ì³¼úÕøl3²Mͦ¹Üü›ŠpWodþ»YÃ3Eaæ8PÆÆmމ•ÐÂÏd#ýÇJpKs½¸æèÙ¦ÞkJ×2çb…Ý‚ÞÔWgac¯, Jr¥ Òo^gŸù,Ý„¦R Y¼13ë1Èæ4oCðç3:!ô><C™â\X[ÅE+‰q=Á‚ˆEHÒÇTˆÁNž4‹Èú¼póÀ2iôóçŸÁ…Iˆ1@BRÛ Å:§ úÛJQ(Ê>ÌyZf” Ónvlëg™ÁLØ åû"“ ÍzI9í>)€‰ÌgA çÝÝ×›hÏÒò8º×OS%d§Rã÷éH)äñ /ä(+H2m©À/„½òZ9¼’vv{”Ì{ïs-KëIȰMÌ:0UJãJÈÜÊÎWJb,B²¢þ†Ò©'^ð! tJ ¿d–äž[1åíÉš2šëO¶M¸G¶Ê}æ”KËÌ0™ õæ¶:Í»ò+Ò&ëev ‚!CY3ã Çc_ð¡{k.‘G.äX#ô!ìëlXBðbÀpQÓWù<]hHû¬o ¹µÊCªSø ^M9˜†krÁï }&p²_S¦-Ð*l’z-ÅÆy Ù+ )ë»%}ÑÄIôwºCô?…¤„†‚t‹Ó:Ľ J„EFðÁWk!Oæ^ì¡ç«r¸°~¾O#ïíÞ8+9r|oh= ÿa{š¾˜ÖÏA9€,^ ~×JìZˆt¼"X¹€¨7â·¾g1;í½?dèfiÝi5\¤§ÚØ´Y¢¬¿; 2¬÷¾ƒç¢ÉIÈžp B3‚QBŽqÆl\ÂÇŒK„ûtáý&!·>QüÙkhl¾åÙH¨pJáÜþˆ˜“¾‘™Œ‚yAŽ%E 1z.po9,,#Éj±}îVòU5ò`09êÆJ¸â1Û¾*h•†5ÎhXR™)«SŸðd¶íŽ=Ì©fßX+Œ™u˜ \±ãÖÖažilº„oвkÁLa´P³ À·kk2qþvS+ˆ™K¹ÃÛJÙ©×'~?¬”$Ò¿Ó:øÞÜ%|êÚúêîÈ^WÇ1ä,@ïWVà4ߪ˜Ô¸q+ s/{¬”¤O:öÒŒÉF§?MdBjŸ(‚²ð]°>8œeWÄ3ÙÃ…>,…ÿ©ì!*Ƙh^'°¦ÿäÃŽŒ sã4o âL;u9ƵÏL}<·xñ z¸ÓOJˆ"xâ×Áä*ë¸ &u›ÇR!Þw1þÌû€v¼6­M}õWÂf^ee¨µi2'8 Â|º1Ž;O¡ÚãAt)|Û¯iöIY\~¡Çú¶Hë¾'j±§›ŠÈ.4>'0¥ c…ì-'q'=NP|F@8‚J±&ƒ~ð9þz®…?|´²Þ‹B¢ m°8³ó19EÒÿ²~wE{É)²…s±¼ì„ øŒ†~•- zêVã¼kP±€‰B¬`-ƒ¦£Ûe­y+øö÷Xƒ÷ ó÷†Odz­ÂÄê…ØÀ$ÝUèbQð@-&`eNˆÇ:?æ,îß÷÷ ]Ì`ƒIë†wÉ\~ç-B¤šÐ¦·É+麖p~çîº%P$KÍÈÁgÆñ=‚¯÷ÿ× žþˆU`r¢oÆ™:ìê§UæºÖØè8Û‹ä}„%…¶Í}HmÀô°ËŽ”•8ûj¶\›þU™¹,¼ª"öåfñCÒ·‹‹ &íÞÿï(x¸OPj+R:sEª-_)Ÿï,>”0c°]ƒ1ó7„?úÎs-SÉëáÛ¼î`YbNæZQÛ«:r¢•‚ø´Þû35uOvàìw–‚ ?‚¥èìL›?õñÞAuoÅùC;¼Un¸ä7´EßþŸ‘ë”QÃÔ!H•…•KÐíÂK^ë騩O·—%Í8¼À»pJÐû p7@ÜúJvw»¬@¾‚Ù/ZfXû™—#e•9ÊÕ>î¼…©(wÙÊ…CÞYlÂÕ?ÛU‚˜ ?{h‡+a¿,ܻó€ÿ_fÞá”#y¿¡ Áùµgd¼ùÔ*LçŸÎmUù~+y7ùI×ýÀ쉣Ï‘ á@•÷)?têû÷ȯ~ÚÞé&ÝÑí„H2²ÖõH)™‡9ÑtÍ=¢úd°ºN\½¶¦uCúõb~‹%x·ž\¿½4¼Ë&&|£ÌÉàQÜ@‹Ô¥‚ñØÄ$1@*z¤¦4ʧCߺ7WÐVÈŽ& ÒJlpŒ;’Têó•Wž à͘(ÖuFa-á´Ûyû3mõ’;Ñøyh(•TE£¶ þL<¥þÂé²ÀoS Rv[S~Ø-Ü€I/”B”«N]R>çI¬RÚ¸šŸþŒ÷ñ÷ˆ÷søég<˜Ä Y"àK»áø!Aƒ7ÿPãýW]âô>/Ä—É–ÌÜÊŒî×ábá«Ã«oÏï€q+fU¶à JqîVR.Ÿ¬XìTÊd¢Œ2\§ö¯œ´ùž±èÙßd¾eñ~=VÏ“íCØ+޽a“ 8¡;ÀDg€ñ…3 uC%%Ë3·]Lµ>@âîyïˆbÇBûM´ÅÈ7Bƒü)Hð‹Bƒå½ó/¿Ø12v ý°³üÀeŒ•C}ŒvêW•¬\ éˆÀ¦…Ù_НRŒÅÎ^ܰ¼â<‚Ý­üλ†sB7nE”t®-Cæ,Ýø“‰U§nw9±ÌŒ/´Tºï ‘Žˆ2ôê”d<ÞÌíÐTK•5[ÃtGt‰t<"Í¥»èJHí´ò/·¸^žͧ‰¦Ï»do gÖíc¦ã«Q=ZÊñ7ÀíG–ÚèŸhQ³5’±ôן€^ 5?ÄÔ+6)V²#˜—ció½µùcWÇVqÐõÊ$í:4ó¿að± |?Ny·Øèù¤£”|'ÕØTõÚÔ cÀXÎG¬ spÁhœLâfLè£5ÙËØU¶!ƒlEkKpRä·¸wÈÞeÊ ß…îø§‘N«ðOûL8„ZzqžþvsÕ_sÚqÏ=N ¹ Qß‹³_ [?nyÌ6jåûæž±.W®( –c²`QëKDHVø&a,sBí¿ÔÙŸB‡mkç~l`û[ýõVšö‰†PýÝz§¦|#|Küõps%èw»~wÒtË;º„Y ©ã€Þã\…vÇ?ô¡Çæ”÷BÑÉA÷w ä>cñÙ´]„ÿ¡èÔc<›I¤ë/hK>ÔÑKÚ|™"ØÓ>ú=­šEøúb image/svg+xml pithos_0.3.17/data/media/icon.png000066400000000000000000001215221175056731700166370ustar00rootroot00000000000000‰PNG  IHDR\r¨fsBIT|dˆ pHYs‡‡åñetEXtSoftwarewww.inkscape.org›î< IDATxœì½y˜ÉY&þ~Y÷Ù÷¥«Z÷1£¹4ÏŒÀ`L/Øð`³»€1Çâå2 ϲÛ6 ÈvcŒÅ±¬Œ–cYÀ? ,``|käc¤9$µŽjIÝênõ}Ô]•ñû#"òª¬®j©[XŸžTeFFFFfçû]ñÅ$„À}ºO÷é«“ØÝîÀ}ºO÷éîÑ}pŸîÓW1Ýg÷é>}Ó}pŸîÓW1Ýg÷é>}Ó}pŸîÓW1Ýg÷é>}“q·;pŸîÓVPfh„8àrvt¸p·ûs¯Ò} à>ý›£ÌÐHÀ¼à|fhä­w¹K÷,Ýg÷éß"}#€ÇÔþ’y×]ìÏ=K÷À}ú·HßâSöþÌÐÈ{îxOîq¢ûsîÓ¿%R¶ÿMí ªü\vtøî`—îiºï¼ zÏ3ï zôªßqa!õëÝws%EÇVj°_0`ƹ8ùliËö_½ðÔ‹.àÏg†FrÙÑá_¹ã=»é_»@뜻í{Ï3ïèpÒ›œ p ö.ÜLt n¦0 `Àç\:qòÙêÝëÞ£ÌÐȇ<£Ûûú±¶¸èeÀÛ²£Ãx§ûw¯Ñ½ÎÖøí’€÷<ó`6Ð8¶¶-¼ÿ¤ €ËÌ@oçœ?qòÙ•»Ù±Í¦ÌÐÈeÈ¿'@„ÎþmB`ivµJÅYµà[²£ÃwºyÏнƶð€ü̉ò¹W !^à5¾ÇÞäË>à9Ÿ:qòÙì]îÏ-Sfhd;€kú˜qŽöÞ~@­ZÅÒì „i:/ÉøÚìèðów´£÷Ý `KAâÝϤʥâÓBˆWCþ14QÛïwr7iŠ@2„ów¹?-SfhäÍþ¯>6‚A¤»z¬ó•RËs³ÞËæ<¾pG:yÑÝd[ü_~gW¥\~+€ÿà ÜÆpçW93¤?áãþðÄÉgOÝíάG™¡‘~Z#$Û;]uй5¬--z/½ àÉìèð-ïä=FwƒlðéW?ðs±ÅùÙ7™B|'„x=6Ù9wŸæÂø£'Ÿ»ÛñRfhäï¼A‡cq´wu¢R©¹<ÂkK (ærÞË_ðšìèðÒèê=Cw’Üð^ûÞŸú‘×›¦ùv!Ä·ˆÝÆ=Z¢ûŒÀ¢/øC¿{¯83C#³,‘K¦Ðßß…JÕÄZÁáËs³¨”ëFO? ಣÃÅ;Ñß{î¸Uà¯{ÝÏÿ×;V­V ¯½Åöo‹î3‹fü€gOœ|¶Ò¬òVQfhd'€qgY¢­;º08&fW]õMÓÄÒ͘µºÑÑ¿ðíÙÑáÚVö÷^¡­ÃÞ(ø›Ö?ñ®ŸÈ”Ë¥÷ !ÞJDÔè’­(Õ݇ˆc\0ÎL" cLˆHc ]ƈä9!‚„r_Ó>Â$!ÌZª• Õjµ--Ùuø ?þžgÞñ®'Ÿýø]êÇ#ÞÆ9‚Žd,ˆ ï1†dG'–gg¼ßÊ›|ÀnegïÚ* `Óòý?Ó±´0ÿÓBˆ¼µnIº•gfœ×8çUƹIŒ™Ì0LƹÉ390jŒsÁ$Ø%k"#Å ä)Ø›ºõêZç~ñ]?þX¥\þÿ Ür¯çùã¼Ê8«2nT¹Á«Ì0ª7jĘp‚8lk¼2…r¥%#f^[JÜŒÐɵÏÃò~Œ1U_6Æä½u(—JTÈçy!—£B.Çä¶F•ry«ƒð‹Þ{âä³wÄVòF@Gße:ÑÓésS(”üƒ!ó+Ëȯúº1~$;:ü?6¿·÷m6hõÃj øðÞŸúÑï6ÍÚG ãæ7•ˆ± ã¼Ä8/sècÕÆ”àËc&´¤w‚`ÄIWU0WmqG{ ÅdÛÌ’÷òm:­Aèb›1Æì>Biœð2»uBµR¦üÚ[Y\`K leq‘Õª[)ü—¾ëÄÉgW›Ö¼MÊ ü)€·èc"BGÿ6ÝÓ…¶x®/`j¾ÎóoÑÊÂÊ…º¼!&€ïÈŽß-³fËi3@+àoøð ÿíÿ±Z­ü~à ¨ù-]ÏGT&ÆJ ô%bÒN—°PÀd\$Uq·´çÌ0³D?Ó*¼ÜˆÀˆT»Ì­Ó Æàd´RÚ Œ31âò˜Á¾7ˆSZcŠ·8ú­˜ ¤Æd'×–—ii~Ž/ÎÍÓÒü«”ËMßm‹ôO¾q«„™¡‘ÏCÆ|¸a ­§Çô"60½Ç…k ¯B`yvÕJ]7KÞ˜þç-éø]¦ÍrÞøýÊé}ï~æÉjµòQ« °û6ÎXDy⬠œn @8Zu4¯"™'â®j’ú­Pöº‚+´½®Ñ§íy¦~õ ˜Õ@05ta3"8Àb€B×1¨Y ¤QõI—Òÿ ú ª¨zÒ1¤ÚÚEº½£–Ù/Ÿ}eq‘ݸz•M]¿ÆJÅÛûZðöÛi¤Úæ<`Œ‚ù¾“ÑõÝFD„DG'–oÎÀt‡ ‡üEfhäµÙÑá6³Ã÷m†p«à÷–YÇûÍǯ^¹tÝ·r;”CŽˆ DT#R’»›)ÝX³^­R ø6 F`F\áH‚3&ë[·Ñ€×m2€3iù[ö>ƒR.¬:Ä”Á$lg"cL›sC>s8 ™9˜¬Á¸dQ¶V@ÒtJ Ð~ueVÈ>¨G™Ÿ™¦Éñq6uý:݆©ðƒ'N>ûÑæÕ6N*@ rj5 ‰"ÙÑ×Ý!¤„?}~Åòúý¯”JXžŸêq1 à©ìèð•Íîÿݤ;‘hCà@ײï´ÁOžM ǺM«&á†`4CÄÖˆ˜ ~R*1c æ‘,c–z Ô_JuFDÌÒžcRÃVàWR–1€1€8ˆq»ƒ®q%¶œ@œÀ,´)»‘ F‚1â¨H¥ ‘Ð/1‚ì"æàl$o!´b`Ù ¤úí—t™JaÄÐÝ? yêióoykíá'Ÿ2£ñø­|ïyÏ3︭ћu¨ðjÐ&’€€@{²¹) !žöÚ àÿe†F|„Ò¿^º]Ð̦÷:58&ô±ßüpʬÕ~b=À»/%¢œI4ÆgUÎ8gĹ¼+#Æ$Ö¤Ôä`r#F Œq"âŒq.á…I“wÎ@Œ3‹©0Fœ˜£lJšëÁb†æ2$ÇqV!qf!Ï“Œ@1N&³aNƒPýƒú-#É „4 #dZ ÒüC2<¥h3A¾wË?Á°kï^ñ ßöíæÑ'^%BáÈF¾•¾o#l€¶y t €pw¦Zó#‡£1DI¿S{ümfh$q‹ý¼çèvÀ†z>e^F˜¾qýõµoU'* Φaðyfð*7 bÜ Æ bÌc)FÆçÄ8'Æ'b\©ºŒ'f0Ãà€Þç `Æi B–10ƒSÀbÌ` Έ8 f`ŒÀ9À8 øLéÞ  N.`Á,­C@j2À€“ NÄ)@ ¥]ÒÚˆ¼¥ýhûÚä˜íK`b’¡0ë2í Õ&‹bĈqŽ=‡‹7¾õ;ÄáGRsh‰¾½ÕФíÞƤ€€@,D(À½U})šL!õ,ÀŸg†F¶J›¹£t« àvÀ_'õûµZí€ |ðu*?cy ̆Q1¸A7ˆsN†!7n0âW Ë Œqƒ`7À F†”ÐDœƒ¸AÜàDŒƒ1Æ8çŒ1Nœˆ ézg Ødñãp ðDNg#N‚ 2˜I 2˜3'“œ3Á ÎÈ$Îq€q€8™d@€Á#©%0DLh&!ߊdªŽ=Óš‘óEBëýJÕW2r¤vÀ,?¿eNQ ÄáGÃÓßðp£%òÁV*Ý5ÐX)ß–h}49‘nC ä[ÿõ~?34ÒŠÿ랦;íðÛw1!Ä?õ߉~âãqnHÀË}ë—œ˜Á‰LkÒ< cb'ƒ+›1bÄçDNŒq&myfpWÃR…`ŒøŒãf ΈÒÇ 3n(-€k Æa²€Rù0`’A&8 p&7Û/Å3À!ˆ3Á8„n´´ É F<äŸ[úrDT›Ê2à°€Ï#b\±£õ§f;wáuß4¤}(ëQÿ{žy‡¯n}›T¯(€¶ÿ%#èhÁ`’íà€ßÙïð«·Øß{†nep#Ò¿ð»~ ¹ÆîUûrΪÁ ²O+s*ÜŠQ¾N¬Y(÷:”åYSjµþ¾¡ÀC܈˗®sÕ,“Ø"΄ˆ”€ 6j˜P9ñ”Ñ1Õ}¦FòH_ÓñLLw2ò@=‰0…Ž&ˆL!­¡®VבrB@@¨îÛBÌ)ÐïÛzV÷là“ÃFK¶µƒqŽF ¢6{ö`C ÀK±HÁG¹ÒÚ\b ©Ž.,ÍÎÀ¬Õ]óÎÌÐÈLvtøý·Òé{6ÊnüM™ã|¬V5ëï¸Ò¬V“Â0„ æôp˜‘“_»°€þü5€4'Pq½P–.˜H×gúDD&ôG¯<øÊî5¥ƒ \޵k/> mròÆØû’Í1;@Q³-ÈÀ!š¹Éñ|¦ÏÁ$Fò‘Uß™P·êåT#FÄ”¿Ê¾‡ÖÀÖ~Å&'Ìa è3’ ¹žûÛÑVÀ¿tâä³ÓÍ*ݹ4=œ0¸eÿÛZ0ÐGvj¹åÆçjâÐM¿y$ïË LgG‡ÿçm>Ã]¡­ jü¾ \¨ÕªuÀ÷WJÅ#âÁHdÍë¹¶~ P_ºÅ'$W`׺ê㊠å`r˜O0a1“”] 5fF‚ F&¤þ,„´Z-4èÃu‚jÌB(F¥yLuSh@ ¥LÐZŠIòcÖAÂB@˜&ˆKÉ"“HHMBª&’ €„|xÅå ƒ ;ÌØö ­è <àŸ¿9ƒÏþÃß#¿¶Öä “‘nõ;WA@†[ï­#ÆÜr«ùÖ£@‰öN¬Ì×¥€ßÎ ÌfG‡ÿj£¿Û´ÐHúoø :»z>?yýj 2âÞUËi€åR)^­T"áXt-޵`¡]ý ­(ÕX˜ ¨ —Š@€ò˜Êsn£H_©~Uˆ1 #… á©¥¼rÐÉx…cûœ6Y@ ­ú3È?ÒLA=Ù1~BG" ƈ„ ÉÚLK£BØÌÆâˆä¸»êÓÜFk*’‘3‹SV––pöùS˜Èn(.æ¹TÞu84Üeÿ;i{wç®.lhfh0F<Ýæ—RŒC.?öõÙÑá;: òvi#‘€e-I|Ç9ëøý?ý“¿U«Õ¾³^ú“óÇÕ 7Œj4_ E" øú¬–ðÖGÀ%8”(cDJÍfD dšrxOÊKiÛL¤'H‡“cõ ÜjÜ^jP¶¾Ê“ª½¤ü–Âï`RòX›$ ó:PX® õüLmö¾T2¸j’+Ń3\Iwή0ÍBÆ@Ik†+°3e†p¦æh–ªnœ_]ÃË_þ"²ÎotZu@æÄÉgonä¢f” A.šbQ(C¢­O=ÐÁ”yP3¥)` Ó˜]*`rnÕ'èo}Ê-/¡°æ;¿iÀ«³£Ã/ßêóÜijU¸]ðûo¸¿#³ûƒÙË¿ƒ£ô~L0kUcmy9]Èåj¡H¤ŽÅŠ@À”µ$?r!íibd ÀPê°#Έ8L¡cRj`¨è:"˜CcóŠ) |í$d–-bÊ›ÙÎ9¬™”†~MõT@ÚúBVP¶ƒòå9½0IŠ &äKa$f$“˜v²V4d\ÓJi¥@šúL…A#b@µRÁäø8®^ÃôÄõ[M¶ò‘Í¿¢º%Àçà\Æ4HðûEö©0Â!Ž…•"–VK0[|®X* ³VC©÷žjƒ :žžÚø£ÜyÚÌŒ@~L¢%À{¿ë~äê‡~ñÝ?[,Þ¯y€¨¿¥Y«ñÂÚZ´°¶ ƒÕH,VŠÄã%nò»e XºÒSr˜t¤,ỉ˜Z'‚0U讈12‰CÍ¥™à yR¥6•Ž‘)Õ Kœk“D8_ÐF Pµ½ átk„)È$y¬ÜBk<ƒô!ë]IûŸI§ ¬gÕ#„ê, ÀÔÄ5\»4†ÉñqT«·5™ï,€Ÿ¿Ö¡zÀxCû_eY²ë!W¨Ør¢EŠ·µÃ¬Õüò nð×jòPKŽ‘»I­˜­HÿFR^ÿ6¿où/ÿüð¯”Ëåïu3úîø•Ž©ÀD†B•P4R ‡#ÕP4Z%Lg cÒ ÐÃ~&IT(›¦–ê‚„IŒd° äX¼ ÔdtA©ø& aÅçK‡ˆjBXÎF™¶­ÍleúÑ™~’g©E 9ÂÇÔ/WáÎŒ N‚+“€3‚ÁÔ,ÎÀ9`Yçò«Ë´ps sS7póÆJõóão…n8~â䳛ј—2C#¯†\ÃÀ¢D{'º;Óx`°ÓRÿM˜¦Ó(WMŒ]_Dµf"`0„ƒÆ†ƒÂ4åŠCþ££Þt¯ç¼U  Shd ¬þºóßóŸü]¿û‘_k7kÕ7Y4»·\(°iª”Jr±XÅ"ˆÁp¨ŠÆªáX´ ÇMƹP&8#’^yiHG£šÚÄ¥"ÓäÊQ©©W›dGçò¼ i‰e5©G YB÷W¸ýšM˜–6 ûSvо9Ø«R ‚›¡jJ‚Ô,¢š@ œ«‹óX™›ÂÒÍ)Zœ™B©¸)€·¨Z3— ÎÞ¸UàWÔá-à>1Z8ÅÝôBÕšœú[©šHDÚa,®¶6ý™C²£ ˳uSˆ`2Wâ·øw…ši·#ý[•úMÁúþï9?8ÐÝnj`{@ßP‹Ó|dz gÁ  …kPÄ4Âa„Ã"Š›‚ 0… ±5‰` F5¡ÄÈTû& ËÔ@¼ ¥ (o< gá S>¢î°vË(ŸG0X »ä'Œ¤¤‡¥ûÑù«$>‘0¸ Zi•ÜÊkË(ç–¨¸¶„ÂÊ"ªõêë¦Ñâj®N¿åÿá·4›Nfhäû|ÌYÖÞÛm=)dú’0M( ÀíÌ—ª»¶P÷½ìÛÞ†™…<–s­¿›j¹Œå9ßø©ìèð‡6ü`wˆ63°‘ÔwîßÒvc~õ Kkùozüð‹ÓZ û0iÉÅ80¼J©Ä*¥"b P#<F(já˜0BQ@d„"F’0I³`C 5þ(Œ`ÂdÊ ( G´MOÊÛg?PÜBǨœ¢BŽe˜¦ôቚ‰j%T •Dµ³¸FµÂ2ª¹%ªä—!Ì;§…J¦‰Ï½SˆçîÀ-}|Ìåð³ÿ§æ×|…ÅKoàuíÆ™K³È[ó{Á mXñO.úÁÌÐÈø½šVìv€Tþ[•ú¾[$9·¸¼ôMe:ÚSȯ¬¡¦T·F&&¿?²V¯½fBA@­\¦j¹Ä±ºhE•Ù¿ÊW Œ<"wö*¾þ± þîùqTªÍ¾1I‘xf­ê#ð—™¡‘'²£Ã—[jìÑfÌl¤ú¯Ç@¿üèàç‰ÓsK "˜¦Àà®<ýä#ع£¡PH‡°º6 gµ’{’ò {êÚÙuíkaEÍAÚiÜýk×àÙ¯Í4–VëÜm""´·§ðЃûqäÈ^p}·¼b|Ý)àÖt wFºiym ÊÕñI¼’Ã7ÛÎZÿ{ÄRi„"Q¿Sþ&34Rg²ÜMjÄnGú·b÷¯wÎÉ ö–oûÖÄÅRÚ]V3‚Áöí݉×<ý~è úz»` X9ö4 t'È™‹ØánØr&Ð Ý«L ™ˆaÏîxÕGñà‘}H¥åìÞªš)·–³<èÙ;Ô%_ 5¯ýŸ/VP®6VëMå/1k5|å¥+XX-âkÙ¶¿G¢­PÈïÔ>ŸPÑ‹÷mVJ0ïÛÙ¨Ïìvæì‹)F •JÍtÍ4't´§qøÐ¼ú©Gqäðtv¶[’ÝÍœ€u1¥ ÀyL¬!p¿7pžô‚¹Upß+L  c×®?öyø¶ ô èš#/PSÎYG ®ÔêžÛ`D€ÁÕ0‰ƒ„–Ö‘þP³ª4•‹iÕj ±¨åßÚr 2Õ¸‹d.@û?WhÂÖ ˜Ê--áìå0:R<õÀþþùñõý  „ÂH¤Û±º8ï=eøÓÌÐÈcÙÑák-5¶É´•Kƒm„ 4TÿÐòÊò!ÆÉX ©²ªiòªe§êïîL“=ÝFÙê1÷¤?t‚‹/`’©8’ɘŠTm¨pÒR¥"™A©‚R¹„R©‚R¹ŒR¹‚r©„jµÓ¨™5˜USþšµši}d˜“©ÈAŒÀÕ¾Üäüü@@< " Ø[ €`ÐPsøÝäÆêæƒBªÕ"!K8|ôØñÀ™Ó§¶rM@7 cÜ‘ÄmÿçŠë§,õ¶ºEµZùÕ|îÅzr¯}hþßóW›®4¤)Â4kÈ-/yOuA¦:;:|[k°Ý 5cͤ¿·®ô~åM~Þ:++«_CÄJÅ,@ÞÁÓáÕüí0`©Ï«ðZ@fÉÒ m~6,Ãm¥†AÂ>§“ŠÏa½ IDAT„‚„„•6Þ”œþÔ„ŽtF :¢M¡„ !L ÜÚë¯ýîg~˜´ng½€†çÜç׿ß=ü´Î…‚ä ED#!Ê•jÀk|²A“›A;šùŒz&X3ͦ`õs:©°¶Šp4ŠÓç¦ðê£ÛðêðO_¹†Z­5³.OÀ4Mê—"À³¾§¥†6‘œoª‘ ¿5²ýý¤¿·¼%Sàþ÷Öjµ‡¡<ÿ![Ê€Ô?‰Vç{ÈOè†ísnÃAO»ÕÌA+mB_'-ûQHUÒ~>«EáüqHzÅ|ˆdÆð@€# À0¸šgè¨í\GõßLðûí6¬ã) åW¥Zþ̀>ýïµ´IäÒœ¹ëíÿæ’ÚÇ[ï&!°¶´ˆ‰Ù5ŒO¯ =ÆãúÖ¿ÆC±d á˜ïÒjoË ü؆ÛZ/`«¥KÁ¿üå¯!"Ù· 2Ï[,’°W`öÿ^U3Úî×A>ЀֵA?€ xÖ¸¾õKÖ5ÖõNMD#ت'×Û÷³|—ÃpH~×#C{ð¡Mÿú缿»† ÉÊÕ*ïÛ¥‹¿ÙÿN›F¾ Àð&iÁþ|}.ÎQ)•PÊçñ¥ r¡Ñ®8Žd:½×­KñtBQßhÁg†F^³¡Æn“ô›ºUéߨ|½Í[§.ðǹ??¿ð:Æ»¶÷ $ã1¥sÍlãØÎ¯ckʰ5]ÏÁ$t7¥ÏÐ|8ê8Îif£µájËnQèv´Šai ÞšºYjþF¬¡øTGs»}ðCHÐ*Ç@—Ö”v=vü€ß7‰Ü €9À½ö  ~à2€“ÞÂÜÊJå*¾tá&ˆ€ý;Ú°³gcë$Ú:¬_[Ñðg™¡‘ºu¶ŠZ‰ô“öÎýõ¤¿÷·Éo?÷éOw–J¥‡ Î ADH%bút2Œú€9²ƒ±Üœ¢Xj©¯ÓYi7œª¾®¯<ÜY×â Êÿ@ B8%?Pç°ðyqëªþŽÿ]åë€ß¿¹ ÷Õ:.NÅ¥S¤P,c×¶]ãÛüîºIäbÜì¤Vœu>À d*³ë®zµ «+˜˜]ÅÄM9ñ硽]èLmh±T$Ú;ü–ë†t Þ‘pa+Ó”‡š•­§1lDú¯ËþäÏ>þ½ð{äßÙPê¿ £Ï¬h&;:œðÞ…µU˜µ¾Ó9¿°ð ¡½’$cQh‰îíŽ%á*¿Ó$µ Ö *¦_¨‹ÝM:TÒ@uÖÑû6ˆm¦$¬v´ÓÏݸí!´îï}Å>k„`=#ÞE[çñ÷kT@Å£R¢õv·!•ˆR¢mEZ,߀€#BPjIúû2ˆÈŽÿ9€¿u·+[^B©RÙK2íAÀ`xü`¯e µBD„TGŒúyoÏ üHË Ý"ùõÔOÒoTú{¯ÝÈÆÐÿù³Ÿîì³¼ïíÉ„#¨Æ©ÚË5–f¶jîuÚi¥Ð #‡$TÇÙ%«{&éŠÂ®j ): áõèBfìG_ý-÷5Rï׿ß=¥µP*ãõO=¬‹j ´€†A@^û¿ØÂ:€ ‚€fû?Ïú¥B•r ×o®bj.ˆ…x`pcNA[Ðg…å“*éé–Ñ­Îl&ý7zε=÷éÏtÏÏÏ¿•ˆðè‘= "$b„Ã+ü×dè?¶ƒ#8~Ž pjp€Ï’éŽòzo?Ü€·b Ü×ZÚ÷×aJ¸ýºZ+:üízü…ïnÃ:~%ë4 ¬` íÝ[©ìôÈ  úOºÔBVŸaÀÖÌ•ÈãÞ ¹%ØóÂ¥›¨Tå ÕþÎ8vt'šÞÓIŒs¤:»¬‘ EH§à@ƒËn›¼ok=鿞ä_ïš oúñŸíÙÙg!¯»=í~Ë™Gþ‘þu¨ pJe»§n“@ÃØQÉåp0‰z…ÝaB8êØÚ¿·¶Ý/g‡ìÎZN?€£¢s¯Ào×ãßЩ :RòãÏKxÃkÕgêè±ã¾à·H.O¹žÊíŒÔšÀ-:·¿ࢳ Z)£˜Ï¡X®áÅ+vNÀ;Û‘ˆÖÙöëã†dîhÎl¡SðV|N‰îWæ¿_½†Ûÿü½ÿuhn~þß:¼JDRŽ’ºqw`ýÝÑR x…©–Ü‚`üºΪkù¬EíóêzaÁé´g’ëí8WDwq%Q7·?Àó0 uáf¿Ad¤¤ô×íi´Ë¹Ý>èñ-‘K*Úë:SIF°Þ @«®‹dG‡K~Ô[)¿² !®ß\ÅÍE™Ž1ÂÑÝ]Ê&Ü ÙÙåeôøïj¨Ej5#ßSÜÀ×Ûfçæ‚ÿôÜs'âÞ-AF„®¶´¼Ázà÷ªþ®ž¹¥¿vÚCGÛk^í­6’%cÐth¨­ÆÖùÃ{™€ÃéçUÈÚ-òø¯ÓŽÓϯ½Û‘Šƒs†j­†oûƧt…ÿ|ôØñ¯kÜó Ѻ ÀÙ¿VÀzN@'eG‡ÿÀŸ¹®­ÕWá½g.Í¢Z“¦@,ÀÁŸe‚ˆ§Û¼Å?Ùô!Õf¡À^€ûQ3éß2ø…÷½ÿ‡ËåÊžX4Œƒ{ä’ï©D Á@ 1ø}D]㬅9u4X˜·JÚmGÖÿÞ{9‡"Iß6“mz_ã¢ÚÆ &d¿Ç^#§ßºàoÆQZ? ãò;“28† O;ÈüØÑcÇ7f û“ï’àÞQ€rÕ„i®ólŠ|€€FYŸàš\\[E­ZE±\ÅåI{ÂOoG }1ïõM)ù… ÿVfh¤{íCÍF¼Ç~ÀõÖ»%ðÿÖGç™›7¿—ˆð†×>†Jµƒ3t·§ÝÍ{%{£® ÷q]‡-•]ﻥ»ýºž‡ ¥;8b\Ö¼úñóè*~&i¥¶]°Ž6±£+78ÜçwàçˆGÈ„‚xèÐ ÒÉ8 w¿Ò K!·`E2—ýߊðÕ²£Ã¾áƒÙÑáI?ç,B ·"ŸZv =îßÑŽX¸õøMñTÚ#Ð9ihÓh½PàV¤¿>çÇZÿääð§>óÙ±‡ïSvSOG; ÎýbuênSzìøPƒfÖ#_ @Ƹ{ÕZPK1è¿pe •Â&n.æë!Ù3F$´±D\D„XªnTàû3C#{7Ôùøi ûæóëvøœ§|ð—B3 "ÌÌ.â?õe|æù—qþòr…":q2øµ@켑ë3°‚…ì«ÉyÎjÏ­]Ôq r· <çõÿ–‚KÅ:‰¸ç9 µ·?À¡ö“÷ŠFòyãÿM¿ðTqì™5W._Ç?~æ+ø£ÿûÏxþ̘óÊ=v¼îënBõ€# PÛÿ¦)Ö]Äê_kA@¾”žð3îöLVå,Á± {­@sÂþííZh‚á0Œ€Ë!h8±¡F|¨@ló”7¿ïvò×ã±¹ù…ï"}`/º:Ò Æ0·¸‚O~î+øÍ?ø+üéß| ¯\¼†r¹êN‡ÓÍꙜ=ÜçéºËkæždéi¬!é äц‡“”,¿€}-õ-¿€Ó³GÖS8¤#yêìgcÕ_çHÜ(øë+5wúµ þ›óKø›>÷äOð‡ñIŒ]™€é$žzìú{:™Öû×wÊ—\€^Ú-À™ÛþoQýoܪ¿ળ °¶ Ó¬aiµ„¹å‚‹éÇ"¶÷l|$4¯|Kfhä‘ 7ä ­‹4“þ¾šµÏ¶Þ9«Îå+ÙȩӧO;º»¶÷bpgÚ’ ŒOÌàÜåk˜[XÅøÄ ®MÎ"x{võãОص­Äì?´=RæÇQ}$ŒÂ¡’«cûœ’Çdª Ävî›1ÖB ªaE :2›¦*׋ނ¤ ng*Ö¯Ô.„ÎEª^¨-u…Ò&¼ÏÚT%ßÝ]<à/•+8{þ ¾øâE\¼iU‹„ƒ8z`7{p/ˆJå ïÛ…þñßBñ]Gÿø™Ó§>Ñà¼äÒ¸cÀeÿ·<x[&²£ÃåÌÐÈûá¢B ¿²‚xº —&—ðxÒ=￯=†¹¥BËK@0[^rY€÷øÆ–ñP³ÕýPÕLú7Ý~éC¿ò“Õju{*Çž]¨ÖjH%bèëjC_W;ž|ä f–qîÒÎ]¾Žµ|®L`,;‰h8Œýƒ8°{;ú´£Ðþ¦’ÎF;àX¸C&Uûd¡S‚ÖFAd'u‹aXÉC€éX;È%í-åp¶¤™ð´mÝ@ÀR‘•Ú¼Áƒ7.ÍÁ¿žãàêä NŸÃÙóã(W*VvïèÃcìÅ¡};ÕŠ=•J “s ø–×?OüÃçà·Ž;þé3§Oµ²êFÓ0``ÀÛóhú]È•~vé‚b>‡H<µ05ŸCO{ ¤4O"`°?…—²së*eN""„cq+êPÑ2C#‡²£Ã¯l°¿$ð“öÞãFê}£ý†Û‡Nþêcsóóÿ‘1ƒQ­Õ èíp›]í)t=žÆ«?‚Éé9œ¿2‹ã7P,•qö|g/Œ#ˆaÿàvì@[*á‚•‹)Ë"¯‡<jÇ)ÒeÌãÌL¦¥îK¼:–‡V&H1[K ‚ÅP4Þ­váÀ"u_‡uè~šõU±v×Wïg¼×LÏ-âü¥køâK—0»°lJ§bxìð^…±+ŸÀÊZÁªà‡÷íÄ£îÅží}¨7Éì~èfg–pîòuœ»x S³ú•¿éÌéS‰”aJph¯ÑDÑd ûw´#2¤PWn,a­…d «‹ó(åóÞâ°šüÓ2©¥ÊÆdœåéî önK£¯#®R ˜B Z5ñRv®ef« ó(\ý]Я²mˆ¼  UI¾að`?ðC?üî……Å·%Q ôtâêÄ L!—Ü xà@ÙƒŽ¶”#$WÞR›èò†¥r—®ÞÀ…ìnÌÌ;º./IÆcèiÇ@_'ú{:­qh NûËT2Ø.We6H%‹€€ Àö¾üP ÕŒA¶!—Ít„Ðõa©pU8þÓç$\ŒÂ}r#௙&®NÎ`,;‰±ì$&gæ]ÃlœsìÚÖ#ûvᡃƒˆ„ƒ~üÆnY[^Íá‹/^Äé³cXY«Þ€ÃgNŸZôž€ÌÐH¯ªcQ<݆p,ŽÃ™ SŒ]_l XËs³¨”\¹>–²£Ã™Ðýû>s–Ãa$;º 8v°×fêwi­„‹¾ëK•r ˳7½ÅïÈŽÿöFûëuz÷½tËöÿ‡NþÚ£ ßEÄðÆ×1ÂÑCƒX[+â…sW°´²†^¹‚Îe±k Ù‹=;ûKÁG(Àá};qhßN”Êܘ^ÀäÌ&§ç0¿¼Š•µVÖr8wYæsL%â’!ôv¢¿§ÑHHªìÚ f·V×µ³ŽÎ;ÓâD‚†©åT$¥1xœòMI_‚Ûç§îíµùAÓZŠØQGû/ütv˰qðÏ-®`ìÊ$Ʋ¸|m ¥²[‚v¶¥°opûv `pG/‚‡©±ýa ŒßÀ©Îã• ˜ªoáP{võãàÞxyì*^»ÚàWÑx‘ ß ÖªÀš* ÖúóÒ-„¯G¿àÝvë‚r±ˆj¥‚<€ÅÕR1wž€d,ˆöd +­- †`‚¨VÊÎâ°a 5€Í’þÞc€]Éf£ÿí§æµZmçñ‡öc׎^¶õv!“6âøä4¾ôâ%ŒOÎ 0bH&¢xèànëæÂÑ  0å¶'ÂÔ½.‡àvÜ;à¥NZîCÕ¶Uã48Õ~ïb¢‹+k˜™[ÆÌì¦g0=·„›ó‹u 3‰½ØŸÀ¾ÌvôwËI,uo ~àòU)íÏ]ºn­ ðСÝxüè>9‚£ÞA["†b¹ŒJµ†7}ëðþò_à٣ǎ>sú”w1½†aÀNª´¤ÉÇ x;ü!€Èl>€R>‡X2……•J•Óa+ œ¡+ÁÌbIäKÁútâð&f^òâŠPÏ$|î·ýòÉ_}daaỉ{`/jf †a ¯ÓŽõ—,@6“NÆñ5O<ˆ§;Œs—¯ã̹+XXZùK×pîòuôwwࡃƒÜÙî·fÝ„°w×6ìݵ kù&¦¤¹01=‡µ\Ëky,­æl鮤¶Á9R‰¨‹)¤“1´%ã {E`×Hƒ6䱩G±²2)S_½+æÐþ‡O§Ã¯^-¯å0ssÓ³‹˜ž[ÄÌœÜת¼7T–ˆÐÑ–Ä®þìÀž]ˆE‚>æBr€?W(âK/^ÄógÇ0¿h{ªz:ðøÑý8zpÐ6~ "Bg*‰éùE$QìÀØ•É~ÈE9¾×sGÿ0`n'¢…4àŠn3 Ø—²£ÃÕÌÐÈÿ‚ –ýœšÏa‡O Pw[ó+ET[0]çà†ZÕÅÀß ¹ŽAËäçÐÇëß{/ø³ãWçžþá¹SgÑ×ݯ{êapÎìPxOCxð@È`bjgÎg‘½>©ÙLÏ." `GvmïÅÎn9ï¼…&â‘ nÇÁíX]Ëcai‹+kXZ^Ãâò*VrÈå ¨Öj˜[\Áìü²ÅLSªñáPm©8Ò‰(’‰†Ã`(‰àG ãÔ ƒ!0` š¦á@´Öô«Õ*ŠÅ2òÅ2ò¥" Å …²ü-–P,•‘/”P(•±´²†éÙEK¶]è]¸-GOg=]mèéJ£·³ ]ii;;8g#ðûIÿ|±„±ñxåâU¼|ñ*jêã  =0ˆÇîÃ@¯'K®g¸³§n.à³_|ó‹+ˆEÂÈŠo?zìøŸ9}êoU[NÒ mÆ`ú й5Dâ L/ä°Ý“4TOîn‹âÆœ+×HC „¨U]uÊ ìØÈRãN À x¿ò 9þ~ñýø‰jµ¶+¢«³ ×oÌbfn òWÿ‚C{wâµÇD[*áñøë_a5µ­¯ Ûûº°š+àìØ8^»ŠR¹ŠËצpùÚˆzºÚ°k ™í½hO%TO•}mïªæÝêq"E"ÅNÑíª_®V±¸¼ŠÅ¥U‹AHÓaåJ…bùâ<®OÍA˜&LaZÌAï õkš¦ŠO—«87 Í, Žr¹Š|¡ˆ|±ˆB±‚jµ !LÇØ¶í?p&½ðþ¦1 èÝmèëL£»#írØùƒ¼y4¡&&¦çpA9 ¯O͹ÞeoWŽ݇íF0è“ãØÔj&¾òÊe|êôK–ÖÀÃÞÁ¼ðòeøme è@ß ÃJ"Ë«wQ€ìèðXfhä3¬$µjåb‡1¿\@{2b™ºãíÉ0æ– (·0z…PÌÕ1‹7­Ï²lÔHê‚ïöÁ_9ùðüÂÂ÷2Fxã×<b„Ãûvbüú .ßÀ+—®c,;‰âéÇ#‹:n&à¶·(âÉGባ093‡«“71>1ƒ•\AÚµ³ 8uæ’±(vnëÆ®ôõ´KSÁc/»Ân­¯Ò5WAƒ£»#…îö”Kj !͈…¥U,,­`ai+ky”Ë”Êe”*U”Ke”ÊK%”+UËËOD¨ÕLT«5˜É4„)ꘇíj1îÁPá`¡°!ƒDÃA„BA„ƒÄ£atu¤Ñ×Õ†P0àêk#¿û ñŸÕ|cÙ \¸2‹ã“ÈÜÃãmIìËlÃуìè調ëϪÎT*Uœ>;†Ï|ée,¯J»70ðÈ‘½Ø½«±hår¯\¼6àþ_µâäIZi¶Ò¯®{›aÀMècp0@jÁpSó9´'ë—#=mQ\¿¹ZwÎK o¢à 1B°> ý¼þ^o¿k«T*ü?¼íퟨV«ÝÁ} "ìèëB2ÅÔÍ0°{{ö `f›Îúëè—? !°š+àôÙ1|þ+ç,FñÄCðä#‡±¸º†Õ|B|ìÿNÿ=Ÿý2nÜœ#9£kÇ@7=²™í½ r8ù¬É4î°IЇKe\»qã“7qýÆ,JûåÝiìèÆŽþ.´;Ì'°à`^•º¢M Œ®óÖ¡7XÈÞlfaZmšºÜhÚA=P×'?0+æã}WÞåÕ¼%å/ß@¡äcFWG û3Û°?3€Ìö^kŽ«Áà.f'ñüÙ 8ùºÁFð䣇dh0`uÊ4nÌ-ÀÙkÓø‡O>¹Òý­Cð¬ÎM¦M$±»?x4`éÒäRK“rËK(¬ÕIÛLvtx¼éÅ-PfhäY?è,ÓK=í1dz“ÖP “̯05ß<°omiÑÏ x4;:üåVú×,3A3û¿®~¥Ra—¯\y'ákž8ŠJ­†€Áå°ÉœzúÂýÝøÎ7uãúô,¾ôâed'¦q}jÓshK%ðð¡Ý8¼w' Ëv®Û:?t]°73€½™˜¦ÀôìÆ'nâê,­ä0£<ã§Î\g„t2ŽŽTmm ´§èH'‹„P‡+À2Q<ž—¥ƒTðäX°Í‹[©6 ËÝÎ:Ãq=Ÿ}?g¹×²ñê2Úß¡ËMÓÄÜ fæ–¬w15»ˆ›ón) ؽ³û3Ø?¸ m©¸Siò?ø×òrTàôÙ1,,Û ëïéÀ±÷ááC»aXŒÄ¾š1B2ÅÒjƒ;z Q*•¿.²ò¥o-$uÝÃòî”i-û¶Ö¤àbåbáX‹+Edzý—OÅB¸¹˜G­IFã~€ãZf~`nfÿ72ðîŸý¹oªTªûb‘0ÚÛ’0…‰Î¶”tÒX’K’oïëÆö¾n,¯äð¹+xåâ5,¯äðÜ©³øÂ çqxïNÙ·K~„ŠÜæ<ù”Ê©¿»ýÝxÕ#±¼šÃµÉŒOÞÄÔìª5s‹+˜[X²ª„‚ÚÓ’!´·%БJ =g<÷•¼ÀŽäs’`û û…É÷@¶ç_ÀöEXmk¼»!nÝ…Ì:ĹÕxùÅeÇp üìÂ2j5^³º;ÒØ?¸ û·!³­œ3xQß ü@öÚ4N9W.^³bîÏàñ£û°M Ø]ð²- Ž`-_DµVÃ^óþò¾2KÃÞ»k€^SX’´òɰš.øÕ½ÊŽ?Ÿy À]V)Ió¦\­!_ª"äÖ´vídŒŠ‡šFú,/Hð‘Vúç7 à~«à§|¾À/_Éþ(ᵯz5ÓDÀ0ÐžŠ«/ºÞ±çôø§“1¼îøƒxò‘CxùâUœ=ŸÅòZ_yå2^xå z:ÓØ—Ù†½™ ëR8¬c›<ß@*Ã2xà@ÂXZÉanqóKr[XZÅêZ¥r7fìØw¥Ö'bÉÒqt¤“H'åŠE¡€@À€¯Nغµ»ÏRÛ×ÚC[P¬ÀÀÙ–¸JµŠ¥•¦ç–0=» >+^UŽ0/ØchOÅÑÓÙ&G :ÓØÑß6™¶»‘Ç !ø`bjg/dñÒØ8–VlÕµ»#ãG÷ã¡Cƒ*ÚR]¹øµ©—ŽÇ0·¼‚Þîv$ãQÌWŒ£ÞèÔqc–ï§Õ`ÓÀÑ_ÃÁ„¨–K„ÂXΕúç lK4fŒùÅ<ÞjÇšåпƒk{Ï{ß;T­V÷Äc¤Sq!ÐÕ.ÓBk­¹Þ¢p«õ  àáÃ{ðÐá=È^›Â™óã¸13‡›ó˘]XÆç¾ü ¶õvaßà·÷!àVK ß]häÒ©8Ò©8ö ßªU.U0·´‚ùÅUÌ/­H±¸‚J¥‚Õ\+ky\¹>eÛò–×^ 04 rü?ä²,h ¿Á€Pа<)•Ê(–Ê(–¤S°X”cûÅR ÅRÅúÕcÿÎM»û Z@ïj“cþíiôv¦ÑÕžó.a]'áÝ/ÐüSsxñÂ8^w…[œãȾ]xüè>ì¨_Ç¢ø5EBAÊ•*^÷ăøøçæà%ƹ”þ¤ûÙz¿\[Á>ï-(‹’¬•ÐÓ Qh€3Ä#¦ÎLÆëÀÌÐH2;:\7oØK^ÀOÊ{}·|¾Àǯ^{'áu¯z¦i" =ƒðð‡C:›9n# îèÃàŽ>¬å‹¸˜Ä…ì$–Wq}z×§gñ)ã%ìèÆÞ̶÷uIµµ!øí¸¥¢ýõƒôu·£¯»ÝQ,°¢´É–1·°Œ•µ*VÞyB©TA±T–žhG\€_,€µéa@Ó„)ôuÊaèÙ×óLÓ´P¤W:nOÇÑÓ!Çû»;¤TïjO)5ŽD!‡ûš_¸13/dqö|ÖzÎöììǃû38¸{;B!ÿ…0ÖUÌ}:–ˆ„1_YC_oÈp‡ÉÚ ‚2—ýßê$ `K€ýè Þ‚r±€X*•\Y G=ê5ÒñpSÀ9‡§D aÁ^ÀÛˆ÷·Îæ×û#úГÕj5“ˆEJÄa ®¶¤O³ò½Ò­[ú;„NJÄ"xøÈ<|dW16.„¬å‹¸tm —¯MÃ0ú»;°£¿;ú»‘ˆ×³Z·ò27±²ì@{:':ˆ':ˆ7p1;ìÄ Š¥ ®Ý˜Åµ³^F*ÃŽ¾.ìèïB_w‡% ýÃúÊ6÷{õÚÇ!! ‰˜ª¯gþéëMË ÏÎaA; ‰Ó´Ü³uÛZ*Û3U¹¥;h=mÈüÕj Ù‰\ÌNbl|Ó³îyêçÜÑ«@¿Sæp6y+à÷¯mi‚±pHÎän†î rÞ·•Xz`KÀýèóp0@Îëç†å\éÿgïÍã,9î:Áod¾|gÕ«³ênu«%µ,·-Ëfì±>aXÙ˜5ç€mØ, ,3\ Ôxf×Öì0+˜á>,0,û± 6ظ°±,’,Y–eY²Ô:ZR©»ÕÝÕu½Wï~yÄoÿÈ+"22ß{UÕGµêן×ïeDdddV~Wüâ™[‰— ¹ÌÜFbJpÍ0ƒM€AžhÚ2ì›O>Yj¶Zße!¹Dþr[u^ˆWà'¯x¤rù"Fr`ß4ì›Æ[ X^­ãô¹eœ>·‚ÕÚ6m|³ÑÆ7Ÿy¹œý{¦}õxf{f'Q*XÉ#ܾþxUž{/ʈhf¸àØ’Ï/ÎEèO„Y…I^%Hò(’’\+¿³¢Ãf§Ï¯àÌùU¼t~§Î-ÃU$èÞ™I»ö Ž=€ëî¦eÊR™à׌S,+ý%Ã}ùíÀýs(Ð.op =à½bAh·×[}Ú3˜ˆVc„r1›$c2øÀ@Jsª@ø[ûùó¿øÿ¾„Òþ½“ ÀÁ61–”þ>‰€ˆ/&ä¼…J›lb@äÑ~ýknD·ßÇKçWpúÜ*Μ_A¿ïà¥ó«xé|ìHªŽ•|›yÖg Ó“Õx?wñe–-‚Ô—Y{Z°ô— Í£¤¡q:‘tãX^ö—¼jØ'DÞ’0èömÿ,ùÏäô¹4“yP)qѹôQ+#T-R!ž¢)Uº‘ðÃ4 óy´S€š tX à"‡«”pò€tû.—Ã4õïz¹`ÁðÓSа)¥þë| ;w~éûcxÝ«nð\,&VgEg(b^zU‡’ ̈d]ø³Tˆ—ƒÖ6¢À—åµ:Zí­.­.Nž:À·i§ªc˜¨V01^ö—11>IIã!\ œ"•púI†â¤¢ñ9:}>8 5†(EpÜ'Á—ê+«X«o`y­Ž•uö¤Vo&@ÊÌNOàš¹YšÛƒ#öbnߌòÇÎB~võðàOö)’™+Âñä5òB ðZ#,¾´&ÀãÚ65=÷=ÛE¥¤wœæ-¦iD3>*¦VŽfPâràAê¿x•}üŸØÛëõ¾Õ0 LNŽƒsÂÄx°°GRÿ aj¬¸.~¡CV+ñ‡€šfѵˆ±H;®à' Y^­ûóækþ¼¹ãzX©m`¥¶½¹ØØ•r“s¯Q,äQ.æQ*P*PÈ[’¡_\3(±¦>Êüuã›Äú½>š­.š­šímÿ÷Z½ÝqÜäú†àx|¬ŒkæfqÍÜÚ¿÷Oûþ‹`,iöxÚóõÏI±ûG?eÔ=7é¿QMÀ7›†ºtÀâ¼wô¶;ðö°Ld¶ëa >ˆÌ°àe(åshumè(œ Q²@%vÑQVZð¡ÔÿÏ|öîw0n¸v.Xêj Z‰ç5Ci-í„£^.Uýͨ2ÁÏ´å1èÊÅŽÚ‡#‡ü¤-œµ&V×6Po¶±Ñl¡Þh£ÞhÃó8Zš.NŸ_FäÐbÞºX°P*æa™&˜á; æ›Ì€i`†¿ö›1×õà¸lDž븰ŽãÂq8Ž‹¾ã¢Ýéb£éÞqœèÚºåÁaJòêx³ÓUÌNU±gz³SØ?;‰‰ñŠÒiè¾F¿¶4­2üÂF7YG2Áþ%¶íÒÐŒN/@`þô°™AÉKs¹lSØ4M¸2І&úUŽÓÔ•"P¯o¼•1†ãÇ®à/ðÈ™f$ý ²ÃOážx±VÀÂi´ðëÎ1óC~'„D ¸Zí.êVði£Þl¡Ûí£Ýí£ÓíÁq]€üŒ8­NWZÞ›~¢¸âàž¼<˜ÊùÁ΂C¥˜G¥âïº[©”0UÃìd5ý,+è#e Ö<Àtë)üYÒ_S’~ht4 À4ÁCÎŒ£]o8éhÀۛI­=%’øq×…‘ϧ®ÿ5s€/Ì0s€#9 Gbi¶?”úC°mûUŒ1”K€‰1q#…Ôħ€â"H–*jõpîlðû…éN¬±r •r÷Ï}P*Ûq"fÐéöÐîôÐëÛp=®ãÁñ\8® Ïqa»nPîÂu=_30 Ã~0‚—Ú0üïb1Œ¡€±r¥bñ2‚ð‡œ¯ àÀŸ²MÿÍN÷iì·tðÇ´‘¬„ý?¼ q^,û?¤ð\¹|}ÇÓQô;›hê‡fi¶øúùËä0ç¼Z*ö„1úø ?…uý!ÅpÎ’šY…t¯ ^ú#ãEG&ø³8LX•·,X–‰Éqß[ÎC@Š¡¹Fò¤F¤8ùBG}!xËÃ8ƒÔ1*B‹Ú ø?¯ÀŸÚM<@Õ7U“;h5€‹ÍÉÿÃ'9(ad3 D†bY»šcO<ñäÍŒ19àÇ{çLÓßž‰1­ /É{ŠKCG]ò¢)  Ä«7ü£ö˜,’ekæƒ#¹Vòßgaw(­'1h¥¿¶¯Íƒ»<þ*ø`£+ƒ5ÞÌ@hÿÈ.½H<Jî¾ÃÕ¿^ØÂ_8ät¸@é‘E‰ `$笮­$}dÂj¯ø еý šO³Ž>yÛò´–®ašô ü}ŧе H_î'¥³L5wÐ\ú(%‰+?«ÄœÔ˜þTʨÞN¿î¦šq5øRbÕOèó}A­í`ŒI¾³Áû$ÄPùÅŽþ ùκkw:¯fŒ‰=ýÔNÑ•EÍä3)(×]Ay‰ünô b+É2¤™¶RÀ?Œº­)§@u£ ÄÿÅ”†ƒîCé5 üé#ÎÑ_e;Ágc’ËU žd‘ýO ¡€—8 ¤d.pA²ëÒ܇Ôañ€®›©Òúýþ« ß—XÔe…*C‰ DÓ}¢–3ÈÓˆt_J“Aª¬"Ã]<9’4Édå`ð«£/ðI¥4 ŸþŒî¶ üœ€fOÏ,!`àG†”`¡ KY§b¢Ó”â/ñ@gÅ` ØGï¼ëMæ‚͆B>tØóLzOÓiA éõÜÙ6ƒ?õ:H{¡Óçñ˸N&ø3¤õà×Jÿ4ðcø³¤¿¦dTð|ð«±=¦6 p„…@—ÇÈdLPŒ£táðC…ù€à&Cs$ `˜ùI8ñÔS×þo€èRHY$¢¬?ÊU4$ëª{)À¯‰z˜ ~Ê4-ÒÁŸ¡ôaéÇ?«­ƒÛødjCzÊŠ– 貘G%ÊÕõ " “ÝX£ÑŒ¬èH;÷®ë–c¨”ý%šù\N˜ç÷ÿ£ÈVõ{¦üÁ㉻­I~©q*ø—.»Òë÷‰Ti‡”¦±+Y¼@×ßfÀ?˜YŽþÔ¡¥Khè€ì×6xY4€×¨F`÷§™'l´úÚ:‘4÷3Ô~ã¡P¤¡€ŽãK¦0‰á ’õ±=%†~»…6C€__—þšRTŸò¢‹k‚ù!@GºF–„Äò®Žé>©0CáÚè$m˜fbŽi‹S€ÀEdGo»£%€™ËEŒ,Ÿ3„€çW[C­mÐh4Ï3. üf\×-2æg{b[†‰>eºOŽмìi01L¡UøSûO¥Dˤt¦ø-ׂ'N¢¿6IL#QO(å%VLVå¦?•2ª/…Ç_~RÖ[ªmÆþ´6so˜z[ WCyysÂÎ>“cÉ]~.Ô:htô €DJÑfžfP›5˜çñ"€ ¯{ꛌêñWçúå±È|A!åÜäe¤kg‚?¾b ÆR%*eIø,ð§ÞŒ~`Û~hdNÆuÞÖ]lðÃjA¸·ßÌD †ÁÀ9¡ïxX<¿æ’?$×Ö¶‰¨êEÚ€çyE€!&$ÌUŒBƒ¾L þDÁ%¿z(­Ÿ# )þ)Eº û<ü­ú£DP¢Å<ὑƴˆê4cO}&$v›¬ÝìMI:øS®-Dª«§EšLhNWlðÑÛî8àMb™a˜0ìì,Áq9ίµpn­=pÊO%×IhË‹ óº¶* ÚL$ ½œ¨f.Ô š0T¹X ‰O'å½– -_!½êŸÔ nVºQdYÓ}щz¤¤ÙÛ¡ß¡ðâoü—nº/ë–!9  f ÉÕOí%èR;üˆZ`aóc¸Pë`¥ÞMeÀƒH£<9ì¹Yir³ˆqÀd± þQÀòD{¹‘”€àO%íˬ€?(#é;n ¿È½ñ:&¡ª»ÒR]é'¥¼I­$õ„¾' ÿ`<Ž~]?bmú-%Ê]èØ £/Ò¨ÿÀÅe?®„[zq",×:›?qÏKD Þ7ìùºiÀ´á¿Ñ……ü^bÔR\Ï ¨ K¿ÕaX¥½Gƒ{¢ôHÏŸUâå·æ‘^}ÅÚBtºÀÒ{ð k4ñÚ`jÆ”JÕ—zºOá8LàÏ—‹¡K|ô¶;¾Êü?3 Êy3F ÇÖÆ|iØóÓ¦R©TZ€VÛß(BÜU#ô´Y ŸEiÒ,ü” þttÄ6y´Eê$Øýq7’miò5D‚FŽ+w ?ÀôiË,†˜ÂtG?eÔª¤í9%¼9m Mm7 Ktô¶;€ÿ –—ÆÆ“q/›$»—Èì@³Q šL¥‰êÄKP ¶}v\Íb…,ÉŸ¿Vv¥ö©¯Ûâê>J©“¤?WÀ… (€V!¶ y¢Î/Mì Àå; ÅîÏzz#ƒ_[šV¹½àW)-)vZÆ\]Â0à mhÊ ¥±¡òuEv/±yèË óÃÍb `nÿ¾³°¼æ;×Ó¨«1Ììö#ƒ_ûvŠGJÔéÀ¯JHñ=JâS ~o¾õ[ë†Á:œŒ€£ÙAv\±ËlETþ‘2Òt%ëHe:ø¥ëQÜ6Žäãq}B‡í}p†»‘Ò>Ò&ÂÃÐH%Fw¯2ø´ÝÓ}©Ý þC€H†‹Àèï€Q³'¤¦½¸0?Ôâ™aèèmw¼À¿SËKcc`I?F%»›°ÿÏ`„`°ùTs9ë% àåw%?@ÂúÍxcÒØÄ€?êH/³ }©7üX%LFH•Á,Jx@Øš[<”¡%ýòÈ)¾ßĨ哲À?øO0"øµâàW¯­=ð$3éWϤ™Xúätô¶;&|€´5cl[¥¿ëØpÄüÿß,.Ì$?åXJ™d±¤©Ëç­sз}àÇ™M³uÂáæG•þMøéÆ¡“· [oZn'KOWÂtŸ®5!é0`ø˜ˆFŒ¼¸aÀ;|c“ÓÛ*ý{­–®ø#£ö“ HHN &˜@±P8ÛétÑíö‘Ï[°ŸPpvzŒ¿¦é•†›jº’åøE‰ª¼”`ÓÀ üaÈ®< ¨þÑ4IãRn%‡t‰¶âSM{&)P ü”Q'\c³à:ë’Q€þ+«j#­¸aÀÓïÿ&æ¿46ŽB9‘tÓDœ£ßM8úŸ^\˜tÔ¾t,ihž_*•_€F°Ã¬ïLþõIù£ª½ie›X³úè8UšEÿ‹àÎ?©g ~ŠLŸ°OÚø@êŽ2Xþÿ‡ÜƒâI>}P‘ø€Ä:ýÙ Ó}:ðwm‚š.?J a°èªœÓYs}ºaÀøÿÀO«uV¾€ÊÄäVºOP¯ÝÒiÑÞL_!ž} m''&ΖVü OÇ•ÿ‰Ÿ 0Ϊ › ãñ×ôC!H5àDÈdt>b9 OÛ‰F? @‡ÀÃÄ:ÓQÊŽñ'áG:CÌ?IGÉ~•’í?¥ˆåú  ¤0ZÐE™øü9‰ ÓÄøôÌ»NR·Ø½¬ _û™T @}â$|íÞöÖ·< €N]À‰£Û·cð¤uõi޲•þ¨D”¦"øÃkSø …1‰’_qúÅ;)à${xWH\¸¶ô (šç㣉ëd¹)<“”©±à× 3üCе¯†&œ%tšQ2PIýßî0ࣷÝñ&LT0†ñé™(hi»¨ßiƒ'cÿÿlqa~u3ýey%Ò˜Atüïxûj>o=KïÉÖîô”“ä ™(¢+ ÚAlø…kkÁ/œ,Ùóâ”]ø¹ü ˜!Fç Úˆ]¤¹éYx;Ú‡’^—ø¥?µ›Ò“àe‡›ÂRà-:­ióP<þP©NFëý·‹ˆíFb•¯ à¿l¶Ï´8€,–*ÕMLLÜO,­¬ðwÈñ–vrâµH¼G¯@"KR4®„Ä9‚_.0,ø#Ež àM€ŸâñŠÀVÀ/Õ ·_?ÔâqÊÌ’¤ótÌ#›Y&ÏÑñ dõpø³ºH»@i&€i0)±ÔåÊxô¶;f|¯Z^(—·5Ü7¤^«©‹aøËÅ…ù³›íS7 ¨2JiCèºk¯}Nœ<"øûÛñéögòe–Zz9f E˜(­Õk ÄptàúÖƒ?y^Të£AJ> T€ Þ‹Ä3}iÃÐ4ÚNÿPàÏòø«×Ðts3¦Þ IDAT¢F†I3“1YD¦mþ—¤Ýprù<Æ&§7Ù]:çè´™¾=¿µ•~U'  ~(Ç fð¾ÿ±Gô6­h…S»ÛK¨å"hâ3þpшÒÛD VÞE9$Wh7†ýFö~ÐV]¯Ïð‹€ç”~’À?Ú„g?ñ¦&Hñ˜aª³ â]#ñ|¥gµàߌÓ/í¢ià'¤'íಆ¿__uzvÛVú‰Ôi6t>?^\˜n+ýk¤2ƒssýr¹ô0´Z„N·¢àI .ÐQ®C%ñ‰þ“¹’þPÏ‘^+|1ƒ—+Z.í Æ…› üăðߨþ¾¤ò$SSKPJâ{tˆ,»[sž®`;ÁO‰ƒÁàJ¨».˜ïÜÒB ÀÚÐtô¶;nðz±llrjÛ~à9ºíDàÏ€¿Õ¾ÅÈ¢JþD43=óe"`ñ̈€N¯/«º‰ï¸.v”…EaF%–FHÊ@”aE3 üÂÕTð å*ø¥x(ðSø).—Æ­ ‡ryåîõ0OkŸ<îK_¹MàO¹¶~h¤å4 é|w„zšY€•QÃgšÌ\ùbiÝ "4kk‰ç àW‡Íû—E¡@S#Ð2„×Ýòšû‰Ï<ÿ’ŸÝ”~_x“D0 Ž6 @qÆÉ——æÕ•¡’4Â8›NøSíýÐÇ š.XcÐûß<üÒyáetà—üÑHãgHšÌAñ“ž\—øiЙþ!h(ðkX•üœ€f_aFl„ĉFJ¢©1–†>Y¦¼x°]~Tj7º¤Ÿ_X\˜9ìWG:@|•$†ðï{ï ¹œ¹Ä‰Ãs}©ÛîÄ©Š<ø¤$·{Ö¼À*ëQ‡Føý Ä7“þ¼‚¦éxlã‡Ì1à .ƒ?„ ~"•¡m…{οþC4i•x”#€_×t=]]Ú9êÁpàü AÕWAÞܯ%К›µÿ%³ô;co…»n3±W‰ àç·ëi@*ÜTªOÆÆÆî'.¬Õ3 IrÉ’Wß#I éÿ¨ñK"t"Ÿ¯Ìƒ pü\` ñŒA¨Æó@ã–ýÑo_8êG²ßuàG rñ^„û—À¯<å¡)Ï.<$áwâ¤ø( 3z®¬­“AN)å™§^XUÿÁ hÄNÀѦ·Õ(Mò‡)¾·‹ˆ­õu]Õ¯,.Ì?½]×QM€Ä84u*“ 4·ÿ—‰O|ÁvômAuTéLq‰ßÛÅ/^$É$¾è±íž”S øI%Á—‚ˆ=<ä>D¦ ¹ þhœr_Ò}'žr|®öµyi²j$ðSFÀŒ6~Ý54èÎ&5 x[§%ÄÓfÈ0Ôª×tÙ~ÿÇâÂümçu²L-ÐSÊèý?þc_bŒ5WÖ ¨·Ú1@ÂÓ$Ü뙀H1¬“XRçþ#Ћßq; üŠ$%"üa{J€?´áÅïXêó¸¯üa˜otÏéà™†5å¯eìʳÌâäüYÍò M²›XˆÓÈÛΖò›g_4`Ý4u› ô;‰xÿÇüì¶]$ ,@ýÎü¼âÆ;““Ÿ$"<÷âY„f»ÏÑJê½ 1"ÛX¶‰EÉIº¢jñ¿¤Jþˆâ8¤²à›8­ý‡¨Þ#¿o«Çc ÇøkÀ/1D‘AfØý™à§ÄIÚË~Jlü„ôtল u‚> ¥„Ÿºæ_ÍÁ;ýÁ[xCýnWî[ðžQ’}K:@ð¬ºèóæ[oý(áO>Ó0À9¡ÑîÄSþ$~Ç"ð¥‹a·º«ÅÀ™…Ò?>‘„NBÀ†j{ôê v¼ 1pÒÞû’^|aš0zÙðGxÏÍŠÄÍòŒAà×ÓhàOékøCJFÚÿD#&Õk§†î Iÿþèw6¿¹GH®m£UK„$8þåâÂüPÛ}Jª  ‚],×KŸÿåýï{®T,~•aema£Ù†¨.‡o5A]Ž®J_uXñG^ ƒ?4?ĶÑåP½WKÄãqEcü‚„U~/¢ßÐ!¶ u nøSpœÄg—~ :³Á¯ö™==øU=EíVþ°4™ ,9èy|è<@ªàôÐ$)bD½d¨îÐÄ=õU•‰8~hqaþ3›b6¥‰`ËT‘ø9rø¯‰}ã00ØŽƒvOˆ _vQ*BÇÔËEUÊü>y¨¦'ú ~‹tF̈dà ׎T}!ˆ‹å‘JY{¹—$Éc bø5w-3†èY„×—ï[:J¿†Tðüm¹V›/®rœ8ëá‘]¶¶ð "Ô·›2ˆs4ÖVTåø‘Å…ùOla|)‡ä_+4²(ø-~‹]ýê/þ›Ïþì/ü›ÕF³=ë8.Ìœ‰fåbŒ` òO&l)æwGä§ X…¼ç˜È#Ô¸xåm*2À yKÅfçq§‚Ô)1 üÈ¿tâï,ì~Tð«ȇ«MŽSëN¯qœ^åXiqtú¸PÑbØ3ÎptÖÀÑYGf TKº˜øÁàÒ³›B^½QÔ:'à™MFæ[Go»ã×üqXÖ\_ÅøÌìÐK¹çacmžìãøÑÅ…ù¿ÛìØ†%Ýäeø@Ä—â·øI”ÍÎÎ:³3Ów-¯¬þÜãO½€×Ý|ÚÝ×…•ËEÒ–Œd&@ ÆBþ‚$0Â#’c©I¿ q ~Å'µUÀ¯óiPô[xLÂê>ø‡õøGµ#Ÿ¤º•&Ç£§\¼°âƒ^Ý€sTê9„3ë„3ë÷;ÓOW|†plŸ‰[®1‘K “WGïx@O ~gBûß‘hL€­Øÿ!ý €ð6À×26V–Q¯¢4^Í\Ôï´Ñnl¨ãêxïâÂüÇ·al)dâ߀)eYZ4eô]ßùó—þÈÏ<»xÖ|ý-7Âã¾/`vªêˆù]1’™€$IA‘ðO7óxI`Á/Hа@ øƒ¶"øI×ðsøÃºhdÂÓK’ M¿4c¢,Ú‚z‡ðè)¼èàÌúhàÙ ­· ëmœòð‰G^ÔÄ·^obÏx¼i¬†ua£›¸`H£ÌZ`+ö?`qažŽÞvÇOߦ‹tš t[MäK%ä E0Äað<®mÃîuá%·Ó{À,.Ìc«ã–B@dS*3ÈÒÔcïùþwŸûÛ¿ûû/v»ï8svsûgÐhw0]õ7FL€ p ‚Å—Ö_dI)(MÏéÀO€d‹ ÀOѹ±­Õ‡ë †¿ð¤´à†š¸Ç,ð§Iå—¾¶èàáEÏ/{Ì4B)¶/w×!Ü÷¬‹ûŸuqÃ>·^oâ•L¹ÒJΩ‡ À0XðìIÚvºHæŸ Lÿ&–úú¡fïþ¾äß¶ J†¡Añ‹ƒ´¤ßxà yô±Ç¾ã+ßx?ø½o†ëzhvº¨VÊ Æ|&ø›ŒS˜áÕO¹y˜øB‹¼(ZtL*h`g€?{øI@ª ~×6¤€_Í8ü)ÿLð ¿xð9Ÿ;ak7ØTÉÌå` 0sV°÷ž Ã4`¦”Ï>r~ˆÃs\¸®Ïqüo×Mr!eŒ'/pœ¼À1]qñžo±pã~!/ k-j–-Õ èxƒï*¦”0à-k!-.ÌÿáÑÛîpü4)Â2ˆÃßCðÿÚŠ?b³¤3TÊÒÄßB\Á¿›ÿõû~ä½ï?mÛÎáV»‹R©€z«j¥äcƒi‚—…ÅÈGTÂE ²ýx¤(C“¼ìþh~> ü ¡>ü4ŠßuI-×€_m6øuvºÏñ€ž³qÏ“¶6˜&$Ó²`å ° XùÂÐëØÃ¬†òÛPÊ ¥Î¸ç _œú¯øG_Pø‘w½ Ì`( 8´w6P+|ûþA‘¸'R8’¨$Ñ‘ZذX# ü"Ù$ø“Dì›Hs4^ñöƒÿÂÇGìáÅU½T4L¥ÊŠ•xsÊb>‡ýÓeLW‹/û h0vÞgô¼yàããø^;=ŽfðQ§é\ÛF§Ù€ÝKljÑ¡)?Üúl=»aš˜ÞËÄþ™ 8'¬Ô»èÙÃÇß÷;?±†L¥Å…yí»/  ƒ]<6Î TÐ'ÊÞÿS?ýokµúO_s`ÞòÏop`våR`B?ö†ï7“m~*V¢_²z¬‘ä" 1 øãßøÃke_lt>—Ç.þü_8aãÓ÷;è€U( XG¡ä«è¥Bs3웪`bLÐÂ(^'2Øg0‚ qXÇ£5q"3 "ôlÍŽõFma^Ïst›Môºé¹¥Q.ŸÇäž}(-ì™,sÂÙÕÖH‰@º­&Úu±¨³¸0q²xì RM€~Bõ)§ùD3 *ûÉ÷½ïïþÛŸ9·RíÛòùVë .åòg|E€E`‘÷O ¡@–²êou:À¾ða}Ø^:O¿È4¤‚_Uí·~õ¾:_çøðƒ=œ^K"?_,¢\ŒÔü©ñ"®;0½SeiŒ\4›TðGùbÍ…G GôL ˆ€(‚ŸÈç LW‹˜/¢Ûw±ºÑÅF«ä,ŒMM£\@{£®ÛçN¢8ÈŸêÙÞHà´3—ÔÛ~¥RVRPÝ'«.üpñûÛßþ¶õ={öü á‹> ïØÁ~‚±ä¥p$¹Å”^š«ëî™ÑK,,á•ÀO›<.ÕW ‚?Ö6dØGUÚÁG»È„kÅ Mþó|LAŸ\|–¾Ù`°@#™›ÀÜL9Ó@¾XÄäÞý(WåÐï€äL@„nôµ÷š0à] I•ÂoIkÊtÀ×2WÞtS÷ÀÜþßçœãžû¿Ã0à:.êÍI‘Ѹ#é/Jt¸þHãÍ3"¦P:“f™nüH$âÃþAÑ„ˆ: yN ø#@*àý¶Køó{»øü‰¤'½\Àäž}ÈYn<4…·ÞrÕJAr8Žœ13ŸI fÑÞ÷¥ºx !åUfþbf"ú Â1Œ•,Þ7ŽÉ± Æ¢ûPµ1 ¸ïxð6‘}G“ `—`ø´àºOV}‚ Üñ›ÿñoÌœù|«ÝÅòj D„Z£åt‹¥»ßmTYòSü’E¹H9_NÚ¾t¡– nð!ƒ/îà™ƒ~˜±F‘~ñq‰OV6=”*éOÑèþàî.?#K>3—ócãULT xËkâÆk¦.¬Á)IiÊPù«ü±T^å5ÙlHj ñB£©ñ"ÌŽ¡7‘³,LîÙ«/¦1Ã(@ÆPon.ñ†fp—`ø²ú4M€OMNºGþmN÷|9Ð<ëÍ@BŧFOP3Uº‡/_ ¶DPKÌ@ðø‡çGýrçŒ~ŠŽà×á9Ã¥Éñ;Ÿé$ìý|©„ɽû‘ËçqìÐÞüšƒ¨–óQ¿‘Ê/‚3Eå'*¿jÿÓ`•?ê3bØAŸÂß'½OÈåŒ`v¢f˜˜Ý‹bÅß_/t¶{ÎÈK€CÚuêiÐÎ@)âKª–)ð?ü½ßýÇ\.÷¨m;xñôy6š-8®+Kcˆ‚d_jYeŽÚ©º½ø¢ ƒ?N$AQÚ®ø£ßƒÁ?¡ß”§Æ£üJ•FòÿÑ=]Ô”Å1ùb Õ©˜†×Û‹Wž ¶gÁ1UåWÀ>šÍ¨ü!˜!ŽAUù•>Ó̆ñ’…©ñ"§q·fGH4ˆR€w²7UHÓÄ2þŠcÇ~“ˆpïÃOÂ48q¬o4cÐG/§(áy$Á…ÊøåÅDøREÀû‹R [{  ÅÓÀ/\zZÙàO©’¨çþøó¬·äöV¡ˆñéX–‰7½jgÇbu1°t*ÔNãð*¿žÈ×ÿvjŸ$ô)öÁES~¼ÂôxŒA ^Ú,¥„ï2 —Tý=*#˜ÁïüÖæóùÏsÏÃO¿ÐhµÑ·mïwJñÄ¿°Iùµ~±ŸAÞ×/¾ÕÑÀï÷¡_¿Ÿ~ ¿Ð‡ëöÅ.ÎÖðç ¨ÎÌ¢R´ðm7Äôx1}šÊÀ,hBêüþ¸)Á †Wù…ö‰>‘ÚgÞ21^–öÞØ4¥„ï2$}âo]Yø{+Ì€¿î–[~“ñ¯=þ,Âu€« É›>¥ÇHzÚ‡o\ÄqD°GÀ”Ái~#-øã˜DŒ% ~eÖ@|`Dº]5O׿æ_~¹‹“ä—6—Ï£:»c¥<Þüšƒ+ZZ•?p¯='Y*‡k8õ|4•?É t}Š Gî3WÁJfÞ ¥dÞeH÷@SNšß›bÿáƒÿÇ“Åbáãœ}ãiZ®¿Ÿ`äP¢DQþ‰ñ!ø¸p á% ^àëðwð Dç‡àï”âÀ¯€$ŒMûpðÀ'íã§eoÎÊcbf Vÿü•ûaåLIå @$ù^t*¿Ø^dvéêy|W”fÿ§«ü®Gèö\´:6ê­>Ö=¬7z¨5{¨·ú©ÏmÚÕÒi”}ÂïAL€øzÛ[Þò[Däñ÷V™€Î!Èßù½ß󻫽´´†³Kk "¬7èöm­ÚYû$t.½´!CˆSvI~aš0~.ÞLÈ\‚’4ðk ‚_]éÇéc]™î+”+È‹¸áÐ$æf*Ñ}ŠÀÖ®âÓDõ «ž§Û꺙ý/› '¬mtñÒJ µfýyl¥˜»ô‘€Àö2´)AúÙŸþ©•=³³¿ADøì—¾¼Öê„ÏŠp¬jB#ÑûŒð凨 n7üa‰(ÅÕ 9²²À¯ôóäY>'/p1 c“/çqìà”?¶mXÈ“®žgÙêJŸ$«ü>3HN¶{έ¶Ðꎾxg;Iãôæ[—c,We9/–& ý¾óÃõ1ËÊýáÓ_øcè;Ö6‚´¹€² Ty£ª˜)ÄÒL@© ü!ÁŸ­Â¢îIúVIþŽMøèW’9)*“S0 ¯>:Hõt•_Œêیʟ==§Wùef ÷éxËõ.VêÝMÅío7i4€]õ? 4' îxÄ ta€¿ûïüVί¬áôKËùëz¶-ÌW‹R,¼H²{Ñ$GAax B#‹úoŒ ~Á¸O%~àó'ìD ¯|±„B©ŒÃûªQâŽíPùc©ŽTf¥òë™Üg«ëKýŽšØÿ2’& x—4h1îX,Ä †bÿúçneïžÙ_&î¾ÿë‘dYZ­ù’ˆ’ŸH² Ò0–îâHÓ@pØ0b ÄL$:udð§ÁŸ¤~|êÚ„{Ÿ‘˜±É)ó&Ž]3¹e•Ыçëü:MAßçZ£‡Õî¶xî·‹Râ/víÿ€†ñˆÇª”×Õ Ë ¤ƒûÑüCÞ²þšˆð©Ï=Æ×Åj}C0Ap"”ô$ $bÑB1ˆEª‚ ܾùÁ¯ç”r_r‹/>ã çÈeåj†iâØ¡©hý;#©üYêù0*ÒlÐ'÷sôm6VÿbRŠpW(+È:KH¿ø{”@ïzçm¿йåµ:žñ,ˆü…Z8‰d—Hj‘´ º–%9B™/“'º“/£[zøõðOÏ!|é)0Œ1+c~òËéJ!w¹×îgôÉ X©w¤œW¥ì¼«4h•ŰL KF ôËÿúê333ÿÇ|½ž ai½Ž¾í šæóßLDY„Ž’ Apl:H "y÷ƒ?ÀÁ…µz$ùEÌk„µä'ˆØ bÀFÎ?ˆuš[Ë¿b+È'üàÙ%­¾\žË瑳ò883æg¿"pª`ŽxÝ&TþíY»ïÛü—#¨gTÚ Φa¥ÕoV3ø¹îèµÞ›o½õý^\Z©á‘Çž¡Ýíb½ÑÔá=’Ž¡›0É~b‰/aSb £?õe€<ñRRj†ÙoæfÇ"ðëò\ k÷kÍþ/ùCJ Nìòr¥Aq*é@?ª6U}~û?ýÇõ}{÷þ€îןxË«u€µFÝ^O»"é%»?V¬#B6…GšÛÚ$øe‚ü`¢'ÎÊàa†B©ŒBÞDEXæ25½Ê75 _È£SùE_×gÚLA§ç\‘Þþ4J™8w©Çq¥Ò0qÃÔg1†a˜„öóÉÝõX±XüyðÉ»¿×ñ",­ÕýÍ!Ö’Ý/tAr‡þEdK†@<$ 뤀?ý þ—Ö½Än½…RŒ1ÌTK8U•?i« Ú ž§©üz˜>ýß¶ëa½¹³vÒJɰË6@W?HPËÒ´€L†pïç>ûÃ0þˆˆp×§îc 縰VCœæËç±}¯t—ÑJq’~IÀOKy4À¾©SÿËþU3ÕbæÚýáÖÙCrÜ×0 y²f <°Z惡=®XÚÕ²i”8€aÛ Ã‚Y)“ê~ãWå×c÷µ»=Ü}ï×`½ÀJ­4$i>@ìñ'M÷Ú;£èkdðkKõ•ªúø[uŒar¬ «çWÀÚýðœµÆ•×?*¥8Ï_êq\©4j@Z›QAðíßóýïv®=røÇ@´´xf 'žyD„F»ƒf»KÙXP/Ìñ‘Z'_MZG Ue€?Kú äqJäù3- Œ1Œ—ó`Œ¼'iÿ^È3xqPÌ9{GxüU"jˆ­-.Ìons«†I·šðaÛ *ÏbÑç®|x©T*ý0Ù÷?ü$ju5çJ½¾¸W\p…Huš¡`ÑßYÆíL²ÃŠ IDATgŸä’µOtnàY.æFVùÓlõaUþXSЙ ~{Çå;Êé'Ò®ú?˜FÉ·<¬þ—Ö. ðj]*ùïóŸ{в¬_€}ú~pÏ\Z«Áãc?uTZÙÎ)ü©74ø`µ•ì)gù™o‹ùÜe]»¯ªüaŸõfoÇÙý!í:Ó¨ ×GaYšÃ zµMôyè¾/ýcìO9ç¸óS_¸žç %™­’‡©ÅR±-àiµ™|!CP*ä†TÏGWùef0XåûlwØ®D;‚v5€Á´™†5 †m?”ôéÛßþö_bŒ=Ðhuñùûc ݾµ¦ü$wê =™§î©j$ðSôÿjKÇ| ”Ï]òµû:•?ÉjÇÁ«ŽÁá¹=Q\¾DÛ~+° è'®sm¨Èíë­>&Æòñæ © áõÍ ©ò‡Çœèªtü…Ä9‡'EbÀ —i8W4]Õ  x'ã­oº‹§—pvi õFüj¢[ÞðÆ£ž`ýì/Àtuc¥"ú¶gÏâùSçpêì2VkÄùåb‡îÅá{°wz3“㘜óUtiN¿ØµWaTþü¶suüˆÔk7Õ¢ÿ~9Ʊ“èªÑD‰üøqvâÄ 677Ǧ¦¦X»Ý6lÛ6>ú·íz­öår©€÷¾Ç f§a.¬Õðä³þ´àÙ k ÇßTu GïÇÑCûpøÀ^LŒ•¥©;Ç_*—ág<üÕr @y¼Šru"ó>󖉱’•ªò‹3ŽËáêwǹꈈcýü9ïó:€»ûfÓUÁcìCúü~¿o¸®kT*–ËåL×uç_X,}æžÏŸ§g'ª(òmþ¾ãâÔé%¼ðÒ^8³„ «u¨Ï©`å°wf ûöLbïìöÍLbïÌ$J+j“~‚ øŸ>ÕC½·2 Óûç’éƒÊ™*E+îMb¡Çÿå#ù Ûj¢½Q‹þëâÂü/]®ñìºL€üµZ͘žž6Úí¶1;;kÔëu3ŸÏívÛ4MÓ¼æÐAÃ4Íg=Ï{u¯ï P°`»nÀb*är¸ñºC¸ñºC€N§‡Î.á…ÓKX^)aïì$Æ+eŒ•‹( ¨”Š(— ¨ýïr©€œiâÛŽå°ðX¼ ‡sýn…rYäz­®¼e"gÆáœü}\ ëj§^«%€?»LCÙQ´Ó œ|Ɖ'Œ#GŽ¥Rɬ×ëf«Õ2 …‚Ùétr…B!çºnÎ0ŒœeY/zž÷ên¯BÁ Öä§{ü T.àUÇŽàU78®‹åÕ:–V븰VÇ…Õ–Wëèôúh¶»h¶'ž5MåÀf¾Äb»¿Ûnd€Ÿ@¤ÛwÁ˜¿¾ ôø¿©ßéÀ“ÿ|bqa~7ý÷´“»ë®»Œ¹¹9£ßï…BÁ䜛Ýn7 gšfÎuÝcÌ"¢œiš–išVÞ²º½^¶ã¿0aTÜ(+—Ãý³8®( úh¶º¸°VÃj­‰v·‹v·N§‡v·v§‡N¯^ß·û=°‘¦]ÛF¿ÛA¡4˜ ø—&x/O܈sUõ'º<£Ùy´S»ë®»ŒZ­fäóy³T*™Ýn7纮€•Ëå,–ëºyÃ0ò¬|>ï@ßöUoÎù¦¦ûâƒøh|¬„±±®?"6$éÎ9ÚÝ>º]Œ.ñ a½Ûý­z V¾:#°K1µu5ÿÿß-.Ì?v¹Æ³Óh'&aXþååe«Õjå»ÝnÑ0Œ¢eY%×u+D4ÆçœWc& Ø,—Š>$q8}¦Ò¨àOÖéË ÃÀx¹„½3˜žÀþ™*~ð ò:â­úú çð²'Çî£×n‹EàöË4œI;Š0ÆØí·ßξøÅ/ù|Þäœ[•J%ošf±P(”Ç©xž7fÆ8缊ô¦iN†1É9Ÿ, 9 TÁõ4”F=ät_¢/‰¯øÿÛgà[ŽÈÒÞîõÐkKŽ­]‰­Zb}ÏÇæ¿ÃÙ©´£áøñãì›ßü¦É9·\×-˜¦YP6M³Ÿ1žs>éyÞç|À”ã¸{ „ýš†ü$…=Kú§•þÞuK¥¼<ý×Þ¨«‰-v) N« ON§Öð¿_¦áìXÚQ àöÛogµZÍè÷û¹n·›7 £èy^…ˆÆ8çUÓ4«œóIÆØ$M2Æ& ØdŒM2Æ&LØŽ= •’¯v†úFSýÓÀŸMÉÎÇ ï~­ì’!"4kkúzù‘Óï¡ÓØP‹cqaþÅË0œM;†° ì®ßïÕj5W*• DT¶,kÌ4Í*€ Ïó&c“!˜ ¢*UmÛž€b0÷/j£Úýz˜ëä;ÒøŠTý-GL¼å˜Ì\ÛÖ½ì/[rµSü€?¾ ÃÙñ´c@¨þ Ó²¬¼ëº¥PòÑEÒÞ0ŒI"šäœO†QeŒŸŠm;〿TðWÚ›sú%þäÄ’Ûn1qã~ùÏÒi6Ðïvðr'îyh¬­€HòßtüÔnʯÍÑŽaP«ÕŒ\.gzžWP@=J|“D4 ßù7`œˆÆà§Ý,»®;Öj·' Ã@.ç;ݬ\n›<þ[?àïüÞ7YØ;.ûšëkèuÚx¹¡±¶ªË÷ÿÝå¾›§Ŧ¦¦X¿ßÏqÎ †aTBOhßsÎ'‚©¿qcŒ± €2•‰¨tú¥³8çæ¡9?€Ç` VN ±Õé>± üiW-怟ø6 %%=@«¶þ²h®¯é¨Þ à.Ãp®Ú1 àöÛogKKK,ˆî+zžW 4€jü*clœˆÆ Ã(Ã~‰1VP`Œå_:{vnºþ@Þ²0Ø8ß¾é>}]²ÌŽ1üÄ­ jÕkè¶Ë^¯Zò%ÿŠ˜ä#¤¯øÉ]Õk´c¸®k ……À8€ªac"UŸˆJDTPgŒY¬¥å•½0·w PÈèÚât_mü!5ð3oË£¬hí:ºÍdòÒ«¸çacåì^"Í×9ï^\˜¼èb—2iÇ0€~ðƒ¨T*Ì0 Ó0Œ<ç¼JzeÎy™1V4 £À‹@ GD9ÛvòëµÚT¹T@!˜(XÖ¶L÷¥Iÿ­€?¤CS ÿêmVbcÑvc㪞pmõ• pÄöe]øà?§9m—F¤Ãîºë.V*•X¿ß7á»HùHÅ'¢<çÜ"" þ:3øO<õÔAιñš›Ž¬œ K‰µ¿TÓ}C7Ʊ·ÊðsïÈa¦"3N³‘æÛÑdw»ØX]ÖÝ— à}‹ ó_» ú*iÇ0h6›Ì4M溮ID9ÆXŽˆrD$‚ÝdŒDd±cŒ=ýìÉÃŒ1¼òÆ#€J±(õ}©§ûTð§1™°|ªÌð¯ÞžÃÁI™ ؽ.j–® ç ¡½QGc}U—Ï à=‹ ó» C»jiG1‘LÓ1"b!©mˆˆÀ¹óKÕZ½>qí¡}°r&c(Òrö]¦O)U10^dø¹wX¸õzEs!ŽV½†Õå:lw»¨]8ŸæàløîÅ…ù¸ÄúêiG1€ññq²,‹cœˆ<Ƙkš¦KDü`xPGðqʼnˆòÄ5ðú×” ù”Í@/žÇ?í„aÁRξïµ&Þÿ­¹„sÐé÷Q_^ÚQBÏuÑX[Ac=Õ”YðŽÅ…ù/]⡽,hG1€n·Ký~Ÿ†á‘~عŒ1€Çã¶m³SgÎ0ÃÔ¤¿ùF¹/¿q}y¦û’”~‘Ž0ð‹ÿÂÂÑY™‘Ú Ô—/è6ɼbˆˆÐi6P_^ÒyùC:àÍ‹ ó_¿„C{YÑŽa'Nœ ©©)âœ{œs›ˆzðÃ@;Œ±€.õô¦àpÖkõ¼ëº9NmÕe°0üWO£N÷ië7 þ—h¢Äð¿¾ÕÂw½ÚD^‰p«+¾ZÝl©Ï.?¹ŽƒV½†õóçÐildå.ü ¯]\˜öïeG;*#P»Ý¦\.ç2ÆúDÔ¿4¤'ø&@>0 ,"2÷íݳ^(ä{ý¾]ô8‡aèôú¯)·¶ìñßð¨K!ƒïx…‰7\kàî^äÒ¾ƒžë¢ÝØ@»Ù@¡XB±2«ÜˆôbÁîvÐm·àÚÉíÐz ÀÏ,.Ìú íeO;F€J¥Â-Ër‰¨gYV‹1Ö`Œ5ˆ¨ADMÆX“ˆÚˆµ‚ža½ƒssgàùSþ®1Ínv°¸& üC{ü“4òtŸÎ¼Æ ÿóërø¥ï´ðÊ9ÍŸ•ýn«Ë‘³MYO¿­ä¹.úÝŽ/í—ΡY[üàU»à¿t´“ö`ú§šë÷û¥B¡0É›fŒMÑT°&` A aøQ€ëÅÓgöúîϽ“sn~ç[¾GïàÇTË%? :Õ;¦ûä¶b÷Bϯp|áiŽç—y&caŒÁ´,ä¬ àwws÷_~Ú1 n¿ývcnnÎt]·ày^©P(”\×­0ÆÊðó” Ã(Q ¾-"¢ÚFqÇ0¶ç†“ÖÌ«–ê=s_צ=¶K“¶¢&¸ðíúÏÀWó\\˜ß™!ŠW9í(n:77gNMMåjµZÞ²¬¼çyÎy!y|"*À_4T@`0ÆòœƒÍVkì«|ýM§Ï¼ôÏ:Ýîžð:ûf'qËñë1·wÅ‚¥ªiÈ™r† Ó4`š&LÀÂ&sºox? ÎÁ‰¢q’Ž9çð4N7ƒ18Ž‹åµ <÷â9<ýü™hÞÝ0 {ÏìÌ7ŽßtÓ½Ç_qãsDdÓ«v£Ëñìygæ¥ugo½Cû:}¾Çã0=îï<̉ "ƈˆ° ‹¼à4üàs‹ óW× ¥«”vˆÝu×]Ɖ'ÌãÇ›KKKÖÄÄ„Õëõò¦iæ{½^Á4ͼ(ù ÃÈ3ÆòžçLÓ´8ç`ŒåXO=óìõO=óì›–WVnöüzÿbŒá†#sxÅõ×`vºåÌ"ƒ1†á3f°(ø€–3B0S°Åwöþ>Œ1ôû6ÖkM~rvfú©®»î›×]{ä ‡sî"ˆ–4 ÃÊlÓ4mι ÀÉårv¯×s,˲8NÇ™™q<ÏólÛö>÷¹Ïñ;ï¼3Þ]u—ví8ÈL Ü"Ì4M³ÛíæÇÉ …\·Ûµ,ËŠ˜‚ú 6À?r00‚cÌ`.¯¬N?sòäñ¥å•Ûíön¯·s®U y ¥b“Õ &«TÇ+¨”‹(æ-är9‹€iŒù|zÇ…í¸°m}ÛA¯o£Õî¡¶ÑD³ÝE«ÝÕªú"å-«V*—ÎNON<|èà‰W¾âÆç ÃpˆÈ5MS~°fÂIûضíær9—ˆœB¡àº®ëõû}ïæ›oöVVVè‡~è‡vÁ¿ÃiG2ÀgDôÿ·wn¹m+Iþ«ºy‘iI–'9°g ä-Î"¼Š¬'ñz²ŠlB~Ëœœy|“u!»«jÄf(ŸœÌˆ£ ؤ.$Eý_)U5Ò ¡u]óÉÉ Ïçs×4ϲÌEá™ÙÅ=€LD¼÷Þ‡2çœWUïœóÌìE$뛟ˆ\¿ À©ª33ÿûç?~û×—/g77·ÿX<>þ}µZ6!ŒEdðƒŽ]2¸®ªêñhøù·—/uvöÏÑhøˆm"T—%ID‘ˆ¢ªFfŽ"E$fYÒrŒ1EÑ™>˲¨ªR–eTU©ªJ§Ó©Ð}Øÿ<ôÓø ËËKÂöoÍüêÕ+nš†½÷.ÆÈÎ9ÇÌ.„ÐÁ€™ˆxfv iÊóÜÅ]2~*6ÒÁ13›;çØÌ››×uϯÿ=¹x8Z<>Ž—«Õx½^ÅíMÊLÕ¼ª´óíö03"Îq`v™‚c˜¹Éól=(w‡‡ÕíðððþÅߎoŽÆãÚÔgŠ6€¤4iâœ33fŽMÓ3Ç4™™Ä£ªŠs.zï;³F#¹¿¿×º®Å{¯EQèd2Ñétj{ó?ýÔè‰.//éüüœ^¾|Ióùœ—Ë%7MÃ1FÇüððàœsNU¹­+èˆÈ5Mãú@èŸS›ˆ:(`ÞŽ'Ö?µûˆˆˆT•˜™T•€¯£©*9ç mþ;3oÿxlfÌl"‚6¿É¶¼é:Óë6½¯['"Ú7| Mmx™¤ú mV¥¨ª Y­VZU•¨ªdY¦××ךŒ?›Íìüüܦө½ÿ>íÏ^Ï@Ï;Ñ@º70›Íèøø˜bŒüâÅ !pŒ‘E„«ªâÕjåʲäÍf㘹ƒƒ™q–eBpÞ{Ž1vódzï=oýɔ֥¶ªvè›Þ{þ:`kz` #˜¹ƒ@ßü}¨ª%ã§)Æ˜Š¡ˆ÷^ˆHCšç¹4M£yžK]×Z–¥¨ª¬×kef­ªJ²,Óûû{õÞkžçºX,ì©ñß½{g´½_ñ<¾0{øÉÒ¿'kkƒ™™]^^€¤ˆàêêJïîîx8><<¤33¯×kÎóœV«• ¬ªlf¼ÙlØÌ8„ÀyžsŒ±{Ì9G"’"Jæ‘j÷…¼÷j!¥öù;ûîœë Ddý¨ U9rÎYtPU Þ{efmšFED™Y³,KPЂzïÕÌôññQsJDz}}mÎ9L&VU•ÞÞÞÚÍÍŸŸÛl6³·oßZï#þ!çr¯§gôõäÞÚŸ éôô”RT0©ª*úôéˆpQ$"œç9‰gYF"ªÊÞ{RUVUvÎQ ÚB¥”‹1’sŽ€í>ˆÈÎòòà–“IDATSµF†sΚ¦÷Þœs¦ªæ½71U5±Á` "b1Fcf}:gf !Øx<Ö‚9ç´ióÞëf³1ï½.—Kë›þõë×6ŸÏ Ø_i¯ó±ïõŸ·ž%’|Àv¸±vÄ!®ªŠƒ- *Ë’Š¢ ù|ÎEQPžç”eÝßßs–eä½§¦ivæý6„¨,K„bŒT>©FœcìND–e¶Ùle™eYfëõ"bUUiŒÑò<·£Å­( !XÁʲìÌ^×µUUeÉð“ÉÄ–Ë¥- ;99±dú?"…ù°õ-=kôõ=\\\àêꪋ`8Òíí-=…Âr¹¤¢(ò<§õzMyžl6ʲŒêº¦ÃÃCÔuÝõöu]SUm3›¦éÖçyÞ€>Š¢0!tó²,­ikßÏvŒ«ÕÊF£‘¥>~:âôôÔÒu=°íé­éÛÏhoü_L¿ ’Ò ¸oÁú@¾FÀW(œáöö–`0°³ ,·Ã÷,—K€ããã®´Z­èèè¨[¾»»윈ªªìææ¦k'ƒ‡Cûòå F£‘µÛ²Édbí>ì˜f³™µÇg>|À›7oö¦ß«Ó/€§úVdÔ‡¤Ë˜Íft~~Ž` àìì O×}OÉÌŸ?ÞY¶€“““ÔƒwFOá|»ß;=<ðÕðí1ïM¿€=vô4:þ I ÐEI³ÙŒz¯ýËmO§Ó®LÝWßàí{í<ç{foi¢÷ú“öø/ú€?ƒ¡¯§øÕ7w_}£Ø©ã·7ü^ÿ‹þ/ÞjMÉêúIEND®B`‚pithos_0.3.17/data/media/pithos-mono.png000066400000000000000000000321671175056731700201710ustar00rootroot00000000000000‰PNG  IHDR€€Ã>aËsBIT|dˆ pHYsÄÄ•+tEXtSoftwarewww.inkscape.org›î< IDATxœí½yœ$Çußù̬ʪ®¾ï»§§çr\bAˆ Ä²I{eCÐ~LÒ®m­hÙK™Àj-Ú²%Q–‰´ÍýФD¢(J‚V0$âN˜!f¦LÏô}wWÕugæþQ9YYYGwOÏŒLþ>Ÿüä™ïÅ{/^¼ˆ¶móüøB¹Úø ®.~Â?æø ü˜C»ÚøqA4Àç€9Ã0þèj—Gâ'àÊá(ð;ÀãÑhôÿ¾Ú…‘ø[!žyæ­»»»IUÕ&!DÐ4 !š„*°æÞ,ËZÍd2k†ad®f¹=¸À4MTUý•h4ª†ñÅ«](±‹Í@Qá^Q¦çÏŸonnnBìÅEdÛ¶#Û,CŠSض½*„˜^Þ^YØfº[F4ý3à§ÆÆÆDQ€bÆo_©2øár1@%babb¢9—ËÝlÛö-\"ú¾­¤q1¼!„xx]UÕ7†††f/w&ÑhT–MÓl|çwhhh ¯¯òáS†a|ûrçY+vÊ5mll¬Ë²¬¿kÛö§ÉüZÆ(ðûŠ¢|kxxøíË‘`4½x)N366†mÛ´··ÓÞÞ~Ê0Œ§.G^[Åv@AyâË{bttô=£££O˜¦9eÛöorí`ø˲ÞýáùóçbçÒé€\.G(`ii‰ € ðÇÑhôªü›­J€JDw066vÀ4Í |l;…Z[[³âñ¸•L&­D"a'“IgK$vá‰D¶,‹p8¬ÔÕÕ‰ºº:á9v¶ºº:ÑÚÚ*„Ø-hÛööíÛ÷Ìv^–úuu•L&ÃÊÊ Š¢044„®ë‹À1Ã0Îo'íb+ à÷ç¼×Äèèèïa¿ŸíÍouuÕœ˜˜ÈŽe/\¸Í­­­Y…´…¢(ض-€BP ¤$hÑ}÷q!?ˆp8,öìÙ£ ©…½ÒÕÕµ¦ø¥‘‘‘_ëÃÑhôGÀÑ¥¥%EA2@ `Ïž=¨ª p‘<Ìm5í¢V(!´ßñ… > üy±æ‹éééìk¯½¶yñâÅìèèhfmmÍ–(Z!ŠŽ]Rd~Š¢1€dEQœcÛ¶±m»(=w™mÛ¡PH ){öìQn¸áåúë¯¯Æ _ù×Už)B4]Zçææ…BlnnJñ@]]¾åGÀ{ ÃXßJÛE- P ñÅøøøˆiš§€€7˲8uêTâ©§žŠŸ>}: —ˆmK ùSä¡ÈãqåuçY7#¹˜@Û¸¤‚C€¾¾>åÞ{ïUŽ;&‚A>¶mû£ûöíûÓj? †Àôô4õõõd2–——‹žknn¦»»[ž>|È0Œt-yìÕ ñKö/^ü¶mÿ#÷ÙLÆzþùçãO=õT|aaÁôäéütW-…Kµ^ñZQ”¢ †(!¸<–—ßjY–W=Èr9Ç‘HD¼÷½ïïÿûEKK‹÷¿¼822rG¥'F÷ïLNNÒØØˆeY,..bYVѳ´µµÉÓï†QüÐeF%O`-ÄwY±ÙlÖþÊW¾²tîܹ \Ùî4$Q\bÛV 7Á {Eò¾[H RH“Â^)˲°m[X–…eYBeY!„‹1Dá@$ þò/ÿ’—_~™/|á tuu¹?ï&òê¨âôË7ÁƒÁ ©TªèÁÅÅEþ#ðÙòØ6ju—#¾{s䥂T*emllتª:MM·n¦XäK‚:Ä•PUUU•ñ%C8 àâYÓ|-·mItÓ4±mÓ4…eY(Šâ¯$T@Õu})™L …Èårœ9s†§Ÿ~šŽ=ÊáNjĭ4Üä13…ßy¹k— Ùl–7Þxƒ—_~™3gÎ`š&ûØÇÜåžØb’­—lîrg³Ù¢‡4M£¡¡A²%­ƒ¥¥%4M£¹¹àD£ÑÓ†aüÆÖ¿°ÕT€Ÿ»´¤æ6%—Ëu!Èf³¨ªJ*•BUUfgg™åÉ'Ÿ¤££ƒžžz{{éíí¥»»—1—Ïh ì†eYÌÍÍ166Æøø8cccLLLÍf‹òO§ÓN7.Pâ,‡B hä@¶í"ß/ÏŽŽæççÉåŠ[šsss¨ª*Uë¯G£Ñ· Ãxbk_] ?¨¥öûJÓ4Û’É$áp˲d3Èøââ"‹‹‹œPÂtn€¼XZZr˜À[¾ééi …Bä[·†QÜ»T#j1å¾’þWÕ²¬ãB–——Kj¿W×ËfZ9b˜¦I:&•JIwnÑ;²És?W0 }Qî^<guuUöÖ=0::ªŒŒÔâ ‚¼èþ^ýïºgC¾u …°m›¶¶¶’žC˲˜œœ”Þ½Àw£Ñè Ã(N¸ø…„•Óý ì€Û¶ov‹ù‚Æ‚g¯d/„ àô)zVJ ùŒÜä= h/7(&¢×!µâŒŽŽ299)O[€Ê>\ŒA(mú€,ÿòª`uu•úúzßæ´išLNNJ;ánà?ÕX¦"8Ô¦ÊÕ~eccãªl²\¼xÑ!š›ènb—;–ïy77¡Ýǵ`»Ä·m›ññqâñ8KKKòò¿ª1Û"p{Ýp1ÀðËÀŠ´–——ikk+²‰$²Ù,“““RþB4ý?j,—ƒrA¡Õ\ÀEL°¾¾Þošæ'„Œ9âºý®{¥‚ß³’à~ÌPÕŒÅZîÙ¶Íòò2o½õ–¼uóèèèO—}ñJ$€Ûø“p©€yÃ0–È3 X–E2™¤½½½HH¤Ói¦¦¦$SýF4½¯†r9ð2@¥f_Y#pccã1 °²²B<çÔ©SE"ÛMH÷õrLà•nB»Ó”çÒ¨ô“ n_ÃNðÊ+¯°¼¼ÌÄ„ãøíÑÑÑÆ*¯ @upI€ùÂþ¿/tuu±´´ä´–üL&™™™¼DþÃh4z°ÆÏr`+À"âÏÏϲ¬;!¯+¥ñ& å&b5âWb ?©PŽèµ|+’Á4MÆÇÇ9qâ„ôÔ ÕDû /ª%£zÛöPʆaØÀgS×uˆÅbèº.ƒEJÇe´qðÿ|Uá–åš~å:ÄÆÆFo&“ùeȇ;e2Nž¿n_ ñ+À\¡„µµ5„477õ,ºË7;;+¿áßD£Qá‚[”Óÿ¾ânnî“¶mß ‚L&K{{;‹‹‹%µÑO<ûÕìZlJÆ`¹¦ 7D‚œi¢‚˜– åUA¯úúûKŒ7/‘ül÷5÷Ï)'üÒ¬f–Cµ{­­­<øÑòë¿þï¸ëλˆÇ7xýµÊGüTAwIG–/ Wü>ð¦‚ÖÖVÖÖÖ€|xÁ3Y‚X,&óy$¾»ì‡‘÷Vsü¸>Až~ hHg2¼óÎ9¦¦¦XXX ³³“ë ƒû?t?çνÍÛo¿M,Ë',ŠB·P¥dù.õ¢ÛÓ'Ÿó2ŠÛµì­åò¼RGS9âëºÎ‘#G¹ó®»8pà'Oœàë_ÿïœ={–P(Ä<ÀùóçeœÀ×FGGŒŒŒÈ½Pì¬Ò€¼ †aØÑhôKÀ÷š››Y^^v\íõõõ¤Óéµ’ËåØØØ ±±Qÿ øYßÄßì'úóÙÙÙ‡,˺ @…w¿ûv,ë%VWWY^^æÅ^à•—_¦··—ë®»žO|â“ds9¦&'™žžbjjŠx<^D°|ZÅÍ:Ip¯‹×­ûÜD-çØJ£‚þþ9Ì‘#Garr’þæoøêïýnQt°ìñlim#ΠëA© þ÷Â#}’5ú,`©äÀ0Œ?‰F£o!nnmm%‹ÑÞÞŽ¢(ÔÕÕÇKÞYYY‘-†£Ñhc¹‘F¶méÍ+qï6Mî×ÖÖú×ÖÖžB4Œ399Éêê*7Ý| DÏœáÍ7Äúz>/)¶»ººèëë§¿¿Ÿîž67ãLMN299ÉÔÔ›››E!\Ò‘äêôÛÜÏär9G̺û ܵ߯ ©©‰ƒ×]ÇáÃG8tèBΜ9Í™Óg8}ú”ó-Ý==?~œ;ŽÝÁÂÂñ –—–8~ü¸$ô}###ÿ3~økkklll ëºÓiåFKK‹ŒöY0 £hðÑhô§€?³,‹ÑÑQš››Ñ4 ˲ˆÅb¾ÍË¡¡!©&6 ãëå@ª?7¯æÚk“““ß°mû½ét†_|ÉÉIPU•®®nn¾åf¼Žé©)Þ|óGnÿyQ íî¿Ÿ¾þ~º:»H§S¬Äb¬,/³¼¼ÌÒÒKKˬ®ÆÊßÜ=‰R,Jfhll¤··—žÞ^z{ûœ®gé¶>}ú§Ofjj²¤sIUUn¾ùÞwÏ= ñÊË/óôÓO311N(âãÿ8ýýý\ýõ9’N§ÿøÀÒÒ™L†@ @</‰ûs:eFÅžÆh4ú6p`ii‰ÍÍMg”Q&“ql7ÚÚÚddó ø·øvﺙ`vvöSÙlößd³9Ré4?øë¿btt´ÈB…Ã9r„£GŽ‚Œ1>>Æôô´Ã¥îŸ«( ÍÍÍ455ÓÜÜDsK ÍÍÍ47· i+Ëˬ­¯“N§È¤3¤Ò)Ò©´³O&X–ítë¡|wq8& …imi!¨ëÌÏÍ255Íô´Ü¦XZZòµ Âá0‡æÈÑ#ÜpÃÄb1ž}æi^|ñÅ""ö÷÷ó³?ûw¸Þ0ˆolÐÜÜðµt:} 8477ç|çÆÆFIGPww·´èÿÊ0ŒTa€üšiš\¸pÁñ Z–ÅúúzI7s8fhhòêeÐ0Œi?pI¨”Š-‘H4.--½4KÑŸH$y÷í·cY&?|õUÎ;ç|¨Ôå ïÝËž=ô¶´0==ÍØØEÆÇÇ‹D«Ô²¶ÊÚ¬ë:MMM465¡©*ªª¡j*-€ÐÐT@0€mÛN @"‘È‹ÚD‚xrÄÑÿ§NâÌéÓEC¸mÛ&Ô¹óÎ;ùÀ?ˆ¦©<õ?ŸâùçŸ#‘H ë:>ø GeppБcccD"lÛv7ÏìÙ³Göôý¦a¿X‰ LðÀý©TŠÉÉIÚÚÚœÇK¤Ààà Œgüo†a<âMÏäÛößÜÜl6MóÈûú³³³ŽõúÖÙ³¼sîûöíãÖ[oãÝ·ßNôL” F‹‰Ñ3gˆž9ƒ¦iô÷ÐÓÓ̓9vÇ„Ãab±‹‹K,-.2¿0Ïââ"ñ ÒétQ ÁÝt,çc?Z×uº»» ñ‡=ôtKK ±XŒ™™οó_ù­ßdbb¢„@Š¢0<<Ì-ïzwÝõ^¦§§ø£Ç¿Ã‰'Šš_étš¥¥%Þ|óM9@‰-â…«Pë¤ßî—ÝÃÒÏùÈ%/$ ɇüóúJš«««?4¦R)fgg9þ|‰GàÂ… \¼x‘ÁÁA®»îz~ú§†t:Í… £œeqáÒŒlù8û‹\¸0ê\ÓuÖÖVÚÚÛiokcdß­­­´¦e’L¦H¥’¤’IÉ$››y1ošf~œ$ l>¦( +++ÌÌÌ033Ík¯ý™™fgfŠ,r7qt]çúë nºé&n¸ñFr¹'Ožà×ÿí¿q÷–¼÷ÜsÏñÐC1??_¤Ÿýb”w—ƒ3üNÎ5 Cã@Is×Å×ûæOišŸ€<ƒÁ £^ÎÛ655Åôô4Š¢Ð××ÇÈÈ|ø,ÛâÂè.\eff¦Äi“J¥˜žžvú¶e“Páø‘ßƒ…Á•Ø8Í«ÍÍx‘- ³ÙlQ ô3ú¹ñÆ›¸ñ¦¹þzƒ¹¹9Nž<ÁoüûWBt÷{nÈøÆÅÅEÚÚÚŠ|~ PÉ \†aLF£Ñi /‰°´´ä‹H&pK—ÑÙFû¼†`E°°°p3°G†OOLL”½œ?Þ²,¦¦òŽŸçž{Žîîn†÷îåž{ï%ÔY\ÈëþÅ¥EX]]-1¤Ç0Q¨é•ìyì×ôª¹¹…þþ>úè `€ÎÎNÞzë-Nž<ÁÿøÆ7|õµåî½ð ÜpC~´»°J'Ô.^>¡ëzI»wı§Õa% PÖïŸÉd>°°°@0tâÒýt®{677ÇÜÜ/½ø"MÍÍt´·ÓÞÞÁu¯ãŽcw ‡t–—–Y\\`aa‘åå%R©TQd¯ƒøõ7HU …ƒttvÒ××Gÿýýýƒ¦¦¦˜œœäüùó<ýôÓŒ5ï¶ÛoL&A$î8€Ý`€@ à¤-ÿƒ¤,£4¬ ÷‡JÊPØûJ˲îƒ|,º{¸’_Ÿ_¯_9¬­®²‹ñÎ;ïù … ú¿žžn>Œ  ë: Žù'©d’T*o$“©|èxÁ'|ÛÎë>©æç癚œäé§ÀäÄDÑô,~ÄÜi¡¬uÕT€'x+>§ /}5MÃ4MGÿ ‘ïfw{s¹œYRx·Àu,VWW{Él`eeÅ òt£Z¿{¥û^c%™L291ÁÄø8@‘o¨h—jZ~ôQ"™tÚýRÿKÑè6¼MA?ì”øîc· ð{×%– èu çYé®WÞ%yø©TêȇišÆòòrI—,TgY€rϹ+žÂÝËf3¤Ó©"À=Ƀw«ágléÞV ûêÝÑÀ[ ©‡~iW¨¨% à"ašæQ€õõugHu9Ñ_Íð#¾Ÿ%^‰[éÛ¯†Z˜Ö‹­0Ž›*ÛðH8îW¹¼C®T_pK¹–e„<øÞ©_!*‰>÷{µÜó2G%f©%ÍÝÐûõõõNÍöŽðbÀ$àí"÷kõTcðW7¦$'—«énâ{ÃÕj{9­\³®Z~Wòž;TËÝÇá‡0À]2Édò¿{»›=zÌ›W¸Q­îfF9×"¢ËuĸkæVñcŒrùU+çNîIpç]Žü¢kÄ=wžÙ¶í¨hiü–Éà¤7!¿ÑÁòX‡â@Ær:ß ¯Jð3ì¼ç^BûmÞ÷üî•K¯\9Ëa»÷¼ \^ F;Ã€Ó •ËåˆÇã%ÄÜ[Œ†±RR×±— BÒçîp€R×"ö+Ýó#¦{ïí&v÷òUêâ½*¡©© Èû*ÙAÛqð~ ´’5^Æ\úÁÅoøÝ/ù•,œ^8y¼]TS nzÛî;Ù*•e'-‰ró o¡ 52@4 +@&“ñ›Á´.8áw¿ÜàPD~%g"‰ËñÓ¶BÀJÌà7Ç@9Â×ZîJ¿& ß{Õ¢Ëà뀒9ü 'Ø(ÀWTe€ºº:Çȸb³š®wÜÛ¡ãV²ãÇûž7írùïä*Ý«Õ ´U7p4ÂUûýb½pÕþ8®nd7¼*À.lÈÁÕDêVZîcïÜ9nB{‰^‹jð¶j‘åÊ»{µ:\`¥Æ™=~¨ƒüÀZàj’~«\X¸[}‰ªªS¶Ž”5±*ýX÷}¯a玖ñÖ|/áýÂÆÝ×e:åô{¥óËE|EQœÑ»ÞYA¼ØJ þsà£õãU+‡p8ì–¿[î¹r*ÀÖ4mòž­\.GSSSYcÍyɧ¶ù‰e?¢ùõãWb ozò½rR¡.§Jhjjrj½tÇîÔF?MaŒ_2™da¡¶µ. “J¼Tiì —ØwÛ---o !ÖUU¥©©É™2ÝM87¼„v_÷c?ÏoPˆ7 ´’j¨¦ók5«=[îž{e‘Ëá,,8ù[€ÈårLOO×ô ¡PÈ™ø/•žõªgS%'„x òÜ䞘À¯f»ý6(%¾7’×ïZ¹Á•|å$”[aˆZ u®wRH?Ô¨>Da^ÂÙÙYßÑ?~pÍ$2 <^éY·(‘š¦='”^&¯áVÜùåÀÍ~÷ä9”N;·S›e;÷¤¨š5«€È©ækA$‘³ŠÙÀ?4 £t1¤°}6+?oÛ6õõõ…“ý%D•Ç~?ÕOWjÒ•«ý~5ßk/xó«fŸ”ÃNî¹@ÕzC=3ƒ•C¨èés#ÐÛëLKð ÃøëjïH QÄMMMÓBˆ W===E„ƒâ¸÷rM¶JçÞîs¿Á~Œà'qÜpŸï¦Aèm–3@·àVË[BáŒuÎR˜i¬¼* D (Šòן­JÜ[ËÜÍÄjµÞ«ÿ½Ä—÷¼Q¾å$†×Ò÷ª7v«æKxÝÀ²<^lÁ |ŠúóË¢³³SúlrÀß’(€žèIDAT¯&ú%¼*Àrí-ÀŽD"ß¶mÛnkkCUU®»îº¢Ÿìm¶¹‰ïnšù ßòÓçÞïJ£k!¾Ÿ±êÅåb Ùôª&¶à~ (;=œDcc£d¾ðˆa¯W|Á]|j=8kâYÍÍÍãñø ¶mß900uî!Zî ü$B¥šìgxk»Ä¯EQhoo§³³“®®.ºººhhh ÇY[[s¦’¯oèµj0mòÓÖÃkÀÛuuuåP6/åÆqà“†aüeõ/¾7È›ÝÇ`o¦Óé;ûúú8þ<]]],,,8?Ø=†7Ú·øÕ~?ñîç’éV3B½epcÏž=?~œ}ûö9³mTC&“áâÅ‹¼øâ‹¼òÊ+Eýï‘HÄ©ÙrörÌâ‰.ë6 ÃŒF£Ïwuu)ÎDÜÒëXhžÏ†áÛãW å$€bf{{û_MOOÏ©ªÚÝÓÓã,!k¿„ ñ“n‚ºkx¹€ŸÍà­íÞ´Ýðýš¦ñ®w½‹÷½ï}%kf³YÒé´³”Ë¥ÐsÂ’uƒA<ÈÁƒy衇xî¹çø‹¿ø ‰ï{ßûœ´r¹œ¯ïûÓkk`Æ‹Ñhô¿‡õÂRnœî7 c¼ZZ¾e)ì+I¡ªjVUÕ?0MóŸ 033ã¬|)žßàrÆ`9Ýï¾çgdúYûÕˆy1yÏ=÷p×]w93oKñžN§™M5MsÖíq—Wu 477;ž¶û￟cÇŽñío[NÄà¨ÄT@MÝÀ†aü—h4ú<ùIª‘_ôiòë Ï0ŒÒI‚j„œ Â= Ô;I„ ¨kkk=kkk/Ú‰'XYYá…^pt¿7LLþ|ùüDy9c°ñýD~µšèÐ!~øagâåÕÕUb±B"‘º®£iZ c¹Ï-Ë¢°z™Léojjrˆ™J¥œµ’.\¸@]]³zš®I!¾iÆßß.ñ.Ü*.Õ|ðŒhjjšÛØØø–eY?·ÿ~^zé%X__w>Ò+Ê5«‚~ÍF/q¼µËkw(ŠÂG>òî¿ÿ~„¬¬¬°²²B8¦­­ 9®Î¦ŸÚp«UU ‡Ãd³YVWWYZZ¢­­­h:wiVêAÝŠ Øm¸U€Ü»ÕA¿‹Å>Q__éîîFUUžyæ 8tLÂÏJwKïŒ ^5á~O¦W ñ›››yä‘GØ¿?ÙlÖYv¥§§§ÈQÊ«ªJ]]#Q‰===h𿍋J~ûD_vø9‚¬2›ÙØØ8¯ªêïÙ¶íüÜÎÎβóü6Yëår©Õ,¯¸¯D|€ƒòÅ/~‘ýû÷³±±ÁØØº®ÓÞÞ^BüílÞæ¦\ X.*!™º\‹@¢’$°ZZZþóÒÒÒÿ¦ëzûÐК¦155U2©#×^?±^IÜ—3úœ‚úˆ×žž>÷¹Ï¡ë:ëëë,,,ÐÑÑA0¬©†{ïÕr òÍà@ ÀÌÌLÉÈ\/|˜½{÷:3WVsöx½‚°uâ+ŠÂ§>õ)„ÎØ¸úúú-ÕðZ¤„išlnnú.3»U¸`+SÂìªI€Š†agg甪ªŸüŒ”mmmÜ}÷Ý%µÞ-N½ç~„’çî½÷àÖ[o•“!3??OsssEñíͧ ‘ËåH$eݺ[…KøN ¥áíóJ‚ªþÁÁÁ'„ÿ àÆoĶmî¼óNßÚ_¼.G|y|ÿýù…)âñ¸³¼ÌNk¼»,¦i–¬;¼S\‹Àûu•˜À—Z[[ÿ/दiÜvÛmÎ,Õå\À~bÖý“½•ßñÐÐÿ¶ººJ]]ÝeÑó2˲.;ñáÚd¨Ì嚈ÎqcccJÓ´¿l´´´pðàA>L0ô%ºß5‰jn^‰o¼Èwãnnn"[®$òk%¾mÛÎä —.pM1ø3A-Œ`ÖÀÀÀ;BˆÏCÞ[__Ï}÷åW1­¦—%j%>\b€x<^2~q§[:®ù³¸$À5m”»æeˆ"i0<<üMàÜvÛm˜¦É±cǪêzØñƒÁ 3#·\@©£®–û¹\®ª7o»ð¸¯9 á®ù~×ì ç¶®ëÿØ¶í³º®sÛm·ÑÔÔÄÀÀ@YG‰üñÞk•à÷V”"ßÝŽÈw×þÝ‚Ç xÍ2€D9iPQ5ôõõÅEù;@²³³³°ŽÀ­î‰ ò/–QÕΧégÛ6™L¦(e§¢7ô¾„‡®Ià…Ÿ4ðÞ+yfxxø´´:䬱WŽè@MÌ !ú¤GNÆ&î„øÒ¥»›ðÌØUÛ0ß]FõPØ<| ]á{ïÞ½ÿ ø¦‚Ûo¿Ó4¹ë®»Š_¨ÂåàÖÑ•jÿVA»¥÷ÝpI€u`f×3¬µ2€µ0ªª~x;së­·ÒÚÚÊÀÀ@ÑÏ÷Í ñ¥Ø÷^ÛɶßþVàb€–ˆ¿êظQÉmlïÙ³gxHõöö244Ä{ÞóžŠ1øÕˆEË `Û—ÚìÛÙ¤å%àb€-ÇïïvÊU122ò&ðy€£GbYÇ÷}¶â%K¥J]-V¾_í¿Rp1@Ù;®4vFFF¾ü™¢(9r„ÎÎÎ’VÁVà^A[QLÓtQ®V۽痫“§ÈÙÖ¸†À¸›ø—ÀýJo_?7ß| Ï>û,§N½ÉÄÄDEBx%ƒ EB ë:ÉÂ*"²†U’îkWJôÃ¥€4pæŠe\WŒFFFNŽŽþ¡€¿;?7Ëìì===Üûþ{Q•Ó§OqêÔ)NŸ:U²`£2ò§§§‡p8L2™Ä²,gÞZ@Fï^)ȥ݀' ÃØy`Áe•”_êííUOœ8ÁÅ‹ùêWŽ=ÊÝwççþ¦§§™™a%¶Âjl•µµUb±UVWc¬®®’N§yë­·èéé)šÅTz½ã½ p¥E?àž´é¯hÆU ®d-ýàŽ\._‹Ÿxâ Þ|óGŽa …8pà­mm´4·ÐÜ’_Hºµ¥…æ–êëëóëmnÒÐPišŒŽŽ:UÅé¸Òµœ¥ÜS@ÇNÆò]n\I=ö˜øô§?ý%!Ä¿šŸŸgyeÛ²Ù·?““œ*¦¦¦‚aÛ¶ëLӌضݠ(J…éÏÜnØj^@Û¶Y\\`qqh4êÜBð«¿ú«twwÓÑÑQó\ºWMMMÒáõCàW¹8¾ØO ÈWo- Ö¥ÓéV +ôƒŠ¢ ùuLkhW»gYßýîwhmmuÏ}U¡ªª \±Ï_+?^ì <ú裢££#(„h ƒ=¶mÚ¶½ØcÛöB…K3o”C­ŒñÆoð½ï}Èöô»_q!èë듞¿o†ñòU-Pì †!4M NÛ¶MÓÜcÛö ¢¿££ã( ‰™L¦ìÒ'[• þçΓO>I pÆ+^-twwKI4|᪤ì Äb1%›ÍF,Ëê¶m{@Q”>Û¶{€öÎÎÎë&''ijjòÕÙÛµÖ¿óïðì³Ï rw¾\!èéé‘“F/ï7 cöŠb‹ØhiiŠ¢„lÛn³m»hB4Ú¶]ßÚÚ:011ÁÊÊÖÇV“ ßøÆ7xõÕW  ¹Šwªª288(‰¿|Ð0Œ³W$ó`W ¾¾^hš¦ !Bä×¹ÕmÛ¶µµµƒÁ°œ¢íõ×Kg4݉A(÷_ûÚ×øÁ~€¦iìÙ³‡ÎÎΚ&‚Ü.–ã6ÉÏÛwÍ}T®ü•óçÏcÛ¶iÛvF‘²@®»»»`zzš¦¦&ffŠÃâvJ| Ó4ùæ7¿É—¿üefggimmexxØÝ!sYèïïw{úRÀƒ†a¼tY3ÚEì èºnY–•ReŶíe &„Xƒ àDâìtÅÑj÷Ο?Ï—¾ô%¾ÿýï#„```€½{÷ÒÒÒ²£–B$¡¿¿Ÿ‘‘9ßäçõ}—a?ØvÂW»Òpá‘L&@ضmUaY–e꺮µµµ¨««ctt”o¼‘d2YqÄí_²,Þ~ûm^ýuz{{éêꢾ¾žÖÖVB¡@ â¼~BB¡ ´´´ÐÕÕEKK‹;¢i“|7÷Æa\õiß¶Š]é xì±Ç”H:îBô©ªÚnšfK hÏ{Þóy]×;‰Ñh”‰‰ Âá0gÏžåĉbµ¹Sâû¡½½ûî»Ûn»Í_ ᎖ÒIÓ´rKæÎ¿ü®aµ-éq b·:ƒÄã?ˆÇãM¶m·ær¹f˲š…Í 7ÝtÓ/ƒÁn€t:Íèè(çÏŸwÄòúú:œ;w®ìP­ZË­( {÷îedd„ÖÖV„¤Ói†‡‡9x𠺮ËÚ/ô£vŽü¤Ìo¸¶W¯¥Èžíb×züq5‹é@ªª˲ê… ¶m7išÖqàÀûÚÛÛ?¤iZ;äEõÂÂ+++Äb1VVVH§Ó„Ãagä{¾ÞT*E2™$—Ë …¨««C×uªª:‘A²¿¡­­öövg“Ž"˲6×ÖÖþ¿ .üÁêêêLGG‡hjjÒêB¡PD1¥(ʉZWàøÛ†Ýì=ö˜ÚØØˆD"A˲fÈår Š¢D4M«Û¿ÿÝ ƒÃÞR©”ÙLƾ•Ë劎e®ëÎb×rÓu†††’f eY+«««ß;wîÜ'‰Ål6×4m3N'tCCC&‰dzè!ËÞÅŸtµqEâ5‹i@À²,=„…¡t:­+Š´m[¾®©©éH]] ¯ ýø‹ãmÁ4ÍÅL&s6NŸ‰Ção^¸pád:Ž«ªšÉf³Ép8œŠÇãé\.—]__7 Ã0?ùÉOÊáïÿËâJ„„‰Ç{LŠaŠd Ëå4 „UUƒ¦iE F"‘Æþþ~£¡¡á ªªMŠ¢4¶ˆ¢(õBˆzEQ"€bÛö¦eYñÂ~£°ÅMÓ\O¥RßœŸŸŸµ,+£ªjF‘M&“Ùp8œM§Ó9˲²nÂG£QûK_úRMÃßþ¶ãŠÄ !„mÛ¸assSYZZR[[[EQÔL&£)Š¢&“É@ Ð,ËÒ€bÛ¶¢(аm[ñnŠ¢˲lMÓL!„%„°R©”--˲€©ªj.›Í怬išf6›5Í\.gƒAk||Üúq#¼Ä v3‚a"Šžž‘N§•††‘ÉdMÓÔõõuµ¡¡AI¥R"ˆ@ Òé´Ð4Mhš&âñ¸"s¹œËålEQ¬`0hçr9;›ÍÚº®ÛÙlÖVÅRUÕÊår¦¦iV0´"‘ˆ‹ÅìÙÙYÛ0 [¾NþcC|¸ aáp‰)p3DKK‹˜››  ‹‰H$"Ö××E]]…B •JÙ‰DÂnll´[ZZì©©)ZZZl€ »»»ÛöàÇ•èn\()DÁÓâR†QbF£Qp÷ÝwsîÜ9çþìgŸ}–Â{¶ç‡Ø…¼(äuõ?üÀÿ°§zÀ… S IEND®B`‚pithos_0.3.17/data/media/rate_bg.png000066400000000000000000000014641175056731700173140ustar00rootroot00000000000000‰PNG  IHDR szzôsBIT|dˆ pHYsÞ>rãtEXtSoftwarewww.inkscape.org›î<±IDATX…¥—ÙrÛ0‡—lçÿ¿5ŠW^0dcÊ9X…¢-ØÞÙƒ«î8ýÇÕ5ÖÑX·×øF'Iƒ¤«ÃžCÒ^ÖVÖÚ‚ù€¾žÊs î ÀR –òùt’0n€Q¯ `ÇZÊsX$=%ßô’>à» Ä]Öòœ ~~0ãe‡8 ]9Ð×^ z~‚ß ’¾ E+ È´qBw-ËNã• €ôEï @ãgÎ$@†?Šü?U`9¶°÷ƒÂ{éµ ,3='ˆ¿#ÀôÀ¥wZUëü¡¦¥‚•p5d®å{g>¥oиï4˜Š0<¼•ýÏ0~6)tª !bˆO½ª0Àv¿YíðTÌj¯I¯P `%\ T`U]¦ °IZ `ØáäËyA:Õ 8CvV…C39y\Ëý D‚dHüt% Ï¦qæÅ(i ×O¸S †Ãåhï{ÕoD>«‚IU¨ŒÕ!Œ{ÄPŒ=áy«ùìõË• -¨Tä«€­Å8 ó¹ÊaÖf§ºIäÕÝìõᣤª+ân—ã$}?¿±w×kŒ¯·Øs;#Ž7ö0ï÷Mu|ó^W,±‹3Iû˜4<ãdÓ*%7™UW#rHgn*0ç7Ö,[*gºQW“ñó‹®FdæYqߨ@ÊšÃG*÷v6™UW'ôyTÁkÁy«ÐŠéé#6±yø­fÙw<×zÚð¯€8a pöfxš3œëسŸ“|†!2$O¸çôJÃ9LÐHÎ{œÇ|à:Ö:=gÌiÀ-:'¢ü5d£U8àL®^áuþà`)¶Þù†´ÁY¡F¥L²Vëd™rÖ{7–g½8T—g¸<”³à]‚¶T¨Âñ°•[³‰ÑþIEND®B`‚pithos_0.3.17/data/media/rate_bg.svg000066400000000000000000000065161175056731700173320ustar00rootroot00000000000000 image/svg+xml pithos_0.3.17/data/ui/000077500000000000000000000000001175056731700145345ustar00rootroot00000000000000pithos_0.3.17/data/ui/AboutPithosDialog.ui000066400000000000000000000031141175056731700204530ustar00rootroot00000000000000 5 ../media/icon.png normal False Pithos 0.3 Copyright © 2012 Kevin Mehall A Pandora Radio client for the GNOME Desktop http://kevinmehall.net/p/pithos ../media/icon.png True 2 True end False end 0 pithos_0.3.17/data/ui/PithosWindow.ui000066400000000000000000000623261175056731700175420ustar00rootroot00000000000000 Pithos 500 360 pithos True True icons False True Play gtk-media-play False False True Next Song True gtk-media-next False False True False True True True False False 1 False True False True True Options True hbox_settingsmenu False True False True True True Open current station's properties page on pandora.com Station Properties True gtk-dialog-info False True True True True False 0 True True adjustment1 never automatic True True adjustment1 False 1 True 2 False 2 100 1 10 10 True Song _Info... True True image1 False _Love Song True True image2 False _Unlove Song True True image12 False _Ban Song True True image3 False _Unban Song True True image13 False _Tired of this song True Don't play song for a month True image4 False Bookmark True image11 False True True Song True Artist True gtk-dialog-info True gtk-about True gtk-cancel True gtk-jump-to 5 normal True pithos_window error Error True 2 True end gtk-cancel True True True True False False 0 Retry True True True image5 False False 1 gtk-preferences True True True True False False 2 False end 0 button1 button2 button3 5 normal True pithos_window error Error True 2 True end gtk-quit True True True True False False 0 False end 0 button-error-quit True gtk-refresh True Settings True image6 False Manage Stations True image7 False True Web Site True image9 False Report bug True image10 False True gtk-about True True True True gtk-quit True True True True gtk-preferences True gtk-index True True gtk-properties 3 0 True 3 down in 1 True gtk-add True gtk-help True gtk-dialog-warning True gtk-about True gtk-cancel 5 normal True False True vertical 2 True end False end 0 5 normal True False error Pithos Upgrade Required Pithos needs to be updated for compatibility with Pandora's latest changes. True True vertical 2 True end Get Help Online True True True False False 0 gtk-quit True True True True False False 1 False end 0 button4 button5 pithos_0.3.17/data/ui/PreferencesPithosDialog.ui000066400000000000000000000410121175056731700216410ustar00rootroot00000000000000 333 5 Preferences center pithos normal False True 2 True 14 3 5 5 True 0 Email 1 2 1 2 True True 2 3 1 2 True 0 Password 1 2 2 3 True True False 2 3 2 3 True 0 Pandora.com Account 3 5 True 0 Options 3 7 8 5 Respond to media keys True False True True 1 3 8 9 Show song notifications True True False True True 1 3 9 10 Pause when screensaver is active True True False True True 1 4 11 12 True <small><a href='http://pandora.com'>Create an account at pandora.com</a></small> True 1 3 3 4 True 0 Audio Format 1 2 5 6 True 2 3 5 6 True 0 Proxy URL 1 2 6 7 True True 2 3 6 7 Show notification area icon True True False True 1 3 10 11 True 0 Last.fm Scrobbling 3 12 13 5 Authenticate True True True 1 3 13 14 True 0 Advanced Settings 3 4 5 5 5 1 True end gtk-cancel True True True True False False 0 gtk-ok True True True True False False 1 False end 0 button2 button1 pithos_0.3.17/data/ui/SearchDialog.ui000066400000000000000000000142341175056731700174240ustar00rootroot00000000000000 380 350 5 Search pithos normal False True 2 True 4 True True True 0 Search True True True image1 False False 1 False 0 True True automatic automatic True True False column 1 1 1 True end gtk-cancel True True True True False False 0 gtk-ok True False True True True False False 1 False end 0 button2 okbtn True gtk-find pithos_0.3.17/data/ui/StationsDialog.ui000066400000000000000000000223761175056731700200310ustar00rootroot00000000000000 480 420 1 Manage Stations pithos normal True vertical 2 True True automatic automatic True True 1 True end Add Station True True True False False 0 True Genre Stations False True True True False False 1 True Refresh Stations True True True False False 2 True gtk-close True True True True False False 3 False end 0 add_station add_genre_station refresh_stations close True Listen Now True True True gtk-info True True True Rename True image1 False gtk-delete True True True True gtk-edit 5 normal True warning True vertical 2 True end gtk-cancel True True True True False False 0 gtk-delete True True True True False False 1 False end 0 button2 button1 pithos_0.3.17/data/ui/about_pithos_dialog.xml000066400000000000000000000006131175056731700212750ustar00rootroot00000000000000 pithos_0.3.17/data/ui/pithos_window.xml000066400000000000000000000005511175056731700201540ustar00rootroot00000000000000 pithos_0.3.17/data/ui/preferences_pithos_dialog.xml000066400000000000000000000006271175056731700224710ustar00rootroot00000000000000 pithos_0.3.17/data/ui/search_dialog.xml000066400000000000000000000005621175056731700200450ustar00rootroot00000000000000 pithos_0.3.17/data/ui/stations_dialog.xml000066400000000000000000000005701175056731700204430ustar00rootroot00000000000000 pithos_0.3.17/debug000077500000000000000000000002371175056731700142240ustar00rootroot00000000000000#!/bin/bash if [ -z "$@" ]; then args="--test --verbose" else args=$@ fi mkdir -p debug_config export XDG_CONFIG_HOME="./debug_config" ./bin/pithos $args pithos_0.3.17/pithos.desktop000066400000000000000000000002361175056731700161100ustar00rootroot00000000000000[Desktop Entry] Name=Pithos Comment=Play music from Pandora Radio Categories=GNOME;AudioVideo;Player; Exec=pithos Icon=pithos Terminal=false Type=Application pithos_0.3.17/pithos/000077500000000000000000000000001175056731700145145ustar00rootroot00000000000000pithos_0.3.17/pithos/AboutPithosDialog.py000066400000000000000000000047371175056731700204620ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE import sys import os import gtk from pithos.pithosconfig import getdatapath class AboutPithosDialog(gtk.AboutDialog): __gtype_name__ = "AboutPithosDialog" def __init__(self): """__init__ - This function is typically not called directly. Creation of a AboutPithosDialog requires redeading the associated ui file and parsing the ui definition extrenally, and then calling AboutPithosDialog.finish_initializing(). Use the convenience function NewAboutPithosDialog to create NewAboutPithosDialog objects. """ pass def finish_initializing(self, builder): """finish_initalizing should be called after parsing the ui definition and creating a AboutPithosDialog object with it in order to finish initializing the start of the new AboutPithosDialog instance. """ #get a reference to the builder and set up the signals self.builder = builder self.builder.connect_signals(self) #code for other initialization actions should be added here def NewAboutPithosDialog(): """NewAboutPithosDialog - returns a fully instantiated AboutPithosDialog object. Use this function rather than creating a AboutPithosDialog instance directly. """ #look for the ui file that describes the ui ui_filename = os.path.join(getdatapath(), 'ui', 'AboutPithosDialog.ui') if not os.path.exists(ui_filename): ui_filename = None builder = gtk.Builder() builder.add_from_file(ui_filename) dialog = builder.get_object("about_pithos_dialog") dialog.finish_initializing(builder) return dialog if __name__ == "__main__": dialog = NewAboutPithosDialog() dialog.show() gtk.main() pithos_0.3.17/pithos/PreferencesPithosDialog.py000066400000000000000000000225701175056731700216440ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE import sys import os import stat import logging import gtk import gobject from pithos.pithosconfig import getdatapath, valid_audio_formats from pithos.plugins.scrobble import LastFmAuth try: from xdg.BaseDirectory import xdg_config_home config_home = xdg_config_home except ImportError: config_home = os.path.dirname(__file__) configfilename = os.path.join(config_home, 'pithos.ini') class PreferencesPithosDialog(gtk.Dialog): __gtype_name__ = "PreferencesPithosDialog" prefernces = {} def __init__(self): """__init__ - This function is typically not called directly. Creation of a PreferencesPithosDialog requires redeading the associated ui file and parsing the ui definition extrenally, and then calling PreferencesPithosDialog.finish_initializing(). Use the convenience function NewPreferencesPithosDialog to create NewAboutPithosDialog objects. """ pass def finish_initializing(self, builder): """finish_initalizing should be called after parsing the ui definition and creating a AboutPithosDialog object with it in order to finish initializing the start of the new AboutPithosDialog instance. """ # get a reference to the builder and set up the signals self.builder = builder self.builder.connect_signals(self) # initialize the "Audio format" combobox backing list audio_format_combo = self.builder.get_object('prefs_audio_format') fmt_store = gtk.ListStore(gobject.TYPE_STRING) for audio_format in valid_audio_formats: fmt_store.append((audio_format,)) audio_format_combo.set_model(fmt_store) render_text = gtk.CellRendererText() audio_format_combo.pack_start(render_text, expand=True) audio_format_combo.add_attribute(render_text, "text", 0) self.__load_preferences() def get_preferences(self): """get_preferences - returns a dictionary object that contains preferences for pithos. """ return self.__preferences def __load_preferences(self): #default preferences that will be overwritten if some are saved self.__preferences = { "username":'', "password":'', "notify":True, "last_station_id":None, "proxy":'', "show_icon": False, "lastfm_key": False, "enable_mediakeys":True, "enable_screensaverpause":False, "volume": 1.0, # If set, allow insecure permissions. Implements CVE-2011-1500 "unsafe_permissions": False, "audio_format": valid_audio_formats[0], } try: f = open(configfilename) except IOError: f = [] for line in f: sep = line.find('=') key = line[:sep] val = line[sep+1:].strip() if val == 'None': val=None elif val == 'False': val=False elif val == 'True': val=True self.__preferences[key]=val self.setup_fields() def fix_perms(self): """Apply new file permission rules, fixing CVE-2011-1500. If the file is 0644 and if "unsafe_permissions" is not True, chmod 0600 If the file is world-readable (but not exactly 0644) and if "unsafe_permissions" is not True: chmod o-rw """ def complain_unsafe(): # Display this message iff permissions are unsafe, which is why # we don't just check once and be done with it. logging.warning("Ignoring potentially unsafe permissions due to user override.") changed = False if os.path.exists(configfilename): # We've already written the file, get current permissions config_perms = stat.S_IMODE(os.stat(configfilename).st_mode) if config_perms == (stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH): if self.__preferences["unsafe_permissions"]: return complain_unsafe() # File is 0644, set to 0600 logging.warning("Removing world- and group-readable permissions, to fix CVE-2011-1500 in older software versions. To force, set unsafe_permissions to True in pithos.ini.") os.chmod(configfilename, stat.S_IRUSR | stat.S_IWUSR) changed = True elif config_perms & stat.S_IROTH: if self.__preferences["unsafe_permissions"]: return complain_unsafe() # File is o+r, logging.warning("Removing world-readable permissions, configuration should not be globally readable. To force, set unsafe_permissions to True in pithos.ini.") config_perms ^= stat.S_IROTH os.chmod(configfilename, config_perms) changed = True if config_perms & stat.S_IWOTH: if self.__preferences["unsafe_permissions"]: return complain_unsafe() logging.warning("Removing world-writable permissions, configuration should not be globally writable. To force, set unsafe_permissions to True in pithos.ini.") config_perms ^= stat.S_IWOTH os.chmod(configfilename, config_perms) changed = True return changed def save(self): existed = os.path.exists(configfilename) f = open(configfilename, 'w') if not existed: # make the file owner-readable and writable only os.fchmod(f.fileno(), (stat.S_IRUSR | stat.S_IWUSR)) for key in self.__preferences: f.write('%s=%s\n'%(key, self.__preferences[key])) f.close() def setup_fields(self): self.builder.get_object('prefs_username').set_text(self.__preferences["username"]) self.builder.get_object('prefs_password').set_text(self.__preferences["password"]) self.builder.get_object('prefs_proxy').set_text(self.__preferences["proxy"]) audio_format_combo = self.builder.get_object('prefs_audio_format') audio_pref_idx = list(valid_audio_formats).index(self.__preferences["audio_format"]) audio_format_combo.set_active(audio_pref_idx) self.builder.get_object('checkbutton_notify').set_active(self.__preferences["notify"]) self.builder.get_object('checkbutton_screensaverpause').set_active(self.__preferences["enable_screensaverpause"]) self.builder.get_object('checkbutton_icon').set_active(self.__preferences["show_icon"]) self.lastfm_auth = LastFmAuth(self.__preferences, "lastfm_key", self.builder.get_object('lastfm_btn')) def ok(self, widget, data=None): """ok - The user has elected to save the changes. Called before the dialog returns gtk.RESONSE_OK from run(). """ self.__preferences["username"] = self.builder.get_object('prefs_username').get_text() self.__preferences["password"] = self.builder.get_object('prefs_password').get_text() self.__preferences["proxy"] = self.builder.get_object('prefs_proxy').get_text() self.__preferences["audio_format"] = valid_audio_formats[self.builder.get_object('prefs_audio_format').get_active()] self.__preferences["notify"] = self.builder.get_object('checkbutton_notify').get_active() self.__preferences["enable_screensaverpause"] = self.builder.get_object('checkbutton_screensaverpause').get_active() self.__preferences["show_icon"] = self.builder.get_object('checkbutton_icon').get_active() self.save() def cancel(self, widget, data=None): """cancel - The user has elected cancel changes. Called before the dialog returns gtk.RESPONSE_CANCEL for run() """ self.setup_fields() # restore fields to previous values pass def NewPreferencesPithosDialog(): """NewPreferencesPithosDialog - returns a fully instantiated PreferencesPithosDialog object. Use this function rather than creating a PreferencesPithosDialog instance directly. """ #look for the ui file that describes the ui ui_filename = os.path.join(getdatapath(), 'ui', 'PreferencesPithosDialog.ui') if not os.path.exists(ui_filename): ui_filename = None builder = gtk.Builder() builder.add_from_file(ui_filename) dialog = builder.get_object("preferences_pithos_dialog") dialog.finish_initializing(builder) return dialog if __name__ == "__main__": dialog = NewPreferencesPithosDialog() dialog.show() gtk.main() pithos_0.3.17/pithos/SearchDialog.py000066400000000000000000000105101175056731700174100ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE import sys import os import gtk, gobject import cgi from pithos.pithosconfig import getdatapath class SearchDialog(gtk.Dialog): __gtype_name__ = "SearchDialog" def __init__(self): """__init__ - This function is typically not called directly. Creation of a SearchDialog requires redeading the associated ui file and parsing the ui definition extrenally, and then calling SearchDialog.finish_initializing(). Use the convenience function NewSearchDialog to create a SearchDialog object. """ pass def finish_initializing(self, builder, worker_run): """finish_initalizing should be called after parsing the ui definition and creating a SearchDialog object with it in order to finish initializing the start of the new SearchDialog instance. """ #get a reference to the builder and set up the signals self.builder = builder self.builder.connect_signals(self) self.entry = self.builder.get_object('entry') self.treeview = self.builder.get_object('treeview') self.okbtn = self.builder.get_object('okbtn') self.searchbtn = self.builder.get_object('searchbtn') self.model = gtk.ListStore(gobject.TYPE_PYOBJECT, str) self.treeview.set_model(self.model) self.worker_run = worker_run self.result = None def ok(self, widget, data=None): """ok - The user has elected to save the changes. Called before the dialog returns gtk.RESONSE_OK from run(). """ def cancel(self, widget, data=None): """cancel - The user has elected cancel changes. Called before the dialog returns gtk.RESPONSE_CANCEL for run() """ pass def search_clicked(self, widget): self.search(self.entry.get_text()) def search(self, query): if not query: return def callback(results): self.model.clear() for i in results: if i.resultType is 'song': mk = "%s by %s"%(cgi.escape(i.title), cgi.escape(i.artist)) elif i.resultType is 'artist': mk = "%s (artist)"%(cgi.escape(i.name)) self.model.append((i, mk)) self.treeview.show() self.searchbtn.set_sensitive(True) self.searchbtn.set_label("Search") self.worker_run('search', (query,), callback, "Searching...") self.searchbtn.set_sensitive(False) self.searchbtn.set_label("Searching...") def get_selected(self): sel = self.treeview.get_selection().get_selected() if sel: return self.treeview.get_model().get_value(sel[1], 0) def cursor_changed(self, *ignore): self.result = self.get_selected() self.okbtn.set_sensitive(not not self.result) def NewSearchDialog(worker_run): """NewSearchDialog - returns a fully instantiated dialog-camel_case_nameDialog object. Use this function rather than creating SearchDialog instance directly. """ #look for the ui file that describes the ui ui_filename = os.path.join(getdatapath(), 'ui', 'SearchDialog.ui') if not os.path.exists(ui_filename): ui_filename = None builder = gtk.Builder() builder.add_from_file(ui_filename) dialog = builder.get_object("search_dialog") dialog.finish_initializing(builder, worker_run) return dialog if __name__ == "__main__": dialog = NewSearchDialog() dialog.show() gtk.main() pithos_0.3.17/pithos/StationsDialog.py000066400000000000000000000204121175056731700200110ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE import sys import os import gtk import logging import webbrowser from pithos.pithosconfig import getdatapath from pithos import SearchDialog class StationsDialog(gtk.Dialog): __gtype_name__ = "StationsDialog" def __init__(self): """__init__ - This function is typically not called directly. Creation of a StationsDialog requires redeading the associated ui file and parsing the ui definition extrenally, and then calling StationsDialog.finish_initializing(). Use the convenience function NewStationsDialog to create a StationsDialog object. """ pass def finish_initializing(self, builder, pithos): """finish_initalizing should be called after parsing the ui definition and creating a StationsDialog object with it in order to finish initializing the start of the new StationsDialog instance. """ #get a reference to the builder and set up the signals self.builder = builder self.builder.connect_signals(self) self.pithos = pithos self.model = pithos.stations_model self.worker_run = pithos.worker_run self.quickmix_changed = False self.searchDialog = None self.modelfilter = self.model.filter_new() self.modelfilter.set_visible_func(lambda m, i: m.get_value(i, 0) and not m.get_value(i, 0).isQuickMix) self.modelsortable = gtk.TreeModelSort(self.modelfilter) """ @todo Leaving it as sorting by date added by default. Probably should make a radio select in the window or an option in program options for user preference """ # self.modelsortable.set_sort_column_id(1, gtk.SORT_ASCENDING) self.treeview = self.builder.get_object("treeview") self.treeview.set_model(self.modelsortable) self.treeview.connect('button_press_event', self.on_treeview_button_press_event) name_col = gtk.TreeViewColumn() name_col.set_title("Name") render_text = gtk.CellRendererText() render_text.set_property('editable', True) render_text.connect("edited", self.station_renamed) name_col.pack_start(render_text, expand=True) name_col.add_attribute(render_text, "text", 1) name_col.set_expand(True) name_col.set_sort_column_id(1) self.treeview.append_column(name_col) qm_col = gtk.TreeViewColumn() qm_col.set_title("In QuickMix") render_toggle = gtk.CellRendererToggle() qm_col.pack_start(render_toggle, expand=True) def qm_datafunc(column, cell, model, iter): if model.get_value(iter,0).useQuickMix: cell.set_active(True) else: cell.set_active(False) qm_col.set_cell_data_func(render_toggle, qm_datafunc) render_toggle.connect("toggled", self.qm_toggled) self.treeview.append_column(qm_col) self.station_menu = builder.get_object("station_menu") def qm_toggled(self, renderer, path): station = self.modelfilter[path][0] station.useQuickMix = not station.useQuickMix self.quickmix_changed = True def station_renamed(self, cellrenderertext, path, new_text): station = self.modelfilter[path][0] self.worker_run(station.rename, (new_text,), context='net', message="Renaming Station...") self.model[self.modelfilter.convert_path_to_child_path(path)][1] = new_text def selected_station(self): sel = self.treeview.get_selection().get_selected() if sel: return self.treeview.get_model().get_value(sel[1], 0) def on_treeview_button_press_event(self, treeview, event): if event.button == 3: x = int(event.x) y = int(event.y) time = event.time pthinfo = treeview.get_path_at_pos(x, y) if pthinfo is not None: path, col, cellx, celly = pthinfo treeview.grab_focus() treeview.set_cursor( path, col, 0) self.station_menu.popup( None, None, None, event.button, time) return True def on_menuitem_listen(self, widget): station = self.selected_station() self.pithos.station_changed(station) self.hide() def on_menuitem_info(self, widget): webbrowser.open(self.selected_station().info_url) def on_menuitem_rename(self, widget): sel = self.treeview.get_selection().get_selected() path = self.treeview.get_model().get_path(sel[1]) self.treeview.set_cursor(path, self.treeview.get_column(0) ,True) def on_menuitem_delete(self, widget): station = self.selected_station() dialog = self.builder.get_object("delete_confirm_dialog") dialog.set_property("text", "Are you sure you want to delete the station \"%s\"?"%(station.name)) response = dialog.run() dialog.hide() if response: self.worker_run(station.delete, context='net', message="Deleting Station...") del self.pithos.stations_model[self.pithos.station_index(station)] if self.pithos.current_station is station: self.pithos.station_changed(self.model[0][0]) def add_station(self, widget): if self.searchDialog: self.searchDialog.present() else: self.searchDialog = SearchDialog.NewSearchDialog(self.worker_run) self.searchDialog.show_all() self.searchDialog.connect("response", self.add_station_cb) def refresh_stations(self, widget): self.pithos.refresh_stations(self.pithos) def add_station_cb(self, dialog, response): print "in add_station_cb", dialog.result, response if response == 1: self.worker_run("add_station_by_music_id", (dialog.result.musicId,), self.station_added, "Creating station...") dialog.hide() dialog.destroy() self.searchDialog = None def station_added(self, station): logging.debug("1 "+ repr(station)) it = self.model.insert_after(self.model.get_iter(1), (station, station.name)) logging.debug("2 "+ repr(it)) self.pithos.station_changed(station) logging.debug("3 ") self.modelfilter.refilter() logging.debug("4") self.treeview.set_cursor(0) logging.debug("5 ") def add_genre_station(self, widget): """ This is just a stub for the non-completed buttn """ def on_close(self, widget, data=None): self.hide() if self.quickmix_changed: self.worker_run("save_quick_mix", message="Saving QuickMix...") self.quickmix_changed = False logging.info("closed dialog") return True def NewStationsDialog(pithos): """NewStationsDialog - returns a fully instantiated Dialog object. Use this function rather than creating StationsDialog instance directly. """ #look for the ui file that describes the ui ui_filename = os.path.join(getdatapath(), 'ui', 'StationsDialog.ui') if not os.path.exists(ui_filename): ui_filename = None builder = gtk.Builder() builder.add_from_file(ui_filename) dialog = builder.get_object("stations_dialog") dialog.finish_initializing(builder, pithos) return dialog if __name__ == "__main__": dialog = NewStationsDialog() dialog.show() gtk.main() pithos_0.3.17/pithos/__init__.py000066400000000000000000000000001175056731700166130ustar00rootroot00000000000000pithos_0.3.17/pithos/dbus_service.py000066400000000000000000000056331175056731700175520ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE import dbus.service DBUS_BUS = "net.kevinmehall.Pithos" DBUS_OBJECT_PATH = "/net/kevinmehall/Pithos" def song_to_dict(song): d = {} if song: for i in ['artist', 'title', 'album', 'songDetailURL']: d[i] = getattr(song, i) return d class PithosDBusProxy(dbus.service.Object): def __init__(self, window): self.bus = dbus.SessionBus() bus_name = dbus.service.BusName(DBUS_BUS, bus=self.bus) dbus.service.Object.__init__(self, bus_name, DBUS_OBJECT_PATH) self.window = window self.window.connect("song-changed", self.songchange_handler) self.window.connect("play-state-changed", self.playstate_handler) def playstate_handler(self, window, state): self.PlayStateChanged(state) def songchange_handler(self, window, song): self.SongChanged(song_to_dict(song)) @dbus.service.method(DBUS_BUS) def PlayPause(self): self.window.playpause_notify() @dbus.service.method(DBUS_BUS) def SkipSong(self): self.window.next_song() @dbus.service.method(DBUS_BUS) def LoveCurrentSong(self): self.window.love_song() @dbus.service.method(DBUS_BUS) def BanCurrentSong(self): self.window.ban_song() @dbus.service.method(DBUS_BUS) def TiredCurrentSong(self): self.window.tired_song() @dbus.service.method(DBUS_BUS) def Present(self): self.window.bring_to_top() @dbus.service.method(DBUS_BUS, out_signature='a{sv}') def GetCurrentSong(self): return song_to_dict(self.window.current_song) @dbus.service.method(DBUS_BUS, out_signature='b') def IsPlaying(self): return self.window.playing @dbus.service.signal(DBUS_BUS, signature='b') def PlayStateChanged(self, state): pass @dbus.service.signal(DBUS_BUS, signature='a{sv}') def SongChanged(self, songinfo): pass def try_to_raise(): bus = dbus.SessionBus() try: proxy = bus.get_object(DBUS_BUS, DBUS_OBJECT_PATH) proxy.Present() return True except dbus.exceptions.DBusException as e: return False pithos_0.3.17/pithos/gobject_worker.py000066400000000000000000000043531175056731700201010ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE import threading import Queue import gobject import traceback gobject.threads_init() class GObjectWorker(): def __init__(self): self.thread = threading.Thread(target=self._run) self.thread.daemon = True self.queue = Queue.Queue() self.thread.start() def _run(self): while True: command, args, callback, errorback = self.queue.get() try: result = command(*args) if callback: gobject.idle_add(callback, result) except Exception, e: e.traceback = traceback.format_exc() if errorback: gobject.idle_add(errorback, e) def send(self, command, args=(), callback=None, errorback=None): if errorback is None: errorback = self._default_errorback self.queue.put((command, args, callback, errorback)) def _default_errorback(self, error): print "Unhandled exception in worker thread:\n", error.traceback if __name__ == '__main__': worker = GObjectWorker() import time, gtk def test_cmd(a, b): print "running..." time.sleep(5) print "done" return a*b def test_cb(result): print "got result", result print "sending" worker.send(test_cmd, (3,4), test_cb) worker.send(test_cmd, ((), ()), test_cb) #trigger exception in worker to test error handling gtk.main() pithos_0.3.17/pithos/pandora/000077500000000000000000000000001175056731700161405ustar00rootroot00000000000000pithos_0.3.17/pithos/pandora/__init__.py000066400000000000000000000016641175056731700202600ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE from pithos.pandora.pandora import * def make_pandora(testing=False): if testing: from pithos.pandora.fake import FakePandora return FakePandora() else: return Pandora() pithos_0.3.17/pithos/pandora/blowfish.py000066400000000000000000000120011175056731700203210ustar00rootroot00000000000000# # blowfish.py # Copyright (C) 2002 Michael Gilfix # # This module is open source; you can redistribute it and/or # modify it under the terms of the GPL or Artistic License. # These licenses are available at http://www.opensource.org # # This software must be used and distributed in accordance # with the law. The author claims no liability for its # misuse. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. # """ Blowfish Encryption This module is a pure python implementation of Bruce Schneier's encryption scheme 'Blowfish'. Blowish is a 16-round Feistel Network cipher and offers substantial speed gains over DES. The key is a string of length anywhere between 64 and 448 bits, or equivalently 8 and 56 bytes. The encryption and decryption functions operate on 64-bit blocks, or 8 byte strings. Send questions, comments, bugs my way: Michael Gilfix This version is modified by Kevin Mehall to accept the S and P boxes directly, rather than computing them from a key """ __author__ = "Michael Gilfix " class Blowfish: """Blowfish encryption Scheme This class implements the encryption and decryption functionality of the Blowfish cipher. Public functions: def __init__ (self, key) Creates an instance of blowfish using 'key' as the encryption key. Key is a string of length ranging from 8 to 56 bytes (64 to 448 bits). Once the instance of the object is created, the key is no longer necessary. def encrypt (self, data): Encrypt an 8 byte (64-bit) block of text where 'data' is an 8 byte string. Returns an 8-byte encrypted string. def decrypt (self, data): Decrypt an 8 byte (64-bit) encrypted block of text, where 'data' is the 8 byte encrypted string. Returns an 8-byte string of plaintext. def cipher (self, xl, xr, direction): Encrypts a 64-bit block of data where xl is the upper 32-bits and xr is the lower 32-bits. 'direction' is the direction to apply the cipher, either ENCRYPT or DECRYPT constants. returns a tuple of either encrypted or decrypted data of the left half and right half of the 64-bit block. Private members: def __round_func (self, xl) Performs an obscuring function on the 32-bit block of data 'xl', which is the left half of the 64-bit block of data. Returns the 32-bit result as a long integer. """ # Cipher directions ENCRYPT = 0 DECRYPT = 1 # For the __round_func modulus = long (2) ** 32 def __init__ (self, p_boxes, s_boxes): self.p_boxes = p_boxes self.s_boxes = s_boxes def cipher (self, xl, xr, direction): if direction == self.ENCRYPT: for i in range (16): xl = xl ^ self.p_boxes[i] xr = self.__round_func (xl) ^ xr xl, xr = xr, xl xl, xr = xr, xl xr = xr ^ self.p_boxes[16] xl = xl ^ self.p_boxes[17] else: for i in range (17, 1, -1): xl = xl ^ self.p_boxes[i] xr = self.__round_func (xl) ^ xr xl, xr = xr, xl xl, xr = xr, xl xr = xr ^ self.p_boxes[1] xl = xl ^ self.p_boxes[0] return xl, xr def __round_func (self, xl): a = (xl & 0xFF000000) >> 24 b = (xl & 0x00FF0000) >> 16 c = (xl & 0x0000FF00) >> 8 d = xl & 0x000000FF # Perform all ops as longs then and out the last 32-bits to # obtain the integer f = (long (self.s_boxes[0][a]) + long (self.s_boxes[1][b])) % self.modulus f = f ^ long (self.s_boxes[2][c]) f = f + long (self.s_boxes[3][d]) f = (f % self.modulus) & 0xFFFFFFFF return f def encrypt (self, data): if not len (data) == 8: raise RuntimeError, "Attempted to encrypt data of invalid block length: %s" %len (data) # Use big endianess since that's what everyone else uses xl = ord (data[3]) | (ord (data[2]) << 8) | (ord (data[1]) << 16) | (ord (data[0]) << 24) xr = ord (data[7]) | (ord (data[6]) << 8) | (ord (data[5]) << 16) | (ord (data[4]) << 24) cl, cr = self.cipher (xl, xr, self.ENCRYPT) chars = ''.join ([ chr ((cl >> 24) & 0xFF), chr ((cl >> 16) & 0xFF), chr ((cl >> 8) & 0xFF), chr (cl & 0xFF), chr ((cr >> 24) & 0xFF), chr ((cr >> 16) & 0xFF), chr ((cr >> 8) & 0xFF), chr (cr & 0xFF) ]) return chars def decrypt (self, data): if not len (data) == 8: raise RuntimeError, "Attempted to encrypt data of invalid block length: %s" %len (data) # Use big endianess since that's what everyone else uses cl = ord (data[3]) | (ord (data[2]) << 8) | (ord (data[1]) << 16) | (ord (data[0]) << 24) cr = ord (data[7]) | (ord (data[6]) << 8) | (ord (data[5]) << 16) | (ord (data[4]) << 24) xl, xr = self.cipher (cl, cr, self.DECRYPT) chars = ''.join ([ chr ((xl >> 24) & 0xFF), chr ((xl >> 16) & 0xFF), chr ((xl >> 8) & 0xFF), chr (xl & 0xFF), chr ((xr >> 24) & 0xFF), chr ((xr >> 16) & 0xFF), chr ((xr >> 8) & 0xFF), chr (xr & 0xFF) ]) return chars def blocksize (self): return 8 def key_length (self): return 56 def key_bits (self): return 56 * 8 pithos_0.3.17/pithos/pandora/fake.py000066400000000000000000000127131175056731700174240ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE from pithos.pandora.pandora import * import gtk import logging class FakePandora(Pandora): def __init__(self): super(FakePandora, self).__init__() self.counter = 0 self.show_fail_window() logging.info("Using test mode") def count(self): self.counter +=1 return self.counter def show_fail_window(self): self.window = gtk.Window() self.window.set_size_request(200, 100) self.window.set_title("Pithos failure tester") self.window.set_opacity(0.7) self.auth_check = gtk.CheckButton("Authenticated") self.time_check = gtk.CheckButton("Be really slow") vbox = gtk.VBox() self.window.add(vbox) vbox.pack_start(self.auth_check) vbox.pack_start(self.time_check) self.window.show_all() def maybe_fail(self): if self.time_check.get_active(): logging.info("fake: Going to sleep for 10s") time.sleep(10) if not self.auth_check.get_active(): logging.info("fake: We're deauthenticated...") raise PandoraAuthTokenInvalid("Auth token invalid", "AUTH_INVALID_TOKEN") def set_authenticated(self): self.auth_check.set_active(True) def xmlrpc_call(self, method, args=[], url_args=True, secure=False): time.sleep(1) if method != 'listener.authenticateListener': self.maybe_fail() if method == 'listener.authenticateListener': self.set_authenticated() return {'webAuthToken': '123', 'listenerId':'456', 'authToken':'789'} elif method == 'station.getStations': return [ {'stationId':'987', 'stationIdToken':'345434', 'isCreator':True, 'isQuickMix':False, 'stationName':"Test Station 1"}, {'stationId':'321', 'stationIdToken':'453544', 'isCreator':True, 'isQuickMix':True, 'stationName':"Fake's QuickMix", 'quickMixStationIds':['987', '343']}, {'stationId':'432', 'stationIdToken':'345485', 'isCreator':True, 'isQuickMix':False, 'stationName':"Test Station 2"}, {'stationId':'254', 'stationIdToken':'345415', 'isCreator':True, 'isQuickMix':False, 'stationName':"Test Station 4 - Out of Order"}, {'stationId':'343', 'stationIdToken':'345435', 'isCreator':True, 'isQuickMix':False, 'stationName':"Test Station 3"}, ] elif method == 'playlist.getFragment': return [self.makeFakeSong(args) for i in range(4)] elif method == 'music.search': return {'artists': [ {'score':90, 'musicId':'988', 'artistName':"artistName"}, ], 'songs':[ {'score':80, 'musicId':'238', 'songTitle':"SongName", 'artistSummary':"ArtistName"}, ], } elif method == 'station.createStation': return {'stationId':'999', 'stationIdToken':'345433', 'isCreator':True, 'isQuickMix':False, 'stationName':"Added Station"} elif method in ('station.setQuickMix', 'station.addFeedback', 'station.transformShared', 'station.setStationName', 'station.removeStation', 'listener.addTiredSong', 'station.createBookmark', 'station.createArtistBookmark', ): return 1 else: logging.error("Invalid method %s" % method) def connect(self, user, password): self.listenerId = self.authToken = None user = self.xmlrpc_call('listener.authenticateListener', [user, password], [], secure=True) self.webAuthToken = user['webAuthToken'] self.listenerId = user['listenerId'] self.authToken = user['authToken'] self.get_stations(self) def makeFakeSong(self, args): c = self.count() return { 'albumTitle':"AlbumName", 'artistSummary':"ArtistName", 'artistMusicId':'4324', 'audioURL':'http://kevinmehall.net/p/pithos/testfile.aac?val='+'0'*48, 'fileGain':0, 'identity':'5908540384', 'musicId':'4543', 'rating': 1 if c%3 == 0 else 0, 'stationId': args[0], 'songTitle': 'Test song %i'%c, 'userSeed': '54543', 'songDetailURL': 'http://kevinmehall.net/p/pithos/', 'albumDetailURL':'http://kevinmehall.net/p/pithos/', 'artRadio':'http://i.imgur.com/H3Z8x.jpg', 'songType':0, 'trackToken':12345, } pithos_0.3.17/pithos/pandora/pandora.py000066400000000000000000000313541175056731700201440ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010 Kevin Mehall # Copyright (C) 2012 Christopher Eby #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE from pithos.pandora.blowfish import Blowfish from pithos.pandora import pandora_keys import json import logging import time import urllib import urllib2 # This is an implementation of the Pandora JSON API using Android partner # credentials. # See http://pan-do-ra-api.wikia.com/wiki/Json/5 for API documentation. PROTOCOL_VERSION = '5' RPC_URL = "://tuner.pandora.com/services/json/?" DEVICE_MODEL = 'android-generic' PARTNER_USERNAME = 'android' PARTNER_PASSWORD = 'AC7IBG09A3DTSYM4R41UJWL07VLN8JI7' HTTP_TIMEOUT = 30 AUDIO_FORMAT = 'aacplus' USER_AGENT = 'pithos' RATE_BAN = 'ban' RATE_LOVE = 'love' RATE_NONE = None API_ERROR_API_VERSION_NOT_SUPPORTED = 11 API_ERROR_INSUFFICIENT_CONNECTIVITY = 13 API_ERROR_READ_ONLY_MODE = 1000 API_ERROR_INVALID_AUTH_TOKEN = 1001 API_ERROR_INVALID_LOGIN = 1002 PLAYLIST_VALIDITY_TIME = 60*60*3 class PandoraError(IOError): def __init__(self, message, status=None, submsg=None): self.status = status self.message = message self.submsg = submsg class PandoraAuthTokenInvalid(PandoraError): pass class PandoraNetError(PandoraError): pass class PandoraAPIVersionError(PandoraError): pass class PandoraTimeout(PandoraNetError): pass blowfish_encode = Blowfish(pandora_keys.out_key_p, pandora_keys.out_key_s) def pad(s, l): return s + "\0" * (l - len(s)) def pandora_encrypt(s): return "".join([blowfish_encode.encrypt(pad(s[i:i+8], 8)).encode('hex') for i in xrange(0, len(s), 8)]) blowfish_decode = Blowfish(pandora_keys.in_key_p, pandora_keys.in_key_s) def pandora_decrypt(s): return "".join([blowfish_decode.decrypt(pad(s[i:i+16].decode('hex'), 8)) for i in xrange(0, len(s), 16)]).rstrip('\x08') class Pandora(object): def __init__(self): self.set_proxy(None) self.set_audio_format(AUDIO_FORMAT) def json_call(self, method, args={}, https=False, blowfish=True): url_arg_strings = [] if self.partnerId: url_arg_strings.append('partner_id=%s'%self.partnerId) if self.userId: url_arg_strings.append('user_id=%s'%self.userId) if self.userAuthToken: url_arg_strings.append('auth_token=%s'%urllib.quote_plus(self.userAuthToken)) elif self.partnerAuthToken: url_arg_strings.append('auth_token=%s'%urllib.quote_plus(self.partnerAuthToken)) url_arg_strings.append('method=%s'%method) protocol = 'https' if https else 'http' url = protocol + RPC_URL + '&'.join(url_arg_strings) if self.time_offset: args['syncTime'] = int(time.time()+self.time_offset) if self.userAuthToken: args['userAuthToken'] = self.userAuthToken elif self.partnerAuthToken: args['partnerAuthToken'] = self.partnerAuthToken data = json.dumps(args) logging.debug(url) logging.debug(data) if blowfish: data = pandora_encrypt(data) try: req = urllib2.Request(url, data, {'User-agent': USER_AGENT, 'Content-type': 'text/plain'}) response = self.opener.open(req, timeout=HTTP_TIMEOUT) text = response.read() except urllib2.HTTPError as e: logging.error("HTTP error: %s", e) raise PandoraNetError(str(e)) except urllib2.URLError as e: logging.error("Network error: %s", e) if e.reason[0] == 'timed out': raise PandoraTimeout("Network error", submsg="Timeout") else: raise PandoraNetError("Network error", submsg=e.reason[1]) logging.debug(text) tree = json.loads(text) if tree['stat'] == 'fail': code = tree['code'] msg = tree['message'] logging.error('fault code: ' + str(code) + ' message: ' + msg) if code == API_ERROR_INVALID_AUTH_TOKEN: raise PandoraAuthTokenInvalid(msg) elif code == API_ERROR_API_VERSION_NOT_SUPPORTED: raise PandoraAPIVersionError(msg) elif code == API_ERROR_INSUFFICIENT_CONNECTIVITY: raise PandoraError("Out of sync", code, submsg="Correct your system's clock. If the problem persists, a Pithos update may be required") elif code == API_ERROR_READ_ONLY_MODE: raise PandoraError("Pandora maintenance", code, submsg="Pandora is in read-only mode as it is performing maintenance. Try again later.") elif code == API_ERROR_INVALID_LOGIN: raise PandoraError("Login Error", code, submsg="Invalid username or password") else: raise PandoraError("Pandora returned an error", code, "%s (code %d)"%(msg, code)) if 'result' in tree: return tree['result'] def set_audio_format(self, fmt): self.audio_format = ['aacplus', 'mp3', 'mp3-hifi'].index(fmt) def set_proxy(self, proxy): if proxy: proxy_handler = urllib2.ProxyHandler({'http': proxy}) self.opener = urllib2.build_opener(proxy_handler) else: self.opener = urllib2.build_opener() def connect(self, user, password): self.partnerId = self.userId = self.partnerAuthToken = self.userAuthToken = self.time_offset = None partner = self.json_call('auth.partnerLogin', {'deviceModel': DEVICE_MODEL, 'username': PARTNER_USERNAME, 'password': PARTNER_PASSWORD, 'version': PROTOCOL_VERSION}, https=True, blowfish=False) self.partnerId = partner['partnerId'] self.partnerAuthToken = partner['partnerAuthToken'] pandora_time = int(pandora_decrypt(partner['syncTime'])[4:14]) self.time_offset = pandora_time - time.time() logging.info("Time offset is %s", self.time_offset) user = self.json_call('auth.userLogin', {'username': user, 'password': password, 'loginType': 'user'}, https=True) self.userId = user['userId'] self.userAuthToken = user['userAuthToken'] self.get_stations(self) def get_stations(self, *ignore): stations = self.json_call('user.getStationList')['stations'] self.quickMixStationIds = None self.stations = [Station(self, i) for i in stations] if self.quickMixStationIds: for i in self.stations: if i.id in self.quickMixStationIds: i.useQuickMix = True def save_quick_mix(self): stationIds = [] for i in self.stations: if i.useQuickMix: stationIds.append(i.id) self.json_call('user.setQuickMix', {'quickMixStationIds': stationIds}) def search(self, query): results = self.json_call('music.search', {'searchText': query}) l = [SearchResult('artist', i) for i in results['artists']] l += [SearchResult('song', i) for i in results['songs']] l.sort(key=lambda i: i.score, reverse=True) return l def add_station_by_music_id(self, musicid): d = self.json_call('station.createStation', {'musicToken': musicid}) station = Station(self, d) self.stations.append(station) return station def get_station_by_id(self, id): for i in self.stations: if i.id == id: return i def add_feedback(self, trackToken, rating): logging.info("pandora: addFeedback") rating_bool = True if rating == RATE_LOVE else False feedback = self.json_call('station.addFeedback', {'trackToken': trackToken, 'isPositive': rating_bool}) return feedback['feedbackId'] def delete_feedback(self, stationToken, feedbackId): self.json_call('station.deleteFeedback', {'feedbackId': feedbackId, 'stationToken': stationToken}) class Station(object): def __init__(self, pandora, d): self.pandora = pandora self.id = d['stationId'] self.idToken = d['stationToken'] self.isCreator = not d['isShared'] self.isQuickMix = d['isQuickMix'] self.name = d['stationName'] self.useQuickMix = False if self.isQuickMix: self.pandora.quickMixStationIds = d.get('quickMixStationIds', []) def transformIfShared(self): if not self.isCreator: logging.info("pandora: transforming station") self.pandora.json_call('station.transformSharedStation', {'stationToken': self.idToken}) self.isCreator = True def get_playlist(self): logging.info("pandora: Get Playlist") playlist = self.pandora.json_call('station.getPlaylist', {'stationToken': self.idToken, 'additionalAudioUrl': 'HTTP_64_AACPLUS_ADTS,HTTP_128_MP3,HTTP_192_MP3'}, https=True) songs = [] for i in playlist['items']: if 'songName' in i: # check for ads songs.append(Song(self.pandora, i)) return songs @property def info_url(self): return 'http://www.pandora.com/stations/'+self.idToken def rename(self, new_name): if new_name != self.name: self.transformIfShared() logging.info("pandora: Renaming station") self.pandora.json_call('station.renameStation', {'stationToken': self.idToken, 'stationName': new_name}) self.name = new_name def delete(self): logging.info("pandora: Deleting Station") self.pandora.json_call('station.deleteStation', {'stationToken': self.idToken}) class Song(object): def __init__(self, pandora, d): self.pandora = pandora self.album = d['albumName'] self.artist = d['artistName'] self.audioUrl = d['additionalAudioUrl'][self.pandora.audio_format] self.fileGain = d['trackGain'] self.trackToken = d['trackToken'] self.rating = RATE_LOVE if d['songRating'] == 1 else RATE_NONE # banned songs won't play, so we don't care about them self.stationId = d['stationId'] self.title = d['songName'] self.songDetailURL = d['songDetailUrl'] self.albumDetailURL = d['albumDetailUrl'] self.artRadio = d['albumArtUrl'] self.tired=False self.message='' self.start_time = None self.finished = False self.playlist_time = time.time() self.feedbackId = None @property def station(self): return self.pandora.get_station_by_id(self.stationId) def rate(self, rating): if self.rating != rating: self.station.transformIfShared() if rating == RATE_NONE: if not self.feedbackId: # We need a feedbackId, get one by re-rating the song. We # could also get one by calling station.getStation, but # that requires transferring a lot of data (all feedback, # seeds, etc for the station). opposite = RATE_BAN if self.rating == RATE_LOVE else RATE_LOVE self.feedbackId = self.pandora.add_feedback(self.trackToken, opposite) self.pandora.delete_feedback(self.station.idToken, self.feedbackId) else: self.feedbackId = self.pandora.add_feedback(self.trackToken, rating) self.rating = rating def set_tired(self): if not self.tired: self.pandora.json_call('user.sleepSong', {'trackToken': self.trackToken}) self.tired = True def bookmark(self): self.pandora.json_call('bookmark.addSongBookmark', {'trackToken': self.trackToken}) def bookmark_artist(self): self.pandora.json_call('bookmark.addArtistBookmark', {'trackToken': self.trackToken}) @property def rating_str(self): return self.rating def is_still_valid(self): return (time.time() - self.playlist_time) < PLAYLIST_VALIDITY_TIME class SearchResult(object): def __init__(self, resultType, d): self.resultType = resultType self.score = d['score'] self.musicId = d['musicToken'] if resultType == 'song': self.title = d['songName'] self.artist = d['artistName'] elif resultType == 'artist': self.name = d['artistName'] pithos_0.3.17/pithos/pandora/pandora_keys.py000066400000000000000000000610351175056731700211760ustar00rootroot00000000000000# processed Android keys out_key_p = [ 0xb43f6f21, 0x77d67cb4, 0x872bc2af, 0x740c67d2, 0x06b5b538, 0x203471d9, 0x5b166908, 0x1992e2dd, 0x709c1604, 0xf44b2f24, 0x80b4e61e, 0xf4dd369b, 0x0b635c77, 0x3ece8651, 0x0d0bcd5b, 0x577afa1f, 0xef341b74, 0xfe722dca ] out_key_s = [[ 0xe15f9e9b, 0x03555599, 0x47048d11, 0xaddfbacb, 0x8563f318, 0x1731d807, 0xc70a3692, 0x5c2375d3, 0x93935e57, 0x63fffcb8, 0x7af11e27, 0x7b350860, 0x68d7c26f, 0xdaf049c4, 0xd14b68ec, 0xda9e11d7, 0x9705dbd0, 0x7cca75fe, 0x03aba426, 0x0f31fc8c, 0xedc14781, 0xaf7d0036, 0xea013ac8, 0x167e94c4, 0xb6fe76a8, 0x076e1b0a, 0x37d3f9b3, 0x9314b846, 0x949216ae, 0xe920d195, 0xe6eedebb, 0xf6e5bb9a, 0x622eee66, 0x1e13131d, 0xffe62a02, 0xd1ea1074, 0xe86c6f7b, 0x3bf2a360, 0xb81d9322, 0x98b98e89, 0xb4a25e21, 0x5d2b39a7, 0xdf2b838a, 0x7d6858fc, 0x53d7534f, 0x699c3bee, 0xef22acba, 0x56ba8780, 0xa8fbe73c, 0xb4caab7f, 0x32fb4391, 0xce117bb4, 0xb26d1c6d, 0x26efcb76, 0x7573e394, 0xd7edbe85, 0x6f61abb8, 0xc9fa6366, 0x45b8b08c, 0x2cf8c2b4, 0xd3cbabf1, 0x6cdde675, 0xae8f00ec, 0xef5107eb, 0x98ff45b5, 0x4b76ee02, 0x31e152be, 0x9f86d02c, 0x358bb661, 0xf821def5, 0x120e9c36, 0x46c23b3e, 0xff5062de, 0x41b8b28c, 0xa22c9f8d, 0x028bf7f9, 0x5ebe8f80, 0x78e8de1c, 0x0d594ad4, 0xec9819a9, 0x10761f9f, 0x2dac4a3c, 0x32d63a18, 0x9eaf8c9e, 0x724a3c41, 0xb9afb3b7, 0xbe6f2245, 0x684c7581, 0xd15b9adf, 0xee9437af, 0x22114a47, 0xe2a9eed4, 0x15c6068d, 0x1dfb4e19, 0xc32abdb2, 0x3bae15ee, 0x19ec45c2, 0x1f90957d, 0xff649405, 0x4ce2fa05, 0x8ef84fd1, 0x2ee46348, 0x84502a27, 0x42077b9b, 0x105183ae, 0xd44db5ac, 0xc754b5ad, 0xaa94e602, 0xeba1d85c, 0x647d6a2a, 0x2d2dc661, 0x17ef131f, 0x65035f04, 0x24bc155f, 0xb852bb07, 0xd2a03fcc, 0x9400b35d, 0x66a23536, 0x68cc1ab0, 0x2b366e16, 0x445202f1, 0x08e28943, 0x59dab809, 0x0d85b68d, 0xf69b85ff, 0x05ec1a2e, 0x9abb1c81, 0xec81e9ed, 0x31ed505f, 0x25f28f7f, 0xa768cab1, 0x6ffef36a, 0xb0347700, 0xa57519ae, 0x9b1b1ed5, 0x4d257368, 0xe0693824, 0x66c1b6d4, 0x28a8f8ba, 0x0d556d85, 0x7ef1cd17, 0x5f028fa3, 0xe5a61301, 0xffef2e0e, 0xb737cb57, 0x35de5f36, 0x244411c7, 0xb860e566, 0x107bc291, 0x163894c9, 0xce006743, 0xee8accbd, 0x2c546301, 0xb628a648, 0x8c9a5f88, 0x4cd0fafe, 0xf376a955, 0x1e67ee50, 0xe488161f, 0x6badb6a6, 0x7fe45f7f, 0x49515270, 0x8f921aa3, 0x7bb9547c, 0x4dd89e33, 0x5b66e4fd, 0x844acb4f, 0xf5fd71bb, 0xb37fd813, 0x26870647, 0x18b17366, 0xc1a76ad3, 0x082adb60, 0x0ec19a13, 0xf54da029, 0xd26d804d, 0xf5709013, 0xd691ac51, 0x1da76f01, 0x84310b0f, 0x98dd505e, 0x3e2887db, 0x9de8b16f, 0xfa84b608, 0x3b348c33, 0x28a3f3ab, 0x189f0238, 0xd7cf415c, 0x8a33849e, 0x0fd0e49f, 0xa6500b7e, 0xd17235ec, 0x7e1f150d, 0xacd0857d, 0x47f179a7, 0x41d258b1, 0x1af08047, 0x799bab6f, 0xd3bc6b2e, 0xa29e8a77, 0x58170aac, 0x96bc089b, 0x71692dec, 0x157aa527, 0x6b8427b1, 0xb7c3cd64, 0x335275e5, 0x31a58e0a, 0x56e77232, 0x8eb18fc5, 0xbe85fbec, 0x45b25fb1, 0x401b0c7c, 0x64268428, 0x2074cf4e, 0x3214c2b0, 0x0f878220, 0x99f3af0c, 0xab466398, 0xbd5d244a, 0xee73ba24, 0x6973a5a6, 0x58a1fd3a, 0x2922b89a, 0xf89afd2a, 0xa154a891, 0xb695fa10, 0xfc4815f4, 0xdf333dbf, 0xaac85b90, 0xf4f715e2, 0x883fd9c7, 0x9921b9a5, 0xf52e8325, 0x3c764f83, 0xdc2f15d0, 0x9d13ed18, 0x0e606226, 0x9a3df52b, 0x1aee312e, 0x7c9c956e, 0x74945570, 0x6511f87e, 0x079b1fac, 0x307ec31b, 0x7ce01d73, 0xd517313d, 0x33932d2b, 0xb9f6d593, 0xe09e0b96, 0x56b123a0, 0xffe4e3b6, 0xb00acaff, 0x79ac263d, 0xb40fcd02, 0xdd291445 ],[ 0xe88915a7, 0x2fd71acb, 0x8de63f40, 0xd9667945, 0xad7f4d6c, 0x7d471d97, 0x763fe4cf, 0x9b7be03f, 0xc2753c36, 0x485ca61c, 0x464f68f9, 0x68e20787, 0xf9b5112e, 0xefa30f29, 0xb0bf5579, 0x1ff012a9, 0x84eb1932, 0x860c72c3, 0xe78c719f, 0x09931794, 0xf40de80c, 0xc3734531, 0x47c0f73d, 0xed152258, 0xd1063d9b, 0x8ab6c8dc, 0x4bd5cf71, 0xe5bb0287, 0x8cfd2000, 0x4943cd0e, 0xb3d1d376, 0x5069c9ef, 0x21cd7de9, 0x0dc70db3, 0x2c52c071, 0x954b1899, 0xf629c4c8, 0x54f7bfea, 0x52736639, 0xdac7c000, 0xf6ea60eb, 0xa155a177, 0x3ed41d88, 0x2a967cd4, 0x8eda21bb, 0x28d8e3d7, 0x0b199754, 0x4d6e5dd8, 0x5355c236, 0x85102121, 0x8ee4939e, 0x30dc9a44, 0x16aea2f8, 0xd7f5e4e0, 0x81f46691, 0x941ec3ec, 0x0b90833b, 0x613c85c5, 0x72678ee4, 0x42af8034, 0x85327e5a, 0x0650eafd, 0x66ea2cbf, 0xb5db4c39, 0x561cc65d, 0xf856517b, 0xda186e32, 0x3c7cb9d8, 0x90a16ab0, 0x7231ad00, 0xd4f3b7af, 0x38409ee1, 0x25663e24, 0x2737afbc, 0x4ef9cde2, 0xdba641e1, 0x616e97dc, 0x6c951874, 0x8796c409, 0x421ee6c4, 0x8c151c79, 0xb11febf1, 0x98bc1204, 0xb028f602, 0x1504d1f6, 0x33202b57, 0xbd993956, 0x2359b3c7, 0xab331fa8, 0xd48afd73, 0xdefafcec, 0x75dd341b, 0x3b83626f, 0x7d3981cc, 0xa6380a9a, 0xf660aff5, 0xa29fcecd, 0xf895d432, 0x31e403e5, 0xb5bb3e12, 0x4601fe55, 0xa6055d21, 0x72d8b825, 0xdb8b8562, 0x0dc236c7, 0x41d3fbe5, 0xc6c02321, 0x6b68bdf8, 0x4e355453, 0x1ed80b3e, 0xc65d4ced, 0x0988916e, 0x8c3fff7d, 0x1c44a511, 0x6e190b89, 0xe4ef9975, 0x554d6a39, 0x4e4def49, 0xa294c0c0, 0x4811b319, 0x6708876d, 0xa1b35ca0, 0x9508cfde, 0x74bcfdfb, 0x43631d77, 0x21871456, 0xa6b83afb, 0xe96a7352, 0x47db29de, 0x39c197fa, 0x1404a39c, 0x92b3ab85, 0x0ec976b4, 0xc77c5425, 0x582d6b41, 0xc5de160f, 0x83f9293a, 0xf561f916, 0x4c3d9b6a, 0x170d6f94, 0x357180fd, 0xc73ad219, 0x727ea163, 0xe9bc0edb, 0x34266e50, 0x93fbc9e6, 0xb0ceae22, 0xc71caeed, 0x5229d6cc, 0xb072c679, 0xead50629, 0x328e387c, 0x31b38479, 0x9ffc2ede, 0x1aebd9f7, 0x66cde36d, 0x94eb1015, 0x214a341b, 0x4e2725ce, 0xb646ed42, 0x126a5d3b, 0xd45d974e, 0x0c23a1ed, 0xc7f23f3a, 0x039e06db, 0x45e7c121, 0xd4fb7c84, 0x93259026, 0x5c273558, 0xfe28386e, 0x55e97b1c, 0xc3273147, 0xaf9cf707, 0x714df708, 0xd020da13, 0x63ff077b, 0xa3e8b295, 0x149e5c40, 0xfec0a3ba, 0x56f00b03, 0x052eeec3, 0xa16bb594, 0x11f71787, 0x3d070441, 0x43921051, 0x81372cf4, 0x508689c9, 0xb67ef857, 0x3acfda1d, 0xe437d27d, 0x503c18a7, 0xcf3c2d49, 0x4fcfde8d, 0x9b9bb94c, 0x4185a775, 0x1ef11c15, 0x5e851380, 0xf0388cee, 0xc5444f7a, 0xe7d10b5b, 0xbbb2deec, 0x54412917, 0x3f7a98d7, 0x68585273, 0xe7fd9971, 0x5ca5fd84, 0xa264a533, 0x6cda27d8, 0x0bc4d33d, 0xfa9ef695, 0x9b1c3ab9, 0xa49ddf15, 0x213aa509, 0xcd2e0539, 0xd9fdb9b1, 0x612d781c, 0x6af5985a, 0xa0585c6d, 0x4d70e637, 0x436e1d58, 0x2e98d56e, 0x36c51320, 0x8424af0e, 0x3233250b, 0x51764e9c, 0x034bad26, 0x5c8550f0, 0x271d7047, 0xa5afaec5, 0xa4d41479, 0xbb775519, 0x5f94a186, 0x5fb27b56, 0xa48405bd, 0x8f543fdf, 0x23ac0b49, 0x9d36d6a0, 0x63739090, 0xc39314ce, 0x1c798ad2, 0x8f3fd9f0, 0x8330ff19, 0x851874b8, 0x32a79ca2, 0xbdd64e38, 0xb6ac2e6c, 0x4691accd, 0x4f5b9d71, 0x0bd4f753, 0xb3074a95, 0xb26e4510, 0x63969c27, 0x22e07207, 0x0129e524, 0xc766650e, 0x438be192 ],[ 0x3750c5d2, 0xfb85d7b4, 0x38836748, 0x2d9144ef, 0x795371b6, 0x56ed49ce, 0xad880cfb, 0xa49b9346, 0xcf773a62, 0xeec4ba92, 0xa4475a71, 0xdd7f2159, 0x0127c957, 0xefb0c0e2, 0x68bbca45, 0xa4e5eca5, 0x67b73975, 0x71d507fc, 0x69e075c7, 0x563c029a, 0x8f3376db, 0x2a6acc70, 0x556b9333, 0x5e5d182f, 0x29f5d5cc, 0xeafea42f, 0x69efc675, 0x68d15318, 0x95f9759b, 0xced1cbb4, 0x1882f46c, 0x6c326bce, 0xc9942a49, 0xddd8c723, 0x8e9ec07a, 0x01f7e12c, 0x645cb5cd, 0x391f1510, 0x163c35f5, 0xd94f9a87, 0xb74585d8, 0x60b1bc61, 0x521eaed9, 0xb73ccd09, 0xcdef5503, 0xc55df55f, 0xefd4c973, 0x78948287, 0xaab7bd7a, 0xeebefe2d, 0x2b8721c9, 0x2b04f7d0, 0xf2cbf31a, 0x7ee524d6, 0x36d46e55, 0x8dac73a0, 0x8427954d, 0x5f2a3a39, 0x6413a9f1, 0x022b301d, 0xa072616c, 0x3d3ef628, 0x98b9e887, 0x866685fb, 0x9e3fcfbc, 0xf3eeed63, 0xcaba75e3, 0xd9015927, 0x325626bc, 0x2c5b752d, 0xc1385649, 0x7d39f1ac, 0xb1b2e515, 0x1d70444d, 0x8f414d5f, 0x7ea7c37d, 0x7b2ef041, 0xab0d8d8c, 0xa8a61f2d, 0xb0bde42d, 0xb0b8a457, 0x44a920a8, 0x2db3ab91, 0xe6b7ad63, 0xbfb64cd4, 0x99568ae7, 0x1bceacc8, 0xcc5f7d17, 0x5fa452eb, 0x446b9f97, 0xe633ddb1, 0x60aca6f4, 0xdab2c9ea, 0xa5b630d6, 0x825f75ff, 0xa3ac0e6d, 0xb3894704, 0x13de228f, 0x4e9f6581, 0x6d107b01, 0xcab6097c, 0xe62b146f, 0xea71c048, 0xfe504e0e, 0x0702cc9a, 0x3d1a7d01, 0x13617030, 0x3d879f89, 0xc28b65ee, 0x5872c3c3, 0x05b4bb68, 0x9a861425, 0x8a4cd6fe, 0x55243744, 0x4858ecb6, 0xb568d8dd, 0xbebf9fdd, 0xc84fd4c6, 0x4f4af9c0, 0x9b08f63d, 0xf1a0376a, 0xa355f6cf, 0x2fcb228a, 0x0cdddf69, 0xe468afe7, 0x29398554, 0x7117abd5, 0xe2fe5567, 0x508a5d85, 0x49adf79d, 0x75011a15, 0x31d8e338, 0x74d222b5, 0x24960278, 0xcdff9aff, 0x7aad9fa0, 0x9b06269e, 0x69b501f1, 0xe0086ab7, 0xf2e16ce4, 0x8cb98307, 0x715b2506, 0x3cc16c6e, 0xd74378d2, 0xb510a616, 0x1922ebbc, 0x75d40946, 0xbc4f0b56, 0x4ab3a831, 0xf6eb3d5e, 0x7110bcd0, 0x105bfce5, 0x8ca82576, 0x96dbeea9, 0x40488279, 0x951974fb, 0x94b565e4, 0x692c10ce, 0x6a692d18, 0xaa0af02e, 0x7379d550, 0x9ce8b210, 0xd4635640, 0x33ea7667, 0x5e776e92, 0x9ae7c2d1, 0x2562c476, 0xe8b9342d, 0xd3d0e320, 0x3cc6af4f, 0x3f3042a1, 0x4bdc1927, 0x5a142bb4, 0x137d70ef, 0x7fb6018a, 0x080d779c, 0x550fee8b, 0xd71ac558, 0xa7298efc, 0x714e8084, 0x8e6d9001, 0x8ca5f159, 0x4a7c41d3, 0xfc3feac7, 0x61aa5710, 0xd13aa1bc, 0x665e4645, 0xfe4d4faf, 0x2ee5c84b, 0x91262e53, 0x699e98d9, 0x4f61f245, 0xbd6e788e, 0x1e5c2d6d, 0xc64185a4, 0xeef57cb3, 0x4d39a6b4, 0x15fa53f4, 0x9c8a0a48, 0x6442e21e, 0xf82b64ce, 0x73d86319, 0xf1a30515, 0x48f14387, 0x848a69a7, 0x8b1c7641, 0x8d271922, 0x135857d4, 0xa3f4e0a8, 0x97b75963, 0x1e761918, 0x6bb49070, 0x34dacfe6, 0xbe78db33, 0x51e3f2ea, 0xbd5ff0c9, 0xd15adc12, 0xadd67ab9, 0x0c0c5c33, 0x149c2097, 0xaad74487, 0x8436773d, 0x6ea35567, 0x54bb4ad0, 0x7447cf20, 0x9c8552a3, 0x811096a6, 0xa3434fba, 0x3803dbcc, 0x504714f7, 0x9052704c, 0xcf5df346, 0x17646400, 0x87cc0403, 0xfaa228ce, 0x6f2d3289, 0x808948f1, 0x505ef302, 0xaaca43db, 0x526f9953, 0x3fbb002b, 0xa7c7443b, 0x4d6e36cd, 0x0457ac81, 0x59139c59, 0x0e155100, 0xd2a1baa7, 0xecc08a20, 0xcdde24cb, 0x16ae51f8, 0xd9a1fa7e, 0xc50f461a, 0xb569ed99, 0x5a77293f, 0x02f86aa8, 0x050f0024 ],[ 0xd404b9a8, 0xd3438135, 0x227e435d, 0x31076cbd, 0xaee796dc, 0xe404313c, 0x2623800a, 0x093a69b2, 0x58ee884f, 0x776f4874, 0xe572d368, 0xd5a5cbe7, 0x3f3bbef0, 0x7c17d8f6, 0x220a067d, 0xd793de4d, 0xa0109a98, 0x62637a6b, 0x22d8d756, 0x5066308f, 0x7e90eca2, 0xc0b754bd, 0x4084b7c5, 0x9486c097, 0x36a046b5, 0x114975c0, 0xd91424c8, 0x890246d5, 0x59eb4a73, 0x9afa3756, 0x70b8c470, 0xc08ea016, 0x4c28c5f4, 0x9f623b08, 0x73fc47c4, 0xedfa1d69, 0x4a2b1786, 0xced564eb, 0xbe12a43a, 0x52e852a4, 0x3cb3c210, 0xca9ae070, 0xe33e7ed5, 0xd6af2ef1, 0xe49e5a83, 0xf5772eaa, 0x8551eb98, 0x1cf22cfc, 0xadaa0256, 0xecd056ca, 0xc209d2b9, 0x9b3e0762, 0x1ee2a087, 0x2b821484, 0x8fe22587, 0xce149c00, 0x91ce4d3e, 0x19a97f27, 0x46bcda1b, 0x404cd997, 0x82e82b04, 0x9d4dedbc, 0xc0859cb5, 0xf01b46c0, 0xb8b203cd, 0x45090f79, 0x8be4ab5d, 0xe2d1cd5c, 0xcbc8431b, 0xe7ee2388, 0x7e111b93, 0xc519d732, 0x0655ccf5, 0x783288a3, 0x9d698132, 0xaa0e34dd, 0x2d34f890, 0x27fe844e, 0x9cbc4da6, 0xd953afc3, 0xfb07a430, 0xcf035ecb, 0xcc4c8d9b, 0x2abd5860, 0xb82869b1, 0x3c70d06f, 0x207e13c0, 0x429c196e, 0xfe9ede86, 0x4f710351, 0xdf8e7c12, 0xe7f5c14f, 0x6f619bf3, 0xa6a99158, 0x23431a99, 0xdc2ae09d, 0x6894ad00, 0xdd61887e, 0x951926ba, 0xb653acd3, 0x4be2af6c, 0xaecb2462, 0x2cd45174, 0x0f92838b, 0x5664d019, 0x38a28976, 0x2dbbeeae, 0xbe54b161, 0xa7570953, 0xa9296b69, 0x6e8cd50d, 0x2dff6493, 0xd8897cc9, 0x9807846e, 0x067833e2, 0xff0a0865, 0x6798fb62, 0x38ff940e, 0x257dfead, 0x36ed0dd9, 0x87786abc, 0x2fcce945, 0x40baaf09, 0xa55edef1, 0x83231abf, 0x29579f57, 0x7d26bfd8, 0x24d3d02c, 0xbad9f470, 0x76049108, 0xe3c6e9fd, 0xaee57efa, 0x974bf27a, 0x4d753ea9, 0x326fa8bf, 0x0f234d18, 0x892d41f1, 0xa314e7a0, 0xe6ad75a3, 0x6b824a07, 0x3c54f6bb, 0x9b41b17f, 0xa717e8f7, 0xe0b4383b, 0xb9d9772c, 0x60bd9aea, 0xb0a28d0c, 0xec6c7f0e, 0x2475ad83, 0x81e3ac81, 0x4f9eb09d, 0x4ae9dcd1, 0xcacf2923, 0x138d5de0, 0xc23dc080, 0xdc6212c5, 0x49d40182, 0x9a299359, 0x96494f05, 0x50958bcc, 0xedd87cf1, 0x8e41d821, 0xdbc893e2, 0x81760ba1, 0x5bc77924, 0xe4c423cf, 0xfb96b131, 0x1ff9238d, 0x0f5ce7d5, 0x550fd44f, 0x2b9979f8, 0x14d1e16f, 0xe694fc5c, 0x06e9befc, 0xa328bce7, 0xb3be44b8, 0x11714887, 0xfd3856e6, 0x6e81a076, 0xdaabdacd, 0xddd1abde, 0xcaf9dabd, 0x50cb477d, 0x1c8fed49, 0xd25a8ad8, 0xa4b5a936, 0x2f7fdcc5, 0x769f6748, 0x416623d7, 0xd9181558, 0x0c864431, 0x00bd5e0d, 0xe64bb5c8, 0x88482e47, 0x1aeda9af, 0x95a56caf, 0x7135065a, 0xa1928e57, 0x8e6eedd9, 0xadc56171, 0xd3c859a0, 0xb13bec39, 0x1dcdb139, 0x188b3229, 0xf6733af0, 0x9c5902f1, 0xe62faa6e, 0xc36f65b6, 0x9cc971f2, 0x4d2ba095, 0x909a0f45, 0x7218f3a9, 0x563c0ce3, 0xf194acfd, 0x386df463, 0x8907bdcb, 0x300035a9, 0x00c7fdc5, 0x50adac43, 0x6e53e258, 0xe1f636b4, 0x271b7918, 0xfa7a3af8, 0x40913066, 0x3e8706de, 0xbd421d95, 0x004e20fc, 0x2a7bb121, 0xac159bf6, 0x49b64135, 0xebe39504, 0x60a191eb, 0xfdcd513d, 0x4bf25769, 0xa8b74196, 0x9fce29fc, 0xb25af8a7, 0x98a93a20, 0xc4bab38d, 0xbeef4028, 0xa4ac98d9, 0x7839b20b, 0x2034d530, 0x9f25f4a0, 0x099fa1a5, 0xd031b88f, 0x9d05688b, 0x5b2fd566, 0x661a06a9, 0x1fceb5ac, 0xca8b6bd5, 0x192151b9, 0x69e54eeb, 0x29429086, 0xa676e0c5, 0x5869aac7 ]] in_key_p = [ 0x7965742c, 0x4a205f3d, 0x143f8f89, 0xc976e0b1, 0x37d227b0, 0x78968d06, 0x9f28933c, 0xd21a7537, 0x80eb812c, 0xe5a60d9b, 0xf2b6b13d, 0x67079baf, 0x0c73a7c2, 0x95d331dd, 0x80379ec4, 0x16b753b1, 0xc23f34ae, 0x7a3c45d7 ] in_key_s = [[ 0x58af6ece, 0x6b306780, 0x033ef993, 0x4299c20b, 0x47adc709, 0xdb40ee14, 0x3772fa47, 0x473385d9, 0xbfc0af75, 0xd439ae96, 0x6a2ef2ee, 0x4a25f261, 0x69345881, 0x65dd6dfc, 0x7a87b813, 0x626a4332, 0x675a3e91, 0x2c19b6da, 0x62108522, 0x26cb31b9, 0x584df87d, 0x5024976f, 0x48136869, 0x5c56cba9, 0x5ad39e1b, 0x133f6eba, 0xb1c66e67, 0x90880621, 0xa9886abc, 0x5aafb5fd, 0x2623955d, 0x737cc474, 0xd5248060, 0x67c4b493, 0xbac12128, 0x095810ab, 0x613ab2f2, 0x30e1b44a, 0x8291449b, 0xaf474e70, 0x6cd5307b, 0xb13ad61d, 0x721871f8, 0xfd55db7f, 0x7415a01c, 0x580b8ca6, 0x284fe1b9, 0xa4f0bd0d, 0x7bf1167d, 0x82662fc7, 0xc7524e17, 0x2f7c69a2, 0x089fa280, 0x90e18cd8, 0x70536f17, 0xf5e7ed0d, 0x13388a46, 0x9db0cece, 0xc6710fe3, 0x00e399ad, 0x22e77d76, 0x63cde083, 0x757d804e, 0xf821aead, 0xf84b66e9, 0xe6bc3e7c, 0x5dfc3e57, 0x158c599d, 0x27dedf6b, 0x777bf721, 0x05d82093, 0x8b2bc85f, 0x09918b2f, 0xf4c702e8, 0xdf00cd28, 0x491a4fad, 0x64944ee2, 0x872ed2e7, 0xf3288db7, 0x1f93d679, 0xad42dd2d, 0xe8131a69, 0xd8ba3a70, 0x73f86d65, 0xb3c72776, 0x52cc70c8, 0xaba8c646, 0x4a323b09, 0x7d482403, 0x9e03399d, 0x2b717494, 0x6bed832b, 0xf8a661ba, 0xc07e4f5e, 0x589460bc, 0x1da78d74, 0xd8ecd29f, 0xba3ed619, 0xf2d647b0, 0xaf86f7a8, 0x4ca53870, 0xbfecf67f, 0xa778b6fe, 0x84d56e44, 0x1f4f61ed, 0x1f8329e1, 0xedd3e331, 0x27f854e3, 0x2da40439, 0xfbc0bb45, 0x91327b1f, 0xc819276c, 0x72ad0fae, 0xde13b223, 0xd2f381dc, 0x826bb46d, 0x295bc153, 0x9048ac23, 0x945605d9, 0x944d59cb, 0xba1a643d, 0xa16f9e33, 0xed95325e, 0xb1e5e9ca, 0xc2233f09, 0x44585853, 0x6a4eec8f, 0xf93c1555, 0xd6793587, 0xe934216b, 0x3a8332b3, 0x3a8466c9, 0xac7386cc, 0x01668a9f, 0xa28ff66f, 0xda303600, 0xd6e18e43, 0x3d592ada, 0xde2c3640, 0x8df5bd6b, 0x1ab26fbb, 0xe59ec9e8, 0xac9925b3, 0xc227130c, 0x467a9af0, 0xa9579945, 0x0e1652a4, 0x433805af, 0x4ae0f0fd, 0xd9218763, 0x54d623ff, 0x39bd38c8, 0xc639e971, 0xefed7056, 0xcf46f0d3, 0x0a43fb36, 0xe73e362e, 0x092400f6, 0x242821e7, 0xc3953cdb, 0x8c02d71c, 0xd9d5b909, 0x64b442af, 0x29d5ffba, 0xb479b691, 0x5aa9a01c, 0x49cbd1c9, 0x41eafbf8, 0x888144a6, 0x844c076d, 0x05581523, 0xc5e98ffd, 0x13056fe1, 0xa4056b01, 0x09f53013, 0x0ad00575, 0xacb8354d, 0x52ece455, 0xfd8890d3, 0xaf651f23, 0xad7374d2, 0x99cceab5, 0x2f0f603d, 0x5e7ea504, 0x608963e1, 0xc1bd2196, 0x200b27b3, 0xd9d1e761, 0xeff36e5a, 0x547b24c8, 0x7c7f77bc, 0xa9e78393, 0x6b9f3172, 0xc6529dbd, 0xb6e0011d, 0x40cda153, 0xe74ddd18, 0x01a98b3b, 0xd9b6f384, 0x57aaa89b, 0x98f36734, 0x98baaa5a, 0x47f961de, 0x12803dcb, 0x24d3e504, 0xb5fa31a1, 0xcda87476, 0x9cc48fc9, 0xbdd02ca2, 0xf5963721, 0x722cc439, 0x519ef966, 0xd5699454, 0xf8aeed1c, 0xc5ec22b8, 0x52d7eb6a, 0xc179828c, 0xb383272e, 0x206888fc, 0xaf1a692e, 0x217bf251, 0x6c0d0a71, 0x0c84184b, 0x79dd1780, 0x3b3f72a8, 0x33478e4b, 0x06bf0967, 0x9023fa3f, 0x8303a262, 0x7ac0e4a6, 0xd439deb1, 0x1dbef98b, 0xfef0be31, 0x1b87f008, 0x7c2196ff, 0xf5447601, 0xb1508f3a, 0x512cfd07, 0x3137b2d4, 0x768cffc8, 0x970c456d, 0xc06d34b4, 0xe257e53d, 0x8c75c72b, 0xc9db8a31, 0xde84bb8f, 0x5b332228, 0x8bf79c5a, 0x0b3efe49, 0xf0c4bf7e, 0xb958ed83, 0x5b37ee2d, 0xdb04c07a, 0x72739791, 0x55c40314, 0x5129c81c ],[ 0x700c96f3, 0xde2d98f3, 0x503d5563, 0xa5a92702, 0x5f87b11c, 0xc5fdf6c2, 0x9d5eadf9, 0x82d21e82, 0xbfbe92ec, 0x27b25533, 0xf6c9aba1, 0x787d218d, 0xfdbf4423, 0x439ed927, 0x3201f7b4, 0xb8dfe640, 0x88ad318e, 0x2076ab45, 0xc8654627, 0x658d0920, 0x09fe3274, 0xf00fd288, 0xf3e47731, 0x6028108c, 0x98f52e66, 0x10b6f6c6, 0xfe6e6cbd, 0x18855ca0, 0x41b04ef1, 0x3a075160, 0x5158de83, 0xfbb9f0c9, 0x5e3fdc6c, 0xd72efef8, 0x04c4ef61, 0x99edda29, 0xc653fe1e, 0x6b85e447, 0xbe07d9f5, 0x16ce88d4, 0x6bf376dd, 0xa12cefde, 0x22fc5353, 0x2890980d, 0x8b99543c, 0xab2c42bc, 0x510892c5, 0x416951dd, 0x219d7d99, 0x5c83a431, 0x7f6b1f4e, 0x3cdddebd, 0xb96b4c75, 0xb88adf78, 0x48d54415, 0xd89aa204, 0x85fa0a84, 0xcceba68c, 0x6ff06438, 0x0f3bae05, 0xd2d85107, 0x19b91d81, 0x2c68aed8, 0xbbe8f8d2, 0xa26c27a8, 0xba1b02e0, 0x90f091fe, 0xa62a3797, 0x9fc43203, 0x59393925, 0x354aa050, 0xa709b895, 0x6b8aa793, 0x4a679a6c, 0x47eea590, 0x21aa4b78, 0xc103cef9, 0x7832f982, 0x0a19af36, 0x71253891, 0xa0c16436, 0x968852bd, 0x6694b976, 0x0884fb93, 0x46eb1e9f, 0xfa945c75, 0xd3c928fb, 0xd1c8bf8c, 0xaf20aaa9, 0x9fa86cd2, 0xdccded57, 0x1bdd4247, 0x94f91d5c, 0x7d6d5058, 0x11f0db4e, 0xf9a48f09, 0xffa3dfb4, 0xb27b4de0, 0xdeab8e3f, 0x20ad0f77, 0x9c13ff7c, 0x16acc3a5, 0x59fd4711, 0xe13fc78e, 0x286b7532, 0x3352f5bb, 0xa3305feb, 0x643cfc7b, 0x689de9f4, 0x4ea0b270, 0x532dc782, 0xa5c504c3, 0xbfc29608, 0x0f3fd845, 0xd62c9c37, 0x8f9d345a, 0x7bca7eb6, 0xda8e1fcc, 0x152b59ce, 0x625bb739, 0x49a5aa8f, 0x24417d34, 0xe9c9ed1b, 0x0e20a019, 0xe81dbc3a, 0xea7fdd74, 0xbd0a0794, 0x85585d33, 0xa48530d2, 0x991cc6ab, 0xa5488f6c, 0x4f1a494d, 0xb45f297f, 0x0f357907, 0x56574fec, 0x4d4519ff, 0x2b78fbdf, 0x28ca6528, 0x095d79b6, 0x48cb1657, 0x6b56eed0, 0xb0ccbe78, 0xe702aec1, 0x350bdfb7, 0x59e0e969, 0xa4154ba8, 0xba56355c, 0x545028bc, 0xef129a26, 0xc594c313, 0xf74051a7, 0x90f33de7, 0x7946623b, 0x06875cf1, 0xa47f30cd, 0x3fd1eee0, 0x848065a2, 0x4788db48, 0x7afff19f, 0x1a6f58aa, 0xa929b0be, 0x4297c802, 0xa5c9db5c, 0x972df7f5, 0xfb449508, 0xfa5e027f, 0x903d0acc, 0xd9481446, 0x485f43f3, 0xe99d44bb, 0xf830b7d5, 0x7a8d521c, 0x84b98afb, 0xe88c86df, 0xf59c4cd1, 0x9f66e618, 0x71f390ec, 0x59c364ef, 0x47e57d97, 0xdb769d9b, 0x8a5df152, 0xf3f1afc2, 0x23791aa5, 0x6032c1e6, 0xcdcd381b, 0x88298f9a, 0x0489b57b, 0x7206785b, 0x086f2c1b, 0x779c61e9, 0xf87ea443, 0x57c8da35, 0xa417c341, 0x7883bff4, 0x165beefa, 0xe630556f, 0xe136b428, 0x65f03ab7, 0xc218b820, 0xc4df8526, 0x2a4f4982, 0x124811e9, 0xf799a377, 0xfd1d0033, 0x663fb7ef, 0x1ccafabc, 0x44af1166, 0x5a164940, 0x848956ea, 0x6e6552b2, 0xf6ef98dd, 0x3bcf388d, 0xb054a3bc, 0x64ef380a, 0xb0df7014, 0xbc6e2bf1, 0xf40268c2, 0xd4552ec1, 0xfc31e5da, 0x8737009f, 0x8a644503, 0x76743771, 0x2a594cc4, 0x9be48de7, 0x0e750c92, 0x7790c8f1, 0x8e2b2824, 0x671bef2d, 0x2fdffaab, 0x0a75c150, 0x9db37e38, 0x964ec3a0, 0xc2f0bef2, 0x4dea50cc, 0x0e224e06, 0x7b5fb816, 0x256bf43c, 0x2e254562, 0xe4d05bb6, 0xb192839c, 0x0dcbc8e7, 0x45565f05, 0xdd0f61ae, 0x2af501fe, 0x740cc6ee, 0x20a23735, 0x6d4c1a5f, 0xdf48e0f3, 0x841b7d9a, 0xec88226d, 0x454937e6, 0xbd38c2fc, 0x67a5fe1c, 0x310dade4, 0xf0544ba3, 0xe5077fdc ],[ 0x3a9b9093, 0xa4633d29, 0x864616ef, 0x6306b63d, 0x6d40a577, 0x8472be3a, 0x3f82f43c, 0x11b3dc89, 0x58b59414, 0x9625f326, 0x9732ecaa, 0x03bf67ad, 0x69fa01d4, 0x48cdebb5, 0x2d5b8bc4, 0x37d5592c, 0xc7d6e32a, 0x33ad6f03, 0xa07d2033, 0x1ecb01f0, 0xc457c910, 0xfd4768a8, 0x60df8140, 0x3f6de965, 0xf705d74e, 0x8a72e059, 0x2205a9eb, 0xce273ab1, 0xfa920510, 0x176e53c5, 0xdf4c779b, 0x0df906ca, 0xb99317fa, 0x3f2951df, 0xe8eb0716, 0xf4378364, 0xb2e5013b, 0x01c87633, 0xe1369e82, 0xb73812cc, 0x859e8144, 0x4feff8f3, 0x2c3b97a3, 0x7e8a3b4f, 0x2ae02629, 0xe3b078cf, 0x69555f9a, 0x9795b141, 0x2cb274c3, 0x0e7f8477, 0x765b20cf, 0xb908ff7d, 0xbd5f6ff5, 0x33dcae67, 0x5223dc88, 0x8c777c0f, 0x257535a3, 0xaf772c03, 0xdbee922f, 0xb9903499, 0x51a5c816, 0x1f566f58, 0xc56c5d6a, 0x5dae7e5c, 0xbf2f4e5d, 0xec994673, 0xd10292f9, 0x4807ebf7, 0x8cb1b02a, 0xc245f1a0, 0x967f40c2, 0x9c18fce3, 0xad6f9f84, 0xffdacf8a, 0x383ba5c7, 0xb1062148, 0x9f8b5794, 0xf04b8b41, 0xf7065c1e, 0x2df21206, 0xd2c19e57, 0x8a1d1724, 0x098807ae, 0xf1ffad8c, 0x28c3b7ab, 0x15f08cb9, 0x819a0342, 0x9ca785ad, 0xbca12936, 0x57005e72, 0xd2952717, 0xaa2c7a40, 0xbad5c47a, 0x5e249a52, 0xe4f67168, 0xa24b0fd9, 0x0c74b46d, 0x2c6f753e, 0x271e8a9d, 0xbecfc090, 0x1ae87e40, 0xb8d370fe, 0xe55229b5, 0x4d4f8df2, 0x5500eaed, 0x7077304a, 0x0cc88f39, 0x0c1a57ff, 0x65a15916, 0xeb25a56f, 0xce051524, 0x6f3b6b29, 0xc377ca76, 0xa4b92e18, 0x6a65ce33, 0x9705be40, 0x0ee9622c, 0xc151bcfa, 0xa4b920bc, 0x0b062e55, 0x907dfe6c, 0x2454ef6a, 0x639b7d23, 0x886fcbf0, 0x4be37f14, 0xe841ca25, 0x19290f76, 0x6ac74f0c, 0xd77807fc, 0x38662787, 0x31fbab00, 0x6e86d381, 0x6542ef1e, 0xcf0fd34d, 0xd76365fa, 0xb38a9713, 0x03ca5ed3, 0xda72b659, 0x98449c9d, 0x5a4bb852, 0xe8b96682, 0x254d34df, 0x8fa2c1b4, 0x2e8bbc0c, 0x9f8c0ad0, 0x4737af25, 0x8d8dcd3f, 0x37ae4fe6, 0x3ca183dd, 0x5c6800a6, 0x8ac9fab7, 0xa6a8560a, 0xf38ff50d, 0x7ef176af, 0xe1cde486, 0x1efe0b95, 0x8aa0b26d, 0x6bb7e125, 0xae3082de, 0xb8b8693c, 0x260bc5ef, 0xaa4ca762, 0x96ee37d5, 0x92ed36ab, 0x4e64cbfe, 0x15302e8e, 0xc7ec0569, 0x7a3e62e4, 0x846a7554, 0x3a527824, 0x413e1bb7, 0xf277c0c0, 0x4aed3640, 0x070e7cf1, 0x34ba52ac, 0xb0e769ac, 0x173ff792, 0x54ab5bf9, 0x2c7fe691, 0x44e1dcff, 0xccad04a0, 0xc36df12c, 0x426046a1, 0x1815e1a6, 0x1d08d080, 0x2122a6f7, 0xfe0adbb3, 0x3ee44567, 0x97eacfd6, 0x6c39b52c, 0xf1666890, 0x0c064e3b, 0x4f0ce499, 0xa57051c2, 0xbafaecf1, 0x4bd81cd4, 0x323926d2, 0x0f486b0f, 0x0bf3059a, 0xb64882b4, 0x26356a23, 0xc409dcc1, 0xd0fab32d, 0x9a6a9a3f, 0xcfe564f1, 0x662a0bab, 0x3531647f, 0xd6d32083, 0x1470e956, 0x170f18da, 0x7777d4af, 0xd3c7d311, 0x31ed300f, 0xb0438514, 0x69596a4d, 0xc204cf7a, 0x9f359719, 0x1f7b1e3f, 0x6fcb0d4b, 0x006ff6a0, 0xc45dd3f3, 0x2004ac7a, 0xa659fc7d, 0x6bb525f7, 0x79c2468c, 0x69b66bc8, 0xacd88068, 0xbc177474, 0x9bb8cdb8, 0xc1847712, 0x198ab988, 0xfb914a8e, 0x58d8915e, 0x9a7546e5, 0x96f72399, 0x0ed06fdb, 0xa3dfa9a1, 0x7afe55c8, 0xefbe9837, 0x28fc70e9, 0x58d7f102, 0x96efb6ce, 0xdcbe8b2c, 0x4e0b3a4d, 0xe6bde8ec, 0xc85297e4, 0xc6a21317, 0x9ab106f5, 0xcfd005d5, 0x8df74a34, 0x9ddda71e, 0x455b9da6, 0xd5d097f7, 0xceefc20d, 0x5ed612b3, 0xe3d05a19 ],[ 0x39f5f98f, 0x5f4aedf0, 0xa78a4e15, 0xefe018c6, 0x794c93ce, 0x619114c5, 0x9bf937bd, 0x11b0e9e8, 0x03ee0ebe, 0x3463a92a, 0xad780118, 0xfec71882, 0xdd2a4fee, 0x16ebed33, 0x32a07f20, 0x07c860ad, 0xaeecf1cc, 0x59155142, 0xf9355fa0, 0x0c888f13, 0x9269f453, 0xbb030f9c, 0x4a7e7d1f, 0x7bde69c6, 0xd6251060, 0x89edd1d7, 0xbfc1fce3, 0x800cd339, 0xbbdb406e, 0x29f830fc, 0xf0185bc8, 0xcb3e5dfc, 0x34bb9de7, 0xb9be2e7f, 0xdc0a256c, 0xa3cf476d, 0xe8146f0e, 0xa05f759c, 0xb207a38f, 0x5cea7f07, 0x11966d9a, 0xb86ae0ca, 0xa7f7507b, 0x86456caf, 0xda2fc94a, 0xf40ee3d7, 0xb3b16d2a, 0x65985d5b, 0x1d568e9d, 0xba2ca598, 0x6e2b6fb5, 0x51c61179, 0xe4541a9f, 0x491b2d44, 0x5a6684e3, 0xfa3a88f0, 0x4df38d58, 0x3eb854b1, 0x640e2fc0, 0x131ac3a0, 0x559a919a, 0x3fc31514, 0xd688840c, 0x1c34479d, 0xa94c5267, 0xc9dba308, 0xd3c74191, 0x8a7b1567, 0xca88f7e1, 0x7eb2621a, 0x891b7145, 0xbb795c83, 0xbea8a0dc, 0xa14cdafe, 0xa4ef8a76, 0x11a4b6c4, 0xfdd49085, 0xd75b50c6, 0x7d250736, 0x36c9e67f, 0xf851eaa6, 0xdbdebcd8, 0xadeb555c, 0xa1d73460, 0x804b6e19, 0x7143d204, 0x08825c66, 0x2303d8b6, 0x1c87b9dd, 0x221cdafa, 0x3c8b6ecb, 0x866e4fde, 0x7e6423f7, 0x176a26b7, 0x2e6a6d38, 0x1c91d2b1, 0x0a00cfcf, 0xf3ab1646, 0x4c7219ec, 0x461eca91, 0x984dd5c4, 0xcad2e054, 0x0154d6d2, 0x4ab7bcfb, 0x339e2ba5, 0x660f4c0b, 0xfb5527f2, 0xfdad33b1, 0x654fab58, 0xd03fc602, 0xe80a4cc3, 0x201abcbe, 0x87aaae96, 0x2b63614b, 0x8a99ec48, 0x10478493, 0xba8dca6c, 0xa0a16ab4, 0x35713cf7, 0x666cb206, 0x4c3cc644, 0x448530df, 0x1c2633ec, 0x53aa9dfb, 0xa302dce8, 0x2591e95e, 0x907278b0, 0x3db7c94d, 0xd24995f5, 0x0d2d47c8, 0x62f6c46c, 0x4a7898ed, 0xf6a0b8b1, 0xc9e996bf, 0x709d7875, 0x114f9629, 0xf6a6ac6d, 0x49e81de9, 0x01f352da, 0xf7cf515f, 0x17687e75, 0xde732d7f, 0xb0bc9739, 0x753fbf17, 0x256dd9c2, 0xb3825bb9, 0xff1c5cf4, 0x1eb65a04, 0x15f13888, 0xb33c5b65, 0x39ac79f1, 0xfc2d0825, 0xc76cdc60, 0xc713543c, 0x7c03244c, 0x59d55bdb, 0x6c4f9986, 0xb179d387, 0x0d6b7585, 0x82650fd8, 0x0c402008, 0xb2b992db, 0x1a98611b, 0x65bec302, 0x3140c3ba, 0x6a0ae834, 0x040de77e, 0xb5620ac0, 0x109f3480, 0xa8b6a324, 0x194ece42, 0x5a1ffefb, 0x4ee8e582, 0xa2c942bf, 0x4959b308, 0xbfc3d444, 0x7dfad51f, 0xafd87111, 0x1696895b, 0xe2c9c82d, 0x1feba9f8, 0xa10feccd, 0xffb77472, 0x06ce8942, 0x24761e62, 0x64190fc3, 0xf457dd2a, 0xa52cba3f, 0xb3b3a04b, 0x93d21005, 0x4e560a41, 0xde9d69fc, 0x9ba5755c, 0x24982126, 0x50308268, 0x4c371eb3, 0x11a9b36e, 0x5d589990, 0x153df664, 0x9fa19c92, 0xe76aa4c4, 0xa5e7176b, 0x85701ed8, 0x9e80db90, 0x1bc954e2, 0x52f1a00f, 0xb86b2d16, 0x367a7fcd, 0xf3ec57c7, 0x5198f53d, 0x28b0881a, 0xedd06df9, 0xfcc06975, 0x2a47fe4a, 0xa2ed56cd, 0xfc36dbdc, 0xd2d6f278, 0xd1fdad09, 0x8e274b1e, 0xd24f7de2, 0x6304d5c8, 0xdd4b9b0c, 0x77830f46, 0x2731ea83, 0xc6269ade, 0x833b38e7, 0xaaf9b7f0, 0x1df7e21a, 0xef33af8a, 0x22da6bc6, 0x4bbdcd98, 0xe31870a1, 0x55126353, 0x6d455688, 0x31cf5aa4, 0x94a4c5f9, 0xbf813c9d, 0xeb4d03a2, 0x930f74bf, 0xbfa60117, 0x84e6954a, 0x6b4c992a, 0xa1b1be37, 0xd13f76ec, 0x31d32bf9, 0xd6f43033, 0x173e7bd2, 0xa0417167, 0x53540194, 0x1384fdaf, 0xbfba75b6, 0xdda06aef, 0x5040678e, 0x73fc27e1, 0xcdf96a1a, 0x88e3e947 ]] pithos_0.3.17/pithos/pithosconfig.py000066400000000000000000000040211175056731700175570ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE # where your project will head for your data (for instance, images and ui files) # by default, this is ../data, relative your trunk layout __pithos_data_directory__ = '../data/' __license__ = 'GPL-3' VERSION = '0.3.17' import os class project_path_not_found(Exception): pass valid_audio_formats = [ 'aacplus', 'mp3', 'mp3-hifi', ] def get_data_file(*path_segments): """Get the full path to a data file. Returns the path to a file underneath the data directory (as defined by `get_data_path`). Equivalent to os.path.join(get_data_path(), *path_segments). """ return os.path.join(getdatapath(), *path_segments) def getdatapath(): """Retrieve pithos data path This path is by default /../data/ in trunk and /usr/share/pithos in an installed version but this path is specified at installation time. """ # get pathname absolute or relative if __pithos_data_directory__.startswith('/'): pathname = __pithos_data_directory__ else: pathname = os.path.dirname(__file__) + '/' + __pithos_data_directory__ abs_data_path = os.path.abspath(pathname) if os.path.exists(abs_data_path): return abs_data_path else: raise project_path_not_found if __name__=='__main__': print VERSION pithos_0.3.17/pithos/plugin.py000066400000000000000000000061321175056731700163660ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE import logging import glob import os class PithosPlugin(object): _PITHOS_PLUGIN = True # used to find the plugin class in a module preference = None def __init__(self, name, window): self.name = name self.window = window self.prepared = False self.enabled = False def enable(self): if not self.prepared: self.error = self.on_prepare() self.prepared = True if not self.error and not self.enabled: logging.info("Enabling module %s"%(self.name)) self.on_enable() self.enabled = True def disable(self): if self.enabled: logging.info("Disabling module %s"%(self.name)) self.on_disable() self.enabled = False def on_prepare(self): pass def on_enable(self): pass def on_disable(self): pass class ErrorPlugin(PithosPlugin): def __init__(self, name, error): logging.error("Error loading plugin %s: %s"%(name, error)) self.prepared = True self.error = error self.name = name self.enabled = False def load_plugin(name, window): try: module = __import__('pithos.plugins.'+name) module = getattr(module.plugins, name) except ImportError as e: return ErrorPlugin(name, e.message) # find the class object for the actual plugin for key, item in module.__dict__.iteritems(): if hasattr(item, '_PITHOS_PLUGIN') and key != "PithosPlugin": plugin_class = item break else: return ErrorPlugin(name, "Could not find module class") return plugin_class(name, window) def load_plugins(window): plugins = window.plugins prefs = window.preferences plugins_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "plugins") discovered_plugins = [ fname.replace(".py", "") for fname in glob.glob1(plugins_dir, "*.py") if not fname.startswith("__") ] for name in discovered_plugins: if not name in plugins: plugin = plugins[name] = load_plugin(name, window) else: plugin = plugins[name] if plugin.preference and prefs.get(plugin.preference, False): plugin.enable() else: plugin.disable() pithos_0.3.17/pithos/plugins/000077500000000000000000000000001175056731700161755ustar00rootroot00000000000000pithos_0.3.17/pithos/plugins/__init__.py000066400000000000000000000000001175056731700202740ustar00rootroot00000000000000pithos_0.3.17/pithos/plugins/mediakeys.py000066400000000000000000000050721175056731700205260ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE from pithos.plugin import PithosPlugin import dbus import logging APP_ID = 'Pithos' class MediaKeyPlugin(PithosPlugin): preference = 'enable_mediakeys' def bind_dbus(self): try: bus = dbus.Bus(dbus.Bus.TYPE_SESSION) mk = bus.get_object("org.gnome.SettingsDaemon","/org/gnome/SettingsDaemon/MediaKeys") mk.GrabMediaPlayerKeys(APP_ID, 0, dbus_interface='org.gnome.SettingsDaemon.MediaKeys') mk.connect_to_signal("MediaPlayerKeyPressed", self.mediakey_pressed) logging.info("Bound media keys with DBUS") self.method = 'dbus' return True except dbus.DBusException: return False def mediakey_pressed(self, app, action): if app == APP_ID: if action == 'Play': self.window.playpause_notify() elif action == 'Next': self.window.next_song() elif action == 'Stop': self.window.user_pause() elif action == 'Previous': self.window.bring_to_top() def bind_keybinder(self): try: import keybinder except: return False keybinder.bind('XF86AudioPlay', self.window.playpause, None) keybinder.bind('XF86AudioStop', self.window.user_pause, None) keybinder.bind('XF86AudioNext', self.window.next_song, None) keybinder.bind('XF86AudioPrev', self.window.bring_to_top, None) logging.info("Bound media keys with keybinder") self.method = 'keybinder' return True def on_enable(self): self.bind_dbus() or self.bind_keybinder() or logging.error("Could not bind media keys") def on_disable(self): logging.error("Not implemented: Can't disable media keys") pithos_0.3.17/pithos/plugins/notification_icon.py000066400000000000000000000126011175056731700222450ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE import gtk from pithos.pithosconfig import get_data_file from pithos.plugin import PithosPlugin # Check if appindicator is available on the system try: import appindicator indicator_capable = True except: indicator_capable = False class PithosNotificationIcon(PithosPlugin): preference = 'show_icon' def on_prepare(self): if indicator_capable: self.ind = appindicator.Indicator("pithos", \ "pithos-mono", \ appindicator.CATEGORY_APPLICATION_STATUS, \ get_data_file('media')) def on_enable(self): self.visible = True self.delete_callback_handle = self.window.connect("delete-event", self.toggle_visible) self.state_callback_handle = self.window.connect("play-state-changed", self.play_state_changed) self.song_callback_handle = self.window.connect("song-changed", self.song_changed) if indicator_capable: self.ind.set_status(appindicator.STATUS_ACTIVE) else: self.statusicon = gtk.status_icon_new_from_file(get_data_file('media', 'icon.png')) self.statusicon.connect('activate', self.toggle_visible) self.build_context_menu() def build_context_menu(self): menu = gtk.Menu() def button(text, action, icon=None): if icon == 'check': item = gtk.CheckMenuItem(text) item.set_active(True) elif icon: item = gtk.ImageMenuItem(text) item.set_image(gtk.image_new_from_stock(icon, gtk.ICON_SIZE_MENU)) else: item = gtk.MenuItem(text) item.connect('activate', action) item.show() menu.append(item) return item if indicator_capable: # We have to add another entry for show / hide Pithos window self.visible_check = button("Show Pithos", self._toggle_visible, 'check') self.playpausebtn = button("Pause", self.window.playpause, gtk.STOCK_MEDIA_PAUSE) button("Skip", self.window.next_song, gtk.STOCK_MEDIA_NEXT) button("Love", (lambda *i: self.window.love_song()), gtk.STOCK_ABOUT) button("Ban", (lambda *i: self.window.ban_song()), gtk.STOCK_CANCEL) button("Tired", (lambda *i: self.window.tired_song()), gtk.STOCK_JUMP_TO) button("Quit", self.window.quit, gtk.STOCK_QUIT ) # connect our new menu to the statusicon or the appindicator if indicator_capable: self.ind.set_menu(menu) else: self.statusicon.connect('popup-menu', self.context_menu, menu) self.menu = menu def play_state_changed(self, window, playing): """ play or pause and rotate the text """ button = self.playpausebtn if not playing: button.set_label("Play") button.set_image(gtk.image_new_from_stock(gtk.STOCK_MEDIA_PLAY, gtk.ICON_SIZE_MENU)) else: button.set_label("Pause") button.set_image(gtk.image_new_from_stock(gtk.STOCK_MEDIA_PAUSE, gtk.ICON_SIZE_MENU)) if indicator_capable: # menu needs to be reset to get updated icon self.ind.set_menu(self.menu) def song_changed(self, window, song): if not indicator_capable: self.statusicon.set_tooltip("%s by %s"%(song.title, song.artist)) def _toggle_visible(self, *args): if self.visible: self.window.hide() else: self.window.bring_to_top() self.visible = not self.visible def toggle_visible(self, *args): if hasattr(self, 'visible_check'): self.visible_check.set_active(not self.visible) else: self._toggle_visible() return True def context_menu(self, widget, button, time, data=None): if button == 3: if data: data.show_all() data.popup(None, None, None, 3, time) def on_disable(self): if indicator_capable: self.ind.set_status(appindicator.STATUS_PASSIVE) else: self.statusicon.set_visible(False) self.window.disconnect(self.delete_callback_handle) self.window.disconnect(self.state_callback_handle) self.window.disconnect(self.song_callback_handle) # Pithos window needs to be reconnected to on_destro() self.window.connect('delete-event',self.window.on_destroy) pithos_0.3.17/pithos/plugins/notify.py000066400000000000000000000047541175056731700200710ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE import pynotify, gtk from cgi import escape from pithos.plugin import PithosPlugin from pithos.pithosconfig import get_data_file class NotifyPlugin(PithosPlugin): preference = 'notify' def on_prepare(self): pynotify.init('pithos') self.notification = pynotify.Notification("Pithos","Pithos") def on_enable(self): self.song_callback_handle = self.window.connect("song-changed", self.song_changed) self.state_changed_handle = self.window.connect("user-changed-play-state", self.playstate_changed) def set_for_song(self, song): self.notification.clear_hints() msg = escape("by %s from %s"%(song.artist, song.album)) self.notification.update(song.title, msg, 'audio-x-generic') def song_changed(self, window, song): if not self.window.is_active(): self.set_for_song(song) if song.art_pixbuf: #logging.debug("has albumart", song.art_pixbuf, song.art_pixbuf.get_width()) self.notification.set_icon_from_pixbuf(song.art_pixbuf) else: self.notification.props.icon_name = get_data_file('media/pithos-mono.png') self.notification.show() def playstate_changed(self, window, state): if not self.window.is_active(): self.set_for_song(window.current_song) if state: self.notification.props.icon_name = 'gtk-media-play-ltr' else: self.notification.props.icon_name = 'gtk-media-pause' self.notification.show() def on_disable(self): self.window.disconnect(self.song_callback_handle) self.window.disconnect(self.state_changed_handle) pithos_0.3.17/pithos/plugins/screensaver_pause.py000066400000000000000000000041151175056731700222650ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE from pithos.plugin import PithosPlugin import dbus import logging class ScreenSaverPausePlugin(PithosPlugin): preference = 'enable_screensaverpause' def bind_session_bus(self): try: self.session_bus = dbus.SessionBus() return True except dbus.DBusException: return False def on_enable(self): self.bind_session_bus() or logging.error("Could not bind session bus") self.connect_events() or logging.error("Could not connect events") def on_disable(self): self.disconnect_events() self.session_bus = None def connect_events(self): try: self.session_bus.add_signal_receiver(self.playPause, 'ActiveChanged', 'org.gnome.ScreenSaver') return True except dbus.DBusException: logging.info("Enable failed") return False def disconnect_events(self): try: self.session_bus.remove_signal_receiver(self.playPause, 'ActiveChanged', 'org.gnome.ScreenSaver') return True except dbus.DBusException: return False def playPause(self,state): if not state: if self.wasplaying: self.window.user_play() else: self.wasplaying = self.window.playing self.window.pause() pithos_0.3.17/pithos/plugins/scrobble.py000066400000000000000000000116631175056731700203510ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2010-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE from pithos import pylast import webbrowser import logging from pithos.gobject_worker import GObjectWorker from pithos.plugin import PithosPlugin #getting an API account: http://www.last.fm/api/account API_KEY = '997f635176130d5d6fe3a7387de601a8' API_SECRET = '3243b876f6bf880b923a3c9fb955720c' #client id, client version info: http://www.last.fm/api/submissions#1.1 CLIENT_ID = 'pth' CLIENT_VERSION = '1.0' _worker = None def get_worker(): # so it can be shared between the plugin and the authorizer global _worker if not _worker: _worker = GObjectWorker() return _worker class LastfmPlugin(PithosPlugin): preference='lastfm_key' def on_prepare(self): self.worker = get_worker() def on_enable(self): self.connect(self.window.preferences['lastfm_key']) self.song_ended_handle = self.window.connect('song-ended', self.song_ended) self.song_changed_handle = self.window.connect('song-changed', self.song_changed) def on_disable(self): self.window.disconnect(self.song_ended_handle) self.window.disconnect(self.song_rating_changed_handle) self.window.disconnect(self.song_changed_handle) def song_ended(self, window, song): self.scrobble(song) def connect(self, session_key): self.network = pylast.get_lastfm_network( api_key=API_KEY, api_secret=API_SECRET, session_key = session_key ) self.scrobbler = self.network.get_scrobbler(CLIENT_ID, CLIENT_VERSION) def song_changed(self, window, song): self.worker.send(self.scrobbler.report_now_playing, (song.artist, song.title, song.album)) def send_rating(self, song, rating): if song.rating: track = self.network.get_track(song.artist, song.title) if rating == 'love': self.worker.send(track.love) elif rating == 'ban': self.worker.send(track.ban) logging.info("Sending song rating to last.fm") def scrobble(self, song): if song.duration > 30 and (song.position > 240 or song.position > song.duration/2): logging.info("Scrobbling song") mode = pylast.SCROBBLE_MODE_PLAYED source = pylast.SCROBBLE_SOURCE_PERSONALIZED_BROADCAST self.worker.send(self.scrobbler.scrobble, (song.artist, song.title, int(song.start_time), source, mode, song.duration, song.album)) class LastFmAuth: def __init__(self, d, prefname, button): self.button = button self.dict = d self.prefname = prefname self.auth_url= False self.set_button_text() self.button.connect('clicked', self.clicked) @property def enabled(self): return self.dict[self.prefname] def setkey(self, key): self.dict[self.prefname] = key self.set_button_text() def set_button_text(self): self.button.set_sensitive(True) if self.auth_url: self.button.set_label("Click once authorized on web site") elif self.enabled: self.button.set_label("Disable") else: self.button.set_label("Authorize") def clicked(self, *ignore): if self.auth_url: def err(e): logging.error(e) self.set_button_text() get_worker().send(self.sg.get_web_auth_session_key, (self.auth_url,), self.setkey, err) self.button.set_label("Checking...") self.button.set_sensitive(False) self.auth_url = False elif self.enabled: self.setkey(False) else: self.network = pylast.get_lastfm_network(api_key=API_KEY, api_secret=API_SECRET) self.sg = pylast.SessionKeyGenerator(self.network) def callback(url): self.auth_url = url self.set_button_text() webbrowser.open(self.auth_url) get_worker().send(self.sg.get_web_auth_url, (), callback) self.button.set_label("Connecting...") self.button.set_sensitive(False) pithos_0.3.17/pithos/pylast.py000066400000000000000000003511171175056731700164120ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # pylast - A Python interface to Last.fm (and other API compatible social networks) # Copyright (C) 2008-2009 Amr Hassan # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 # USA # # http://code.google.com/p/pylast/ __version__ = '0.4' __author__ = 'Amr Hassan' __copyright__ = "Copyright (C) 2008-2009 Amr Hassan" __license__ = "gpl" __email__ = 'amr.hassan@gmail.com' import hashlib import httplib import urllib import threading from xml.dom import minidom import xml.dom import time import shelve import tempfile import sys import htmlentitydefs try: import collections except ImportError: pass STATUS_INVALID_SERVICE = 2 STATUS_INVALID_METHOD = 3 STATUS_AUTH_FAILED = 4 STATUS_INVALID_FORMAT = 5 STATUS_INVALID_PARAMS = 6 STATUS_INVALID_RESOURCE = 7 STATUS_TOKEN_ERROR = 8 STATUS_INVALID_SK = 9 STATUS_INVALID_API_KEY = 10 STATUS_OFFLINE = 11 STATUS_SUBSCRIBERS_ONLY = 12 STATUS_INVALID_SIGNATURE = 13 STATUS_TOKEN_UNAUTHORIZED = 14 STATUS_TOKEN_EXPIRED = 15 EVENT_ATTENDING = '0' EVENT_MAYBE_ATTENDING = '1' EVENT_NOT_ATTENDING = '2' PERIOD_OVERALL = 'overall' PERIOD_7DAYS = "7day" PERIOD_3MONTHS = '3month' PERIOD_6MONTHS = '6month' PERIOD_12MONTHS = '12month' DOMAIN_ENGLISH = 0 DOMAIN_GERMAN = 1 DOMAIN_SPANISH = 2 DOMAIN_FRENCH = 3 DOMAIN_ITALIAN = 4 DOMAIN_POLISH = 5 DOMAIN_PORTUGUESE = 6 DOMAIN_SWEDISH = 7 DOMAIN_TURKISH = 8 DOMAIN_RUSSIAN = 9 DOMAIN_JAPANESE = 10 DOMAIN_CHINESE = 11 COVER_SMALL = 0 COVER_MEDIUM = 1 COVER_LARGE = 2 COVER_EXTRA_LARGE = 3 COVER_MEGA = 4 IMAGES_ORDER_POPULARITY = "popularity" IMAGES_ORDER_DATE = "dateadded" USER_MALE = 'Male' USER_FEMALE = 'Female' SCROBBLE_SOURCE_USER = "P" SCROBBLE_SOURCE_NON_PERSONALIZED_BROADCAST = "R" SCROBBLE_SOURCE_PERSONALIZED_BROADCAST = "E" SCROBBLE_SOURCE_LASTFM = "L" SCROBBLE_SOURCE_UNKNOWN = "U" SCROBBLE_MODE_PLAYED = "" SCROBBLE_MODE_LOVED = "L" SCROBBLE_MODE_BANNED = "B" SCROBBLE_MODE_SKIPPED = "S" """ A list of the implemented webservices (from http://www.last.fm/api/intro) ===================================== # Album * album.addTags DONE * album.getInfo DONE * album.getTags DONE * album.removeTag DONE * album.search DONE # Artist * artist.addTags DONE * artist.getEvents DONE * artist.getImages DONE * artist.getInfo DONE * artist.getPodcast TODO * artist.getShouts DONE * artist.getSimilar DONE * artist.getTags DONE * artist.getTopAlbums DONE * artist.getTopFans DONE * artist.getTopTags DONE * artist.getTopTracks DONE * artist.removeTag DONE * artist.search DONE * artist.share DONE * artist.shout DONE # Auth * auth.getMobileSession DONE * auth.getSession DONE * auth.getToken DONE # Event * event.attend DONE * event.getAttendees DONE * event.getInfo DONE * event.getShouts DONE * event.share DONE * event.shout DONE # Geo * geo.getEvents * geo.getTopArtists * geo.getTopTracks # Group * group.getMembers DONE * group.getWeeklyAlbumChart DONE * group.getWeeklyArtistChart DONE * group.getWeeklyChartList DONE * group.getWeeklyTrackChart DONE # Library * library.addAlbum DONE * library.addArtist DONE * library.addTrack DONE * library.getAlbums DONE * library.getArtists DONE * library.getTracks DONE # Playlist * playlist.addTrack DONE * playlist.create DONE * playlist.fetch DONE # Radio * radio.getPlaylist * radio.tune # Tag * tag.getSimilar DONE * tag.getTopAlbums DONE * tag.getTopArtists DONE * tag.getTopTags DONE * tag.getTopTracks DONE * tag.getWeeklyArtistChart DONE * tag.getWeeklyChartList DONE * tag.search DONE # Tasteometer * tasteometer.compare DONE # Track * track.addTags DONE * track.ban DONE * track.getInfo DONE * track.getSimilar DONE * track.getTags DONE * track.getTopFans DONE * track.getTopTags DONE * track.love DONE * track.removeTag DONE * track.search DONE * track.share DONE # User * user.getEvents DONE * user.getFriends DONE * user.getInfo DONE * user.getLovedTracks DONE * user.getNeighbours DONE * user.getPastEvents DONE * user.getPlaylists DONE * user.getRecentStations TODO * user.getRecentTracks DONE * user.getRecommendedArtists DONE * user.getRecommendedEvents DONE * user.getShouts DONE * user.getTopAlbums DONE * user.getTopArtists DONE * user.getTopTags DONE * user.getTopTracks DONE * user.getWeeklyAlbumChart DONE * user.getWeeklyArtistChart DONE * user.getWeeklyChartList DONE * user.getWeeklyTrackChart DONE * user.shout DONE # Venue * venue.getEvents DONE * venue.getPastEvents DONE * venue.search DONE """ class Network(object): """ A music social network website that is Last.fm or one exposing a Last.fm compatible API """ def __init__(self, name, homepage, ws_server, api_key, api_secret, session_key, submission_server, username, password_hash, domain_names, urls): """ name: the name of the network homepage: the homepage url ws_server: the url of the webservices server api_key: a provided API_KEY api_secret: a provided API_SECRET session_key: a generated session_key or None submission_server: the url of the server to which tracks are submitted (scrobbled) username: a username of a valid user password_hash: the output of pylast.md5(password) where password is the user's password thingy domain_names: a dict mapping each DOMAIN_* value to a string domain name urls: a dict mapping types to urls if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. Either a valid session_key or a combination of username and password_hash must be present for scrobbling. You should use a preconfigured network object through a get_*_network(...) method instead of creating an object of this class, unless you know what you're doing. """ self.ws_server = ws_server self.submission_server = submission_server self.name = name self.homepage = homepage self.api_key = api_key self.api_secret = api_secret self.session_key = session_key self.username = username self.password_hash = password_hash self.domain_names = domain_names self.urls = urls self.cache_backend = None self.proxy_enabled = False self.proxy = None self.last_call_time = 0 #generate a session_key if necessary if (self.api_key and self.api_secret) and not self.session_key and (self.username and self.password_hash): sk_gen = SessionKeyGenerator(self) self.session_key = sk_gen.get_session_key(self.username, self.password_hash) def get_artist(self, artist_name): """ Return an Artist object """ return Artist(artist_name, self) def get_track(self, artist, title): """ Return a Track object """ return Track(artist, title, self) def get_album(self, artist, title): """ Return an Album object """ return Album(artist, title, self) def get_authenticated_user(self): """ Returns the authenticated user """ return AuthenticatedUser(self) def get_country(self, country_name): """ Returns a country object """ return Country(country_name, self) def get_group(self, name): """ Returns a Group object """ return Group(name, self) def get_user(self, username): """ Returns a user object """ return User(username, self) def get_tag(self, name): """ Returns a tag object """ return Tag(name, self) def get_scrobbler(self, client_id, client_version): """ Returns a Scrobbler object used for submitting tracks to the server Quote from http://www.last.fm/api/submissions: ======== Client identifiers are used to provide a centrally managed database of the client versions, allowing clients to be banned if they are found to be behaving undesirably. The client ID is associated with a version number on the server, however these are only incremented if a client is banned and do not have to reflect the version of the actual client application. During development, clients which have not been allocated an identifier should use the identifier tst, with a version number of 1.0. Do not distribute code or client implementations which use this test identifier. Do not use the identifiers used by other clients. ========= To obtain a new client identifier please contact: * Last.fm: submissions@last.fm * # TODO: list others ...and provide us with the name of your client and its homepage address. """ return Scrobbler(self, client_id, client_version) def _get_language_domain(self, domain_language): """ Returns the mapped domain name of the network to a DOMAIN_* value """ if domain_language in self.domain_names: return self.domain_names[domain_language] def _get_url(self, domain, type): return "http://%s/%s" %(self._get_language_domain(domain), self.urls[type]) def _get_ws_auth(self): """ Returns a (API_KEY, API_SECRET, SESSION_KEY) tuple. """ return (self.api_key, self.api_secret, self.session_key) def _delay_call(self): """ Makes sure that web service calls are at least a second apart """ # delay time in seconds DELAY_TIME = 1.0 now = time.time() if (now - self.last_call_time) < DELAY_TIME: time.sleep(1) self.last_call_time = now def create_new_playlist(self, title, description): """ Creates a playlist for the authenticated user and returns it title: The title of the new playlist. description: The description of the new playlist. """ params = {} params['title'] = _unicode(title) params['description'] = _unicode(description) doc = _Request(self, 'playlist.create', params).execute(False) e_id = doc.getElementsByTagName("id")[0].firstChild.data user = doc.getElementsByTagName('playlists')[0].getAttribute('user') return Playlist(user, e_id, self) def get_top_tags(self, limit=None): """Returns a sequence of the most used tags as a sequence of TopItem objects.""" doc = _Request(self, "tag.getTopTags").execute(True) seq = [] for node in doc.getElementsByTagName("tag"): tag = Tag(_extract(node, "name"), self) weight = _number(_extract(node, "count")) if len(seq) < limit: seq.append(TopItem(tag, weight)) return seq def enable_proxy(self, host, port): """Enable a default web proxy""" self.proxy = [host, _number(port)] self.proxy_enabled = True def disable_proxy(self): """Disable using the web proxy""" self.proxy_enabled = False def is_proxy_enabled(self): """Returns True if a web proxy is enabled.""" return self.proxy_enabled def _get_proxy(self): """Returns proxy details.""" return self.proxy def enable_caching(self, file_path = None): """Enables caching request-wide for all cachable calls. In choosing the backend used for caching, it will try _SqliteCacheBackend first if the module sqlite3 is present. If not, it will fallback to _ShelfCacheBackend which uses shelve.Shelf objects. * file_path: A file path for the backend storage file. If None set, a temp file would probably be created, according the backend. """ if not file_path: file_path = tempfile.mktemp(prefix="pylast_tmp_") self.cache_backend = _ShelfCacheBackend(file_path) def disable_caching(self): """Disables all caching features.""" self.cache_backend = None def is_caching_enabled(self): """Returns True if caching is enabled.""" return not (self.cache_backend == None) def _get_cache_backend(self): return self.cache_backend def search_for_album(self, album_name): """Searches for an album by its name. Returns a AlbumSearch object. Use get_next_page() to retreive sequences of results.""" return AlbumSearch(album_name, self) def search_for_artist(self, artist_name): """Searches of an artist by its name. Returns a ArtistSearch object. Use get_next_page() to retreive sequences of results.""" return ArtistSearch(artist_name, self) def search_for_tag(self, tag_name): """Searches of a tag by its name. Returns a TagSearch object. Use get_next_page() to retreive sequences of results.""" return TagSearch(tag_name, self) def search_for_track(self, artist_name, track_name): """Searches of a track by its name and its artist. Set artist to an empty string if not available. Returns a TrackSearch object. Use get_next_page() to retreive sequences of results.""" return TrackSearch(artist_name, track_name, self) def search_for_venue(self, venue_name, country_name): """Searches of a venue by its name and its country. Set country_name to an empty string if not available. Returns a VenueSearch object. Use get_next_page() to retreive sequences of results.""" return VenueSearch(venue_name, country_name, self) def get_track_by_mbid(self, mbid): """Looks up a track by its MusicBrainz ID""" params = {"mbid": _unicode(mbid)} doc = _Request(self, "track.getInfo", params).execute(True) return Track(_extract(doc, "name", 1), _extract(doc, "name"), self) def get_artist_by_mbid(self, mbid): """Loooks up an artist by its MusicBrainz ID""" params = {"mbid": _unicode(mbid)} doc = _Request(self, "artist.getInfo", params).execute(True) return Artist(_extract(doc, "name"), self) def get_album_by_mbid(self, mbid): """Looks up an album by its MusicBrainz ID""" params = {"mbid": _unicode(mbid)} doc = _Request(self, "album.getInfo", params).execute(True) return Album(_extract(doc, "artist"), _extract(doc, "name"), self) def get_lastfm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""): """ Returns a preconfigured Network object for Last.fm api_key: a provided API_KEY api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user password_hash: the output of pylast.md5(password) where password is the user's password if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. Either a valid session_key or a combination of username and password_hash must be present for scrobbling. Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: http://www.last.fm/api/account """ return Network ( name = "Last.fm", homepage = "http://last.fm", ws_server = ("ws.audioscrobbler.com", "/2.0/"), api_key = api_key, api_secret = api_secret, session_key = session_key, submission_server = "http://post.audioscrobbler.com:80/", username = username, password_hash = password_hash, domain_names = { DOMAIN_ENGLISH: 'www.last.fm', DOMAIN_GERMAN: 'www.lastfm.de', DOMAIN_SPANISH: 'www.lastfm.es', DOMAIN_FRENCH: 'www.lastfm.fr', DOMAIN_ITALIAN: 'www.lastfm.it', DOMAIN_POLISH: 'www.lastfm.pl', DOMAIN_PORTUGUESE: 'www.lastfm.com.br', DOMAIN_SWEDISH: 'www.lastfm.se', DOMAIN_TURKISH: 'www.lastfm.com.tr', DOMAIN_RUSSIAN: 'www.lastfm.ru', DOMAIN_JAPANESE: 'www.lastfm.jp', DOMAIN_CHINESE: 'cn.last.fm', }, urls = { "album": "music/%(artist)s/%(album)s", "artist": "music/%(artist)s", "event": "event/%(id)s", "country": "place/%(country_name)s", "playlist": "user/%(user)s/library/playlists/%(appendix)s", "tag": "tag/%(name)s", "track": "music/%(artist)s/_/%(title)s", "group": "group/%(name)s", "user": "user/%(name)s", } ) def get_librefm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""): """ Returns a preconfigured Network object for Libre.fm api_key: a provided API_KEY api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user password_hash: the output of pylast.md5(password) where password is the user's password if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. """ return Network ( name = "Libre.fm", homepage = "http://alpha.dev.libre.fm", ws_server = ("alpha.dev.libre.fm", "/2.0/"), api_key = api_key, api_secret = api_secret, session_key = session_key, submission_server = "http://turtle.libre.fm:80/", username = username, password_hash = password_hash, domain_names = { DOMAIN_ENGLISH: "alpha.dev.libre.fm", DOMAIN_GERMAN: "alpha.dev.libre.fm", DOMAIN_SPANISH: "alpha.dev.libre.fm", DOMAIN_FRENCH: "alpha.dev.libre.fm", DOMAIN_ITALIAN: "alpha.dev.libre.fm", DOMAIN_POLISH: "alpha.dev.libre.fm", DOMAIN_PORTUGUESE: "alpha.dev.libre.fm", DOMAIN_SWEDISH: "alpha.dev.libre.fm", DOMAIN_TURKISH: "alpha.dev.libre.fm", DOMAIN_RUSSIAN: "alpha.dev.libre.fm", DOMAIN_JAPANESE: "alpha.dev.libre.fm", DOMAIN_CHINESE: "alpha.dev.libre.fm", }, urls = { "album": "artist/%(artist)s/album/%(album)s", "artist": "artist/%(artist)s", "event": "event/%(id)s", "country": "place/%(country_name)s", "playlist": "user/%(user)s/library/playlists/%(appendix)s", "tag": "tag/%(name)s", "track": "music/%(artist)s/_/%(title)s", "group": "group/%(name)s", "user": "user/%(name)s", } ) class _ShelfCacheBackend(object): """Used as a backend for caching cacheable requests.""" def __init__(self, file_path = None): self.shelf = shelve.open(file_path) def get_xml(self, key): return self.shelf[key] def set_xml(self, key, xml_string): self.shelf[key] = xml_string def has_key(self, key): return key in self.shelf.keys() class _ThreadedCall(threading.Thread): """Facilitates calling a function on another thread.""" def __init__(self, sender, funct, funct_args, callback, callback_args): threading.Thread.__init__(self) self.funct = funct self.funct_args = funct_args self.callback = callback self.callback_args = callback_args self.sender = sender def run(self): output = [] if self.funct: if self.funct_args: output = self.funct(*self.funct_args) else: output = self.funct() if self.callback: if self.callback_args: self.callback(self.sender, output, *self.callback_args) else: self.callback(self.sender, output) class _Request(object): """Representing an abstract web service operation.""" def __init__(self, network, method_name, params = {}): self.params = params self.network = network (self.api_key, self.api_secret, self.session_key) = network._get_ws_auth() self.params["api_key"] = self.api_key self.params["method"] = method_name if network.is_caching_enabled(): self.cache = network._get_cache_backend() if self.session_key: self.params["sk"] = self.session_key self.sign_it() def sign_it(self): """Sign this request.""" if not "api_sig" in self.params.keys(): self.params['api_sig'] = self._get_signature() def _get_signature(self): """Returns a 32-character hexadecimal md5 hash of the signature string.""" keys = self.params.keys()[:] keys.sort() string = "" for name in keys: string += name string += self.params[name] string += self.api_secret return md5(string) def _get_cache_key(self): """The cache key is a string of concatenated sorted names and values.""" keys = self.params.keys() keys.sort() cache_key = str() for key in keys: if key != "api_sig" and key != "api_key" and key != "sk": cache_key += key + _string(self.params[key]) return hashlib.sha1(cache_key).hexdigest() def _get_cached_response(self): """Returns a file object of the cached response.""" if not self._is_cached(): response = self._download_response() self.cache.set_xml(self._get_cache_key(), response) return self.cache.get_xml(self._get_cache_key()) def _is_cached(self): """Returns True if the request is already in cache.""" return self.cache.has_key(self._get_cache_key()) def _download_response(self): """Returns a response body string from the server.""" # Delay the call if necessary #self.network._delay_call() # enable it if you want. data = [] for name in self.params.keys(): data.append('='.join((name, urllib.quote_plus(_string(self.params[name]))))) data = '&'.join(data) headers = { "Content-type": "application/x-www-form-urlencoded", 'Accept-Charset': 'utf-8', 'User-Agent': "pylast" + '/' + __version__ } (HOST_NAME, HOST_SUBDIR) = self.network.ws_server if self.network.is_proxy_enabled(): conn = httplib.HTTPConnection(host = self._get_proxy()[0], port = self._get_proxy()[1]) conn.request(method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, body=data, headers=headers) else: conn = httplib.HTTPConnection(host=HOST_NAME) conn.request(method='POST', url=HOST_SUBDIR, body=data, headers=headers) response = conn.getresponse() response_text = _unicode(response.read()) self._check_response_for_errors(response_text) return response_text def execute(self, cacheable = False): """Returns the XML DOM response of the POST Request from the server""" if self.network.is_caching_enabled() and cacheable: response = self._get_cached_response() else: response = self._download_response() return minidom.parseString(_string(response)) def _check_response_for_errors(self, response): """Checks the response for errors and raises one if any exists.""" doc = minidom.parseString(_string(response)) e = doc.getElementsByTagName('lfm')[0] if e.getAttribute('status') != "ok": e = doc.getElementsByTagName('error')[0] status = e.getAttribute('code') details = e.firstChild.data.strip() raise WSError(self.network, status, details) class SessionKeyGenerator(object): """Methods of generating a session key: 1) Web Authentication: a. network = get_*_network(API_KEY, API_SECRET) b. sg = SessionKeyGenerator(network) c. url = sg.get_web_auth_url() d. Ask the user to open the url and authorize you, and wait for it. e. session_key = sg.get_web_auth_session_key(url) 2) Username and Password Authentication: a. network = get_*_network(API_KEY, API_SECRET) b. username = raw_input("Please enter your username: ") c. password_hash = pylast.md5(raw_input("Please enter your password: ") d. session_key = SessionKeyGenerator(network).get_session_key(username, password_hash) A session key's lifetime is infinie, unless the user provokes the rights of the given API Key. If you create a Network object with just a API_KEY and API_SECRET and a username and a password_hash, a SESSION_KEY will be automatically generated for that network and stored in it so you don't have to do this manually, unless you want to. """ def __init__(self, network): self.network = network self.web_auth_tokens = {} def _get_web_auth_token(self): """Retrieves a token from the network for web authentication. The token then has to be authorized from getAuthURL before creating session. """ request = _Request(self.network, 'auth.getToken') # default action is that a request is signed only when # a session key is provided. request.sign_it() doc = request.execute() e = doc.getElementsByTagName('token')[0] return e.firstChild.data def get_web_auth_url(self): """The user must open this page, and you first, then call get_web_auth_session_key(url) after that.""" token = self._get_web_auth_token() url = '%(homepage)s/api/auth/?api_key=%(api)s&token=%(token)s' % \ {"homepage": self.network.homepage, "api": self.network.api_key, "token": token} self.web_auth_tokens[url] = token return url def get_web_auth_session_key(self, url): """Retrieves the session key of a web authorization process by its url.""" if url in self.web_auth_tokens.keys(): token = self.web_auth_tokens[url] else: token = "" #that's gonna raise a WSError of an unauthorized token when the request is executed. request = _Request(self.network, 'auth.getSession', {'token': token}) # default action is that a request is signed only when # a session key is provided. request.sign_it() doc = request.execute() return doc.getElementsByTagName('key')[0].firstChild.data def get_session_key(self, username, password_hash): """Retrieve a session key with a username and a md5 hash of the user's password.""" params = {"username": username, "authToken": md5(username + password_hash)} request = _Request(self.network, "auth.getMobileSession", params) # default action is that a request is signed only when # a session key is provided. request.sign_it() doc = request.execute() return _extract(doc, "key") def _namedtuple(name, children): """ collections.namedtuple is available in (python >= 2.6) """ v = sys.version_info if v[1] >= 6 and v[0] < 3: return collections.namedtuple(name, children) else: def fancydict(*args): d = {} i = 0 for child in children: d[child.strip()] = args[i] i += 1 return d return fancydict TopItem = _namedtuple("TopItem", ["item", "weight"]) SimilarItem = _namedtuple("SimilarItem", ["item", "match"]) LibraryItem = _namedtuple("LibraryItem", ["item", "playcount", "tagcount"]) PlayedTrack = _namedtuple("PlayedTrack", ["track", "playback_date", "timestamp"]) LovedTrack = _namedtuple("LovedTrack", ["track", "date", "timestamp"]) ImageSizes = _namedtuple("ImageSizes", ["original", "large", "largesquare", "medium", "small", "extralarge"]) Image = _namedtuple("Image", ["title", "url", "dateadded", "format", "owner", "sizes", "votes"]) Shout = _namedtuple("Shout", ["body", "author", "date"]) def _string_output(funct): def r(*args): return _string(funct(*args)) return r def _pad_list(given_list, desired_length, padding = None): """ Pads a list to be of the desired_length. """ while len(given_list) < desired_length: given_list.append(padding) return given_list class _BaseObject(object): """An abstract webservices object.""" network = None def __init__(self, network): self.network = network def _request(self, method_name, cacheable = False, params = None): if not params: params = self._get_params() return _Request(self.network, method_name, params).execute(cacheable) def _get_params(self): """Returns the most common set of parameters between all objects.""" return {} def __hash__(self): return hash(self.network) + \ hash(str(type(self)) + "".join(self._get_params().keys() + self._get_params().values()).lower()) class _Taggable(object): """Common functions for classes with tags.""" def __init__(self, ws_prefix): self.ws_prefix = ws_prefix def add_tags(self, *tags): """Adds one or several tags. * *tags: Any number of tag names or Tag objects. """ for tag in tags: self._add_tag(tag) def _add_tag(self, tag): """Adds one or several tags. * tag: one tag name or a Tag object. """ if isinstance(tag, Tag): tag = tag.get_name() params = self._get_params() params['tags'] = _unicode(tag) self._request(self.ws_prefix + '.addTags', False, params) def _remove_tag(self, single_tag): """Remove a user's tag from this object.""" if isinstance(single_tag, Tag): single_tag = single_tag.get_name() params = self._get_params() params['tag'] = _unicode(single_tag) self._request(self.ws_prefix + '.removeTag', False, params) def get_tags(self): """Returns a list of the tags set by the user to this object.""" # Uncacheable because it can be dynamically changed by the user. params = self._get_params() doc = self._request(self.ws_prefix + '.getTags', False, params) tag_names = _extract_all(doc, 'name') tags = [] for tag in tag_names: tags.append(Tag(tag, self.network)) return tags def remove_tags(self, *tags): """Removes one or several tags from this object. * *tags: Any number of tag names or Tag objects. """ for tag in tags: self._remove_tag(tag) def clear_tags(self): """Clears all the user-set tags. """ self.remove_tags(*(self.get_tags())) def set_tags(self, *tags): """Sets this object's tags to only those tags. * *tags: any number of tag names. """ c_old_tags = [] old_tags = [] c_new_tags = [] new_tags = [] to_remove = [] to_add = [] tags_on_server = self.get_tags() for tag in tags_on_server: c_old_tags.append(tag.get_name().lower()) old_tags.append(tag.get_name()) for tag in tags: c_new_tags.append(tag.lower()) new_tags.append(tag) for i in range(0, len(old_tags)): if not c_old_tags[i] in c_new_tags: to_remove.append(old_tags[i]) for i in range(0, len(new_tags)): if not c_new_tags[i] in c_old_tags: to_add.append(new_tags[i]) self.remove_tags(*to_remove) self.add_tags(*to_add) def get_top_tags(self, limit = None): """Returns a list of the most frequently used Tags on this object.""" doc = self._request(self.ws_prefix + '.getTopTags', True) elements = doc.getElementsByTagName('tag') seq = [] for element in elements: if limit and len(seq) >= limit: break tag_name = _extract(element, 'name') tagcount = _extract(element, 'count') seq.append(TopItem(Tag(tag_name, self.network), tagcount)) return seq class WSError(Exception): """Exception related to the Network web service""" def __init__(self, network, status, details): self.status = status self.details = details self.network = network @_string_output def __str__(self): return self.details def get_id(self): """Returns the exception ID, from one of the following: STATUS_INVALID_SERVICE = 2 STATUS_INVALID_METHOD = 3 STATUS_AUTH_FAILED = 4 STATUS_INVALID_FORMAT = 5 STATUS_INVALID_PARAMS = 6 STATUS_INVALID_RESOURCE = 7 STATUS_TOKEN_ERROR = 8 STATUS_INVALID_SK = 9 STATUS_INVALID_API_KEY = 10 STATUS_OFFLINE = 11 STATUS_SUBSCRIBERS_ONLY = 12 STATUS_TOKEN_UNAUTHORIZED = 14 STATUS_TOKEN_EXPIRED = 15 """ return self.status class Album(_BaseObject, _Taggable): """An album.""" title = None artist = None def __init__(self, artist, title, network): """ Create an album instance. # Parameters: * artist: An artist name or an Artist object. * title: The album title. """ _BaseObject.__init__(self, network) _Taggable.__init__(self, 'album') if isinstance(artist, Artist): self.artist = artist else: self.artist = Artist(artist, self.network) self.title = title @_string_output def __repr__(self): return u"%s - %s" %(self.get_artist().get_name(), self.get_title()) def __eq__(self, other): return (self.get_title().lower() == other.get_title().lower()) and (self.get_artist().get_name().lower() == other.get_artist().get_name().lower()) def __ne__(self, other): return (self.get_title().lower() != other.get_title().lower()) or (self.get_artist().get_name().lower() != other.get_artist().get_name().lower()) def _get_params(self): return {'artist': self.get_artist().get_name(), 'album': self.get_title(), } def get_artist(self): """Returns the associated Artist object.""" return self.artist def get_title(self): """Returns the album title.""" return self.title def get_name(self): """Returns the album title (alias to Album.get_title).""" return self.get_title() def get_release_date(self): """Retruns the release date of the album.""" return _extract(self._request("album.getInfo", cacheable = True), "releasedate") def get_cover_image(self, size = COVER_EXTRA_LARGE): """ Returns a uri to the cover image size can be one of: COVER_MEGA COVER_EXTRA_LARGE COVER_LARGE COVER_MEDIUM COVER_SMALL """ return _extract_all(self._request("album.getInfo", cacheable = True), 'image')[size] def get_id(self): """Returns the ID""" return _extract(self._request("album.getInfo", cacheable = True), "id") def get_playcount(self): """Returns the number of plays on the network""" return _number(_extract(self._request("album.getInfo", cacheable = True), "playcount")) def get_listener_count(self): """Returns the number of liteners on the network""" return _number(_extract(self._request("album.getInfo", cacheable = True), "listeners")) def get_top_tags(self, limit=None): """Returns a list of the most-applied tags to this album.""" doc = self._request("album.getInfo", True) e = doc.getElementsByTagName("toptags")[0] seq = [] for name in _extract_all(e, "name"): if len(seq) < limit: seq.append(Tag(name, self.network)) return seq def get_tracks(self): """Returns the list of Tracks on this album.""" uri = 'lastfm://playlist/album/%s' %self.get_id() return XSPF(uri, self.network).get_tracks() def get_mbid(self): """Returns the MusicBrainz id of the album.""" return _extract(self._request("album.getInfo", cacheable = True), "mbid") def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the album page on the network. # Parameters: * domain_name str: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ artist = _url_safe(self.get_artist().get_name()) album = _url_safe(self.get_title()) return self.network._get_url(domain_name, "album") %{'artist': artist, 'album': album} def get_wiki_published_date(self): """Returns the date of publishing this version of the wiki.""" doc = self._request("album.getInfo", True) if len(doc.getElementsByTagName("wiki")) == 0: return node = doc.getElementsByTagName("wiki")[0] return _extract(node, "published") def get_wiki_summary(self): """Returns the summary of the wiki.""" doc = self._request("album.getInfo", True) if len(doc.getElementsByTagName("wiki")) == 0: return node = doc.getElementsByTagName("wiki")[0] return _extract(node, "summary") def get_wiki_content(self): """Returns the content of the wiki.""" doc = self._request("album.getInfo", True) if len(doc.getElementsByTagName("wiki")) == 0: return node = doc.getElementsByTagName("wiki")[0] return _extract(node, "content") class Artist(_BaseObject, _Taggable): """An artist.""" name = None def __init__(self, name, network): """Create an artist object. # Parameters: * name str: The artist's name. """ _BaseObject.__init__(self, network) _Taggable.__init__(self, 'artist') self.name = name @_string_output def __repr__(self): return self.get_name() def __eq__(self, other): return self.get_name().lower() == other.get_name().lower() def __ne__(self, other): return self.get_name().lower() != other.get_name().lower() def _get_params(self): return {'artist': self.get_name()} def get_name(self): """Returns the name of the artist.""" return self.name def get_cover_image(self, size = COVER_LARGE): """ Returns a uri to the cover image size can be one of: COVER_MEGA COVER_EXTRA_LARGE COVER_LARGE COVER_MEDIUM COVER_SMALL """ return _extract_all(self._request("artist.getInfo", True), "image")[size] def get_playcount(self): """Returns the number of plays on the network.""" return _number(_extract(self._request("artist.getInfo", True), "playcount")) def get_mbid(self): """Returns the MusicBrainz ID of this artist.""" doc = self._request("artist.getInfo", True) return _extract(doc, "mbid") def get_listener_count(self): """Returns the number of liteners on the network.""" return _number(_extract(self._request("artist.getInfo", True), "listeners")) def is_streamable(self): """Returns True if the artist is streamable.""" return bool(_number(_extract(self._request("artist.getInfo", True), "streamable"))) def get_bio_published_date(self): """Returns the date on which the artist's biography was published.""" return _extract(self._request("artist.getInfo", True), "published") def get_bio_summary(self): """Returns the summary of the artist's biography.""" return _extract(self._request("artist.getInfo", True), "summary") def get_bio_content(self): """Returns the content of the artist's biography.""" return _extract(self._request("artist.getInfo", True), "content") def get_upcoming_events(self): """Returns a list of the upcoming Events for this artist.""" doc = self._request('artist.getEvents', True) ids = _extract_all(doc, 'id') events = [] for e_id in ids: events.append(Event(e_id, self.network)) return events def get_similar(self, limit = None): """Returns the similar artists on the network.""" params = self._get_params() if limit: params['limit'] = _unicode(limit) doc = self._request('artist.getSimilar', True, params) names = _extract_all(doc, "name") matches = _extract_all(doc, "match") artists = [] for i in range(0, len(names)): artists.append(SimilarItem(Artist(names[i], self.network), _number(matches[i]))) return artists def get_top_albums(self): """Retuns a list of the top albums.""" doc = self._request('artist.getTopAlbums', True) seq = [] for node in doc.getElementsByTagName("album"): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _extract(node, "playcount") seq.append(TopItem(Album(artist, name, self.network), playcount)) return seq def get_top_tracks(self): """Returns a list of the most played Tracks by this artist.""" doc = self._request("artist.getTopTracks", True) seq = [] for track in doc.getElementsByTagName('track'): title = _extract(track, "name") artist = _extract(track, "name", 1) playcount = _number(_extract(track, "playcount")) seq.append( TopItem(Track(artist, title, self.network), playcount) ) return seq def get_top_fans(self, limit = None): """Returns a list of the Users who played this artist the most. # Parameters: * limit int: Max elements. """ doc = self._request('artist.getTopFans', True) seq = [] elements = doc.getElementsByTagName('user') for element in elements: if limit and len(seq) >= limit: break name = _extract(element, 'name') weight = _number(_extract(element, 'weight')) seq.append(TopItem(User(name, self.network), weight)) return seq def share(self, users, message = None): """Shares this artist (sends out recommendations). # Parameters: * users [User|str,]: A list that can contain usernames, emails, User objects, or all of them. * message str: A message to include in the recommendation message. """ #last.fm currently accepts a max of 10 recipient at a time while(len(users) > 10): section = users[0:9] users = users[9:] self.share(section, message) nusers = [] for user in users: if isinstance(user, User): nusers.append(user.get_name()) else: nusers.append(user) params = self._get_params() recipients = ','.join(nusers) params['recipient'] = recipients if message: params['message'] = _unicode(message) self._request('artist.share', False, params) def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the artist page on the network. # Parameters: * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ artist = _url_safe(self.get_name()) return self.network._get_url(domain_name, "artist") %{'artist': artist} def get_images(self, order=IMAGES_ORDER_POPULARITY, limit=None): """ Returns a sequence of Image objects if limit is None it will return all order can be IMAGES_ORDER_POPULARITY or IMAGES_ORDER_DATE """ images = [] params = self._get_params() params["order"] = order nodes = _collect_nodes(limit, self, "artist.getImages", True, params) for e in nodes: if _extract(e, "name"): user = User(_extract(e, "name"), self.network) else: user = None images.append(Image( _extract(e, "title"), _extract(e, "url"), _extract(e, "dateadded"), _extract(e, "format"), user, ImageSizes(*_extract_all(e, "size")), (_extract(e, "thumbsup"), _extract(e, "thumbsdown")) ) ) return images def get_shouts(self, limit=50): """ Returns a sequqence of Shout objects """ shouts = [] for node in _collect_nodes(limit, self, "artist.getShouts", False): shouts.append(Shout( _extract(node, "body"), User(_extract(node, "author"), self.network), _extract(node, "date") ) ) return shouts def shout(self, message): """ Post a shout """ params = self._get_params() params["message"] = message self._request("artist.Shout", False, params) class Event(_BaseObject): """An event.""" id = None def __init__(self, event_id, network): _BaseObject.__init__(self, network) self.id = _unicode(event_id) @_string_output def __repr__(self): return "Event #" + self.get_id() def __eq__(self, other): return self.get_id() == other.get_id() def __ne__(self, other): return self.get_id() != other.get_id() def _get_params(self): return {'event': self.get_id()} def attend(self, attending_status): """Sets the attending status. * attending_status: The attending status. Possible values: o EVENT_ATTENDING o EVENT_MAYBE_ATTENDING o EVENT_NOT_ATTENDING """ params = self._get_params() params['status'] = _unicode(attending_status) self._request('event.attend', False, params) def get_attendees(self): """ Get a list of attendees for an event """ doc = self._request("event.getAttendees", False) users = [] for name in _extract_all(doc, "name"): users.append(User(name, self.network)) return users def get_id(self): """Returns the id of the event on the network. """ return self.id def get_title(self): """Returns the title of the event. """ doc = self._request("event.getInfo", True) return _extract(doc, "title") def get_headliner(self): """Returns the headliner of the event. """ doc = self._request("event.getInfo", True) return Artist(_extract(doc, "headliner"), self.network) def get_artists(self): """Returns a list of the participating Artists. """ doc = self._request("event.getInfo", True) names = _extract_all(doc, "artist") artists = [] for name in names: artists.append(Artist(name, self.network)) return artists def get_venue(self): """Returns the venue where the event is held.""" doc = self._request("event.getInfo", True) v = doc.getElementsByTagName("venue")[0] venue_id = _number(_extract(v, "id")) return Venue(venue_id, self.network) def get_start_date(self): """Returns the date when the event starts.""" doc = self._request("event.getInfo", True) return _extract(doc, "startDate") def get_description(self): """Returns the description of the event. """ doc = self._request("event.getInfo", True) return _extract(doc, "description") def get_cover_image(self, size = COVER_LARGE): """ Returns a uri to the cover image size can be one of: COVER_MEGA COVER_EXTRA_LARGE COVER_LARGE COVER_MEDIUM COVER_SMALL """ doc = self._request("event.getInfo", True) return _extract_all(doc, "image")[size] def get_attendance_count(self): """Returns the number of attending people. """ doc = self._request("event.getInfo", True) return _number(_extract(doc, "attendance")) def get_review_count(self): """Returns the number of available reviews for this event. """ doc = self._request("event.getInfo", True) return _number(_extract(doc, "reviews")) def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the event page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ return self.network._get_url(domain_name, "event") %{'id': self.get_id()} def share(self, users, message = None): """Shares this event (sends out recommendations). * users: A list that can contain usernames, emails, User objects, or all of them. * message: A message to include in the recommendation message. """ #last.fm currently accepts a max of 10 recipient at a time while(len(users) > 10): section = users[0:9] users = users[9:] self.share(section, message) nusers = [] for user in users: if isinstance(user, User): nusers.append(user.get_name()) else: nusers.append(user) params = self._get_params() recipients = ','.join(nusers) params['recipient'] = recipients if message: params['message'] = _unicode(message) self._request('event.share', False, params) def get_shouts(self, limit=50): """ Returns a sequqence of Shout objects """ shouts = [] for node in _collect_nodes(limit, self, "event.getShouts", False): shouts.append(Shout( _extract(node, "body"), User(_extract(node, "author"), self.network), _extract(node, "date") ) ) return shouts def shout(self, message): """ Post a shout """ params = self._get_params() params["message"] = message self._request("event.Shout", False, params) class Country(_BaseObject): """A country at Last.fm.""" name = None def __init__(self, name, network): _BaseObject.__init__(self, network) self.name = name @_string_output def __repr__(self): return self.get_name() def __eq__(self, other): return self.get_name().lower() == other.get_name().lower() def __ne__(self, other): return self.get_name() != other.get_name() def _get_params(self): return {'country': self.get_name()} def _get_name_from_code(self, alpha2code): # TODO: Have this function lookup the alpha-2 code and return the country name. return alpha2code def get_name(self): """Returns the country name. """ return self.name def get_top_artists(self): """Returns a sequence of the most played artists.""" doc = self._request('geo.getTopArtists', True) seq = [] for node in doc.getElementsByTagName("artist"): name = _extract(node, 'name') playcount = _extract(node, "playcount") seq.append(TopItem(Artist(name, self.network), playcount)) return seq def get_top_tracks(self): """Returns a sequence of the most played tracks""" doc = self._request("geo.getTopTracks", True) seq = [] for n in doc.getElementsByTagName('track'): title = _extract(n, 'name') artist = _extract(n, 'name', 1) playcount = _number(_extract(n, "playcount")) seq.append( TopItem(Track(artist, title, self.network), playcount)) return seq def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the event page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ country_name = _url_safe(self.get_name()) return self.network._get_url(domain_name, "country") %{'country_name': country_name} class Library(_BaseObject): """A user's Last.fm library.""" user = None def __init__(self, user, network): _BaseObject.__init__(self, network) if isinstance(user, User): self.user = user else: self.user = User(user, self.network) self._albums_index = 0 self._artists_index = 0 self._tracks_index = 0 @_string_output def __repr__(self): return repr(self.get_user()) + "'s Library" def _get_params(self): return {'user': self.user.get_name()} def get_user(self): """Returns the user who owns this library.""" return self.user def add_album(self, album): """Add an album to this library.""" params = self._get_params() params["artist"] = album.get_artist.get_name() params["album"] = album.get_name() self._request("library.addAlbum", False, params) def add_artist(self, artist): """Add an artist to this library.""" params = self._get_params() params["artist"] = artist.get_name() self._request("library.addArtist", False, params) def add_track(self, track): """Add a track to this library.""" params = self._get_params() params["track"] = track.get_title() self._request("library.addTrack", False, params) def get_albums(self, limit=50): """ Returns a sequence of Album objects if limit==None it will return all (may take a while) """ seq = [] for node in _collect_nodes(limit, self, "library.getAlbums", True): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) tagcount = _number(_extract(node, "tagcount")) seq.append(LibraryItem(Album(artist, name, self.network), playcount, tagcount)) return seq def get_artists(self, limit=50): """ Returns a sequence of Album objects if limit==None it will return all (may take a while) """ seq = [] for node in _collect_nodes(limit, self, "library.getArtists", True): name = _extract(node, "name") playcount = _number(_extract(node, "playcount")) tagcount = _number(_extract(node, "tagcount")) seq.append(LibraryItem(Artist(name, self.network), playcount, tagcount)) return seq def get_tracks(self, limit=50): """ Returns a sequence of Album objects if limit==None it will return all (may take a while) """ seq = [] for node in _collect_nodes(limit, self, "library.getTracks", True): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) tagcount = _number(_extract(node, "tagcount")) seq.append(LibraryItem(Track(artist, name, self.network), playcount, tagcount)) return seq class Playlist(_BaseObject): """A Last.fm user playlist.""" id = None user = None def __init__(self, user, id, network): _BaseObject.__init__(self, network) if isinstance(user, User): self.user = user else: self.user = User(user, self.network) self.id = _unicode(id) @_string_output def __repr__(self): return repr(self.user) + "'s playlist # " + repr(self.id) def _get_info_node(self): """Returns the node from user.getPlaylists where this playlist's info is.""" doc = self._request("user.getPlaylists", True) for node in doc.getElementsByTagName("playlist"): if _extract(node, "id") == str(self.get_id()): return node def _get_params(self): return {'user': self.user.get_name(), 'playlistID': self.get_id()} def get_id(self): """Returns the playlist id.""" return self.id def get_user(self): """Returns the owner user of this playlist.""" return self.user def get_tracks(self): """Returns a list of the tracks on this user playlist.""" uri = u'lastfm://playlist/%s' %self.get_id() return XSPF(uri, self.network).get_tracks() def add_track(self, track): """Adds a Track to this Playlist.""" params = self._get_params() params['artist'] = track.get_artist().get_name() params['track'] = track.get_title() self._request('playlist.addTrack', False, params) def get_title(self): """Returns the title of this playlist.""" return _extract(self._get_info_node(), "title") def get_creation_date(self): """Returns the creation date of this playlist.""" return _extract(self._get_info_node(), "date") def get_size(self): """Returns the number of tracks in this playlist.""" return _number(_extract(self._get_info_node(), "size")) def get_description(self): """Returns the description of this playlist.""" return _extract(self._get_info_node(), "description") def get_duration(self): """Returns the duration of this playlist in milliseconds.""" return _number(_extract(self._get_info_node(), "duration")) def is_streamable(self): """Returns True if the playlist is streamable. For a playlist to be streamable, it needs at least 45 tracks by 15 different artists.""" if _extract(self._get_info_node(), "streamable") == '1': return True else: return False def has_track(self, track): """Checks to see if track is already in the playlist. * track: Any Track object. """ return track in self.get_tracks() def get_cover_image(self, size = COVER_LARGE): """ Returns a uri to the cover image size can be one of: COVER_MEGA COVER_EXTRA_LARGE COVER_LARGE COVER_MEDIUM COVER_SMALL """ return _extract(self._get_info_node(), "image")[size] def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the playlist on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ english_url = _extract(self._get_info_node(), "url") appendix = english_url[english_url.rfind("/") + 1:] return self.network._get_url(domain_name, "playlist") %{'appendix': appendix, "user": self.get_user().get_name()} class Tag(_BaseObject): """A Last.fm object tag.""" # TODO: getWeeklyArtistChart (too lazy, i'll wait for when someone requests it) name = None def __init__(self, name, network): _BaseObject.__init__(self, network) self.name = name def _get_params(self): return {'tag': self.get_name()} @_string_output def __repr__(self): return self.get_name() def __eq__(self, other): return self.get_name().lower() == other.get_name().lower() def __ne__(self, other): return self.get_name().lower() != other.get_name().lower() def get_name(self): """Returns the name of the tag. """ return self.name def get_similar(self): """Returns the tags similar to this one, ordered by similarity. """ doc = self._request('tag.getSimilar', True) seq = [] names = _extract_all(doc, 'name') for name in names: seq.append(Tag(name, self.network)) return seq def get_top_albums(self): """Retuns a list of the top albums.""" doc = self._request('tag.getTopAlbums', True) seq = [] for node in doc.getElementsByTagName("album"): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _extract(node, "playcount") seq.append(TopItem(Album(artist, name, self.network), playcount)) return seq def get_top_tracks(self): """Returns a list of the most played Tracks by this artist.""" doc = self._request("tag.getTopTracks", True) seq = [] for track in doc.getElementsByTagName('track'): title = _extract(track, "name") artist = _extract(track, "name", 1) playcount = _number(_extract(track, "playcount")) seq.append( TopItem(Track(artist, title, self.network), playcount) ) return seq def get_top_artists(self): """Returns a sequence of the most played artists.""" doc = self._request('tag.getTopArtists', True) seq = [] for node in doc.getElementsByTagName("artist"): name = _extract(node, 'name') playcount = _extract(node, "playcount") seq.append(TopItem(Artist(name, self.network), playcount)) return seq def get_weekly_chart_dates(self): """Returns a list of From and To tuples for the available charts.""" doc = self._request("tag.getWeeklyChartList", True) seq = [] for node in doc.getElementsByTagName("chart"): seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) return seq def get_weekly_artist_charts(self, from_date = None, to_date = None): """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date doc = self._request("tag.getWeeklyArtistChart", True, params) seq = [] for node in doc.getElementsByTagName("artist"): item = Artist(_extract(node, "name"), self.network) weight = _number(_extract(node, "weight")) seq.append(TopItem(item, weight)) return seq def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the tag page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) return self.network._get_url(domain_name, "tag") %{'name': name} class Track(_BaseObject, _Taggable): """A Last.fm track.""" artist = None title = None def __init__(self, artist, title, network): _BaseObject.__init__(self, network) _Taggable.__init__(self, 'track') if isinstance(artist, Artist): self.artist = artist else: self.artist = Artist(artist, self.network) self.title = title @_string_output def __repr__(self): return self.get_artist().get_name() + ' - ' + self.get_title() def __eq__(self, other): return (self.get_title().lower() == other.get_title().lower()) and (self.get_artist().get_name().lower() == other.get_artist().get_name().lower()) def __ne__(self, other): return (self.get_title().lower() != other.get_title().lower()) or (self.get_artist().get_name().lower() != other.get_artist().get_name().lower()) def _get_params(self): return {'artist': self.get_artist().get_name(), 'track': self.get_title()} def get_artist(self): """Returns the associated Artist object.""" return self.artist def get_title(self): """Returns the track title.""" return self.title def get_name(self): """Returns the track title (alias to Track.get_title).""" return self.get_title() def get_id(self): """Returns the track id on the network.""" doc = self._request("track.getInfo", True) return _extract(doc, "id") def get_duration(self): """Returns the track duration.""" doc = self._request("track.getInfo", True) return _number(_extract(doc, "duration")) def get_mbid(self): """Returns the MusicBrainz ID of this track.""" doc = self._request("track.getInfo", True) return _extract(doc, "mbid") def get_listener_count(self): """Returns the listener count.""" doc = self._request("track.getInfo", True) return _number(_extract(doc, "listeners")) def get_playcount(self): """Returns the play count.""" doc = self._request("track.getInfo", True) return _number(_extract(doc, "playcount")) def is_streamable(self): """Returns True if the track is available at Last.fm.""" doc = self._request("track.getInfo", True) return _extract(doc, "streamable") == "1" def is_fulltrack_available(self): """Returns True if the fulltrack is available for streaming.""" doc = self._request("track.getInfo", True) return doc.getElementsByTagName("streamable")[0].getAttribute("fulltrack") == "1" def get_album(self): """Returns the album object of this track.""" doc = self._request("track.getInfo", True) albums = doc.getElementsByTagName("album") if len(albums) == 0: return node = doc.getElementsByTagName("album")[0] return Album(_extract(node, "artist"), _extract(node, "title"), self.network) def get_wiki_published_date(self): """Returns the date of publishing this version of the wiki.""" doc = self._request("track.getInfo", True) if len(doc.getElementsByTagName("wiki")) == 0: return node = doc.getElementsByTagName("wiki")[0] return _extract(node, "published") def get_wiki_summary(self): """Returns the summary of the wiki.""" doc = self._request("track.getInfo", True) if len(doc.getElementsByTagName("wiki")) == 0: return node = doc.getElementsByTagName("wiki")[0] return _extract(node, "summary") def get_wiki_content(self): """Returns the content of the wiki.""" doc = self._request("track.getInfo", True) if len(doc.getElementsByTagName("wiki")) == 0: return node = doc.getElementsByTagName("wiki")[0] return _extract(node, "content") def love(self): """Adds the track to the user's loved tracks. """ self._request('track.love') def ban(self): """Ban this track from ever playing on the radio. """ self._request('track.ban') def get_similar(self): """Returns similar tracks for this track on the network, based on listening data. """ doc = self._request('track.getSimilar', True) seq = [] for node in doc.getElementsByTagName("track"): title = _extract(node, 'name') artist = _extract(node, 'name', 1) match = _number(_extract(node, "match")) seq.append(SimilarItem(Track(artist, title, self.network), match)) return seq def get_top_fans(self, limit = None): """Returns a list of the Users who played this track.""" doc = self._request('track.getTopFans', True) seq = [] elements = doc.getElementsByTagName('user') for element in elements: if limit and len(seq) >= limit: break name = _extract(element, 'name') weight = _number(_extract(element, 'weight')) seq.append(TopItem(User(name, self.network), weight)) return seq def share(self, users, message = None): """Shares this track (sends out recommendations). * users: A list that can contain usernames, emails, User objects, or all of them. * message: A message to include in the recommendation message. """ #last.fm currently accepts a max of 10 recipient at a time while(len(users) > 10): section = users[0:9] users = users[9:] self.share(section, message) nusers = [] for user in users: if isinstance(user, User): nusers.append(user.get_name()) else: nusers.append(user) params = self._get_params() recipients = ','.join(nusers) params['recipient'] = recipients if message: params['message'] = _unicode(message) self._request('track.share', False, params) def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the track page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ artist = _url_safe(self.get_artist().get_name()) title = _url_safe(self.get_title()) return self.network._get_url(domain_name, "track") %{'domain': self.network._get_language_domain(domain_name), 'artist': artist, 'title': title} def get_shouts(self, limit=50): """ Returns a sequqence of Shout objects """ shouts = [] for node in _collect_nodes(limit, self, "track.getShouts", False): shouts.append(Shout( _extract(node, "body"), User(_extract(node, "author"), self.network), _extract(node, "date") ) ) return shouts def shout(self, message): """ Post a shout """ params = self._get_params() params["message"] = message self._request("track.Shout", False, params) class Group(_BaseObject): """A Last.fm group.""" name = None def __init__(self, group_name, network): _BaseObject.__init__(self, network) self.name = group_name @_string_output def __repr__(self): return self.get_name() def __eq__(self, other): return self.get_name().lower() == other.get_name().lower() def __ne__(self, other): return self.get_name() != other.get_name() def _get_params(self): return {'group': self.get_name()} def get_name(self): """Returns the group name. """ return self.name def get_weekly_chart_dates(self): """Returns a list of From and To tuples for the available charts.""" doc = self._request("group.getWeeklyChartList", True) seq = [] for node in doc.getElementsByTagName("chart"): seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) return seq def get_weekly_artist_charts(self, from_date = None, to_date = None): """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date doc = self._request("group.getWeeklyArtistChart", True, params) seq = [] for node in doc.getElementsByTagName("artist"): item = Artist(_extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) return seq def get_weekly_album_charts(self, from_date = None, to_date = None): """Returns the weekly album charts for the week starting from the from_date value to the to_date value.""" params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date doc = self._request("group.getWeeklyAlbumChart", True, params) seq = [] for node in doc.getElementsByTagName("album"): item = Album(_extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) return seq def get_weekly_track_charts(self, from_date = None, to_date = None): """Returns the weekly track charts for the week starting from the from_date value to the to_date value.""" params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date doc = self._request("group.getWeeklyTrackChart", True, params) seq = [] for node in doc.getElementsByTagName("track"): item = Track(_extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) return seq def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the group page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) return self.network._get_url(domain_name, "group") %{'name': name} def get_members(self, limit=50): """ Returns a sequence of User objects if limit==None it will return all """ nodes = _collect_nodes(limit, self, "group.getMembers", False) users = [] for node in nodes: users.append(User(_extract(node, "name"), self.network)) return users class XSPF(_BaseObject): "A Last.fm XSPF playlist.""" uri = None def __init__(self, uri, network): _BaseObject.__init__(self, network) self.uri = uri def _get_params(self): return {'playlistURL': self.get_uri()} @_string_output def __repr__(self): return self.get_uri() def __eq__(self, other): return self.get_uri() == other.get_uri() def __ne__(self, other): return self.get_uri() != other.get_uri() def get_uri(self): """Returns the Last.fm playlist URI. """ return self.uri def get_tracks(self): """Returns the tracks on this playlist.""" doc = self._request('playlist.fetch', True) seq = [] for n in doc.getElementsByTagName('track'): title = _extract(n, 'title') artist = _extract(n, 'creator') seq.append(Track(artist, title, self.network)) return seq class User(_BaseObject): """A Last.fm user.""" name = None def __init__(self, user_name, network): _BaseObject.__init__(self, network) self.name = user_name self._past_events_index = 0 self._recommended_events_index = 0 self._recommended_artists_index = 0 @_string_output def __repr__(self): return self.get_name() def __eq__(self, another): return self.get_name() == another.get_name() def __ne__(self, another): return self.get_name() != another.get_name() def _get_params(self): return {"user": self.get_name()} def get_name(self): """Returns the nuser name.""" return self.name def get_upcoming_events(self): """Returns all the upcoming events for this user. """ doc = self._request('user.getEvents', True) ids = _extract_all(doc, 'id') events = [] for e_id in ids: events.append(Event(e_id, self.network)) return events def get_friends(self, limit = 50): """Returns a list of the user's friends. """ seq = [] for node in _collect_nodes(limit, self, "user.getFriends", False): seq.append(User(_extract(node, "name"), self.network)) return seq def get_loved_tracks(self, limit=50): """Returns this user's loved track as a sequence of LovedTrack objects in reverse order of their timestamp, all the way back to the first track. If limit==None, it will try to pull all the available data. This method uses caching. Enable caching only if you're pulling a large amount of data. Use extract_items() with the return of this function to get only a sequence of Track objects with no playback dates. """ params = self._get_params() if limit: params['limit'] = _unicode(limit) seq = [] for track in _collect_nodes(limit, self, "user.getLovedTracks", True, params): title = _extract(track, "name") artist = _extract(track, "name", 1) date = _extract(track, "date") timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") seq.append(LovedTrack(Track(artist, title, self.network), date, timestamp)) return seq def get_neighbours(self, limit = 50): """Returns a list of the user's friends.""" params = self._get_params() if limit: params['limit'] = _unicode(limit) doc = self._request('user.getNeighbours', True, params) seq = [] names = _extract_all(doc, 'name') for name in names: seq.append(User(name, self.network)) return seq def get_past_events(self, limit=50): """ Returns a sequence of Event objects if limit==None it will return all """ seq = [] for n in _collect_nodes(limit, self, "user.getPastEvents", False): seq.append(Event(_extract(n, "id"), self.network)) return seq def get_playlists(self): """Returns a list of Playlists that this user owns.""" doc = self._request("user.getPlaylists", True) playlists = [] for playlist_id in _extract_all(doc, "id"): playlists.append(Playlist(self.get_name(), playlist_id, self.network)) return playlists def get_now_playing(self): """Returns the currently playing track, or None if nothing is playing. """ params = self._get_params() params['limit'] = '1' doc = self._request('user.getRecentTracks', False, params) e = doc.getElementsByTagName('track')[0] if not e.hasAttribute('nowplaying'): return None artist = _extract(e, 'artist') title = _extract(e, 'name') return Track(artist, title, self.network) def get_recent_tracks(self, limit = 10): """Returns this user's played track as a sequence of PlayedTrack objects in reverse order of their playtime, all the way back to the first track. If limit==None, it will try to pull all the available data. This method uses caching. Enable caching only if you're pulling a large amount of data. Use extract_items() with the return of this function to get only a sequence of Track objects with no playback dates. """ params = self._get_params() if limit: params['limit'] = _unicode(limit) seq = [] for track in _collect_nodes(limit, self, "user.getRecentTracks", True, params): if track.hasAttribute('nowplaying'): continue #to prevent the now playing track from sneaking in here title = _extract(track, "name") artist = _extract(track, "artist") date = _extract(track, "date") timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) return seq def get_id(self): """Returns the user id.""" doc = self._request("user.getInfo", True) return _extract(doc, "id") def get_language(self): """Returns the language code of the language used by the user.""" doc = self._request("user.getInfo", True) return _extract(doc, "lang") def get_country(self): """Returns the name of the country of the user.""" doc = self._request("user.getInfo", True) return Country(_extract(doc, "country"), self.network) def get_age(self): """Returns the user's age.""" doc = self._request("user.getInfo", True) return _number(_extract(doc, "age")) def get_gender(self): """Returns the user's gender. Either USER_MALE or USER_FEMALE.""" doc = self._request("user.getInfo", True) value = _extract(doc, "gender") if value == 'm': return USER_MALE elif value == 'f': return USER_FEMALE return None def is_subscriber(self): """Returns whether the user is a subscriber or not. True or False.""" doc = self._request("user.getInfo", True) return _extract(doc, "subscriber") == "1" def get_playcount(self): """Returns the user's playcount so far.""" doc = self._request("user.getInfo", True) return _number(_extract(doc, "playcount")) def get_top_albums(self, period = PERIOD_OVERALL): """Returns the top albums played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS """ params = self._get_params() params['period'] = period doc = self._request('user.getTopAlbums', True, params) seq = [] for album in doc.getElementsByTagName('album'): name = _extract(album, 'name') artist = _extract(album, 'name', 1) playcount = _extract(album, "playcount") seq.append(TopItem(Album(artist, name, self.network), playcount)) return seq def get_top_artists(self, period = PERIOD_OVERALL): """Returns the top artists played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS """ params = self._get_params() params['period'] = period doc = self._request('user.getTopArtists', True, params) seq = [] for node in doc.getElementsByTagName('artist'): name = _extract(node, 'name') playcount = _extract(node, "playcount") seq.append(TopItem(Artist(name, self.network), playcount)) return seq def get_top_tags(self, limit = None): """Returns a sequence of the top tags used by this user with their counts as (Tag, tagcount). * limit: The limit of how many tags to return. """ doc = self._request("user.getTopTags", True) seq = [] for node in doc.getElementsByTagName("tag"): if len(seq) < limit: seq.append(TopItem(Tag(_extract(node, "name"), self.network), _extract(node, "count"))) return seq def get_top_tracks(self, period = PERIOD_OVERALL): """Returns the top tracks played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS """ params = self._get_params() params['period'] = period doc = self._request('user.getTopTracks', True, params) seq = [] for track in doc.getElementsByTagName('track'): name = _extract(track, 'name') artist = _extract(track, 'name', 1) playcount = _extract(track, "playcount") seq.append(TopItem(Track(artist, name, self.network), playcount)) return seq def get_weekly_chart_dates(self): """Returns a list of From and To tuples for the available charts.""" doc = self._request("user.getWeeklyChartList", True) seq = [] for node in doc.getElementsByTagName("chart"): seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) return seq def get_weekly_artist_charts(self, from_date = None, to_date = None): """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date doc = self._request("user.getWeeklyArtistChart", True, params) seq = [] for node in doc.getElementsByTagName("artist"): item = Artist(_extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) return seq def get_weekly_album_charts(self, from_date = None, to_date = None): """Returns the weekly album charts for the week starting from the from_date value to the to_date value.""" params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date doc = self._request("user.getWeeklyAlbumChart", True, params) seq = [] for node in doc.getElementsByTagName("album"): item = Album(_extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) return seq def get_weekly_track_charts(self, from_date = None, to_date = None): """Returns the weekly track charts for the week starting from the from_date value to the to_date value.""" params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date doc = self._request("user.getWeeklyTrackChart", True, params) seq = [] for node in doc.getElementsByTagName("track"): item = Track(_extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) return seq def compare_with_user(self, user, shared_artists_limit = None): """Compare this user with another Last.fm user. Returns a sequence (tasteometer_score, (shared_artist1, shared_artist2, ...)) user: A User object or a username string/unicode object. """ if isinstance(user, User): user = user.get_name() params = self._get_params() if shared_artists_limit: params['limit'] = _unicode(shared_artists_limit) params['type1'] = 'user' params['type2'] = 'user' params['value1'] = self.get_name() params['value2'] = user doc = self._request('tasteometer.compare', False, params) score = _extract(doc, 'score') artists = doc.getElementsByTagName('artists')[0] shared_artists_names = _extract_all(artists, 'name') shared_artists_seq = [] for name in shared_artists_names: shared_artists_seq.append(Artist(name, self.network)) return (score, shared_artists_seq) def get_image(self): """Returns the user's avatar.""" doc = self._request("user.getInfo", True) return _extract(doc, "image") def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the user page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) return self.network._get_url(domain_name, "user") %{'name': name} def get_library(self): """Returns the associated Library object. """ return Library(self, self.network) def get_shouts(self, limit=50): """ Returns a sequqence of Shout objects """ shouts = [] for node in _collect_nodes(limit, self, "user.getShouts", False): shouts.append(Shout( _extract(node, "body"), User(_extract(node, "author"), self.network), _extract(node, "date") ) ) return shouts def shout(self, message): """ Post a shout """ params = self._get_params() params["message"] = message self._request("user.Shout", False, params) class AuthenticatedUser(User): def __init__(self, network): User.__init__(self, "", network); def _get_params(self): return {"user": self.get_name()} def get_name(self): """Returns the name of the authenticated user.""" doc = self._request("user.getInfo", True, {"user": ""}) # hack self.name = _extract(doc, "name") return self.name def get_recommended_events(self, limit=50): """ Returns a sequence of Event objects if limit==None it will return all """ seq = [] for node in _collect_nodes(limit, self, "user.getRecommendedEvents", False): seq.append(Event(_extract(node, "id"), self.network)) return seq def get_recommended_artists(self, limit=50): """ Returns a sequence of Event objects if limit==None it will return all """ seq = [] for node in _collect_nodes(limit, self, "user.getRecommendedArtists", False): seq.append(Artist(_extract(node, "name"), self.network)) return seq class _Search(_BaseObject): """An abstract class. Use one of its derivatives.""" def __init__(self, ws_prefix, search_terms, network): _BaseObject.__init__(self, network) self._ws_prefix = ws_prefix self.search_terms = search_terms self._last_page_index = 0 def _get_params(self): params = {} for key in self.search_terms.keys(): params[key] = self.search_terms[key] return params def get_total_result_count(self): """Returns the total count of all the results.""" doc = self._request(self._ws_prefix + ".search", True) return _extract(doc, "opensearch:totalResults") def _retreive_page(self, page_index): """Returns the node of matches to be processed""" params = self._get_params() params["page"] = str(page_index) doc = self._request(self._ws_prefix + ".search", True, params) return doc.getElementsByTagName(self._ws_prefix + "matches")[0] def _retrieve_next_page(self): self._last_page_index += 1 return self._retreive_page(self._last_page_index) class AlbumSearch(_Search): """Search for an album by name.""" def __init__(self, album_name, network): _Search.__init__(self, "album", {"album": album_name}, network) def get_next_page(self): """Returns the next page of results as a sequence of Album objects.""" master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("album"): seq.append(Album(_extract(node, "artist"), _extract(node, "name"), self.network)) return seq class ArtistSearch(_Search): """Search for an artist by artist name.""" def __init__(self, artist_name, network): _Search.__init__(self, "artist", {"artist": artist_name}, network) def get_next_page(self): """Returns the next page of results as a sequence of Artist objects.""" master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("artist"): seq.append(Artist(_extract(node, "name"), self.network)) return seq class TagSearch(_Search): """Search for a tag by tag name.""" def __init__(self, tag_name, network): _Search.__init__(self, "tag", {"tag": tag_name}, network) def get_next_page(self): """Returns the next page of results as a sequence of Tag objects.""" master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("tag"): seq.append(Tag(_extract(node, "name"), self.network)) return seq class TrackSearch(_Search): """Search for a track by track title. If you don't wanna narrow the results down by specifying the artist name, set it to empty string.""" def __init__(self, artist_name, track_title, network): _Search.__init__(self, "track", {"track": track_title, "artist": artist_name}, network) def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("track"): seq.append(Track(_extract(node, "artist"), _extract(node, "name"), self.network)) return seq class VenueSearch(_Search): """Search for a venue by its name. If you don't wanna narrow the results down by specifying a country, set it to empty string.""" def __init__(self, venue_name, country_name, network): _Search.__init__(self, "venue", {"venue": venue_name, "country": country_name}, network) def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("venue"): seq.append(Venue(_extract(node, "id"), self.network)) return seq class Venue(_BaseObject): """A venue where events are held.""" # TODO: waiting for a venue.getInfo web service to use. id = None def __init__(self, id, network): _BaseObject.__init__(self, network) self.id = _number(id) @_string_output def __repr__(self): return "Venue #" + str(self.id) def __eq__(self, other): return self.get_id() == other.get_id() def _get_params(self): return {"venue": self.get_id()} def get_id(self): """Returns the id of the venue.""" return self.id def get_upcoming_events(self): """Returns the upcoming events in this venue.""" doc = self._request("venue.getEvents", True) seq = [] for node in doc.getElementsByTagName("event"): seq.append(Event(_extract(node, "id"), self.network)) return seq def get_past_events(self): """Returns the past events held in this venue.""" doc = self._request("venue.getEvents", True) seq = [] for node in doc.getElementsByTagName("event"): seq.append(Event(_extract(node, "id"), self.network)) return seq def md5(text): """Returns the md5 hash of a string.""" h = hashlib.md5() h.update(_string(text)) return h.hexdigest() def async_call(sender, call, callback = None, call_args = None, callback_args = None): """This is the function for setting up an asynchronous operation. * call: The function to call asynchronously. * callback: The function to call after the operation is complete, Its prototype has to be like: callback(sender, output[, param1, param3, ... ]) * call_args: A sequence of args to be passed to call. * callback_args: A sequence of args to be passed to callback. """ thread = _ThreadedCall(sender, call, call_args, callback, callback_args) thread.start() def _unicode(text): if type(text) == unicode: return text if type(text) == int: return unicode(text) return unicode(text, "utf-8") def _string(text): if type(text) == str: return text if type(text) == int: return str(text) return text.encode("utf-8") def _collect_nodes(limit, sender, method_name, cacheable, params=None): """ Returns a sequqnce of dom.Node objects about as close to limit as possible """ if not limit: limit = sys.maxint if not params: params = sender._get_params() nodes = [] page = 1 end_of_pages = False while len(nodes) < limit and not end_of_pages: params["page"] = str(page) doc = sender._request(method_name, cacheable, params) main = doc.documentElement.childNodes[1] if main.hasAttribute("totalPages"): total_pages = _number(main.getAttribute("totalPages")) elif main.hasAttribute("totalpages"): total_pages = _number(main.getAttribute("totalpages")) else: raise Exception("No total pages attribute") for node in main.childNodes: if not node.nodeType == xml.dom.Node.TEXT_NODE and len(nodes) < limit: nodes.append(node) if page >= total_pages: end_of_pages = True page += 1 return nodes def _extract(node, name, index = 0): """Extracts a value from the xml string""" nodes = node.getElementsByTagName(name) if len(nodes): if nodes[index].firstChild: return _unescape_htmlentity(nodes[index].firstChild.data.strip()) else: return None def _extract_all(node, name, limit_count = None): """Extracts all the values from the xml string. returning a list.""" seq = [] for i in range(0, len(node.getElementsByTagName(name))): if len(seq) == limit_count: break seq.append(_extract(node, name, i)) return seq def _url_safe(text): """Does all kinds of tricks on a text to make it safe to use in a url.""" if type(text) == unicode: text = text.encode('utf-8') return urllib.quote_plus(urllib.quote_plus(text)).lower() def _number(string): """ Extracts an int from a string. Returns a 0 if None or an empty string was passed """ if not string: return 0 elif string == "": return 0 else: try: return int(string) except ValueError: return float(string) def _unescape_htmlentity(string): string = _unicode(string) mapping = htmlentitydefs.name2codepoint for key in mapping: string = string.replace("&%s;" %key, unichr(mapping[key])) return string def extract_items(topitems_or_libraryitems): """Extracts a sequence of items from a sequence of TopItem or LibraryItem objects.""" seq = [] for i in topitems_or_libraryitems: seq.append(i.item) return seq class ScrobblingError(Exception): def __init__(self, message): Exception.__init__(self) self.message = message @_string_output def __str__(self): return self.message class BannedClientError(ScrobblingError): def __init__(self): ScrobblingError.__init__(self, "This version of the client has been banned") class BadAuthenticationError(ScrobblingError): def __init__(self): ScrobblingError.__init__(self, "Bad authentication token") class BadTimeError(ScrobblingError): def __init__(self): ScrobblingError.__init__(self, "Time provided is not close enough to current time") class BadSessionError(ScrobblingError): def __init__(self): ScrobblingError.__init__(self, "Bad session id, consider re-handshaking") class _ScrobblerRequest(object): def __init__(self, url, params, network, type="POST"): self.params = params self.type = type (self.hostname, self.subdir) = urllib.splithost(url[len("http:"):]) self.network = network def execute(self): """Returns a string response of this request.""" connection = httplib.HTTPConnection(self.hostname) data = [] for name in self.params.keys(): value = urllib.quote_plus(self.params[name]) data.append('='.join((name, value))) data = "&".join(data) headers = { "Content-type": "application/x-www-form-urlencoded", "Accept-Charset": "utf-8", "User-Agent": "pylast" + "/" + __version__, "HOST": self.hostname } if self.type == "GET": connection.request("GET", self.subdir + "?" + data, headers = headers) else: connection.request("POST", self.subdir, data, headers) response = connection.getresponse().read() self._check_response_for_errors(response) return response def _check_response_for_errors(self, response): """When passed a string response it checks for erros, raising any exceptions as necessary.""" lines = response.split("\n") status_line = lines[0] if status_line == "OK": return elif status_line == "BANNED": raise BannedClientError() elif status_line == "BADAUTH": raise BadAuthenticationError() elif status_line == "BadTimeError": raise BadTimeError() elif status_line == "BadSessionError": raise BadSessionError() elif status_line.startswith("FAILED "): reason = status_line[status_line.find("FAILED ")+len("FAILED "):] raise ScrobblingError(reason) class Scrobbler(object): """A class for scrobbling tracks to Last.fm""" session_id = None nowplaying_url = None submissions_url = None def __init__(self, network, client_id, client_version): self.client_id = client_id self.client_version = client_version self.username = network.username self.password = network.password_hash self.network = network def _do_handshake(self): """Handshakes with the server""" timestamp = str(int(time.time())) if self.password and self.username: token = md5(self.password + timestamp) elif self.network.api_key and self.network.api_secret and self.network.session_key: if not self.username: self.username = self.network.get_authenticated_user().get_name() token = md5(self.network.api_secret + timestamp) params = {"hs": "true", "p": "1.2.1", "c": self.client_id, "v": self.client_version, "u": self.username, "t": timestamp, "a": token} if self.network.session_key and self.network.api_key: params["sk"] = self.network.session_key params["api_key"] = self.network.api_key server = self.network.submission_server response = _ScrobblerRequest(server, params, self.network, "GET").execute().split("\n") self.session_id = response[1] self.nowplaying_url = response[2] self.submissions_url = response[3] def _get_session_id(self, new = False): """Returns a handshake. If new is true, then it will be requested from the server even if one was cached.""" if not self.session_id or new: self._do_handshake() return self.session_id def report_now_playing(self, artist, title, album = "", duration = "", track_number = "", mbid = ""): params = {"s": self._get_session_id(), "a": artist, "t": title, "b": album, "l": duration, "n": track_number, "m": mbid} try: _ScrobblerRequest(self.nowplaying_url, params, self.network).execute() except BadSessionError: self._do_handshake() self.report_now_playing(artist, title, album, duration, track_number, mbid) def scrobble(self, artist, title, time_started, source, mode, duration, album="", track_number="", mbid=""): """Scrobble a track. parameters: artist: Artist name. title: Track title. time_started: UTC timestamp of when the track started playing. source: The source of the track SCROBBLE_SOURCE_USER: Chosen by the user (the most common value, unless you have a reason for choosing otherwise, use this). SCROBBLE_SOURCE_NON_PERSONALIZED_BROADCAST: Non-personalised broadcast (e.g. Shoutcast, BBC Radio 1). SCROBBLE_SOURCE_PERSONALIZED_BROADCAST: Personalised recommendation except Last.fm (e.g. Pandora, Launchcast). SCROBBLE_SOURCE_LASTFM: ast.fm (any mode). In this case, the 5-digit recommendation_key value must be set. SCROBBLE_SOURCE_UNKNOWN: Source unknown. mode: The submission mode SCROBBLE_MODE_PLAYED: The track was played. SCROBBLE_MODE_LOVED: The user manually loved the track (implies a listen) SCROBBLE_MODE_SKIPPED: The track was skipped (Only if source was Last.fm) SCROBBLE_MODE_BANNED: The track was banned (Only if source was Last.fm) duration: Track duration in seconds. album: The album name. track_number: The track number on the album. mbid: MusicBrainz ID. """ params = {"s": self._get_session_id(), "a[0]": _string(artist), "t[0]": _string(title), "i[0]": str(time_started), "o[0]": source, "r[0]": mode, "l[0]": str(duration), "b[0]": _string(album), "n[0]": track_number, "m[0]": mbid} _ScrobblerRequest(self.submissions_url, params, self.network).execute() def scrobble_many(self, tracks): """ Scrobble several tracks at once. tracks: A sequence of a sequence of parameters for each trach. The order of parameters is the same as if passed to the scrobble() method. """ remainder = [] if len(tracks) > 50: remainder = tracks[50:] tracks = tracks[:50] params = {"s": self._get_session_id()} i = 0 for t in tracks: _pad_list(t, 9, "") params["a[%s]" % str(i)] = _string(t[0]) params["t[%s]" % str(i)] = _string(t[1]) params["i[%s]" % str(i)] = str(t[2]) params["o[%s]" % str(i)] = t[3] params["r[%s]" % str(i)] = t[4] params["l[%s]" % str(i)] = str(t[5]) params["b[%s]" % str(i)] = _string(t[6]) params["n[%s]" % str(i)] = t[7] params["m[%s]" % str(i)] = t[8] i += 1 _ScrobblerRequest(self.submissions_url, params, self.network).execute() if remainder: self.scrobble_many(remainder) pithos_0.3.17/pithos/sound_menu.py000066400000000000000000000155531175056731700172530ustar00rootroot00000000000000# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- ### BEGIN LICENSE # Copyright (C) 2011 Rick Spencer # Copyright (C) 2011-2012 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE import dbus import dbus.service DESKTOP_NAME = 'pithos' class PithosSoundMenu(dbus.service.Object): def __init__(self, window): """ Creates a PithosSoundMenu object. Requires a dbus loop to be created before the gtk mainloop, typically by calling DBusGMainLoop(set_as_default=True). """ bus_str = """org.mpris.MediaPlayer2.%s""" % DESKTOP_NAME bus_name = dbus.service.BusName(bus_str, bus=dbus.SessionBus()) dbus.service.Object.__init__(self, bus_name, "/org/mpris/MediaPlayer2") self.window = window self.song_changed() self.window.connect("song-changed", self.songchange_handler) self.window.connect("play-state-changed", self.playstate_handler) def playstate_handler(self, window, state): if state: self.signal_playing() else: self.signal_paused() def songchange_handler(self, window, song): self.song_changed([song.artist], song.album, song.title, song.artRadio) self.signal_playing() def song_changed(self, artists = None, album = None, title = None, artUrl=''): """song_changed - sets the info for the current song. This method is not typically overriden. It should be called by implementations of this class when the player has changed songs. named arguments: artists - a list of strings representing the artists" album - a string for the name of the album title - a string for the title of the song """ if artists is None: artists = ["Artist Unknown"] if album is None: album = "Album Unknown" if title is None: title = "Title Unknown" if artUrl is None: artUrl = '' self.__meta_data = dbus.Dictionary({"xesam:album":album, "xesam:title":title, "xesam:artist":artists, "mpris:artUrl":artUrl, }, "sv", variant_level=1) @dbus.service.method('org.mpris.MediaPlayer2') def Raise(self): """Bring the media player to the front when selected by the sound menu""" self.window.bring_to_top() @dbus.service.method(dbus.PROPERTIES_IFACE, in_signature='ss', out_signature='v') def Get(self, interface, prop): """Get A function necessary to implement dbus properties. This function is only called by the Sound Menu, and should not be overriden or called directly. """ my_prop = self.__getattribute__(prop) return my_prop @dbus.service.method(dbus.PROPERTIES_IFACE, in_signature='ssv') def Set(self, interface, prop, value): """Set A function necessary to implement dbus properties. This function is only called by the Sound Menu, and should not be overriden or called directly. """ my_prop = self.__getattribute__(prop) my_prop = value @dbus.service.method(dbus.PROPERTIES_IFACE, in_signature='s', out_signature='a{sv}') def GetAll(self, interface): """GetAll A function necessary to implement dbus properties. This function is only called by the Sound Menu, and should not be overriden or called directly. """ return [DesktopEntry, PlaybackStatus, MetaData] @property def DesktopEntry(self): """DesktopEntry The name of the desktop file. This propert is only used by the Sound Menu, and should not be overriden or called directly. """ return DESKTOP_NAME @property def PlaybackStatus(self): """PlaybackStatus Current status "Playing", "Paused", or "Stopped" This property is only used by the Sound Menu, and should not be overriden or called directly. """ if not self.window.current_song: return "Stopped" if self.window.playing: return "Playing" else: return "Paused" @property def MetaData(self): """MetaData The info for the current song. This property is only used by the Sound Menu, and should not be overriden or called directly. """ return self.__meta_data @dbus.service.method('org.mpris.MediaPlayer2.Player') def Next(self): """Next This function is called when the user has clicked the next button in the Sound Indicator. """ self.window.next_song() @dbus.service.method('org.mpris.MediaPlayer2.Player') def Previous(self): """Previous This function is called when the user has clicked the previous button in the Sound Indicator. """ pass @dbus.service.method('org.mpris.MediaPlayer2.Player') def PlayPause(self): self.window.playpause() def signal_playing(self): """signal_playing - Tell the Sound Menu that the player has started playing. """ self.__playback_status = "Playing" d = dbus.Dictionary({"PlaybackStatus":self.__playback_status, "Metadata":self.__meta_data}, "sv",variant_level=1) self.PropertiesChanged("org.mpris.MediaPlayer2.Player",d,[]) def signal_paused(self): """signal_paused - Tell the Sound Menu that the player has been paused """ self.__playback_status = "Paused" d = dbus.Dictionary({"PlaybackStatus":self.__playback_status}, "sv",variant_level=1) self.PropertiesChanged("org.mpris.MediaPlayer2.Player",d,[]) @dbus.service.signal(dbus.PROPERTIES_IFACE, signature='sa{sv}as') def PropertiesChanged(self, interface_name, changed_properties, invalidated_properties): """PropertiesChanged A function necessary to implement dbus properties. Typically, this function is not overriden or called directly. """ pass pithos_0.3.17/setup.py000066400000000000000000000065101175056731700147220ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- ### BEGIN LICENSE # Copyright (C) 2010 Kevin Mehall #This program is free software: you can redistribute it and/or modify it #under the terms of the GNU General Public License version 3, as published #by the Free Software Foundation. # #This program is distributed in the hope that it will be useful, but #WITHOUT ANY WARRANTY; without even the implied warranties of #MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR #PURPOSE. See the GNU General Public License for more details. # #You should have received a copy of the GNU General Public License along #with this program. If not, see . ### END LICENSE ###################### DO NOT TOUCH THIS (HEAD TO THE SECOND PART) ###################### try: import DistUtilsExtra.auto except ImportError: import sys print >> sys.stderr, 'To build pithos you need https://launchpad.net/python-distutils-extra' sys.exit(1) assert DistUtilsExtra.auto.__version__ >= '2.10', 'needs DistUtilsExtra.auto >= 2.10' import os def update_data_path(prefix, oldvalue=None): try: fin = file('pithos/pithosconfig.py', 'r') fout = file(fin.name + '.new', 'w') for line in fin: fields = line.split(' = ') # Separate variable from value if fields[0] == '__pithos_data_directory__': # update to prefix, store oldvalue if not oldvalue: oldvalue = fields[1] line = "%s = '%s'\n" % (fields[0], prefix) else: # restore oldvalue line = "%s = %s" % (fields[0], oldvalue) fout.write(line) fout.flush() fout.close() fin.close() os.rename(fout.name, fin.name) except (OSError, IOError), e: print ("ERROR: Can't find pithos/pithosconfig.py") sys.exit(1) return oldvalue class InstallAndUpdateDataDirectory(DistUtilsExtra.auto.install_auto): def run(self): if self.root or self.home: print "WARNING: You don't use a standard --prefix installation, take care that you eventually " \ "need to update quickly/quicklyconfig.py file to adjust __quickly_data_directory__. You can " \ "ignore this warning if you are packaging and uses --prefix." previous_value = update_data_path(self.prefix + '/share/pithos/') DistUtilsExtra.auto.install_auto.run(self) update_data_path(self.prefix, previous_value) from distutils.cmd import Command class OverrideI18NCommand(Command): def initialize_options(self): pass def finalize_options(self): pass def run(self): self.distribution.data_files.append(('share/applications', ['pithos.desktop'])) from DistUtilsExtra.command.build_extra import build_extra from DistUtilsExtra.command.build_icons import build_icons DistUtilsExtra.auto.setup( name='pithos', version='0.3', ext_modules=[], license='GPL-3', author='Kevin Mehall', author_email='km@kevinmehall.net', description='Pandora.com client for the GNOME desktop', #long_description='Here a longer description', url='https://launchpad.net/pithos', cmdclass={'install': InstallAndUpdateDataDirectory, 'build_icons':build_icons, 'build':build_extra, 'build_i18n':OverrideI18NCommand} )