PKvwFH<1ccmopidy_pandora/__init__.pyfrom __future__ import absolute_import, division, print_function, unicode_literals import os from mopidy import config, ext __version__ = '0.2.1' class Extension(ext.Extension): dist_name = 'Mopidy-Pandora' ext_name = 'pandora' version = __version__ def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(conf_file) def get_config_schema(self): from pandora import BaseAPIClient schema = super(Extension, self).get_config_schema() schema['api_host'] = config.String() schema['partner_encryption_key'] = config.String() schema['partner_decryption_key'] = config.String() schema['partner_username'] = config.String() schema['partner_password'] = config.String() schema['partner_device'] = config.String() schema['username'] = config.String() schema['password'] = config.Secret() schema['preferred_audio_quality'] = config.String(choices=[BaseAPIClient.LOW_AUDIO_QUALITY, BaseAPIClient.MED_AUDIO_QUALITY, BaseAPIClient.HIGH_AUDIO_QUALITY]) schema['sort_order'] = config.String(choices=['date', 'A-Z', 'a-z']) schema['auto_setup'] = config.Boolean() schema['auto_set_repeat'] = config.Deprecated() schema['cache_time_to_live'] = config.Integer(minimum=0) schema['event_support_enabled'] = config.Boolean() schema['double_click_interval'] = config.String() schema['on_pause_resume_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep', 'add_artist_bookmark', 'add_song_bookmark', 'delete_station']) schema['on_pause_next_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep', 'add_artist_bookmark', 'add_song_bookmark', 'delete_station']) schema['on_pause_previous_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep', 'add_artist_bookmark', 'add_song_bookmark', 'delete_station']) schema['on_pause_resume_pause_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep', 'add_artist_bookmark', 'add_song_bookmark', 'delete_station']) return schema def setup(self, registry): from .backend import PandoraBackend from .frontend import EventMonitorFrontend, PandoraFrontend registry.add('backend', PandoraBackend) registry.add('frontend', PandoraFrontend) registry.add('frontend', EventMonitorFrontend) PKvFH?&wwmopidy_pandora/backend.pyfrom __future__ import absolute_import, division, print_function, unicode_literals import logging from mopidy import backend, core from pandora.errors import PandoraException import pykka from mopidy_pandora import listener, utils from mopidy_pandora.client import MopidyAPIClient, MopidySettingsDictBuilder from mopidy_pandora.library import PandoraLibraryProvider from mopidy_pandora.playback import PandoraPlaybackProvider from mopidy_pandora.uri import PandoraUri # noqa: I101 logger = logging.getLogger(__name__) class PandoraBackend(pykka.ThreadingActor, backend.Backend, core.CoreListener, listener.PandoraFrontendListener, listener.EventMonitorListener): def __init__(self, config, audio): super(PandoraBackend, self).__init__() self.config = config['pandora'] settings = { 'CACHE_TTL': self.config.get('cache_time_to_live'), 'API_HOST': self.config.get('api_host'), 'DECRYPTION_KEY': self.config['partner_decryption_key'], 'ENCRYPTION_KEY': self.config['partner_encryption_key'], 'PARTNER_USER': self.config['partner_username'], 'PARTNER_PASSWORD': self.config['partner_password'], 'DEVICE': self.config['partner_device'], 'PROXY': utils.format_proxy(config['proxy']), 'AUDIO_QUALITY': self.config.get('preferred_audio_quality') } self.api = MopidySettingsDictBuilder(settings, client_class=MopidyAPIClient).build() self.library = PandoraLibraryProvider(backend=self, sort_order=self.config.get('sort_order')) self.playback = PandoraPlaybackProvider(audio, self) self.uri_schemes = [PandoraUri.SCHEME] def on_start(self): self.api.login(self.config['username'], self.config['password']) def end_of_tracklist_reached(self, station_id=None, auto_play=False): self.prepare_next_track(station_id, auto_play) def prepare_next_track(self, station_id, auto_play=False): self._trigger_next_track_available(self.library.get_next_pandora_track(station_id), auto_play) def event_triggered(self, track_uri, pandora_event): self.process_event(track_uri, pandora_event) def process_event(self, track_uri, pandora_event): func = getattr(self, pandora_event) try: if pandora_event == 'delete_station': logger.info("Triggering event '{}' for Pandora station with ID: '{}'." .format(pandora_event, PandoraUri.factory(track_uri).station_id)) else: logger.info("Triggering event '{}' for Pandora song: '{}'." .format(pandora_event, self.library.lookup_pandora_track(track_uri).song_name)) func(track_uri) self._trigger_event_processed(track_uri, pandora_event) return True except PandoraException: logger.exception('Error calling Pandora event: {}.'.format(pandora_event)) return False def thumbs_up(self, track_uri): return self.api.add_feedback(PandoraUri.factory(track_uri).token, True) def thumbs_down(self, track_uri): return self.api.add_feedback(PandoraUri.factory(track_uri).token, False) def sleep(self, track_uri): return self.api.sleep_song(PandoraUri.factory(track_uri).token) def add_artist_bookmark(self, track_uri): return self.api.add_artist_bookmark(PandoraUri.factory(track_uri).token) def add_song_bookmark(self, track_uri): return self.api.add_song_bookmark(PandoraUri.factory(track_uri).token) def delete_station(self, track_uri): r = self.api.delete_station(PandoraUri.factory(track_uri).station_id) self.library.refresh() self.library.browse(self.library.root_directory.uri) return r def _trigger_next_track_available(self, track, auto_play=False): listener.PandoraBackendListener.send('next_track_available', track=track, auto_play=auto_play) def _trigger_event_processed(self, track_uri, pandora_event): listener.PandoraBackendListener.send('event_processed', track_uri=track_uri, pandora_event=pandora_event) PK:H&K K mopidy_pandora/client.pyfrom __future__ import absolute_import, division, print_function, unicode_literals import logging import time from cachetools import TTLCache import pandora from pandora.clientbuilder import APITransport, DEFAULT_API_HOST, Encryptor, SettingsDictBuilder import requests logger = logging.getLogger(__name__) class MopidySettingsDictBuilder(SettingsDictBuilder): def build_from_settings_dict(self, settings): enc = Encryptor(settings['DECRYPTION_KEY'], settings['ENCRYPTION_KEY']) trans = APITransport(enc, settings.get('API_HOST', DEFAULT_API_HOST), settings.get('PROXY', None)) quality = settings.get('AUDIO_QUALITY', self.client_class.MED_AUDIO_QUALITY) return self.client_class(settings['CACHE_TTL'], trans, settings['PARTNER_USER'], settings['PARTNER_PASSWORD'], settings['DEVICE'], quality) class MopidyAPIClient(pandora.APIClient): """Pydora API Client for Mopidy-Pandora This API client implements caching of the station list. """ def __init__(self, cache_ttl, transport, partner_user, partner_password, device, default_audio_quality=pandora.BaseAPIClient.MED_AUDIO_QUALITY): super(MopidyAPIClient, self).__init__(transport, partner_user, partner_password, device, default_audio_quality) self.station_list_cache = TTLCache(1, cache_ttl) self.genre_stations_cache = TTLCache(1, cache_ttl) def get_station_list(self, force_refresh=False): station_list = [] try: if (self.station_list_cache.currsize == 0 or (force_refresh and self.station_list_cache.values()[0].has_changed())): station_list = super(MopidyAPIClient, self).get_station_list() self.station_list_cache[time.time()] = station_list except requests.exceptions.RequestException: logger.exception('Error retrieving Pandora station list.') station_list = [] try: return self.station_list_cache.values()[0] except IndexError: # Cache disabled return station_list def get_station(self, station_token): try: return self.get_station_list()[station_token] except TypeError: # Could not find station_token in cached list, try retrieving from Pandora server. return super(MopidyAPIClient, self).get_station(station_token) def get_genre_stations(self, force_refresh=False): genre_stations = [] try: if (self.genre_stations_cache.currsize == 0 or (force_refresh and self.genre_stations_cache.values()[0].has_changed())): genre_stations = super(MopidyAPIClient, self).get_genre_stations() self.genre_stations_cache[time.time()] = genre_stations except requests.exceptions.RequestException: logger.exception('Error retrieving Pandora genre stations.') return genre_stations try: return self.genre_stations_cache.values()[0] except IndexError: # Cache disabled return genre_stations PK:HKmopidy_pandora/ext.conf[pandora] enabled = true api_host = tuner.pandora.com/services/json/ partner_encryption_key = partner_decryption_key = partner_username = iphone partner_password = partner_device = IP01 username = password = preferred_audio_quality = highQuality sort_order = a-z auto_setup = true cache_time_to_live = 86400 event_support_enabled = false double_click_interval = 2.50 on_pause_resume_click = thumbs_up on_pause_next_click = thumbs_down on_pause_previous_click = sleep on_pause_resume_pause_click = delete_station PKvFHC*L*Lmopidy_pandora/frontend.pyfrom __future__ import absolute_import, division, print_function, unicode_literals import Queue import logging import threading import time from collections import namedtuple from difflib import SequenceMatcher from functools import total_ordering from mopidy import audio, core from mopidy.audio import PlaybackState import pykka from mopidy_pandora import listener from mopidy_pandora.uri import AdItemUri, PandoraUri from mopidy_pandora.utils import run_async logger = logging.getLogger(__name__) def only_execute_for_pandora_uris(func): """ Function decorator intended to ensure that "func" is only executed if a Pandora track is currently playing. Allows CoreListener events to be ignored if they are being raised while playing non-Pandora tracks. :param func: the function to be executed :return: the return value of the function if it was run, or 'None' otherwise. """ from functools import wraps @wraps(func) def check_pandora(self, *args, **kwargs): """ Check if a pandora track is currently being played. :param args: all arguments will be passed to the target function. :param kwargs: all kwargs will be passed to the target function. :return: the return value of the function if it was run or 'None' otherwise. """ uri = get_active_uri(self.core, *args, **kwargs) if uri and PandoraUri.is_pandora_uri(uri): return func(self, *args, **kwargs) return check_pandora def get_active_uri(core, *args, **kwargs): """ Tries to determine what the currently 'active' Mopidy track is, and returns it's URI. Makes use of a best-effort determination base on: 1. looking for 'track' in kwargs, then 2. 'tl_track' in kwargs, then 3. interrogating the Mopidy core for the currently playing track, and lastly 4. checking which track was played last according to the history that Mopidy keeps. :param core: the Mopidy core that can be used as a fallback if no suitable arguments are available. :param args: all available arguments from the calling function. :param kwargs: all available kwargs from the calling function. :return: the URI of the active Mopidy track, if it could be determined, or None otherwise. """ uri = None track = kwargs.get('track', None) if track: uri = track.uri else: tl_track = kwargs.get('tl_track', core.playback.get_current_tl_track().get()) if tl_track: uri = tl_track.track.uri if not uri: history = core.history.get_history().get() if history: uri = history[0] return uri class PandoraFrontend(pykka.ThreadingActor, core.CoreListener, listener.PandoraBackendListener, listener.PandoraPlaybackListener, listener.EventMonitorListener): def __init__(self, config, core): super(PandoraFrontend, self).__init__() self.config = config['pandora'] self.auto_setup = self.config.get('auto_setup') self.setup_required = True self.core = core self.track_change_completed_event = threading.Event() self.track_change_completed_event.set() def set_options(self): # Setup playback to mirror behaviour of official Pandora front-ends. if self.auto_setup and self.setup_required: if self.core.tracklist.get_consume().get() is False: self.core.tracklist.set_consume(True) return if self.core.tracklist.get_repeat().get() is True: self.core.tracklist.set_repeat(False) return if self.core.tracklist.get_random().get() is True: self.core.tracklist.set_random(False) return if self.core.tracklist.get_single().get() is True: self.core.tracklist.set_single(False) return self.setup_required = False @only_execute_for_pandora_uris def options_changed(self): self.setup_required = True self.set_options() @only_execute_for_pandora_uris def track_playback_started(self, tl_track): self.set_options() if not self.track_change_completed_event.is_set(): self.track_change_completed_event.set() self.update_tracklist(tl_track.track) @only_execute_for_pandora_uris def track_playback_ended(self, tl_track, time_position): self.set_options() @only_execute_for_pandora_uris def track_playback_paused(self, tl_track, time_position): self.set_options() if not self.track_change_completed_event.is_set(): self.track_change_completed_event.set() self.update_tracklist(tl_track.track) @only_execute_for_pandora_uris def track_playback_resumed(self, tl_track, time_position): self.set_options() def is_end_of_tracklist_reached(self, track=None): length = self.core.tracklist.get_length().get() if length <= 1: return True if track: tl_track = self.core.tracklist.filter({'uri': [track.uri]}).get()[0] track_index = self.core.tracklist.index(tl_track).get() else: track_index = self.core.tracklist.index().get() return track_index == length - 1 def is_station_changed(self, track): try: previous_track_uri = PandoraUri.factory(self.core.history.get_history().get()[1][1].uri) if previous_track_uri.station_id != PandoraUri.factory(track.uri).station_id: return True except (IndexError, NotImplementedError): # No tracks in history, or last played track was not a Pandora track. Ignore pass return False def track_changing(self, track): self.track_change_completed_event.clear() def update_tracklist(self, track): if self.is_station_changed(track): # Station has changed, remove tracks from previous station from tracklist. self._trim_tracklist(keep_only=track) if self.is_end_of_tracklist_reached(track): self._trigger_end_of_tracklist_reached(PandoraUri.factory(track).station_id, auto_play=False) def track_unplayable(self, track): if self.is_end_of_tracklist_reached(track): self.core.playback.stop() self._trigger_end_of_tracklist_reached(PandoraUri.factory(track).station_id, auto_play=True) self.core.tracklist.remove({'uri': [track.uri]}) def next_track_available(self, track, auto_play=False): if track: self.add_track(track, auto_play) else: logger.warning('No more Pandora tracks available to play.') self.core.playback.stop() def skip_limit_exceeded(self): self.core.playback.stop() def add_track(self, track, auto_play=False): # Add the next Pandora track self.core.tracklist.add(uris=[track.uri]) if auto_play: tl_tracks = self.core.tracklist.get_tl_tracks().get() self.core.playback.play(tlid=tl_tracks[-1].tlid) self._trim_tracklist(maxsize=2) def _trim_tracklist(self, keep_only=None, maxsize=2): tl_tracks = self.core.tracklist.get_tl_tracks().get() if keep_only: trim_tlids = [t.tlid for t in tl_tracks if t.track.uri != keep_only.uri] if len(trim_tlids) > 0: return self.core.tracklist.remove({'tlid': trim_tlids}) else: return 0 elif len(tl_tracks) > maxsize: # Only need two tracks in the tracklist at any given time, remove the oldest tracks return self.core.tracklist.remove( {'tlid': [tl_tracks[t].tlid for t in range(0, len(tl_tracks)-maxsize)]} ) def _trigger_end_of_tracklist_reached(self, station_id, auto_play=False): listener.PandoraFrontendListener.send('end_of_tracklist_reached', station_id=station_id, auto_play=auto_play) @total_ordering class MatchResult(object): def __init__(self, marker, ratio): super(MatchResult, self).__init__() self.marker = marker self.ratio = ratio def __eq__(self, other): return self.ratio == other.ratio def __lt__(self, other): return self.ratio < other.ratio EventMarker = namedtuple('EventMarker', 'event, uri, time') class EventMonitorFrontend(pykka.ThreadingActor, core.CoreListener, audio.AudioListener, listener.PandoraFrontendListener, listener.PandoraBackendListener, listener.PandoraPlaybackListener, listener.EventMonitorListener): def __init__(self, config, core): super(EventMonitorFrontend, self).__init__() self.core = core self.event_sequences = [] self.sequence_match_results = None self._track_changed_marker = None self._monitor_lock = threading.Lock() self.config = config['pandora'] self.is_active = self.config['event_support_enabled'] def on_start(self): if not self.is_active: return interval = float(self.config['double_click_interval']) self.sequence_match_results = Queue.PriorityQueue(maxsize=4) self.event_sequences.append(EventSequence(self.config['on_pause_resume_click'], ['track_playback_paused', 'track_playback_resumed'], self.sequence_match_results, interval=interval)) self.event_sequences.append(EventSequence(self.config['on_pause_resume_pause_click'], ['track_playback_paused', 'track_playback_resumed', 'track_playback_paused'], self.sequence_match_results, interval=interval)) self.event_sequences.append(EventSequence(self.config['on_pause_previous_click'], ['track_playback_paused', 'track_playback_ended', 'track_playback_paused'], self.sequence_match_results, wait_for='track_changed_previous', interval=interval)) self.event_sequences.append(EventSequence(self.config['on_pause_next_click'], ['track_playback_paused', 'track_playback_ended', 'track_playback_paused'], self.sequence_match_results, wait_for='track_changed_next', interval=interval)) self.trigger_events = set(e.target_sequence[0] for e in self.event_sequences) @only_execute_for_pandora_uris def on_event(self, event, **kwargs): if not self.is_active: return super(EventMonitorFrontend, self).on_event(event, **kwargs) self._detect_track_change(event, **kwargs) if self._monitor_lock.acquire(False): if event in self.trigger_events: # Monitor not running and current event will not trigger any starts either, ignore self.notify_all(event, uri=get_active_uri(self.core, event, **kwargs), **kwargs) self.monitor_sequences() else: self._monitor_lock.release() return else: # Just pass on the event self.notify_all(event, **kwargs) def notify_all(self, event, **kwargs): for es in self.event_sequences: es.notify(event, **kwargs) def _detect_track_change(self, event, **kwargs): if not self._track_changed_marker and event == 'track_playback_ended': self._track_changed_marker = EventMarker(event, kwargs['tl_track'].track.uri, int(time.time() * 1000)) elif self._track_changed_marker and event in ['track_playback_paused', 'track_playback_started']: change_direction = self._get_track_change_direction(self._track_changed_marker) if change_direction: self._trigger_track_changed(change_direction, old_uri=self._track_changed_marker.uri, new_uri=kwargs['tl_track'].track.uri) self._track_changed_marker = None @run_async def monitor_sequences(self): for es in self.event_sequences: # Wait until all sequences have been processed es.wait() # Get the last item in the queue (will have highest ratio) match = None while not self.sequence_match_results.empty(): match = self.sequence_match_results.get() self.sequence_match_results.task_done() if match and match.ratio == 1.0: if match.marker.uri and type(PandoraUri.factory(match.marker.uri)) is AdItemUri: logger.info('Ignoring doubleclick event for Pandora advertisement...') else: self._trigger_event_triggered(match.marker.event, match.marker.uri) # Resume playback... if self.core.playback.get_state().get() != PlaybackState.PLAYING: self.core.playback.resume() self._monitor_lock.release() def event_processed(self, track_uri, pandora_event): if pandora_event == 'delete_station': self.core.tracklist.clear() def _get_track_change_direction(self, track_marker): history = self.core.history.get_history().get() for i, h in enumerate(history): # TODO: find a way to eliminate this timing disparity between when 'track_playback_ended' event for # one track is processed, and the next track is added to the history. if h[0] + 100 < track_marker.time: if h[1].uri == track_marker.uri: # This is the point in time in the history that the track was played. if history[i-1][1].uri == track_marker.uri: # Track was played again immediately. # User either clicked 'previous' in consume mode or clicked 'stop' -> 'play' for same track. # Both actions are interpreted as 'previous'. return 'track_changed_previous' else: # Switched to another track, user clicked 'next'. return 'track_changed_next' def _trigger_event_triggered(self, event, uri): (listener.EventMonitorListener.send('event_triggered', track_uri=uri, pandora_event=event)) def _trigger_track_changed(self, track_change_event, old_uri, new_uri): (listener.EventMonitorListener.send(track_change_event, old_uri=old_uri, new_uri=new_uri)) class EventSequence(object): pykka_traversable = True def __init__(self, on_match_event, target_sequence, result_queue, interval=1.0, strict=False, wait_for=None): self.on_match_event = on_match_event self.target_sequence = target_sequence self.result_queue = result_queue self.interval = interval self.strict = strict self.wait_for = wait_for self.wait_for_event = threading.Event() if not self.wait_for: self.wait_for_event.set() self.events_seen = [] self._timer = None self.target_uri = None self.monitoring_completed = threading.Event() self.monitoring_completed.set() @classmethod def match_sequence(cls, a, b): sm = SequenceMatcher(a=' '.join(a), b=' '.join(b)) return sm.ratio() def notify(self, event, **kwargs): if self.is_monitoring(): self.events_seen.append(event) if not self.wait_for_event.is_set() and self.wait_for == event: self.wait_for_event.set() elif self.target_sequence[0] == event: if kwargs.get('time_position', 0) == 0: # Don't do anything if track playback has not yet started. return else: self.start_monitor(kwargs.get('uri', None)) self.events_seen.append(event) def is_monitoring(self): return not self.monitoring_completed.is_set() def start_monitor(self, uri): self.monitoring_completed.clear() self.target_uri = uri self._timer = threading.Timer(self.interval, self.stop_monitor, args=(self.interval,)) self._timer.daemon = True self._timer.start() @run_async def stop_monitor(self, timeout): try: if self.strict: i = 0 try: for e in self.target_sequence: i = self.events_seen[i:].index(e) + 1 except ValueError: # Make sure that we have seen every event in the target sequence, and in the right order return elif not all([e in self.events_seen for e in self.target_sequence]): # Make sure that we have seen every event in the target sequence, ignoring order return if self.wait_for_event.wait(timeout=timeout): self.result_queue.put( MatchResult( EventMarker(self.on_match_event, self.target_uri, int(time.time() * 1000)), self.get_ratio() ) ) finally: self.reset() self.monitoring_completed.set() def reset(self): if self.wait_for: self.wait_for_event.clear() else: self.wait_for_event.set() self.events_seen = [] def get_ratio(self): if self.wait_for: # Add 'wait_for' event as well to make ratio more accurate. match_sequence = self.target_sequence + [self.wait_for] else: match_sequence = self.target_sequence if self.strict: ratio = EventSequence.match_sequence(self.events_seen, match_sequence) else: filtered_list = [e for e in self.events_seen if e in match_sequence] ratio = EventSequence.match_sequence(filtered_list, match_sequence) if ratio < 1.0 and self.strict: return 0 return ratio def wait(self, timeout=None): return self.monitoring_completed.wait(timeout=timeout) PKvFH,Lo"o"mopidy_pandora/library.pyfrom __future__ import absolute_import, division, print_function, unicode_literals import logging from collections import namedtuple from cachetools import LRUCache from mopidy import backend, models from pandora.models.pandora import Station from pydora.utils import iterate_forever from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, StationUri, TrackUri # noqa I101 logger = logging.getLogger(__name__) StationCacheItem = namedtuple('StationCacheItem', 'station, iter') TrackCacheItem = namedtuple('TrackCacheItem', 'ref, track') class PandoraLibraryProvider(backend.LibraryProvider): ROOT_DIR_NAME = 'Pandora' GENRE_DIR_NAME = 'Browse Genres' root_directory = models.Ref.directory(name=ROOT_DIR_NAME, uri=PandoraUri('directory').uri) genre_directory = models.Ref.directory(name=GENRE_DIR_NAME, uri=PandoraUri('genres').uri) def __init__(self, backend, sort_order): super(PandoraLibraryProvider, self).__init__(backend) self.sort_order = sort_order.lower() self.pandora_station_cache = LRUCache(maxsize=5, missing=self.get_station_cache_item) self.pandora_track_cache = LRUCache(maxsize=10) def browse(self, uri): self.backend.playback.reset_skip_limits() if uri == self.root_directory.uri: return self._browse_stations() if uri == self.genre_directory.uri: return self._browse_genre_categories() pandora_uri = PandoraUri.factory(uri) if type(pandora_uri) is GenreUri: return self._browse_genre_stations(uri) if type(pandora_uri) is StationUri or type(pandora_uri) is GenreStationUri: return self._browse_tracks(uri) def lookup(self, uri): pandora_uri = PandoraUri.factory(uri) if isinstance(pandora_uri, TrackUri): try: track = self.lookup_pandora_track(uri) except KeyError: logger.exception("Failed to lookup Pandora URI '{}'.".format(uri)) return [] else: track_kwargs = {'uri': uri} (album_kwargs, artist_kwargs) = {}, {} # TODO: Album.images has been deprecated in Mopidy 1.2. Remove this code when all frontends have been # updated to make use of the newer LibraryController.get_images() images = self.get_images([uri])[uri] if len(images) > 0: album_kwargs = {'images': [image.uri for image in images]} if type(pandora_uri) is AdItemUri: track_kwargs['name'] = 'Advertisement' if not track.title: track.title = '(Title not specified)' artist_kwargs['name'] = track.title if not track.company_name: track.company_name = '(Company name not specified)' album_kwargs['name'] = track.company_name album_kwargs['uri'] = track.click_through_url else: track_kwargs['name'] = track.song_name track_kwargs['length'] = track.track_length * 1000 track_kwargs['bitrate'] = int(track.bitrate) artist_kwargs['name'] = track.artist_name album_kwargs['name'] = track.album_name album_kwargs['uri'] = track.album_detail_url else: raise ValueError('Unexpected type to perform Pandora track lookup: {}.'.format(pandora_uri.uri_type)) track_kwargs['artists'] = [models.Artist(**artist_kwargs)] track_kwargs['album'] = models.Album(**album_kwargs) return [models.Track(**track_kwargs)] def get_images(self, uris): result = {} for uri in uris: image_uris = set() try: track = self.lookup_pandora_track(uri) if track.is_ad is True: image_uri = track.image_url else: image_uri = track.album_art_url if image_uri: image_uris.update([image_uri]) except (TypeError, KeyError): logger.exception("Failed to lookup image for Pandora URI '{}'.".format(uri)) pass result[uri] = [models.Image(uri=u) for u in image_uris] return result def _formatted_station_list(self, list): # Find QuickMix stations and move QuickMix to top for i, station in enumerate(list[:]): if station.is_quickmix: quickmix_stations = station.quickmix_stations if not station.name.endswith(' (marked with *)'): station.name += ' (marked with *)' list.insert(0, list.pop(i)) break # Mark QuickMix stations for station in list: if station.id in quickmix_stations: if not station.name.endswith('*'): station.name += '*' return list def _browse_stations(self): station_directories = [] stations = self.backend.api.get_station_list() if stations: if self.sort_order == 'a-z': stations.sort(key=lambda x: x.name, reverse=False) for station in self._formatted_station_list(stations): # As of version 5 of the Pandora API, station IDs and tokens are always equivalent. # We're using this assumption as we don't have the station token available for deleting the station. # Detect if any Pandora API changes ever breaks this assumption in the future. assert station.token == station.id station_directories.append( models.Ref.directory(name=station.name, uri=PandoraUri.factory(station).uri)) station_directories.insert(0, self.genre_directory) return station_directories def _browse_tracks(self, uri): pandora_uri = PandoraUri.factory(uri) return [self.get_next_pandora_track(pandora_uri.station_id)] def _create_station_for_genre(self, genre_token): json_result = self.backend.api.create_station(search_token=genre_token) new_station = Station.from_json(self.backend.api, json_result) self.refresh() return PandoraUri.factory(new_station) def _browse_genre_categories(self): return [models.Ref.directory(name=category, uri=GenreUri(category).uri) for category in sorted(self.backend.api.get_genre_stations().keys())] def _browse_genre_stations(self, uri): return [models.Ref.directory(name=station.name, uri=PandoraUri.factory(station).uri) for station in self.backend.api.get_genre_stations() [PandoraUri.factory(uri).category_name]] def lookup_pandora_track(self, uri): return self.pandora_track_cache[uri].track def get_station_cache_item(self, station_id): if GenreStationUri.pattern.match(station_id): pandora_uri = self._create_station_for_genre(station_id) station_id = pandora_uri.station_id station = self.backend.api.get_station(station_id) station_iter = iterate_forever(station.get_playlist) return StationCacheItem(station, station_iter) def get_next_pandora_track(self, station_id): try: station_iter = self.pandora_station_cache[station_id].iter track = next(station_iter) except Exception: logger.exception('Error retrieving next Pandora track.') return None track_uri = PandoraUri.factory(track) if type(track_uri) is AdItemUri: track_name = 'Advertisement' else: track_name = track.song_name ref = models.Ref.track(name=track_name, uri=track_uri.uri) self.pandora_track_cache[track_uri.uri] = TrackCacheItem(ref, track) return ref def refresh(self, uri=None): if not uri or uri == self.root_directory.uri: self.backend.api.get_station_list(force_refresh=True) elif uri == self.genre_directory.uri: self.backend.api.get_genre_stations(force_refresh=True) else: pandora_uri = PandoraUri.factory(uri) if type(pandora_uri) is StationUri: try: self.pandora_station_cache.pop(pandora_uri.station_id) except KeyError: # Item not in cache, ignore pass else: raise ValueError('Unexpected URI type to perform refresh of Pandora directory: {}.' .format(pandora_uri.uri_type)) PK:Hdomopidy_pandora/listener.pyfrom __future__ import absolute_import, division, print_function, unicode_literals from mopidy import backend, listener class EventMonitorListener(listener.Listener): """ Marker interface for recipients of events sent by the event monitor. """ @staticmethod def send(event, **kwargs): listener.send(EventMonitorListener, event, **kwargs) def event_triggered(self, track_uri, pandora_event): """ Called when one of the Pandora events have been triggered (e.g. thumbs_up, thumbs_down, sleep, etc.). :param track_uri: the URI of the track that the event should be applied to. :type track_uri: string :param pandora_event: the Pandora event that should be called. Needs to correspond with the name of one of the event handling methods defined in `:class:mopidy_pandora.backend.PandoraBackend` :type pandora_event: string """ pass def track_changed_previous(self, old_uri, new_uri): """ Called when a 'previous' track change has been completed. :param old_uri: the URI of the Pandora track that was changed from. :type old_uri: string :param new_uri: the URI of the Pandora track that was changed to. :type new_uri: string """ pass def track_changed_next(self, old_uri, new_uri): """ Called when a 'next' track change has been completed. Let's the frontend know that it should probably expand the tracklist by fetching and adding another track to the tracklist, and removing tracks that do not belong to the currently selected station. :param old_uri: the URI of the Pandora track that was changed from. :type old_uri: string :param new_uri: the URI of the Pandora track that was changed to. :type new_uri: string """ pass class PandoraFrontendListener(listener.Listener): """ Marker interface for recipients of events sent by the frontend actor. """ @staticmethod def send(event, **kwargs): listener.send(PandoraFrontendListener, event, **kwargs) def end_of_tracklist_reached(self, station_id, auto_play=False): """ Called whenever the tracklist contains only one track, or the last track in the tracklist is being played. :param station_id: the ID of the station that is currently being played in the tracklist :type station_id: string :param auto_play: specifies if the next track should be played as soon as it is added to the tracklist. :type auto_play: boolean """ pass class PandoraBackendListener(backend.BackendListener): """ Marker interface for recipients of events sent by the backend actor. """ @staticmethod def send(event, **kwargs): listener.send(PandoraBackendListener, event, **kwargs) def next_track_available(self, track, auto_play=False): """ Called when the backend has the next Pandora track available to be added to the tracklist. :param track: the Pandora track that was fetched :type track: :class:`mopidy.models.Ref` :param auto_play: specifies if the track should be played as soon as it is added to the tracklist. :type auto_play: boolean """ pass def event_processed(self, track_uri, pandora_event): """ Called when the backend has successfully processed the event for the given URI. :param track_uri: the URI of the track that the event was applied to. :type track_uri: string :param pandora_event: the Pandora event that was called. Needs to correspond with the name of one of the event handling methods defined in `:class:mopidy_pandora.backend.PandoraBackend` :type pandora_event: string """ pass class PandoraPlaybackListener(listener.Listener): """ Marker interface for recipients of events sent by the playback provider. """ @staticmethod def send(event, **kwargs): listener.send(PandoraPlaybackListener, event, **kwargs) def track_changing(self, track): """ Called when a track is being changed to. :param track: the Pandora track that is being changed to. :type track: :class:`mopidy.models.Ref` """ pass def track_unplayable(self, track): """ Called when the track is not playable. Let's the frontend know that it should probably remove this track from the tracklist and try to replace it with the next track that Pandora provides. :param track: the unplayable Pandora track. :type track: :class:`mopidy.models.Ref` """ pass def skip_limit_exceeded(self): """ Called when the playback provider has skipped over the maximum number of permissible unplayable tracks using :func:`~mopidy_pandora.pandora.PandoraPlaybackProvider.change_track`. This lets the frontend know that the player should probably be stopped in order to avoid an infinite loop on the tracklist, or to avoid exceeding the maximum number of station playlist requests as determined by the Pandora server. """ pass PK:HOJBmopidy_pandora/playback.pyfrom __future__ import absolute_import, division, print_function, unicode_literals import logging from mopidy import backend import requests from mopidy_pandora import listener logger = logging.getLogger(__name__) class PandoraPlaybackProvider(backend.PlaybackProvider): SKIP_LIMIT = 5 def __init__(self, audio, backend): super(PandoraPlaybackProvider, self).__init__(audio, backend) # TODO: It shouldn't be necessary to keep track of the number of tracks that have been skipped in the # player anymore once https://github.com/mopidy/mopidy/issues/1221 has been fixed. self._consecutive_track_skips = 0 # TODO: add gapless playback when it is supported in Mopidy > 1.1 # self.audio.set_about_to_finish_callback(self.callback) # def callback(self): # See: https://discuss.mopidy.com/t/has-the-gapless-playback-implementation-been-completed-yet/784/2 # self.audio.set_uri(self.translate_uri(self.get_next_track())) def change_pandora_track(self, track): """ Attempt to retrieve the Pandora playlist item from the buffer and verify that it is ready to be played. A track is playable if it has been stored in the buffer, has a URL, and the header for the Pandora URL can be retrieved and the status code checked. :param track: the track to retrieve and check the Pandora playlist item for. :return: True if the track is playable, False otherwise. """ try: pandora_track = self.backend.library.lookup_pandora_track(track.uri) if pandora_track.get_is_playable(): # Success, reset track skip counter. self._consecutive_track_skips = 0 else: raise Unplayable("Track with URI '{}' is not playable.".format(track.uri)) except (AttributeError, requests.exceptions.RequestException, Unplayable) as e: # Track is not playable. self._consecutive_track_skips += 1 self.check_skip_limit() self._trigger_track_unplayable(track) raise Unplayable('Error changing Pandora track: {}, ({})'.format(track, e)) def change_track(self, track): if track.uri is None: logger.warning("No URI for Pandora track '{}'. Track cannot be played.".format(track)) return False try: self._trigger_track_changing(track) self.check_skip_limit() self.change_pandora_track(track) return super(PandoraPlaybackProvider, self).change_track(track) except KeyError: logger.exception("Error changing Pandora track: failed to lookup '{}'.".format(track.uri)) return False except (MaxSkipLimitExceeded, Unplayable) as e: logger.warning(e) return False def check_skip_limit(self): if self._consecutive_track_skips >= self.SKIP_LIMIT: self._trigger_skip_limit_exceeded() raise MaxSkipLimitExceeded(('Maximum track skip limit ({:d}) exceeded.' .format(self.SKIP_LIMIT))) def reset_skip_limits(self): self._consecutive_track_skips = 0 def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url def _trigger_track_changing(self, track): listener.PandoraPlaybackListener.send('track_changing', track=track) def _trigger_track_unplayable(self, track): listener.PandoraPlaybackListener.send('track_unplayable', track=track) def _trigger_skip_limit_exceeded(self): listener.PandoraPlaybackListener.send('skip_limit_exceeded') class MaxSkipLimitExceeded(Exception): pass class Unplayable(Exception): pass PK:HtIImopidy_pandora/uri.pyfrom __future__ import absolute_import, division, print_function, unicode_literals import logging import re from mopidy import compat, models from pandora.models.pandora import AdItem, GenreStation, PlaylistItem, Station from requests.utils import quote, unquote logger = logging.getLogger(__name__) def with_metaclass(meta, *bases): return meta(str('NewBase'), bases, {}) class _PandoraUriMeta(type): def __init__(cls, name, bases, clsdict): # noqa N805 super(_PandoraUriMeta, cls).__init__(name, bases, clsdict) if hasattr(cls, 'uri_type'): cls.TYPES[cls.uri_type] = cls class PandoraUri(with_metaclass(_PandoraUriMeta, object)): TYPES = {} SCHEME = 'pandora' def __init__(self, uri_type=None): self.uri_type = uri_type def __repr__(self): return '{}:{uri_type}'.format(self.SCHEME, **self.__dict__) @property def encoded_attributes(self): encoded_dict = {} for k, v in list(self.__dict__.items()): encoded_dict[k] = quote(PandoraUri.encode(v)) return encoded_dict @property def uri(self): return repr(self) @classmethod def encode(cls, value): if value is None: value = '' if isinstance(value, compat.text_type): value = value.encode('utf-8') return value @classmethod def factory(cls, obj): if isinstance(obj, basestring): # A string return PandoraUri._from_uri(obj) if isinstance(obj, models.Ref) or isinstance(obj, models.Track): # A mopidy track or track reference return PandoraUri._from_uri(obj.uri) elif isinstance(obj, Station) or isinstance(obj, GenreStation): # One of the station types return PandoraUri._from_station(obj) elif isinstance(obj, PlaylistItem) or isinstance(obj, AdItem): # One of the playlist item (track) types return PandoraUri._from_track(obj) else: raise NotImplementedError("Unsupported URI object type '{}'".format(type(obj))) @classmethod def _from_uri(cls, uri): parts = [unquote(cls.encode(p)) for p in uri.split(':')] if not parts or parts[0] != PandoraUri.SCHEME or len(parts) < 2: raise NotImplementedError('Not a Pandora URI: {}'.format(uri)) uri_cls = cls.TYPES.get(parts[1]) if uri_cls: return uri_cls(*parts[2:]) else: raise NotImplementedError("Unsupported Pandora URI type '{}'".format(uri)) @classmethod def _from_station(cls, station): if isinstance(station, Station) or isinstance(station, GenreStation): if GenreStationUri.pattern.match(station.id) and station.id == station.token: return GenreStationUri(station.id, station.token) return StationUri(station.id, station.token) else: raise NotImplementedError("Unsupported station item type '{}'".format(station)) @classmethod def _from_track(cls, track): if isinstance(track, PlaylistItem): return PlaylistItemUri(track.station_id, track.track_token) elif isinstance(track, AdItem): return AdItemUri(track.station_id, track.ad_token) else: raise NotImplementedError("Unsupported playlist item type '{}'".format(track)) @classmethod def is_pandora_uri(cls, uri): try: return uri and isinstance(uri, basestring) and uri.startswith(PandoraUri.SCHEME) and PandoraUri.factory(uri) except NotImplementedError: return False class GenreUri(PandoraUri): uri_type = 'genre' def __init__(self, category_name): super(GenreUri, self).__init__(self.uri_type) self.category_name = category_name def __repr__(self): return '{}:{category_name}'.format( super(GenreUri, self).__repr__(), **self.encoded_attributes ) class StationUri(PandoraUri): uri_type = 'station' def __init__(self, station_id, token): super(StationUri, self).__init__(self.uri_type) self.station_id = station_id self.token = token def __repr__(self): return '{}:{station_id}:{token}'.format( super(StationUri, self).__repr__(), **self.encoded_attributes ) class GenreStationUri(StationUri): uri_type = 'genre_station' pattern = re.compile('^([G])(\d*)$') def __init__(self, station_id, token): # Check that this really is a Genre station as opposed to a regular station. # Genre station IDs and tokens always start with 'G'. assert GenreStationUri.pattern.match(station_id) assert GenreStationUri.pattern.match(token) super(GenreStationUri, self).__init__(station_id, token) class TrackUri(PandoraUri): uri_type = 'track' class PlaylistItemUri(TrackUri): def __init__(self, station_id, token): super(PlaylistItemUri, self).__init__(self.uri_type) self.station_id = station_id self.token = token def __repr__(self): return '{}:{station_id}:{token}'.format( super(PlaylistItemUri, self).__repr__(), **self.encoded_attributes ) class AdItemUri(TrackUri): uri_type = 'ad' def __init__(self, station_id, ad_token): super(AdItemUri, self).__init__(self.uri_type) self.station_id = station_id self.ad_token = ad_token def __repr__(self): return '{}:{station_id}:{ad_token}'.format( super(AdItemUri, self).__repr__(), **self.encoded_attributes ) PK:Het_08 8 mopidy_pandora/utils.pyfrom __future__ import absolute_import, division, print_function, unicode_literals import json import requests def run_async(func): """ Function decorator intended to make "func" run in a separate thread (asynchronously). :param func: the function to run asynchronously :return: the created Thread object that the function is running in. """ from threading import Thread from functools import wraps @wraps(func) def async_func(*args, **kwargs): """ Run a function asynchronously :param args: all arguments will be passed to the target function :param kwargs: pass a Queue.Queue() object with the optional 'queue' keyword if you would like to retrieve the results after the thread has run. All other keyword arguments will be passed to the target function. :return: the created Thread object that the function is running in. """ t = Thread(target=func, args=args, kwargs=kwargs) queue = kwargs.get('queue', None) if queue is not None: t.result_queue = queue t.start() return t return async_func def format_proxy(proxy_config): if not proxy_config.get('hostname'): return None port = proxy_config.get('port') if not port or port < 0: port = 80 template = '{hostname}:{port}' return template.format(hostname=proxy_config['hostname'], port=port) class RPCClient(object): hostname = '127.0.0.1' port = '6680' url = 'http://' + str(hostname) + ':' + str(port) + '/mopidy/rpc' id = 0 @classmethod def configure(cls, hostname, port): cls.hostname = hostname cls.port = port @classmethod @run_async def _do_rpc(cls, method, params=None, queue=None): """ Makes an asynchronously remote procedure call to the Mopidy server. :param method: the name of the Mopidy remote procedure to be called (typically from the 'core' module. :param params: a dictionary of argument:value pairs to be passed directly to the remote procedure. :param queue: a Queue.Queue() object that the results of the thread should be stored in. """ cls.id += 1 data = {'method': method, 'jsonrpc': '2.0', 'id': cls.id} if params is not None: data['params'] = params json_data = json.loads(requests.request('POST', cls.url, data=json.dumps(data), headers={'Content-Type': 'application/json'}).text) if queue is not None: queue.put(json_data['result']) PKxFHO%%.Mopidy_Pandora-0.2.1.dist-info/DESCRIPTION.rst************** Mopidy-Pandora ************** .. image:: https://img.shields.io/pypi/v/Mopidy-Pandora.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Pandora/ :alt: Latest PyPI version .. image:: https://img.shields.io/pypi/dm/Mopidy-Pandora.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Pandora/ :alt: Number of PyPI downloads .. image:: https://img.shields.io/travis/rectalogic/mopidy-pandora/develop.svg?style=flat :target: https://travis-ci.org/rectalogic/mopidy-pandora :alt: Travis CI build status .. image:: https://img.shields.io/coveralls/rectalogic/mopidy-pandora/develop.svg?style=flat :target: https://coveralls.io/r/rectalogic/mopidy-pandora?branch=develop :alt: Test coverage `Mopidy `_ extension for playing music from `Pandora Radio `_. Features ======== - Support for both Pandora One and ad-supported free accounts. - Add ratings to tracks (thumbs up, thumbs down, sleep, etc.). - Bookmark songs or artists. - Browse and add genre stations. - Play QuickMix stations. - Sort stations alphabetically or by date added. - Delete stations from the user's Pandora profile. - Scrobbling to last.fm using the `Mopidy scrobbler `_. Usage ===== Ideally, Mopidy needs `dynamic playlists `_ and `core extensions `_ to properly support Pandora. In the meantime, Mopidy-Pandora comes bundled with a frontend extension that automatically adds more tracks to the tracklist as needed. Mopidy-Pandora will ensure that there are always just two tracks in the tracklist: the currently playing track and the track that is up next. It is not possible to mix Pandora and non-Pandora tracks for playback at the same time, so any non-Pandora tracks will be removed from the tracklist when playback starts. Pandora expects users to interact with tracks at the point in time and in the sequence that it serves them up. For this reason, trying to save tracks to playlists or messing with the Mopidy-Pandora generated tracklist is probably not a good idea. And not recommended. Dependencies ============ - Requires a Pandora user account. Users with a Pandora One subscription will have access to the higher quality 192 Kbps audio stream. Free accounts will play advertisements. - ``pydora`` >= 1.7.0. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. - ``cachetools`` >= 1.0. Extensible memoizing collections and decorators. The package is available as ``cachetools`` on PyPI. - ``Mopidy`` >= 1.1.2. The music server that Mopidy-Pandora extends. - ``requests`` >= 2.5.0. Python HTTP Requests for Humans™. Installation ============ Install by running:: pip install Mopidy-Pandora Configuration ============= Before starting Mopidy, you must add your Pandora username and password to your Mopidy configuration file. The minimum configuration also requires that you provide the details of the JSON API endpoint that you would like to use:: [pandora] enabled = true api_host = tuner.pandora.com/services/json/ partner_encryption_key = partner_decryption_key = partner_username = iphone partner_password = partner_device = IP01 username = password = The following configuration values are available: - ``pandora/enabled``: If the Pandora extension should be enabled or not. Defaults to ``true``. - ``pandora/api_host``: Which of the JSON API `endpoints `_ to use. Note that the endpoints are different for Pandora One and free accounts (details in the link provided). - ``pandora/partner_`` related values: The `credentials `_ to use for the Pandora API entry point. - ``pandora/username``: Your Pandora username. You *must* provide this. - ``pandora/password``: Your Pandora password. You *must* provide this. - ``pandora/preferred_audio_quality``: can be one of ``lowQuality``, ``mediumQuality``, or ``highQuality`` (default). If the preferred audio quality is not available for the partner device specified, then the next-lowest bitrate stream that Pandora supports for the chosen device will be used. - ``pandora/sort_order``: defaults to ``a-z``. Use ``date`` to display the list of stations in the order that the stations were added. - ``pandora/auto_setup``: Specifies if Mopidy-Pandora should automatically configure the Mopidy player for best compatibility with the Pandora radio stream. Defaults to ``true`` and turns ``consume`` on and ``repeat``, ``random``, and ``single`` modes off. - ``pandora/cache_time_to_live``: specifies the length of time (in seconds) that station and genre lists should be cached for between automatic refreshes. Using a local cache greatly speeds up browsing the library. It should not be necessary to fiddle with this unless the Mopidy frontend that you are using does not support manually refreshing the library, and you want Mopidy-Pandora to immediately detect changes to your Pandora user profile that are made in other Pandora players. Setting this to ``0`` will disable caching completely and ensure that the latest lists are always retrieved directly from the Pandora server. Defaults to ``86400`` (i.e. 24 hours). It is also possible to apply Pandora ratings and perform other actions on the currently playing track using the standard pause/play/previous/next buttons. - ``pandora/event_support_enabled``: setting this to ``true`` will enable the event triggers. Event support is disabled by default as this is still an experimental feature, and not something that is provided for in the Mopidy API. It works, but it is not impossible that the wrong events may be triggered for tracks or (in the worst case scenario) that one of your stations may be deleted accidentally. Mileage may vary - **use at your own risk.** - ``pandora/double_click_interval``: successive button clicks that occur within this interval will trigger an event. Defaults to ``2.50`` seconds. - ``pandora/on_pause_resume_click``: click pause and then play while a song is playing to trigger the event. Defaults to ``thumbs_up``. - ``pandora/on_pause_next_click``: click pause and then next in quick succession. Calls event and skips to next song. Defaults to ``thumbs_down``. - ``pandora/on_pause_previous_click``: click pause and then previous in quick succession. Calls event and restarts the current song. Defaults to ``sleep``. - ``pandora/on_pause_resume_pause_click``: click pause, resume, and pause again in quick succession (i.e. triple click). Calls event. Defaults to ``delete_station``. The full list of supported events are: ``thumbs_up``, ``thumbs_down``, ``sleep``, ``add_artist_bookmark``, ``add_song_bookmark``, and ``delete_station``. Project resources ================= - `Change log `_ - `Troubleshooting guide `_ - `Source code `_ - `Issue tracker `_ - `Development branch tarball `_ PKxFHs11/Mopidy_Pandora-0.2.1.dist-info/entry_points.txt[mopidy.ext] pandora = mopidy_pandora:Extension PKxFHY,Mopidy_Pandora-0.2.1.dist-info/metadata.json{"classifiers": ["Environment :: No Input/Output (Daemon)", "Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 2", "Topic :: Multimedia :: Sound/Audio :: Players"], "extensions": {"python.details": {"contacts": [{"email": "rectalogic@rectalogic.com", "name": "Andrew Wason", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}, "project_urls": {"Home": "https://github.com/rectalogic/mopidy-pandora"}}, "python.exports": {"mopidy.ext": {"pandora": "mopidy_pandora:Extension"}}}, "extras": [], "generator": "bdist_wheel (0.28.0)", "license": "Apache License, Version 2.0", "metadata_version": "2.0", "name": "Mopidy-Pandora", "run_requires": [{"requires": ["Mopidy (>=1.1.2)", "Pykka (>=1.1)", "cachetools (>=1.0.0)", "pydora (>=1.7.0)", "requests (>=2.5.0)", "setuptools"]}], "summary": "Mopidy extension for Pandora", "test_requires": [{"requires": ["tox"]}], "version": "0.2.1"}PK`'H=//'Mopidy_Pandora-0.2.1.dist-info/pbr.json{"is_release": false, "git_version": "de65f9c"}PKxFHpa,Mopidy_Pandora-0.2.1.dist-info/top_level.txtmopidy_pandora PKxFH>nn$Mopidy_Pandora-0.2.1.dist-info/WHEELWheel-Version: 1.0 Generator: bdist_wheel (0.28.0) Root-Is-Purelib: true Tag: py2-none-any Tag: py3-none-any PKxFH#{$. . 'Mopidy_Pandora-0.2.1.dist-info/METADATAMetadata-Version: 2.0 Name: Mopidy-Pandora Version: 0.2.1 Summary: Mopidy extension for Pandora Home-page: https://github.com/rectalogic/mopidy-pandora Author: Andrew Wason Author-email: rectalogic@rectalogic.com License: Apache License, Version 2.0 Platform: UNKNOWN Classifier: Environment :: No Input/Output (Daemon) Classifier: Intended Audience :: End Users/Desktop Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 2 Classifier: Topic :: Multimedia :: Sound/Audio :: Players Requires-Dist: Mopidy (>=1.1.2) Requires-Dist: Pykka (>=1.1) Requires-Dist: cachetools (>=1.0.0) Requires-Dist: pydora (>=1.7.0) Requires-Dist: requests (>=2.5.0) Requires-Dist: setuptools ************** Mopidy-Pandora ************** .. image:: https://img.shields.io/pypi/v/Mopidy-Pandora.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Pandora/ :alt: Latest PyPI version .. image:: https://img.shields.io/pypi/dm/Mopidy-Pandora.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Pandora/ :alt: Number of PyPI downloads .. image:: https://img.shields.io/travis/rectalogic/mopidy-pandora/develop.svg?style=flat :target: https://travis-ci.org/rectalogic/mopidy-pandora :alt: Travis CI build status .. image:: https://img.shields.io/coveralls/rectalogic/mopidy-pandora/develop.svg?style=flat :target: https://coveralls.io/r/rectalogic/mopidy-pandora?branch=develop :alt: Test coverage `Mopidy `_ extension for playing music from `Pandora Radio `_. Features ======== - Support for both Pandora One and ad-supported free accounts. - Add ratings to tracks (thumbs up, thumbs down, sleep, etc.). - Bookmark songs or artists. - Browse and add genre stations. - Play QuickMix stations. - Sort stations alphabetically or by date added. - Delete stations from the user's Pandora profile. - Scrobbling to last.fm using the `Mopidy scrobbler `_. Usage ===== Ideally, Mopidy needs `dynamic playlists `_ and `core extensions `_ to properly support Pandora. In the meantime, Mopidy-Pandora comes bundled with a frontend extension that automatically adds more tracks to the tracklist as needed. Mopidy-Pandora will ensure that there are always just two tracks in the tracklist: the currently playing track and the track that is up next. It is not possible to mix Pandora and non-Pandora tracks for playback at the same time, so any non-Pandora tracks will be removed from the tracklist when playback starts. Pandora expects users to interact with tracks at the point in time and in the sequence that it serves them up. For this reason, trying to save tracks to playlists or messing with the Mopidy-Pandora generated tracklist is probably not a good idea. And not recommended. Dependencies ============ - Requires a Pandora user account. Users with a Pandora One subscription will have access to the higher quality 192 Kbps audio stream. Free accounts will play advertisements. - ``pydora`` >= 1.7.0. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. - ``cachetools`` >= 1.0. Extensible memoizing collections and decorators. The package is available as ``cachetools`` on PyPI. - ``Mopidy`` >= 1.1.2. The music server that Mopidy-Pandora extends. - ``requests`` >= 2.5.0. Python HTTP Requests for Humans™. Installation ============ Install by running:: pip install Mopidy-Pandora Configuration ============= Before starting Mopidy, you must add your Pandora username and password to your Mopidy configuration file. The minimum configuration also requires that you provide the details of the JSON API endpoint that you would like to use:: [pandora] enabled = true api_host = tuner.pandora.com/services/json/ partner_encryption_key = partner_decryption_key = partner_username = iphone partner_password = partner_device = IP01 username = password = The following configuration values are available: - ``pandora/enabled``: If the Pandora extension should be enabled or not. Defaults to ``true``. - ``pandora/api_host``: Which of the JSON API `endpoints `_ to use. Note that the endpoints are different for Pandora One and free accounts (details in the link provided). - ``pandora/partner_`` related values: The `credentials `_ to use for the Pandora API entry point. - ``pandora/username``: Your Pandora username. You *must* provide this. - ``pandora/password``: Your Pandora password. You *must* provide this. - ``pandora/preferred_audio_quality``: can be one of ``lowQuality``, ``mediumQuality``, or ``highQuality`` (default). If the preferred audio quality is not available for the partner device specified, then the next-lowest bitrate stream that Pandora supports for the chosen device will be used. - ``pandora/sort_order``: defaults to ``a-z``. Use ``date`` to display the list of stations in the order that the stations were added. - ``pandora/auto_setup``: Specifies if Mopidy-Pandora should automatically configure the Mopidy player for best compatibility with the Pandora radio stream. Defaults to ``true`` and turns ``consume`` on and ``repeat``, ``random``, and ``single`` modes off. - ``pandora/cache_time_to_live``: specifies the length of time (in seconds) that station and genre lists should be cached for between automatic refreshes. Using a local cache greatly speeds up browsing the library. It should not be necessary to fiddle with this unless the Mopidy frontend that you are using does not support manually refreshing the library, and you want Mopidy-Pandora to immediately detect changes to your Pandora user profile that are made in other Pandora players. Setting this to ``0`` will disable caching completely and ensure that the latest lists are always retrieved directly from the Pandora server. Defaults to ``86400`` (i.e. 24 hours). It is also possible to apply Pandora ratings and perform other actions on the currently playing track using the standard pause/play/previous/next buttons. - ``pandora/event_support_enabled``: setting this to ``true`` will enable the event triggers. Event support is disabled by default as this is still an experimental feature, and not something that is provided for in the Mopidy API. It works, but it is not impossible that the wrong events may be triggered for tracks or (in the worst case scenario) that one of your stations may be deleted accidentally. Mileage may vary - **use at your own risk.** - ``pandora/double_click_interval``: successive button clicks that occur within this interval will trigger an event. Defaults to ``2.50`` seconds. - ``pandora/on_pause_resume_click``: click pause and then play while a song is playing to trigger the event. Defaults to ``thumbs_up``. - ``pandora/on_pause_next_click``: click pause and then next in quick succession. Calls event and skips to next song. Defaults to ``thumbs_down``. - ``pandora/on_pause_previous_click``: click pause and then previous in quick succession. Calls event and restarts the current song. Defaults to ``sleep``. - ``pandora/on_pause_resume_pause_click``: click pause, resume, and pause again in quick succession (i.e. triple click). Calls event. Defaults to ``delete_station``. The full list of supported events are: ``thumbs_up``, ``thumbs_down``, ``sleep``, ``add_artist_bookmark``, ``add_song_bookmark``, and ``delete_station``. Project resources ================= - `Change log `_ - `Troubleshooting guide `_ - `Source code `_ - `Issue tracker `_ - `Development branch tarball `_ PKxFHs%Mopidy_Pandora-0.2.1.dist-info/RECORDMopidy_Pandora-0.2.1.dist-info/DESCRIPTION.rst,sha256=HG0K0K3W4l59uHl0PrBSk2KaQh7NrugbOJfZpgkOc3g,7461 Mopidy_Pandora-0.2.1.dist-info/METADATA,sha256=86lh-6L_mI4hH1luK6pYLKbR86Hv2t7mUdJ_LV7K-fY,8238 Mopidy_Pandora-0.2.1.dist-info/RECORD,, Mopidy_Pandora-0.2.1.dist-info/WHEEL,sha256=c5du820PMLPXFYzXDp0SSjIjJ-7MmVRpJa1kKfTaqlc,110 Mopidy_Pandora-0.2.1.dist-info/entry_points.txt,sha256=4aoTJTMmJc7JSNj6o7PbzWpIcNAF3XVJMXCsu3zZfac,49 Mopidy_Pandora-0.2.1.dist-info/metadata.json,sha256=F8r-AtgW6AHdmvPx_lBKZIexSXhfsuJeWIwAazLenaM,1019 Mopidy_Pandora-0.2.1.dist-info/pbr.json,sha256=3xyTVZi7edRtQFBrUvW2q1iBBPUDlTjjCwIOby1gss0,47 Mopidy_Pandora-0.2.1.dist-info/top_level.txt,sha256=4jj4V7oJnykoZeTyrnx0_wU-WgZtz43alKxrGaofnsI,15 mopidy_pandora/__init__.py,sha256=p-and90BxXwW6L1JMTki9-1YqgTrHJb1jTTMFlNDXqs,3939 mopidy_pandora/backend.py,sha256=BW2KTvaxK7ejhiqhZzxdBfaEsGfSd-6MXQgejMspdXg,4215 mopidy_pandora/client.py,sha256=8B9VIr47tYz_GWA9zA6-ZiARR3FoHA0N7PXDCDDOTps,3403 mopidy_pandora/ext.conf,sha256=K-VemM2F3XqRtsMWiIaN5Lv36A1b36v7Qo-5rfe0GFk,517 mopidy_pandora/frontend.py,sha256=uptTudblUQvUagC9YZ6H0RZW5dmElM8bSA9FE_UDPGk,19498 mopidy_pandora/library.py,sha256=80uMYr1--NgrxbzeUdLlJfDOsr3vO3sEciQrqYzaYv0,8815 mopidy_pandora/listener.py,sha256=n5qtCZxXVOk6L3r3LW-aMZGfDrPcziLf5bD8AaYd_hw,5321 mopidy_pandora/playback.py,sha256=nzWzNTcU5dXBYVm6cZhMZ78vP2UfDq5JV4Jo1hyZ-3k,3800 mopidy_pandora/uri.py,sha256=kK9R_8_hQeqnp8kMb0WXCPmoXptVeHb4C4ir9qNzYDs,5705 mopidy_pandora/utils.py,sha256=YBX-nJOT8Nsouxmqhj1WiRYfB5evU_rDrqy9XaMafAw,2616 PKvwFH<1ccmopidy_pandora/__init__.pyPKvFH?&wwmopidy_pandora/backend.pyPK:H&K K I mopidy_pandora/client.pyPK:HK-mopidy_pandora/ext.confPKvFHC*L*L0mopidy_pandora/frontend.pyPKvFH,Lo"o"f|mopidy_pandora/library.pyPK:Hdo mopidy_pandora/listener.pyPK:HOJB mopidy_pandora/playback.pyPK:HtIImopidy_pandora/uri.pyPK:Het_08 8 mopidy_pandora/utils.pyPKxFHO%%.Mopidy_Pandora-0.2.1.dist-info/DESCRIPTION.rstPKxFHs11/wMopidy_Pandora-0.2.1.dist-info/entry_points.txtPKxFHY,Mopidy_Pandora-0.2.1.dist-info/metadata.jsonPK`'H=//':Mopidy_Pandora-0.2.1.dist-info/pbr.jsonPKxFHpa,Mopidy_Pandora-0.2.1.dist-info/top_level.txtPKxFH>nn$Mopidy_Pandora-0.2.1.dist-info/WHEELPKxFH#{$. . 'Mopidy_Pandora-0.2.1.dist-info/METADATAPKxFHs%*(Mopidy_Pandora-0.2.1.dist-info/RECORDPK}.