PK vwFH<1c c mopidy_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)
PK vFH?&w w mopidy_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 :HK mopidy_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
PK vFHC*L *L mopidy_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)
PK vFH,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 :Hdo mopidy_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 :HOJB mopidy_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 :HtI I mopidy_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'])
PK xFHO% % . 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 `_
PK xFHs1 1 / Mopidy_Pandora-0.2.1.dist-info/entry_points.txt[mopidy.ext]
pandora = mopidy_pandora:Extension
PK xFHY , 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"}PK xFHpa , Mopidy_Pandora-0.2.1.dist-info/top_level.txtmopidy_pandora
PK xFH>n n $ 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
PK xFH#{$. . ' 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 `_
PK xFHs % 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
PK vwFH<1c c mopidy_pandora/__init__.pyPK vFH?&w w mopidy_pandora/backend.pyPK :H&K
K
I mopidy_pandora/client.pyPK :HK - mopidy_pandora/ext.confPK vFHC*L *L 0 mopidy_pandora/frontend.pyPK vFH,Lo" o" f| mopidy_pandora/library.pyPK :Hdo mopidy_pandora/listener.pyPK :HOJB
mopidy_pandora/playback.pyPK :HtI I mopidy_pandora/uri.pyPK :Het_08
8
mopidy_pandora/utils.pyPK xFHO% % . Mopidy_Pandora-0.2.1.dist-info/DESCRIPTION.rstPK xFHs1 1 / w Mopidy_Pandora-0.2.1.dist-info/entry_points.txtPK xFHY , Mopidy_Pandora-0.2.1.dist-info/metadata.jsonPK `'H=/ / ' : Mopidy_Pandora-0.2.1.dist-info/pbr.jsonPK xFHpa , Mopidy_Pandora-0.2.1.dist-info/top_level.txtPK xFH>n n $ Mopidy_Pandora-0.2.1.dist-info/WHEELPK xFH#{$. . ' Mopidy_Pandora-0.2.1.dist-info/METADATAPK xFHs % *( Mopidy_Pandora-0.2.1.dist-info/RECORDPK } .