From c0f0c61f4b2db4a22c7c68bbed248e56f7c8367d Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 28 Oct 2015 11:31:54 +0200 Subject: [PATCH 001/311] Initial implementation of ad support. --- README.rst | 2 ++ mopidy_pandora/__init__.py | 1 + mopidy_pandora/doubleclick.py | 5 +++ mopidy_pandora/ext.conf | 1 + mopidy_pandora/library.py | 2 +- mopidy_pandora/playback.py | 14 +++++++- mopidy_pandora/uri.py | 9 +++-- setup.py | 2 +- tests/conftest.py | 64 ++++++++++++++++++----------------- tests/test_doubleclick.py | 15 ++++++++ tests/test_playback.py | 8 ++--- tests/test_uri.py | 6 ++++ 12 files changed, 88 insertions(+), 41 deletions(-) diff --git a/README.rst b/README.rst index 6d923f2..f42cfb6 100644 --- a/README.rst +++ b/README.rst @@ -51,6 +51,7 @@ Mopidy-Pandora to your Mopidy configuration file:: password = sort_order = date auto_setup = true + ad_support = true ### EXPERIMENTAL EVENT HANDLING IMPLEMENTATION ### event_support_enabled = false @@ -110,6 +111,7 @@ Changelog v0.1.7 (UNRELEASED) ---------------------------------------- +- Add support for playing advertisements. This should prevent free Pandora accounts from locking up. - Configuration parameter 'auto_set_repeat' has been renamed to 'auto_setup' - please update your Mopidy configuration file. - Now resumes playback after a track has been rated. - Enhanced auto_setup routines to ensure that 'consume', 'shuffle', and 'single' modes are disabled as well. diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index d3ade1e..4bea54b 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -45,6 +45,7 @@ def get_config_schema(self): schema['on_pause_resume_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep']) schema['on_pause_next_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep']) schema['on_pause_previous_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep']) + schema['ad_support'] = config.Boolean() return schema def setup(self, registry): diff --git a/mopidy_pandora/doubleclick.py b/mopidy_pandora/doubleclick.py index bcbc2be..99ea8d2 100644 --- a/mopidy_pandora/doubleclick.py +++ b/mopidy_pandora/doubleclick.py @@ -68,6 +68,11 @@ def process_click(self, method, track_uri): self.set_click_time(0) uri = PandoraUri.parse(track_uri) + + if uri.ad_token is not None and len(uri.ad_token) > 0: + logger.info("Skipping event for advertisement") + return True + logger.info("Triggering event '%s' for song: %s", method, uri.name) func = getattr(self, method) diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index c125607..f25b50c 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -11,6 +11,7 @@ password = preferred_audio_quality = highQuality sort_order = date auto_setup = true +ad_support = true ### EXPERIMENTAL RATINGS IMPLEMENTATION ### event_support_enabled = false diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 01226bc..067a8c5 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -32,7 +32,7 @@ def browse(self, uri): tracks.append(models.Ref.track(name=pandora_uri.name, uri=TrackUri(pandora_uri.station_id, pandora_uri.token, pandora_uri.name, pandora_uri.detail_url, pandora_uri.art_url, - index=str(i)).uri)) + pandora_uri.ad_token, index=str(i)).uri)) return tracks diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 46a437a..7b5fd9a 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -67,6 +67,15 @@ def get_next_track(self, index): for track in self._station_iter: try: + if not track.audio_url and track.ad_token: + data = self.backend.api.get_ad_metadata(track.ad_token) + track.audio_url = track.get_audio_url(data, self.backend.api.default_audio_quality) + + track.song_name = data['title'] + track.artist_name = data['companyName'] + + self.backend.api.register_ad(self._station.id, data['adTrackingTokens']) + is_playable = track.audio_url and track.get_is_playable() except requests.exceptions.RequestException as e: is_playable = False @@ -74,7 +83,10 @@ def get_next_track(self, index): if is_playable: self.active_track_uri = TrackUri.from_track(track, index).uri - logger.info("Up next: '%s' by %s", track.song_name, track.artist_name) + if track.ad_token is not None and len(track.ad_token) > 0: + logger.info("Up next: Advertisement") + else: + logger.info("Up next: '%s' by %s", track.song_name, track.artist_name) return models.Track(uri=self.active_track_uri) else: consecutive_track_skips += 1 diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index a36b926..0911cff 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -73,20 +73,23 @@ def uri(self): class TrackUri(StationUri): scheme = 'track' - def __init__(self, station_id, track_token, name, detail_url, art_url, audio_url='none_generated', index=0): + def __init__(self, station_id, track_token, name, detail_url, art_url, ad_token, audio_url='none_generated', + index=0): super(TrackUri, self).__init__(station_id, track_token, name, detail_url, art_url) + self.ad_token = ad_token self.audio_url = audio_url self.index = index @classmethod def from_track(cls, track, index=0): return TrackUri(track.station_id, track.track_token, track.song_name, track.song_detail_url, - track.album_art_url, track.audio_url, index) + track.album_art_url, track.ad_token, track.audio_url, index) @property def uri(self): - return "{}:{}:{}".format( + return "{}:{}:{}:{}".format( super(TrackUri, self).uri, + self.quote(self.ad_token), self.quote(self.audio_url), self.quote(self.index), ) diff --git a/setup.py b/setup.py index 6f6d7b7..2f8a43a 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,7 @@ def run_tests(self): 'setuptools', 'Mopidy >= 1.0.7', 'Pykka >= 1.1', - 'pydora >= 1.4.0', + 'pydora >= 1.6', 'requests >= 2.5.0' ], tests_require=['tox'], diff --git a/tests/conftest.py b/tests/conftest.py index c710e17..4970ec5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,7 @@ MOCK_TRACK_SCHEME = "track" MOCK_TRACK_NAME = "Mock Track" MOCK_TRACK_TOKEN = "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" +MOCK_TRACK_AD_TOKEN = "000000000000000000-none" MOCK_TRACK_AUDIO_HIGH = "http://mockup.com/high_quality_audiofile.mp4?..." MOCK_TRACK_AUDIO_MED = "http://mockup.com/medium_quality_audiofile.mp4?..." MOCK_TRACK_AUDIO_LOW = "http://mockup.com/low_quality_audiofile.mp4?..." @@ -107,36 +108,37 @@ def get_station_mock(self, station_token): def playlist_result_mock(): # TODO: Test inclusion of add tokens mock_result = {"stat": "ok", - "result": { - "items": [{ - "trackToken": MOCK_TRACK_TOKEN, - "artistName": "Mock Artist Name", - "albumName": "Mock Album Name", - "albumArtUrl": MOCK_TRACK_ART_URL, - "audioUrlMap": { - "highQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_HIGH, - "protocol": "http" - }, - "mediumQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_MED, - "protocol": "http" - }, - "lowQuality": { - "bitrate": "32", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_LOW, - "protocol": "http" - } - }, - "songName": MOCK_TRACK_NAME, - "songDetailUrl": MOCK_TRACK_DETAIL_URL, - "stationId": MOCK_STATION_ID, - "songRating": 0, }]}} + "result": dict(items=[{ + "trackToken": MOCK_TRACK_TOKEN, + "artistName": "Mock Artist Name", + "albumName": "Mock Album Name", + "albumArtUrl": MOCK_TRACK_ART_URL, + "audioUrlMap": { + "highQuality": { + "bitrate": "64", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_HIGH, + "protocol": "http" + }, + "mediumQuality": { + "bitrate": "64", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_MED, + "protocol": "http" + }, + "lowQuality": { + "bitrate": "32", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_LOW, + "protocol": "http" + } + }, + "songName": MOCK_TRACK_NAME, + "songDetailUrl": MOCK_TRACK_DETAIL_URL, + "stationId": MOCK_STATION_ID, + "songRating": 0, + "adToken": "", + }])} return mock_result @@ -156,7 +158,7 @@ def get_station_playlist_mock(self): return iter(get_playlist_mock(self, MOCK_STATION_TOKEN)) -@pytest.fixture(scope="session") +@pytest.fixture def playlist_item_mock(): return PlaylistItem.from_json(get_backend( config()).api, playlist_result_mock()["result"]["items"][0]) diff --git a/tests/test_doubleclick.py b/tests/test_doubleclick.py index a508221..1f3e765 100644 --- a/tests/test_doubleclick.py +++ b/tests/test_doubleclick.py @@ -121,6 +121,21 @@ def test_process_click_resets_click_time(config, handler, playlist_item_mock): assert handler.get_click_time() == 0 +def test_events_not_processed_for_ads(config, handler, playlist_item_mock): + + playlist_item_mock.ad_token = conftest.MOCK_TRACK_AD_TOKEN + + thumbs_up_mock = mock.PropertyMock() + + handler.thumbs_up = thumbs_up_mock + + track_uri = TrackUri.from_track(playlist_item_mock).uri + + handler.process_click(config['pandora']['on_pause_resume_click'], track_uri) + + assert not handler.thumbs_up.called + + def test_process_click_resume(config, handler, playlist_item_mock): thumbs_up_mock = mock.PropertyMock() diff --git a/tests/test_playback.py b/tests/test_playback.py index f00a035..856168a 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -152,7 +152,7 @@ def test_change_track_enforces_skip_limit(provider): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=False): - track = models.Track(uri="pandora:track:test::::") + track = models.Track(uri="pandora:track:test:::::") assert provider.change_track(track) is False assert PlaylistItem.get_is_playable.call_count == 4 @@ -161,7 +161,7 @@ def test_change_track_enforces_skip_limit(provider): def test_change_track_handles_request_exceptions(config, caplog): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.request_exception_mock): - track = models.Track(uri="pandora:track:test::::") + track = models.Track(uri="pandora:track:test:::::") playback = conftest.get_backend(config).playback @@ -261,14 +261,14 @@ def test_is_playable_handles_request_exceptions(provider, caplog): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): with mock.patch.object(PlaylistItem, 'get_is_playable', conftest.request_exception_mock): - track = models.Track(uri="pandora:track:test::::") + track = models.Track(uri="pandora:track:test:::::") assert provider.change_track(track) is False assert 'Error checking if track is playable' in caplog.text() def test_translate_uri_returns_audio_url(provider): - assert provider.translate_uri("pandora:track:test:::::audio_url") == "audio_url" + assert provider.translate_uri("pandora:track:test::::::audio_url") == "audio_url" def test_auto_setup_only_called_once(provider): diff --git a/tests/test_uri.py b/tests/test_uri.py index 179e924..24726d2 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -84,6 +84,8 @@ def test_station_uri_parse(station_mock): def test_track_uri_from_track(playlist_item_mock): + playlist_item_mock.ad_token = conftest.MOCK_TRACK_AD_TOKEN + track_uri = TrackUri.from_track(playlist_item_mock) assert track_uri.uri == "pandora:" + \ @@ -93,12 +95,15 @@ def test_track_uri_from_track(playlist_item_mock): track_uri.quote(conftest.MOCK_TRACK_NAME) + ":" + \ track_uri.quote(conftest.MOCK_TRACK_DETAIL_URL) + ":" + \ track_uri.quote(conftest.MOCK_TRACK_ART_URL) + ":" + \ + track_uri.quote(conftest.MOCK_TRACK_AD_TOKEN) + ":" + \ track_uri.quote(conftest.MOCK_TRACK_AUDIO_HIGH) + ":" + \ track_uri.quote(0) def test_track_uri_parse(playlist_item_mock): + playlist_item_mock.ad_token = conftest.MOCK_TRACK_AD_TOKEN + track_uri = TrackUri.from_track(playlist_item_mock) obj = TrackUri.parse(track_uri.uri) @@ -111,6 +116,7 @@ def test_track_uri_parse(playlist_item_mock): assert obj.name == conftest.MOCK_TRACK_NAME assert obj.detail_url == conftest.MOCK_TRACK_DETAIL_URL assert obj.art_url == conftest.MOCK_TRACK_ART_URL + assert obj.ad_token == conftest.MOCK_TRACK_AD_TOKEN assert obj.audio_url == conftest.MOCK_TRACK_AUDIO_HIGH assert obj.uri == track_uri.uri From 33af13c41ac423acfb3837af7294acc6867fc1d3 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 28 Oct 2015 11:42:55 +0200 Subject: [PATCH 002/311] Rename ad support config parameter. --- README.rst | 2 +- mopidy_pandora/__init__.py | 2 +- mopidy_pandora/ext.conf | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index f42cfb6..3222ff5 100644 --- a/README.rst +++ b/README.rst @@ -51,7 +51,7 @@ Mopidy-Pandora to your Mopidy configuration file:: password = sort_order = date auto_setup = true - ad_support = true + ad_support_enabled = true ### EXPERIMENTAL EVENT HANDLING IMPLEMENTATION ### event_support_enabled = false diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index 4bea54b..f97605d 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -45,7 +45,7 @@ def get_config_schema(self): schema['on_pause_resume_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep']) schema['on_pause_next_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep']) schema['on_pause_previous_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep']) - schema['ad_support'] = config.Boolean() + schema['ad_support_enabled'] = config.Boolean() return schema def setup(self, registry): diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index f25b50c..39f0834 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -11,7 +11,7 @@ password = preferred_audio_quality = highQuality sort_order = date auto_setup = true -ad_support = true +ad_support_enabled = true ### EXPERIMENTAL RATINGS IMPLEMENTATION ### event_support_enabled = false From ab11dc54a40a14a2eb39369c27cbb67ac1cb05ca Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 28 Oct 2015 11:53:11 +0200 Subject: [PATCH 003/311] Default ad_token in tracklist. --- mopidy_pandora/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 067a8c5..a2ebb48 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -32,7 +32,7 @@ def browse(self, uri): tracks.append(models.Ref.track(name=pandora_uri.name, uri=TrackUri(pandora_uri.station_id, pandora_uri.token, pandora_uri.name, pandora_uri.detail_url, pandora_uri.art_url, - pandora_uri.ad_token, index=str(i)).uri)) + "", index=str(i)).uri)) return tracks From 2d716f267609bb90702037e9c1c493984a75e9c2 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 28 Oct 2015 12:44:11 +0200 Subject: [PATCH 004/311] Add functionality to toggle ad playback based on config settings. --- mopidy_pandora/backend.py | 2 ++ mopidy_pandora/playback.py | 2 +- tests/conftest.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 1fa60bb..bd0d779 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -45,6 +45,8 @@ def __init__(self, config, audio): else: self.playback = PandoraPlaybackProvider(audio=audio, backend=self) + self.play_ads = self._config['ad_support_enabled'] + self.uri_schemes = ['pandora'] def on_start(self): diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 7b5fd9a..3b9dde9 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -67,7 +67,7 @@ def get_next_track(self, index): for track in self._station_iter: try: - if not track.audio_url and track.ad_token: + if not track.audio_url and track.ad_token and self.backend.play_ads: data = self.backend.api.get_ad_metadata(track.ad_token) track.audio_url = track.get_audio_url(data, self.backend.api.default_audio_quality) diff --git a/tests/conftest.py b/tests/conftest.py index 4970ec5..d4fd767 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,6 +54,7 @@ def config(): 'preferred_audio_quality': MOCK_DEFAULT_AUDIO_QUALITY, 'sort_order': 'date', 'auto_setup': True, + 'ad_support_enabled': True, 'event_support_enabled': True, 'double_click_interval': '0.1', From 8a65a93c6a28b4331a0c7399708885c6e3380d51 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 28 Oct 2015 15:51:50 +0200 Subject: [PATCH 005/311] Refactor ad processing logic. --- mopidy_pandora/playback.py | 13 ++++------- mopidy_pandora/uri.py | 11 +++++++-- tests/conftest.py | 47 +++++++++++++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 3b9dde9..ae0942e 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -67,14 +67,9 @@ def get_next_track(self, index): for track in self._station_iter: try: - if not track.audio_url and track.ad_token and self.backend.play_ads: - data = self.backend.api.get_ad_metadata(track.ad_token) - track.audio_url = track.get_audio_url(data, self.backend.api.default_audio_quality) - - track.song_name = data['title'] - track.artist_name = data['companyName'] - - self.backend.api.register_ad(self._station.id, data['adTrackingTokens']) + if track.is_ad and self.backend.play_ads: + track = self.backend.api.get_ad_item(track.ad_token) + track.register_ad(self._station.id) is_playable = track.audio_url and track.get_is_playable() except requests.exceptions.RequestException as e: @@ -83,7 +78,7 @@ def get_next_track(self, index): if is_playable: self.active_track_uri = TrackUri.from_track(track, index).uri - if track.ad_token is not None and len(track.ad_token) > 0: + if track.is_ad: logger.info("Up next: Advertisement") else: logger.info("Up next: '%s' by %s", track.song_name, track.artist_name) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 0911cff..eca37bd 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -1,6 +1,7 @@ import logging import urllib +from pandora.models.pandora import AdItem, PlaylistItem logger = logging.getLogger(__name__) @@ -82,8 +83,14 @@ def __init__(self, station_id, track_token, name, detail_url, art_url, ad_token, @classmethod def from_track(cls, track, index=0): - return TrackUri(track.station_id, track.track_token, track.song_name, track.song_detail_url, - track.album_art_url, track.ad_token, track.audio_url, index) + + if isinstance(track, PlaylistItem): + return TrackUri(track.station_id, track.track_token, track.song_name, track.song_detail_url, + track.album_art_url, track.ad_token, track.audio_url, index) + elif isinstance(track, AdItem): + return TrackUri("", "", track.title, "", "", "", track.audio_url, index) + else: + raise NotImplementedError("Track type not supported") @property def uri(self): diff --git a/tests/conftest.py b/tests/conftest.py index d4fd767..168d6d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ from mock import Mock -from pandora.models.pandora import Playlist, PlaylistItem, Station, StationList +from pandora.models.pandora import AdItem, Playlist, PlaylistItem, Station, StationList import pytest @@ -107,7 +107,6 @@ def get_station_mock(self, station_token): @pytest.fixture(scope="session") def playlist_result_mock(): - # TODO: Test inclusion of add tokens mock_result = {"stat": "ok", "result": dict(items=[{ "trackToken": MOCK_TRACK_TOKEN, @@ -138,12 +137,48 @@ def playlist_result_mock(): "songDetailUrl": MOCK_TRACK_DETAIL_URL, "stationId": MOCK_STATION_ID, "songRating": 0, - "adToken": "", + "adToken": None, }])} return mock_result +@pytest.fixture(scope="session") +def ad_metadata_result_mock(): + mock_result = {"stat": "ok", + "result": { + "title": MOCK_TRACK_TOKEN, + "companyName": "Mock Company Name", + "audioUrlMap": { + "highQuality": { + "bitrate": "64", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_HIGH, + "protocol": "http" + }, + "mediumQuality": { + "bitrate": "64", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_MED, + "protocol": "http" + }, + "lowQuality": { + "bitrate": "32", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_LOW, + "protocol": "http" + } + }, + "adTrackingTokens": { + MOCK_TRACK_AD_TOKEN, + MOCK_TRACK_AD_TOKEN, + MOCK_TRACK_AD_TOKEN + }, + }} + + return mock_result + + @pytest.fixture(scope="session") def playlist_mock(simulate_request_exceptions=False): return Playlist.from_json(get_backend(config(), simulate_request_exceptions).api, playlist_result_mock()["result"]) @@ -165,6 +200,12 @@ def playlist_item_mock(): config()).api, playlist_result_mock()["result"]["items"][0]) +@pytest.fixture +def ad_item_mock(): + return AdItem.from_json(get_backend( + config()).api, ad_metadata_result_mock()["result"]) + + @pytest.fixture(scope="session") def station_list_result_mock(): mock_result = {"stat": "ok", From 8ea4ca259b6b7d5784769cbe3b0290cc6f0f58b0 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 28 Oct 2015 17:00:21 +0200 Subject: [PATCH 006/311] Refactor ad processing logic. --- mopidy_pandora/doubleclick.py | 2 +- mopidy_pandora/uri.py | 16 +++------ tests/conftest.py | 62 +++++++++++++++++------------------ tests/test_doubleclick.py | 6 ++-- tests/test_lookup.py | 4 +-- tests/test_playback.py | 8 ++--- tests/test_uri.py | 6 ---- 7 files changed, 45 insertions(+), 59 deletions(-) diff --git a/mopidy_pandora/doubleclick.py b/mopidy_pandora/doubleclick.py index 99ea8d2..db716b9 100644 --- a/mopidy_pandora/doubleclick.py +++ b/mopidy_pandora/doubleclick.py @@ -69,7 +69,7 @@ def process_click(self, method, track_uri): uri = PandoraUri.parse(track_uri) - if uri.ad_token is not None and len(uri.ad_token) > 0: + if uri.token == "": logger.info("Skipping event for advertisement") return True diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index eca37bd..f48f6f1 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -1,7 +1,6 @@ import logging import urllib -from pandora.models.pandora import AdItem, PlaylistItem logger = logging.getLogger(__name__) @@ -74,29 +73,24 @@ def uri(self): class TrackUri(StationUri): scheme = 'track' - def __init__(self, station_id, track_token, name, detail_url, art_url, ad_token, audio_url='none_generated', - index=0): + def __init__(self, station_id, track_token, name, detail_url, art_url, audio_url='none_generated', index=0): super(TrackUri, self).__init__(station_id, track_token, name, detail_url, art_url) - self.ad_token = ad_token self.audio_url = audio_url self.index = index @classmethod def from_track(cls, track, index=0): - if isinstance(track, PlaylistItem): + if not track.is_ad: return TrackUri(track.station_id, track.track_token, track.song_name, track.song_detail_url, - track.album_art_url, track.ad_token, track.audio_url, index) - elif isinstance(track, AdItem): - return TrackUri("", "", track.title, "", "", "", track.audio_url, index) + track.album_art_url, track.audio_url, index) else: - raise NotImplementedError("Track type not supported") + return TrackUri(None, None, track.title, None, None, track.audio_url, index) @property def uri(self): - return "{}:{}:{}:{}".format( + return "{}:{}:{}".format( super(TrackUri, self).uri, - self.quote(self.ad_token), self.quote(self.audio_url), self.quote(self.index), ) diff --git a/tests/conftest.py b/tests/conftest.py index 168d6d7..e288f65 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -108,37 +108,37 @@ def get_station_mock(self, station_token): @pytest.fixture(scope="session") def playlist_result_mock(): mock_result = {"stat": "ok", - "result": dict(items=[{ - "trackToken": MOCK_TRACK_TOKEN, - "artistName": "Mock Artist Name", - "albumName": "Mock Album Name", - "albumArtUrl": MOCK_TRACK_ART_URL, - "audioUrlMap": { - "highQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_HIGH, - "protocol": "http" - }, - "mediumQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_MED, - "protocol": "http" - }, - "lowQuality": { - "bitrate": "32", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_LOW, - "protocol": "http" - } - }, - "songName": MOCK_TRACK_NAME, - "songDetailUrl": MOCK_TRACK_DETAIL_URL, - "stationId": MOCK_STATION_ID, - "songRating": 0, - "adToken": None, - }])} + "result": { + "items": [{ + "trackToken": MOCK_TRACK_TOKEN, + "artistName": "Mock Artist Name", + "albumName": "Mock Album Name", + "albumArtUrl": MOCK_TRACK_ART_URL, + "audioUrlMap": { + "highQuality": { + "bitrate": "64", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_HIGH, + "protocol": "http" + }, + "mediumQuality": { + "bitrate": "64", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_MED, + "protocol": "http" + }, + "lowQuality": { + "bitrate": "32", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_LOW, + "protocol": "http" + } + }, + "songName": MOCK_TRACK_NAME, + "songDetailUrl": MOCK_TRACK_DETAIL_URL, + "stationId": MOCK_STATION_ID, + "songRating": 0, + "adToken": None, }]}} return mock_result diff --git a/tests/test_doubleclick.py b/tests/test_doubleclick.py index 1f3e765..c480e0d 100644 --- a/tests/test_doubleclick.py +++ b/tests/test_doubleclick.py @@ -121,15 +121,13 @@ def test_process_click_resets_click_time(config, handler, playlist_item_mock): assert handler.get_click_time() == 0 -def test_events_not_processed_for_ads(config, handler, playlist_item_mock): - - playlist_item_mock.ad_token = conftest.MOCK_TRACK_AD_TOKEN +def test_events_not_processed_for_ads(config, handler, ad_item_mock): thumbs_up_mock = mock.PropertyMock() handler.thumbs_up = thumbs_up_mock - track_uri = TrackUri.from_track(playlist_item_mock).uri + track_uri = TrackUri.from_track(ad_item_mock).uri handler.process_click(config['pandora']['on_pause_resume_click'], track_uri) diff --git a/tests/test_lookup.py b/tests/test_lookup.py index 09ce180..2416200 100644 --- a/tests/test_lookup.py +++ b/tests/test_lookup.py @@ -103,8 +103,8 @@ def test_browse_track_uri(config, playlist_item_mock, caplog): assert TrackUri.parse(results[0].uri).index == str(0) # Track should not have an audio URL at this stage - assert TrackUri.parse(results[0].uri).audio_url == "none_generated" + assert TrackUri.parse(results[0].uri).audio_url == "" # Also clear reference track's audio URI so that we can compare more easily - track_uri.audio_url = "none_generated" + track_uri.audio_url = "" assert results[0].uri == track_uri.uri diff --git a/tests/test_playback.py b/tests/test_playback.py index 856168a..f00a035 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -152,7 +152,7 @@ def test_change_track_enforces_skip_limit(provider): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=False): - track = models.Track(uri="pandora:track:test:::::") + track = models.Track(uri="pandora:track:test::::") assert provider.change_track(track) is False assert PlaylistItem.get_is_playable.call_count == 4 @@ -161,7 +161,7 @@ def test_change_track_enforces_skip_limit(provider): def test_change_track_handles_request_exceptions(config, caplog): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.request_exception_mock): - track = models.Track(uri="pandora:track:test:::::") + track = models.Track(uri="pandora:track:test::::") playback = conftest.get_backend(config).playback @@ -261,14 +261,14 @@ def test_is_playable_handles_request_exceptions(provider, caplog): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): with mock.patch.object(PlaylistItem, 'get_is_playable', conftest.request_exception_mock): - track = models.Track(uri="pandora:track:test:::::") + track = models.Track(uri="pandora:track:test::::") assert provider.change_track(track) is False assert 'Error checking if track is playable' in caplog.text() def test_translate_uri_returns_audio_url(provider): - assert provider.translate_uri("pandora:track:test::::::audio_url") == "audio_url" + assert provider.translate_uri("pandora:track:test:::::audio_url") == "audio_url" def test_auto_setup_only_called_once(provider): diff --git a/tests/test_uri.py b/tests/test_uri.py index 24726d2..179e924 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -84,8 +84,6 @@ def test_station_uri_parse(station_mock): def test_track_uri_from_track(playlist_item_mock): - playlist_item_mock.ad_token = conftest.MOCK_TRACK_AD_TOKEN - track_uri = TrackUri.from_track(playlist_item_mock) assert track_uri.uri == "pandora:" + \ @@ -95,15 +93,12 @@ def test_track_uri_from_track(playlist_item_mock): track_uri.quote(conftest.MOCK_TRACK_NAME) + ":" + \ track_uri.quote(conftest.MOCK_TRACK_DETAIL_URL) + ":" + \ track_uri.quote(conftest.MOCK_TRACK_ART_URL) + ":" + \ - track_uri.quote(conftest.MOCK_TRACK_AD_TOKEN) + ":" + \ track_uri.quote(conftest.MOCK_TRACK_AUDIO_HIGH) + ":" + \ track_uri.quote(0) def test_track_uri_parse(playlist_item_mock): - playlist_item_mock.ad_token = conftest.MOCK_TRACK_AD_TOKEN - track_uri = TrackUri.from_track(playlist_item_mock) obj = TrackUri.parse(track_uri.uri) @@ -116,7 +111,6 @@ def test_track_uri_parse(playlist_item_mock): assert obj.name == conftest.MOCK_TRACK_NAME assert obj.detail_url == conftest.MOCK_TRACK_DETAIL_URL assert obj.art_url == conftest.MOCK_TRACK_ART_URL - assert obj.ad_token == conftest.MOCK_TRACK_AD_TOKEN assert obj.audio_url == conftest.MOCK_TRACK_AUDIO_HIGH assert obj.uri == track_uri.uri From b1ed2b1e6aa1954d3c741bad801b2c3ce0b05a44 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 29 Oct 2015 06:32:39 +0200 Subject: [PATCH 007/311] Don't set audio_url when tracks are browsed. audio_url will be set later when URLS for dynamic playlists are generated. --- mopidy_pandora/library.py | 2 +- tests/test_lookup.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index a2ebb48..01226bc 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -32,7 +32,7 @@ def browse(self, uri): tracks.append(models.Ref.track(name=pandora_uri.name, uri=TrackUri(pandora_uri.station_id, pandora_uri.token, pandora_uri.name, pandora_uri.detail_url, pandora_uri.art_url, - "", index=str(i)).uri)) + index=str(i)).uri)) return tracks diff --git a/tests/test_lookup.py b/tests/test_lookup.py index 2416200..09ce180 100644 --- a/tests/test_lookup.py +++ b/tests/test_lookup.py @@ -103,8 +103,8 @@ def test_browse_track_uri(config, playlist_item_mock, caplog): assert TrackUri.parse(results[0].uri).index == str(0) # Track should not have an audio URL at this stage - assert TrackUri.parse(results[0].uri).audio_url == "" + assert TrackUri.parse(results[0].uri).audio_url == "none_generated" # Also clear reference track's audio URI so that we can compare more easily - track_uri.audio_url = "" + track_uri.audio_url = "none_generated" assert results[0].uri == track_uri.uri From 38f127eff2e22799e9f9f59077d08afbd35adbb6 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 29 Oct 2015 08:49:20 +0200 Subject: [PATCH 008/311] Refactor ad-processing code. --- mopidy_pandora/doubleclick.py | 2 +- mopidy_pandora/playback.py | 33 ++++++++-- mopidy_pandora/uri.py | 11 +++- tests/conftest.py | 117 ++++++++++++++++++++-------------- tests/test_playback.py | 34 ++++++++-- tests/test_uri.py | 12 ++++ 6 files changed, 145 insertions(+), 64 deletions(-) diff --git a/mopidy_pandora/doubleclick.py b/mopidy_pandora/doubleclick.py index db716b9..1a60451 100644 --- a/mopidy_pandora/doubleclick.py +++ b/mopidy_pandora/doubleclick.py @@ -69,7 +69,7 @@ def process_click(self, method, track_uri): uri = PandoraUri.parse(track_uri) - if uri.token == "": + if uri.is_ad(): logger.info("Skipping event for advertisement") return True diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index ae0942e..90f6492 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -13,6 +13,8 @@ class PandoraPlaybackProvider(backend.PlaybackProvider): + SKIP_LIMIT = 3 + def __init__(self, audio, backend): super(PandoraPlaybackProvider, self).__init__(audio, backend) self._station = None @@ -22,7 +24,7 @@ def __init__(self, audio, backend): # TODO: add gapless playback when it is supported in Mopidy > 1.1 # self.audio.set_about_to_finish_callback(self.callback).get() - # def callback(self): + # 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())).get() @@ -47,14 +49,16 @@ def change_track(self, track): if track.uri is None: return False + track_uri = TrackUri.parse(track.uri) + station_id = PandoraUri.parse(track.uri).station_id - if not self._station or station_id != self._station.id: + if not self._station or (station_id != self._station.id and not track_uri.is_ad()): self._station = self.backend.api.get_station(station_id) self._station_iter = iterate_forever(self._station.get_playlist) try: - next_track = self.get_next_track(TrackUri.parse(track.uri).index) + next_track = self.get_next_track(track_uri.index) if next_track: return super(PandoraPlaybackProvider, self).change_track(next_track) except requests.exceptions.RequestException as e: @@ -67,9 +71,9 @@ def get_next_track(self, index): for track in self._station_iter: try: - if track.is_ad and self.backend.play_ads: - track = self.backend.api.get_ad_item(track.ad_token) - track.register_ad(self._station.id) + track = self.process_track(track) + if track is None: + return None is_playable = track.audio_url and track.get_is_playable() except requests.exceptions.RequestException as e: @@ -86,7 +90,7 @@ def get_next_track(self, index): else: consecutive_track_skips += 1 logger.warning("Track with uri '%s' is not playable.", TrackUri.from_track(track).uri) - if consecutive_track_skips >= 4: + if consecutive_track_skips >= self.SKIP_LIMIT: logger.error('Unplayable track skip limit exceeded!') return None @@ -95,6 +99,21 @@ def get_next_track(self, index): def translate_uri(self, uri): return PandoraUri.parse(uri).audio_url + def process_track(self, track): + if track.is_ad: + track = self.process_ad(track) + return track + + def process_ad(self, track): + if self.backend.play_ads: + track = self.backend.api.get_ad_item(track.ad_token) + track.register_ad(self._station.id) + else: + logger.info('Skipping advertisement...') + return None + + return track + class EventSupportPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index f48f6f1..54556d0 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -1,6 +1,7 @@ import logging import urllib +from pandora.models.pandora import AdItem, PlaylistItem logger = logging.getLogger(__name__) @@ -72,6 +73,7 @@ def uri(self): class TrackUri(StationUri): scheme = 'track' + ADVERTISEMENT_TOKEN = "advertisement-none" def __init__(self, station_id, track_token, name, detail_url, art_url, audio_url='none_generated', index=0): super(TrackUri, self).__init__(station_id, track_token, name, detail_url, art_url) @@ -81,11 +83,13 @@ def __init__(self, station_id, track_token, name, detail_url, art_url, audio_url @classmethod def from_track(cls, track, index=0): - if not track.is_ad: + if isinstance(track, PlaylistItem): return TrackUri(track.station_id, track.track_token, track.song_name, track.song_detail_url, track.album_art_url, track.audio_url, index) + elif isinstance(track, AdItem): + return TrackUri(None, cls.ADVERTISEMENT_TOKEN, track.title, None, None, track.audio_url, index) else: - return TrackUri(None, None, track.title, None, None, track.audio_url, index) + raise NotImplementedError("Unsupported playlist item type") @property def uri(self): @@ -94,3 +98,6 @@ def uri(self): self.quote(self.audio_url), self.quote(self.index), ) + + def is_ad(self): + return self.token == self.ADVERTISEMENT_TOKEN diff --git a/tests/conftest.py b/tests/conftest.py index e288f65..2a5258d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -108,47 +108,11 @@ def get_station_mock(self, station_token): @pytest.fixture(scope="session") def playlist_result_mock(): mock_result = {"stat": "ok", - "result": { - "items": [{ - "trackToken": MOCK_TRACK_TOKEN, - "artistName": "Mock Artist Name", - "albumName": "Mock Album Name", - "albumArtUrl": MOCK_TRACK_ART_URL, - "audioUrlMap": { - "highQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_HIGH, - "protocol": "http" - }, - "mediumQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_MED, - "protocol": "http" - }, - "lowQuality": { - "bitrate": "32", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_LOW, - "protocol": "http" - } - }, - "songName": MOCK_TRACK_NAME, - "songDetailUrl": MOCK_TRACK_DETAIL_URL, - "stationId": MOCK_STATION_ID, - "songRating": 0, - "adToken": None, }]}} - - return mock_result - - -@pytest.fixture(scope="session") -def ad_metadata_result_mock(): - mock_result = {"stat": "ok", - "result": { - "title": MOCK_TRACK_TOKEN, - "companyName": "Mock Company Name", + "result": dict(items=[{ + "trackToken": MOCK_TRACK_TOKEN, + "artistName": "Mock Artist Name", + "albumName": "Mock Album Name", + "albumArtUrl": MOCK_TRACK_ART_URL, "audioUrlMap": { "highQuality": { "bitrate": "64", @@ -169,12 +133,66 @@ def ad_metadata_result_mock(): "protocol": "http" } }, - "adTrackingTokens": { - MOCK_TRACK_AD_TOKEN, - MOCK_TRACK_AD_TOKEN, - MOCK_TRACK_AD_TOKEN + "songName": MOCK_TRACK_NAME, + "songDetailUrl": MOCK_TRACK_DETAIL_URL, + "stationId": MOCK_STATION_ID, + "songRating": 0, + "adToken": None, }, + + # Also add an advertisement to the playlist. + {'trackToken': None, 'artistName': None, 'albumName': None, 'albumArtUrl': None, 'audioUrlMap': { + "highQuality": { + "bitrate": "64", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_HIGH, + "protocol": "http" + }, + "mediumQuality": { + "bitrate": "64", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_MED, + "protocol": "http" + }, + "lowQuality": { + "bitrate": "32", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_LOW, + "protocol": "http" + } + }, 'songName': None, 'songDetailUrl': None, 'stationId': None, 'songRating': None, + 'adToken': MOCK_TRACK_AD_TOKEN} + ])} + + return mock_result + + +@pytest.fixture(scope="session") +def ad_metadata_result_mock(): + mock_result = {"stat": "ok", + "result": dict(title=MOCK_TRACK_NAME, companyName="Mock Company Name", audioUrlMap={ + "highQuality": { + "bitrate": "64", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_HIGH, + "protocol": "http" + }, + "mediumQuality": { + "bitrate": "64", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_MED, + "protocol": "http" }, - }} + "lowQuality": { + "bitrate": "32", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_LOW, + "protocol": "http" + } + }, adTrackingTokens={ + MOCK_TRACK_AD_TOKEN, + MOCK_TRACK_AD_TOKEN, + MOCK_TRACK_AD_TOKEN + })} return mock_result @@ -200,12 +218,17 @@ def playlist_item_mock(): config()).api, playlist_result_mock()["result"]["items"][0]) -@pytest.fixture +@pytest.fixture(scope="session") def ad_item_mock(): return AdItem.from_json(get_backend( config()).api, ad_metadata_result_mock()["result"]) +@pytest.fixture +def get_ad_item_mock(self, token): + return ad_item_mock() + + @pytest.fixture(scope="session") def station_list_result_mock(): mock_result = {"stat": "ok", diff --git a/tests/test_playback.py b/tests/test_playback.py index f00a035..9f8dedb 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -11,7 +11,7 @@ from pandora.errors import PandoraException -from pandora.models.pandora import PlaylistItem, Station +from pandora.models.pandora import PlaylistItem, PlaylistModel, Station import pytest @@ -148,14 +148,32 @@ def test_change_track(audio_mock, provider): conftest.MOCK_DEFAULT_AUDIO_QUALITY)) +def test_change_track_skip_ads(caplog, provider): + with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): + with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): + with mock.patch.object(PlaylistModel, 'get_is_playable', return_value=True): + with mock.patch.object(MopidyPandoraAPIClient, 'get_ad_item', conftest.get_ad_item_mock): + with mock.patch.object(MopidyPandoraAPIClient, 'register_ad', mock.Mock()): + track = models.Track(uri=TrackUri.from_track(conftest.ad_item_mock()).uri) + + provider.backend.play_ads = False + + assert provider.change_track(track) is True + assert provider.change_track(track) is False + # Check that skipping of ads is caught and logged + assert 'Skipping advertisement...' in caplog.text() + + def test_change_track_enforces_skip_limit(provider): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=False): - track = models.Track(uri="pandora:track:test::::") + with mock.patch.object(MopidyPandoraAPIClient, 'get_ad_item', conftest.get_ad_item_mock): + with mock.patch.object(MopidyPandoraAPIClient, 'register_ad', mock.Mock()): + track = models.Track(uri="pandora:track:test::::") - assert provider.change_track(track) is False - assert PlaylistItem.get_is_playable.call_count == 4 + assert provider.change_track(track) is False + assert PlaylistItem.get_is_playable.call_count == PandoraPlaybackProvider.SKIP_LIMIT - 1 def test_change_track_handles_request_exceptions(config, caplog): @@ -261,10 +279,12 @@ def test_is_playable_handles_request_exceptions(provider, caplog): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): with mock.patch.object(PlaylistItem, 'get_is_playable', conftest.request_exception_mock): - track = models.Track(uri="pandora:track:test::::") + with mock.patch.object(MopidyPandoraAPIClient, 'get_ad_item', conftest.get_ad_item_mock): + with mock.patch.object(MopidyPandoraAPIClient, 'register_ad', mock.Mock()): + track = models.Track(uri="pandora:track:test::::") - assert provider.change_track(track) is False - assert 'Error checking if track is playable' in caplog.text() + assert provider.change_track(track) is False + assert 'Error checking if track is playable' in caplog.text() def test_translate_uri_returns_audio_url(provider): diff --git a/tests/test_uri.py b/tests/test_uri.py index 179e924..4fd06cf 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -97,6 +97,18 @@ def test_track_uri_from_track(playlist_item_mock): track_uri.quote(0) +def test_track_uri_from_track_for_ads(ad_item_mock): + + track_uri = TrackUri.from_track(ad_item_mock) + + assert track_uri.uri == "pandora:" + \ + track_uri.quote(conftest.MOCK_TRACK_SCHEME) + "::" + \ + track_uri.quote(TrackUri.ADVERTISEMENT_TOKEN) + ":" + \ + track_uri.quote(conftest.MOCK_TRACK_NAME) + ":::" + \ + track_uri.quote(conftest.MOCK_TRACK_AUDIO_HIGH) + ":" + \ + track_uri.quote(0) + + def test_track_uri_parse(playlist_item_mock): track_uri = TrackUri.from_track(playlist_item_mock) From ab9b2d3a22d05bd183084a1ca872684abcffb44e Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 29 Oct 2015 11:45:12 +0200 Subject: [PATCH 009/311] Refactor ad-processing code. --- mopidy_pandora/playback.py | 3 +++ tests/test_playback.py | 16 +++++++++++++++- tests/test_uri.py | 13 +++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 90f6492..9c32224 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -72,10 +72,12 @@ def get_next_track(self, index): for track in self._station_iter: try: track = self.process_track(track) + if track is None: return None is_playable = track.audio_url and track.get_is_playable() + except requests.exceptions.RequestException as e: is_playable = False logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) @@ -102,6 +104,7 @@ def translate_uri(self, uri): def process_track(self, track): if track.is_ad: track = self.process_ad(track) + return track def process_ad(self, track): diff --git a/tests/test_playback.py b/tests/test_playback.py index 9f8dedb..2e9ca44 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -11,7 +11,7 @@ from pandora.errors import PandoraException -from pandora.models.pandora import PlaylistItem, PlaylistModel, Station +from pandora.models.pandora import AdItem, PlaylistItem, PlaylistModel, Station import pytest @@ -164,6 +164,20 @@ def test_change_track_skip_ads(caplog, provider): assert 'Skipping advertisement...' in caplog.text() +def test_change_track_processes_ads(provider): + with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): + with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): + with mock.patch.object(PlaylistModel, 'get_is_playable', return_value=True): + with mock.patch.object(MopidyPandoraAPIClient, 'get_ad_item', conftest.get_ad_item_mock): + with mock.patch.object(AdItem, 'register_ad', mock.Mock()) as mock_register: + track = models.Track(uri=TrackUri.from_track(conftest.ad_item_mock()).uri) + + assert provider.change_track(track) is True + assert provider.change_track(track) is True + # Check that ads are registered + mock_register.assert_called_once_with(conftest.MOCK_STATION_ID) + + def test_change_track_enforces_skip_limit(provider): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): diff --git a/tests/test_uri.py b/tests/test_uri.py index 4fd06cf..2e9c5c2 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -126,3 +126,16 @@ def test_track_uri_parse(playlist_item_mock): assert obj.audio_url == conftest.MOCK_TRACK_AUDIO_HIGH assert obj.uri == track_uri.uri + + +def test_track_uri_is_ad(playlist_item_mock, ad_item_mock): + + track_uri = TrackUri.from_track(ad_item_mock) + obj = TrackUri.parse(track_uri.uri) + + assert obj.is_ad() + + track_uri = TrackUri.from_track(playlist_item_mock) + obj = TrackUri.parse(track_uri.uri) + + assert not obj.is_ad() From 596c1c2f9ad0e52ecc16bb7bec5b23a955100a6c Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 29 Oct 2015 14:55:13 +0200 Subject: [PATCH 010/311] Fix incorrect reference in README to 'shuffle' mode instead of 'random'. --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 3222ff5..b08641b 100644 --- a/README.rst +++ b/README.rst @@ -87,7 +87,7 @@ Usage Mopidy needs `dynamic playlist `_ and `core extensions `_ support to properly support Pandora. In the meantime, Mopidy-Pandora represents each Pandora station as a separate playlist. The Playlist needs to be played **in repeat mode** -and **consume**, **shuffle**, and **single** should be turned off. Mopidy-Pandora will set this up automatically unless +and **consume**, **random**, and **single** should be turned off. Mopidy-Pandora will set this up automatically unless you set the **auto_setup** config parameter to 'false'. Each time a track is played, the next dynamic track for that Pandora station will be played. The playlist will consist @@ -114,7 +114,7 @@ v0.1.7 (UNRELEASED) - Add support for playing advertisements. This should prevent free Pandora accounts from locking up. - Configuration parameter 'auto_set_repeat' has been renamed to 'auto_setup' - please update your Mopidy configuration file. - Now resumes playback after a track has been rated. -- Enhanced auto_setup routines to ensure that 'consume', 'shuffle', and 'single' modes are disabled as well. +- Enhanced auto_setup routines to ensure that 'consume', 'random', and 'single' modes are disabled as well. - Optimized auto_setup routines: now only called when the Mopidy tracklist changes. v0.1.6 (Oct 26, 2015) From ee16b15b2f9625434cf6a518ed5650d8a1c83d05 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 29 Oct 2015 15:25:10 +0200 Subject: [PATCH 011/311] Add todo to handle shuffling of tracks. --- mopidy_pandora/doubleclick.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy_pandora/doubleclick.py b/mopidy_pandora/doubleclick.py index 1a60451..8c8695a 100644 --- a/mopidy_pandora/doubleclick.py +++ b/mopidy_pandora/doubleclick.py @@ -49,6 +49,8 @@ def on_change_track(self, active_track_uri, new_track_uri): new_track_index = int(PandoraUri.parse(new_track_uri).index) active_track_index = int(PandoraUri.parse(active_track_uri).index) + # TODO: the order of the tracks will no longer be sequential if the user has 'shuffled' the tracklist + # Need to find a better approach for determining whether 'next' or 'previous' was clicked. if new_track_index > active_track_index or new_track_index == 0 and active_track_index == 2: return self.process_click(self.on_pause_next_click, active_track_uri) From 3cf01b96ec5661162007486804684cc8baddefc3 Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 30 Oct 2015 09:13:07 +0200 Subject: [PATCH 012/311] Update client constructor for new ad_support in pydora. --- mopidy_pandora/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index b396ba0..092558c 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -16,10 +16,10 @@ class MopidyPandoraAPIClient(pandora.APIClient): """ def __init__(self, transport, partner_user, partner_password, device, - default_audio_quality=pandora.BaseAPIClient.MED_AUDIO_QUALITY): + default_audio_quality=pandora.BaseAPIClient.MED_AUDIO_QUALITY, ad_support_enabled=True): super(MopidyPandoraAPIClient, self).__init__(transport, partner_user, partner_password, device, - default_audio_quality) + default_audio_quality, ad_support_enabled) self._station_list = [] def get_station_list(self): From 7533d8f39636e481f67d52f52da9acf7f10ace6a Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 30 Oct 2015 16:07:01 +0200 Subject: [PATCH 013/311] Add py.test to testenv whitelist to suppress warning message for external command. --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 6d91e65..f597090 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ envlist = py27, flake8 [testenv] sitepackages = true +whitelist_externals=py.test install_command = pip install --allow-unverified=mopidy --pre {opts} {packages} commands = py.test \ From ee1636f5c51a58462737d11cbbd7ab5152ce798b Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 31 Oct 2015 17:53:46 +0200 Subject: [PATCH 014/311] Fix handling of ad tracks to align with regular tracks. --- mopidy_pandora/playback.py | 6 ++- mopidy_pandora/uri.py | 2 +- tests/conftest.py | 49 ++++++++++++++--------- tests/{test_lookup.py => test_library.py} | 20 +++++++++ 4 files changed, 54 insertions(+), 23 deletions(-) rename tests/{test_lookup.py => test_library.py} (83%) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 9c32224..3b43d65 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -53,7 +53,9 @@ def change_track(self, track): station_id = PandoraUri.parse(track.uri).station_id - if not self._station or (station_id != self._station.id and not track_uri.is_ad()): + # TODO: should be able to perform check on is_ad() once dynamic tracklist support is available + # if not self._station or (not track.is_ad() and station_id != self._station.id): + if not self._station or (station_id != '' and station_id != self._station.id): self._station = self.backend.api.get_station(station_id) self._station_iter = iterate_forever(self._station.get_playlist) @@ -113,7 +115,7 @@ def process_ad(self, track): track.register_ad(self._station.id) else: logger.info('Skipping advertisement...') - return None + track = None return track diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 54556d0..7b3fd20 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -87,7 +87,7 @@ def from_track(cls, track, index=0): return TrackUri(track.station_id, track.track_token, track.song_name, track.song_detail_url, track.album_art_url, track.audio_url, index) elif isinstance(track, AdItem): - return TrackUri(None, cls.ADVERTISEMENT_TOKEN, track.title, None, None, track.audio_url, index) + return TrackUri('', cls.ADVERTISEMENT_TOKEN, track.title, '', '', track.audio_url, index) else: raise NotImplementedError("Unsupported playlist item type") diff --git a/tests/conftest.py b/tests/conftest.py index 2a5258d..939c027 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -140,27 +140,36 @@ def playlist_result_mock(): "adToken": None, }, # Also add an advertisement to the playlist. - {'trackToken': None, 'artistName': None, 'albumName': None, 'albumArtUrl': None, 'audioUrlMap': { - "highQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_HIGH, - "protocol": "http" + { + 'trackToken': None, + 'artistName': None, + 'albumName': None, + 'albumArtUrl': None, + 'audioUrlMap': { + "highQuality": { + "bitrate": "64", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_HIGH, + "protocol": "http" + }, + "mediumQuality": { + "bitrate": "64", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_MED, + "protocol": "http" + }, + "lowQuality": { + "bitrate": "32", + "encoding": "aacplus", + "audioUrl": MOCK_TRACK_AUDIO_LOW, + "protocol": "http" + } }, - "mediumQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_MED, - "protocol": "http" - }, - "lowQuality": { - "bitrate": "32", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_LOW, - "protocol": "http" - } - }, 'songName': None, 'songDetailUrl': None, 'stationId': None, 'songRating': None, - 'adToken': MOCK_TRACK_AD_TOKEN} + 'songName': None, + 'songDetailUrl': None, + 'stationId': None, + 'songRating': None, + 'adToken': MOCK_TRACK_AD_TOKEN} ])} return mock_result diff --git a/tests/test_lookup.py b/tests/test_library.py similarity index 83% rename from tests/test_lookup.py rename to tests/test_library.py index 09ce180..173721b 100644 --- a/tests/test_lookup.py +++ b/tests/test_library.py @@ -41,6 +41,26 @@ def test_lookup_of_track_uri(config, playlist_item_mock): assert track.album.uri == track_uri.detail_url assert next(iter(track.album.images)) == track_uri.art_url +# For now, ad tracks will appear exactly as normal tracks in the Mopidy tracklist. +# This test should fail when dynamic tracklist support ever becomes available. +def test_lookup_of_ad_track_uri(config, ad_item_mock): + + backend = conftest.get_backend(config) + + track_uri = TrackUri.from_track(ad_item_mock) + results = backend.library.lookup(track_uri.uri) + + assert len(results) == 1 + + track = results[0] + + assert track.name == track_uri.name + assert track.uri == track_uri.uri + assert next(iter(track.artists)).name == "Pandora" + assert track.album.name == track_uri.name + assert track.album.uri == track_uri.detail_url + assert next(iter(track.album.images)) == track_uri.art_url + def test_browse_directory_uri(config, caplog): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): From 2bee40bac7443bc1dd84a02732033930c5040638 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 31 Oct 2015 17:57:43 +0200 Subject: [PATCH 015/311] Fix flake8 error. --- tests/test_library.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_library.py b/tests/test_library.py index 173721b..736c459 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -41,6 +41,7 @@ def test_lookup_of_track_uri(config, playlist_item_mock): assert track.album.uri == track_uri.detail_url assert next(iter(track.album.images)) == track_uri.art_url + # For now, ad tracks will appear exactly as normal tracks in the Mopidy tracklist. # This test should fail when dynamic tracklist support ever becomes available. def test_lookup_of_ad_track_uri(config, ad_item_mock): From 1945bef4727997f3110d3e90842fb159416bf862 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 31 Oct 2015 18:11:08 +0200 Subject: [PATCH 016/311] Update version number. --- mopidy_pandora/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index f97605d..ba3b80d 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -9,7 +9,7 @@ from pandora import BaseAPIClient -__version__ = '0.1.6' +__version__ = '0.1.7' logger = logging.getLogger(__name__) From 7887cbcd537a288c8a66b891315f899b86b1f88b Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 31 Oct 2015 19:01:18 +0200 Subject: [PATCH 017/311] Increment version number. --- README.rst | 6 +++++- mopidy_pandora/__init__.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index b08641b..afeeb5b 100644 --- a/README.rst +++ b/README.rst @@ -108,10 +108,14 @@ Project resources Changelog ========= -v0.1.7 (UNRELEASED) +v0.1.8 (UNRELEASED) ---------------------------------------- - Add support for playing advertisements. This should prevent free Pandora accounts from locking up. + +v0.1.7 (Oct 31, 2015) +---------------------------------------- + - Configuration parameter 'auto_set_repeat' has been renamed to 'auto_setup' - please update your Mopidy configuration file. - Now resumes playback after a track has been rated. - Enhanced auto_setup routines to ensure that 'consume', 'random', and 'single' modes are disabled as well. diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index ba3b80d..afb9f31 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -9,7 +9,7 @@ from pandora import BaseAPIClient -__version__ = '0.1.7' +__version__ = '0.1.8' logger = logging.getLogger(__name__) From 8dbf582d6263db483edc74de72cedaf6c7baedb0 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 3 Nov 2015 07:45:12 +0200 Subject: [PATCH 018/311] Update README to ready more easily. --- README.rst | 14 ++++++-------- mopidy_pandora/playback.py | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index c746651..1c088f8 100644 --- a/README.rst +++ b/README.rst @@ -35,8 +35,7 @@ Or, if available, install the Debian/Ubuntu package from `apt.mopidy.com Configuration ============= -Before starting Mopidy, you must add configuration for -Mopidy-Pandora to your Mopidy configuration file:: +Before starting Mopidy, you must add the configuration settings for Mopidy-Pandora to your Mopidy configuration file:: [pandora] enabled = true @@ -78,7 +77,7 @@ and web extensions: - on_pause_next_click - click pause and then next in quick succession. Calls event and skips to next song. - on_pause_previous_click - click pause and then previous in quick succession. Calls event and skips to next song. -The supported events are: thumbs_up, thumbs_down, sleep, add_artist_bookmark, add_song_bookmark +The supported events are: 'thumbs_up', 'thumbs_down', 'sleep', 'add_artist_bookmark', and 'add_song_bookmark'. Usage ===== @@ -89,11 +88,10 @@ Mopidy-Pandora represents each Pandora station as a separate playlist. The Playl and **consume**, **random**, and **single** should be turned off. Mopidy-Pandora will set this up automatically unless you set the **auto_setup** config parameter to 'false'. -Each time a track is played, the next dynamic track for that Pandora station will be played. The playlist will consist -of a single track unless the experimental ratings support is enabled. With ratings support enabled, the playlist will -contain three tracks. These are just used to determine whether the user clicked on the 'previous' or 'next' playback -buttons, and all three tracks point to the same dynamic track for that Pandora station (i.e. it does not matter which -one you select to play). +Each time a track is played, the next dynamic track for that Pandora station will be retrieved and played. If ratings +support is enabled, Mopidy-Pandora will add three tracks to the playlist for each dynamic track. These are just used to +determine whether the user clicked on the 'previous' or 'next' playback buttons, and all three tracks point to the same +dynamic track for that Pandora station (i.e. it does not matter which one you select to play). Project resources diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 88d94ca..2d6c58e 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -24,7 +24,7 @@ def __init__(self, audio, backend): # TODO: add gapless playback when it is supported in Mopidy > 1.1 # self.audio.set_about_to_finish_callback(self.callback).get() - # def callback(self): + # 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())).get() From f3adc0dc2e0dbe08aebb005a3ddf4417019b9c16 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 22 Nov 2015 10:29:49 +0200 Subject: [PATCH 019/311] Re-align with latest proposed implementation of pydora 1.6. --- README.rst | 2 +- mopidy_pandora/backend.py | 2 -- mopidy_pandora/client.py | 4 ++-- mopidy_pandora/playback.py | 22 --------------------- tests/test_playback.py | 40 ++++---------------------------------- 5 files changed, 7 insertions(+), 63 deletions(-) diff --git a/README.rst b/README.rst index afeeb5b..f20cc04 100644 --- a/README.rst +++ b/README.rst @@ -111,7 +111,7 @@ Changelog v0.1.8 (UNRELEASED) ---------------------------------------- -- Add support for playing advertisements. This should prevent free Pandora accounts from locking up. +- Add support for handling advertisements which was introduced in version 1.6 of the pydora API. v0.1.7 (Oct 31, 2015) ---------------------------------------- diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index bd0d779..1fa60bb 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -45,8 +45,6 @@ def __init__(self, config, audio): else: self.playback = PandoraPlaybackProvider(audio=audio, backend=self) - self.play_ads = self._config['ad_support_enabled'] - self.uri_schemes = ['pandora'] def on_start(self): diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 092558c..b396ba0 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -16,10 +16,10 @@ class MopidyPandoraAPIClient(pandora.APIClient): """ def __init__(self, transport, partner_user, partner_password, device, - default_audio_quality=pandora.BaseAPIClient.MED_AUDIO_QUALITY, ad_support_enabled=True): + default_audio_quality=pandora.BaseAPIClient.MED_AUDIO_QUALITY): super(MopidyPandoraAPIClient, self).__init__(transport, partner_user, partner_password, device, - default_audio_quality, ad_support_enabled) + default_audio_quality) self._station_list = [] def get_station_list(self): diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 3b43d65..19b23e0 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -73,13 +73,7 @@ def get_next_track(self, index): for track in self._station_iter: try: - track = self.process_track(track) - - if track is None: - return None - is_playable = track.audio_url and track.get_is_playable() - except requests.exceptions.RequestException as e: is_playable = False logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) @@ -103,22 +97,6 @@ def get_next_track(self, index): def translate_uri(self, uri): return PandoraUri.parse(uri).audio_url - def process_track(self, track): - if track.is_ad: - track = self.process_ad(track) - - return track - - def process_ad(self, track): - if self.backend.play_ads: - track = self.backend.api.get_ad_item(track.ad_token) - track.register_ad(self._station.id) - else: - logger.info('Skipping advertisement...') - track = None - - return track - class EventSupportPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): diff --git a/tests/test_playback.py b/tests/test_playback.py index 2e9ca44..eb02a17 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -11,7 +11,7 @@ from pandora.errors import PandoraException -from pandora.models.pandora import AdItem, PlaylistItem, PlaylistModel, Station +from pandora.models.pandora import PlaylistItem, Station import pytest @@ -148,46 +148,14 @@ def test_change_track(audio_mock, provider): conftest.MOCK_DEFAULT_AUDIO_QUALITY)) -def test_change_track_skip_ads(caplog, provider): - with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): - with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): - with mock.patch.object(PlaylistModel, 'get_is_playable', return_value=True): - with mock.patch.object(MopidyPandoraAPIClient, 'get_ad_item', conftest.get_ad_item_mock): - with mock.patch.object(MopidyPandoraAPIClient, 'register_ad', mock.Mock()): - track = models.Track(uri=TrackUri.from_track(conftest.ad_item_mock()).uri) - - provider.backend.play_ads = False - - assert provider.change_track(track) is True - assert provider.change_track(track) is False - # Check that skipping of ads is caught and logged - assert 'Skipping advertisement...' in caplog.text() - - -def test_change_track_processes_ads(provider): - with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): - with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): - with mock.patch.object(PlaylistModel, 'get_is_playable', return_value=True): - with mock.patch.object(MopidyPandoraAPIClient, 'get_ad_item', conftest.get_ad_item_mock): - with mock.patch.object(AdItem, 'register_ad', mock.Mock()) as mock_register: - track = models.Track(uri=TrackUri.from_track(conftest.ad_item_mock()).uri) - - assert provider.change_track(track) is True - assert provider.change_track(track) is True - # Check that ads are registered - mock_register.assert_called_once_with(conftest.MOCK_STATION_ID) - - def test_change_track_enforces_skip_limit(provider): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=False): - with mock.patch.object(MopidyPandoraAPIClient, 'get_ad_item', conftest.get_ad_item_mock): - with mock.patch.object(MopidyPandoraAPIClient, 'register_ad', mock.Mock()): - track = models.Track(uri="pandora:track:test::::") + track = models.Track(uri="pandora:track:test::::") - assert provider.change_track(track) is False - assert PlaylistItem.get_is_playable.call_count == PandoraPlaybackProvider.SKIP_LIMIT - 1 + assert provider.change_track(track) is False + assert PlaylistItem.get_is_playable.call_count == PandoraPlaybackProvider.SKIP_LIMIT def test_change_track_handles_request_exceptions(config, caplog): From 8b4db71a329b2527edd622ba924dbc6fb6f5cd93 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 22 Nov 2015 10:31:26 +0200 Subject: [PATCH 020/311] Re-align with latest proposed implementation of pydora 1.6. --- tests/test_playback.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_playback.py b/tests/test_playback.py index eb02a17..df4751c 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -261,12 +261,10 @@ def test_is_playable_handles_request_exceptions(provider, caplog): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): with mock.patch.object(PlaylistItem, 'get_is_playable', conftest.request_exception_mock): - with mock.patch.object(MopidyPandoraAPIClient, 'get_ad_item', conftest.get_ad_item_mock): - with mock.patch.object(MopidyPandoraAPIClient, 'register_ad', mock.Mock()): - track = models.Track(uri="pandora:track:test::::") + track = models.Track(uri="pandora:track:test::::") - assert provider.change_track(track) is False - assert 'Error checking if track is playable' in caplog.text() + assert provider.change_track(track) is False + assert 'Error checking if track is playable' in caplog.text() def test_translate_uri_returns_audio_url(provider): From b9fdd024ea1f36c7f328b83afa1f1c3dea55fe4c Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 23 Nov 2015 05:01:32 +0200 Subject: [PATCH 021/311] Force Mopidy to stop if track skip limit is exceeded. --- mopidy_pandora/playback.py | 26 +++++++++++++++++++++----- mopidy_pandora/rpc.py | 4 ++++ tests/conftest.py | 2 +- tests/test_playback.py | 7 +++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 2d6c58e..e572856 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -21,6 +21,10 @@ def __init__(self, audio, backend): self._station_iter = None self.active_track_uri = None + # 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).get() @@ -62,6 +66,7 @@ def change_track(self, track): try: next_track = self.get_next_track(track_uri.index) if next_track: + self.consecutive_track_skips = 0 return super(PandoraPlaybackProvider, self).change_track(next_track) except requests.exceptions.RequestException as e: logger.error('Error changing track: %s', encoding.locale_decode(e)) @@ -69,7 +74,6 @@ def change_track(self, track): return False def get_next_track(self, index): - consecutive_track_skips = 0 for track in self._station_iter: try: @@ -83,17 +87,29 @@ def get_next_track(self, index): logger.info("Up next: '%s' by %s", track.song_name, track.artist_name) return models.Track(uri=self.active_track_uri) else: - consecutive_track_skips += 1 - logger.warning("Track with uri '%s' is not playable.", TrackUri.from_track(track).uri) - if consecutive_track_skips >= self.SKIP_LIMIT: - logger.error('Unplayable track skip limit exceeded!') + logger.warning("Audio URI for track '%s' cannot be played.", TrackUri.from_track(track).uri) + if self.increment_skip_exceeds_limit(): return None + logger.warning("No tracks left in playlist") + if self.increment_skip_exceeds_limit(): + return None + return None def translate_uri(self, uri): return PandoraUri.parse(uri).audio_url + def increment_skip_exceeds_limit(self): + self.consecutive_track_skips += 1 + + if self.consecutive_track_skips >= self.SKIP_LIMIT: + logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) + self.backend.rpc_client.stop_playback() + return True + + return False + class EventSupportPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/rpc.py index 2a1b728..647d709 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/rpc.py @@ -42,3 +42,7 @@ def get_current_track_uri(self): def resume_playback(self): self._do_rpc('core.playback.resume') + + def stop_playback(self): + + self._do_rpc('core.playback.stop') diff --git a/tests/conftest.py b/tests/conftest.py index c710e17..94a5377 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -196,7 +196,7 @@ class TransportCallTestNotImplemented(Exception): @pytest.fixture -def rpc_call_not_implemented_mock(method, params=None): +def rpc_call_not_implemented_mock(method, params=''): raise RPCCallTestNotImplemented(method + "(" + params + ")") diff --git a/tests/test_playback.py b/tests/test_playback.py index df4751c..8e89ef9 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -154,7 +154,10 @@ def test_change_track_enforces_skip_limit(provider): with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=False): track = models.Track(uri="pandora:track:test::::") + provider.backend.rpc_client.stop_playback = mock.PropertyMock() + assert provider.change_track(track) is False + provider.backend.rpc_client.stop_playback.assert_called_once_with() assert PlaylistItem.get_is_playable.call_count == PandoraPlaybackProvider.SKIP_LIMIT @@ -164,6 +167,8 @@ def test_change_track_handles_request_exceptions(config, caplog): track = models.Track(uri="pandora:track:test::::") playback = conftest.get_backend(config).playback + playback.backend.rpc_client._do_rpc = mock.PropertyMock() + playback.backend.rpc_client.stop_playback = mock.PropertyMock() assert playback.change_track(track) is False assert 'Error changing track' in caplog.text() @@ -263,6 +268,8 @@ def test_is_playable_handles_request_exceptions(provider, caplog): with mock.patch.object(PlaylistItem, 'get_is_playable', conftest.request_exception_mock): track = models.Track(uri="pandora:track:test::::") + provider.backend.rpc_client.stop_playback = mock.PropertyMock() + assert provider.change_track(track) is False assert 'Error checking if track is playable' in caplog.text() From d04de7c952e9444878db10a2a0ea7e6ab1481a75 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 23 Nov 2015 05:07:28 +0200 Subject: [PATCH 022/311] Force Mopidy to stop if track skip limit is exceeded. --- README.rst | 15 +++++++-------- mopidy_pandora/playback.py | 26 +++++++++++++++++++++----- mopidy_pandora/rpc.py | 4 ++++ tests/conftest.py | 2 +- tests/test_playback.py | 7 +++++++ 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/README.rst b/README.rst index f20cc04..a2f9bd1 100644 --- a/README.rst +++ b/README.rst @@ -35,8 +35,7 @@ Or, if available, install the Debian/Ubuntu package from `apt.mopidy.com Configuration ============= -Before starting Mopidy, you must add configuration for -Mopidy-Pandora to your Mopidy configuration file:: +Before starting Mopidy, you must add the configuration settings for Mopidy-Pandora to your Mopidy configuration file:: [pandora] enabled = true @@ -79,7 +78,7 @@ and web extensions: - on_pause_next_click - click pause and then next in quick succession. Calls event and skips to next song. - on_pause_previous_click - click pause and then previous in quick succession. Calls event and skips to next song. -The supported events are: thumbs_up, thumbs_down, sleep, add_artist_bookmark, add_song_bookmark +The supported events are: 'thumbs_up', 'thumbs_down', 'sleep', 'add_artist_bookmark', and 'add_song_bookmark'. Usage ===== @@ -90,11 +89,10 @@ Mopidy-Pandora represents each Pandora station as a separate playlist. The Playl and **consume**, **random**, and **single** should be turned off. Mopidy-Pandora will set this up automatically unless you set the **auto_setup** config parameter to 'false'. -Each time a track is played, the next dynamic track for that Pandora station will be played. The playlist will consist -of a single track unless the experimental ratings support is enabled. With ratings support enabled, the playlist will -contain three tracks. These are just used to determine whether the user clicked on the 'previous' or 'next' playback -buttons, and all three tracks point to the same dynamic track for that Pandora station (i.e. it does not matter which -one you select to play). +Each time a track is played, the next dynamic track for that Pandora station will be retrieved and played. If ratings +support is enabled, Mopidy-Pandora will add three tracks to the playlist for each dynamic track. These are just used to +determine whether the user clicked on the 'previous' or 'next' playback buttons, and all three tracks point to the same +dynamic track for that Pandora station (i.e. it does not matter which one you select to play). Project resources @@ -112,6 +110,7 @@ v0.1.8 (UNRELEASED) ---------------------------------------- - Add support for handling advertisements which was introduced in version 1.6 of the pydora API. +- Enforce Mopidy to stop when skip limit is exceeded (workaround for https://github.com/mopidy/mopidy/issues/1221). v0.1.7 (Oct 31, 2015) ---------------------------------------- diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 19b23e0..846a905 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -21,6 +21,10 @@ def __init__(self, audio, backend): self._station_iter = None self.active_track_uri = None + # 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).get() @@ -62,6 +66,7 @@ def change_track(self, track): try: next_track = self.get_next_track(track_uri.index) if next_track: + self.consecutive_track_skips = 0 return super(PandoraPlaybackProvider, self).change_track(next_track) except requests.exceptions.RequestException as e: logger.error('Error changing track: %s', encoding.locale_decode(e)) @@ -69,7 +74,6 @@ def change_track(self, track): return False def get_next_track(self, index): - consecutive_track_skips = 0 for track in self._station_iter: try: @@ -86,17 +90,29 @@ def get_next_track(self, index): logger.info("Up next: '%s' by %s", track.song_name, track.artist_name) return models.Track(uri=self.active_track_uri) else: - consecutive_track_skips += 1 - logger.warning("Track with uri '%s' is not playable.", TrackUri.from_track(track).uri) - if consecutive_track_skips >= self.SKIP_LIMIT: - logger.error('Unplayable track skip limit exceeded!') + logger.warning("Audio URI for track '%s' cannot be played.", TrackUri.from_track(track).uri) + if self.increment_skip_exceeds_limit(): return None + logger.warning("No tracks left in playlist") + if self.increment_skip_exceeds_limit(): + return None + return None def translate_uri(self, uri): return PandoraUri.parse(uri).audio_url + def increment_skip_exceeds_limit(self): + self.consecutive_track_skips += 1 + + if self.consecutive_track_skips >= self.SKIP_LIMIT: + logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) + self.backend.rpc_client.stop_playback() + return True + + return False + class EventSupportPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/rpc.py index 2a1b728..647d709 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/rpc.py @@ -42,3 +42,7 @@ def get_current_track_uri(self): def resume_playback(self): self._do_rpc('core.playback.resume') + + def stop_playback(self): + + self._do_rpc('core.playback.stop') diff --git a/tests/conftest.py b/tests/conftest.py index 939c027..98cbeae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -272,7 +272,7 @@ class TransportCallTestNotImplemented(Exception): @pytest.fixture -def rpc_call_not_implemented_mock(method, params=None): +def rpc_call_not_implemented_mock(method, params=''): raise RPCCallTestNotImplemented(method + "(" + params + ")") diff --git a/tests/test_playback.py b/tests/test_playback.py index df4751c..8e89ef9 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -154,7 +154,10 @@ def test_change_track_enforces_skip_limit(provider): with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=False): track = models.Track(uri="pandora:track:test::::") + provider.backend.rpc_client.stop_playback = mock.PropertyMock() + assert provider.change_track(track) is False + provider.backend.rpc_client.stop_playback.assert_called_once_with() assert PlaylistItem.get_is_playable.call_count == PandoraPlaybackProvider.SKIP_LIMIT @@ -164,6 +167,8 @@ def test_change_track_handles_request_exceptions(config, caplog): track = models.Track(uri="pandora:track:test::::") playback = conftest.get_backend(config).playback + playback.backend.rpc_client._do_rpc = mock.PropertyMock() + playback.backend.rpc_client.stop_playback = mock.PropertyMock() assert playback.change_track(track) is False assert 'Error changing track' in caplog.text() @@ -263,6 +268,8 @@ def test_is_playable_handles_request_exceptions(provider, caplog): with mock.patch.object(PlaylistItem, 'get_is_playable', conftest.request_exception_mock): track = models.Track(uri="pandora:track:test::::") + provider.backend.rpc_client.stop_playback = mock.PropertyMock() + assert provider.change_track(track) is False assert 'Error checking if track is playable' in caplog.text() From 59f29848db6ba1bb91b0df08f004f8ba7c5ab452 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 23 Nov 2015 10:20:29 +0200 Subject: [PATCH 023/311] Move RPC command to its own thread. --- mopidy_pandora/playback.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index e572856..0b15fc5 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -88,11 +88,11 @@ def get_next_track(self, index): return models.Track(uri=self.active_track_uri) else: logger.warning("Audio URI for track '%s' cannot be played.", TrackUri.from_track(track).uri) - if self.increment_skip_exceeds_limit(): + if self._increment_skip_exceeds_limit(): return None logger.warning("No tracks left in playlist") - if self.increment_skip_exceeds_limit(): + if self._increment_skip_exceeds_limit(): return None return None @@ -100,12 +100,12 @@ def get_next_track(self, index): def translate_uri(self, uri): return PandoraUri.parse(uri).audio_url - def increment_skip_exceeds_limit(self): + def _increment_skip_exceeds_limit(self): self.consecutive_track_skips += 1 if self.consecutive_track_skips >= self.SKIP_LIMIT: logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) - self.backend.rpc_client.stop_playback() + Thread(target=self.backend.rpc_client.stop_playback).start() return True return False From ef6237b48f9c1eb24dc3563818f0e2047e9c4d34 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 23 Nov 2015 10:22:46 +0200 Subject: [PATCH 024/311] Sync with develop. --- README.rst | 2 +- mopidy_pandora/playback.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index a2f9bd1..885ab8c 100644 --- a/README.rst +++ b/README.rst @@ -110,7 +110,7 @@ v0.1.8 (UNRELEASED) ---------------------------------------- - Add support for handling advertisements which was introduced in version 1.6 of the pydora API. -- Enforce Mopidy to stop when skip limit is exceeded (workaround for https://github.com/mopidy/mopidy/issues/1221). +- Force Mopidy to stop when skip limit is exceeded (workaround for https://github.com/mopidy/mopidy/issues/1221). v0.1.7 (Oct 31, 2015) ---------------------------------------- diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 846a905..b5e3944 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -91,11 +91,11 @@ def get_next_track(self, index): return models.Track(uri=self.active_track_uri) else: logger.warning("Audio URI for track '%s' cannot be played.", TrackUri.from_track(track).uri) - if self.increment_skip_exceeds_limit(): + if self._increment_skip_exceeds_limit(): return None logger.warning("No tracks left in playlist") - if self.increment_skip_exceeds_limit(): + if self._increment_skip_exceeds_limit(): return None return None @@ -103,12 +103,12 @@ def get_next_track(self, index): def translate_uri(self, uri): return PandoraUri.parse(uri).audio_url - def increment_skip_exceeds_limit(self): + def _increment_skip_exceeds_limit(self): self.consecutive_track_skips += 1 if self.consecutive_track_skips >= self.SKIP_LIMIT: logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) - self.backend.rpc_client.stop_playback() + Thread(target=self.backend.rpc_client.stop_playback).start() return True return False From b6eadbde35b152c6052f5a3ad57a2e345e42d1bf Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 28 Nov 2015 10:04:28 +0200 Subject: [PATCH 025/311] Update README and increment version number. --- README.rst | 5 +++++ mopidy_pandora/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 1c088f8..1ea137b 100644 --- a/README.rst +++ b/README.rst @@ -105,6 +105,11 @@ Project resources Changelog ========= +v0.1.7.1 (UNRELEASED) +---------------------------------------- + +- Force Mopidy to stop when skip limit is exceeded (workaround for https://github.com/mopidy/mopidy/issues/1221). + v0.1.7 (Oct 31, 2015) ---------------------------------------- diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index 15a4870..e01492c 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -9,7 +9,7 @@ from pandora import BaseAPIClient -__version__ = '0.1.7' +__version__ = '0.1.7.1' logger = logging.getLogger(__name__) From 52371603c40eecca54c604c8bfadccbd329612da Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 28 Nov 2015 11:48:38 +0200 Subject: [PATCH 026/311] Sync with mopidy-beets example. - Don't install anything system-wide. - Test with global site-packages disabled. --- .travis.yml | 10 +++------- tox.ini | 1 - 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index a090495..6769d08 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,13 +5,6 @@ language: python python: - "2.7_with_system_site_packages" -addons: - apt: - sources: - - mopidy-stable - packages: - - mopidy - env: - TOX_ENV=py27 - TOX_ENV=flake8 @@ -26,3 +19,6 @@ after_success: - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi" +branches: + except: + - debian diff --git a/tox.ini b/tox.ini index f597090..cd1a43c 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,6 @@ envlist = py27, flake8 [testenv] -sitepackages = true whitelist_externals=py.test install_command = pip install --allow-unverified=mopidy --pre {opts} {packages} commands = From 8d600fabfe793b689ba2ae765d00a599b1abc05f Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 28 Nov 2015 11:59:20 +0200 Subject: [PATCH 027/311] Rollback: test with global site-packages enabled. --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index cd1a43c..f597090 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = py27, flake8 [testenv] +sitepackages = true whitelist_externals=py.test install_command = pip install --allow-unverified=mopidy --pre {opts} {packages} commands = From d1d0b5124764b9957ca7e77a2cb318988ffc8d05 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 28 Nov 2015 12:04:28 +0200 Subject: [PATCH 028/311] Rollback: Install system-wide. --- .travis.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 6769d08..a090495 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,13 @@ language: python python: - "2.7_with_system_site_packages" +addons: + apt: + sources: + - mopidy-stable + packages: + - mopidy + env: - TOX_ENV=py27 - TOX_ENV=flake8 @@ -19,6 +26,3 @@ after_success: - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi" -branches: - except: - - debian From eebead1e356c329cf65ab5559b5298326fa46316 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 29 Nov 2015 11:55:29 +0200 Subject: [PATCH 029/311] Work in progress: simulate dynamic tracklists. --- README.rst | 6 +- mopidy_pandora/client.py | 13 +++++ mopidy_pandora/doubleclick.py | 21 +++---- mopidy_pandora/library.py | 100 +++++++++++++++++++++++++------- mopidy_pandora/playback.py | 105 ++++++++++++++++++---------------- mopidy_pandora/rpc.py | 96 +++++++++++++++++++++++++------ mopidy_pandora/uri.py | 44 +++++++------- tests/test_playback.py | 47 ++++++++------- 8 files changed, 288 insertions(+), 144 deletions(-) diff --git a/README.rst b/README.rst index 1ea137b..2ce0975 100644 --- a/README.rst +++ b/README.rst @@ -105,9 +105,13 @@ Project resources Changelog ========= -v0.1.7.1 (UNRELEASED) +v0.2.0 (UNRELEASED) ---------------------------------------- +- Major overhaul that completely changes how tracks are handled. Finally allows all track information to be accessible + during playback (e.g. song and artist names, album covers, etc.). +- Simulate dynamic tracklist (workaround for https://github.com/rectalogic/mopidy-pandora/issues/2) +- Add support for browsing genre stations - Force Mopidy to stop when skip limit is exceeded (workaround for https://github.com/mopidy/mopidy/issues/1221). v0.1.7 (Oct 31, 2015) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index b396ba0..cf289ce 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -21,6 +21,7 @@ def __init__(self, transport, partner_user, partner_password, device, super(MopidyPandoraAPIClient, self).__init__(transport, partner_user, partner_password, device, default_audio_quality) self._station_list = [] + self._genre_stations = [] def get_station_list(self): @@ -39,3 +40,15 @@ def get_station(self, station_id): except TypeError: # Could not find station_id in cached list, try retrieving from Pandora server. return super(MopidyPandoraAPIClient, self).get_station(station_id) + + def get_genre_stations(self): + + if not any(self._genre_stations) or self._genre_stations.has_changed(): + try: + self._genre_stations = super(MopidyPandoraAPIClient, self).get_genre_stations() + # if any(self._genre_stations): + # self._genre_stations.sort(key=lambda x: x[0], reverse=False) + except requests.exceptions.RequestException as e: + logger.error('Error retrieving genre stations: %s', encoding.locale_decode(e)) + + return self._genre_stations diff --git a/mopidy_pandora/doubleclick.py b/mopidy_pandora/doubleclick.py index cfe5737..95fa34d 100644 --- a/mopidy_pandora/doubleclick.py +++ b/mopidy_pandora/doubleclick.py @@ -38,28 +38,23 @@ def is_double_click(self): return double_clicked - def on_change_track(self, active_track_uri, new_track_uri): + def on_change_track(self, track, previous_tlid, next_tlid): from mopidy_pandora.uri import PandoraUri if not self.is_double_click(): return False - if active_track_uri is not None: + # TODO: the order of the tracks will no longer be sequential if the user has 'shuffled' the tracklist + # Need to find a better approach for determining whether 'next' or 'previous' was clicked. + if track.tlid == next_tlid: + return self.process_click(self.on_pause_next_click, track.uri) - new_track_index = int(PandoraUri.parse(new_track_uri).index) - active_track_index = int(PandoraUri.parse(active_track_uri).index) - - # TODO: the order of the tracks will no longer be sequential if the user has 'shuffled' the tracklist - # Need to find a better approach for determining whether 'next' or 'previous' was clicked. - if new_track_index > active_track_index or new_track_index == 0 and active_track_index == 2: - return self.process_click(self.on_pause_next_click, active_track_uri) - - elif new_track_index < active_track_index or new_track_index == active_track_index: - return self.process_click(self.on_pause_previous_click, active_track_uri) + elif track.tlid == previous_tlid: + return self.process_click(self.on_pause_previous_click, track.uri) return False - def on_resume_click(self, track_uri, time_position): + def on_resume_click(self, time_position): if not self.is_double_click() or time_position == 0: return False diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 01226bc..ed26a72 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,51 +1,109 @@ +from threading import Thread from mopidy import backend, models +from pandora.models.pandora import Station +from pydora.utils import iterate_forever -from mopidy_pandora.uri import PandoraUri, StationUri, TrackUri, logger +from mopidy_pandora.uri import PandoraUri, StationUri, TrackUri, logger, GenreUri class PandoraLibraryProvider(backend.LibraryProvider): root_directory = models.Ref.directory(name='Pandora', uri=PandoraUri('directory').uri) + genre_directory = models.Ref.directory(name='Browse Genres', uri=PandoraUri('genres').uri) def __init__(self, backend, sort_order): self.sort_order = sort_order.upper() + self._station = None + self._station_iter = None + self._uri_translation_map = {} super(PandoraLibraryProvider, self).__init__(backend) def browse(self, uri): if uri == self.root_directory.uri: - stations = self.backend.api.get_station_list() + return self._browse_stations() - if any(stations) and self.sort_order == "A-Z": - stations.sort(key=lambda x: x.name, reverse=False) + if uri == self.genre_directory.uri: + return self._browse_genre_categories() - return [models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri) - for station in stations] - else: + pandora_uri = PandoraUri.parse(uri) + + if pandora_uri.scheme == GenreUri.scheme: + return self._browse_genre_stations(uri) + + if pandora_uri.scheme == StationUri.scheme: - pandora_uri = PandoraUri.parse(uri) + # Thread(target=self.backend.rpc_client.add_to_tracklist(track)).start() + + # TODO: should be able to perform check on is_ad() once dynamic tracklist support is available + # if not self._station or (not track.is_ad() and station_id != self._station.id): + if self._station is None or (pandora_uri.station_id != '' and pandora_uri.station_id != self._station.id): + self._station = self.backend.api.get_station(pandora_uri.station_id) + self._station_iter = iterate_forever(self._station.get_playlist) tracks = [] - number_of_tracks = 1 - if self.backend.supports_events: - number_of_tracks = 3 + number_of_tracks = 3 + for i in range(0, number_of_tracks): - tracks.append(models.Ref.track(name=pandora_uri.name, - uri=TrackUri(pandora_uri.station_id, pandora_uri.token, pandora_uri.name, - pandora_uri.detail_url, pandora_uri.art_url, - index=str(i)).uri)) + track = self._station_iter.next() + + track_uri = TrackUri(track.station_id, track.track_token) + + tracks.append(models.Ref.track(name=track.song_name, uri=track_uri.uri)) + + self._uri_translation_map[track_uri.uri] = track return tracks + raise Exception("Unknown or unsupported URI type '%s'", uri) + def lookup(self, uri): - pandora_uri = PandoraUri.parse(uri) + if PandoraUri.parse(uri).scheme == TrackUri.scheme: - if pandora_uri.scheme == TrackUri.scheme: + pandora_track = self.lookup_pandora_track(uri) - return [models.Track(name=pandora_uri.name, uri=uri, - artists=[models.Artist(name="Pandora")], - album=models.Album(name=pandora_uri.name, uri=pandora_uri.detail_url, - images=[pandora_uri.art_url]))] + track = models.Track(name=pandora_track.song_name, uri=uri, + artists=[models.Artist(name=pandora_track.artist_name)], + album=models.Album(name=pandora_track.album_name, uri=pandora_track.album_detail_url, + images=[pandora_track.album_art_url])) + return [track] logger.error("Failed to lookup '%s'", uri) return [] + + def _prep_station_list(self, list): + + index = 0 + for item in list: + if item.name == "QuickMix": + index = list.index(item) + break + + list.insert(0, list.pop(index)) + + def _browse_stations(self): + stations = self.backend.api.get_station_list() + + if any(stations) and self.sort_order == "A-Z": + stations.sort(key=lambda x: x.name, reverse=False) + self._prep_station_list(stations) + + station_directories = [] + for station in stations: + station_directories.append( + models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri)) + + station_directories.insert(0, self.genre_directory) + + return station_directories + + 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=StationUri.from_station(station).uri) + for station in self.backend.api.get_genre_stations()[GenreUri.parse(uri).category_name]] + + def lookup_pandora_track(self, uri): + return self._uri_translation_map[uri] diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 0b15fc5..c88a054 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -1,3 +1,4 @@ +import Queue from threading import Thread from mopidy import backend, models @@ -17,9 +18,6 @@ class PandoraPlaybackProvider(backend.PlaybackProvider): def __init__(self, audio, backend): super(PandoraPlaybackProvider, self).__init__(audio, backend) - self._station = None - self._station_iter = None - self.active_track_uri = None # 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. @@ -34,17 +32,22 @@ def __init__(self, audio, backend): def _auto_setup(self): - self.backend.rpc_client.set_repeat() - self.backend.rpc_client.set_consume(False) + self.backend.rpc_client.set_repeat(False) + self.backend.rpc_client.set_consume(True) self.backend.rpc_client.set_random(False) self.backend.rpc_client.set_single(False) self.backend.setup_required = False + def _update_tracklist(self): + + tracklist_length = self.backend.rpc_client.tracklist_get_length() + + def prepare_change(self): if self.backend.auto_setup and self.backend.setup_required: - Thread(target=self._auto_setup).start() + self._auto_setup() super(PandoraPlaybackProvider, self).prepare_change() @@ -53,76 +56,73 @@ def change_track(self, track): if track.uri is None: return False - track_uri = TrackUri.parse(track.uri) - - station_id = PandoraUri.parse(track.uri).station_id - - # TODO: should be able to perform check on is_ad() once dynamic tracklist support is available - # if not self._station or (not track.is_ad() and station_id != self._station.id): - if self._station is None or (station_id != '' and station_id != self._station.id): - self._station = self.backend.api.get_station(station_id) - self._station_iter = iterate_forever(self._station.get_playlist) + pandora_track = self.backend.library.lookup_pandora_track(track.uri) try: - next_track = self.get_next_track(track_uri.index) - if next_track: - self.consecutive_track_skips = 0 - return super(PandoraPlaybackProvider, self).change_track(next_track) + is_playable = pandora_track.audio_url and pandora_track.get_is_playable() except requests.exceptions.RequestException as e: - logger.error('Error changing track: %s', encoding.locale_decode(e)) - - return False - - def get_next_track(self, index): - - for track in self._station_iter: - try: - is_playable = track.audio_url and track.get_is_playable() - except requests.exceptions.RequestException as e: - is_playable = False - logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) + is_playable = False + logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) - if is_playable: - self.active_track_uri = TrackUri.from_track(track, index).uri - logger.info("Up next: '%s' by %s", track.song_name, track.artist_name) - return models.Track(uri=self.active_track_uri) - else: - logger.warning("Audio URI for track '%s' cannot be played.", TrackUri.from_track(track).uri) - if self._increment_skip_exceeds_limit(): - return None + if is_playable: + logger.info("Up next: '%s' by %s", pandora_track.song_name, pandora_track.artist_name) + self.consecutive_track_skips = 0 - logger.warning("No tracks left in playlist") - if self._increment_skip_exceeds_limit(): - return None + Thread(target=self._update_tracklist).start() - return None + return super(PandoraPlaybackProvider, self).change_track(track) + else: + # TODO: also remove from tracklist? Handled by consume? + logger.warning("Audio URI for track '%s' cannot be played.", track.uri) + self._check_skip_limit() + return False def translate_uri(self, uri): - return PandoraUri.parse(uri).audio_url + return self.backend.library.lookup_pandora_track(uri).audio_url - def _increment_skip_exceeds_limit(self): + def _check_skip_limit(self): self.consecutive_track_skips += 1 if self.consecutive_track_skips >= self.SKIP_LIMIT: logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) - Thread(target=self.backend.rpc_client.stop_playback).start() + self.backend.rpc_client.stop_playback() return True return False class EventSupportPlaybackProvider(PandoraPlaybackProvider): + def __init__(self, audio, backend): super(EventSupportPlaybackProvider, self).__init__(audio, backend) self._double_click_handler = DoubleClickHandler(backend._config, backend.api) + self.next_tlid = None + self.previous_tlid = None + + # def play(self): + # + # Thread(target=self._update_tlids).start() + # super(EventSupportPlaybackProvider, self).play() + def change_track(self, track): - event_processed = self._double_click_handler.on_change_track(self.active_track_uri, track.uri) + event_processed = False + + t = self.backend.rpc_client.tracklist_get_previous_tlid() + try: + x = t.result_queue.get_nowait() + except Queue.Empty: + pass + + if self.next_tlid and self.previous_tlid: + event_processed = self._double_click_handler.on_change_track(track, self.previous_tlid, + self.next_tlid) + return_value = super(EventSupportPlaybackProvider, self).change_track(track) if event_processed: - Thread(target=self.backend.rpc_client.resume_playback).start() + self.backend.rpc_client.resume_playback() return return_value @@ -134,6 +134,13 @@ def pause(self): return super(EventSupportPlaybackProvider, self).pause() def resume(self): - self._double_click_handler.on_resume_click(self.active_track_uri, self.get_time_position()) + self._double_click_handler.on_resume_click(self.get_time_position()) return super(EventSupportPlaybackProvider, self).resume() + + # @threaded + # def _update_tlids(self, a, b): + # # self.next_tlid = self.backend.rpc_client.tracklist_get_next_tlid() + # # self.previous_tlid = self.backend.rpc_client.tracklist_get_previous_tlid() + # a = self.backend.rpc_client.tracklist_get_next_tlid() + # b = self.backend.rpc_client.tracklist_get_previous_tlid() diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/rpc.py index 647d709..9158fa9 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/rpc.py @@ -1,48 +1,112 @@ +import Queue import json +from threading import Thread import requests +# def run_in_thread(fn): +# def run(*k, **kw): +# t = Thread(target=fn, args=k, kwargs=kw) +# t.start() +# return t +# return run + +previous_tlid_queue = Queue.Queue() +next_tlid_queue = Queue.Queue() + + +# def threaded(fn): +# +# def wrapped_f(*args, **kwargs): +# '''this function calls the decorated function and puts the +# result in a queue''' +# ret = fn(*args, **kwargs) +# previous_tlid_queue.put(ret) +# +# def wrap(*args, **kwargs): +# '''this is the function returned from the decorator. It fires off +# wrapped_f in a new thread and returns the thread object with +# the result queue attached''' +# +# t = Thread(target=wrapped_f, args=args, kwargs=kwargs) +# t.daemon = False +# t.start() +# t.result_queue = Queue.Queue() +# return t +# +# return wrap + + class RPCClient(object): def __init__(self, hostname, port): self.url = 'http://' + str(hostname) + ':' + str(port) + '/mopidy/rpc' self.id = 0 - def _do_rpc(self, method, params=None): + def _do_rpc(self, *args, **kwargs): + + method = args[0] self.id += 1 data = {'method': method, 'jsonrpc': '2.0', 'id': self.id} + + params = kwargs.get('params') if params is not None: data['params'] = params - return requests.request('POST', self.url, data=json.dumps(data), headers={'Content-Type': 'application/json'}) + json_data = json.loads(requests.request('POST', self.url, data=json.dumps(data), + headers={'Content-Type': 'application/json'}).text) + queue = kwargs.get('queue') + if queue is not None: + queue.put(json_data['result']) + else: + return json_data['result'] - def set_repeat(self, value=True): + def _do_threaded_rpc(self, *args, **kwargs): - self._do_rpc('core.tracklist.set_repeat', {'value': value}) + t = Thread(target=self._do_rpc, args=args, kwargs=kwargs) + t.start() - def set_consume(self, value=True): + queue = kwargs.get('queue') - self._do_rpc('core.tracklist.set_consume', {'value': value}) + if queue is not None: + t.result_queue = queue - def set_single(self, value=True): + return t - self._do_rpc('core.tracklist.set_single', {'value': value}) + def set_repeat(self, value=True): + return self._do_threaded_rpc('core.tracklist.set_repeat', params={'value': value}) + + def set_consume(self, value=True): + return self._do_threaded_rpc('core.tracklist.set_consume', params={'value': value}) + + def set_single(self, value=True): + return self._do_threaded_rpc('core.tracklist.set_single', params={'value': value}) def set_random(self, value=True): + return self._do_threaded_rpc('core.tracklist.set_random', params={'value': value}) + + def resume_playback(self): + return self._do_threaded_rpc('core.playback.resume') - self._do_rpc('core.tracklist.set_random', {'value': value}) + def stop_playback(self): + return self._do_threaded_rpc('core.playback.stop') - def get_current_track_uri(self): + def tracklist_add(self, tracks): + return self._do_threaded_rpc('core.tracklist.add', params={'tracks': tracks}) - response = self._do_rpc('core.playback.get_current_tl_track') - return response.json()['result']['track']['uri'] + def tracklist_get_length(self): + return self._do_threaded_rpc('core.tracklist.get_length') - def resume_playback(self): + def tracklist_get_next_tlid(self): + return self._do_threaded_rpc('core.tracklist.get_next_tlid', queue=next_tlid_queue) - self._do_rpc('core.playback.resume') + def tracklist_get_previous_tlid(self): + return self._do_threaded_rpc('core.tracklist.get_previous_tlid', queue=previous_tlid_queue) - def stop_playback(self): + def get_current_tlid(self): + return self._do_threaded_rpc('core.playback.get_current_tlid') - self._do_rpc('core.playback.stop') + def get_current_tl_track(self): + return self._do_threaded_rpc('core.playback.get_current_tl_track') diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index a36b926..aa67d94 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -43,50 +43,54 @@ def parse(cls, uri): return cls(*parts[1:]) +class GenreUri(PandoraUri): + scheme = 'genre' + + def __init__(self, category_name): + super(GenreUri, self).__init__() + self.category_name = category_name + + @property + def uri(self): + return "{}:{}".format( + super(GenreUri, self).uri, + self.quote(self.category_name), + ) + + class StationUri(PandoraUri): scheme = 'station' - def __init__(self, station_id, station_token, name, detail_url, art_url): + def __init__(self, station_id, token): super(StationUri, self).__init__() self.station_id = station_id - self.token = station_token - self.name = name - self.detail_url = detail_url - self.art_url = art_url + self.token = token @classmethod def from_station(cls, station): - return StationUri(station.id, station.token, station.name, station.detail_url, station.art_url) + return StationUri(station.id, station.token) @property def uri(self): - return "{}:{}:{}:{}:{}:{}".format( + return "{}:{}:{}".format( super(StationUri, self).uri, self.quote(self.station_id), self.quote(self.token), - self.quote(self.name), - self.quote(self.detail_url), - self.quote(self.art_url), ) class TrackUri(StationUri): scheme = 'track' - def __init__(self, station_id, track_token, name, detail_url, art_url, audio_url='none_generated', index=0): - super(TrackUri, self).__init__(station_id, track_token, name, detail_url, art_url) - self.audio_url = audio_url - self.index = index + def __init__(self, station_id, token): + super(TrackUri, self).__init__(station_id, token) @classmethod - def from_track(cls, track, index=0): - return TrackUri(track.station_id, track.track_token, track.song_name, track.song_detail_url, - track.album_art_url, track.audio_url, index) + def from_track(cls, track): + return TrackUri(track.station_id, track.track_token) @property def uri(self): - return "{}:{}:{}".format( + return "{}".format( super(TrackUri, self).uri, - self.quote(self.audio_url), - self.quote(self.index), ) diff --git a/tests/test_playback.py b/tests/test_playback.py index 8e89ef9..3cc84c3 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -275,40 +275,39 @@ def test_is_playable_handles_request_exceptions(provider, caplog): def test_translate_uri_returns_audio_url(provider): - assert provider.translate_uri("pandora:track:test:::::audio_url") == "audio_url" + assert provider.lookup_pandora_track("pandora:track:test:::::audio_url") == "audio_url" def test_auto_setup_only_called_once(provider): with mock.patch.multiple('mopidy_pandora.rpc.RPCClient', set_repeat=mock.DEFAULT, set_random=mock.DEFAULT, set_consume=mock.DEFAULT, set_single=mock.DEFAULT) as values: - with mock.patch.object(RPCClient, 'get_current_track_uri', return_value="pandora::::::"): - event = threading.Event() + event = threading.Event() - def set_event(*args, **kwargs): - event.set() + def set_event(*args, **kwargs): + event.set() - values['set_single'].side_effect = set_event + values['set_single'].side_effect = set_event - provider.prepare_change() + provider.prepare_change() - if event.wait(timeout=1.0): - values['set_repeat'].assert_called_once_with() - values['set_random'].assert_called_once_with(False) - values['set_consume'].assert_called_once_with(False) - values['set_single'].assert_called_once_with(False) - else: - assert False + if event.wait(timeout=1.0): + values['set_repeat'].assert_called_once_with() + values['set_random'].assert_called_once_with(False) + values['set_consume'].assert_called_once_with(False) + values['set_single'].assert_called_once_with(False) + else: + assert False - event = threading.Event() - values['set_single'].side_effect = set_event + event = threading.Event() + values['set_single'].side_effect = set_event - provider.prepare_change() + provider.prepare_change() - if event.wait(timeout=1.0): - assert False - else: - values['set_repeat'].assert_called_once_with() - values['set_random'].assert_called_once_with(False) - values['set_consume'].assert_called_once_with(False) - values['set_single'].assert_called_once_with(False) + if event.wait(timeout=1.0): + assert False + else: + values['set_repeat'].assert_called_once_with() + values['set_random'].assert_called_once_with(False) + values['set_consume'].assert_called_once_with(False) + values['set_single'].assert_called_once_with(False) From d7f6660d4cd27ba31113496679a48ce17a2d4899 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 29 Nov 2015 17:35:50 +0200 Subject: [PATCH 030/311] Work in progress: events working again. --- mopidy_pandora/backend.py | 2 +- mopidy_pandora/client.py | 2 +- mopidy_pandora/doubleclick.py | 65 ++++++++++++----- mopidy_pandora/playback.py | 66 ++++++------------ mopidy_pandora/rpc.py | 128 ++++++++++++++++------------------ tests/test_doubleclick.py | 4 +- tests/test_playback.py | 16 ++--- 7 files changed, 142 insertions(+), 141 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 1fa60bb..26d0212 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -36,7 +36,7 @@ def __init__(self, config, audio): self.auto_setup = self._config['auto_setup'] self.setup_required = self.auto_setup - self.rpc_client = rpc.RPCClient(config['http']['hostname'], config['http']['port']) + rpc.RPCClient.configure(config['http']['hostname'], config['http']['port']) self.supports_events = False if self._config['event_support_enabled']: diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index cf289ce..dd944bd 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -12,7 +12,7 @@ class MopidyPandoraAPIClient(pandora.APIClient): """Pydora API Client for Mopidy-Pandora - This API client implements caching of the station list. + This API api implements caching of the station list. """ def __init__(self, transport, partner_user, partner_password, device, diff --git a/mopidy_pandora/doubleclick.py b/mopidy_pandora/doubleclick.py index 95fa34d..ac3e00b 100644 --- a/mopidy_pandora/doubleclick.py +++ b/mopidy_pandora/doubleclick.py @@ -1,10 +1,13 @@ +import Queue import logging +from threading import Thread import time from mopidy.internal import encoding from pandora.errors import PandoraException +from mopidy_pandora import rpc from mopidy_pandora.library import PandoraUri @@ -12,20 +15,28 @@ class DoubleClickHandler(object): - def __init__(self, config, client): + def __init__(self, backend): + self.backend = backend + config = self.backend._config self.on_pause_resume_click = config["on_pause_resume_click"] self.on_pause_next_click = config["on_pause_next_click"] self.on_pause_previous_click = config["on_pause_previous_click"] self.double_click_interval = config['double_click_interval'] - self.client = client + self.api = self.backend.api self._click_time = 0 + self.previous_tlid = Queue.Queue() + self.next_tlid = Queue.Queue() + def set_click_time(self, click_time=None): if click_time is None: self._click_time = time.time() else: self._click_time = click_time + rpc.RPCClient.core_tracklist_get_previous_tlid(queue=self.previous_tlid) + rpc.RPCClient.core_tracklist_get_next_tlid(queue=self.next_tlid) + def get_click_time(self): return self._click_time @@ -38,23 +49,42 @@ def is_double_click(self): return double_clicked - def on_change_track(self, track, previous_tlid, next_tlid): - from mopidy_pandora.uri import PandoraUri + def on_change_track(self, event_track_uri): if not self.is_double_click(): return False - # TODO: the order of the tracks will no longer be sequential if the user has 'shuffled' the tracklist - # Need to find a better approach for determining whether 'next' or 'previous' was clicked. - if track.tlid == next_tlid: - return self.process_click(self.on_pause_next_click, track.uri) + # Start playing the next song so long... + rpc.RPCClient.core_playback_resume() + + try: + # These tlids should already have been retrieved when 'pause' was clicked to trigger the event + previous_tlid = self.previous_tlid.get_nowait() + next_tlid = self.next_tlid.get_nowait() + + # Try to retrieve the current tlid, time out if not found + queue = Queue.Queue() + rpc.RPCClient.core_playback_get_current_tlid(queue=queue) + current_tlid = queue.get(timeout=2) + + # Cleanup asynchronous queues + queue.task_done() + self.previous_tlid.task_done() + self.next_tlid.task_done() + + except Queue.Empty as e: + logger.error('Error retrieving tracklist IDs: %s. Ignoring event...', encoding.locale_decode(e)) + return False + + if current_tlid == next_tlid: + return self.process_click(self.on_pause_next_click, event_track_uri) - elif track.tlid == previous_tlid: - return self.process_click(self.on_pause_previous_click, track.uri) + elif current_tlid.tlid == previous_tlid: + return self.process_click(self.on_pause_previous_click, event_track_uri) return False - def on_resume_click(self, time_position): + def on_resume_click(self, time_position, track_uri): if not self.is_double_click() or time_position == 0: return False @@ -65,7 +95,8 @@ def process_click(self, method, track_uri): self.set_click_time(0) uri = PandoraUri.parse(track_uri) - logger.info("Triggering event '%s' for song: %s", method, uri.name) + logger.info("Triggering event '%s' for song: %s", method, + self.backend.library.lookup_pandora_track(track_uri).song_name) func = getattr(self, method) @@ -78,16 +109,16 @@ def process_click(self, method, track_uri): return True def thumbs_up(self, track_token): - return self.client.add_feedback(track_token, True) + return self.api.add_feedback(track_token, True) def thumbs_down(self, track_token): - return self.client.add_feedback(track_token, False) + return self.api.add_feedback(track_token, False) def sleep(self, track_token): - return self.client.sleep_song(track_token) + return self.api.sleep_song(track_token) def add_artist_bookmark(self, track_token): - return self.client.add_artist_bookmark(track_token) + return self.api.add_artist_bookmark(track_token) def add_song_bookmark(self, track_token): - return self.client.add_song_bookmark(track_token) + return self.api.add_song_bookmark(track_token) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index c88a054..f7e25e4 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -7,6 +7,7 @@ from pydora.utils import iterate_forever import requests +from mopidy_pandora import rpc from mopidy_pandora.doubleclick import DoubleClickHandler @@ -19,6 +20,8 @@ class PandoraPlaybackProvider(backend.PlaybackProvider): def __init__(self, audio, backend): super(PandoraPlaybackProvider, self).__init__(audio, backend) + self.last_played_track_uri = None + # 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 @@ -32,17 +35,17 @@ def __init__(self, audio, backend): def _auto_setup(self): - self.backend.rpc_client.set_repeat(False) - self.backend.rpc_client.set_consume(True) - self.backend.rpc_client.set_random(False) - self.backend.rpc_client.set_single(False) + rpc.RPCClient.core_tracklist_set_repeat(False) + rpc.RPCClient.core_tracklist_set_consume(False) + rpc.RPCClient.core_tracklist_set_random(False) + rpc.RPCClient.core_tracklist_set_single(False) self.backend.setup_required = False - def _update_tracklist(self): - - tracklist_length = self.backend.rpc_client.tracklist_get_length() + def _update_tracklist(self, current_track_uri): + self.last_played_track_uri = current_track_uri + # tracklist_length = rpc.RPCClient.core_tracklist_get_length() def prepare_change(self): @@ -68,7 +71,7 @@ def change_track(self, track): logger.info("Up next: '%s' by %s", pandora_track.song_name, pandora_track.artist_name) self.consecutive_track_skips = 0 - Thread(target=self._update_tracklist).start() + self._update_tracklist(track.uri) return super(PandoraPlaybackProvider, self).change_track(track) else: @@ -85,7 +88,7 @@ def _check_skip_limit(self): if self.consecutive_track_skips >= self.SKIP_LIMIT: logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) - self.backend.rpc_client.stop_playback() + rpc.RPCClient.core_playback_stop() return True return False @@ -95,52 +98,27 @@ class EventSupportPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): super(EventSupportPlaybackProvider, self).__init__(audio, backend) - self._double_click_handler = DoubleClickHandler(backend._config, backend.api) - - self.next_tlid = None - self.previous_tlid = None - - # def play(self): - # - # Thread(target=self._update_tlids).start() - # super(EventSupportPlaybackProvider, self).play() + self._double_click_handler = DoubleClickHandler(backend) def change_track(self, track): - event_processed = False - - t = self.backend.rpc_client.tracklist_get_previous_tlid() - try: - x = t.result_queue.get_nowait() - except Queue.Empty: - pass + t = Thread(target=self._double_click_handler.on_change_track, args=[self.last_played_track_uri]) + t.start() - if self.next_tlid and self.previous_tlid: - event_processed = self._double_click_handler.on_change_track(track, self.previous_tlid, - self.next_tlid) - - return_value = super(EventSupportPlaybackProvider, self).change_track(track) - - if event_processed: - self.backend.rpc_client.resume_playback() - - return return_value + return super(EventSupportPlaybackProvider, self).change_track(track) def pause(self): if self.get_time_position() > 0: - self._double_click_handler.set_click_time() + t = Thread(target=self._double_click_handler.set_click_time) + t.start() return super(EventSupportPlaybackProvider, self).pause() def resume(self): - self._double_click_handler.on_resume_click(self.get_time_position()) - return super(EventSupportPlaybackProvider, self).resume() + t = Thread(target=self._double_click_handler.on_resume_click, + args=[self.get_time_position(), self.last_played_track_uri]) + t.start() - # @threaded - # def _update_tlids(self, a, b): - # # self.next_tlid = self.backend.rpc_client.tracklist_get_next_tlid() - # # self.previous_tlid = self.backend.rpc_client.tracklist_get_previous_tlid() - # a = self.backend.rpc_client.tracklist_get_next_tlid() - # b = self.backend.rpc_client.tracklist_get_previous_tlid() + return super(EventSupportPlaybackProvider, self).resume() diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/rpc.py index 9158fa9..38ea105 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/rpc.py @@ -5,108 +5,100 @@ import requests -# def run_in_thread(fn): -# def run(*k, **kw): -# t = Thread(target=fn, args=k, kwargs=kw) -# t.start() -# return t -# return run - -previous_tlid_queue = Queue.Queue() -next_tlid_queue = Queue.Queue() - - -# def threaded(fn): -# -# def wrapped_f(*args, **kwargs): -# '''this function calls the decorated function and puts the -# result in a queue''' -# ret = fn(*args, **kwargs) -# previous_tlid_queue.put(ret) -# -# def wrap(*args, **kwargs): -# '''this is the function returned from the decorator. It fires off -# wrapped_f in a new thread and returns the thread object with -# the result queue attached''' -# -# t = Thread(target=wrapped_f, args=args, kwargs=kwargs) -# t.daemon = False -# t.start() -# t.result_queue = Queue.Queue() -# return t -# -# return wrap +class RPCClient(object): + hostname = '127.0.0.1' + port = '6680' -class RPCClient(object): - def __init__(self, hostname, port): + url = 'http://' + str(hostname) + ':' + str(port) + '/mopidy/rpc' + id = 0 + + previous_tlid_queue = Queue.Queue() + current_tlid_queue = Queue.Queue() + next_tlid_queue = Queue.Queue() - self.url = 'http://' + str(hostname) + ':' + str(port) + '/mopidy/rpc' - self.id = 0 + @classmethod + def configure(cls, hostname, port): + cls.hostname = hostname + cls.port = port - def _do_rpc(self, *args, **kwargs): + @classmethod + def _do_rpc(cls, *args, **kwargs): method = args[0] - self.id += 1 - data = {'method': method, 'jsonrpc': '2.0', 'id': self.id} + cls.id += 1 + data = {'method': method, 'jsonrpc': '2.0', 'id': cls.id} params = kwargs.get('params') if params is not None: data['params'] = params - json_data = json.loads(requests.request('POST', self.url, data=json.dumps(data), + json_data = json.loads(requests.request('POST', cls.url, data=json.dumps(data), headers={'Content-Type': 'application/json'}).text) queue = kwargs.get('queue') if queue is not None: queue.put(json_data['result']) + return queue else: return json_data['result'] - def _do_threaded_rpc(self, *args, **kwargs): + @classmethod + def _start_thread(cls, *args, **kwargs): + + queue = kwargs.get('queue', None) - t = Thread(target=self._do_rpc, args=args, kwargs=kwargs) + t = Thread(target=cls._do_rpc, args=args, kwargs=kwargs) t.start() - - queue = kwargs.get('queue') - if queue is not None: t.result_queue = queue return t - def set_repeat(self, value=True): - return self._do_threaded_rpc('core.tracklist.set_repeat', params={'value': value}) + @classmethod + def core_tracklist_set_repeat(cls, value=True, queue=None): + return cls._start_thread('core.tracklist.set_repeat', params={'value': value}, queue=queue) - def set_consume(self, value=True): - return self._do_threaded_rpc('core.tracklist.set_consume', params={'value': value}) + @classmethod + def core_tracklist_set_consume(cls, value=True, queue=None): + return cls._start_thread('core.tracklist.set_consume', params={'value': value}, queue=queue) - def set_single(self, value=True): - return self._do_threaded_rpc('core.tracklist.set_single', params={'value': value}) + @classmethod + def core_tracklist_set_single(cls, value=True, queue=None): + return cls._start_thread('core.tracklist.set_single', params={'value': value}, queue=queue) - def set_random(self, value=True): - return self._do_threaded_rpc('core.tracklist.set_random', params={'value': value}) + @classmethod + def core_tracklist_set_random(cls, value=True, queue=None): + return cls._start_thread('core.tracklist.set_random', params={'value': value}, queue=queue) - def resume_playback(self): - return self._do_threaded_rpc('core.playback.resume') + @classmethod + def core_playback_resume(cls, queue=None): + return cls._start_thread('core.playback.resume', queue=queue) - def stop_playback(self): - return self._do_threaded_rpc('core.playback.stop') + @classmethod + def core_playback_stop(cls, queue=None): + return cls._start_thread('core.playback.stop', queue=queue) - def tracklist_add(self, tracks): - return self._do_threaded_rpc('core.tracklist.add', params={'tracks': tracks}) + @classmethod + def core_tracklist_add(cls, tracks, queue=None): + return cls._start_thread('core.tracklist.add', params={'tracks': tracks}, queue=queue) - def tracklist_get_length(self): - return self._do_threaded_rpc('core.tracklist.get_length') + @classmethod + def core_tracklist_get_length(cls, queue=None): + return cls._start_thread('core.tracklist.get_length', queue=queue) - def tracklist_get_next_tlid(self): - return self._do_threaded_rpc('core.tracklist.get_next_tlid', queue=next_tlid_queue) + @classmethod + def core_tracklist_get_next_tlid(cls, queue=None): + return cls._start_thread('core.tracklist.get_next_tlid', queue=queue) - def tracklist_get_previous_tlid(self): - return self._do_threaded_rpc('core.tracklist.get_previous_tlid', queue=previous_tlid_queue) + @classmethod + def core_tracklist_get_previous_tlid(cls, queue=None): + return cls._start_thread('core.tracklist.get_previous_tlid', queue=queue) - def get_current_tlid(self): - return self._do_threaded_rpc('core.playback.get_current_tlid') + @classmethod + def core_playback_get_current_tlid(cls, queue=None): + return cls._start_thread('core.playback.get_current_tlid', queue=queue) - def get_current_tl_track(self): - return self._do_threaded_rpc('core.playback.get_current_tl_track') + @classmethod + def core_playback_get_current_tl_track(cls, queue=None): + return cls._start_thread('core.playback.get_current_tl_track', queue=queue) diff --git a/tests/test_doubleclick.py b/tests/test_doubleclick.py index a508221..41d22d1 100644 --- a/tests/test_doubleclick.py +++ b/tests/test_doubleclick.py @@ -25,10 +25,10 @@ def client_mock(): def handler(config): handler = DoubleClickHandler(config['pandora'], client_mock) add_feedback_mock = mock.PropertyMock() - handler.client.add_feedback = add_feedback_mock + handler.api.add_feedback = add_feedback_mock sleep_mock = mock.PropertyMock() - handler.client.sleep_song = sleep_mock + handler.api.sleep_song = sleep_mock handler.set_click_time() diff --git a/tests/test_playback.py b/tests/test_playback.py index 3cc84c3..668f0c4 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -20,7 +20,7 @@ from mopidy_pandora.backend import MopidyPandoraAPIClient from mopidy_pandora.playback import PandoraPlaybackProvider -from mopidy_pandora.rpc import RPCClient +from mopidy_pandora import rpc from mopidy_pandora.uri import TrackUri @@ -100,7 +100,7 @@ def test_change_track_checks_for_double_click(provider): process_click_mock = mock.PropertyMock() provider._double_click_handler.is_double_click = is_double_click_mock provider._double_click_handler.process_click = process_click_mock - provider.backend.rpc_client.resume_playback = mock.PropertyMock() + rpc.RPCClient.core_playback_resume = mock.PropertyMock() provider.change_track(models.Track(uri=TrackUri.from_track(conftest.playlist_item_mock()).uri)) provider._double_click_handler.is_double_click.assert_called_once_with() @@ -117,7 +117,7 @@ def test_change_track_double_click_call(config, provider, playlist_item_mock): process_click_mock = mock.PropertyMock() provider._double_click_handler.process_click = process_click_mock - provider.backend.rpc_client.resume_playback = mock.PropertyMock() + rpc.RPCClient.core_playback_resume = mock.PropertyMock() provider._double_click_handler.set_click_time() provider.active_track_uri = track_0 provider.change_track(models.Track(uri=track_1)) @@ -154,10 +154,10 @@ def test_change_track_enforces_skip_limit(provider): with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=False): track = models.Track(uri="pandora:track:test::::") - provider.backend.rpc_client.stop_playback = mock.PropertyMock() + rpc.RPCClient.core_playback_stop = mock.PropertyMock() assert provider.change_track(track) is False - provider.backend.rpc_client.stop_playback.assert_called_once_with() + rpc.RPCClient.core_playback_stop.assert_called_once_with() assert PlaylistItem.get_is_playable.call_count == PandoraPlaybackProvider.SKIP_LIMIT @@ -167,8 +167,8 @@ def test_change_track_handles_request_exceptions(config, caplog): track = models.Track(uri="pandora:track:test::::") playback = conftest.get_backend(config).playback - playback.backend.rpc_client._do_rpc = mock.PropertyMock() - playback.backend.rpc_client.stop_playback = mock.PropertyMock() + rpc.RPCClient._do_rpc = mock.PropertyMock() + rpc.RPCClient.rpc_client.core_playback_stop = mock.PropertyMock() assert playback.change_track(track) is False assert 'Error changing track' in caplog.text() @@ -268,7 +268,7 @@ def test_is_playable_handles_request_exceptions(provider, caplog): with mock.patch.object(PlaylistItem, 'get_is_playable', conftest.request_exception_mock): track = models.Track(uri="pandora:track:test::::") - provider.backend.rpc_client.stop_playback = mock.PropertyMock() + rpc.RPCClient.core_playback_stop = mock.PropertyMock() assert provider.change_track(track) is False assert 'Error checking if track is playable' in caplog.text() From 39f004647528565f2d42689e0f1c1bc72fa12873 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 29 Nov 2015 19:03:23 +0200 Subject: [PATCH 031/311] Work in progress: extending tracklist dynamically is working. --- mopidy_pandora/client.py | 2 +- mopidy_pandora/doubleclick.py | 24 ++++++++++++------------ mopidy_pandora/library.py | 23 ++++++++++++++--------- mopidy_pandora/playback.py | 34 +++++++++++++++++++++++++++++----- mopidy_pandora/rpc.py | 24 +++++++++++++++++++----- 5 files changed, 75 insertions(+), 32 deletions(-) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index dd944bd..cf289ce 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -12,7 +12,7 @@ class MopidyPandoraAPIClient(pandora.APIClient): """Pydora API Client for Mopidy-Pandora - This API api implements caching of the station list. + This API client implements caching of the station list. """ def __init__(self, transport, partner_user, partner_password, device, diff --git a/mopidy_pandora/doubleclick.py b/mopidy_pandora/doubleclick.py index ac3e00b..0f7d786 100644 --- a/mopidy_pandora/doubleclick.py +++ b/mopidy_pandora/doubleclick.py @@ -25,8 +25,8 @@ def __init__(self, backend): self.api = self.backend.api self._click_time = 0 - self.previous_tlid = Queue.Queue() - self.next_tlid = Queue.Queue() + self.previous_tlid_queue = Queue.Queue() + self.next_tlid_queue = Queue.Queue() def set_click_time(self, click_time=None): if click_time is None: @@ -34,8 +34,8 @@ def set_click_time(self, click_time=None): else: self._click_time = click_time - rpc.RPCClient.core_tracklist_get_previous_tlid(queue=self.previous_tlid) - rpc.RPCClient.core_tracklist_get_next_tlid(queue=self.next_tlid) + rpc.RPCClient.core_tracklist_get_previous_tlid(queue=self.previous_tlid_queue) + rpc.RPCClient.core_tracklist_get_next_tlid(queue=self.next_tlid_queue) def get_click_time(self): return self._click_time @@ -59,18 +59,18 @@ def on_change_track(self, event_track_uri): try: # These tlids should already have been retrieved when 'pause' was clicked to trigger the event - previous_tlid = self.previous_tlid.get_nowait() - next_tlid = self.next_tlid.get_nowait() + previous_tlid = self.previous_tlid_queue.get_nowait() + next_tlid = self.next_tlid_queue.get_nowait() # Try to retrieve the current tlid, time out if not found - queue = Queue.Queue() - rpc.RPCClient.core_playback_get_current_tlid(queue=queue) - current_tlid = queue.get(timeout=2) + current_tlid_queue = Queue.Queue() + rpc.RPCClient.core_playback_get_current_tlid(queue=current_tlid_queue) + current_tlid = current_tlid_queue.get(timeout=2) # Cleanup asynchronous queues - queue.task_done() - self.previous_tlid.task_done() - self.next_tlid.task_done() + current_tlid_queue.task_done() + self.previous_tlid_queue.task_done() + self.next_tlid_queue.task_done() except Queue.Empty as e: logger.error('Error retrieving tracklist IDs: %s. Ignoring event...', encoding.locale_decode(e)) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index ed26a72..b9dc333 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -32,25 +32,18 @@ def browse(self, uri): if pandora_uri.scheme == StationUri.scheme: - # Thread(target=self.backend.rpc_client.add_to_tracklist(track)).start() - # TODO: should be able to perform check on is_ad() once dynamic tracklist support is available # if not self._station or (not track.is_ad() and station_id != self._station.id): if self._station is None or (pandora_uri.station_id != '' and pandora_uri.station_id != self._station.id): self._station = self.backend.api.get_station(pandora_uri.station_id) self._station_iter = iterate_forever(self._station.get_playlist) + self._uri_translation_map.clear() tracks = [] number_of_tracks = 3 for i in range(0, number_of_tracks): - track = self._station_iter.next() - - track_uri = TrackUri(track.station_id, track.track_token) - - tracks.append(models.Ref.track(name=track.song_name, uri=track_uri.uri)) - - self._uri_translation_map[track_uri.uri] = track + tracks.append(self.next_track()) return tracks @@ -107,3 +100,15 @@ def _browse_genre_stations(self, uri): def lookup_pandora_track(self, uri): return self._uri_translation_map[uri] + + def next_track(self): + pandora_track = self._station_iter.next() + + if pandora_track.track_token is None: + # TODO process add tokens properly when pydora 1.6 is available + return self.next_track() + + track = models.Ref.track(name=pandora_track.song_name, uri=TrackUri.from_track(pandora_track).uri) + self._uri_translation_map[track.uri] = pandora_track + + return track diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index f7e25e4..81a39ae 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -1,4 +1,5 @@ import Queue +import copy from threading import Thread from mopidy import backend, models @@ -42,10 +43,32 @@ def _auto_setup(self): self.backend.setup_required = False - def _update_tracklist(self, current_track_uri): + def _sync_tracklist(self, current_track_uri): self.last_played_track_uri = current_track_uri - # tracklist_length = rpc.RPCClient.core_tracklist_get_length() + + length_queue = Queue.Queue() + rpc.RPCClient.core_tracklist_get_length(queue=length_queue) + + current_tlid_queue = Queue.Queue() + rpc.RPCClient.core_playback_get_current_tlid(queue=current_tlid_queue) + + current_tlid = current_tlid_queue.get(timeout=2) + + index_queue = Queue.Queue() + rpc.RPCClient.core_tracklist_index(tlid=current_tlid, queue=index_queue) + + index = index_queue.get(timeout=2) + length = length_queue.get(timeout=2) + + if index == length-1: + # Need to add more tracks + track = self.backend.library.next_track() + rpc.RPCClient.core_tracklist_add(uris=[track.uri]) + + length_queue.task_done() + current_tlid_queue.task_done() + index_queue.task_done() def prepare_change(self): @@ -71,7 +94,8 @@ def change_track(self, track): logger.info("Up next: '%s' by %s", pandora_track.song_name, pandora_track.artist_name) self.consecutive_track_skips = 0 - self._update_tracklist(track.uri) + t = Thread(target=self._sync_tracklist, args=[track.uri]) + t.start() return super(PandoraPlaybackProvider, self).change_track(track) else: @@ -102,7 +126,7 @@ def __init__(self, audio, backend): def change_track(self, track): - t = Thread(target=self._double_click_handler.on_change_track, args=[self.last_played_track_uri]) + t = Thread(target=self._double_click_handler.on_change_track, args=[copy.copy(self.last_played_track_uri)]) t.start() return super(EventSupportPlaybackProvider, self).change_track(track) @@ -118,7 +142,7 @@ def pause(self): def resume(self): t = Thread(target=self._double_click_handler.on_resume_click, - args=[self.get_time_position(), self.last_played_track_uri]) + args=[self.get_time_position(), copy.copy(self.last_played_track_uri)]) t.start() return super(EventSupportPlaybackProvider, self).resume() diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/rpc.py index 38ea105..4a5b4a8 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/rpc.py @@ -83,9 +83,9 @@ def core_playback_stop(cls, queue=None): def core_tracklist_add(cls, tracks, queue=None): return cls._start_thread('core.tracklist.add', params={'tracks': tracks}, queue=queue) - @classmethod - def core_tracklist_get_length(cls, queue=None): - return cls._start_thread('core.tracklist.get_length', queue=queue) + # @classmethod + # def core_tracklist_get_length(cls, queue=None): + # return cls._start_thread('core.tracklist.get_length', queue=queue) @classmethod def core_tracklist_get_next_tlid(cls, queue=None): @@ -99,6 +99,20 @@ def core_tracklist_get_previous_tlid(cls, queue=None): def core_playback_get_current_tlid(cls, queue=None): return cls._start_thread('core.playback.get_current_tlid', queue=queue) + # @classmethod + # def core_playback_get_current_tl_track(cls, queue=None): + # return cls._start_thread('core.playback.get_current_tl_track', queue=queue) + + @classmethod + def core_tracklist_index(cls, tl_track=None, tlid=None, queue=None): + return cls._start_thread('core.tracklist.index', params={'tl_track': tl_track, 'tlid': tlid}, + queue=queue) + + @classmethod + def core_tracklist_get_length(cls, queue=None): + return cls._start_thread('core.tracklist.get_length', queue=queue) + @classmethod - def core_playback_get_current_tl_track(cls, queue=None): - return cls._start_thread('core.playback.get_current_tl_track', queue=queue) + def core_tracklist_add(cls, tracks=None, at_position=None, uri=None, uris=None, queue=None): + return cls._start_thread('core.tracklist.add', params={'tracks': tracks, 'at_position': at_position, + 'uri': uri, 'uris': uris}, queue=queue) From d020613638bc7d1a0b0f28ae8125515c332205b4 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 29 Nov 2015 19:41:04 +0200 Subject: [PATCH 032/311] Fix to extend tracklist if we are on the last or next to last track. --- mopidy_pandora/playback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 81a39ae..3e5ed98 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -61,8 +61,8 @@ def _sync_tracklist(self, current_track_uri): index = index_queue.get(timeout=2) length = length_queue.get(timeout=2) - if index == length-1: - # Need to add more tracks + if index >= length-1: + # We're at the end of the tracklist, add teh next Pandora track track = self.backend.library.next_track() rpc.RPCClient.core_tracklist_add(uris=[track.uri]) From 251506f1e2133f39ecb963a12050ade4b1380b59 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 29 Nov 2015 22:13:54 +0200 Subject: [PATCH 033/311] Add bitrate and track length to Mopidy track info. --- mopidy_pandora/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index b9dc333..a8c6fdf 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -55,8 +55,8 @@ def lookup(self, uri): pandora_track = self.lookup_pandora_track(uri) - track = models.Track(name=pandora_track.song_name, uri=uri, - artists=[models.Artist(name=pandora_track.artist_name)], + track = models.Track(name=pandora_track.song_name, uri=uri, length=pandora_track.track_length*1000, + bitrate=int(pandora_track.bitrate), artists=[models.Artist(name=pandora_track.artist_name)], album=models.Album(name=pandora_track.album_name, uri=pandora_track.album_detail_url, images=[pandora_track.album_art_url])) return [track] From 7b5b64cbcc1b23e140159a6ebfd4cc21666d4b00 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 29 Nov 2015 23:29:57 +0200 Subject: [PATCH 034/311] WIP: fixing test cases. --- tests/conftest.py | 27 +++++---- tests/test_doubleclick.py | 41 ++++++++------ tests/test_library.py | 34 +++++------- tests/test_playback.py | 112 ++++++++++---------------------------- tests/test_uri.py | 19 +------ 5 files changed, 84 insertions(+), 149 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 94a5377..d0bc4a3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ import requests -from mopidy_pandora import backend +from mopidy_pandora import backend, rpc MOCK_STATION_SCHEME = "station" MOCK_STATION_NAME = "Mock Station" @@ -66,7 +66,7 @@ def config(): def get_backend(config, simulate_request_exceptions=False): obj = backend.PandoraBackend(config=config, audio=Mock()) - obj.rpc_client._do_rpc = rpc_call_not_implemented_mock + rpc.RPCClient._do_rpc = rpc_call_not_implemented_mock if simulate_request_exceptions: type(obj.api.transport).__call__ = request_exception_mock @@ -82,20 +82,27 @@ def get_backend(config, simulate_request_exceptions=False): @pytest.fixture(scope="session") def station_result_mock(): mock_result = {"stat": "ok", - "result": - {"stationId": MOCK_STATION_ID, - "stationDetailUrl": MOCK_STATION_DETAIL_URL, - "artUrl": MOCK_STATION_ART_URL, - "stationToken": MOCK_STATION_TOKEN, - "stationName": MOCK_STATION_NAME}, - } + "result":{ + "stations":[ + {"stationId": MOCK_STATION_ID, + "stationDetailUrl": MOCK_STATION_DETAIL_URL, + "artUrl": MOCK_STATION_ART_URL, + "stationToken": MOCK_STATION_TOKEN, + "stationName": MOCK_STATION_NAME}, + {"stationId": MOCK_STATION_ID, + "stationDetailUrl": MOCK_STATION_DETAIL_URL, + "artUrl": MOCK_STATION_ART_URL, + "stationToken": MOCK_STATION_TOKEN, + "stationName": "QuikMix"}, + ]}} return mock_result @pytest.fixture(scope="session") def station_mock(simulate_request_exceptions=False): - return Station.from_json(get_backend(config(), simulate_request_exceptions).api, station_result_mock()["result"]) + return Station.from_json(get_backend(config(), simulate_request_exceptions).api, + station_result_mock()["result"]["items"][0]) @pytest.fixture(scope="session") diff --git a/tests/test_doubleclick.py b/tests/test_doubleclick.py index 41d22d1..2d5dd19 100644 --- a/tests/test_doubleclick.py +++ b/tests/test_doubleclick.py @@ -7,7 +7,7 @@ import mock import pytest - +from mopidy_pandora import rpc from mopidy_pandora.backend import MopidyPandoraAPIClient from mopidy_pandora.doubleclick import DoubleClickHandler @@ -23,7 +23,8 @@ def client_mock(): @pytest.fixture def handler(config): - handler = DoubleClickHandler(config['pandora'], client_mock) + + handler = DoubleClickHandler(conftest.get_backend(config)) add_feedback_mock = mock.PropertyMock() handler.api.add_feedback = add_feedback_mock @@ -53,28 +54,32 @@ def test_is_double_click_resets_click_time(handler): assert handler.get_click_time() == 0 -def test_on_change_track_forward(config, handler, playlist_item_mock): +def test_on_change_track_forward(config, handler): + with mock.patch.object(rpc.RPCClient, '_do_rpc', mock.PropertyMock): + + process_click_mock = mock.PropertyMock() + handler.process_click = process_click_mock + + rpc.RPCClient.core_playback_resume=mock.PropertyMock() - track_0 = TrackUri.from_track(playlist_item_mock, 0).uri - track_1 = TrackUri.from_track(playlist_item_mock, 1).uri - track_2 = TrackUri.from_track(playlist_item_mock, 2).uri + handler.previous_tlid_queue.get_nowait = mock.PropertyMock(return_value=0) + handler.next_tlid_queue.get_nowait = mock.PropertyMock(return_value=2) - process_click_mock = mock.PropertyMock() - handler.process_click = process_click_mock + rpc.RPCClient.core_playback_get_current_tlid = mock.PropertyMock(return_value=1) - handler.on_change_track(track_0, track_1) - handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_0) - handler.on_change_track(track_1, track_2) - handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_1) - handler.on_change_track(track_2, track_0) - handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_2) + handler.on_change_track(track_1) + handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_0) + handler.on_change_track(track_1, track_2) + handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_1) + handler.on_change_track(track_2, track_0) + handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_2) def test_on_change_track_back(config, handler, playlist_item_mock): - track_0 = TrackUri.from_track(playlist_item_mock, 0).uri - track_1 = TrackUri.from_track(playlist_item_mock, 1).uri - track_2 = TrackUri.from_track(playlist_item_mock, 2).uri + track_0 = TrackUri.from_track(playlist_item_mock).uri + track_1 = TrackUri.from_track(playlist_item_mock).uri + track_2 = TrackUri.from_track(playlist_item_mock).uri process_click_mock = mock.PropertyMock() handler.process_click = process_click_mock @@ -91,7 +96,7 @@ def test_on_resume_click_ignored_if_start_of_track(handler, playlist_item_mock): process_click_mock = mock.PropertyMock() handler.process_click = process_click_mock - handler.on_resume_click(TrackUri.from_track(playlist_item_mock).uri, 0) + handler.on_resume_click(TrackUri.from_track(playlist_item_mock).uri) handler.process_click.assert_not_called() diff --git a/tests/test_library.py b/tests/test_library.py index 09ce180..0466a8a 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -68,8 +68,10 @@ def test_browse_directory_sort_za(config, caplog): results = backend.library.browse(backend.library.root_directory.uri) - assert results[0].name == conftest.MOCK_STATION_NAME + " 1" - assert results[1].name == conftest.MOCK_STATION_NAME + " 2" + assert results[0].name == "Browse Genres" + assert results[1].name == "QuickMix" + assert results[2].name == conftest.MOCK_STATION_NAME + " 1" + assert results[3].name == conftest.MOCK_STATION_NAME + " 2" def test_browse_directory_sort_date(config, caplog): @@ -80,31 +82,21 @@ def test_browse_directory_sort_date(config, caplog): results = backend.library.browse(backend.library.root_directory.uri) - assert results[0].name == conftest.MOCK_STATION_NAME + " 2" - assert results[1].name == conftest.MOCK_STATION_NAME + " 1" + assert results[0].name == "Browse Genres" + assert results[1].name == "QuickMix" + assert results[2].name == conftest.MOCK_STATION_NAME + " 2" + assert results[3].name == conftest.MOCK_STATION_NAME + " 1" -def test_browse_track_uri(config, playlist_item_mock, caplog): +def test_browse_station_uri(config, station_mock, caplog): backend = conftest.get_backend(config) - track_uri = TrackUri.from_track(playlist_item_mock) + station_uri = StationUri.from_track(station_mock) - results = backend.library.browse(track_uri.uri) + results = backend.library.browse(station_uri.uri) - assert len(results) == 3 - - backend.supports_events = False - - results = backend.library.browse(track_uri.uri) assert len(results) == 1 - assert results[0].type == models.Ref.TRACK - assert results[0].name == track_uri.name - assert TrackUri.parse(results[0].uri).index == str(0) - - # Track should not have an audio URL at this stage - assert TrackUri.parse(results[0].uri).audio_url == "none_generated" + assert results[0].uri == station_uri.name + assert results[0].name == station_uri.name - # Also clear reference track's audio URI so that we can compare more easily - track_uri.audio_url = "none_generated" - assert results[0].uri == track_uri.uri diff --git a/tests/test_playback.py b/tests/test_playback.py index 668f0c4..16691b7 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -18,6 +18,7 @@ from mopidy_pandora import playback from mopidy_pandora.backend import MopidyPandoraAPIClient +from mopidy_pandora.library import PandoraLibraryProvider from mopidy_pandora.playback import PandoraPlaybackProvider from mopidy_pandora import rpc @@ -79,80 +80,24 @@ def test_resume_checks_for_double_click(provider): provider._double_click_handler.is_double_click.assert_called_once_with() -def test_resume_double_click_call(config, provider): - assert provider.backend.supports_events - - process_click_mock = mock.PropertyMock() - - provider._double_click_handler.process_click = process_click_mock - provider._double_click_handler.set_click_time() - provider.resume() - - provider._double_click_handler.process_click.assert_called_once_with(config['pandora']['on_pause_resume_click'], - provider.active_track_uri) - - -def test_change_track_checks_for_double_click(provider): - with mock.patch.object(PandoraPlaybackProvider, 'change_track', return_value=True): - with mock.patch.object(PandoraPlaybackProvider, 'get_time_position', return_value=100): - assert provider.backend.supports_events - is_double_click_mock = mock.PropertyMock() - process_click_mock = mock.PropertyMock() - provider._double_click_handler.is_double_click = is_double_click_mock - provider._double_click_handler.process_click = process_click_mock - rpc.RPCClient.core_playback_resume = mock.PropertyMock() - provider.change_track(models.Track(uri=TrackUri.from_track(conftest.playlist_item_mock()).uri)) - - provider._double_click_handler.is_double_click.assert_called_once_with() - - -def test_change_track_double_click_call(config, provider, playlist_item_mock): - with mock.patch.object(PandoraPlaybackProvider, 'change_track', return_value=True): - assert provider.backend.supports_events - - track_0 = TrackUri.from_track(playlist_item_mock, 0).uri - track_1 = TrackUri.from_track(playlist_item_mock, 1).uri - # track_2 = TrackUri.from_track(playlist_item_mock, 2).uri - - process_click_mock = mock.PropertyMock() - - provider._double_click_handler.process_click = process_click_mock - rpc.RPCClient.core_playback_resume = mock.PropertyMock() - provider._double_click_handler.set_click_time() - provider.active_track_uri = track_0 - provider.change_track(models.Track(uri=track_1)) - - provider._double_click_handler.process_click.assert_called_once_with(config['pandora']['on_pause_next_click'], - provider.active_track_uri) - - provider._double_click_handler.set_click_time() - - provider.active_track_uri = track_1 - provider.change_track(models.Track(uri=track_0)) - - provider._double_click_handler.process_click.assert_called_with(config['pandora']['on_pause_previous_click'], - provider.active_track_uri) - - def test_change_track(audio_mock, provider): - with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): - with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): - with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=True): - track = models.Track(uri=TrackUri.from_track(conftest.playlist_item_mock()).uri) + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=conftest.playlist_item_mock): + with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=True): + track = models.Track(uri=TrackUri.from_track(conftest.playlist_item_mock()).uri) - assert provider.change_track(track) is True - assert audio_mock.prepare_change.call_count == 0 - assert audio_mock.start_playback.call_count == 0 - audio_mock.set_uri.assert_called_once_with(PlaylistItem.get_audio_url( - conftest.playlist_result_mock()["result"]["items"][0], - conftest.MOCK_DEFAULT_AUDIO_QUALITY)) + assert provider.change_track(track) is True + assert audio_mock.prepare_change.call_count == 0 + assert audio_mock.start_playback.call_count == 0 + audio_mock.set_uri.assert_called_once_with(PlaylistItem.get_audio_url( + conftest.playlist_result_mock()["result"]["items"][0], + conftest.MOCK_DEFAULT_AUDIO_QUALITY)) def test_change_track_enforces_skip_limit(provider): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=False): - track = models.Track(uri="pandora:track:test::::") + track = models.Track(uri="pandora:track:test_station_id:test_token") rpc.RPCClient.core_playback_stop = mock.PropertyMock() @@ -164,11 +109,11 @@ def test_change_track_enforces_skip_limit(provider): def test_change_track_handles_request_exceptions(config, caplog): with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.request_exception_mock): - track = models.Track(uri="pandora:track:test::::") + track = models.Track(uri="pandora:track:test_station_id:test_token") playback = conftest.get_backend(config).playback rpc.RPCClient._do_rpc = mock.PropertyMock() - rpc.RPCClient.rpc_client.core_playback_stop = mock.PropertyMock() + rpc.RPCClient.core_playback_stop = mock.PropertyMock() assert playback.change_track(track) is False assert 'Error changing track' in caplog.text() @@ -176,7 +121,7 @@ def test_change_track_handles_request_exceptions(config, caplog): def test_change_track_resumes_playback(provider, playlist_item_mock): with mock.patch.object(PandoraPlaybackProvider, 'change_track', return_value=True): - with mock.patch.object(RPCClient, 'resume_playback') as mock_rpc: + with mock.patch.object(PandoraPlaybackProvider, 'resume_playback') as mock_rpc: assert provider.backend.supports_events event = threading.Event() @@ -205,7 +150,7 @@ def set_event(): def test_change_track_does_not_resume_playback_if_not_doubleclick(config, provider, playlist_item_mock): with mock.patch.object(PandoraPlaybackProvider, 'change_track', return_value=True): - with mock.patch.object(RPCClient, 'resume_playback') as mock_rpc: + with mock.patch.object(PandoraPlaybackProvider, 'resume_playback') as mock_rpc: assert provider.backend.supports_events event = threading.Event() @@ -235,7 +180,7 @@ def set_event(): def test_change_track_does_not_resume_playback_if_event_failed(provider, playlist_item_mock): with mock.patch.object(PandoraPlaybackProvider, 'change_track', return_value=True): - with mock.patch.object(RPCClient, 'resume_playback') as mock_rpc: + with mock.patch.object(PandoraPlaybackProvider, 'resume_playback') as mock_rpc: assert provider.backend.supports_events event = threading.Event() @@ -279,35 +224,36 @@ def test_translate_uri_returns_audio_url(provider): def test_auto_setup_only_called_once(provider): - with mock.patch.multiple('mopidy_pandora.rpc.RPCClient', set_repeat=mock.DEFAULT, set_random=mock.DEFAULT, - set_consume=mock.DEFAULT, set_single=mock.DEFAULT) as values: + with mock.patch.multiple('mopidy_pandora.rpc.RPCClient', core_tracklist_set_repeat=mock.DEFAULT, + core_tracklist_set_random=mock.DEFAULT, core_tracklist_set_consume=mock.DEFAULT, + core_tracklist_set_single=mock.DEFAULT) as values: event = threading.Event() def set_event(*args, **kwargs): event.set() - values['set_single'].side_effect = set_event + values['core_tracklist_set_single'].side_effect = set_event provider.prepare_change() if event.wait(timeout=1.0): - values['set_repeat'].assert_called_once_with() - values['set_random'].assert_called_once_with(False) - values['set_consume'].assert_called_once_with(False) - values['set_single'].assert_called_once_with(False) + values['core_tracklist_set_repeat'].assert_called_once_with(False) + values['core_tracklist_set_random'].assert_called_once_with(False) + values['core_tracklist_set_consume'].assert_called_once_with(False) + values['core_tracklist_set_single'].assert_called_once_with(False) else: assert False event = threading.Event() - values['set_single'].side_effect = set_event + values['core_tracklist_set_single'].side_effect = set_event provider.prepare_change() if event.wait(timeout=1.0): assert False else: - values['set_repeat'].assert_called_once_with() - values['set_random'].assert_called_once_with(False) - values['set_consume'].assert_called_once_with(False) - values['set_single'].assert_called_once_with(False) + values['core_tracklist_set_repeat'].assert_called_once_with(False) + values['core_tracklist_set_random'].assert_called_once_with(False) + values['core_tracklist_set_consume'].assert_called_once_with(False) + values['core_tracklist_set_single'].assert_called_once_with(False) diff --git a/tests/test_uri.py b/tests/test_uri.py index 179e924..0ceaaf0 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -58,10 +58,7 @@ def test_station_uri_from_station(station_mock): assert station_uri.uri == "pandora:" + \ station_uri.quote(conftest.MOCK_STATION_SCHEME) + ":" + \ station_uri.quote(conftest.MOCK_STATION_ID) + ":" + \ - station_uri.quote(conftest.MOCK_STATION_TOKEN) + ":" + \ - station_uri.quote(conftest.MOCK_STATION_NAME) + ":" + \ - station_uri.quote(conftest.MOCK_STATION_DETAIL_URL) + ":" + \ - station_uri.quote(conftest.MOCK_STATION_ART_URL) + station_uri.quote(conftest.MOCK_STATION_TOKEN) def test_station_uri_parse(station_mock): @@ -75,9 +72,6 @@ def test_station_uri_parse(station_mock): assert obj.scheme == conftest.MOCK_STATION_SCHEME assert obj.station_id == conftest.MOCK_STATION_ID assert obj.token == conftest.MOCK_STATION_TOKEN - assert obj.name == conftest.MOCK_STATION_NAME - assert obj.detail_url == conftest.MOCK_STATION_DETAIL_URL - assert obj.art_url == conftest.MOCK_STATION_ART_URL assert obj.uri == station_uri.uri @@ -89,12 +83,7 @@ def test_track_uri_from_track(playlist_item_mock): assert track_uri.uri == "pandora:" + \ track_uri.quote(conftest.MOCK_TRACK_SCHEME) + ":" + \ track_uri.quote(conftest.MOCK_STATION_ID) + ":" + \ - track_uri.quote(conftest.MOCK_TRACK_TOKEN) + ":" + \ - track_uri.quote(conftest.MOCK_TRACK_NAME) + ":" + \ - track_uri.quote(conftest.MOCK_TRACK_DETAIL_URL) + ":" + \ - track_uri.quote(conftest.MOCK_TRACK_ART_URL) + ":" + \ - track_uri.quote(conftest.MOCK_TRACK_AUDIO_HIGH) + ":" + \ - track_uri.quote(0) + track_uri.quote(conftest.MOCK_TRACK_TOKEN) def test_track_uri_parse(playlist_item_mock): @@ -108,9 +97,5 @@ def test_track_uri_parse(playlist_item_mock): assert obj.scheme == conftest.MOCK_TRACK_SCHEME assert obj.station_id == conftest.MOCK_STATION_ID assert obj.token == conftest.MOCK_TRACK_TOKEN - assert obj.name == conftest.MOCK_TRACK_NAME - assert obj.detail_url == conftest.MOCK_TRACK_DETAIL_URL - assert obj.art_url == conftest.MOCK_TRACK_ART_URL - assert obj.audio_url == conftest.MOCK_TRACK_AUDIO_HIGH assert obj.uri == track_uri.uri From efa912bdebeca7c8f20c056cdaad2f3ba172f8c2 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 29 Nov 2015 23:58:52 +0200 Subject: [PATCH 035/311] WIP: fixing test cases. --- tests/test_doubleclick.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/tests/test_doubleclick.py b/tests/test_doubleclick.py index 2d5dd19..84ea587 100644 --- a/tests/test_doubleclick.py +++ b/tests/test_doubleclick.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +import Queue import time @@ -54,25 +55,26 @@ def test_is_double_click_resets_click_time(handler): assert handler.get_click_time() == 0 -def test_on_change_track_forward(config, handler): - with mock.patch.object(rpc.RPCClient, '_do_rpc', mock.PropertyMock): +def test_on_change_track_forward(config, handler, playlist_item_mock): + with mock.patch.object(rpc.RPCClient, '_do_rpc', mock.PropertyMock()): + with mock.patch.object(Queue.Queue, 'get', mock.PropertyMock()): - process_click_mock = mock.PropertyMock() - handler.process_click = process_click_mock + handler.is_double_click = mock.PropertyMock(return_value=True) + handler.process_click = mock.PropertyMock() - rpc.RPCClient.core_playback_resume=mock.PropertyMock() + handler.previous_tlid_queue = mock.PropertyMock() + handler.next_tlid_queue = mock.PropertyMock() - handler.previous_tlid_queue.get_nowait = mock.PropertyMock(return_value=0) - handler.next_tlid_queue.get_nowait = mock.PropertyMock(return_value=2) + rpc.RPCClient.core_playback_get_current_tlid = mock.PropertyMock() - rpc.RPCClient.core_playback_get_current_tlid = mock.PropertyMock(return_value=1) + track_0 = TrackUri.from_track(playlist_item_mock).uri - handler.on_change_track(track_1) - handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_0) - handler.on_change_track(track_1, track_2) - handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_1) - handler.on_change_track(track_2, track_0) - handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_2) + handler.on_change_track(1) + handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_0) + # handler.on_change_track(track_1, track_2) + # handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_1) + # handler.on_change_track(track_2, track_0) + # handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_2) def test_on_change_track_back(config, handler, playlist_item_mock): From 2907a6b14b5fd7d145dd955af30d61acb524bc6b Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 30 Nov 2015 06:04:16 +0200 Subject: [PATCH 036/311] Add error handling for looking up Pandora tracks in uri translation map. --- mopidy_pandora/library.py | 19 +++++++++++++------ mopidy_pandora/playback.py | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index a8c6fdf..84d5874 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -2,6 +2,7 @@ from mopidy import backend, models from pandora.models.pandora import Station from pydora.utils import iterate_forever +from mopidy.internal import encoding from mopidy_pandora.uri import PandoraUri, StationUri, TrackUri, logger, GenreUri @@ -55,11 +56,13 @@ def lookup(self, uri): pandora_track = self.lookup_pandora_track(uri) - track = models.Track(name=pandora_track.song_name, uri=uri, length=pandora_track.track_length*1000, - bitrate=int(pandora_track.bitrate), artists=[models.Artist(name=pandora_track.artist_name)], - album=models.Album(name=pandora_track.album_name, uri=pandora_track.album_detail_url, - images=[pandora_track.album_art_url])) - return [track] + if pandora_track: + + track = models.Track(name=pandora_track.song_name, uri=uri, length=pandora_track.track_length*1000, + bitrate=int(pandora_track.bitrate), artists=[models.Artist(name=pandora_track.artist_name)], + album=models.Album(name=pandora_track.album_name, uri=pandora_track.album_detail_url, + images=[pandora_track.album_art_url])) + return [track] logger.error("Failed to lookup '%s'", uri) return [] @@ -99,7 +102,11 @@ def _browse_genre_stations(self, uri): for station in self.backend.api.get_genre_stations()[GenreUri.parse(uri).category_name]] def lookup_pandora_track(self, uri): - return self._uri_translation_map[uri] + try: + return self._uri_translation_map[uri] + except KeyError as e: + logger.error("Failed to lookup '%s' in uri translation map: %s", uri, encoding.locale_decode(e)) + return None def next_track(self): pandora_track = self._station_iter.next() diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 3e5ed98..867430c 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -85,7 +85,7 @@ def change_track(self, track): pandora_track = self.backend.library.lookup_pandora_track(track.uri) try: - is_playable = pandora_track.audio_url and pandora_track.get_is_playable() + is_playable = pandora_track and pandora_track.audio_url and pandora_track.get_is_playable() except requests.exceptions.RequestException as e: is_playable = False logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) From a9ab39df6df392f2d54e258ed8320b8a86a96b05 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 30 Nov 2015 07:48:28 +0200 Subject: [PATCH 037/311] WIP: fix test cases. --- mopidy_pandora/library.py | 18 ++++++--- tests/conftest.py | 31 ++++++++-------- tests/test_library.py | 77 +++++++++++++++++++++++++-------------- 3 files changed, 77 insertions(+), 49 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 84d5874..79c5b0b 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -8,8 +8,13 @@ class PandoraLibraryProvider(backend.LibraryProvider): - root_directory = models.Ref.directory(name='Pandora', uri=PandoraUri('directory').uri) - genre_directory = models.Ref.directory(name='Browse Genres', uri=PandoraUri('genres').uri) + + ROOT_DIR_NAME = 'Pandora' + GENRE_DIR_NAME = 'Browse Genres' + QUICKMIX_DIR_NAME = 'QuickMix' + + 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): self.sort_order = sort_order.upper() @@ -71,7 +76,7 @@ def _prep_station_list(self, list): index = 0 for item in list: - if item.name == "QuickMix": + if item.name == PandoraLibraryProvider.QUICKMIX_DIR_NAME: index = list.index(item) break @@ -80,8 +85,11 @@ def _prep_station_list(self, list): def _browse_stations(self): stations = self.backend.api.get_station_list() - if any(stations) and self.sort_order == "A-Z": - stations.sort(key=lambda x: x.name, reverse=False) + if any(stations): + + if self.sort_order == "A-Z": + stations.sort(key=lambda x: x.name, reverse=False) + self._prep_station_list(stations) station_directories = [] diff --git a/tests/conftest.py b/tests/conftest.py index d0bc4a3..5c0f1a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -82,19 +82,13 @@ def get_backend(config, simulate_request_exceptions=False): @pytest.fixture(scope="session") def station_result_mock(): mock_result = {"stat": "ok", - "result":{ - "stations":[ - {"stationId": MOCK_STATION_ID, - "stationDetailUrl": MOCK_STATION_DETAIL_URL, - "artUrl": MOCK_STATION_ART_URL, - "stationToken": MOCK_STATION_TOKEN, - "stationName": MOCK_STATION_NAME}, - {"stationId": MOCK_STATION_ID, - "stationDetailUrl": MOCK_STATION_DETAIL_URL, - "artUrl": MOCK_STATION_ART_URL, - "stationToken": MOCK_STATION_TOKEN, - "stationName": "QuikMix"}, - ]}} + "result": + {"stationId": MOCK_STATION_ID, + "stationDetailUrl": MOCK_STATION_DETAIL_URL, + "artUrl": MOCK_STATION_ART_URL, + "stationToken": MOCK_STATION_TOKEN, + "stationName": MOCK_STATION_NAME}, + } return mock_result @@ -102,7 +96,7 @@ def station_result_mock(): @pytest.fixture(scope="session") def station_mock(simulate_request_exceptions=False): return Station.from_json(get_backend(config(), simulate_request_exceptions).api, - station_result_mock()["result"]["items"][0]) + station_result_mock()["result"]) @pytest.fixture(scope="session") @@ -173,11 +167,16 @@ def playlist_item_mock(): def station_list_result_mock(): mock_result = {"stat": "ok", "result": {"stations": [ - {"stationId": MOCK_STATION_ID.replace("1", "2"), "stationToken": MOCK_STATION_TOKEN, + {"stationId": MOCK_STATION_ID.replace("1", "2"), + "stationToken": MOCK_STATION_TOKEN.replace("010","100"), "stationName": MOCK_STATION_NAME + " 2"}, {"stationId": MOCK_STATION_ID, "stationToken": MOCK_STATION_TOKEN, - "stationName": MOCK_STATION_NAME + " 1"}, ], "checksum": MOCK_STATION_LIST_CHECKSUM} + "stationName": MOCK_STATION_NAME + " 1"}, + {"stationId": MOCK_STATION_ID.replace("1", "3"), + "stationToken": MOCK_STATION_TOKEN.replace("0010","1000"), + "stationName": "QuickMix"}, + ], "checksum": MOCK_STATION_LIST_CHECKSUM}, } return mock_result["result"] diff --git a/tests/test_library.py b/tests/test_library.py index 0466a8a..a912410 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -9,7 +9,10 @@ from pandora import APIClient from pandora.models.pandora import Station -from mopidy_pandora.uri import StationUri, TrackUri +from mopidy_pandora.client import MopidyPandoraAPIClient +from mopidy_pandora.library import PandoraLibraryProvider + +from mopidy_pandora.uri import PandoraUri, StationUri, TrackUri from tests.conftest import get_station_list_mock @@ -28,39 +31,59 @@ def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) track_uri = TrackUri.from_track(playlist_item_mock) + + backend.library._uri_translation_map[track_uri.uri] = playlist_item_mock + results = backend.library.lookup(track_uri.uri) assert len(results) == 1 track = results[0] - assert track.name == track_uri.name assert track.uri == track_uri.uri - assert next(iter(track.artists)).name == "Pandora" - assert track.album.name == track_uri.name - assert track.album.uri == track_uri.detail_url - assert next(iter(track.album.images)) == track_uri.art_url -def test_browse_directory_uri(config, caplog): +def test_lookup_of_missing_track(config, playlist_item_mock, caplog): + + backend = conftest.get_backend(config) + + track_uri = TrackUri.from_track(playlist_item_mock) + results = backend.library.lookup(track_uri.uri) + + assert len(results) == 0 + + assert "Failed to lookup '%s' in uri translation map: %s", track_uri.uri in caplog.text() + + +def test_browse_directory_uri(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): backend = conftest.get_backend(config) results = backend.library.browse(backend.library.root_directory.uri) - assert len(results) == 2 - assert results[0].type == models.Ref.DIRECTORY - assert results[0].name == conftest.MOCK_STATION_NAME + " 2" - assert results[0].uri == StationUri.from_station( - Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][0])).uri + assert len(results) == 4 assert results[0].type == models.Ref.DIRECTORY - assert results[1].name == conftest.MOCK_STATION_NAME + " 1" + assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME + assert results[0].uri == PandoraUri('genres').uri + + assert results[1].type == models.Ref.DIRECTORY + assert results[1].name == PandoraLibraryProvider.QUICKMIX_DIR_NAME assert results[1].uri == StationUri.from_station( + Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][2])).uri + + assert results[2].type == models.Ref.DIRECTORY + assert results[2].name == conftest.MOCK_STATION_NAME + " 2" + assert results[2].uri == StationUri.from_station( + Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][0])).uri + + assert results[3].type == models.Ref.DIRECTORY + assert results[3].name == conftest.MOCK_STATION_NAME + " 1" + assert results[3].uri == StationUri.from_station( Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][1])).uri -def test_browse_directory_sort_za(config, caplog): +def test_browse_directory_sort_za(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): config['pandora']['sort_order'] = 'A-Z' @@ -68,13 +91,13 @@ def test_browse_directory_sort_za(config, caplog): results = backend.library.browse(backend.library.root_directory.uri) - assert results[0].name == "Browse Genres" - assert results[1].name == "QuickMix" + assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME + assert results[1].name == PandoraLibraryProvider.QUICKMIX_DIR_NAME assert results[2].name == conftest.MOCK_STATION_NAME + " 1" assert results[3].name == conftest.MOCK_STATION_NAME + " 2" -def test_browse_directory_sort_date(config, caplog): +def test_browse_directory_sort_date(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): config['pandora']['sort_order'] = 'date' @@ -82,21 +105,19 @@ def test_browse_directory_sort_date(config, caplog): results = backend.library.browse(backend.library.root_directory.uri) - assert results[0].name == "Browse Genres" - assert results[1].name == "QuickMix" + assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME + assert results[1].name == PandoraLibraryProvider.QUICKMIX_DIR_NAME assert results[2].name == conftest.MOCK_STATION_NAME + " 2" assert results[3].name == conftest.MOCK_STATION_NAME + " 1" -def test_browse_station_uri(config, station_mock, caplog): - - backend = conftest.get_backend(config) - station_uri = StationUri.from_track(station_mock) - - results = backend.library.browse(station_uri.uri) +def test_browse_station_uri(config, station_mock): + with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): + with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): - assert len(results) == 1 + backend = conftest.get_backend(config) + station_uri = StationUri.from_station(station_mock) - assert results[0].uri == station_uri.name - assert results[0].name == station_uri.name + results = backend.library.browse(station_uri.uri) + assert len(results) == 3 From cc53a90ca4e73656ef0138cc27e2e40923dacb0c Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 30 Nov 2015 08:02:16 +0200 Subject: [PATCH 038/311] WIP: fix flake8 errors. --- mopidy_pandora/doubleclick.py | 2 +- mopidy_pandora/library.py | 18 +++++++++--------- mopidy_pandora/playback.py | 7 +++---- mopidy_pandora/rpc.py | 9 ++------- tests/conftest.py | 4 ++-- tests/test_doubleclick.py | 2 ++ tests/test_playback.py | 3 +-- 7 files changed, 20 insertions(+), 25 deletions(-) diff --git a/mopidy_pandora/doubleclick.py b/mopidy_pandora/doubleclick.py index 0f7d786..e5a4895 100644 --- a/mopidy_pandora/doubleclick.py +++ b/mopidy_pandora/doubleclick.py @@ -1,12 +1,12 @@ import Queue import logging -from threading import Thread import time from mopidy.internal import encoding from pandora.errors import PandoraException + from mopidy_pandora import rpc from mopidy_pandora.library import PandoraUri diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 79c5b0b..76ae9c5 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,14 +1,13 @@ -from threading import Thread from mopidy import backend, models -from pandora.models.pandora import Station -from pydora.utils import iterate_forever + from mopidy.internal import encoding -from mopidy_pandora.uri import PandoraUri, StationUri, TrackUri, logger, GenreUri +from pydora.utils import iterate_forever + +from mopidy_pandora.uri import GenreUri, logger, PandoraUri, StationUri, TrackUri # noqa I101 class PandoraLibraryProvider(backend.LibraryProvider): - ROOT_DIR_NAME = 'Pandora' GENRE_DIR_NAME = 'Browse Genres' QUICKMIX_DIR_NAME = 'QuickMix' @@ -62,10 +61,11 @@ def lookup(self, uri): pandora_track = self.lookup_pandora_track(uri) if pandora_track: - - track = models.Track(name=pandora_track.song_name, uri=uri, length=pandora_track.track_length*1000, - bitrate=int(pandora_track.bitrate), artists=[models.Artist(name=pandora_track.artist_name)], - album=models.Album(name=pandora_track.album_name, uri=pandora_track.album_detail_url, + track = models.Track(name=pandora_track.song_name, uri=uri, length=pandora_track.track_length * 1000, + bitrate=int(pandora_track.bitrate), + artists=[models.Artist(name=pandora_track.artist_name)], + album=models.Album(name=pandora_track.album_name, + uri=pandora_track.album_detail_url, images=[pandora_track.album_art_url])) return [track] diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 867430c..0009e26 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -2,17 +2,16 @@ import copy from threading import Thread -from mopidy import backend, models +from mopidy import backend from mopidy.internal import encoding -from pydora.utils import iterate_forever - import requests + from mopidy_pandora import rpc from mopidy_pandora.doubleclick import DoubleClickHandler -from mopidy_pandora.uri import PandoraUri, TrackUri, logger +from mopidy_pandora.uri import logger class PandoraPlaybackProvider(backend.PlaybackProvider): diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/rpc.py index 4a5b4a8..78d34ca 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/rpc.py @@ -6,7 +6,6 @@ class RPCClient(object): - hostname = '127.0.0.1' port = '6680' @@ -45,7 +44,7 @@ def _do_rpc(cls, *args, **kwargs): @classmethod def _start_thread(cls, *args, **kwargs): - + queue = kwargs.get('queue', None) t = Thread(target=cls._do_rpc, args=args, kwargs=kwargs) @@ -79,10 +78,6 @@ def core_playback_resume(cls, queue=None): def core_playback_stop(cls, queue=None): return cls._start_thread('core.playback.stop', queue=queue) - @classmethod - def core_tracklist_add(cls, tracks, queue=None): - return cls._start_thread('core.tracklist.add', params={'tracks': tracks}, queue=queue) - # @classmethod # def core_tracklist_get_length(cls, queue=None): # return cls._start_thread('core.tracklist.get_length', queue=queue) @@ -115,4 +110,4 @@ def core_tracklist_get_length(cls, queue=None): @classmethod def core_tracklist_add(cls, tracks=None, at_position=None, uri=None, uris=None, queue=None): return cls._start_thread('core.tracklist.add', params={'tracks': tracks, 'at_position': at_position, - 'uri': uri, 'uris': uris}, queue=queue) + 'uri': uri, 'uris': uris}, queue=queue) diff --git a/tests/conftest.py b/tests/conftest.py index 5c0f1a0..ac01582 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -168,13 +168,13 @@ def station_list_result_mock(): mock_result = {"stat": "ok", "result": {"stations": [ {"stationId": MOCK_STATION_ID.replace("1", "2"), - "stationToken": MOCK_STATION_TOKEN.replace("010","100"), + "stationToken": MOCK_STATION_TOKEN.replace("010", "100"), "stationName": MOCK_STATION_NAME + " 2"}, {"stationId": MOCK_STATION_ID, "stationToken": MOCK_STATION_TOKEN, "stationName": MOCK_STATION_NAME + " 1"}, {"stationId": MOCK_STATION_ID.replace("1", "3"), - "stationToken": MOCK_STATION_TOKEN.replace("0010","1000"), + "stationToken": MOCK_STATION_TOKEN.replace("0010", "1000"), "stationName": "QuickMix"}, ], "checksum": MOCK_STATION_LIST_CHECKSUM}, } diff --git a/tests/test_doubleclick.py b/tests/test_doubleclick.py index 84ea587..983090f 100644 --- a/tests/test_doubleclick.py +++ b/tests/test_doubleclick.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals + import Queue import time @@ -8,6 +9,7 @@ import mock import pytest + from mopidy_pandora import rpc from mopidy_pandora.backend import MopidyPandoraAPIClient diff --git a/tests/test_playback.py b/tests/test_playback.py index 16691b7..b380b5c 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -15,13 +15,12 @@ import pytest -from mopidy_pandora import playback +from mopidy_pandora import playback, rpc from mopidy_pandora.backend import MopidyPandoraAPIClient from mopidy_pandora.library import PandoraLibraryProvider from mopidy_pandora.playback import PandoraPlaybackProvider -from mopidy_pandora import rpc from mopidy_pandora.uri import TrackUri From 0addf0ada9ff62eda5edd8e2e7d9aa314218667b Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 30 Nov 2015 09:09:12 +0200 Subject: [PATCH 039/311] Handle request exceptions when looking up next track for playback. --- mopidy_pandora/library.py | 2 +- mopidy_pandora/playback.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 76ae9c5..c94e8a4 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -113,7 +113,7 @@ def lookup_pandora_track(self, uri): try: return self._uri_translation_map[uri] except KeyError as e: - logger.error("Failed to lookup '%s' in uri translation map: %s", uri, encoding.locale_decode(e)) + logger.error("Failed to lookup '%s' in uri translation map.", uri) return None def next_track(self): diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 0009e26..1a6725e 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -81,9 +81,9 @@ def change_track(self, track): if track.uri is None: return False - pandora_track = self.backend.library.lookup_pandora_track(track.uri) - try: + pandora_track = self.backend.library.lookup_pandora_track(track.uri) + is_playable = pandora_track and pandora_track.audio_url and pandora_track.get_is_playable() except requests.exceptions.RequestException as e: is_playable = False From 20e58e476bc3787e94f90b56ae61fb70a79930d3 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 30 Nov 2015 09:13:39 +0200 Subject: [PATCH 040/311] Fix: don't invalidate translation map when browsing stations. --- mopidy_pandora/library.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index c94e8a4..46edc1d 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -43,7 +43,8 @@ def browse(self, uri): self._station = self.backend.api.get_station(pandora_uri.station_id) self._station_iter = iterate_forever(self._station.get_playlist) - self._uri_translation_map.clear() + # TODO: find sensible location for clearing the uri translation map to avoid excessive memory usage + #self._uri_translation_map.clear() tracks = [] number_of_tracks = 3 From 653b4276029468cd773d9bcbda69b487d7dfe298 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 1 Dec 2015 07:38:55 +0200 Subject: [PATCH 041/311] Add decorator to run methods asynchronously. Refactor existing calls to Thread(). --- mopidy_pandora/doubleclick.py | 3 ++ mopidy_pandora/playback.py | 19 ++++--- mopidy_pandora/rpc.py | 97 ++++++++++++++++++++--------------- 3 files changed, 68 insertions(+), 51 deletions(-) diff --git a/mopidy_pandora/doubleclick.py b/mopidy_pandora/doubleclick.py index e5a4895..87392ed 100644 --- a/mopidy_pandora/doubleclick.py +++ b/mopidy_pandora/doubleclick.py @@ -28,6 +28,7 @@ def __init__(self, backend): self.previous_tlid_queue = Queue.Queue() self.next_tlid_queue = Queue.Queue() + @rpc.run_async def set_click_time(self, click_time=None): if click_time is None: self._click_time = time.time() @@ -49,6 +50,7 @@ def is_double_click(self): return double_clicked + @rpc.run_async def on_change_track(self, event_track_uri): if not self.is_double_click(): @@ -84,6 +86,7 @@ def on_change_track(self, event_track_uri): return False + @rpc.run_async def on_resume_click(self, time_position, track_uri): if not self.is_double_click() or time_position == 0: return False diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 1a6725e..f8bed15 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -42,6 +42,7 @@ def _auto_setup(self): self.backend.setup_required = False + @rpc.run_async def _sync_tracklist(self, current_track_uri): self.last_played_track_uri = current_track_uri @@ -60,6 +61,9 @@ def _sync_tracklist(self, current_track_uri): index = index_queue.get(timeout=2) length = length_queue.get(timeout=2) + # TODO note that tlid's will be changed to start at '1' instead of '0' in the next release of Mopidy. + # the following statement should change to 'if index >= length:' when that happens. + # see https://github.com/mopidy/mopidy/commit/4c5e80a2790c6bea971b105f11ab3f7c16617173 if index >= length-1: # We're at the end of the tracklist, add teh next Pandora track track = self.backend.library.next_track() @@ -81,6 +85,8 @@ def change_track(self, track): if track.uri is None: return False + self._sync_tracklist(track.uri) + try: pandora_track = self.backend.library.lookup_pandora_track(track.uri) @@ -93,9 +99,6 @@ def change_track(self, track): logger.info("Up next: '%s' by %s", pandora_track.song_name, pandora_track.artist_name) self.consecutive_track_skips = 0 - t = Thread(target=self._sync_tracklist, args=[track.uri]) - t.start() - return super(PandoraPlaybackProvider, self).change_track(track) else: # TODO: also remove from tracklist? Handled by consume? @@ -125,23 +128,19 @@ def __init__(self, audio, backend): def change_track(self, track): - t = Thread(target=self._double_click_handler.on_change_track, args=[copy.copy(self.last_played_track_uri)]) - t.start() + self._double_click_handler.on_change_track(copy.copy(self.last_played_track_uri)) return super(EventSupportPlaybackProvider, self).change_track(track) def pause(self): if self.get_time_position() > 0: - t = Thread(target=self._double_click_handler.set_click_time) - t.start() + self._double_click_handler.set_click_time() return super(EventSupportPlaybackProvider, self).pause() def resume(self): - t = Thread(target=self._double_click_handler.on_resume_click, - args=[self.get_time_position(), copy.copy(self.last_played_track_uri)]) - t.start() + self._double_click_handler.on_resume_click(self.get_time_position(), copy.copy(self.last_played_track_uri)) return super(EventSupportPlaybackProvider, self).resume() diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/rpc.py index 78d34ca..c6b8afd 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/rpc.py @@ -1,10 +1,42 @@ import Queue import json -from threading import Thread 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. + """ + + queue = kwargs.get('queue', None) + + t = Thread(target=func, args=args, kwargs=kwargs) + t.start() + + if queue is not None: + t.result_queue = queue + + return t + + return async_func + + class RPCClient(object): hostname = '127.0.0.1' port = '6680' @@ -22,92 +54,75 @@ def configure(cls, hostname, port): cls.port = port @classmethod - def _do_rpc(cls, *args, **kwargs): + @run_async + def _do_rpc(cls, method, params=None, queue=None): + """ Makes an asynchronously remote procedure call to the Mopidy server. - method = args[0] + :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. + :return: the 'result' element of the json results list returned by the remote procedure call. + """ cls.id += 1 data = {'method': method, 'jsonrpc': '2.0', 'id': cls.id} - params = kwargs.get('params') 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) - queue = kwargs.get('queue') if queue is not None: queue.put(json_data['result']) - return queue - else: - return json_data['result'] - @classmethod - def _start_thread(cls, *args, **kwargs): - - queue = kwargs.get('queue', None) - - t = Thread(target=cls._do_rpc, args=args, kwargs=kwargs) - t.start() - if queue is not None: - t.result_queue = queue - - return t + return json_data['result'] @classmethod def core_tracklist_set_repeat(cls, value=True, queue=None): - return cls._start_thread('core.tracklist.set_repeat', params={'value': value}, queue=queue) + return cls._do_rpc('core.tracklist.set_repeat', params={'value': value}, queue=queue) @classmethod def core_tracklist_set_consume(cls, value=True, queue=None): - return cls._start_thread('core.tracklist.set_consume', params={'value': value}, queue=queue) + return cls._do_rpc('core.tracklist.set_consume', params={'value': value}, queue=queue) @classmethod def core_tracklist_set_single(cls, value=True, queue=None): - return cls._start_thread('core.tracklist.set_single', params={'value': value}, queue=queue) + return cls._do_rpc('core.tracklist.set_single', params={'value': value}, queue=queue) @classmethod def core_tracklist_set_random(cls, value=True, queue=None): - return cls._start_thread('core.tracklist.set_random', params={'value': value}, queue=queue) + return cls._do_rpc('core.tracklist.set_random', params={'value': value}, queue=queue) @classmethod def core_playback_resume(cls, queue=None): - return cls._start_thread('core.playback.resume', queue=queue) + return cls._do_rpc('core.playback.resume', queue=queue) @classmethod def core_playback_stop(cls, queue=None): - return cls._start_thread('core.playback.stop', queue=queue) - - # @classmethod - # def core_tracklist_get_length(cls, queue=None): - # return cls._start_thread('core.tracklist.get_length', queue=queue) + return cls._do_rpc('core.playback.stop', queue=queue) @classmethod def core_tracklist_get_next_tlid(cls, queue=None): - return cls._start_thread('core.tracklist.get_next_tlid', queue=queue) + return cls._do_rpc('core.tracklist.get_next_tlid', queue=queue) @classmethod def core_tracklist_get_previous_tlid(cls, queue=None): - return cls._start_thread('core.tracklist.get_previous_tlid', queue=queue) + return cls._do_rpc('core.tracklist.get_previous_tlid', queue=queue) @classmethod def core_playback_get_current_tlid(cls, queue=None): - return cls._start_thread('core.playback.get_current_tlid', queue=queue) - - # @classmethod - # def core_playback_get_current_tl_track(cls, queue=None): - # return cls._start_thread('core.playback.get_current_tl_track', queue=queue) + return cls._do_rpc('core.playback.get_current_tlid', queue=queue) @classmethod def core_tracklist_index(cls, tl_track=None, tlid=None, queue=None): - return cls._start_thread('core.tracklist.index', params={'tl_track': tl_track, 'tlid': tlid}, - queue=queue) + return cls._do_rpc('core.tracklist.index', params={'tl_track': tl_track, 'tlid': tlid}, + queue=queue) @classmethod def core_tracklist_get_length(cls, queue=None): - return cls._start_thread('core.tracklist.get_length', queue=queue) + return cls._do_rpc('core.tracklist.get_length', queue=queue) @classmethod def core_tracklist_add(cls, tracks=None, at_position=None, uri=None, uris=None, queue=None): - return cls._start_thread('core.tracklist.add', params={'tracks': tracks, 'at_position': at_position, - 'uri': uri, 'uris': uris}, queue=queue) + return cls._do_rpc('core.tracklist.add', params={'tracks': tracks, 'at_position': at_position, + 'uri': uri, 'uris': uris}, queue=queue) From 3acb1101c8ce1e094565b013d9a6b42c648d7038 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 2 Dec 2015 22:12:09 +0200 Subject: [PATCH 042/311] Doubleclick event handling overhaul. --- README.rst | 17 ++- mopidy_pandora/doubleclick.py | 127 --------------------- mopidy_pandora/library.py | 26 ++--- mopidy_pandora/playback.py | 202 ++++++++++++++++++++++++++-------- mopidy_pandora/rpc.py | 17 ++- tests/test_library.py | 6 +- 6 files changed, 183 insertions(+), 212 deletions(-) delete mode 100644 mopidy_pandora/doubleclick.py diff --git a/README.rst b/README.rst index 2ce0975..9349096 100644 --- a/README.rst +++ b/README.rst @@ -75,7 +75,7 @@ and web extensions: - double_click_interval - successive button clicks that occur within this interval (in seconds) will trigger the event. - on_pause_resume_click - click pause and then play while a song is playing to trigger the event. - on_pause_next_click - click pause and then next in quick succession. Calls event and skips to next song. -- on_pause_previous_click - click pause and then previous in quick succession. Calls event and skips to next song. +- on_pause_previous_click - click pause and then previous in quick succession. Calls event and restarts the current song. The supported events are: 'thumbs_up', 'thumbs_down', 'sleep', 'add_artist_bookmark', and 'add_song_bookmark'. @@ -84,14 +84,13 @@ Usage Mopidy needs `dynamic playlist `_ and `core extensions `_ support to properly support Pandora. In the meantime, -Mopidy-Pandora represents each Pandora station as a separate playlist. The Playlist needs to be played **in repeat mode** -and **consume**, **random**, and **single** should be turned off. Mopidy-Pandora will set this up automatically unless -you set the **auto_setup** config parameter to 'false'. - -Each time a track is played, the next dynamic track for that Pandora station will be retrieved and played. If ratings -support is enabled, Mopidy-Pandora will add three tracks to the playlist for each dynamic track. These are just used to -determine whether the user clicked on the 'previous' or 'next' playback buttons, and all three tracks point to the same -dynamic track for that Pandora station (i.e. it does not matter which one you select to play). +Mopidy-Pandora simulates dynamic playlists by adding the next track to the tracklist when the second to last track +starts to play. It is recommended that the Playlist is played with **consume** turned on in order to simulate how +Pandora clients are *supposed* to behave. For the same reason, **repeat**, **random**, and **single** should be turned +off. Mopidy-Pandora will set all of this up automatically unless you set the **auto_setup** config parameter to 'false'. + +Mopidy-Pandora will ensure that there are always at least two tracks in the playlist so that it is possible to determine +the direction that tracks are changed in (i.e. whether the user clicked on the 'previous' or 'next' playback buttons). Project resources diff --git a/mopidy_pandora/doubleclick.py b/mopidy_pandora/doubleclick.py deleted file mode 100644 index 87392ed..0000000 --- a/mopidy_pandora/doubleclick.py +++ /dev/null @@ -1,127 +0,0 @@ -import Queue -import logging - -import time - -from mopidy.internal import encoding - -from pandora.errors import PandoraException - -from mopidy_pandora import rpc - -from mopidy_pandora.library import PandoraUri - -logger = logging.getLogger(__name__) - - -class DoubleClickHandler(object): - def __init__(self, backend): - self.backend = backend - config = self.backend._config - self.on_pause_resume_click = config["on_pause_resume_click"] - self.on_pause_next_click = config["on_pause_next_click"] - self.on_pause_previous_click = config["on_pause_previous_click"] - self.double_click_interval = config['double_click_interval'] - self.api = self.backend.api - self._click_time = 0 - - self.previous_tlid_queue = Queue.Queue() - self.next_tlid_queue = Queue.Queue() - - @rpc.run_async - def set_click_time(self, click_time=None): - if click_time is None: - self._click_time = time.time() - else: - self._click_time = click_time - - rpc.RPCClient.core_tracklist_get_previous_tlid(queue=self.previous_tlid_queue) - rpc.RPCClient.core_tracklist_get_next_tlid(queue=self.next_tlid_queue) - - def get_click_time(self): - return self._click_time - - def is_double_click(self): - - double_clicked = self._click_time > 0 and time.time() - self._click_time < float(self.double_click_interval) - - if double_clicked is False: - self._click_time = 0 - - return double_clicked - - @rpc.run_async - def on_change_track(self, event_track_uri): - - if not self.is_double_click(): - return False - - # Start playing the next song so long... - rpc.RPCClient.core_playback_resume() - - try: - # These tlids should already have been retrieved when 'pause' was clicked to trigger the event - previous_tlid = self.previous_tlid_queue.get_nowait() - next_tlid = self.next_tlid_queue.get_nowait() - - # Try to retrieve the current tlid, time out if not found - current_tlid_queue = Queue.Queue() - rpc.RPCClient.core_playback_get_current_tlid(queue=current_tlid_queue) - current_tlid = current_tlid_queue.get(timeout=2) - - # Cleanup asynchronous queues - current_tlid_queue.task_done() - self.previous_tlid_queue.task_done() - self.next_tlid_queue.task_done() - - except Queue.Empty as e: - logger.error('Error retrieving tracklist IDs: %s. Ignoring event...', encoding.locale_decode(e)) - return False - - if current_tlid == next_tlid: - return self.process_click(self.on_pause_next_click, event_track_uri) - - elif current_tlid.tlid == previous_tlid: - return self.process_click(self.on_pause_previous_click, event_track_uri) - - return False - - @rpc.run_async - def on_resume_click(self, time_position, track_uri): - if not self.is_double_click() or time_position == 0: - return False - - return self.process_click(self.on_pause_resume_click, track_uri) - - def process_click(self, method, track_uri): - - self.set_click_time(0) - - uri = PandoraUri.parse(track_uri) - logger.info("Triggering event '%s' for song: %s", method, - self.backend.library.lookup_pandora_track(track_uri).song_name) - - func = getattr(self, method) - - try: - func(uri.token) - except PandoraException as e: - logger.error('Error calling event: %s', encoding.locale_decode(e)) - return False - - return True - - def thumbs_up(self, track_token): - return self.api.add_feedback(track_token, True) - - def thumbs_down(self, track_token): - return self.api.add_feedback(track_token, False) - - def sleep(self, track_token): - return self.api.sleep_song(track_token) - - def add_artist_bookmark(self, track_token): - return self.api.add_artist_bookmark(track_token) - - def add_song_bookmark(self, track_token): - return self.api.add_song_bookmark(track_token) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 46edc1d..927d922 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,7 +1,5 @@ from mopidy import backend, models -from mopidy.internal import encoding - from pydora.utils import iterate_forever from mopidy_pandora.uri import GenreUri, logger, PandoraUri, StationUri, TrackUri # noqa I101 @@ -10,7 +8,7 @@ class PandoraLibraryProvider(backend.LibraryProvider): ROOT_DIR_NAME = 'Pandora' GENRE_DIR_NAME = 'Browse Genres' - QUICKMIX_DIR_NAME = 'QuickMix' + SHUFFLE_STATION_NAME = 'QuickMix' 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) @@ -44,9 +42,11 @@ def browse(self, uri): self._station_iter = iterate_forever(self._station.get_playlist) # TODO: find sensible location for clearing the uri translation map to avoid excessive memory usage - #self._uri_translation_map.clear() + # self._uri_translation_map.clear() tracks = [] - number_of_tracks = 3 + # Need to add at least two tracks to determine direction of 'on_change' events + # that are handled by DoubleClickHandler + number_of_tracks = 2 for i in range(0, number_of_tracks): tracks.append(self.next_track()) @@ -73,15 +73,11 @@ def lookup(self, uri): logger.error("Failed to lookup '%s'", uri) return [] - def _prep_station_list(self, list): - - index = 0 - for item in list: - if item.name == PandoraLibraryProvider.QUICKMIX_DIR_NAME: - index = list.index(item) - break + def _move_shuffle_to_top(self, list): - list.insert(0, list.pop(index)) + for station in list: + if station.name == PandoraLibraryProvider.SHUFFLE_STATION_NAME: + return list.insert(0, list.pop(list.index(station))) def _browse_stations(self): stations = self.backend.api.get_station_list() @@ -91,7 +87,7 @@ def _browse_stations(self): if self.sort_order == "A-Z": stations.sort(key=lambda x: x.name, reverse=False) - self._prep_station_list(stations) + self._move_shuffle_to_top(stations) station_directories = [] for station in stations: @@ -113,7 +109,7 @@ def _browse_genre_stations(self, uri): def lookup_pandora_track(self, uri): try: return self._uri_translation_map[uri] - except KeyError as e: + except KeyError: logger.error("Failed to lookup '%s' in uri translation map.", uri) return None diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index f8bed15..145d814 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -1,17 +1,19 @@ import Queue -import copy -from threading import Thread + +import threading + +import time from mopidy import backend from mopidy.internal import encoding +from pandora.errors import PandoraException + import requests from mopidy_pandora import rpc -from mopidy_pandora.doubleclick import DoubleClickHandler - -from mopidy_pandora.uri import logger +from mopidy_pandora.uri import logger, PandoraUri # noqa I101 class PandoraPlaybackProvider(backend.PlaybackProvider): @@ -20,8 +22,6 @@ class PandoraPlaybackProvider(backend.PlaybackProvider): def __init__(self, audio, backend): super(PandoraPlaybackProvider, self).__init__(audio, backend) - self.last_played_track_uri = None - # 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 @@ -36,43 +36,12 @@ def __init__(self, audio, backend): def _auto_setup(self): rpc.RPCClient.core_tracklist_set_repeat(False) - rpc.RPCClient.core_tracklist_set_consume(False) + rpc.RPCClient.core_tracklist_set_consume(True) rpc.RPCClient.core_tracklist_set_random(False) rpc.RPCClient.core_tracklist_set_single(False) self.backend.setup_required = False - @rpc.run_async - def _sync_tracklist(self, current_track_uri): - - self.last_played_track_uri = current_track_uri - - length_queue = Queue.Queue() - rpc.RPCClient.core_tracklist_get_length(queue=length_queue) - - current_tlid_queue = Queue.Queue() - rpc.RPCClient.core_playback_get_current_tlid(queue=current_tlid_queue) - - current_tlid = current_tlid_queue.get(timeout=2) - - index_queue = Queue.Queue() - rpc.RPCClient.core_tracklist_index(tlid=current_tlid, queue=index_queue) - - index = index_queue.get(timeout=2) - length = length_queue.get(timeout=2) - - # TODO note that tlid's will be changed to start at '1' instead of '0' in the next release of Mopidy. - # the following statement should change to 'if index >= length:' when that happens. - # see https://github.com/mopidy/mopidy/commit/4c5e80a2790c6bea971b105f11ab3f7c16617173 - if index >= length-1: - # We're at the end of the tracklist, add teh next Pandora track - track = self.backend.library.next_track() - rpc.RPCClient.core_tracklist_add(uris=[track.uri]) - - length_queue.task_done() - current_tlid_queue.task_done() - index_queue.task_done() - def prepare_change(self): if self.backend.auto_setup and self.backend.setup_required: @@ -83,10 +52,10 @@ def prepare_change(self): def change_track(self, track): if track.uri is None: + logger.warning("No URI for track '%s': cannot be played.", track.name) + self._check_skip_limit() return False - self._sync_tracklist(track.uri) - try: pandora_track = self.backend.library.lookup_pandora_track(track.uri) @@ -121,26 +90,163 @@ def _check_skip_limit(self): class EventSupportPlaybackProvider(PandoraPlaybackProvider): - def __init__(self, audio, backend): super(EventSupportPlaybackProvider, self).__init__(audio, backend) - self._double_click_handler = DoubleClickHandler(backend) + + self._doubleclick_processed_event = threading.Event() + + config = self.backend._config + self.on_pause_resume_click = config["on_pause_resume_click"] + self.on_pause_next_click = config["on_pause_next_click"] + self.on_pause_previous_click = config["on_pause_previous_click"] + self.double_click_interval = config['double_click_interval'] + + self._click_time = 0 + self.thread_timeout = 2 + + self.previous_tl_track = None + self.current_tl_track = None + self.next_tl_track = None + + def set_click_time(self, click_time=None): + if click_time is None: + self._click_time = time.time() + else: + self._click_time = click_time + + def get_click_time(self): + return self._click_time + + def is_double_click(self): + + double_clicked = self._click_time > 0 and time.time() - self._click_time < float(self.double_click_interval) + + if double_clicked: + self._doubleclick_processed_event.clear() + else: + self._click_time = 0 + + return double_clicked def change_track(self, track): - self._double_click_handler.on_change_track(copy.copy(self.last_played_track_uri)) + if self.is_double_click(): + + if track.uri == self.next_tl_track['track']['uri']: + self.process_click(self.on_pause_next_click, track.uri) + elif track.uri == self.previous_tl_track['track']['uri']: + self.process_click(self.on_pause_previous_click, track.uri) + + rpc.RPCClient.core_playback_resume() + + self._sync_tracklist() return super(EventSupportPlaybackProvider, self).change_track(track) + + def resume(self): + if self.is_double_click() and self.get_time_position() > 0: + self.process_click(self.on_pause_resume_click, self.current_tl_track['track']['uri']) + + return super(EventSupportPlaybackProvider, self).resume() + def pause(self): if self.get_time_position() > 0: - self._double_click_handler.set_click_time() + self.set_click_time() return super(EventSupportPlaybackProvider, self).pause() - def resume(self): + @rpc.run_async + def process_click(self, method, track_uri): - self._double_click_handler.on_resume_click(self.get_time_position(), copy.copy(self.last_played_track_uri)) + self.set_click_time(0) - return super(EventSupportPlaybackProvider, self).resume() + uri = PandoraUri.parse(track_uri) + logger.info("Triggering event '%s' for song: %s", method, + self.backend.library.lookup_pandora_track(track_uri).song_name) + + func = getattr(self, method) + + try: + func(uri.token) + + except PandoraException as e: + logger.error('Error calling event: %s', encoding.locale_decode(e)) + return False + finally: + self._doubleclick_processed_event.set() + + def thumbs_up(self, track_token): + return self.backend.api.add_feedback(track_token, True) + + def thumbs_down(self, track_token): + return self.backend.api.add_feedback(track_token, False) + + def sleep(self, track_token): + return self.backend.api.sleep_song(track_token) + + def add_artist_bookmark(self, track_token): + return self.backend.api.add_artist_bookmark(track_token) + + def add_song_bookmark(self, track_token): + return self.backend.api.add_song_bookmark(track_token) + + @rpc.run_async + def _sync_tracklist(self): + + # Wait until doubleclick events have finished processing + self._doubleclick_processed_event.wait(self.thread_timeout) + + previous_tl_track_queue = Queue.Queue(1) + current_tl_track_queue = Queue.Queue(1) + next_tl_track_queue = Queue.Queue(1) + length_queue = Queue.Queue(1) + index_queue = Queue.Queue(1) + + try: + # Try to retrieve the current tlids, time out if not found + rpc.RPCClient.core_playback_get_current_tl_track(queue=current_tl_track_queue) + rpc.RPCClient.core_tracklist_get_length(queue=length_queue) + + self.current_tl_track = current_tl_track_queue.get(timeout=self.thread_timeout) + + rpc.RPCClient.core_tracklist_index(tlid=self.current_tl_track['tlid'], queue=index_queue) + + tl_index = index_queue.get(timeout=self.thread_timeout) + tl_length = length_queue.get(timeout=self.thread_timeout) + + # TODO note that tlid's will be changed to start at '1' instead of '0' in the next release of Mopidy. + # the following statement should change to 'if index >= length:' when that happens. + # see https://github.com/mopidy/mopidy/commit/4c5e80a2790c6bea971b105f11ab3f7c16617173 + if tl_index >= tl_length-1: + # We're at the end of the tracklist, add the next Pandora track + track = self.backend.library.next_track() + add_track_queue = Queue.Queue() + + rpc.RPCClient.core_tracklist_add(uris=[track.uri], queue=add_track_queue) + add_track_queue.get(timeout=self.thread_timeout*2) + + # 'Previous' and 'next' would have changed after adding the new track. Fetch again. + rpc.RPCClient.core_tracklist_previous_track(self.current_tl_track, queue=previous_tl_track_queue) + rpc.RPCClient.core_tracklist_next_track(self.current_tl_track, queue=next_tl_track_queue) + + self.previous_tl_track = previous_tl_track_queue.get(timeout=self.thread_timeout) + self.next_tl_track = next_tl_track_queue.get(timeout=self.thread_timeout) + + self._doubleclick_processed_event.set() + + except Exception as e: + logger.error('Error syncing tracklist: %s.', encoding.locale_decode(e)) + self.previous_tl_track = self.current_tl_track = self.next_tl_track = None + return False + + finally: + # Cleanup asynchronous queues + previous_tl_track_queue.task_done() + current_tl_track_queue.task_done() + next_tl_track_queue.task_done() + length_queue.task_done() + index_queue.task_done() + + return True diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/rpc.py index c6b8afd..4cfa019 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/rpc.py @@ -44,10 +44,6 @@ class RPCClient(object): url = 'http://' + str(hostname) + ':' + str(port) + '/mopidy/rpc' id = 0 - previous_tlid_queue = Queue.Queue() - current_tlid_queue = Queue.Queue() - next_tlid_queue = Queue.Queue() - @classmethod def configure(cls, hostname, port): cls.hostname = hostname @@ -102,16 +98,17 @@ def core_playback_stop(cls, queue=None): return cls._do_rpc('core.playback.stop', queue=queue) @classmethod - def core_tracklist_get_next_tlid(cls, queue=None): - return cls._do_rpc('core.tracklist.get_next_tlid', queue=queue) + def core_tracklist_previous_track(cls, tl_track, queue=None): + return cls._do_rpc('core.tracklist.previous_track', params={'tl_track': tl_track}, queue=queue) @classmethod - def core_tracklist_get_previous_tlid(cls, queue=None): - return cls._do_rpc('core.tracklist.get_previous_tlid', queue=queue) + def core_playback_get_current_tl_track(cls, queue=None): + return cls._do_rpc('core.playback.get_current_tl_track', queue=queue) @classmethod - def core_playback_get_current_tlid(cls, queue=None): - return cls._do_rpc('core.playback.get_current_tlid', queue=queue) + def core_tracklist_next_track(cls, tl_track, queue=None): + return cls._do_rpc('core.tracklist.next_track', params={'tl_track': tl_track}, queue=queue) + @classmethod def core_tracklist_index(cls, tl_track=None, tlid=None, queue=None): diff --git a/tests/test_library.py b/tests/test_library.py index a912410..d4da9b2 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -68,7 +68,7 @@ def test_browse_directory_uri(config): assert results[0].uri == PandoraUri('genres').uri assert results[1].type == models.Ref.DIRECTORY - assert results[1].name == PandoraLibraryProvider.QUICKMIX_DIR_NAME + assert results[1].name == PandoraLibraryProvider.SHUFFLE_STATION_NAME assert results[1].uri == StationUri.from_station( Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][2])).uri @@ -92,7 +92,7 @@ def test_browse_directory_sort_za(config): results = backend.library.browse(backend.library.root_directory.uri) assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME - assert results[1].name == PandoraLibraryProvider.QUICKMIX_DIR_NAME + assert results[1].name == PandoraLibraryProvider.SHUFFLE_STATION_NAME assert results[2].name == conftest.MOCK_STATION_NAME + " 1" assert results[3].name == conftest.MOCK_STATION_NAME + " 2" @@ -106,7 +106,7 @@ def test_browse_directory_sort_date(config): results = backend.library.browse(backend.library.root_directory.uri) assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME - assert results[1].name == PandoraLibraryProvider.QUICKMIX_DIR_NAME + assert results[1].name == PandoraLibraryProvider.SHUFFLE_STATION_NAME assert results[2].name == conftest.MOCK_STATION_NAME + " 2" assert results[3].name == conftest.MOCK_STATION_NAME + " 1" From ff43cbc744c289cc3603eec55a94ca634ce8945d Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 3 Dec 2015 01:00:57 +0200 Subject: [PATCH 043/311] Create genre stations on the fly. --- mopidy_pandora/library.py | 7 +++++++ mopidy_pandora/uri.py | 3 +++ 2 files changed, 10 insertions(+) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 927d922..a7c41ee 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,4 +1,5 @@ from mopidy import backend, models +from pandora.models.pandora import Station from pydora.utils import iterate_forever @@ -38,6 +39,12 @@ def browse(self, uri): # TODO: should be able to perform check on is_ad() once dynamic tracklist support is available # if not self._station or (not track.is_ad() and station_id != self._station.id): if self._station is None or (pandora_uri.station_id != '' and pandora_uri.station_id != self._station.id): + + if pandora_uri.is_genre_station_uri(): + json_result = self.backend.api.create_station(search_token=pandora_uri.token) + new_station = Station.from_json(self.backend.api, json_result) + pandora_uri = StationUri.from_station(new_station) + self._station = self.backend.api.get_station(pandora_uri.station_id) self._station_iter = iterate_forever(self._station.get_playlist) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index aa67d94..277f902 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -66,6 +66,9 @@ def __init__(self, station_id, token): self.station_id = station_id self.token = token + def is_genre_station_uri(self): + return self.station_id.startswith('G') and self.station_id == self.token + @classmethod def from_station(cls, station): return StationUri(station.id, station.token) From a0a83853980ef66194f26b85d3e7f2095cd85233 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 3 Dec 2015 07:42:13 +0200 Subject: [PATCH 044/311] Refactored code to make it more readable. --- README.rst | 16 ++++----- mopidy_pandora/library.py | 58 +++++++++++++++++------------- mopidy_pandora/playback.py | 73 +++++++++++++++++++------------------- mopidy_pandora/rpc.py | 25 +++++++------ tests/test_playback.py | 8 ++--- 5 files changed, 93 insertions(+), 87 deletions(-) diff --git a/README.rst b/README.rst index 9349096..14463a1 100644 --- a/README.rst +++ b/README.rst @@ -84,13 +84,12 @@ Usage Mopidy needs `dynamic playlist `_ and `core extensions `_ support to properly support Pandora. In the meantime, -Mopidy-Pandora simulates dynamic playlists by adding the next track to the tracklist when the second to last track -starts to play. It is recommended that the Playlist is played with **consume** turned on in order to simulate how -Pandora clients are *supposed* to behave. For the same reason, **repeat**, **random**, and **single** should be turned -off. Mopidy-Pandora will set all of this up automatically unless you set the **auto_setup** config parameter to 'false'. +Mopidy-Pandora simulates dynamic playlists by adding more tracks to the tracklist as needed. It is recommended that the +Playlist is played with **consume** turned on in order to simulate the behaviour of the standard Pandora clients. For +the same reason, **repeat**, **random**, and **single** should be turned off. Mopidy-Pandora will set all of this up +automatically unless you set the **auto_setup** config parameter to 'false'. -Mopidy-Pandora will ensure that there are always at least two tracks in the playlist so that it is possible to determine -the direction that tracks are changed in (i.e. whether the user clicked on the 'previous' or 'next' playback buttons). +Mopidy-Pandora will ensure that there are always at least two tracks in the playlist to avoid playback gaps when switching tracks. Project resources @@ -108,9 +107,10 @@ v0.2.0 (UNRELEASED) ---------------------------------------- - Major overhaul that completely changes how tracks are handled. Finally allows all track information to be accessible - during playback (e.g. song and artist names, album covers, etc.). + during playback (e.g. song and artist names, album covers, track length, bitrate etc.). - Simulate dynamic tracklist (workaround for https://github.com/rectalogic/mopidy-pandora/issues/2) -- Add support for browsing genre stations +- Add support for browsing genre stations. Note that clicking on a genre station will automatically add that station to + your profile. At the moment, there is no way to remove stations from within Mopidy-Pandora. - Force Mopidy to stop when skip limit is exceeded (workaround for https://github.com/mopidy/mopidy/issues/1221). v0.1.7 (Oct 31, 2015) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index a7c41ee..9cab7de 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -35,30 +35,7 @@ def browse(self, uri): return self._browse_genre_stations(uri) if pandora_uri.scheme == StationUri.scheme: - - # TODO: should be able to perform check on is_ad() once dynamic tracklist support is available - # if not self._station or (not track.is_ad() and station_id != self._station.id): - if self._station is None or (pandora_uri.station_id != '' and pandora_uri.station_id != self._station.id): - - if pandora_uri.is_genre_station_uri(): - json_result = self.backend.api.create_station(search_token=pandora_uri.token) - new_station = Station.from_json(self.backend.api, json_result) - pandora_uri = StationUri.from_station(new_station) - - self._station = self.backend.api.get_station(pandora_uri.station_id) - self._station_iter = iterate_forever(self._station.get_playlist) - - # TODO: find sensible location for clearing the uri translation map to avoid excessive memory usage - # self._uri_translation_map.clear() - tracks = [] - # Need to add at least two tracks to determine direction of 'on_change' events - # that are handled by DoubleClickHandler - number_of_tracks = 2 - - for i in range(0, number_of_tracks): - tracks.append(self.next_track()) - - return tracks + return self._browse_tracks(uri) raise Exception("Unknown or unsupported URI type '%s'", uri) @@ -84,6 +61,8 @@ def _move_shuffle_to_top(self, list): for station in list: if station.name == PandoraLibraryProvider.SHUFFLE_STATION_NAME: + # Align with 'QuickMix' being renamed to 'Shuffle' in most other Pandora front-ends. + station.name = 'Shuffle' return list.insert(0, list.pop(list.index(station))) def _browse_stations(self): @@ -105,6 +84,28 @@ def _browse_stations(self): return station_directories + def _browse_tracks(self, uri): + + pandora_uri = PandoraUri.parse(uri) + + # TODO: should be able to perform check on is_ad() once dynamic tracklist support is available + # if not self._station or (not track.is_ad() and station_id != self._station.id): + if self._station is None or (pandora_uri.station_id != '' and pandora_uri.station_id != self._station.id): + + if pandora_uri.is_genre_station_uri(): + pandora_uri = self._create_station_for_genre(pandora_uri.token) + + self._station = self.backend.api.get_station(pandora_uri.station_id) + self._station_iter = iterate_forever(self._station.get_playlist) + + return [self.next_track()] + + 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) + + return StationUri.from_station(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())] @@ -127,7 +128,14 @@ def next_track(self): # TODO process add tokens properly when pydora 1.6 is available return self.next_track() - track = models.Ref.track(name=pandora_track.song_name, uri=TrackUri.from_track(pandora_track).uri) + track_uri = TrackUri.from_track(pandora_track) + track = models.Ref.track(name=pandora_track.song_name, uri=track_uri.uri) + + if any(self._uri_translation_map) and \ + track_uri.station_id != TrackUri.parse(self._uri_translation_map.keys()[0]).station_id: + # We've switched stations, clear the translation map. + self._uri_translation_map.clear() + self._uri_translation_map[track.uri] = pandora_track return track diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 145d814..abd2146 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -35,10 +35,11 @@ def __init__(self, audio, backend): def _auto_setup(self): - rpc.RPCClient.core_tracklist_set_repeat(False) - rpc.RPCClient.core_tracklist_set_consume(True) - rpc.RPCClient.core_tracklist_set_random(False) - rpc.RPCClient.core_tracklist_set_single(False) + # Setup player to mirror behaviour of official Pandora front-ends. + rpc.RPCClient.tracklist_set_repeat(False) + rpc.RPCClient.tracklist_set_consume(True) + rpc.RPCClient.tracklist_set_random(False) + rpc.RPCClient.tracklist_set_single(False) self.backend.setup_required = False @@ -53,13 +54,13 @@ def change_track(self, track): if track.uri is None: logger.warning("No URI for track '%s': cannot be played.", track.name) - self._check_skip_limit() + self._check_skip_limit_exceeded() return False try: pandora_track = self.backend.library.lookup_pandora_track(track.uri) - is_playable = pandora_track and pandora_track.audio_url and pandora_track.get_is_playable() + except requests.exceptions.RequestException as e: is_playable = False logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) @@ -70,20 +71,19 @@ def change_track(self, track): return super(PandoraPlaybackProvider, self).change_track(track) else: - # TODO: also remove from tracklist? Handled by consume? logger.warning("Audio URI for track '%s' cannot be played.", track.uri) - self._check_skip_limit() + self._check_skip_limit_exceeded() return False def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url - def _check_skip_limit(self): + def _check_skip_limit_exceeded(self): self.consecutive_track_skips += 1 if self.consecutive_track_skips >= self.SKIP_LIMIT: logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) - rpc.RPCClient.core_playback_stop() + rpc.RPCClient.playback_stop() return True return False @@ -138,12 +138,11 @@ def change_track(self, track): elif track.uri == self.previous_tl_track['track']['uri']: self.process_click(self.on_pause_previous_click, track.uri) - rpc.RPCClient.core_playback_resume() + rpc.RPCClient.playback_resume() self._sync_tracklist() return super(EventSupportPlaybackProvider, self).change_track(track) - def resume(self): if self.is_double_click() and self.get_time_position() > 0: self.process_click(self.on_pause_resume_click, self.current_tl_track['track']['uri']) @@ -194,27 +193,29 @@ def add_song_bookmark(self, track_token): @rpc.run_async def _sync_tracklist(self): + """ Sync the current tracklist information, to be used when the next + event needs to be processed. + """ # Wait until doubleclick events have finished processing self._doubleclick_processed_event.wait(self.thread_timeout) - previous_tl_track_queue = Queue.Queue(1) - current_tl_track_queue = Queue.Queue(1) - next_tl_track_queue = Queue.Queue(1) - length_queue = Queue.Queue(1) - index_queue = Queue.Queue(1) + previous_tl_track_q = Queue.Queue(1) + current_tl_track_q = Queue.Queue(1) + next_tl_track_q = Queue.Queue(1) + length_q = Queue.Queue(1) + index_q = Queue.Queue(1) try: - # Try to retrieve the current tlids, time out if not found - rpc.RPCClient.core_playback_get_current_tl_track(queue=current_tl_track_queue) - rpc.RPCClient.core_tracklist_get_length(queue=length_queue) + rpc.RPCClient.playback_get_current_tl_track(queue=current_tl_track_q) + rpc.RPCClient.tracklist_get_length(queue=length_q) - self.current_tl_track = current_tl_track_queue.get(timeout=self.thread_timeout) + self.current_tl_track = current_tl_track_q.get(timeout=self.thread_timeout) - rpc.RPCClient.core_tracklist_index(tlid=self.current_tl_track['tlid'], queue=index_queue) + rpc.RPCClient.tracklist_index(tlid=self.current_tl_track['tlid'], queue=index_q) - tl_index = index_queue.get(timeout=self.thread_timeout) - tl_length = length_queue.get(timeout=self.thread_timeout) + tl_index = index_q.get(timeout=self.thread_timeout) + tl_length = length_q.get(timeout=self.thread_timeout) # TODO note that tlid's will be changed to start at '1' instead of '0' in the next release of Mopidy. # the following statement should change to 'if index >= length:' when that happens. @@ -222,17 +223,15 @@ def _sync_tracklist(self): if tl_index >= tl_length-1: # We're at the end of the tracklist, add the next Pandora track track = self.backend.library.next_track() - add_track_queue = Queue.Queue() - rpc.RPCClient.core_tracklist_add(uris=[track.uri], queue=add_track_queue) - add_track_queue.get(timeout=self.thread_timeout*2) + t = rpc.RPCClient.tracklist_add(uris=[track.uri]) + t.join(self.thread_timeout*2) - # 'Previous' and 'next' would have changed after adding the new track. Fetch again. - rpc.RPCClient.core_tracklist_previous_track(self.current_tl_track, queue=previous_tl_track_queue) - rpc.RPCClient.core_tracklist_next_track(self.current_tl_track, queue=next_tl_track_queue) + rpc.RPCClient.tracklist_previous_track(self.current_tl_track, queue=previous_tl_track_q) + rpc.RPCClient.tracklist_next_track(self.current_tl_track, queue=next_tl_track_q) - self.previous_tl_track = previous_tl_track_queue.get(timeout=self.thread_timeout) - self.next_tl_track = next_tl_track_queue.get(timeout=self.thread_timeout) + self.previous_tl_track = previous_tl_track_q.get(timeout=self.thread_timeout) + self.next_tl_track = next_tl_track_q.get(timeout=self.thread_timeout) self._doubleclick_processed_event.set() @@ -243,10 +242,10 @@ def _sync_tracklist(self): finally: # Cleanup asynchronous queues - previous_tl_track_queue.task_done() - current_tl_track_queue.task_done() - next_tl_track_queue.task_done() - length_queue.task_done() - index_queue.task_done() + previous_tl_track_q.task_done() + current_tl_track_q.task_done() + next_tl_track_q.task_done() + length_q.task_done() + index_q.task_done() return True diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/rpc.py index 4cfa019..84b749d 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/rpc.py @@ -74,52 +74,51 @@ def _do_rpc(cls, method, params=None, queue=None): return json_data['result'] @classmethod - def core_tracklist_set_repeat(cls, value=True, queue=None): + def tracklist_set_repeat(cls, value=True, queue=None): return cls._do_rpc('core.tracklist.set_repeat', params={'value': value}, queue=queue) @classmethod - def core_tracklist_set_consume(cls, value=True, queue=None): + def tracklist_set_consume(cls, value=True, queue=None): return cls._do_rpc('core.tracklist.set_consume', params={'value': value}, queue=queue) @classmethod - def core_tracklist_set_single(cls, value=True, queue=None): + def tracklist_set_single(cls, value=True, queue=None): return cls._do_rpc('core.tracklist.set_single', params={'value': value}, queue=queue) @classmethod - def core_tracklist_set_random(cls, value=True, queue=None): + def tracklist_set_random(cls, value=True, queue=None): return cls._do_rpc('core.tracklist.set_random', params={'value': value}, queue=queue) @classmethod - def core_playback_resume(cls, queue=None): + def playback_resume(cls, queue=None): return cls._do_rpc('core.playback.resume', queue=queue) @classmethod - def core_playback_stop(cls, queue=None): + def playback_stop(cls, queue=None): return cls._do_rpc('core.playback.stop', queue=queue) @classmethod - def core_tracklist_previous_track(cls, tl_track, queue=None): + def tracklist_previous_track(cls, tl_track, queue=None): return cls._do_rpc('core.tracklist.previous_track', params={'tl_track': tl_track}, queue=queue) @classmethod - def core_playback_get_current_tl_track(cls, queue=None): + def playback_get_current_tl_track(cls, queue=None): return cls._do_rpc('core.playback.get_current_tl_track', queue=queue) @classmethod - def core_tracklist_next_track(cls, tl_track, queue=None): + def tracklist_next_track(cls, tl_track, queue=None): return cls._do_rpc('core.tracklist.next_track', params={'tl_track': tl_track}, queue=queue) - @classmethod - def core_tracklist_index(cls, tl_track=None, tlid=None, queue=None): + def tracklist_index(cls, tl_track=None, tlid=None, queue=None): return cls._do_rpc('core.tracklist.index', params={'tl_track': tl_track, 'tlid': tlid}, queue=queue) @classmethod - def core_tracklist_get_length(cls, queue=None): + def tracklist_get_length(cls, queue=None): return cls._do_rpc('core.tracklist.get_length', queue=queue) @classmethod - def core_tracklist_add(cls, tracks=None, at_position=None, uri=None, uris=None, queue=None): + def tracklist_add(cls, tracks=None, at_position=None, uri=None, uris=None, queue=None): return cls._do_rpc('core.tracklist.add', params={'tracks': tracks, 'at_position': at_position, 'uri': uri, 'uris': uris}, queue=queue) diff --git a/tests/test_playback.py b/tests/test_playback.py index b380b5c..5fb06c8 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -98,10 +98,10 @@ def test_change_track_enforces_skip_limit(provider): with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=False): track = models.Track(uri="pandora:track:test_station_id:test_token") - rpc.RPCClient.core_playback_stop = mock.PropertyMock() + rpc.RPCClient.playback_stop = mock.PropertyMock() assert provider.change_track(track) is False - rpc.RPCClient.core_playback_stop.assert_called_once_with() + rpc.RPCClient.playback_stop.assert_called_once_with() assert PlaylistItem.get_is_playable.call_count == PandoraPlaybackProvider.SKIP_LIMIT @@ -112,7 +112,7 @@ def test_change_track_handles_request_exceptions(config, caplog): playback = conftest.get_backend(config).playback rpc.RPCClient._do_rpc = mock.PropertyMock() - rpc.RPCClient.core_playback_stop = mock.PropertyMock() + rpc.RPCClient.playback_stop = mock.PropertyMock() assert playback.change_track(track) is False assert 'Error changing track' in caplog.text() @@ -212,7 +212,7 @@ def test_is_playable_handles_request_exceptions(provider, caplog): with mock.patch.object(PlaylistItem, 'get_is_playable', conftest.request_exception_mock): track = models.Track(uri="pandora:track:test::::") - rpc.RPCClient.core_playback_stop = mock.PropertyMock() + rpc.RPCClient.playback_stop = mock.PropertyMock() assert provider.change_track(track) is False assert 'Error checking if track is playable' in caplog.text() From 36f45374bf9c0e1a356914407d2148e4f8441afc Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 3 Dec 2015 08:25:26 +0200 Subject: [PATCH 045/311] WIP: update test cases. --- mopidy_pandora/library.py | 6 ++++-- mopidy_pandora/rpc.py | 1 - tests/test_client.py | 5 +++-- tests/test_library.py | 9 +++++---- tests/test_playback.py | 33 ++++++++++++++++++--------------- 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 9cab7de..aaaf1b4 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,4 +1,5 @@ from mopidy import backend, models + from pandora.models.pandora import Station from pydora.utils import iterate_forever @@ -131,8 +132,9 @@ def next_track(self): track_uri = TrackUri.from_track(pandora_track) track = models.Ref.track(name=pandora_track.song_name, uri=track_uri.uri) - if any(self._uri_translation_map) and \ - track_uri.station_id != TrackUri.parse(self._uri_translation_map.keys()[0]).station_id: + if any(self._uri_translation_map) and track_uri.station_id != \ + TrackUri.parse(self._uri_translation_map.keys()[0]).station_id: + # We've switched stations, clear the translation map. self._uri_translation_map.clear() diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/rpc.py index 84b749d..d8db20d 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/rpc.py @@ -1,4 +1,3 @@ -import Queue import json import requests diff --git a/tests/test_client.py b/tests/test_client.py index 9576c16..c96faa3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,9 +23,10 @@ def test_get_station_list(config): station_list = backend.api.get_station_list() - assert len(station_list) == 2 + assert len(station_list) == len(conftest.station_list_result_mock()['stations']) assert station_list[0].name == conftest.MOCK_STATION_NAME + " 2" assert station_list[1].name == conftest.MOCK_STATION_NAME + " 1" + assert station_list[2].name == "QuickMix" def test_get_station_list_changed(config): @@ -54,7 +55,7 @@ def test_get_station_list_changed(config): backend.api.get_station_list() assert backend.api._station_list.checksum == conftest.MOCK_STATION_LIST_CHECKSUM - assert len(backend.api._station_list) == 2 + assert len(backend.api._station_list) == len(conftest.station_list_result_mock()['stations']) def test_get_station_list_handles_request_exception(config, caplog): diff --git a/tests/test_library.py b/tests/test_library.py index d4da9b2..97d3e7d 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -68,7 +68,7 @@ def test_browse_directory_uri(config): assert results[0].uri == PandoraUri('genres').uri assert results[1].type == models.Ref.DIRECTORY - assert results[1].name == PandoraLibraryProvider.SHUFFLE_STATION_NAME + assert results[1].name == 'Shuffle' assert results[1].uri == StationUri.from_station( Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][2])).uri @@ -92,7 +92,7 @@ def test_browse_directory_sort_za(config): results = backend.library.browse(backend.library.root_directory.uri) assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME - assert results[1].name == PandoraLibraryProvider.SHUFFLE_STATION_NAME + assert results[1].name == 'Shuffle' assert results[2].name == conftest.MOCK_STATION_NAME + " 1" assert results[3].name == conftest.MOCK_STATION_NAME + " 2" @@ -106,7 +106,7 @@ def test_browse_directory_sort_date(config): results = backend.library.browse(backend.library.root_directory.uri) assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME - assert results[1].name == PandoraLibraryProvider.SHUFFLE_STATION_NAME + assert results[1].name == 'Shuffle' assert results[2].name == conftest.MOCK_STATION_NAME + " 2" assert results[3].name == conftest.MOCK_STATION_NAME + " 1" @@ -120,4 +120,5 @@ def test_browse_station_uri(config, station_mock): results = backend.library.browse(station_uri.uri) - assert len(results) == 3 + # Station should just contain the first track to be played. + assert len(results) == 1 diff --git a/tests/test_playback.py b/tests/test_playback.py index 5fb06c8..bd1e425 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -34,8 +34,11 @@ def audio_mock(): @pytest.fixture def provider(audio_mock, config): if config['pandora']['event_support_enabled']: - return playback.EventSupportPlaybackProvider( + provider = playback.EventSupportPlaybackProvider( audio=audio_mock, backend=conftest.get_backend(config)) + + provider.current_tl_track = {'track': {'uri': 'test'}} + return provider else: return playback.PandoraPlaybackProvider( audio=audio_mock, backend=conftest.get_backend(config)) @@ -54,17 +57,17 @@ def test_change_track_aborts_if_no_track_uri(provider): def test_pause_starts_double_click_timer(provider): with mock.patch.object(PandoraPlaybackProvider, 'get_time_position', return_value=100): assert provider.backend.supports_events - assert provider._double_click_handler.get_click_time() == 0 + assert provider.get_click_time() == 0 provider.pause() - assert provider._double_click_handler.get_click_time() > 0 + assert provider.get_click_time() > 0 def test_pause_does_not_start_timer_at_track_start(provider): with mock.patch.object(PandoraPlaybackProvider, 'get_time_position', return_value=0): assert provider.backend.supports_events - assert provider._double_click_handler.get_click_time() == 0 + assert provider.get_click_time() == 0 provider.pause() - assert provider._double_click_handler.get_click_time() == 0 + assert provider.get_click_time() == 0 def test_resume_checks_for_double_click(provider): @@ -72,11 +75,11 @@ def test_resume_checks_for_double_click(provider): assert provider.backend.supports_events is_double_click_mock = mock.PropertyMock() process_click_mock = mock.PropertyMock() - provider._double_click_handler.is_double_click = is_double_click_mock - provider._double_click_handler.process_click = process_click_mock + provider.is_double_click = is_double_click_mock + provider.process_click = process_click_mock provider.resume() - provider._double_click_handler.is_double_click.assert_called_once_with() + provider.is_double_click.assert_called_once_with() def test_change_track(audio_mock, provider): @@ -135,8 +138,8 @@ def set_event(): process_click_mock = mock.PropertyMock() - provider._double_click_handler.process_click = process_click_mock - provider._double_click_handler.set_click_time() + provider.process_click = process_click_mock + provider.set_click_time() provider.active_track_uri = track_0 provider.change_track(models.Track(uri=track_1)) @@ -166,8 +169,8 @@ def set_event(): click_interval = float(config['pandora']['double_click_interval']) + 1.0 - provider._double_click_handler.process_click = process_click_mock - provider._double_click_handler.set_click_time(time.time() - click_interval) + provider.process_click = process_click_mock + provider.set_click_time(time.time() - click_interval) provider.active_track_uri = track_0 provider.change_track(models.Track(uri=track_1)) @@ -193,10 +196,10 @@ def set_event(): track_1 = TrackUri.from_track(playlist_item_mock, 1).uri e = PandoraException().from_code(0000, "Mock exception") - provider._double_click_handler.thumbs_down = mock.Mock() - provider._double_click_handler.thumbs_down.side_effect = e + provider.thumbs_down = mock.Mock() + provider.thumbs_down.side_effect = e - provider._double_click_handler.set_click_time() + provider.set_click_time() provider.active_track_uri = track_0 provider.change_track(models.Track(uri=track_1)) From 172cf1db890ef0ddc98586a43839822cd0620147 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 3 Dec 2015 09:54:01 +0200 Subject: [PATCH 046/311] Run startup routines asynchronously to improve performance. Prefetch stations and genres. --- mopidy_pandora/backend.py | 3 +++ mopidy_pandora/library.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 26d0212..cb0318e 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -47,9 +47,12 @@ def __init__(self, config, audio): self.uri_schemes = ['pandora'] + @rpc.run_async def on_start(self): try: self.api.login(self._config["username"], self._config["password"]) + # Prefetch list of stations linked to the user's profile + self.api.get_station_list() except requests.exceptions.RequestException as e: logger.error('Error logging in to Pandora: %s', encoding.locale_decode(e)) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index aaaf1b4..758fd98 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -4,6 +4,8 @@ from pydora.utils import iterate_forever +from mopidy_pandora import rpc + from mopidy_pandora.uri import GenreUri, logger, PandoraUri, StationUri, TrackUri # noqa I101 @@ -25,6 +27,8 @@ def __init__(self, backend, sort_order): def browse(self, uri): if uri == self.root_directory.uri: + # Prefetch genre category list + rpc.run_async(self.backend.api.get_genre_stations) return self._browse_stations() if uri == self.genre_directory.uri: From 454c3f327f5dba411c7160d009d8a920fcffdaa8 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 3 Dec 2015 10:23:30 +0200 Subject: [PATCH 047/311] Make refreshing of station list caches optional. --- mopidy_pandora/client.py | 8 ++++---- mopidy_pandora/library.py | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index cf289ce..ae7d8f8 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -23,9 +23,9 @@ def __init__(self, transport, partner_user, partner_password, device, self._station_list = [] self._genre_stations = [] - def get_station_list(self): + def get_station_list(self, refresh_cache=True): - if not any(self._station_list) or self._station_list.has_changed(): + if not any(self._station_list) or (refresh_cache and self._station_list.has_changed()): try: self._station_list = super(MopidyPandoraAPIClient, self).get_station_list() except requests.exceptions.RequestException as e: @@ -41,9 +41,9 @@ def get_station(self, station_id): # Could not find station_id in cached list, try retrieving from Pandora server. return super(MopidyPandoraAPIClient, self).get_station(station_id) - def get_genre_stations(self): + def get_genre_stations(self, refresh_cache=True): - if not any(self._genre_stations) or self._genre_stations.has_changed(): + if not any(self._genre_stations) or (refresh_cache and self._genre_stations.has_changed()): try: self._genre_stations = super(MopidyPandoraAPIClient, self).get_genre_stations() # if any(self._genre_stations): diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 758fd98..d7da910 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -117,7 +117,8 @@ def _browse_genre_categories(self): def _browse_genre_stations(self, uri): return [models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri) - for station in self.backend.api.get_genre_stations()[GenreUri.parse(uri).category_name]] + for station in self.backend.api.get_genre_stations(refresh_cache=False) + [GenreUri.parse(uri).category_name]] def lookup_pandora_track(self, uri): try: From bec20da6d491aa4b25e39d3af33534e6bc745ec1 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 3 Dec 2015 15:59:21 +0200 Subject: [PATCH 048/311] WIP: update test cases. --- mopidy_pandora/playback.py | 2 +- tests/test_backend.py | 3 +- tests/test_doubleclick.py | 207 ------------------------ tests/test_playback.py | 323 +++++++++++++++++++++++-------------- 4 files changed, 203 insertions(+), 332 deletions(-) delete mode 100644 tests/test_doubleclick.py diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index abd2146..340cbf7 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -81,7 +81,7 @@ def translate_uri(self, uri): def _check_skip_limit_exceeded(self): self.consecutive_track_skips += 1 - if self.consecutive_track_skips >= self.SKIP_LIMIT: + if self.consecutive_track_skips >= self.SKIP_LIMIT-1: logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) rpc.RPCClient.playback_stop() return True diff --git a/tests/test_backend.py b/tests/test_backend.py index 526a170..d1d5595 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -70,7 +70,8 @@ def test_on_start_handles_request_exception(config, caplog): backend = get_backend(config, True) backend.api.login = request_exception_mock - backend.on_start() + t = backend.on_start() + t.join() # Check that request exceptions are caught and logged assert 'Error logging in to Pandora' in caplog.text() diff --git a/tests/test_doubleclick.py b/tests/test_doubleclick.py deleted file mode 100644 index 983090f..0000000 --- a/tests/test_doubleclick.py +++ /dev/null @@ -1,207 +0,0 @@ -from __future__ import unicode_literals - -import Queue - -import time - -import conftest - -import mock - -import pytest - -from mopidy_pandora import rpc - -from mopidy_pandora.backend import MopidyPandoraAPIClient -from mopidy_pandora.doubleclick import DoubleClickHandler -from mopidy_pandora.playback import PandoraPlaybackProvider -from mopidy_pandora.uri import PandoraUri, TrackUri - - -@pytest.fixture(scope="session") -def client_mock(): - client_mock = mock.Mock(spec=MopidyPandoraAPIClient) - return client_mock - - -@pytest.fixture -def handler(config): - - handler = DoubleClickHandler(conftest.get_backend(config)) - add_feedback_mock = mock.PropertyMock() - handler.api.add_feedback = add_feedback_mock - - sleep_mock = mock.PropertyMock() - handler.api.sleep_song = sleep_mock - - handler.set_click_time() - - return handler - - -def test_is_double_click(handler): - - assert handler.is_double_click() - - time.sleep(float(handler.double_click_interval) + 0.1) - assert handler.is_double_click() is False - - -def test_is_double_click_resets_click_time(handler): - - assert handler.is_double_click() - - time.sleep(float(handler.double_click_interval) + 0.1) - assert handler.is_double_click() is False - - assert handler.get_click_time() == 0 - - -def test_on_change_track_forward(config, handler, playlist_item_mock): - with mock.patch.object(rpc.RPCClient, '_do_rpc', mock.PropertyMock()): - with mock.patch.object(Queue.Queue, 'get', mock.PropertyMock()): - - handler.is_double_click = mock.PropertyMock(return_value=True) - handler.process_click = mock.PropertyMock() - - handler.previous_tlid_queue = mock.PropertyMock() - handler.next_tlid_queue = mock.PropertyMock() - - rpc.RPCClient.core_playback_get_current_tlid = mock.PropertyMock() - - track_0 = TrackUri.from_track(playlist_item_mock).uri - - handler.on_change_track(1) - handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_0) - # handler.on_change_track(track_1, track_2) - # handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_1) - # handler.on_change_track(track_2, track_0) - # handler.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track_2) - - -def test_on_change_track_back(config, handler, playlist_item_mock): - - track_0 = TrackUri.from_track(playlist_item_mock).uri - track_1 = TrackUri.from_track(playlist_item_mock).uri - track_2 = TrackUri.from_track(playlist_item_mock).uri - - process_click_mock = mock.PropertyMock() - handler.process_click = process_click_mock - - handler.on_change_track(track_2, track_1) - handler.process_click.assert_called_with(config['pandora']['on_pause_previous_click'], track_2) - handler.on_change_track(track_1, track_0) - handler.process_click.assert_called_with(config['pandora']['on_pause_previous_click'], track_1) - handler.on_change_track(track_0, track_0) - handler.process_click.assert_called_with(config['pandora']['on_pause_previous_click'], track_0) - - -def test_on_resume_click_ignored_if_start_of_track(handler, playlist_item_mock): - - process_click_mock = mock.PropertyMock() - handler.process_click = process_click_mock - handler.on_resume_click(TrackUri.from_track(playlist_item_mock).uri) - - handler.process_click.assert_not_called() - - -def test_on_resume_click(config, handler, playlist_item_mock): - with mock.patch.object(PandoraPlaybackProvider, 'get_time_position', return_value=100): - - process_click_mock = mock.PropertyMock() - handler.process_click = process_click_mock - - track_uri = TrackUri.from_track(playlist_item_mock).uri - handler.on_resume_click(track_uri, 100) - - handler.process_click.assert_called_once_with(config['pandora']['on_pause_resume_click'], track_uri) - - -def test_process_click_resets_click_time(config, handler, playlist_item_mock): - - thumbs_up_mock = mock.PropertyMock() - - handler.thumbs_up = thumbs_up_mock - - track_uri = TrackUri.from_track(playlist_item_mock).uri - - handler.process_click(config['pandora']['on_pause_resume_click'], track_uri) - - assert handler.get_click_time() == 0 - - -def test_process_click_resume(config, handler, playlist_item_mock): - - thumbs_up_mock = mock.PropertyMock() - - handler.thumbs_up = thumbs_up_mock - - track_uri = TrackUri.from_track(playlist_item_mock).uri - - handler.process_click(config['pandora']['on_pause_resume_click'], track_uri) - - token = PandoraUri.parse(track_uri).token - handler.thumbs_up.assert_called_once_with(token) - - -def test_process_click_next(config, handler, playlist_item_mock): - - thumbs_down_mock = mock.PropertyMock() - - handler.thumbs_down = thumbs_down_mock - - track_uri = TrackUri.from_track(playlist_item_mock).uri - - handler.process_click(config['pandora']['on_pause_next_click'], track_uri) - - token = PandoraUri.parse(track_uri).token - handler.thumbs_down.assert_called_once_with(token) - - -def test_process_click_previous(config, handler, playlist_item_mock): - - sleep_mock = mock.PropertyMock() - - handler.sleep = sleep_mock - - track_uri = TrackUri.from_track(playlist_item_mock).uri - - handler.process_click(config['pandora']['on_pause_previous_click'], track_uri) - - token = PandoraUri.parse(track_uri).token - handler.sleep.assert_called_once_with(token) - - -def test_thumbs_up(handler): - - handler.thumbs_up(conftest.MOCK_TRACK_TOKEN) - - handler.client.add_feedback.assert_called_once_with(conftest.MOCK_TRACK_TOKEN, True) - - -def test_thumbs_down(handler): - - handler.thumbs_down(conftest.MOCK_TRACK_TOKEN) - - handler.client.add_feedback.assert_called_once_with(conftest.MOCK_TRACK_TOKEN, False) - - -def test_sleep(handler): - - handler.sleep(conftest.MOCK_TRACK_TOKEN) - - handler.client.sleep_song.assert_called_once_with(conftest.MOCK_TRACK_TOKEN) - - -def add_artist_bookmark(handler): - - handler.add_artist_bookmark(conftest.MOCK_TRACK_TOKEN) - - handler.client.add_artist_bookmark.assert_called_once_with(conftest.MOCK_TRACK_TOKEN) - - -def add_song_bookmark(handler): - - handler.add_song_bookmark(conftest.MOCK_TRACK_TOKEN) - - handler.client.add_song_bookmark.assert_called_once_with(conftest.MOCK_TRACK_TOKEN) diff --git a/tests/test_playback.py b/tests/test_playback.py index bd1e425..a987e67 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -9,9 +9,7 @@ from mopidy import audio, backend as backend_api, models -from pandora.errors import PandoraException - -from pandora.models.pandora import PlaylistItem, Station +from pandora.models.pandora import PlaylistItem import pytest @@ -20,9 +18,9 @@ from mopidy_pandora.backend import MopidyPandoraAPIClient from mopidy_pandora.library import PandoraLibraryProvider -from mopidy_pandora.playback import PandoraPlaybackProvider +from mopidy_pandora.playback import EventSupportPlaybackProvider, PandoraPlaybackProvider -from mopidy_pandora.uri import TrackUri +from mopidy_pandora.uri import PandoraUri, TrackUri @pytest.fixture @@ -33,16 +31,28 @@ def audio_mock(): @pytest.fixture def provider(audio_mock, config): + + provider = None + if config['pandora']['event_support_enabled']: provider = playback.EventSupportPlaybackProvider( audio=audio_mock, backend=conftest.get_backend(config)) provider.current_tl_track = {'track': {'uri': 'test'}} - return provider + + provider._sync_tracklist = mock.PropertyMock() else: - return playback.PandoraPlaybackProvider( + provider = playback.PandoraPlaybackProvider( audio=audio_mock, backend=conftest.get_backend(config)) + return provider + + +@pytest.fixture(scope="session") +def client_mock(): + client_mock = mock.Mock(spec=MopidyPandoraAPIClient) + return client_mock + def test_is_a_playback_provider(provider): assert isinstance(provider, backend_api.PlaybackProvider) @@ -82,180 +92,247 @@ def test_resume_checks_for_double_click(provider): provider.is_double_click.assert_called_once_with() -def test_change_track(audio_mock, provider): - with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=conftest.playlist_item_mock): - with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=True): - track = models.Track(uri=TrackUri.from_track(conftest.playlist_item_mock()).uri) +def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): + with mock.patch.object(EventSupportPlaybackProvider, 'is_double_click', return_value=False): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=None): + track = TrackUri.from_track(playlist_item_mock) + + process_click_mock = mock.PropertyMock() + provider.process_click = process_click_mock + + provider.previous_tl_track = {'track': {'uri': 'previous_track'}} + provider.next_tl_track = {'track': {'uri': track.uri}} + + rpc.RPCClient.playback_stop = mock.PropertyMock() + + for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): + provider.change_track(track) is False + + assert rpc.RPCClient.playback_stop.called + assert "Maximum track skip limit (%s) exceeded, stopping...", \ + PandoraPlaybackProvider.SKIP_LIMIT in caplog.text() + + # with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): + # with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): + # with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=False): + # track = models.Track(uri="pandora:track:test_station_id:test_token") + # + # rpc.RPCClient.playback_stop = mock.PropertyMock() + # + # assert provider.change_track(track) is False + # rpc.RPCClient.playback_stop.assert_called_once_with() + # assert PlaylistItem.get_is_playable.call_count == PandoraPlaybackProvider.SKIP_LIMIT + + +def test_change_track_resumes_playback(provider, playlist_item_mock): + with mock.patch.object(EventSupportPlaybackProvider, 'is_double_click', return_value=True): + track = TrackUri.from_track(playlist_item_mock) + + process_click_mock = mock.PropertyMock() + provider.process_click = process_click_mock - assert provider.change_track(track) is True - assert audio_mock.prepare_change.call_count == 0 - assert audio_mock.start_playback.call_count == 0 - audio_mock.set_uri.assert_called_once_with(PlaylistItem.get_audio_url( - conftest.playlist_result_mock()["result"]["items"][0], - conftest.MOCK_DEFAULT_AUDIO_QUALITY)) + provider.previous_tl_track = {'track': {'uri': 'previous_track'}} + provider.next_tl_track = {'track': {'uri': track.uri}} + rpc.RPCClient.playback_resume = mock.PropertyMock() -def test_change_track_enforces_skip_limit(provider): - with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): - with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): - with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=False): - track = models.Track(uri="pandora:track:test_station_id:test_token") + provider.change_track(track) + assert rpc.RPCClient.playback_resume.called - rpc.RPCClient.playback_stop = mock.PropertyMock() - assert provider.change_track(track) is False - rpc.RPCClient.playback_stop.assert_called_once_with() - assert PlaylistItem.get_is_playable.call_count == PandoraPlaybackProvider.SKIP_LIMIT +def test_change_track_does_not_resume_playback_if_not_doubleclick(provider, playlist_item_mock): + with mock.patch.object(EventSupportPlaybackProvider, 'is_double_click', return_value=False): + track = TrackUri.from_track(playlist_item_mock) + process_click_mock = mock.PropertyMock() + provider.process_click = process_click_mock + + provider.previous_tl_track = {'track': {'uri': 'previous_track'}} + provider.next_tl_track = {'track': {'uri': track.uri}} + + rpc.RPCClient.playback_resume = mock.PropertyMock() + + provider.change_track(track) + assert not rpc.RPCClient.playback_resume.called + + +def test_change_track_handles_request_exceptions(config, caplog, playlist_item_mock): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): + with mock.patch.object(PlaylistItem, 'get_is_playable', conftest.request_exception_mock): -def test_change_track_handles_request_exceptions(config, caplog): - with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): - with mock.patch.object(Station, 'get_playlist', conftest.request_exception_mock): track = models.Track(uri="pandora:track:test_station_id:test_token") - playback = conftest.get_backend(config).playback + playback = conftest.get_backend(config, True).playback + rpc.RPCClient._do_rpc = mock.PropertyMock() rpc.RPCClient.playback_stop = mock.PropertyMock() + rpc.RPCClient.playback_resume = mock.PropertyMock() + assert playback.change_track(track) is False - assert 'Error changing track' in caplog.text() + assert 'Error checking if track is playable' in caplog.text() -def test_change_track_resumes_playback(provider, playlist_item_mock): - with mock.patch.object(PandoraPlaybackProvider, 'change_track', return_value=True): - with mock.patch.object(PandoraPlaybackProvider, 'resume_playback') as mock_rpc: - assert provider.backend.supports_events +def test_change_track_handles_unplayable(provider, caplog): - event = threading.Event() + track = models.Track(uri="pandora:track:test_station_id:test_token") - def set_event(): - event.set() + provider.previous_tl_track = {'track': {'uri': track.uri}} + provider.next_tl_track = {'track': {'uri': 'next_track'}} - mock_rpc.side_effect = set_event + rpc.RPCClient.playback_resume = mock.PropertyMock() - track_0 = TrackUri.from_track(playlist_item_mock, 0).uri - track_1 = TrackUri.from_track(playlist_item_mock, 1).uri + assert provider.change_track(track) is False + assert "Audio URI for track '%s' cannot be played", track.uri in caplog.text() - process_click_mock = mock.PropertyMock() - provider.process_click = process_click_mock - provider.set_click_time() - provider.active_track_uri = track_0 +def test_translate_uri_returns_audio_url(provider, playlist_item_mock): - provider.change_track(models.Track(uri=track_1)) + test_uri = "pandora:track:test_station_id:test_token" - if event.wait(timeout=1.0): - mock_rpc.assert_called_once_with() - else: - assert False + provider.backend.library._uri_translation_map[test_uri] = playlist_item_mock + assert provider.translate_uri(test_uri) == conftest.MOCK_TRACK_AUDIO_HIGH -def test_change_track_does_not_resume_playback_if_not_doubleclick(config, provider, playlist_item_mock): - with mock.patch.object(PandoraPlaybackProvider, 'change_track', return_value=True): - with mock.patch.object(PandoraPlaybackProvider, 'resume_playback') as mock_rpc: - assert provider.backend.supports_events - event = threading.Event() +def test_auto_setup_only_called_once(provider): + with mock.patch.multiple('mopidy_pandora.rpc.RPCClient', tracklist_set_repeat=mock.DEFAULT, + tracklist_set_random=mock.DEFAULT, tracklist_set_consume=mock.DEFAULT, + tracklist_set_single=mock.DEFAULT) as values: - def set_event(): - event.set() + event = threading.Event() - mock_rpc.side_effect = set_event + def set_event(*args, **kwargs): + event.set() - track_0 = TrackUri.from_track(playlist_item_mock, 0).uri - track_1 = TrackUri.from_track(playlist_item_mock, 1).uri + values['tracklist_set_single'].side_effect = set_event - process_click_mock = mock.PropertyMock() + provider.prepare_change() - click_interval = float(config['pandora']['double_click_interval']) + 1.0 + if event.wait(timeout=1.0): + values['tracklist_set_repeat'].assert_called_once_with(False) + values['tracklist_set_random'].assert_called_once_with(False) + values['tracklist_set_consume'].assert_called_once_with(True) + values['tracklist_set_single'].assert_called_once_with(False) + else: + assert False - provider.process_click = process_click_mock - provider.set_click_time(time.time() - click_interval) - provider.active_track_uri = track_0 - provider.change_track(models.Track(uri=track_1)) + event = threading.Event() + values['tracklist_set_single'].side_effect = set_event + + provider.prepare_change() if event.wait(timeout=1.0): assert False else: - assert not mock_rpc.called + values['tracklist_set_repeat'].assert_called_once_with(False) + values['tracklist_set_random'].assert_called_once_with(False) + values['tracklist_set_consume'].assert_called_once_with(True) + values['tracklist_set_single'].assert_called_once_with(False) -def test_change_track_does_not_resume_playback_if_event_failed(provider, playlist_item_mock): - with mock.patch.object(PandoraPlaybackProvider, 'change_track', return_value=True): - with mock.patch.object(PandoraPlaybackProvider, 'resume_playback') as mock_rpc: - assert provider.backend.supports_events +def test_is_double_click(provider): - event = threading.Event() + provider.set_click_time() + assert provider.is_double_click() - def set_event(): - event.set() + time.sleep(float(provider.double_click_interval) + 0.1) + assert provider.is_double_click() is False - mock_rpc.side_effect = set_event - track_0 = TrackUri.from_track(playlist_item_mock, 0).uri - track_1 = TrackUri.from_track(playlist_item_mock, 1).uri +def test_is_double_click_resets_click_time(provider): - e = PandoraException().from_code(0000, "Mock exception") - provider.thumbs_down = mock.Mock() - provider.thumbs_down.side_effect = e + provider.set_click_time() + assert provider.is_double_click() - provider.set_click_time() - provider.active_track_uri = track_0 - provider.change_track(models.Track(uri=track_1)) + time.sleep(float(provider.double_click_interval) + 0.1) + assert provider.is_double_click() is False - if event.wait(timeout=1.0): - assert False - else: - assert not mock_rpc.called + assert provider.get_click_time() == 0 -def test_is_playable_handles_request_exceptions(provider, caplog): - with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): - with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): - with mock.patch.object(PlaylistItem, 'get_is_playable', conftest.request_exception_mock): - track = models.Track(uri="pandora:track:test::::") +def test_change_track_next(config, provider, playlist_item_mock): - rpc.RPCClient.playback_stop = mock.PropertyMock() + provider.set_click_time() + track = TrackUri.from_track(playlist_item_mock) - assert provider.change_track(track) is False - assert 'Error checking if track is playable' in caplog.text() + process_click_mock = mock.PropertyMock() + provider.process_click = process_click_mock + provider.previous_tl_track = {'track': {'uri': 'previous_track'}} + provider.next_tl_track = {'track': {'uri': track.uri}} -def test_translate_uri_returns_audio_url(provider): - assert provider.lookup_pandora_track("pandora:track:test:::::audio_url") == "audio_url" + rpc.RPCClient._do_rpc = mock.PropertyMock() + provider.change_track(track) + provider.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track.uri) -def test_auto_setup_only_called_once(provider): - with mock.patch.multiple('mopidy_pandora.rpc.RPCClient', core_tracklist_set_repeat=mock.DEFAULT, - core_tracklist_set_random=mock.DEFAULT, core_tracklist_set_consume=mock.DEFAULT, - core_tracklist_set_single=mock.DEFAULT) as values: - event = threading.Event() +def test_change_track_back(config, provider, playlist_item_mock): - def set_event(*args, **kwargs): - event.set() + provider.set_click_time() + track = TrackUri.from_track(playlist_item_mock) - values['core_tracklist_set_single'].side_effect = set_event + process_click_mock = mock.PropertyMock() + provider.process_click = process_click_mock - provider.prepare_change() + provider.previous_tl_track = {'track': {'uri': track.uri}} + provider.next_tl_track = {'track': {'uri': 'next_track'}} - if event.wait(timeout=1.0): - values['core_tracklist_set_repeat'].assert_called_once_with(False) - values['core_tracklist_set_random'].assert_called_once_with(False) - values['core_tracklist_set_consume'].assert_called_once_with(False) - values['core_tracklist_set_single'].assert_called_once_with(False) - else: - assert False + rpc.RPCClient._do_rpc = mock.PropertyMock() - event = threading.Event() - values['core_tracklist_set_single'].side_effect = set_event + provider.change_track(track) + provider.process_click.assert_called_with(config['pandora']['on_pause_previous_click'], track.uri) - provider.prepare_change() - if event.wait(timeout=1.0): - assert False - else: - values['core_tracklist_set_repeat'].assert_called_once_with(False) - values['core_tracklist_set_random'].assert_called_once_with(False) - values['core_tracklist_set_consume'].assert_called_once_with(False) - values['core_tracklist_set_single'].assert_called_once_with(False) +def test_resume_click_ignored_if_start_of_track(provider): + with mock.patch.object(PandoraPlaybackProvider, 'get_time_position', return_value=0): + + process_click_mock = mock.PropertyMock() + provider.process_click = process_click_mock + + provider.resume() + + provider.process_click.assert_not_called() + + +def test_process_click_resets_click_time(config, provider, playlist_item_mock): + + provider.thumbs_up = mock.PropertyMock() + + track_uri = TrackUri.from_track(playlist_item_mock).uri + + provider.process_click(config['pandora']['on_pause_resume_click'], track_uri) + + assert provider.get_click_time() == 0 + + +def test_process_click_triggers_event(config, provider, playlist_item_mock): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): + with mock.patch.multiple(EventSupportPlaybackProvider, thumbs_up=mock.PropertyMock(), + thumbs_down=mock.PropertyMock(), sleep=mock.PropertyMock()): + + track_uri = TrackUri.from_track(playlist_item_mock).uri + + method = config['pandora']['on_pause_next_click'] + method_call = getattr(provider, method) + + t = provider.process_click(method, track_uri) + t.join() + + token = PandoraUri.parse(track_uri).token + method_call.assert_called_once_with(token) + + +def add_artist_bookmark(provider): + + provider.add_artist_bookmark(conftest.MOCK_TRACK_TOKEN) + + provider.client.add_artist_bookmark.assert_called_once_with(conftest.MOCK_TRACK_TOKEN) + + +def add_song_bookmark(provider): + + provider.add_song_bookmark(conftest.MOCK_TRACK_TOKEN) + + provider.client.add_song_bookmark.assert_called_once_with(conftest.MOCK_TRACK_TOKEN) From 7f372d18ebd8660abdceb85692c1011c893e0896 Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 4 Dec 2015 07:59:03 +0200 Subject: [PATCH 049/311] Refactor tracklist syncing routines into superclass. --- mopidy_pandora/library.py | 5 -- mopidy_pandora/playback.py | 141 +++++++++++++++++++++---------------- 2 files changed, 81 insertions(+), 65 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index d7da910..eda9d56 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -25,7 +25,6 @@ def __init__(self, backend, sort_order): super(PandoraLibraryProvider, self).__init__(backend) def browse(self, uri): - if uri == self.root_directory.uri: # Prefetch genre category list rpc.run_async(self.backend.api.get_genre_stations) @@ -47,7 +46,6 @@ def browse(self, uri): def lookup(self, uri): if PandoraUri.parse(uri).scheme == TrackUri.scheme: - pandora_track = self.lookup_pandora_track(uri) if pandora_track: @@ -74,7 +72,6 @@ def _browse_stations(self): stations = self.backend.api.get_station_list() if any(stations): - if self.sort_order == "A-Z": stations.sort(key=lambda x: x.name, reverse=False) @@ -86,11 +83,9 @@ def _browse_stations(self): models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri)) station_directories.insert(0, self.genre_directory) - return station_directories def _browse_tracks(self, uri): - pandora_uri = PandoraUri.parse(uri) # TODO: should be able to perform check on is_ad() once dynamic tracklist support is available diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 340cbf7..2970cda 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -22,6 +22,9 @@ class PandoraPlaybackProvider(backend.PlaybackProvider): def __init__(self, audio, backend): super(PandoraPlaybackProvider, self).__init__(audio, backend) + self.current_tl_track = None + self.thread_timeout = 2 + # 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 @@ -44,7 +47,6 @@ def _auto_setup(self): self.backend.setup_required = False def prepare_change(self): - if self.backend.auto_setup and self.backend.setup_required: self._auto_setup() @@ -52,28 +54,33 @@ def prepare_change(self): def change_track(self, track): - if track.uri is None: - logger.warning("No URI for track '%s': cannot be played.", track.name) - self._check_skip_limit_exceeded() - return False - try: - pandora_track = self.backend.library.lookup_pandora_track(track.uri) - is_playable = pandora_track and pandora_track.audio_url and pandora_track.get_is_playable() - - except requests.exceptions.RequestException as e: - is_playable = False - logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) - - if is_playable: - logger.info("Up next: '%s' by %s", pandora_track.song_name, pandora_track.artist_name) - self.consecutive_track_skips = 0 - - return super(PandoraPlaybackProvider, self).change_track(track) - else: - logger.warning("Audio URI for track '%s' cannot be played.", track.uri) - self._check_skip_limit_exceeded() - return False + if track.uri is None: + logger.warning("No URI for track '%s': cannot be played.", track.name) + self._check_skip_limit_exceeded() + return False + + try: + pandora_track = self.backend.library.lookup_pandora_track(track.uri) + is_playable = pandora_track and pandora_track.audio_url and pandora_track.get_is_playable() + + except requests.exceptions.RequestException as e: + is_playable = False + logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) + + if is_playable: + logger.info("Up next: '%s' by %s", pandora_track.song_name, pandora_track.artist_name) + self.consecutive_track_skips = 0 + + return super(PandoraPlaybackProvider, self).change_track(track) + else: + logger.warning("Audio URI for track '%s' cannot be played.", track.uri) + self._check_skip_limit_exceeded() + return False + finally: + # TODO: how to ensure consistent state if tracklist sync fails? + # Should we stop playback or retry? Ignore events? + self._sync_tracklist() def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url @@ -88,6 +95,48 @@ def _check_skip_limit_exceeded(self): return False + @rpc.run_async + def _sync_tracklist(self): + """ Sync the current tracklist information, and add more Pandora tracks to the tracklist as necessary. + """ + current_tl_track_q = Queue.Queue(1) + length_q = Queue.Queue(1) + index_q = Queue.Queue(1) + + try: + rpc.RPCClient.playback_get_current_tl_track(queue=current_tl_track_q) + rpc.RPCClient.tracklist_get_length(queue=length_q) + + self.current_tl_track = current_tl_track_q.get(timeout=self.thread_timeout) + + rpc.RPCClient.tracklist_index(tlid=self.current_tl_track['tlid'], queue=index_q) + + tl_index = index_q.get(timeout=self.thread_timeout) + tl_length = length_q.get(timeout=self.thread_timeout) + + # TODO note that tlid's will be changed to start at '1' instead of '0' in the next release of Mopidy. + # the following statement should change to 'if index >= length:' when that happens. + # see https://github.com/mopidy/mopidy/commit/4c5e80a2790c6bea971b105f11ab3f7c16617173 + if tl_index >= tl_length-1: + # We're at the end of the tracklist, add the next Pandora track + track = self.backend.library.next_track() + + t = rpc.RPCClient.tracklist_add(uris=[track.uri]) + t.join(self.thread_timeout*2) + + except Exception as e: + logger.error('Error syncing tracklist: %s.', encoding.locale_decode(e)) + self.current_tl_track = None + return False + + finally: + # Cleanup asynchronous queues + current_tl_track_q.task_done() + length_q.task_done() + index_q.task_done() + + return True + class EventSupportPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): @@ -102,10 +151,8 @@ def __init__(self, audio, backend): self.double_click_interval = config['double_click_interval'] self._click_time = 0 - self.thread_timeout = 2 self.previous_tl_track = None - self.current_tl_track = None self.next_tl_track = None def set_click_time(self, click_time=None): @@ -118,7 +165,6 @@ def get_click_time(self): return self._click_time def is_double_click(self): - double_clicked = self._click_time > 0 and time.time() - self._click_time < float(self.double_click_interval) if double_clicked: @@ -131,16 +177,14 @@ def is_double_click(self): def change_track(self, track): if self.is_double_click(): - if track.uri == self.next_tl_track['track']['uri']: - self.process_click(self.on_pause_next_click, track.uri) + self.process_click(self.on_pause_next_click, self.current_tl_track['track']['uri']) elif track.uri == self.previous_tl_track['track']['uri']: - self.process_click(self.on_pause_previous_click, track.uri) + self.process_click(self.on_pause_previous_click, self.current_tl_track['track']['uri']) rpc.RPCClient.playback_resume() - self._sync_tracklist() return super(EventSupportPlaybackProvider, self).change_track(track) def resume(self): @@ -150,7 +194,6 @@ def resume(self): return super(EventSupportPlaybackProvider, self).resume() def pause(self): - if self.get_time_position() > 0: self.set_click_time() @@ -158,7 +201,6 @@ def pause(self): @rpc.run_async def process_click(self, method, track_uri): - self.set_click_time(0) uri = PandoraUri.parse(track_uri) @@ -197,35 +239,16 @@ def _sync_tracklist(self): event needs to be processed. """ - # Wait until doubleclick events have finished processing - self._doubleclick_processed_event.wait(self.thread_timeout) - previous_tl_track_q = Queue.Queue(1) - current_tl_track_q = Queue.Queue(1) next_tl_track_q = Queue.Queue(1) - length_q = Queue.Queue(1) - index_q = Queue.Queue(1) try: - rpc.RPCClient.playback_get_current_tl_track(queue=current_tl_track_q) - rpc.RPCClient.tracklist_get_length(queue=length_q) - self.current_tl_track = current_tl_track_q.get(timeout=self.thread_timeout) + # Wait until events that depend on the tracklist state have finished processing + self._doubleclick_processed_event.wait(self.thread_timeout) - rpc.RPCClient.tracklist_index(tlid=self.current_tl_track['tlid'], queue=index_q) - - tl_index = index_q.get(timeout=self.thread_timeout) - tl_length = length_q.get(timeout=self.thread_timeout) - - # TODO note that tlid's will be changed to start at '1' instead of '0' in the next release of Mopidy. - # the following statement should change to 'if index >= length:' when that happens. - # see https://github.com/mopidy/mopidy/commit/4c5e80a2790c6bea971b105f11ab3f7c16617173 - if tl_index >= tl_length-1: - # We're at the end of the tracklist, add the next Pandora track - track = self.backend.library.next_track() - - t = rpc.RPCClient.tracklist_add(uris=[track.uri]) - t.join(self.thread_timeout*2) + t = super(EventSupportPlaybackProvider, self)._sync_tracklist() + t.join() rpc.RPCClient.tracklist_previous_track(self.current_tl_track, queue=previous_tl_track_q) rpc.RPCClient.tracklist_next_track(self.current_tl_track, queue=next_tl_track_q) @@ -233,19 +256,17 @@ def _sync_tracklist(self): self.previous_tl_track = previous_tl_track_q.get(timeout=self.thread_timeout) self.next_tl_track = next_tl_track_q.get(timeout=self.thread_timeout) - self._doubleclick_processed_event.set() - except Exception as e: logger.error('Error syncing tracklist: %s.', encoding.locale_decode(e)) - self.previous_tl_track = self.current_tl_track = self.next_tl_track = None + self.previous_tl_track = self.next_tl_track = None return False finally: # Cleanup asynchronous queues previous_tl_track_q.task_done() - current_tl_track_q.task_done() next_tl_track_q.task_done() - length_q.task_done() - index_q.task_done() + + # Reset lock so that we are ready to process the next event. + self._doubleclick_processed_event.set() return True From 3fac0b5eb914e5985263d1646c3d48e370086ecb Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 4 Dec 2015 18:25:36 +0200 Subject: [PATCH 050/311] Refactor tracklist handling into separate class. --- mopidy_pandora/backend.py | 8 +- mopidy_pandora/library.py | 32 ++++--- mopidy_pandora/playback.py | 173 +++++++++++------------------------- mopidy_pandora/rpc.py | 56 +----------- mopidy_pandora/tracklist.py | 99 +++++++++++++++++++++ 5 files changed, 177 insertions(+), 191 deletions(-) create mode 100644 mopidy_pandora/tracklist.py diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index cb0318e..fc8de60 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -12,6 +12,7 @@ from mopidy_pandora.client import MopidyPandoraAPIClient from mopidy_pandora.library import PandoraLibraryProvider from mopidy_pandora.playback import EventSupportPlaybackProvider, PandoraPlaybackProvider +from mopidy_pandora.tracklist import PandoraTracklistProvider from mopidy_pandora.uri import logger @@ -29,15 +30,16 @@ def __init__(self, config, audio): "DEVICE": self._config["partner_device"], "AUDIO_QUALITY": self._config.get("preferred_audio_quality", BaseAPIClient.HIGH_AUDIO_QUALITY) } - self.api = clientbuilder.SettingsDictBuilder(settings, client_class=MopidyPandoraAPIClient).build() + rpc.RPCClient.configure(config['http']['hostname'], config['http']['port']) + + self.api = clientbuilder.SettingsDictBuilder(settings, client_class=MopidyPandoraAPIClient).build() self.library = PandoraLibraryProvider(backend=self, sort_order=self._config['sort_order']) + self.tracklist = PandoraTracklistProvider(self) self.auto_setup = self._config['auto_setup'] self.setup_required = self.auto_setup - rpc.RPCClient.configure(config['http']['hostname'], config['http']['port']) - self.supports_events = False if self._config['event_support_enabled']: self.supports_events = True diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index eda9d56..e1d8189 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,9 +1,13 @@ from mopidy import backend, models +from mopidy.internal import encoding + from pandora.models.pandora import Station from pydora.utils import iterate_forever +import requests + from mopidy_pandora import rpc from mopidy_pandora.uri import GenreUri, logger, PandoraUri, StationUri, TrackUri # noqa I101 @@ -123,21 +127,25 @@ def lookup_pandora_track(self, uri): return None def next_track(self): - pandora_track = self._station_iter.next() - if pandora_track.track_token is None: - # TODO process add tokens properly when pydora 1.6 is available - return self.next_track() + try: + pandora_track = self._station_iter.next() + + if pandora_track.track_token is None: + # TODO process add tokens properly when pydora 1.6 is available + return self.next_track() - track_uri = TrackUri.from_track(pandora_track) - track = models.Ref.track(name=pandora_track.song_name, uri=track_uri.uri) + track_uri = TrackUri.from_track(pandora_track) + track = models.Ref.track(name=pandora_track.song_name, uri=track_uri.uri) - if any(self._uri_translation_map) and track_uri.station_id != \ - TrackUri.parse(self._uri_translation_map.keys()[0]).station_id: + if any(self._uri_translation_map) and track_uri.station_id != \ + TrackUri.parse(self._uri_translation_map.keys()[0]).station_id: - # We've switched stations, clear the translation map. - self._uri_translation_map.clear() + # We've switched stations, clear the translation map. + self._uri_translation_map.clear() - self._uri_translation_map[track.uri] = pandora_track + self._uri_translation_map[track.uri] = pandora_track - return track + return track + except requests.exceptions.RequestException as e: + logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 2970cda..6ef78bb 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -1,5 +1,4 @@ import Queue - import threading import time @@ -22,12 +21,9 @@ class PandoraPlaybackProvider(backend.PlaybackProvider): def __init__(self, audio, backend): super(PandoraPlaybackProvider, self).__init__(audio, backend) - self.current_tl_track = None - self.thread_timeout = 2 - # 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 + 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).get() @@ -36,106 +32,74 @@ def __init__(self, audio, backend): # 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())).get() - def _auto_setup(self): + @property + def consecutive_track_skips(self): + return self._consecutive_track_skips - # Setup player to mirror behaviour of official Pandora front-ends. - rpc.RPCClient.tracklist_set_repeat(False) - rpc.RPCClient.tracklist_set_consume(True) - rpc.RPCClient.tracklist_set_random(False) - rpc.RPCClient.tracklist_set_single(False) + @consecutive_track_skips.setter + def consecutive_track_skips(self, value=1): + if value > 0: + self._consecutive_track_skips += value - self.backend.setup_required = False + if self.consecutive_track_skips >= self.SKIP_LIMIT-1: + logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) + self.trigger_stop() + else: + self._consecutive_track_skips = 0 def prepare_change(self): if self.backend.auto_setup and self.backend.setup_required: - self._auto_setup() + self.backend.tracklist.configure() + self.backend.setup_required = False super(PandoraPlaybackProvider, self).prepare_change() def change_track(self, track): - try: if track.uri is None: - logger.warning("No URI for track '%s': cannot be played.", track.name) - self._check_skip_limit_exceeded() + logger.warning("No URI for track '%s'. Track cannot be played.", track.name) + self.consecutive_track_skips += 1 return False - try: - pandora_track = self.backend.library.lookup_pandora_track(track.uri) - is_playable = pandora_track and pandora_track.audio_url and pandora_track.get_is_playable() - - except requests.exceptions.RequestException as e: - is_playable = False - logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) - - if is_playable: - logger.info("Up next: '%s' by %s", pandora_track.song_name, pandora_track.artist_name) + if self.is_playable(track.uri): self.consecutive_track_skips = 0 - return super(PandoraPlaybackProvider, self).change_track(track) else: - logger.warning("Audio URI for track '%s' cannot be played.", track.uri) - self._check_skip_limit_exceeded() + self.consecutive_track_skips += 1 return False + finally: # TODO: how to ensure consistent state if tracklist sync fails? # Should we stop playback or retry? Ignore events? - self._sync_tracklist() + self.backend.tracklist.sync() def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url - def _check_skip_limit_exceeded(self): - self.consecutive_track_skips += 1 + def trigger_resume(self, queue=Queue.Queue(1)): + return rpc.RPCClient._do_rpc('core.playback.resume', queue=queue) - if self.consecutive_track_skips >= self.SKIP_LIMIT-1: - logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) - rpc.RPCClient.playback_stop() - return True + def trigger_stop(cls, queue=Queue.Queue(1)): + return rpc.RPCClient._do_rpc('core.playback.stop', queue=queue) - return False + def get_current_tl_track(self, queue=Queue.Queue(1)): + return rpc.RPCClient._do_rpc('core.playback.get_current_tl_track', queue=queue) - @rpc.run_async - def _sync_tracklist(self): - """ Sync the current tracklist information, and add more Pandora tracks to the tracklist as necessary. - """ - current_tl_track_q = Queue.Queue(1) - length_q = Queue.Queue(1) - index_q = Queue.Queue(1) + def is_playable(self, track_uri): + """ A track is playable if it can be retrieved, has a URL, and the Pandora URL can be accessed. + :param track_uri: uri of the track to be checked. + :return: True if the track is playable, False otherwise. + """ + is_playable = False try: - rpc.RPCClient.playback_get_current_tl_track(queue=current_tl_track_q) - rpc.RPCClient.tracklist_get_length(queue=length_q) - - self.current_tl_track = current_tl_track_q.get(timeout=self.thread_timeout) - - rpc.RPCClient.tracklist_index(tlid=self.current_tl_track['tlid'], queue=index_q) - - tl_index = index_q.get(timeout=self.thread_timeout) - tl_length = length_q.get(timeout=self.thread_timeout) - - # TODO note that tlid's will be changed to start at '1' instead of '0' in the next release of Mopidy. - # the following statement should change to 'if index >= length:' when that happens. - # see https://github.com/mopidy/mopidy/commit/4c5e80a2790c6bea971b105f11ab3f7c16617173 - if tl_index >= tl_length-1: - # We're at the end of the tracklist, add the next Pandora track - track = self.backend.library.next_track() - - t = rpc.RPCClient.tracklist_add(uris=[track.uri]) - t.join(self.thread_timeout*2) - - except Exception as e: - logger.error('Error syncing tracklist: %s.', encoding.locale_decode(e)) - self.current_tl_track = None - return False + pandora_track = self.backend.library.lookup_pandora_track(track_uri) + is_playable = pandora_track and pandora_track.audio_url and pandora_track.get_is_playable() + except requests.exceptions.RequestException as e: + logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) finally: - # Cleanup asynchronous queues - current_tl_track_q.task_done() - length_q.task_done() - index_q.task_done() - - return True + return is_playable class EventSupportPlaybackProvider(PandoraPlaybackProvider): @@ -152,9 +116,6 @@ def __init__(self, audio, backend): self._click_time = 0 - self.previous_tl_track = None - self.next_tl_track = None - def set_click_time(self, click_time=None): if click_time is None: self._click_time = time.time() @@ -177,19 +138,25 @@ def is_double_click(self): def change_track(self, track): if self.is_double_click(): - if track.uri == self.next_tl_track['track']['uri']: - self.process_click(self.on_pause_next_click, self.current_tl_track['track']['uri']) + if track.uri == self.backend.tracklist.next_tl_track['track']['uri']: + self.process_click(self.on_pause_next_click, + self.backend.tracklist.current_tl_track['track']['uri']) + + elif track.uri == self.backend.tracklist.previous_tl_track['track']['uri']: + self.process_click(self.on_pause_previous_click, + self.backend.tracklist.current_tl_track['track']['uri']) - elif track.uri == self.previous_tl_track['track']['uri']: - self.process_click(self.on_pause_previous_click, self.current_tl_track['track']['uri']) + # Resume playback after doubleclick has been processed + self.trigger_resume() - rpc.RPCClient.playback_resume() + # Wait until events that depend on the tracklist state have finished processing + self._doubleclick_processed_event.wait(rpc.thread_timeout) return super(EventSupportPlaybackProvider, self).change_track(track) def resume(self): if self.is_double_click() and self.get_time_position() > 0: - self.process_click(self.on_pause_resume_click, self.current_tl_track['track']['uri']) + self.process_click(self.on_pause_resume_click, self.backend.tracklist.current_tl_track['track']['uri']) return super(EventSupportPlaybackProvider, self).resume() @@ -208,7 +175,6 @@ def process_click(self, method, track_uri): self.backend.library.lookup_pandora_track(track_uri).song_name) func = getattr(self, method) - try: func(uri.token) @@ -216,6 +182,7 @@ def process_click(self, method, track_uri): logger.error('Error calling event: %s', encoding.locale_decode(e)) return False finally: + # Reset lock so that we are ready to process the next event. self._doubleclick_processed_event.set() def thumbs_up(self, track_token): @@ -232,41 +199,3 @@ def add_artist_bookmark(self, track_token): def add_song_bookmark(self, track_token): return self.backend.api.add_song_bookmark(track_token) - - @rpc.run_async - def _sync_tracklist(self): - """ Sync the current tracklist information, to be used when the next - event needs to be processed. - - """ - previous_tl_track_q = Queue.Queue(1) - next_tl_track_q = Queue.Queue(1) - - try: - - # Wait until events that depend on the tracklist state have finished processing - self._doubleclick_processed_event.wait(self.thread_timeout) - - t = super(EventSupportPlaybackProvider, self)._sync_tracklist() - t.join() - - rpc.RPCClient.tracklist_previous_track(self.current_tl_track, queue=previous_tl_track_q) - rpc.RPCClient.tracklist_next_track(self.current_tl_track, queue=next_tl_track_q) - - self.previous_tl_track = previous_tl_track_q.get(timeout=self.thread_timeout) - self.next_tl_track = next_tl_track_q.get(timeout=self.thread_timeout) - - except Exception as e: - logger.error('Error syncing tracklist: %s.', encoding.locale_decode(e)) - self.previous_tl_track = self.next_tl_track = None - return False - - finally: - # Cleanup asynchronous queues - previous_tl_track_q.task_done() - next_tl_track_q.task_done() - - # Reset lock so that we are ready to process the next event. - self._doubleclick_processed_event.set() - - return True diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/rpc.py index d8db20d..f9d0e87 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/rpc.py @@ -2,6 +2,8 @@ import requests +thread_timeout = 2 + def run_async(func): """ Function decorator intended to make "func" run in a separate thread (asynchronously). @@ -9,7 +11,6 @@ def run_async(func): :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 @@ -22,7 +23,6 @@ def async_func(*args, **kwargs): 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. """ - queue = kwargs.get('queue', None) t = Thread(target=func, args=args, kwargs=kwargs) @@ -30,7 +30,6 @@ def async_func(*args, **kwargs): if queue is not None: t.result_queue = queue - return t return async_func @@ -58,7 +57,6 @@ def _do_rpc(cls, method, params=None, queue=None): :param queue: a Queue.Queue() object that the results of the thread should be stored in. :return: the 'result' element of the json results list returned by the remote procedure call. """ - cls.id += 1 data = {'method': method, 'jsonrpc': '2.0', 'id': cls.id} @@ -71,53 +69,3 @@ def _do_rpc(cls, method, params=None, queue=None): queue.put(json_data['result']) return json_data['result'] - - @classmethod - def tracklist_set_repeat(cls, value=True, queue=None): - return cls._do_rpc('core.tracklist.set_repeat', params={'value': value}, queue=queue) - - @classmethod - def tracklist_set_consume(cls, value=True, queue=None): - return cls._do_rpc('core.tracklist.set_consume', params={'value': value}, queue=queue) - - @classmethod - def tracklist_set_single(cls, value=True, queue=None): - return cls._do_rpc('core.tracklist.set_single', params={'value': value}, queue=queue) - - @classmethod - def tracklist_set_random(cls, value=True, queue=None): - return cls._do_rpc('core.tracklist.set_random', params={'value': value}, queue=queue) - - @classmethod - def playback_resume(cls, queue=None): - return cls._do_rpc('core.playback.resume', queue=queue) - - @classmethod - def playback_stop(cls, queue=None): - return cls._do_rpc('core.playback.stop', queue=queue) - - @classmethod - def tracklist_previous_track(cls, tl_track, queue=None): - return cls._do_rpc('core.tracklist.previous_track', params={'tl_track': tl_track}, queue=queue) - - @classmethod - def playback_get_current_tl_track(cls, queue=None): - return cls._do_rpc('core.playback.get_current_tl_track', queue=queue) - - @classmethod - def tracklist_next_track(cls, tl_track, queue=None): - return cls._do_rpc('core.tracklist.next_track', params={'tl_track': tl_track}, queue=queue) - - @classmethod - def tracklist_index(cls, tl_track=None, tlid=None, queue=None): - return cls._do_rpc('core.tracklist.index', params={'tl_track': tl_track, 'tlid': tlid}, - queue=queue) - - @classmethod - def tracklist_get_length(cls, queue=None): - return cls._do_rpc('core.tracklist.get_length', queue=queue) - - @classmethod - def tracklist_add(cls, tracks=None, at_position=None, uri=None, uris=None, queue=None): - return cls._do_rpc('core.tracklist.add', params={'tracks': tracks, 'at_position': at_position, - 'uri': uri, 'uris': uris}, queue=queue) diff --git a/mopidy_pandora/tracklist.py b/mopidy_pandora/tracklist.py new file mode 100644 index 0000000..4c88a52 --- /dev/null +++ b/mopidy_pandora/tracklist.py @@ -0,0 +1,99 @@ +import Queue + +import threading + +from mopidy.internal import encoding + +from mopidy_pandora import logger, rpc + + +class PandoraTracklistProvider(object): + + def __init__(self, backend): + + self.backend = backend + + self.previous_tl_track = None + self.current_tl_track = None + self.next_tl_track = None + + self._tracklist_synced_event = threading.Event() + + @property + def tracklist_is_synced(self): + return self._tracklist_synced_event.get() + + def configure(self): + # Setup tracklist to mirror behaviour of official Pandora front-ends. + self.set_repeat(False) + self.set_consume(True) + self.set_random(False) + self.set_single(False) + + def get_length(self, queue=Queue.Queue(1)): + return rpc.RPCClient._do_rpc('core.tracklist.get_length', queue=queue) + + def set_random(self, value=True, queue=Queue.Queue(1)): + return rpc.RPCClient._do_rpc('core.tracklist.set_random', params={'value': value}, queue=queue) + + def set_repeat(self, value=True, queue=Queue.Queue(1)): + return rpc.RPCClient._do_rpc('core.tracklist.set_repeat', params={'value': value}, queue=queue) + + def set_single(self, value=True, queue=Queue.Queue(1)): + return rpc.RPCClient._do_rpc('core.tracklist.set_single', params={'value': value}, queue=queue) + + def set_consume(self, value=True, queue=Queue.Queue(1)): + return rpc.RPCClient._do_rpc('core.tracklist.set_consume', params={'value': value}, queue=queue) + + def index(self, tl_track=None, tlid=None, queue=Queue.Queue(1)): + return rpc.RPCClient._do_rpc('core.tracklist.index', params={'tl_track': tl_track, 'tlid': tlid}, + queue=queue) + + def next_track(self, tl_track, queue=Queue.Queue(1)): + return rpc.RPCClient._do_rpc('core.tracklist.next_track', params={'tl_track': tl_track}, queue=queue) + + def previous_track(self, tl_track, queue=Queue.Queue(1)): + return rpc.RPCClient._do_rpc('core.tracklist.previous_track', params={'tl_track': tl_track}, queue=queue) + + def add(self, tracks=None, at_position=None, uri=None, uris=None, queue=Queue.Queue(1)): + return rpc.RPCClient._do_rpc('core.tracklist.add', params={'tracks': tracks, 'at_position': at_position, + 'uri': uri, 'uris': uris}, queue=queue) + + def clear(self): + raise NotImplementedError + + @rpc.run_async + def sync(self): + """ Sync the current tracklist information, and add more Pandora tracks to the tracklist as necessary. + """ + self._tracklist_synced_event.clear() + try: + self.current_tl_track = self.backend.playback.get_current_tl_track().result_queue\ + .get(timeout=rpc.thread_timeout) + + tl_index = self.index(tlid=self.current_tl_track['tlid']).result_queue\ + .get(timeout=rpc.thread_timeout) + + tl_length = self.get_length().result_queue.get(timeout=rpc.thread_timeout) + + # TODO note that tlid's will be changed to start at '1' instead of '0' in the next release of Mopidy. + # the following statement should change to 'if index >= length:' when that happens. + # see https://github.com/mopidy/mopidy/commit/4c5e80a2790c6bea971b105f11ab3f7c16617173 + if tl_index >= tl_length-1: + # We're at the end of the tracklist, add the next Pandora track + track = self.backend.library.next_track() + + t = self.add(uris=[track.uri]) + t.join(rpc.thread_timeout*2) + + self.previous_tl_track = self.previous_track(self.current_tl_track).result_queue\ + .get(timeout=rpc.thread_timeout) + + self.next_tl_track = self.next_track(self.current_tl_track).result_queue\ + .get(timeout=rpc.thread_timeout) + + self._tracklist_synced_event.set() + + except Exception as e: + logger.error('Error syncing tracklist: %s.', encoding.locale_decode(e)) + self.previous_tl_track = self.current_tl_track = self.next_tl_track = None From ab32b9e122ed4b3ba40a641313da0f4595179c1b Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 4 Dec 2015 21:37:31 +0200 Subject: [PATCH 051/311] Make use of time-to-live caches to reduce interaction with Pandora server. --- mopidy_pandora/__init__.py | 15 +++++------ mopidy_pandora/backend.py | 13 +++++----- mopidy_pandora/client.py | 52 ++++++++++++++++++++++++++++---------- mopidy_pandora/ext.conf | 1 + mopidy_pandora/library.py | 24 +++++++++--------- mopidy_pandora/playback.py | 10 ++++---- setup.py | 5 ++-- 7 files changed, 74 insertions(+), 46 deletions(-) diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index e01492c..5804336 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -4,7 +4,6 @@ import os from mopidy import config, ext -from mopidy.config import Deprecated from pandora import BaseAPIClient @@ -38,13 +37,13 @@ def get_config_schema(self): BaseAPIClient.MED_AUDIO_QUALITY, BaseAPIClient.HIGH_AUDIO_QUALITY]) schema['sort_order'] = config.String(optional=True, choices=['date', 'A-Z', 'a-z']) - schema['auto_setup'] = config.Boolean() - schema['auto_set_repeat'] = Deprecated() - 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']) - schema['on_pause_next_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep']) - schema['on_pause_previous_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep']) + schema['auto_setup'] = config.Boolean(optional=True) + schema['cache_time_to_live'] = config.Integer(optional=True) + schema['event_support_enabled'] = config.Boolean(optional=True) + schema['double_click_interval'] = config.String(optional=True) + schema['on_pause_resume_click'] = config.String(optional=True, choices=['thumbs_up', 'thumbs_down', 'sleep']) + schema['on_pause_next_click'] = config.String(optional=True, choices=['thumbs_up', 'thumbs_down', 'sleep']) + schema['on_pause_previous_click'] = config.String(optional=True, choices=['thumbs_up', 'thumbs_down', 'sleep']) return schema def setup(self, registry): diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index fc8de60..e3e301e 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -1,7 +1,7 @@ from mopidy import backend, core from mopidy.internal import encoding -from pandora import BaseAPIClient, clientbuilder +from pandora import BaseAPIClient import pykka @@ -9,7 +9,7 @@ import rpc -from mopidy_pandora.client import MopidyPandoraAPIClient +from mopidy_pandora.client import MopidyPandoraAPIClient, MopidyPandoraSettingsDictBuilder from mopidy_pandora.library import PandoraLibraryProvider from mopidy_pandora.playback import EventSupportPlaybackProvider, PandoraPlaybackProvider from mopidy_pandora.tracklist import PandoraTracklistProvider @@ -22,6 +22,7 @@ def __init__(self, config, audio): super(PandoraBackend, self).__init__() self._config = config['pandora'] settings = { + "CACHE_TTL": self._config.get("cache_time_to_live", 1800), "API_HOST": self._config.get("api_host", 'tuner.pandora.com/services/json/'), "DECRYPTION_KEY": self._config["partner_decryption_key"], "ENCRYPTION_KEY": self._config["partner_encryption_key"], @@ -33,15 +34,15 @@ def __init__(self, config, audio): rpc.RPCClient.configure(config['http']['hostname'], config['http']['port']) - self.api = clientbuilder.SettingsDictBuilder(settings, client_class=MopidyPandoraAPIClient).build() - self.library = PandoraLibraryProvider(backend=self, sort_order=self._config['sort_order']) + self.api = MopidyPandoraSettingsDictBuilder(settings, client_class=MopidyPandoraAPIClient).build() + self.library = PandoraLibraryProvider(backend=self, sort_order=self._config.get('sort_order', 'date')) self.tracklist = PandoraTracklistProvider(self) - self.auto_setup = self._config['auto_setup'] + self.auto_setup = self._config.get('auto_setup', True) self.setup_required = self.auto_setup self.supports_events = False - if self._config['event_support_enabled']: + if self._config.get('event_support_enabled', True): self.supports_events = True self.playback = EventSupportPlaybackProvider(audio=audio, backend=self) else: diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index ae7d8f8..73eb024 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -1,37 +1,62 @@ import logging +from cachetools import TTLCache + from mopidy.internal import encoding import pandora +from pandora.clientbuilder import APITransport, DEFAULT_API_HOST, Encryptor, SettingsDictBuilder import requests logger = logging.getLogger(__name__) +class MopidyPandoraSettingsDictBuilder(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 MopidyPandoraAPIClient(pandora.APIClient): """Pydora API Client for Mopidy-Pandora This API client implements caching of the station list. """ - def __init__(self, transport, partner_user, partner_password, device, + def __init__(self, cache_ttl, transport, partner_user, partner_password, device, default_audio_quality=pandora.BaseAPIClient.MED_AUDIO_QUALITY): super(MopidyPandoraAPIClient, self).__init__(transport, partner_user, partner_password, device, default_audio_quality) - self._station_list = [] - self._genre_stations = [] - def get_station_list(self, refresh_cache=True): + self.cache_ttl = cache_ttl + self._station_list_cache = TTLCache(1, cache_ttl) + self._genre_stations_cache = TTLCache(1, cache_ttl) + + def get_station_list(self, force_refresh=False): - if not any(self._station_list) or (refresh_cache and self._station_list.has_changed()): + if not any(self._station_list_cache) or \ + (force_refresh is True and self._station_list_cache.itervalues().next().has_changed()): try: - self._station_list = super(MopidyPandoraAPIClient, self).get_station_list() + self._station_list_cache['key'] = super(MopidyPandoraAPIClient, self).get_station_list() except requests.exceptions.RequestException as e: logger.error('Error retrieving station list: %s', encoding.locale_decode(e)) - return self._station_list + return self._station_list_cache.itervalues().next() def get_station(self, station_id): @@ -41,14 +66,15 @@ def get_station(self, station_id): # Could not find station_id in cached list, try retrieving from Pandora server. return super(MopidyPandoraAPIClient, self).get_station(station_id) - def get_genre_stations(self, refresh_cache=True): + def get_genre_stations(self, force_refresh=False): - if not any(self._genre_stations) or (refresh_cache and self._genre_stations.has_changed()): + if not any(self._genre_stations_cache) or \ + (force_refresh is True and self._genre_stations_cache.itervalues().next().has_changed()): try: - self._genre_stations = super(MopidyPandoraAPIClient, self).get_genre_stations() - # if any(self._genre_stations): - # self._genre_stations.sort(key=lambda x: x[0], reverse=False) + self._genre_stations_cache['key'] = super(MopidyPandoraAPIClient, self).get_genre_stations() + # if any(self._genre_stations_cache): + # self._genre_stations_cache.sort(key=lambda x: x[0], reverse=False) except requests.exceptions.RequestException as e: logger.error('Error retrieving genre stations: %s', encoding.locale_decode(e)) - return self._genre_stations + return self._genre_stations_cache.itervalues().next() diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index c125607..41cd683 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -11,6 +11,7 @@ password = preferred_audio_quality = highQuality sort_order = date auto_setup = true +cache_time_to_live = 1800 ### EXPERIMENTAL RATINGS IMPLEMENTATION ### event_support_enabled = false diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index e1d8189..810b58e 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,3 +1,5 @@ +from cachetools import TTLCache + from mopidy import backend, models from mopidy.internal import encoding @@ -8,8 +10,6 @@ import requests -from mopidy_pandora import rpc - from mopidy_pandora.uri import GenreUri, logger, PandoraUri, StationUri, TrackUri # noqa I101 @@ -25,13 +25,14 @@ def __init__(self, backend, sort_order): self.sort_order = sort_order.upper() self._station = None self._station_iter = None - self._uri_translation_map = {} + + self._pandora_tracks_cache = TTLCache(25, 1800) super(PandoraLibraryProvider, self).__init__(backend) def browse(self, uri): if uri == self.root_directory.uri: # Prefetch genre category list - rpc.run_async(self.backend.api.get_genre_stations) + self.backend.api.get_genre_stations() return self._browse_stations() if uri == self.genre_directory.uri: @@ -116,18 +117,17 @@ def _browse_genre_categories(self): def _browse_genre_stations(self, uri): return [models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri) - for station in self.backend.api.get_genre_stations(refresh_cache=False) + for station in self.backend.api.get_genre_stations() [GenreUri.parse(uri).category_name]] def lookup_pandora_track(self, uri): try: - return self._uri_translation_map[uri] + return self._pandora_tracks_cache[uri] except KeyError: logger.error("Failed to lookup '%s' in uri translation map.", uri) return None def next_track(self): - try: pandora_track = self._station_iter.next() @@ -138,13 +138,13 @@ def next_track(self): track_uri = TrackUri.from_track(pandora_track) track = models.Ref.track(name=pandora_track.song_name, uri=track_uri.uri) - if any(self._uri_translation_map) and track_uri.station_id != \ - TrackUri.parse(self._uri_translation_map.keys()[0]).station_id: + if any(self._pandora_tracks_cache) and track_uri.station_id != \ + TrackUri.parse(self._pandora_tracks_cache.keys()[0]).station_id: - # We've switched stations, clear the translation map. - self._uri_translation_map.clear() + # We've switched stations, clear the cache. + self._pandora_tracks_cache.clear() - self._uri_translation_map[track.uri] = pandora_track + self._pandora_tracks_cache[track.uri] = pandora_track return track except requests.exceptions.RequestException as e: diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 6ef78bb..31e2fbe 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -109,10 +109,10 @@ def __init__(self, audio, backend): self._doubleclick_processed_event = threading.Event() config = self.backend._config - self.on_pause_resume_click = config["on_pause_resume_click"] - self.on_pause_next_click = config["on_pause_next_click"] - self.on_pause_previous_click = config["on_pause_previous_click"] - self.double_click_interval = config['double_click_interval'] + self.on_pause_resume_click = config.get("on_pause_resume_click", "thumbs_up") + self.on_pause_next_click = config.get("on_pause_next_click", "thumbs_down") + self.on_pause_previous_click = config.get("on_pause_previous_click", "sleep") + self.double_click_interval = float(config.get('double_click_interval', 2.00)) self._click_time = 0 @@ -126,7 +126,7 @@ def get_click_time(self): return self._click_time def is_double_click(self): - double_clicked = self._click_time > 0 and time.time() - self._click_time < float(self.double_click_interval) + double_clicked = self._click_time > 0 and time.time() - self._click_time < self.double_click_interval if double_clicked: self._doubleclick_processed_event.clear() diff --git a/setup.py b/setup.py index 6f6d7b7..4905ba6 100644 --- a/setup.py +++ b/setup.py @@ -49,9 +49,10 @@ def run_tests(self): include_package_data=True, install_requires=[ 'setuptools', - 'Mopidy >= 1.0.7', + 'cachetools >= 1.0.0' + 'Mopidy >= 1.1.1', 'Pykka >= 1.1', - 'pydora >= 1.4.0', + 'pydora >= 1.5.1', 'requests >= 2.5.0' ], tests_require=['tox'], From 77bca3330db7644e308a0c500bf1fb8b9717943d Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 4 Dec 2015 22:36:47 +0200 Subject: [PATCH 052/311] Update README. --- README.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 14463a1..24fdc9b 100644 --- a/README.rst +++ b/README.rst @@ -18,7 +18,7 @@ Mopidy-Pandora :target: https://coveralls.io/r/rectalogic/mopidy-pandora?branch=develop :alt: Test coverage -Mopidy extension for Pandora +Mopidy extension for Pandora radio (http://www.pandora.com). Installation @@ -28,9 +28,6 @@ Install by running:: pip install Mopidy-Pandora -Or, if available, install the Debian/Ubuntu package from `apt.mopidy.com -`_. - Configuration ============= @@ -50,9 +47,10 @@ Before starting Mopidy, you must add the configuration settings for Mopidy-Pando password = sort_order = date auto_setup = true + cache_time_to_live = 1800 ### EXPERIMENTAL EVENT HANDLING IMPLEMENTATION ### - event_support_enabled = false + event_support_enabled = true double_click_interval = 2.00 on_pause_resume_click = thumbs_up on_pause_next_click = thumbs_down @@ -112,6 +110,9 @@ v0.2.0 (UNRELEASED) - Add support for browsing genre stations. Note that clicking on a genre station will automatically add that station to your profile. At the moment, there is no way to remove stations from within Mopidy-Pandora. - Force Mopidy to stop when skip limit is exceeded (workaround for https://github.com/mopidy/mopidy/issues/1221). +- Scrobbling tracks to Last.fm should now work +- Implemented caching to speed up browsing of the list of stations. Configuration parameter 'cache_time_to_live' can be + used to specify when cache iterms should expire and be refreshed (in seconds). v0.1.7 (Oct 31, 2015) ---------------------------------------- From 44953eab3a680826d5f38776fd49367377f8e3d4 Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 4 Dec 2015 23:11:30 +0200 Subject: [PATCH 053/311] Merge functionality for playing advertisements. --- mopidy_pandora/uri.py | 2 -- setup.py | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index d9aa1be..f4d9da5 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -1,6 +1,5 @@ import logging import urllib -from pandora.models.pandora import PlaylistItem from pandora.models.pandora import AdItem, PlaylistItem logger = logging.getLogger(__name__) @@ -99,7 +98,6 @@ def from_track(cls, track): else: raise NotImplementedError("Unsupported playlist item type") - @property def uri(self): return "{}".format( diff --git a/setup.py b/setup.py index 4905ba6..59fc200 100644 --- a/setup.py +++ b/setup.py @@ -49,10 +49,10 @@ def run_tests(self): include_package_data=True, install_requires=[ 'setuptools', - 'cachetools >= 1.0.0' + 'cachetools >= 1.0.0', 'Mopidy >= 1.1.1', 'Pykka >= 1.1', - 'pydora >= 1.5.1', + 'pydora >= 1.6', 'requests >= 2.5.0' ], tests_require=['tox'], From 1d33b056880983238effbef4c71b7d4ff18f5b4a Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 4 Dec 2015 23:13:48 +0200 Subject: [PATCH 054/311] Merge functionality for playing advertisements. --- tests/test_library.py | 99 ++++++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 53 deletions(-) diff --git a/tests/test_library.py b/tests/test_library.py index 736c459..97d3e7d 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -9,7 +9,10 @@ from pandora import APIClient from pandora.models.pandora import Station -from mopidy_pandora.uri import StationUri, TrackUri +from mopidy_pandora.client import MopidyPandoraAPIClient +from mopidy_pandora.library import PandoraLibraryProvider + +from mopidy_pandora.uri import PandoraUri, StationUri, TrackUri from tests.conftest import get_station_list_mock @@ -28,60 +31,59 @@ def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) track_uri = TrackUri.from_track(playlist_item_mock) + + backend.library._uri_translation_map[track_uri.uri] = playlist_item_mock + results = backend.library.lookup(track_uri.uri) assert len(results) == 1 track = results[0] - assert track.name == track_uri.name assert track.uri == track_uri.uri - assert next(iter(track.artists)).name == "Pandora" - assert track.album.name == track_uri.name - assert track.album.uri == track_uri.detail_url - assert next(iter(track.album.images)) == track_uri.art_url -# For now, ad tracks will appear exactly as normal tracks in the Mopidy tracklist. -# This test should fail when dynamic tracklist support ever becomes available. -def test_lookup_of_ad_track_uri(config, ad_item_mock): +def test_lookup_of_missing_track(config, playlist_item_mock, caplog): backend = conftest.get_backend(config) - track_uri = TrackUri.from_track(ad_item_mock) + track_uri = TrackUri.from_track(playlist_item_mock) results = backend.library.lookup(track_uri.uri) - assert len(results) == 1 - - track = results[0] + assert len(results) == 0 - assert track.name == track_uri.name - assert track.uri == track_uri.uri - assert next(iter(track.artists)).name == "Pandora" - assert track.album.name == track_uri.name - assert track.album.uri == track_uri.detail_url - assert next(iter(track.album.images)) == track_uri.art_url + assert "Failed to lookup '%s' in uri translation map: %s", track_uri.uri in caplog.text() -def test_browse_directory_uri(config, caplog): +def test_browse_directory_uri(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): backend = conftest.get_backend(config) results = backend.library.browse(backend.library.root_directory.uri) - assert len(results) == 2 - assert results[0].type == models.Ref.DIRECTORY - assert results[0].name == conftest.MOCK_STATION_NAME + " 2" - assert results[0].uri == StationUri.from_station( - Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][0])).uri + assert len(results) == 4 assert results[0].type == models.Ref.DIRECTORY - assert results[1].name == conftest.MOCK_STATION_NAME + " 1" + assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME + assert results[0].uri == PandoraUri('genres').uri + + assert results[1].type == models.Ref.DIRECTORY + assert results[1].name == 'Shuffle' assert results[1].uri == StationUri.from_station( + Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][2])).uri + + assert results[2].type == models.Ref.DIRECTORY + assert results[2].name == conftest.MOCK_STATION_NAME + " 2" + assert results[2].uri == StationUri.from_station( + Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][0])).uri + + assert results[3].type == models.Ref.DIRECTORY + assert results[3].name == conftest.MOCK_STATION_NAME + " 1" + assert results[3].uri == StationUri.from_station( Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][1])).uri -def test_browse_directory_sort_za(config, caplog): +def test_browse_directory_sort_za(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): config['pandora']['sort_order'] = 'A-Z' @@ -89,11 +91,13 @@ def test_browse_directory_sort_za(config, caplog): results = backend.library.browse(backend.library.root_directory.uri) - assert results[0].name == conftest.MOCK_STATION_NAME + " 1" - assert results[1].name == conftest.MOCK_STATION_NAME + " 2" + assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME + assert results[1].name == 'Shuffle' + assert results[2].name == conftest.MOCK_STATION_NAME + " 1" + assert results[3].name == conftest.MOCK_STATION_NAME + " 2" -def test_browse_directory_sort_date(config, caplog): +def test_browse_directory_sort_date(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): config['pandora']['sort_order'] = 'date' @@ -101,31 +105,20 @@ def test_browse_directory_sort_date(config, caplog): results = backend.library.browse(backend.library.root_directory.uri) - assert results[0].name == conftest.MOCK_STATION_NAME + " 2" - assert results[1].name == conftest.MOCK_STATION_NAME + " 1" + assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME + assert results[1].name == 'Shuffle' + assert results[2].name == conftest.MOCK_STATION_NAME + " 2" + assert results[3].name == conftest.MOCK_STATION_NAME + " 1" -def test_browse_track_uri(config, playlist_item_mock, caplog): - - backend = conftest.get_backend(config) - track_uri = TrackUri.from_track(playlist_item_mock) - - results = backend.library.browse(track_uri.uri) - - assert len(results) == 3 - - backend.supports_events = False - - results = backend.library.browse(track_uri.uri) - assert len(results) == 1 +def test_browse_station_uri(config, station_mock): + with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): + with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): - assert results[0].type == models.Ref.TRACK - assert results[0].name == track_uri.name - assert TrackUri.parse(results[0].uri).index == str(0) + backend = conftest.get_backend(config) + station_uri = StationUri.from_station(station_mock) - # Track should not have an audio URL at this stage - assert TrackUri.parse(results[0].uri).audio_url == "none_generated" + results = backend.library.browse(station_uri.uri) - # Also clear reference track's audio URI so that we can compare more easily - track_uri.audio_url = "none_generated" - assert results[0].uri == track_uri.uri + # Station should just contain the first track to be played. + assert len(results) == 1 From b27be3f51e3d0f08d8cea89e5bc46e69ffaf645e Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 5 Dec 2015 00:00:37 +0200 Subject: [PATCH 055/311] Merge functionality for playing advertisements. --- mopidy_pandora/library.py | 36 +++++++++++++++++++----------------- mopidy_pandora/uri.py | 8 +++++--- tests/conftest.py | 1 - tests/test_uri.py | 4 ++-- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 810b58e..5117bb2 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -53,14 +53,17 @@ def lookup(self, uri): if PandoraUri.parse(uri).scheme == TrackUri.scheme: pandora_track = self.lookup_pandora_track(uri) - if pandora_track: - track = models.Track(name=pandora_track.song_name, uri=uri, length=pandora_track.track_length * 1000, - bitrate=int(pandora_track.bitrate), - artists=[models.Artist(name=pandora_track.artist_name)], - album=models.Album(name=pandora_track.album_name, - uri=pandora_track.album_detail_url, - images=[pandora_track.album_art_url])) - return [track] + if pandora_track is not None: + if pandora_track.is_ad: + return[models.Track(name='Advertisement', uri=uri)] + + else: + return[models.Track(name=pandora_track.song_name, uri=uri, length=pandora_track.track_length * 1000, + bitrate=int(pandora_track.bitrate), + artists=[models.Artist(name=pandora_track.artist_name)], + album=models.Album(name=pandora_track.album_name, + uri=pandora_track.album_detail_url, + images=[pandora_track.album_art_url]))] logger.error("Failed to lookup '%s'", uri) return [] @@ -93,11 +96,9 @@ def _browse_stations(self): def _browse_tracks(self, uri): pandora_uri = PandoraUri.parse(uri) - # TODO: should be able to perform check on is_ad() once dynamic tracklist support is available - # if not self._station or (not track.is_ad() and station_id != self._station.id): - if self._station is None or (pandora_uri.station_id != '' and pandora_uri.station_id != self._station.id): + if self._station is None or (pandora_uri.station_id != self._station.id): - if pandora_uri.is_genre_station_uri(): + if pandora_uri.is_genre_station_uri: pandora_uri = self._create_station_for_genre(pandora_uri.token) self._station = self.backend.api.get_station(pandora_uri.station_id) @@ -130,13 +131,14 @@ def lookup_pandora_track(self, uri): def next_track(self): try: pandora_track = self._station_iter.next() + track_uri = TrackUri.from_track(pandora_track) - if pandora_track.track_token is None: - # TODO process add tokens properly when pydora 1.6 is available - return self.next_track() + if track_uri.is_ad_uri: + track_name = 'Advertisement' + else: + track_name = pandora_track.song_name - track_uri = TrackUri.from_track(pandora_track) - track = models.Ref.track(name=pandora_track.song_name, uri=track_uri.uri) + track = models.Ref.track(name=track_name, uri=track_uri.uri) if any(self._pandora_tracks_cache) and track_uri.station_id != \ TrackUri.parse(self._pandora_tracks_cache.keys()[0]).station_id: diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index f4d9da5..dd2f431 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -66,6 +66,7 @@ def __init__(self, station_id, token): self.station_id = station_id self.token = token + @property def is_genre_station_uri(self): return self.station_id.startswith('G') and self.station_id == self.token @@ -84,7 +85,7 @@ def uri(self): class TrackUri(StationUri): scheme = 'track' - ADVERTISEMENT_TOKEN = "advertisement-none" + ADVERTISEMENT_TOKEN = "advertisement" def __init__(self, station_id, token): super(TrackUri, self).__init__(station_id, token) @@ -94,7 +95,7 @@ def from_track(cls, track): if isinstance(track, PlaylistItem): return TrackUri(track.station_id, track.track_token) elif isinstance(track, AdItem): - return TrackUri('', cls.ADVERTISEMENT_TOKEN) + return TrackUri(track.station_id, cls.ADVERTISEMENT_TOKEN) else: raise NotImplementedError("Unsupported playlist item type") @@ -104,5 +105,6 @@ def uri(self): super(TrackUri, self).uri, ) - def is_ad(self): + @property + def is_ad_uri(self): return self.token == self.ADVERTISEMENT_TOKEN diff --git a/tests/conftest.py b/tests/conftest.py index 127c71e..dfe5d73 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,7 +54,6 @@ def config(): 'preferred_audio_quality': MOCK_DEFAULT_AUDIO_QUALITY, 'sort_order': 'date', 'auto_setup': True, - 'ad_support_enabled': True, 'event_support_enabled': True, 'double_click_interval': '0.1', diff --git a/tests/test_uri.py b/tests/test_uri.py index fd1ea54..fb20eef 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -118,9 +118,9 @@ def test_track_uri_is_ad(playlist_item_mock, ad_item_mock): track_uri = TrackUri.from_track(ad_item_mock) obj = TrackUri.parse(track_uri.uri) - assert obj.is_ad() + assert obj.is_ad_uri() track_uri = TrackUri.from_track(playlist_item_mock) obj = TrackUri.parse(track_uri.uri) - assert not obj.is_ad() + assert not obj.is_ad_uri() From 7db3a6ee5a2a5ff6142024002e72e548c7163c12 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 6 Dec 2015 15:23:11 +0200 Subject: [PATCH 056/311] Major refactoring to convert to using Pykka actors. --- mopidy_pandora/__init__.py | 6 +- mopidy_pandora/backend.py | 91 +++++++++++------- mopidy_pandora/ext.conf | 1 - mopidy_pandora/frontend.py | 177 ++++++++++++++++++++++++++++++++++++ mopidy_pandora/library.py | 14 +-- mopidy_pandora/listener.py | 31 +++++++ mopidy_pandora/playback.py | 121 +++++++----------------- mopidy_pandora/tracklist.py | 99 -------------------- 8 files changed, 308 insertions(+), 232 deletions(-) create mode 100644 mopidy_pandora/frontend.py create mode 100644 mopidy_pandora/listener.py delete mode 100644 mopidy_pandora/tracklist.py diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index 07bd104..4bb7170 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -7,7 +7,6 @@ from pandora import BaseAPIClient - __version__ = '0.2.0' logger = logging.getLogger(__name__) @@ -38,8 +37,9 @@ def get_config_schema(self): BaseAPIClient.HIGH_AUDIO_QUALITY]) schema['sort_order'] = config.String(optional=True, choices=['date', 'A-Z', 'a-z']) schema['auto_setup'] = config.Boolean(optional=True) + schema['auto_set_repeat'] = config.Deprecated() schema['cache_time_to_live'] = config.Integer(optional=True) - schema['event_support_enabled'] = config.Boolean(optional=True) + schema['event_support_enabled'] = config.Deprecated() schema['double_click_interval'] = config.String(optional=True) schema['on_pause_resume_click'] = config.String(optional=True, choices=['thumbs_up', 'thumbs_down', 'sleep']) schema['on_pause_next_click'] = config.String(optional=True, choices=['thumbs_up', 'thumbs_down', 'sleep']) @@ -48,4 +48,6 @@ def get_config_schema(self): def setup(self, registry): from .backend import PandoraBackend + from .frontend import EventSupportPandoraFrontend registry.add('backend', PandoraBackend) + registry.add('frontend', EventSupportPandoraFrontend) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index e3e301e..6fee804 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -2,62 +2,91 @@ from mopidy.internal import encoding from pandora import BaseAPIClient +from pandora.errors import PandoraException import pykka import requests +from mopidy_pandora import listener, rpc -import rpc from mopidy_pandora.client import MopidyPandoraAPIClient, MopidyPandoraSettingsDictBuilder from mopidy_pandora.library import PandoraLibraryProvider -from mopidy_pandora.playback import EventSupportPlaybackProvider, PandoraPlaybackProvider -from mopidy_pandora.tracklist import PandoraTracklistProvider -from mopidy_pandora.uri import logger +from mopidy_pandora.playback import EventSupportPlaybackProvider +from mopidy_pandora.uri import logger, PandoraUri -class PandoraBackend(pykka.ThreadingActor, backend.Backend, core.CoreListener): +class PandoraBackend(pykka.ThreadingActor, backend.Backend, core.CoreListener, listener.PandoraListener): def __init__(self, config, audio): super(PandoraBackend, self).__init__() - self._config = config['pandora'] + self.config = config['pandora'] settings = { - "CACHE_TTL": self._config.get("cache_time_to_live", 1800), - "API_HOST": self._config.get("api_host", 'tuner.pandora.com/services/json/'), - "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"], - "AUDIO_QUALITY": self._config.get("preferred_audio_quality", BaseAPIClient.HIGH_AUDIO_QUALITY) + "CACHE_TTL": self.config.get("cache_time_to_live", 1800), + "API_HOST": self.config.get("api_host", 'tuner.pandora.com/services/json/'), + "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"], + "AUDIO_QUALITY": self.config.get("preferred_audio_quality", BaseAPIClient.HIGH_AUDIO_QUALITY) } - rpc.RPCClient.configure(config['http']['hostname'], config['http']['port']) - self.api = MopidyPandoraSettingsDictBuilder(settings, client_class=MopidyPandoraAPIClient).build() - self.library = PandoraLibraryProvider(backend=self, sort_order=self._config.get('sort_order', 'date')) - self.tracklist = PandoraTracklistProvider(self) - - self.auto_setup = self._config.get('auto_setup', True) - self.setup_required = self.auto_setup - - self.supports_events = False - if self._config.get('event_support_enabled', True): - self.supports_events = True - self.playback = EventSupportPlaybackProvider(audio=audio, backend=self) - else: - self.playback = PandoraPlaybackProvider(audio=audio, backend=self) + self.library = PandoraLibraryProvider(backend=self, sort_order=self.config.get('sort_order', 'date')) + self.playback = EventSupportPlaybackProvider(audio=audio, backend=self) self.uri_schemes = ['pandora'] @rpc.run_async def on_start(self): try: - self.api.login(self._config["username"], self._config["password"]) + logger.debug('PandoraBackend: doing on_start') + self.api.login(self.config["username"], self.config["password"]) # Prefetch list of stations linked to the user's profile self.api.get_station_list() + # Prefetch genre category list + self.api.get_genre_stations() except requests.exceptions.RequestException as e: logger.error('Error logging in to Pandora: %s', encoding.locale_decode(e)) - def tracklist_changed(self): - self.setup_required = self.auto_setup + def end_of_tracklist_reached(self): + logger.debug('PandoraBackend: Handling end_of_tracklist_reached event') + next_track = self.library.next_track() + if next_track: + self._trigger_add_next_pandora_track(next_track) + + def _trigger_add_next_pandora_track(self, track): + logger.debug('PandoraBackend: Triggering add_next_pandora_track event') + listener.PandoraListener.send('add_next_pandora_track', track=track) + + def _trigger_event_processed(self, track_uri): + logger.debug('PandoraBackend: Triggering event_processed event') + listener.PandoraListener.send('event_processed', track_uri=track_uri) + + def call_event(self, track_uri, pandora_event): + logger.debug('PandoraBackend: Handling call_event %s, %s', track_uri, pandora_event) + func = getattr(self, pandora_event) + try: + logger.info("Triggering event '%s' for song: %s", pandora_event, + self.library.lookup_pandora_track(track_uri).song_name) + func(track_uri) + self._trigger_event_processed(track_uri) + except PandoraException as e: + logger.error('Error calling event: %s', encoding.locale_decode(e)) + return False + + def thumbs_up(self, track_uri): + return self.api.add_feedback(PandoraUri.parse(track_uri).token, True) + + def thumbs_down(self, track_uri): + return self.api.add_feedback(PandoraUri.parse(track_uri).token, False) + + def sleep(self, track_uri): + return self.api.sleep_song(PandoraUri.parse(track_uri).token) + + def add_artist_bookmark(self, track_uri): + return self.api.add_artist_bookmark(PandoraUri.parse(track_uri).token) + + def add_song_bookmark(self, track_uri): + return self.api.add_song_bookmark(PandoraUri.parse(track_uri).token) diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index 41cd683..465396e 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -14,7 +14,6 @@ auto_setup = true cache_time_to_live = 1800 ### EXPERIMENTAL RATINGS IMPLEMENTATION ### -event_support_enabled = false double_click_interval = 2.00 on_pause_resume_click = thumbs_up on_pause_next_click = thumbs_down diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py new file mode 100644 index 0000000..c269bcd --- /dev/null +++ b/mopidy_pandora/frontend.py @@ -0,0 +1,177 @@ +import Queue +import threading +from mopidy import core + +import pykka + +from mopidy_pandora import logger, listener +from mopidy_pandora.uri import TrackUri + + + +class PandoraFrontend(pykka.ThreadingActor, core.CoreListener, listener.PandoraListener): + + def __init__(self, config, core): + super(PandoraFrontend, self).__init__() + + self.config = config + self.auto_setup = self.config.get('auto_setup', True) + + self.setup_required = True + self.core = core + + # TODO: only configure when pandora track starts to play + def on_start(self): + self.set_options() + + 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_repeat().get() is True: self.core.tracklist.set_repeat(False) + if self.core.tracklist.get_consume().get() is False: self.core.tracklist.set_consume(True) + if self.core.tracklist.get_random().get() is True: self.core.tracklist.set_random(False) + if self.core.tracklist.get_single().get() is True: self.core.tracklist.set_single(False) + + self.setup_required = False + + def options_changed(self): + logger.debug('PandoraFrontend: Handling options_changed event') + self.setup_required = True + + def prepare_change(self): + logger.debug('PandoraFrontend: Handling prepare_change event') + if self.is_playing_last_track(): + self._trigger_end_of_tracklist_reached() + + self.set_options() + + def stop(self): + self.core.playback.stop() + + def is_playing_last_track(self): + """ Sync the current tracklist information, and add more Pandora tracks to the tracklist as necessary. + """ + current_tl_track = self.core.playback.get_current_tl_track().get() + next_tl_track = self.core.tracklist.next_track(current_tl_track).get() + + return next_tl_track is None + + def add_next_pandora_track(self, track): + logger.debug('PandoraFrontend: Handling add_next_pandora_track event') + self.core.tracklist.add(uris=[track.uri]) + + def _trigger_end_of_tracklist_reached(self): + logger.debug('PandoraFrontend: Triggering end_of_tracklist_reached event') + listener.PandoraListener.send('end_of_tracklist_reached') + +class EventSupportPandoraFrontend(PandoraFrontend): + + def __init__(self, config, core): + super(EventSupportPandoraFrontend, self).__init__(config, core) + + self.on_pause_resume_click = config.get("on_pause_resume_click", "thumbs_up") + self.on_pause_next_click = config.get("on_pause_next_click", "thumbs_down") + self.on_pause_previous_click = config.get("on_pause_previous_click", "sleep") + + self.previous_tl_track = None + self.current_tl_track = None + self.next_tl_track = None + + self.event_processed_event = threading.Event() + self.event_processed_event.set() + self.event_target_uri = None + + self.tracklist_changed_event = threading.Event() + self.tracklist_changed_event.set() + + def tracklist_changed(self): + logger.debug('EventSupportPandoraFrontend: Handling tracklist_changed event') + + if self.event_processed_event.isSet(): + self.current_tl_track = self.core.playback.get_current_tl_track().get() + self.previous_tl_track = self.core.tracklist.previous_track(self.current_tl_track).get() + self.next_tl_track = self.core.tracklist.next_track(self.current_tl_track).get() + self.tracklist_changed_event.set() + else: + self.tracklist_changed_event.clear() + + # def track_playback_paused(self, tl_track, time_position): + # # TODO: REMOVE WORKAROUND. + # # Mopidy does not add the track to the history if the user skips to the next track + # # while the player is paused (i.e. click pause -> next -> resume). Manually add the track + # # to the history until this is fixed. + # + # history = self.core.history.get_history().get() + # for tupple in history: + # if tupple[1].uri == tl_track.track.uri: + # return + # + # self.core.history._add_track(tl_track.track) + + # def track_playback_started(self, tl_track): + # logger.debug('EventSupportPandoraFrontend: Handling track_playback_started event') + # track_changed = True + # self._process_events(tl_track.track.uri, track_changed=track_changed) + + def track_playback_resumed(self, tl_track, time_position): + logger.debug('EventSupportPandoraFrontend: Handling track_playback_resumed event') + track_changed = time_position == 0 + self._process_events(tl_track.track.uri, track_changed=track_changed) + + def _process_events(self, track_uri, track_changed=False): + + # Check if there are any events that still require processing + if self.event_processed_event.isSet(): + # No events to process + return + + if track_changed: + # Trigger the event for the previously played track. + history = self.core.history.get_history().get() + self.event_target_uri = history[1][1].uri + else: + # Trigger the event for the track that is playing currently + self.event_target_uri = track_uri + + if TrackUri.parse(self.event_target_uri).is_ad_uri: + logger.info('Ignoring doubleclick event for advertisement') + self.event_processed_event.set() + return + + if track_uri == self.previous_tl_track.track.uri: + if not track_changed: + # Resuming playback on the first track in the tracklist. + event = self.on_pause_resume_click + else: + event = self.on_pause_previous_click + + elif track_uri == self.current_tl_track.track.uri: + event = self.on_pause_resume_click + + elif track_uri == self.next_tl_track.track.uri: + event = self.on_pause_next_click + else: + logger.error("Unexpected doubleclick event URI '%s'", track_uri) + self.event_processed_event.set() + return + + self._trigger_call_event(self.event_target_uri, event) + + def event_processed(self, track_uri): + logger.debug('EventSupportPandoraFrontend: Handling event_processed event') + if self.event_target_uri and self.event_target_uri != track_uri: + logger.error("Unexpected event_processed URI '%s',", track_uri) + + self.event_processed_event.set() + self.event_target_uri = None + if not self.tracklist_changed_event.isSet(): + self.tracklist_changed() + + def doubleclicked(self): + logger.debug('EventSupportPandoraFrontend: Handling doubleclicked event') + self.event_processed_event.clear() + self.core.playback.resume() + + def _trigger_call_event(self, track_uri, event): + logger.debug('EventSupportPandoraFrontend: Triggering call_event event') + listener.PandoraListener.send('call_event', track_uri=track_uri, pandora_event=event) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 5117bb2..7b21c4e 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -10,6 +10,7 @@ import requests +from mopidy_pandora import listener from mopidy_pandora.uri import GenreUri, logger, PandoraUri, StationUri, TrackUri # noqa I101 @@ -31,8 +32,6 @@ def __init__(self, backend, sort_order): def browse(self, uri): if uri == self.root_directory.uri: - # Prefetch genre category list - self.backend.api.get_genre_stations() return self._browse_stations() if uri == self.genre_directory.uri: @@ -140,14 +139,9 @@ def next_track(self): track = models.Ref.track(name=track_name, uri=track_uri.uri) - if any(self._pandora_tracks_cache) and track_uri.station_id != \ - TrackUri.parse(self._pandora_tracks_cache.keys()[0]).station_id: - - # We've switched stations, clear the cache. - self._pandora_tracks_cache.clear() - + self._pandora_tracks_cache.expire() self._pandora_tracks_cache[track.uri] = pandora_track - return track + except requests.exceptions.RequestException as e: - logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) + logger.error('Error retrieving next Pandora track: %s', encoding.locale_decode(e)) diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py new file mode 100644 index 0000000..eb75555 --- /dev/null +++ b/mopidy_pandora/listener.py @@ -0,0 +1,31 @@ +from __future__ import absolute_import, unicode_literals + +from mopidy import listener + + +class PandoraListener(listener.Listener): + + @staticmethod + def send(event, **kwargs): + listener.send_async(PandoraListener, event, **kwargs) + + def end_of_tracklist_reached(self): + pass + + def add_next_pandora_track(self, track): + pass + + def prepare_change(self): + pass + + def doubleclicked(self): + pass + + def call_event(self, track_uri, pandora_event): + pass + + def event_processed(self, track_uri): + pass + + def stop(self): + pass diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 31e2fbe..84099f5 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -1,18 +1,15 @@ import Queue -import threading import time from mopidy import backend from mopidy.internal import encoding -from pandora.errors import PandoraException - import requests -from mopidy_pandora import rpc +from mopidy_pandora import listener, rpc -from mopidy_pandora.uri import logger, PandoraUri # noqa I101 +from mopidy_pandora.uri import logger class PandoraPlaybackProvider(backend.PlaybackProvider): @@ -43,47 +40,32 @@ def consecutive_track_skips(self, value=1): if self.consecutive_track_skips >= self.SKIP_LIMIT-1: logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) - self.trigger_stop() + self._trigger_stop() else: self._consecutive_track_skips = 0 def prepare_change(self): - if self.backend.auto_setup and self.backend.setup_required: - self.backend.tracklist.configure() - self.backend.setup_required = False - + self._trigger_prepare_change() super(PandoraPlaybackProvider, self).prepare_change() def change_track(self, track): - try: - if track.uri is None: - logger.warning("No URI for track '%s'. Track cannot be played.", track.name) - self.consecutive_track_skips += 1 - return False - - if self.is_playable(track.uri): - self.consecutive_track_skips = 0 - return super(PandoraPlaybackProvider, self).change_track(track) - else: - self.consecutive_track_skips += 1 - return False + if track.uri is None: + logger.warning("No URI for track '%s'. Track cannot be played.", track.name) + self.consecutive_track_skips += 1 + return False - finally: - # TODO: how to ensure consistent state if tracklist sync fails? - # Should we stop playback or retry? Ignore events? - self.backend.tracklist.sync() + if self.is_playable(track.uri): + self.consecutive_track_skips = 0 + return super(PandoraPlaybackProvider, self).change_track(track) + else: + self.consecutive_track_skips += 1 + return False def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url - def trigger_resume(self, queue=Queue.Queue(1)): - return rpc.RPCClient._do_rpc('core.playback.resume', queue=queue) - - def trigger_stop(cls, queue=Queue.Queue(1)): - return rpc.RPCClient._do_rpc('core.playback.stop', queue=queue) - - def get_current_tl_track(self, queue=Queue.Queue(1)): - return rpc.RPCClient._do_rpc('core.playback.get_current_tl_track', queue=queue) + # def get_current_tl_track(self, queue=Queue.Queue(1)): + # return rpc.RPCClient._do_rpc('core.playback.get_current_tl_track', queue=queue) def is_playable(self, track_uri): """ A track is playable if it can be retrieved, has a URL, and the Pandora URL can be accessed. @@ -101,18 +83,20 @@ def is_playable(self, track_uri): finally: return is_playable + def _trigger_prepare_change(self): + logger.debug('PandoraPlaybackProvider: Triggering prepare_change event') + listener.PandoraListener.send('prepare_change') + + def _trigger_stop(self): + logger.debug('PandoraPlaybackProvider: Triggering stop event') + listener.PandoraListener.send('stop') + class EventSupportPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): super(EventSupportPlaybackProvider, self).__init__(audio, backend) - self._doubleclick_processed_event = threading.Event() - - config = self.backend._config - self.on_pause_resume_click = config.get("on_pause_resume_click", "thumbs_up") - self.on_pause_next_click = config.get("on_pause_next_click", "thumbs_down") - self.on_pause_previous_click = config.get("on_pause_previous_click", "sleep") - self.double_click_interval = float(config.get('double_click_interval', 2.00)) + self.double_click_interval = float(backend.config.get('double_click_interval', 2.00)) self._click_time = 0 @@ -128,9 +112,7 @@ def get_click_time(self): def is_double_click(self): double_clicked = self._click_time > 0 and time.time() - self._click_time < self.double_click_interval - if double_clicked: - self._doubleclick_processed_event.clear() - else: + if not double_clicked: self._click_time = 0 return double_clicked @@ -138,25 +120,14 @@ def is_double_click(self): def change_track(self, track): if self.is_double_click(): - if track.uri == self.backend.tracklist.next_tl_track['track']['uri']: - self.process_click(self.on_pause_next_click, - self.backend.tracklist.current_tl_track['track']['uri']) - - elif track.uri == self.backend.tracklist.previous_tl_track['track']['uri']: - self.process_click(self.on_pause_previous_click, - self.backend.tracklist.current_tl_track['track']['uri']) - - # Resume playback after doubleclick has been processed - self.trigger_resume() - - # Wait until events that depend on the tracklist state have finished processing - self._doubleclick_processed_event.wait(rpc.thread_timeout) + self._trigger_doubleclicked() + self.set_click_time(0) return super(EventSupportPlaybackProvider, self).change_track(track) def resume(self): if self.is_double_click() and self.get_time_position() > 0: - self.process_click(self.on_pause_resume_click, self.backend.tracklist.current_tl_track['track']['uri']) + self._trigger_doubleclicked() return super(EventSupportPlaybackProvider, self).resume() @@ -166,36 +137,8 @@ def pause(self): return super(EventSupportPlaybackProvider, self).pause() - @rpc.run_async - def process_click(self, method, track_uri): - self.set_click_time(0) - - uri = PandoraUri.parse(track_uri) - logger.info("Triggering event '%s' for song: %s", method, - self.backend.library.lookup_pandora_track(track_uri).song_name) - - func = getattr(self, method) - try: - func(uri.token) - - except PandoraException as e: - logger.error('Error calling event: %s', encoding.locale_decode(e)) - return False - finally: - # Reset lock so that we are ready to process the next event. - self._doubleclick_processed_event.set() - - def thumbs_up(self, track_token): - return self.backend.api.add_feedback(track_token, True) - - def thumbs_down(self, track_token): - return self.backend.api.add_feedback(track_token, False) - - def sleep(self, track_token): - return self.backend.api.sleep_song(track_token) + def _trigger_doubleclicked(self): + logger.debug('EventSupportPlaybackProvider: Triggering doubleclicked event') + listener.PandoraListener.send('doubleclicked') - def add_artist_bookmark(self, track_token): - return self.backend.api.add_artist_bookmark(track_token) - def add_song_bookmark(self, track_token): - return self.backend.api.add_song_bookmark(track_token) diff --git a/mopidy_pandora/tracklist.py b/mopidy_pandora/tracklist.py deleted file mode 100644 index 4c88a52..0000000 --- a/mopidy_pandora/tracklist.py +++ /dev/null @@ -1,99 +0,0 @@ -import Queue - -import threading - -from mopidy.internal import encoding - -from mopidy_pandora import logger, rpc - - -class PandoraTracklistProvider(object): - - def __init__(self, backend): - - self.backend = backend - - self.previous_tl_track = None - self.current_tl_track = None - self.next_tl_track = None - - self._tracklist_synced_event = threading.Event() - - @property - def tracklist_is_synced(self): - return self._tracklist_synced_event.get() - - def configure(self): - # Setup tracklist to mirror behaviour of official Pandora front-ends. - self.set_repeat(False) - self.set_consume(True) - self.set_random(False) - self.set_single(False) - - def get_length(self, queue=Queue.Queue(1)): - return rpc.RPCClient._do_rpc('core.tracklist.get_length', queue=queue) - - def set_random(self, value=True, queue=Queue.Queue(1)): - return rpc.RPCClient._do_rpc('core.tracklist.set_random', params={'value': value}, queue=queue) - - def set_repeat(self, value=True, queue=Queue.Queue(1)): - return rpc.RPCClient._do_rpc('core.tracklist.set_repeat', params={'value': value}, queue=queue) - - def set_single(self, value=True, queue=Queue.Queue(1)): - return rpc.RPCClient._do_rpc('core.tracklist.set_single', params={'value': value}, queue=queue) - - def set_consume(self, value=True, queue=Queue.Queue(1)): - return rpc.RPCClient._do_rpc('core.tracklist.set_consume', params={'value': value}, queue=queue) - - def index(self, tl_track=None, tlid=None, queue=Queue.Queue(1)): - return rpc.RPCClient._do_rpc('core.tracklist.index', params={'tl_track': tl_track, 'tlid': tlid}, - queue=queue) - - def next_track(self, tl_track, queue=Queue.Queue(1)): - return rpc.RPCClient._do_rpc('core.tracklist.next_track', params={'tl_track': tl_track}, queue=queue) - - def previous_track(self, tl_track, queue=Queue.Queue(1)): - return rpc.RPCClient._do_rpc('core.tracklist.previous_track', params={'tl_track': tl_track}, queue=queue) - - def add(self, tracks=None, at_position=None, uri=None, uris=None, queue=Queue.Queue(1)): - return rpc.RPCClient._do_rpc('core.tracklist.add', params={'tracks': tracks, 'at_position': at_position, - 'uri': uri, 'uris': uris}, queue=queue) - - def clear(self): - raise NotImplementedError - - @rpc.run_async - def sync(self): - """ Sync the current tracklist information, and add more Pandora tracks to the tracklist as necessary. - """ - self._tracklist_synced_event.clear() - try: - self.current_tl_track = self.backend.playback.get_current_tl_track().result_queue\ - .get(timeout=rpc.thread_timeout) - - tl_index = self.index(tlid=self.current_tl_track['tlid']).result_queue\ - .get(timeout=rpc.thread_timeout) - - tl_length = self.get_length().result_queue.get(timeout=rpc.thread_timeout) - - # TODO note that tlid's will be changed to start at '1' instead of '0' in the next release of Mopidy. - # the following statement should change to 'if index >= length:' when that happens. - # see https://github.com/mopidy/mopidy/commit/4c5e80a2790c6bea971b105f11ab3f7c16617173 - if tl_index >= tl_length-1: - # We're at the end of the tracklist, add the next Pandora track - track = self.backend.library.next_track() - - t = self.add(uris=[track.uri]) - t.join(rpc.thread_timeout*2) - - self.previous_tl_track = self.previous_track(self.current_tl_track).result_queue\ - .get(timeout=rpc.thread_timeout) - - self.next_tl_track = self.next_track(self.current_tl_track).result_queue\ - .get(timeout=rpc.thread_timeout) - - self._tracklist_synced_event.set() - - except Exception as e: - logger.error('Error syncing tracklist: %s.', encoding.locale_decode(e)) - self.previous_tl_track = self.current_tl_track = self.next_tl_track = None From 9d79130ec63c6711d80afc7d9ba02c1c2201da88 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 6 Dec 2015 16:26:52 +0200 Subject: [PATCH 057/311] Code cleanup. Increment mopidy version number for bugfix of #1352 --- mopidy_pandora/backend.py | 5 +-- mopidy_pandora/client.py | 2 -- mopidy_pandora/frontend.py | 63 +++++++++++++++----------------------- mopidy_pandora/library.py | 15 +++++---- mopidy_pandora/playback.py | 10 +----- setup.py | 2 +- 6 files changed, 39 insertions(+), 58 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 6fee804..8464bce 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -7,13 +7,14 @@ import pykka import requests + from mopidy_pandora import listener, rpc from mopidy_pandora.client import MopidyPandoraAPIClient, MopidyPandoraSettingsDictBuilder from mopidy_pandora.library import PandoraLibraryProvider from mopidy_pandora.playback import EventSupportPlaybackProvider -from mopidy_pandora.uri import logger, PandoraUri +from mopidy_pandora.uri import logger, PandoraUri # noqa: I101 class PandoraBackend(pykka.ThreadingActor, backend.Backend, core.CoreListener, listener.PandoraListener): @@ -69,7 +70,7 @@ def call_event(self, track_uri, pandora_event): func = getattr(self, pandora_event) try: logger.info("Triggering event '%s' for song: %s", pandora_event, - self.library.lookup_pandora_track(track_uri).song_name) + self.library.lookup_pandora_track(track_uri).song_name) func(track_uri) self._trigger_event_processed(track_uri) except PandoraException as e: diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 73eb024..3cfc694 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -72,8 +72,6 @@ def get_genre_stations(self, force_refresh=False): (force_refresh is True and self._genre_stations_cache.itervalues().next().has_changed()): try: self._genre_stations_cache['key'] = super(MopidyPandoraAPIClient, self).get_genre_stations() - # if any(self._genre_stations_cache): - # self._genre_stations_cache.sort(key=lambda x: x[0], reverse=False) except requests.exceptions.RequestException as e: logger.error('Error retrieving genre stations: %s', encoding.locale_decode(e)) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index c269bcd..8706266 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -1,14 +1,13 @@ -import Queue import threading + from mopidy import core import pykka -from mopidy_pandora import logger, listener +from mopidy_pandora import listener, logger from mopidy_pandora.uri import TrackUri - class PandoraFrontend(pykka.ThreadingActor, core.CoreListener, listener.PandoraListener): def __init__(self, config, core): @@ -27,10 +26,15 @@ def on_start(self): 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_repeat().get() is True: self.core.tracklist.set_repeat(False) - if self.core.tracklist.get_consume().get() is False: self.core.tracklist.set_consume(True) - if self.core.tracklist.get_random().get() is True: self.core.tracklist.set_random(False) - if self.core.tracklist.get_single().get() is True: self.core.tracklist.set_single(False) + assert isinstance(self.core.tracklist, object) + if self.core.tracklist.get_repeat().get() is True: + self.core.tracklist.set_repeat(False) + if self.core.tracklist.get_consume().get() is False: + self.core.tracklist.set_consume(True) + if self.core.tracklist.get_random().get() is True: + self.core.tracklist.set_random(False) + if self.core.tracklist.get_single().get() is True: + self.core.tracklist.set_single(False) self.setup_required = False @@ -49,8 +53,6 @@ def stop(self): self.core.playback.stop() def is_playing_last_track(self): - """ Sync the current tracklist information, and add more Pandora tracks to the tracklist as necessary. - """ current_tl_track = self.core.playback.get_current_tl_track().get() next_tl_track = self.core.tracklist.next_track(current_tl_track).get() @@ -64,6 +66,7 @@ def _trigger_end_of_tracklist_reached(self): logger.debug('PandoraFrontend: Triggering end_of_tracklist_reached event') listener.PandoraListener.send('end_of_tracklist_reached') + class EventSupportPandoraFrontend(PandoraFrontend): def __init__(self, config, core): @@ -79,7 +82,6 @@ def __init__(self, config, core): self.event_processed_event = threading.Event() self.event_processed_event.set() - self.event_target_uri = None self.tracklist_changed_event = threading.Event() self.tracklist_changed_event.set() @@ -87,31 +89,15 @@ def __init__(self, config, core): def tracklist_changed(self): logger.debug('EventSupportPandoraFrontend: Handling tracklist_changed event') - if self.event_processed_event.isSet(): + if not self.event_processed_event.isSet(): + # Delay 'tracklist_changed' events untill all events have been processed. + self.tracklist_changed_event.clear() + else: self.current_tl_track = self.core.playback.get_current_tl_track().get() self.previous_tl_track = self.core.tracklist.previous_track(self.current_tl_track).get() self.next_tl_track = self.core.tracklist.next_track(self.current_tl_track).get() - self.tracklist_changed_event.set() - else: - self.tracklist_changed_event.clear() - # def track_playback_paused(self, tl_track, time_position): - # # TODO: REMOVE WORKAROUND. - # # Mopidy does not add the track to the history if the user skips to the next track - # # while the player is paused (i.e. click pause -> next -> resume). Manually add the track - # # to the history until this is fixed. - # - # history = self.core.history.get_history().get() - # for tupple in history: - # if tupple[1].uri == tl_track.track.uri: - # return - # - # self.core.history._add_track(tl_track.track) - - # def track_playback_started(self, tl_track): - # logger.debug('EventSupportPandoraFrontend: Handling track_playback_started event') - # track_changed = True - # self._process_events(tl_track.track.uri, track_changed=track_changed) + self.tracklist_changed_event.set() def track_playback_resumed(self, tl_track, time_position): logger.debug('EventSupportPandoraFrontend: Handling track_playback_resumed event') @@ -128,12 +114,12 @@ def _process_events(self, track_uri, track_changed=False): if track_changed: # Trigger the event for the previously played track. history = self.core.history.get_history().get() - self.event_target_uri = history[1][1].uri + event_target_uri = history[1][1].uri else: # Trigger the event for the track that is playing currently - self.event_target_uri = track_uri + event_target_uri = track_uri - if TrackUri.parse(self.event_target_uri).is_ad_uri: + if TrackUri.parse(event_target_uri).is_ad_uri: logger.info('Ignoring doubleclick event for advertisement') self.event_processed_event.set() return @@ -155,21 +141,22 @@ def _process_events(self, track_uri, track_changed=False): self.event_processed_event.set() return - self._trigger_call_event(self.event_target_uri, event) + self._trigger_call_event(event_target_uri, event) def event_processed(self, track_uri): logger.debug('EventSupportPandoraFrontend: Handling event_processed event') - if self.event_target_uri and self.event_target_uri != track_uri: - logger.error("Unexpected event_processed URI '%s',", track_uri) self.event_processed_event.set() - self.event_target_uri = None + if not self.tracklist_changed_event.isSet(): + # Do any 'tracklist_changed' updates that are pending self.tracklist_changed() def doubleclicked(self): logger.debug('EventSupportPandoraFrontend: Handling doubleclicked event') + self.event_processed_event.clear() + # Resume playback of the next track so long... self.core.playback.resume() def _trigger_call_event(self, track_uri, event): diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 7b21c4e..34fa041 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -10,7 +10,7 @@ import requests -from mopidy_pandora import listener +from mopidy_pandora import rpc from mopidy_pandora.uri import GenreUri, logger, PandoraUri, StationUri, TrackUri # noqa I101 @@ -58,11 +58,11 @@ def lookup(self, uri): else: return[models.Track(name=pandora_track.song_name, uri=uri, length=pandora_track.track_length * 1000, - bitrate=int(pandora_track.bitrate), - artists=[models.Artist(name=pandora_track.artist_name)], - album=models.Album(name=pandora_track.album_name, - uri=pandora_track.album_detail_url, - images=[pandora_track.album_art_url]))] + bitrate=int(pandora_track.bitrate), + artists=[models.Artist(name=pandora_track.artist_name)], + album=models.Album(name=pandora_track.album_name, + uri=pandora_track.album_detail_url, + images=[pandora_track.album_art_url]))] logger.error("Failed to lookup '%s'", uri) return [] @@ -90,6 +90,9 @@ def _browse_stations(self): models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri)) station_directories.insert(0, self.genre_directory) + + # Prefetch genre category list + rpc.run_async(self.backend.api.get_genre_stations)() return station_directories def _browse_tracks(self, uri): diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 84099f5..0ceccce 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -1,5 +1,3 @@ -import Queue - import time from mopidy import backend @@ -7,7 +5,7 @@ import requests -from mopidy_pandora import listener, rpc +from mopidy_pandora import listener from mopidy_pandora.uri import logger @@ -64,9 +62,6 @@ def change_track(self, track): def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url - # def get_current_tl_track(self, queue=Queue.Queue(1)): - # return rpc.RPCClient._do_rpc('core.playback.get_current_tl_track', queue=queue) - def is_playable(self, track_uri): """ A track is playable if it can be retrieved, has a URL, and the Pandora URL can be accessed. @@ -97,7 +92,6 @@ def __init__(self, audio, backend): super(EventSupportPlaybackProvider, self).__init__(audio, backend) self.double_click_interval = float(backend.config.get('double_click_interval', 2.00)) - self._click_time = 0 def set_click_time(self, click_time=None): @@ -140,5 +134,3 @@ def pause(self): def _trigger_doubleclicked(self): logger.debug('EventSupportPlaybackProvider: Triggering doubleclicked event') listener.PandoraListener.send('doubleclicked') - - diff --git a/setup.py b/setup.py index 59fc200..818f03d 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ def run_tests(self): install_requires=[ 'setuptools', 'cachetools >= 1.0.0', - 'Mopidy >= 1.1.1', + 'Mopidy >= 1.1.2', 'Pykka >= 1.1', 'pydora >= 1.6', 'requests >= 2.5.0' From dcda3b590bd34ff8f444931d943337d1f900d6ba Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 6 Dec 2015 17:11:06 +0200 Subject: [PATCH 058/311] Make event support user-configurable again. Cleanup debug log messages. --- README.rst | 11 ++++++----- mopidy_pandora/__init__.py | 2 +- mopidy_pandora/backend.py | 15 ++++++++------- mopidy_pandora/ext.conf | 1 + mopidy_pandora/frontend.py | 12 +----------- mopidy_pandora/playback.py | 3 --- 6 files changed, 17 insertions(+), 27 deletions(-) diff --git a/README.rst b/README.rst index 24fdc9b..b8fefd9 100644 --- a/README.rst +++ b/README.rst @@ -38,9 +38,9 @@ Before starting Mopidy, you must add the configuration settings for Mopidy-Pando enabled = true api_host = tuner.pandora.com/services/json/ partner_encryption_key = - partner_decryption_key = + partner_decryption_key = partner_username = iphone - partner_password = + partner_password = partner_device = IP01 preferred_audio_quality = highQuality username = @@ -83,7 +83,7 @@ Usage Mopidy needs `dynamic playlist `_ and `core extensions `_ support to properly support Pandora. In the meantime, Mopidy-Pandora simulates dynamic playlists by adding more tracks to the tracklist as needed. It is recommended that the -Playlist is played with **consume** turned on in order to simulate the behaviour of the standard Pandora clients. For +tracklist is played with **consume** turned on in order to simulate the behaviour of the standard Pandora clients. For the same reason, **repeat**, **random**, and **single** should be turned off. Mopidy-Pandora will set all of this up automatically unless you set the **auto_setup** config parameter to 'false'. @@ -111,8 +111,9 @@ v0.2.0 (UNRELEASED) your profile. At the moment, there is no way to remove stations from within Mopidy-Pandora. - Force Mopidy to stop when skip limit is exceeded (workaround for https://github.com/mopidy/mopidy/issues/1221). - Scrobbling tracks to Last.fm should now work -- Implemented caching to speed up browsing of the list of stations. Configuration parameter 'cache_time_to_live' can be - used to specify when cache iterms should expire and be refreshed (in seconds). +- Implemented caching to speed up startup and browsing of the list of stations. Configuration parameter + 'cache_time_to_live' can be used to specify when cache iterms should expire and be refreshed (in seconds). +- Better support for non-PandoraONE users: now plays advertisements which should prevent free accounts from being locked. v0.1.7 (Oct 31, 2015) ---------------------------------------- diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index 4bb7170..f9221bb 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -39,7 +39,7 @@ def get_config_schema(self): schema['auto_setup'] = config.Boolean(optional=True) schema['auto_set_repeat'] = config.Deprecated() schema['cache_time_to_live'] = config.Integer(optional=True) - schema['event_support_enabled'] = config.Deprecated() + schema['event_support_enabled'] = config.Boolean(optional=True) schema['double_click_interval'] = config.String(optional=True) schema['on_pause_resume_click'] = config.String(optional=True, choices=['thumbs_up', 'thumbs_down', 'sleep']) schema['on_pause_next_click'] = config.String(optional=True, choices=['thumbs_up', 'thumbs_down', 'sleep']) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 8464bce..23e0cf6 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -13,7 +13,7 @@ from mopidy_pandora.client import MopidyPandoraAPIClient, MopidyPandoraSettingsDictBuilder from mopidy_pandora.library import PandoraLibraryProvider -from mopidy_pandora.playback import EventSupportPlaybackProvider +from mopidy_pandora.playback import EventSupportPlaybackProvider, PandoraPlaybackProvider from mopidy_pandora.uri import logger, PandoraUri # noqa: I101 @@ -35,14 +35,19 @@ def __init__(self, config, audio): self.api = MopidyPandoraSettingsDictBuilder(settings, client_class=MopidyPandoraAPIClient).build() self.library = PandoraLibraryProvider(backend=self, sort_order=self.config.get('sort_order', 'date')) - self.playback = EventSupportPlaybackProvider(audio=audio, backend=self) + + self.supports_events = False + if self.config.get('event_support_enabled', True): + self.supports_events = True + self.playback = EventSupportPlaybackProvider(audio=audio, backend=self) + else: + self.playback = PandoraPlaybackProvider(audio=audio, backend=self) self.uri_schemes = ['pandora'] @rpc.run_async def on_start(self): try: - logger.debug('PandoraBackend: doing on_start') self.api.login(self.config["username"], self.config["password"]) # Prefetch list of stations linked to the user's profile self.api.get_station_list() @@ -52,21 +57,17 @@ def on_start(self): logger.error('Error logging in to Pandora: %s', encoding.locale_decode(e)) def end_of_tracklist_reached(self): - logger.debug('PandoraBackend: Handling end_of_tracklist_reached event') next_track = self.library.next_track() if next_track: self._trigger_add_next_pandora_track(next_track) def _trigger_add_next_pandora_track(self, track): - logger.debug('PandoraBackend: Triggering add_next_pandora_track event') listener.PandoraListener.send('add_next_pandora_track', track=track) def _trigger_event_processed(self, track_uri): - logger.debug('PandoraBackend: Triggering event_processed event') listener.PandoraListener.send('event_processed', track_uri=track_uri) def call_event(self, track_uri, pandora_event): - logger.debug('PandoraBackend: Handling call_event %s, %s', track_uri, pandora_event) func = getattr(self, pandora_event) try: logger.info("Triggering event '%s' for song: %s", pandora_event, diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index 465396e..e0bbdc1 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -14,6 +14,7 @@ auto_setup = true cache_time_to_live = 1800 ### EXPERIMENTAL RATINGS IMPLEMENTATION ### +event_support_enabled = true double_click_interval = 2.00 on_pause_resume_click = thumbs_up on_pause_next_click = thumbs_down diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 8706266..8d775c3 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -19,7 +19,6 @@ def __init__(self, config, core): self.setup_required = True self.core = core - # TODO: only configure when pandora track starts to play def on_start(self): self.set_options() @@ -39,11 +38,9 @@ def set_options(self): self.setup_required = False def options_changed(self): - logger.debug('PandoraFrontend: Handling options_changed event') self.setup_required = True def prepare_change(self): - logger.debug('PandoraFrontend: Handling prepare_change event') if self.is_playing_last_track(): self._trigger_end_of_tracklist_reached() @@ -59,11 +56,9 @@ def is_playing_last_track(self): return next_tl_track is None def add_next_pandora_track(self, track): - logger.debug('PandoraFrontend: Handling add_next_pandora_track event') self.core.tracklist.add(uris=[track.uri]) def _trigger_end_of_tracklist_reached(self): - logger.debug('PandoraFrontend: Triggering end_of_tracklist_reached event') listener.PandoraListener.send('end_of_tracklist_reached') @@ -87,10 +82,9 @@ def __init__(self, config, core): self.tracklist_changed_event.set() def tracklist_changed(self): - logger.debug('EventSupportPandoraFrontend: Handling tracklist_changed event') if not self.event_processed_event.isSet(): - # Delay 'tracklist_changed' events untill all events have been processed. + # Delay 'tracklist_changed' events until all events have been processed. self.tracklist_changed_event.clear() else: self.current_tl_track = self.core.playback.get_current_tl_track().get() @@ -100,7 +94,6 @@ def tracklist_changed(self): self.tracklist_changed_event.set() def track_playback_resumed(self, tl_track, time_position): - logger.debug('EventSupportPandoraFrontend: Handling track_playback_resumed event') track_changed = time_position == 0 self._process_events(tl_track.track.uri, track_changed=track_changed) @@ -144,7 +137,6 @@ def _process_events(self, track_uri, track_changed=False): self._trigger_call_event(event_target_uri, event) def event_processed(self, track_uri): - logger.debug('EventSupportPandoraFrontend: Handling event_processed event') self.event_processed_event.set() @@ -153,12 +145,10 @@ def event_processed(self, track_uri): self.tracklist_changed() def doubleclicked(self): - logger.debug('EventSupportPandoraFrontend: Handling doubleclicked event') self.event_processed_event.clear() # Resume playback of the next track so long... self.core.playback.resume() def _trigger_call_event(self, track_uri, event): - logger.debug('EventSupportPandoraFrontend: Triggering call_event event') listener.PandoraListener.send('call_event', track_uri=track_uri, pandora_event=event) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 0ceccce..803791b 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -79,11 +79,9 @@ def is_playable(self, track_uri): return is_playable def _trigger_prepare_change(self): - logger.debug('PandoraPlaybackProvider: Triggering prepare_change event') listener.PandoraListener.send('prepare_change') def _trigger_stop(self): - logger.debug('PandoraPlaybackProvider: Triggering stop event') listener.PandoraListener.send('stop') @@ -132,5 +130,4 @@ def pause(self): return super(EventSupportPlaybackProvider, self).pause() def _trigger_doubleclicked(self): - logger.debug('EventSupportPlaybackProvider: Triggering doubleclicked event') listener.PandoraListener.send('doubleclicked') From 49ff4ce94f0a9291bcf7b3c62d90b4e33835677e Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 7 Dec 2015 08:11:58 +0200 Subject: [PATCH 059/311] Prepare for merging with main 'develop' branch. --- README.rst | 4 ++++ mopidy_pandora/backend.py | 2 +- mopidy_pandora/ext.conf | 2 +- setup.py | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index b8fefd9..93e553b 100644 --- a/README.rst +++ b/README.rst @@ -114,6 +114,10 @@ v0.2.0 (UNRELEASED) - Implemented caching to speed up startup and browsing of the list of stations. Configuration parameter 'cache_time_to_live' can be used to specify when cache iterms should expire and be refreshed (in seconds). - Better support for non-PandoraONE users: now plays advertisements which should prevent free accounts from being locked. +- There is a bug in Mopidy that prevents the eventing support from working as expected, so it has been disabled by + default until https://github.com/mopidy/mopidy/issues/1352 is fixed. This should be fixed when Mopidy 1.1.2 is realeased. + Alternatively you can patch Mopidy 1.1.1 with https://github.com/mopidy/mopidy/pull/1356 if you want to keep using + events in the interim. v0.1.7 (Oct 31, 2015) ---------------------------------------- diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 23e0cf6..dd268a7 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -37,7 +37,7 @@ def __init__(self, config, audio): self.library = PandoraLibraryProvider(backend=self, sort_order=self.config.get('sort_order', 'date')) self.supports_events = False - if self.config.get('event_support_enabled', True): + if self.config.get('event_support_enabled', False): self.supports_events = True self.playback = EventSupportPlaybackProvider(audio=audio, backend=self) else: diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index e0bbdc1..41cd683 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -14,7 +14,7 @@ auto_setup = true cache_time_to_live = 1800 ### EXPERIMENTAL RATINGS IMPLEMENTATION ### -event_support_enabled = true +event_support_enabled = false double_click_interval = 2.00 on_pause_resume_click = thumbs_up on_pause_next_click = thumbs_down diff --git a/setup.py b/setup.py index 818f03d..f93744c 100644 --- a/setup.py +++ b/setup.py @@ -50,9 +50,9 @@ def run_tests(self): install_requires=[ 'setuptools', 'cachetools >= 1.0.0', - 'Mopidy >= 1.1.2', + 'Mopidy >= 1.1.1', 'Pykka >= 1.1', - 'pydora >= 1.6', + 'pydora >= 1.6.0', 'requests >= 2.5.0' ], tests_require=['tox'], From ffb3f79657d4d61d48404ee56f2206b54151b17e Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 7 Dec 2015 17:12:15 +0200 Subject: [PATCH 060/311] Cleanup failing unit tests. --- mopidy_pandora/backend.py | 4 +- mopidy_pandora/client.py | 53 ++++++----- tests/conftest.py | 13 +-- tests/test_backend.py | 13 +-- tests/test_client.py | 67 +++++++++---- tests/test_extension.py | 6 +- tests/test_library.py | 7 +- tests/test_playback.py | 191 ++------------------------------------ tests/test_uri.py | 9 +- 9 files changed, 102 insertions(+), 261 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index dd268a7..ed85dc8 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -11,7 +11,7 @@ from mopidy_pandora import listener, rpc -from mopidy_pandora.client import MopidyPandoraAPIClient, MopidyPandoraSettingsDictBuilder +from mopidy_pandora.client import MopidyAPIClient, MopidySettingsDictBuilder from mopidy_pandora.library import PandoraLibraryProvider from mopidy_pandora.playback import EventSupportPlaybackProvider, PandoraPlaybackProvider from mopidy_pandora.uri import logger, PandoraUri # noqa: I101 @@ -33,7 +33,7 @@ def __init__(self, config, audio): "AUDIO_QUALITY": self.config.get("preferred_audio_quality", BaseAPIClient.HIGH_AUDIO_QUALITY) } - self.api = MopidyPandoraSettingsDictBuilder(settings, client_class=MopidyPandoraAPIClient).build() + self.api = MopidySettingsDictBuilder(settings, client_class=MopidyAPIClient).build() self.library = PandoraLibraryProvider(backend=self, sort_order=self.config.get('sort_order', 'date')) self.supports_events = False diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 3cfc694..c701391 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) -class MopidyPandoraSettingsDictBuilder(SettingsDictBuilder): +class MopidySettingsDictBuilder(SettingsDictBuilder): def build_from_settings_dict(self, settings): enc = Encryptor(settings["DECRYPTION_KEY"], @@ -31,32 +31,37 @@ def build_from_settings_dict(self, settings): settings["DEVICE"], quality) -class MopidyPandoraAPIClient(pandora.APIClient): +class MopidyAPIClient(pandora.APIClient): """Pydora API Client for Mopidy-Pandora This API client implements caching of the station list. """ + station_key = "stations" + genre_key = "genre_stations" + def __init__(self, cache_ttl, transport, partner_user, partner_password, device, default_audio_quality=pandora.BaseAPIClient.MED_AUDIO_QUALITY): - super(MopidyPandoraAPIClient, self).__init__(transport, partner_user, partner_password, device, - default_audio_quality) + super(MopidyAPIClient, self).__init__(transport, partner_user, partner_password, device, + default_audio_quality) - self.cache_ttl = cache_ttl - self._station_list_cache = TTLCache(1, cache_ttl) - self._genre_stations_cache = TTLCache(1, cache_ttl) + self._pandora_api_cache = TTLCache(1, cache_ttl) def get_station_list(self, force_refresh=False): - if not any(self._station_list_cache) or \ - (force_refresh is True and self._station_list_cache.itervalues().next().has_changed()): - try: - self._station_list_cache['key'] = super(MopidyPandoraAPIClient, self).get_station_list() - except requests.exceptions.RequestException as e: - logger.error('Error retrieving station list: %s', encoding.locale_decode(e)) + try: + if MopidyAPIClient.station_key not in self._pandora_api_cache.keys() or \ + (force_refresh and self._pandora_api_cache[MopidyAPIClient.station_key].has_changed()): + + self._pandora_api_cache[MopidyAPIClient.station_key] = \ + super(MopidyAPIClient, self).get_station_list() + + except requests.exceptions.RequestException as e: + logger.error('Error retrieving station list: %s', encoding.locale_decode(e)) + return [] - return self._station_list_cache.itervalues().next() + return self._pandora_api_cache[MopidyAPIClient.station_key] def get_station(self, station_id): @@ -64,15 +69,19 @@ def get_station(self, station_id): return self.get_station_list()[station_id] except TypeError: # Could not find station_id in cached list, try retrieving from Pandora server. - return super(MopidyPandoraAPIClient, self).get_station(station_id) + return super(MopidyAPIClient, self).get_station(station_id) def get_genre_stations(self, force_refresh=False): - if not any(self._genre_stations_cache) or \ - (force_refresh is True and self._genre_stations_cache.itervalues().next().has_changed()): - try: - self._genre_stations_cache['key'] = super(MopidyPandoraAPIClient, self).get_genre_stations() - except requests.exceptions.RequestException as e: - logger.error('Error retrieving genre stations: %s', encoding.locale_decode(e)) + try: + if MopidyAPIClient.genre_key not in self._pandora_api_cache.keys() or \ + (force_refresh and self._pandora_api_cache[MopidyAPIClient.genre_key].has_changed()): + + self._pandora_api_cache[MopidyAPIClient.genre_key] = \ + super(MopidyAPIClient, self).get_genre_stations() + + except requests.exceptions.RequestException as e: + logger.error('Error retrieving genre stations: %s', encoding.locale_decode(e)) + return [] - return self._genre_stations_cache.itervalues().next() + return self._pandora_api_cache[MopidyAPIClient.genre_key] diff --git a/tests/conftest.py b/tests/conftest.py index dfe5d73..db9fb5a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ import requests -from mopidy_pandora import backend, rpc +from mopidy_pandora import backend MOCK_STATION_SCHEME = "station" MOCK_STATION_NAME = "Mock Station" @@ -67,8 +67,6 @@ def config(): def get_backend(config, simulate_request_exceptions=False): obj = backend.PandoraBackend(config=config, audio=Mock()) - rpc.RPCClient._do_rpc = rpc_call_not_implemented_mock - if simulate_request_exceptions: type(obj.api.transport).__call__ = request_exception_mock else: @@ -274,12 +272,3 @@ def transport_call_not_implemented_mock(self, method, **data): class TransportCallTestNotImplemented(Exception): pass - - -@pytest.fixture -def rpc_call_not_implemented_mock(method, params=''): - raise RPCCallTestNotImplemented(method + "(" + params + ")") - - -class RPCCallTestNotImplemented(Exception): - pass diff --git a/tests/test_backend.py b/tests/test_backend.py index d1d5595..3dc69cb 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -19,7 +19,7 @@ def test_uri_schemes(config): def test_init_sets_up_the_providers(config): backend = get_backend(config) - assert isinstance(backend.api, client.MopidyPandoraAPIClient) + assert isinstance(backend.api, client.MopidyAPIClient) assert isinstance(backend.library, library.PandoraLibraryProvider) assert isinstance(backend.library, backend_api.LibraryProvider) @@ -75,14 +75,3 @@ def test_on_start_handles_request_exception(config, caplog): # Check that request exceptions are caught and logged assert 'Error logging in to Pandora' in caplog.text() - - -def test_auto_setup_resets_when_tracklist_changes(config): - - backend = get_backend(config, True) - - backend.setup_required = False - - backend.tracklist_changed() - - assert backend.setup_required diff --git a/tests/test_client.py b/tests/test_client.py index c96faa3..164b2c8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,5 @@ from __future__ import unicode_literals -import copy - import conftest import mock @@ -11,6 +9,8 @@ import pytest +from mopidy_pandora.client import MopidyAPIClient + from tests.conftest import get_backend from tests.conftest import get_station_list_mock @@ -19,8 +19,6 @@ def test_get_station_list(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): backend = get_backend(config) - assert not any(backend.api._station_list) - station_list = backend.api.get_station_list() assert len(station_list) == len(conftest.station_list_result_mock()['stations']) @@ -29,9 +27,20 @@ def test_get_station_list(config): assert station_list[2].name == "QuickMix" -def test_get_station_list_changed(config): +def test_get_station_list_populates_cache(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): - # Ensure that the cache is invalidated between calls + backend = get_backend(config) + + assert backend.api._pandora_api_cache.currsize == 0 + + backend.api.get_station_list() + assert backend.api._pandora_api_cache.currsize == 1 + assert MopidyAPIClient.station_key in backend.api._pandora_api_cache.keys() + + +def test_get_station_list_changed_cached(config): + with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): + # Ensure that the cache is re-used between calls with mock.patch.object(StationList, 'has_changed', return_value=True): backend = get_backend(config) @@ -44,27 +53,51 @@ def test_get_station_list_changed(config): "stationName": conftest.MOCK_STATION_NAME }, ], "checksum": cached_checksum - } - } + }} - backend.api._station_list = StationList.from_json( + backend.api._pandora_api_cache[MopidyAPIClient.station_key] = StationList.from_json( APIClient, mock_cached_result["result"]) - assert backend.api._station_list.checksum == cached_checksum - assert len(backend.api._station_list) == 1 - backend.api.get_station_list() - assert backend.api._station_list.checksum == conftest.MOCK_STATION_LIST_CHECKSUM - assert len(backend.api._station_list) == len(conftest.station_list_result_mock()['stations']) + assert backend.api.get_station_list().checksum == cached_checksum + assert len(backend.api._pandora_api_cache[MopidyAPIClient.station_key]) == \ + len(mock_cached_result['result']['stations']) + + +def test_get_station_list_changed_refreshed(config): + with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): + # Ensure that the cache is invalidated if 'force_refresh' is True + with mock.patch.object(StationList, 'has_changed', return_value=True): + backend = get_backend(config) + + cached_checksum = "zz00aa00aa00aa00aa00aa00aa00aa99" + mock_cached_result = {"stat": "ok", + "result": { + "stations": [ + {"stationId": conftest.MOCK_STATION_ID, + "stationToken": conftest.MOCK_STATION_TOKEN, + "stationName": conftest.MOCK_STATION_NAME + }, ], + "checksum": cached_checksum + }} + + backend.api._pandora_api_cache[MopidyAPIClient.station_key] = StationList.from_json( + APIClient, mock_cached_result["result"]) + + assert backend.api.get_station_list().checksum == cached_checksum + + backend.api.get_station_list(force_refresh=True) + assert backend.api.get_station_list().checksum == conftest.MOCK_STATION_LIST_CHECKSUM + assert len(backend.api._pandora_api_cache[MopidyAPIClient.station_key]) == \ + len(conftest.station_list_result_mock()['stations']) def test_get_station_list_handles_request_exception(config, caplog): backend = get_backend(config, True) - station_list = copy.copy(backend.api._station_list) - assert backend.api.get_station_list() == station_list + assert backend.api.get_station_list() == [] - # Check that request execptions are caught and logged + # Check that request exceptions are caught and logged assert 'Error retrieving station list' in caplog.text() diff --git a/tests/test_extension.py b/tests/test_extension.py index d021330..efd799e 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -7,6 +7,7 @@ from mopidy_pandora import Extension from mopidy_pandora import backend as backend_lib +from mopidy_pandora import frontend as frontend_lib class ExtensionTest(unittest.TestCase): @@ -61,5 +62,6 @@ def test_setup(self): ext = Extension() ext.setup(registry) - - registry.add.assert_called_with('backend', backend_lib.PandoraBackend) + calls = [mock.call('frontend', frontend_lib.EventSupportPandoraFrontend), + mock.call('backend', backend_lib.PandoraBackend)] + registry.add.assert_has_calls(calls, any_order=True) diff --git a/tests/test_library.py b/tests/test_library.py index 97d3e7d..9c9d366 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -9,7 +9,7 @@ from pandora import APIClient from pandora.models.pandora import Station -from mopidy_pandora.client import MopidyPandoraAPIClient +from mopidy_pandora.client import MopidyAPIClient from mopidy_pandora.library import PandoraLibraryProvider from mopidy_pandora.uri import PandoraUri, StationUri, TrackUri @@ -31,8 +31,7 @@ def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) track_uri = TrackUri.from_track(playlist_item_mock) - - backend.library._uri_translation_map[track_uri.uri] = playlist_item_mock + backend.library._pandora_tracks_cache[track_uri.uri] = playlist_item_mock results = backend.library.lookup(track_uri.uri) @@ -112,7 +111,7 @@ def test_browse_directory_sort_date(config): def test_browse_station_uri(config, station_mock): - with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): + with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): backend = conftest.get_backend(config) diff --git a/tests/test_playback.py b/tests/test_playback.py index a987e67..646c3bd 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -import threading import time import conftest @@ -9,18 +8,16 @@ from mopidy import audio, backend as backend_api, models -from pandora.models.pandora import PlaylistItem - import pytest -from mopidy_pandora import playback, rpc +from mopidy_pandora import listener, playback -from mopidy_pandora.backend import MopidyPandoraAPIClient +from mopidy_pandora.backend import MopidyAPIClient from mopidy_pandora.library import PandoraLibraryProvider from mopidy_pandora.playback import EventSupportPlaybackProvider, PandoraPlaybackProvider -from mopidy_pandora.uri import PandoraUri, TrackUri +from mopidy_pandora.uri import TrackUri @pytest.fixture @@ -50,7 +47,7 @@ def provider(audio_mock, config): @pytest.fixture(scope="session") def client_mock(): - client_mock = mock.Mock(spec=MopidyPandoraAPIClient) + client_mock = mock.Mock(spec=MopidyAPIClient) return client_mock @@ -103,134 +100,24 @@ def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): provider.previous_tl_track = {'track': {'uri': 'previous_track'}} provider.next_tl_track = {'track': {'uri': track.uri}} - rpc.RPCClient.playback_stop = mock.PropertyMock() + listener.PandoraListener.send = mock.PropertyMock() for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): provider.change_track(track) is False - assert rpc.RPCClient.playback_stop.called + listener.PandoraListener.send.assert_called_with('stop') assert "Maximum track skip limit (%s) exceeded, stopping...", \ PandoraPlaybackProvider.SKIP_LIMIT in caplog.text() - # with mock.patch.object(MopidyPandoraAPIClient, 'get_station', conftest.get_station_mock): - # with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): - # with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=False): - # track = models.Track(uri="pandora:track:test_station_id:test_token") - # - # rpc.RPCClient.playback_stop = mock.PropertyMock() - # - # assert provider.change_track(track) is False - # rpc.RPCClient.playback_stop.assert_called_once_with() - # assert PlaylistItem.get_is_playable.call_count == PandoraPlaybackProvider.SKIP_LIMIT - - -def test_change_track_resumes_playback(provider, playlist_item_mock): - with mock.patch.object(EventSupportPlaybackProvider, 'is_double_click', return_value=True): - track = TrackUri.from_track(playlist_item_mock) - - process_click_mock = mock.PropertyMock() - provider.process_click = process_click_mock - - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} - provider.next_tl_track = {'track': {'uri': track.uri}} - - rpc.RPCClient.playback_resume = mock.PropertyMock() - - provider.change_track(track) - assert rpc.RPCClient.playback_resume.called - - -def test_change_track_does_not_resume_playback_if_not_doubleclick(provider, playlist_item_mock): - with mock.patch.object(EventSupportPlaybackProvider, 'is_double_click', return_value=False): - track = TrackUri.from_track(playlist_item_mock) - - process_click_mock = mock.PropertyMock() - provider.process_click = process_click_mock - - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} - provider.next_tl_track = {'track': {'uri': track.uri}} - - rpc.RPCClient.playback_resume = mock.PropertyMock() - - provider.change_track(track) - assert not rpc.RPCClient.playback_resume.called - - -def test_change_track_handles_request_exceptions(config, caplog, playlist_item_mock): - with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): - with mock.patch.object(PlaylistItem, 'get_is_playable', conftest.request_exception_mock): - - track = models.Track(uri="pandora:track:test_station_id:test_token") - - playback = conftest.get_backend(config, True).playback - - rpc.RPCClient._do_rpc = mock.PropertyMock() - rpc.RPCClient.playback_stop = mock.PropertyMock() - - rpc.RPCClient.playback_resume = mock.PropertyMock() - - assert playback.change_track(track) is False - assert 'Error checking if track is playable' in caplog.text() - - -def test_change_track_handles_unplayable(provider, caplog): - - track = models.Track(uri="pandora:track:test_station_id:test_token") - - provider.previous_tl_track = {'track': {'uri': track.uri}} - provider.next_tl_track = {'track': {'uri': 'next_track'}} - - rpc.RPCClient.playback_resume = mock.PropertyMock() - - assert provider.change_track(track) is False - assert "Audio URI for track '%s' cannot be played", track.uri in caplog.text() - def test_translate_uri_returns_audio_url(provider, playlist_item_mock): test_uri = "pandora:track:test_station_id:test_token" - - provider.backend.library._uri_translation_map[test_uri] = playlist_item_mock + provider.backend.library._pandora_tracks_cache[test_uri] = playlist_item_mock assert provider.translate_uri(test_uri) == conftest.MOCK_TRACK_AUDIO_HIGH -def test_auto_setup_only_called_once(provider): - with mock.patch.multiple('mopidy_pandora.rpc.RPCClient', tracklist_set_repeat=mock.DEFAULT, - tracklist_set_random=mock.DEFAULT, tracklist_set_consume=mock.DEFAULT, - tracklist_set_single=mock.DEFAULT) as values: - - event = threading.Event() - - def set_event(*args, **kwargs): - event.set() - - values['tracklist_set_single'].side_effect = set_event - - provider.prepare_change() - - if event.wait(timeout=1.0): - values['tracklist_set_repeat'].assert_called_once_with(False) - values['tracklist_set_random'].assert_called_once_with(False) - values['tracklist_set_consume'].assert_called_once_with(True) - values['tracklist_set_single'].assert_called_once_with(False) - else: - assert False - - event = threading.Event() - values['tracklist_set_single'].side_effect = set_event - - provider.prepare_change() - - if event.wait(timeout=1.0): - assert False - else: - values['tracklist_set_repeat'].assert_called_once_with(False) - values['tracklist_set_random'].assert_called_once_with(False) - values['tracklist_set_consume'].assert_called_once_with(True) - values['tracklist_set_single'].assert_called_once_with(False) - - def test_is_double_click(provider): provider.set_click_time() @@ -251,40 +138,6 @@ def test_is_double_click_resets_click_time(provider): assert provider.get_click_time() == 0 -def test_change_track_next(config, provider, playlist_item_mock): - - provider.set_click_time() - track = TrackUri.from_track(playlist_item_mock) - - process_click_mock = mock.PropertyMock() - provider.process_click = process_click_mock - - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} - provider.next_tl_track = {'track': {'uri': track.uri}} - - rpc.RPCClient._do_rpc = mock.PropertyMock() - - provider.change_track(track) - provider.process_click.assert_called_with(config['pandora']['on_pause_next_click'], track.uri) - - -def test_change_track_back(config, provider, playlist_item_mock): - - provider.set_click_time() - track = TrackUri.from_track(playlist_item_mock) - - process_click_mock = mock.PropertyMock() - provider.process_click = process_click_mock - - provider.previous_tl_track = {'track': {'uri': track.uri}} - provider.next_tl_track = {'track': {'uri': 'next_track'}} - - rpc.RPCClient._do_rpc = mock.PropertyMock() - - provider.change_track(track) - provider.process_click.assert_called_with(config['pandora']['on_pause_previous_click'], track.uri) - - def test_resume_click_ignored_if_start_of_track(provider): with mock.patch.object(PandoraPlaybackProvider, 'get_time_position', return_value=0): @@ -296,43 +149,13 @@ def test_resume_click_ignored_if_start_of_track(provider): provider.process_click.assert_not_called() -def test_process_click_resets_click_time(config, provider, playlist_item_mock): - - provider.thumbs_up = mock.PropertyMock() - - track_uri = TrackUri.from_track(playlist_item_mock).uri - - provider.process_click(config['pandora']['on_pause_resume_click'], track_uri) - - assert provider.get_click_time() == 0 - - -def test_process_click_triggers_event(config, provider, playlist_item_mock): - with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): - with mock.patch.multiple(EventSupportPlaybackProvider, thumbs_up=mock.PropertyMock(), - thumbs_down=mock.PropertyMock(), sleep=mock.PropertyMock()): - - track_uri = TrackUri.from_track(playlist_item_mock).uri - - method = config['pandora']['on_pause_next_click'] - method_call = getattr(provider, method) - - t = provider.process_click(method, track_uri) - t.join() - - token = PandoraUri.parse(track_uri).token - method_call.assert_called_once_with(token) - - def add_artist_bookmark(provider): provider.add_artist_bookmark(conftest.MOCK_TRACK_TOKEN) - provider.client.add_artist_bookmark.assert_called_once_with(conftest.MOCK_TRACK_TOKEN) def add_song_bookmark(provider): provider.add_song_bookmark(conftest.MOCK_TRACK_TOKEN) - provider.client.add_song_bookmark.assert_called_once_with(conftest.MOCK_TRACK_TOKEN) diff --git a/tests/test_uri.py b/tests/test_uri.py index fb20eef..a44f827 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -92,10 +92,7 @@ def test_track_uri_from_track_for_ads(ad_item_mock): assert track_uri.uri == "pandora:" + \ track_uri.quote(conftest.MOCK_TRACK_SCHEME) + "::" + \ - track_uri.quote(TrackUri.ADVERTISEMENT_TOKEN) + ":" + \ - track_uri.quote(conftest.MOCK_TRACK_NAME) + ":::" + \ - track_uri.quote(conftest.MOCK_TRACK_AUDIO_HIGH) + ":" + \ - track_uri.quote(0) + track_uri.quote(TrackUri.ADVERTISEMENT_TOKEN) def test_track_uri_parse(playlist_item_mock): @@ -118,9 +115,9 @@ def test_track_uri_is_ad(playlist_item_mock, ad_item_mock): track_uri = TrackUri.from_track(ad_item_mock) obj = TrackUri.parse(track_uri.uri) - assert obj.is_ad_uri() + assert obj.is_ad_uri track_uri = TrackUri.from_track(playlist_item_mock) obj = TrackUri.parse(track_uri.uri) - assert not obj.is_ad_uri() + assert not obj.is_ad_uri From 2fd0fddd4132660bc5b8b57e928e19075ed34974 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 7 Dec 2015 21:12:11 +0200 Subject: [PATCH 061/311] Fix list cache handling. --- mopidy_pandora/client.py | 26 ++++++++++++-------------- mopidy_pandora/library.py | 3 +++ tests/test_backend.py | 21 +++++++++++++++++++-- tests/test_client.py | 30 +++++++++++++++++++++--------- 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index c701391..31ef219 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -1,5 +1,7 @@ import logging +import time + from cachetools import TTLCache from mopidy.internal import encoding @@ -37,31 +39,28 @@ class MopidyAPIClient(pandora.APIClient): This API client implements caching of the station list. """ - station_key = "stations" - genre_key = "genre_stations" - 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._pandora_api_cache = TTLCache(1, cache_ttl) + self._station_list_cache = TTLCache(1, cache_ttl) + self._genre_stations_cache = TTLCache(1, cache_ttl) def get_station_list(self, force_refresh=False): try: - if MopidyAPIClient.station_key not in self._pandora_api_cache.keys() or \ - (force_refresh and self._pandora_api_cache[MopidyAPIClient.station_key].has_changed()): + if self._station_list_cache.currsize == 0 or \ + (force_refresh and self._station_list_cache.itervalues().next().has_changed()): - self._pandora_api_cache[MopidyAPIClient.station_key] = \ - super(MopidyAPIClient, self).get_station_list() + self._station_list_cache[time.time()] = super(MopidyAPIClient, self).get_station_list() except requests.exceptions.RequestException as e: logger.error('Error retrieving station list: %s', encoding.locale_decode(e)) return [] - return self._pandora_api_cache[MopidyAPIClient.station_key] + return self._station_list_cache.itervalues().next() def get_station(self, station_id): @@ -74,14 +73,13 @@ def get_station(self, station_id): def get_genre_stations(self, force_refresh=False): try: - if MopidyAPIClient.genre_key not in self._pandora_api_cache.keys() or \ - (force_refresh and self._pandora_api_cache[MopidyAPIClient.genre_key].has_changed()): + if self._genre_stations_cache.currsize == 0 or \ + (force_refresh and self._genre_stations_cache.itervalues().next().has_changed()): - self._pandora_api_cache[MopidyAPIClient.genre_key] = \ - super(MopidyAPIClient, self).get_genre_stations() + self._genre_stations_cache[time.time()] = super(MopidyAPIClient, self).get_genre_stations() except requests.exceptions.RequestException as e: logger.error('Error retrieving genre stations: %s', encoding.locale_decode(e)) return [] - return self._pandora_api_cache[MopidyAPIClient.genre_key] + return self._genre_stations_cache.itervalues().next() diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 34fa041..c891143 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -112,6 +112,9 @@ 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) + # Invalidate the cache so that it is refreshed on the next request + self.backend.api._station_list_cache.popitem() + return StationUri.from_station(new_station) def _browse_genre_categories(self): diff --git a/tests/test_backend.py b/tests/test_backend.py index 3dc69cb..3f8260f 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -4,10 +4,10 @@ from mopidy import backend as backend_api -from pandora import BaseAPIClient +from pandora import APIClient, BaseAPIClient from mopidy_pandora import client, library, playback -from tests.conftest import get_backend, request_exception_mock +from tests.conftest import get_backend, get_station_list_mock, request_exception_mock def test_uri_schemes(config): @@ -66,6 +66,23 @@ def test_on_start_logs_in(config): backend.api.login.assert_called_once_with('john', 'doe') +def test_on_start_pre_fetches_lists(config): + with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): + backend = get_backend(config) + + backend.api.login = mock.PropertyMock() + backend.api.get_genre_stations = mock.PropertyMock() + + assert backend.api._station_list_cache.currsize == 0 + assert backend.api._genre_stations_cache.currsize == 0 + + t = backend.on_start() + t.join() + + assert backend.api._station_list_cache.currsize == 1 + assert backend.api.get_genre_stations.called + + def test_on_start_handles_request_exception(config, caplog): backend = get_backend(config, True) diff --git a/tests/test_client.py b/tests/test_client.py index 164b2c8..8f6f0cc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import time + import conftest import mock @@ -9,7 +11,6 @@ import pytest -from mopidy_pandora.client import MopidyAPIClient from tests.conftest import get_backend from tests.conftest import get_station_list_mock @@ -31,11 +32,10 @@ def test_get_station_list_populates_cache(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): backend = get_backend(config) - assert backend.api._pandora_api_cache.currsize == 0 + assert backend.api._station_list_cache.currsize == 0 backend.api.get_station_list() - assert backend.api._pandora_api_cache.currsize == 1 - assert MopidyAPIClient.station_key in backend.api._pandora_api_cache.keys() + assert backend.api._station_list_cache.currsize == 1 def test_get_station_list_changed_cached(config): @@ -55,13 +55,13 @@ def test_get_station_list_changed_cached(config): "checksum": cached_checksum }} - backend.api._pandora_api_cache[MopidyAPIClient.station_key] = StationList.from_json( + backend.api._station_list_cache[time.time()] = StationList.from_json( APIClient, mock_cached_result["result"]) backend.api.get_station_list() assert backend.api.get_station_list().checksum == cached_checksum - assert len(backend.api._pandora_api_cache[MopidyAPIClient.station_key]) == \ - len(mock_cached_result['result']['stations']) + assert len(backend.api._station_list_cache.itervalues().next()) == len(StationList.from_json( + APIClient, mock_cached_result["result"])) def test_get_station_list_changed_refreshed(config): @@ -81,14 +81,14 @@ def test_get_station_list_changed_refreshed(config): "checksum": cached_checksum }} - backend.api._pandora_api_cache[MopidyAPIClient.station_key] = StationList.from_json( + backend.api._station_list_cache[time.time()] = StationList.from_json( APIClient, mock_cached_result["result"]) assert backend.api.get_station_list().checksum == cached_checksum backend.api.get_station_list(force_refresh=True) assert backend.api.get_station_list().checksum == conftest.MOCK_STATION_LIST_CHECKSUM - assert len(backend.api._pandora_api_cache[MopidyAPIClient.station_key]) == \ + assert len(backend.api._station_list_cache.itervalues().next()) == \ len(conftest.station_list_result_mock()['stations']) @@ -125,3 +125,15 @@ def test_get_invalid_station(config): backend = get_backend(config) backend.api.get_station("9999999999999999999") + + +def test_create_genre_station_invalidates_cache(config): + backend = get_backend(config) + + backend.api.create_station = mock.PropertyMock(return_value=conftest.station_result_mock()) + backend.api._station_list_cache[time.time()] = 'test_value' + assert backend.api._station_list_cache.currsize == 1 + + backend.library._create_station_for_genre('test_token') + + assert backend.api._station_list_cache.currsize == 0 From 0b8e1a2823f34da9021b588a5db1e6be65c706a4 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 7 Dec 2015 21:42:50 +0200 Subject: [PATCH 062/311] Make unit test thread safe. --- tests/test_backend.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_backend.py b/tests/test_backend.py index 3f8260f..716c4b7 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -61,7 +61,8 @@ def test_on_start_logs_in(config): login_mock = mock.PropertyMock() backend.api.login = login_mock - backend.on_start() + t = backend.on_start() + t.join() backend.api.login.assert_called_once_with('john', 'doe') From d73a8e3795a05f9efbd611e5797291827a901958 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 7 Dec 2015 22:44:39 +0200 Subject: [PATCH 063/311] Refactor event processing. Only set options when Pandora tracks start, stop, pause, or are resumed. Ad token handling dependant on fix in pydora 1.6.1 --- mopidy_pandora/frontend.py | 67 +++++++++++++++++++++++--------------- mopidy_pandora/library.py | 1 + setup.py | 2 +- tests/test_extension.py | 4 +++ 4 files changed, 46 insertions(+), 28 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 8d775c3..b91af53 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -19,9 +19,6 @@ def __init__(self, config, core): self.setup_required = True self.core = core - def on_start(self): - self.set_options() - def set_options(self): # Setup playback to mirror behaviour of official Pandora front-ends. if self.auto_setup and self.setup_required: @@ -44,11 +41,21 @@ def prepare_change(self): if self.is_playing_last_track(): self._trigger_end_of_tracklist_reached() - self.set_options() - def stop(self): self.core.playback.stop() + def track_playback_started(self, tl_track): + self.set_options() + + def track_playback_ended(self, tl_track, time_position): + self.set_options() + + def track_playback_paused(self, tl_track, time_position): + self.set_options() + + def track_playback_resumed(self, tl_track, time_position): + self.set_options() + def is_playing_last_track(self): current_tl_track = self.core.playback.get_current_tl_track().get() next_tl_track = self.core.tracklist.next_track(current_tl_track).get() @@ -94,50 +101,57 @@ def tracklist_changed(self): self.tracklist_changed_event.set() def track_playback_resumed(self, tl_track, time_position): - track_changed = time_position == 0 - self._process_events(tl_track.track.uri, track_changed=track_changed) + super(EventSupportPandoraFrontend, self).track_playback_resumed(tl_track, time_position) + + self._process_events(tl_track.track.uri, time_position) - def _process_events(self, track_uri, track_changed=False): + def _process_events(self, track_uri, time_position): # Check if there are any events that still require processing if self.event_processed_event.isSet(): # No events to process return - if track_changed: - # Trigger the event for the previously played track. - history = self.core.history.get_history().get() - event_target_uri = history[1][1].uri - else: - # Trigger the event for the track that is playing currently - event_target_uri = track_uri + event_target_uri = self._get_event_target_uri(track_uri, time_position) if TrackUri.parse(event_target_uri).is_ad_uri: logger.info('Ignoring doubleclick event for advertisement') self.event_processed_event.set() return + event = self._get_event(track_uri, time_position) + if event_target_uri and event: + self._trigger_call_event(event_target_uri, event) + else: + logger.error("Unexpected doubleclick event URI '%s'", track_uri) + self.event_processed_event.set() + + def _get_event_target_uri(self, track_uri, time_position): + if time_position == 0: + # Track was just changed, trigger the event for the previously played track. + history = self.core.history.get_history().get() + return history[1][1].uri + else: + # Trigger the event for the track that is playing currently + return track_uri + + def _get_event(self, track_uri, time_position): if track_uri == self.previous_tl_track.track.uri: - if not track_changed: + if time_position > 0: # Resuming playback on the first track in the tracklist. - event = self.on_pause_resume_click + return self.on_pause_resume_click else: - event = self.on_pause_previous_click + return self.on_pause_previous_click elif track_uri == self.current_tl_track.track.uri: - event = self.on_pause_resume_click + return self.on_pause_resume_click elif track_uri == self.next_tl_track.track.uri: - event = self.on_pause_next_click + return self.on_pause_next_click else: - logger.error("Unexpected doubleclick event URI '%s'", track_uri) - self.event_processed_event.set() - return - - self._trigger_call_event(event_target_uri, event) + return None def event_processed(self, track_uri): - self.event_processed_event.set() if not self.tracklist_changed_event.isSet(): @@ -145,7 +159,6 @@ def event_processed(self, track_uri): self.tracklist_changed() def doubleclicked(self): - self.event_processed_event.clear() # Resume playback of the next track so long... self.core.playback.resume() diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index c891143..dccae75 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -151,3 +151,4 @@ def next_track(self): except requests.exceptions.RequestException as e: logger.error('Error retrieving next Pandora track: %s', encoding.locale_decode(e)) + return None diff --git a/setup.py b/setup.py index f93744c..8f41437 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ def run_tests(self): 'cachetools >= 1.0.0', 'Mopidy >= 1.1.1', 'Pykka >= 1.1', - 'pydora >= 1.6.0', + 'pydora >= 1.6.1', 'requests >= 2.5.0' ], tests_require=['tox'], diff --git a/tests/test_extension.py b/tests/test_extension.py index efd799e..34e55bc 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -29,6 +29,8 @@ def test_get_default_config(self): self.assertIn('password =', config) self.assertIn('preferred_audio_quality = highQuality', config) self.assertIn('sort_order = date', config) + self.assertIn('auto_setup = true', config) + self.assertIn('cache_time_to_live = 1800', config) self.assertIn('event_support_enabled = false', config) self.assertIn('double_click_interval = 2.00', config) self.assertIn('on_pause_resume_click = thumbs_up', config) @@ -51,6 +53,8 @@ def test_get_config_schema(self): self.assertIn('password', schema) self.assertIn('preferred_audio_quality', schema) self.assertIn('sort_order', schema) + self.assertIn('auto_setup', schema) + self.assertIn('cache_time_to_live', schema) self.assertIn('event_support_enabled', schema) self.assertIn('double_click_interval', schema) self.assertIn('on_pause_resume_click', schema) From c8430f69b683e3709f6a48c1cbb6580a9e5b75fb Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 00:28:17 +0200 Subject: [PATCH 064/311] Add decorator for ensuring that CoreListener events are only processed for Pandora tracks. --- mopidy_pandora/frontend.py | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index b91af53..47a0ee1 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -8,6 +8,43 @@ from mopidy_pandora.uri import TrackUri +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: active_uri should contain the uri to be checkrd, all other kwargs + will be passed to the target function + :return: the return value of the function if it was run or 'None' otherwise. + """ + active_uri = kwargs.pop('active_uri', None) + if active_uri is None: + active_track = self.core.playback.get_current_tl_track().get() + if active_track: + active_uri = active_track.track.uri + + if is_pandora_uri(active_uri): + return func(self, *args, **kwargs) + else: + return None + + return check_pandora + + +def is_pandora_uri(active_uri): + return active_uri and active_uri.startswith('pandora:') + + class PandoraFrontend(pykka.ThreadingActor, core.CoreListener, listener.PandoraListener): def __init__(self, config, core): @@ -19,6 +56,7 @@ def __init__(self, config, core): self.setup_required = True self.core = core + @only_execute_for_pandora_uris def set_options(self): # Setup playback to mirror behaviour of official Pandora front-ends. if self.auto_setup and self.setup_required: @@ -44,15 +82,19 @@ def prepare_change(self): def stop(self): self.core.playback.stop() + @only_execute_for_pandora_uris def track_playback_started(self, tl_track): self.set_options() + @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() + @only_execute_for_pandora_uris def track_playback_resumed(self, tl_track, time_position): self.set_options() @@ -88,6 +130,7 @@ def __init__(self, config, core): self.tracklist_changed_event = threading.Event() self.tracklist_changed_event.set() + @only_execute_for_pandora_uris def tracklist_changed(self): if not self.event_processed_event.isSet(): @@ -100,6 +143,7 @@ def tracklist_changed(self): self.tracklist_changed_event.set() + @only_execute_for_pandora_uris def track_playback_resumed(self, tl_track, time_position): super(EventSupportPandoraFrontend, self).track_playback_resumed(tl_track, time_position) From 72bda8a3be835864fafa7ed415ca0b3fe3f39143 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 05:57:52 +0200 Subject: [PATCH 065/311] Add warning message to help determnie playback state. --- mopidy_pandora/playback.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 803791b..faf2854 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -56,6 +56,7 @@ def change_track(self, track): self.consecutive_track_skips = 0 return super(PandoraPlaybackProvider, self).change_track(track) else: + logger.warning("Skipping unplayable track '%s'.", track.name) self.consecutive_track_skips += 1 return False From 564c7b4492f4f984ae17de09a37c718be318223b Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 06:06:57 +0200 Subject: [PATCH 066/311] Fix logging message. --- mopidy_pandora/playback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index faf2854..f2efeeb 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -48,7 +48,7 @@ def prepare_change(self): def change_track(self, track): if track.uri is None: - logger.warning("No URI for track '%s'. Track cannot be played.", track.name) + logger.warning("No URI for track '%s'. Track cannot be played.", track) self.consecutive_track_skips += 1 return False @@ -56,7 +56,7 @@ def change_track(self, track): self.consecutive_track_skips = 0 return super(PandoraPlaybackProvider, self).change_track(track) else: - logger.warning("Skipping unplayable track '%s'.", track.name) + logger.warning("Skipping unplayable track with URI '%s'.", track.uri) self.consecutive_track_skips += 1 return False From b639f0666894c7c1e1af91bce66ff59dc2f2d069 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 07:32:11 +0200 Subject: [PATCH 067/311] Update README --- README.rst | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 93e553b..66a4521 100644 --- a/README.rst +++ b/README.rst @@ -50,7 +50,7 @@ Before starting Mopidy, you must add the configuration settings for Mopidy-Pando cache_time_to_live = 1800 ### EXPERIMENTAL EVENT HANDLING IMPLEMENTATION ### - event_support_enabled = true + event_support_enabled = false double_click_interval = 2.00 on_pause_resume_click = thumbs_up on_pause_next_click = thumbs_down @@ -114,10 +114,9 @@ v0.2.0 (UNRELEASED) - Implemented caching to speed up startup and browsing of the list of stations. Configuration parameter 'cache_time_to_live' can be used to specify when cache iterms should expire and be refreshed (in seconds). - Better support for non-PandoraONE users: now plays advertisements which should prevent free accounts from being locked. -- There is a bug in Mopidy that prevents the eventing support from working as expected, so it has been disabled by - default until https://github.com/mopidy/mopidy/issues/1352 is fixed. This should be fixed when Mopidy 1.1.2 is realeased. - Alternatively you can patch Mopidy 1.1.1 with https://github.com/mopidy/mopidy/pull/1356 if you want to keep using - events in the interim. +- **Eventing support does not work at the momentd**, so it has been disabled by default until + https://github.com/mopidy/mopidy/issues/1352 is fixed. Alternatively you can patch Mopidy 1.1.1 with + https://github.com/mopidy/mopidy/pull/1356 if you want to keep using events in the interim. v0.1.7 (Oct 31, 2015) ---------------------------------------- From 026feb7def34daaac516ef0735ec1eb0cfbbeb8f Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 07:36:38 +0200 Subject: [PATCH 068/311] Update README --- README.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 66a4521..217869a 100644 --- a/README.rst +++ b/README.rst @@ -67,6 +67,9 @@ supports for the chosen device will be used. **sort_order** defaults to the date that the station was added. Use 'A-Z' to display the list of stations in alphabetical order. +**cache_time_to_live** specifies how long station and genre lists should be cached for between refreshes. Setting this +to '0' will disable caching entirely and ensure that the latest lists are always retrieved from Pandora. + **EXPERIMENTAL EVENT HANDLING IMPLEMENTATION:** use these settings to work around the limitations of the current Mopidy core and web extensions: @@ -114,7 +117,7 @@ v0.2.0 (UNRELEASED) - Implemented caching to speed up startup and browsing of the list of stations. Configuration parameter 'cache_time_to_live' can be used to specify when cache iterms should expire and be refreshed (in seconds). - Better support for non-PandoraONE users: now plays advertisements which should prevent free accounts from being locked. -- **Eventing support does not work at the momentd**, so it has been disabled by default until +- **Event support does not work at the moment**, so it has been disabled by default until https://github.com/mopidy/mopidy/issues/1352 is fixed. Alternatively you can patch Mopidy 1.1.1 with https://github.com/mopidy/mopidy/pull/1356 if you want to keep using events in the interim. From c69bfc6dfbf59ee24f40d2e32b5d53f2c9767ecf Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 07:41:25 +0200 Subject: [PATCH 069/311] Update README --- README.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 217869a..7d6dd6e 100644 --- a/README.rst +++ b/README.rst @@ -67,8 +67,10 @@ supports for the chosen device will be used. **sort_order** defaults to the date that the station was added. Use 'A-Z' to display the list of stations in alphabetical order. -**cache_time_to_live** specifies how long station and genre lists should be cached for between refreshes. Setting this -to '0' will disable caching entirely and ensure that the latest lists are always retrieved from Pandora. +**cache_time_to_live** specifies how long station and genre lists should be cached for between refreshes, which greatly +speeds up browsing the library. Setting this to '0' will disable caching entirely and ensure that the latest lists are +always retrieved from Pandora. It shouldn't be necessary to fiddle with this unless you want Mopidy-Pandora to immediately +detect changes to your Pandora user profile that are made in other players. **EXPERIMENTAL EVENT HANDLING IMPLEMENTATION:** use these settings to work around the limitations of the current Mopidy core and web extensions: From b297c974f6641a4536254e3ab21ce5f2905de2f7 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 07:46:12 +0200 Subject: [PATCH 070/311] Update README --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 7d6dd6e..36742bb 100644 --- a/README.rst +++ b/README.rst @@ -117,7 +117,7 @@ v0.2.0 (UNRELEASED) - Force Mopidy to stop when skip limit is exceeded (workaround for https://github.com/mopidy/mopidy/issues/1221). - Scrobbling tracks to Last.fm should now work - Implemented caching to speed up startup and browsing of the list of stations. Configuration parameter - 'cache_time_to_live' can be used to specify when cache iterms should expire and be refreshed (in seconds). + 'cache_time_to_live' can be used to specify when cache items should expire and be refreshed (in seconds). - Better support for non-PandoraONE users: now plays advertisements which should prevent free accounts from being locked. - **Event support does not work at the moment**, so it has been disabled by default until https://github.com/mopidy/mopidy/issues/1352 is fixed. Alternatively you can patch Mopidy 1.1.1 with From 98a73455147b0dc1ea79e06a173d7d1614d18476 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 07:59:22 +0200 Subject: [PATCH 071/311] Rename library.next_track to avoid confusion with tracklist.next_track. --- mopidy_pandora/backend.py | 2 +- mopidy_pandora/library.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index ed85dc8..d479e12 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -57,7 +57,7 @@ def on_start(self): logger.error('Error logging in to Pandora: %s', encoding.locale_decode(e)) def end_of_tracklist_reached(self): - next_track = self.library.next_track() + next_track = self.library.get_next_pandora_track() if next_track: self._trigger_add_next_pandora_track(next_track) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index dccae75..6c27f98 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -106,7 +106,7 @@ def _browse_tracks(self, uri): self._station = self.backend.api.get_station(pandora_uri.station_id) self._station_iter = iterate_forever(self._station.get_playlist) - return [self.next_track()] + return [self.get_next_pandora_track()] def _create_station_for_genre(self, genre_token): json_result = self.backend.api.create_station(search_token=genre_token) @@ -133,7 +133,7 @@ def lookup_pandora_track(self, uri): logger.error("Failed to lookup '%s' in uri translation map.", uri) return None - def next_track(self): + def get_next_pandora_track(self): try: pandora_track = self._station_iter.next() track_uri = TrackUri.from_track(pandora_track) @@ -145,7 +145,6 @@ def next_track(self): track = models.Ref.track(name=track_name, uri=track_uri.uri) - self._pandora_tracks_cache.expire() self._pandora_tracks_cache[track.uri] = pandora_track return track From 6e9d60bdb23afaad3a9a65f26fd31d4024defaef Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 14:28:45 +0200 Subject: [PATCH 072/311] Switch pandora track cache to ordered dictionary history. --- mopidy_pandora/library.py | 8 ++++---- tests/test_library.py | 2 +- tests/test_playback.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 6c27f98..40c56bf 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,4 +1,4 @@ -from cachetools import TTLCache +from collections import OrderedDict from mopidy import backend, models @@ -27,7 +27,7 @@ def __init__(self, backend, sort_order): self._station = None self._station_iter = None - self._pandora_tracks_cache = TTLCache(25, 1800) + self._pandora_history = OrderedDict() super(PandoraLibraryProvider, self).__init__(backend) def browse(self, uri): @@ -128,7 +128,7 @@ def _browse_genre_stations(self, uri): def lookup_pandora_track(self, uri): try: - return self._pandora_tracks_cache[uri] + return self._pandora_history[uri] except KeyError: logger.error("Failed to lookup '%s' in uri translation map.", uri) return None @@ -145,7 +145,7 @@ def get_next_pandora_track(self): track = models.Ref.track(name=track_name, uri=track_uri.uri) - self._pandora_tracks_cache[track.uri] = pandora_track + self._pandora_history[track.uri] = pandora_track return track except requests.exceptions.RequestException as e: diff --git a/tests/test_library.py b/tests/test_library.py index 9c9d366..5cc4843 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -31,7 +31,7 @@ def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) track_uri = TrackUri.from_track(playlist_item_mock) - backend.library._pandora_tracks_cache[track_uri.uri] = playlist_item_mock + backend.library._pandora_history[track_uri.uri] = playlist_item_mock results = backend.library.lookup(track_uri.uri) diff --git a/tests/test_playback.py b/tests/test_playback.py index 646c3bd..a14b22e 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -113,7 +113,7 @@ def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): def test_translate_uri_returns_audio_url(provider, playlist_item_mock): test_uri = "pandora:track:test_station_id:test_token" - provider.backend.library._pandora_tracks_cache[test_uri] = playlist_item_mock + provider.backend.library._pandora_history[test_uri] = playlist_item_mock assert provider.translate_uri(test_uri) == conftest.MOCK_TRACK_AUDIO_HIGH From 5c7481358e8079f8b0002ff1a88d6ac9aada2fef Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 14:48:53 +0200 Subject: [PATCH 073/311] Update error message. --- mopidy_pandora/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 40c56bf..3fb89dc 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -130,7 +130,7 @@ def lookup_pandora_track(self, uri): try: return self._pandora_history[uri] except KeyError: - logger.error("Failed to lookup '%s' in uri translation map.", uri) + logger.error("Failed to lookup '%s' in Pandora track history.", uri) return None def get_next_pandora_track(self): From 9bc798036151990f3e68d46e4a80f833419b9b73 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 17:34:17 +0200 Subject: [PATCH 074/311] Update README. --- README.rst | 101 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 38 deletions(-) diff --git a/README.rst b/README.rst index 36742bb..fa59f06 100644 --- a/README.rst +++ b/README.rst @@ -18,7 +18,21 @@ Mopidy-Pandora :target: https://coveralls.io/r/rectalogic/mopidy-pandora?branch=develop :alt: Test coverage -Mopidy extension for Pandora radio (http://www.pandora.com). +Mopidy extension for Pandora Internet Radio (http://www.pandora.com). + + +Dependencies +============ + +- A free ad supported Pandora account or a Pandora One subscription (provides ad-free playback and higher quality + 192 Kbps audio stream). + +- ``pydora`` >= 1.6. 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. The music server that Mopidy-Pandora extends. Installation @@ -60,27 +74,35 @@ The **api_host** and **partner_** keys can be obtained from: `pandora-apidoc `_ -**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. +The following configuration values are available: + +- ``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. -**sort_order** defaults to the date that the station was added. Use 'A-Z' to display the list of stations in +- ``sort_order``: defaults to the ``date`` that the station was added. Use ``A-Z`` to display the list of stations in alphabetical order. -**cache_time_to_live** specifies how long station and genre lists should be cached for between refreshes, which greatly -speeds up browsing the library. Setting this to '0' will disable caching entirely and ensure that the latest lists are -always retrieved from Pandora. It shouldn't be necessary to fiddle with this unless you want Mopidy-Pandora to immediately -detect changes to your Pandora user profile that are made in other players. +- ``cache_time_to_live``: specifies how long station and genre lists should be cached for between refreshes, +which greatly speeds up browsing the library. Setting this to ``0`` will disable caching entirely and ensure that the +latest lists are always retrieved from Pandora. It shouldn't be necessary to fiddle with this unless you want +Mopidy-Pandora to immediately detect changes to your Pandora user profile that are made in other players. -**EXPERIMENTAL EVENT HANDLING IMPLEMENTATION:** use these settings to work around the limitations of the current Mopidy core -and web extensions: +**EXPERIMENTAL EVENT HANDLING IMPLEMENTATION:** use these settings to apply Pandora ratings to the track that is +playing currently using the standard pause/play/previous/next buttons: -- double_click_interval - successive button clicks that occur within this interval (in seconds) will trigger the event. -- on_pause_resume_click - click pause and then play while a song is playing to trigger the event. -- on_pause_next_click - click pause and then next in quick succession. Calls event and skips to next song. -- on_pause_previous_click - click pause and then previous in quick succession. Calls event and restarts the current song. +- ``event_support_enabled``: setting this to ``false`` will disable all event triggers entirely. +- ``double_click_interval``: successive button clicks that occur within this interval (in seconds) will trigger an + event. +- ``on_pause_resume_click``: click pause and then play while a song is playing to trigger the event. Defaults to + ``thumbs_up``. +- ``on_pause_next_click``: click pause and then next in quick succession. Calls event and skips to next song. Defaults + to ``thumbs_down``. +- ``on_pause_previous_click``: click pause and then previous in quick succession. Calls event and restarts the + current song. Defaults to ``sleep``. -The supported events are: 'thumbs_up', 'thumbs_down', 'sleep', 'add_artist_bookmark', and 'add_song_bookmark'. +The full list of supported events include: ``thumbs_up``, ``thumbs_down``, ``sleep``, ``add_artist_bookmark``, and +``add_song_bookmark``. Usage ===== @@ -88,9 +110,9 @@ Usage Mopidy needs `dynamic playlist `_ and `core extensions `_ support to properly support Pandora. In the meantime, Mopidy-Pandora simulates dynamic playlists by adding more tracks to the tracklist as needed. It is recommended that the -tracklist is played with **consume** turned on in order to simulate the behaviour of the standard Pandora clients. For -the same reason, **repeat**, **random**, and **single** should be turned off. Mopidy-Pandora will set all of this up -automatically unless you set the **auto_setup** config parameter to 'false'. +tracklist is played with ``consume`` turned on in order to simulate the behaviour of the standard Pandora clients. For +the same reason, ``repeat``, ``random``, and ``single`` should be turned off. Mopidy-Pandora will set all of this up +automatically unless you set the ``auto_setup`` config parameter to ``false``. Mopidy-Pandora will ensure that there are always at least two tracks in the playlist to avoid playback gaps when switching tracks. @@ -107,37 +129,39 @@ Changelog ========= v0.2.0 (UNRELEASED) ----------------------------------------- +------------------- - Major overhaul that completely changes how tracks are handled. Finally allows all track information to be accessible during playback (e.g. song and artist names, album covers, track length, bitrate etc.). -- Simulate dynamic tracklist (workaround for https://github.com/rectalogic/mopidy-pandora/issues/2) +- Simulate dynamic tracklist (workaround for `#2 `_) - Add support for browsing genre stations. Note that clicking on a genre station will automatically add that station to your profile. At the moment, there is no way to remove stations from within Mopidy-Pandora. -- Force Mopidy to stop when skip limit is exceeded (workaround for https://github.com/mopidy/mopidy/issues/1221). +- Force Mopidy to stop when skip limit is exceeded (workaround for `#1221 `_). - Scrobbling tracks to Last.fm should now work - Implemented caching to speed up startup and browsing of the list of stations. Configuration parameter - 'cache_time_to_live' can be used to specify when cache items should expire and be refreshed (in seconds). -- Better support for non-PandoraONE users: now plays advertisements which should prevent free accounts from being locked. + ``cache_time_to_live`` can be used to specify when cache items should expire and be refreshed (in seconds). +- Better support for users with free Pandora accounts: now plays advertisements which should prevent free accounts + from being locked. - **Event support does not work at the moment**, so it has been disabled by default until - https://github.com/mopidy/mopidy/issues/1352 is fixed. Alternatively you can patch Mopidy 1.1.1 with - https://github.com/mopidy/mopidy/pull/1356 if you want to keep using events in the interim. + `#1352 `_ is fixed. Alternatively you can patch Mopidy 1.1.1 with + `#1356 `_ if you want to keep using events in the interim. v0.1.7 (Oct 31, 2015) ----------------------------------------- +--------------------- -- Configuration parameter 'auto_set_repeat' has been renamed to 'auto_setup' - please update your Mopidy configuration file. +- Configuration parameter ``auto_set_repeat`` has been renamed to ``auto_setup`` - please update your Mopidy + configuration file. - Now resumes playback after a track has been rated. -- Enhanced auto_setup routines to ensure that 'consume', 'random', and 'single' modes are disabled as well. +- Enhanced auto_setup routines to ensure that ``consume``, ``random``, and ``single`` modes are disabled as well. - Optimized auto_setup routines: now only called when the Mopidy tracklist changes. v0.1.6 (Oct 26, 2015) ----------------------------------------- +--------------------- - Release to pypi v0.1.5 (Aug 20, 2015) ----------------------------------------- +--------------------- - Add option to automatically set tracks to play in repeat mode when Mopidy-Pandora starts. - Add experimental support for rating songs by re-using buttons available in the current front-end Mopidy extensions. @@ -147,14 +171,15 @@ v0.1.5 (Aug 20, 2015) - Add unit tests to increase test coverage. v0.1.4 (Aug 17, 2015) ----------------------------------------- +--------------------- -- Limit number of consecutive track skips to prevent Mopidy's skip-to-next-on-error behaviour from locking the user's Pandora account. +- Limit number of consecutive track skips to prevent Mopidy's skip-to-next-on-error behaviour from locking the user's + Pandora account. - Better handling of exceptions that occur in the backend to prevent Mopidy actor crashes. - Add support for unicode characters in station and track names. v0.1.3 (Jul 11, 2015) ----------------------------------------- +--------------------- - Update to work with release of Mopidy version 1.0 - Update to work with pydora version >= 1.4.0: now keeps the Pandora session alive in tha API itself. @@ -163,18 +188,18 @@ v0.1.3 (Jul 11, 2015) - Fill artist name to improve how tracks are displayed in various Mopidy front-end extensions. v0.1.2 (Jun 20, 2015) ----------------------------------------- +--------------------- -- Enhancement to handle 'Invalid Auth Token' exceptions when the Pandora session expires after long periods of +- Enhancement to handle ``Invalid Auth Token`` exceptions when the Pandora session expires after long periods of inactivity. Allows Mopidy-Pandora to run indefinitely on dedicated music servers like the Pi MusicBox. - Add configuration option to sort stations alphabetically, instead of by date. v0.1.1 (Mar 22, 2015) ----------------------------------------- +--------------------- - Added ability to make preferred audio quality user-configurable. v0.1.0 (Dec 28, 2014) ----------------------------------------- +--------------------- - Initial release. From 48ad2afd7c051a9eae9367f6567fd20b4a4cad62 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 17:36:34 +0200 Subject: [PATCH 075/311] Update README. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index fa59f06..0080787 100644 --- a/README.rst +++ b/README.rst @@ -29,7 +29,7 @@ Dependencies - ``pydora`` >= 1.6. 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`` +- ``cachetools`` >= 1.0. Extensible memoizing collections and decorators. The package is available as ``cachetools`` on PyPI. - ``Mopidy`` >= 1.1. The music server that Mopidy-Pandora extends. From b6bd587234ef8b8c716593c0d7d203254c4011d2 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 17:37:28 +0200 Subject: [PATCH 076/311] Update README. --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 0080787..4d2841b 100644 --- a/README.rst +++ b/README.rst @@ -77,11 +77,11 @@ The **api_host** and **partner_** keys can be obtained from: The following configuration values are available: - ``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. + 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. - ``sort_order``: defaults to the ``date`` that the station was added. Use ``A-Z`` to display the list of stations in -alphabetical order. + alphabetical order. - ``cache_time_to_live``: specifies how long station and genre lists should be cached for between refreshes, which greatly speeds up browsing the library. Setting this to ``0`` will disable caching entirely and ensure that the From 3f344c6b710a7914fee2533e4a4e6986b518b179 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 17:38:52 +0200 Subject: [PATCH 077/311] Update README. --- README.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 4d2841b..4d53bbc 100644 --- a/README.rst +++ b/README.rst @@ -77,16 +77,16 @@ The **api_host** and **partner_** keys can be obtained from: The following configuration values are available: - ``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. + 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. - ``sort_order``: defaults to the ``date`` that the station was added. Use ``A-Z`` to display the list of stations in - alphabetical order. + alphabetical order. - ``cache_time_to_live``: specifies how long station and genre lists should be cached for between refreshes, -which greatly speeds up browsing the library. Setting this to ``0`` will disable caching entirely and ensure that the -latest lists are always retrieved from Pandora. It shouldn't be necessary to fiddle with this unless you want -Mopidy-Pandora to immediately detect changes to your Pandora user profile that are made in other players. + which greatly speeds up browsing the library. Setting this to ``0`` will disable caching entirely and ensure that the + latest lists are always retrieved from Pandora. It shouldn't be necessary to fiddle with this unless you want + Mopidy-Pandora to immediately detect changes to your Pandora user profile that are made in other players. **EXPERIMENTAL EVENT HANDLING IMPLEMENTATION:** use these settings to apply Pandora ratings to the track that is playing currently using the standard pause/play/previous/next buttons: From 97cf9eef652f73d7ae96415b9e60fa79da886113 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 8 Dec 2015 17:41:15 +0200 Subject: [PATCH 078/311] Update README. --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 4d53bbc..e7b40d7 100644 --- a/README.rst +++ b/README.rst @@ -24,7 +24,7 @@ Mopidy extension for Pandora Internet Radio (http://www.pandora.com). Dependencies ============ -- A free ad supported Pandora account or a Pandora One subscription (provides ad-free playback and higher quality +- A free, ad supported, Pandora account or a paid Pandora One subscription (provides ad-free playback and higher quality 192 Kbps audio stream). - ``pydora`` >= 1.6. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. @@ -70,7 +70,7 @@ Before starting Mopidy, you must add the configuration settings for Mopidy-Pando on_pause_next_click = thumbs_down on_pause_previous_click = sleep -The **api_host** and **partner_** keys can be obtained from: +The ``api_host`` and ``partner_`` keys can be obtained from: `pandora-apidoc `_ From d8fa7e6a5bfd24e4bffe2efb905d4b5212044427 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 9 Dec 2015 06:32:01 +0200 Subject: [PATCH 079/311] Update README. --- README.rst | 61 +++++++++++++++++++++++++----------------------------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/README.rst b/README.rst index e7b40d7..15f4a8f 100644 --- a/README.rst +++ b/README.rst @@ -46,7 +46,8 @@ Install by running:: Configuration ============= -Before starting Mopidy, you must add the configuration settings for Mopidy-Pandora to your Mopidy configuration file:: +Before starting Mopidy, you must add your Pandora username and password to your Mopidy configuration file, and provide +the details of the JSON API endpoint that you would like to use:: [pandora] enabled = true @@ -56,49 +57,47 @@ Before starting Mopidy, you must add the configuration settings for Mopidy-Pando partner_username = iphone partner_password = partner_device = IP01 - preferred_audio_quality = highQuality username = password = - sort_order = date - auto_setup = true - cache_time_to_live = 1800 +The following optional configuration values are also available: - ### EXPERIMENTAL EVENT HANDLING IMPLEMENTATION ### - event_support_enabled = false - double_click_interval = 2.00 - on_pause_resume_click = thumbs_up - on_pause_next_click = thumbs_down - on_pause_previous_click = sleep +- ``pandora/enabled``: If the Pandora extension should be enabled or not. Defaults to ``true``. -The ``api_host`` and ``partner_`` keys can be obtained from: +- ``pandora/api_host``: Which of the JSON API `endpoints `_ to use. - `pandora-apidoc `_ +- ``pandora/partner_`` related values: The `credentials `_ to use for the Pandora API entry point. -The following configuration values are available: +- ``pandora/username``: Your Pandora username. You *must* provide this. -- ``preferred_audio_quality``: can be one of ``lowQuality``, ``mediumQuality``, or ``highQuality`` (default). If the +- ``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. -- ``sort_order``: defaults to the ``date`` that the station was added. Use ``A-Z`` to display the list of stations in +- ``pandora/sort_order``: defaults to the ``date`` that the station was added. Use ``A-Z`` to display the list of stations in alphabetical order. -- ``cache_time_to_live``: specifies how long station and genre lists should be cached for between refreshes, +- ``pandora/auto_setup``: If Mopidy-Pandora should automatically configure the Mopidy player for best compatibility + with the Pandora radio stream. Defaults to ``true`` and turns on ``consume`` mode and ``repeat``, ``random``, and + ``single`` off. + +- ``pandora/cache_time_to_live``: specifies how long station and genre lists should be cached for between refreshes, which greatly speeds up browsing the library. Setting this to ``0`` will disable caching entirely and ensure that the - latest lists are always retrieved from Pandora. It shouldn't be necessary to fiddle with this unless you want + latest lists are always retrieved from Pandora. It should not be necessary to fiddle with this unless you want Mopidy-Pandora to immediately detect changes to your Pandora user profile that are made in other players. -**EXPERIMENTAL EVENT HANDLING IMPLEMENTATION:** use these settings to apply Pandora ratings to the track that is -playing currently using the standard pause/play/previous/next buttons: +**EXPERIMENTAL EVENT HANDLING IMPLEMENTATION:** apply Pandora ratings or perform other actions on the track that is +currently playing using the standard pause/play/previous/next buttons. -- ``event_support_enabled``: setting this to ``false`` will disable all event triggers entirely. -- ``double_click_interval``: successive button clicks that occur within this interval (in seconds) will trigger an - event. -- ``on_pause_resume_click``: click pause and then play while a song is playing to trigger the event. Defaults to +- ``pandora/event_support_enabled``: setting this to ``false`` will disable all event triggers entirely. +- ``pandora/double_click_interval``: successive button clicks that occur within this interval (in seconds) will +trigger an event. Defaults to ``2.00`` seconds. +- ``pandora/on_pause_resume_click``: click pause and then play while a song is playing to trigger the event. Defaults to ``thumbs_up``. -- ``on_pause_next_click``: click pause and then next in quick succession. Calls event and skips to next song. Defaults - to ``thumbs_down``. -- ``on_pause_previous_click``: click pause and then previous in quick succession. Calls event and restarts the +- ``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``. The full list of supported events include: ``thumbs_up``, ``thumbs_down``, ``sleep``, ``add_artist_bookmark``, and @@ -109,12 +108,8 @@ Usage Mopidy needs `dynamic playlist `_ and `core extensions `_ support to properly support Pandora. In the meantime, -Mopidy-Pandora simulates dynamic playlists by adding more tracks to the tracklist as needed. It is recommended that the -tracklist is played with ``consume`` turned on in order to simulate the behaviour of the standard Pandora clients. For -the same reason, ``repeat``, ``random``, and ``single`` should be turned off. Mopidy-Pandora will set all of this up -automatically unless you set the ``auto_setup`` config parameter to ``false``. - -Mopidy-Pandora will ensure that there are always at least two tracks in the playlist to avoid playback gaps when switching tracks. +Mopidy-Pandora simulates dynamic playlists by adding more tracks to the tracklist as needed. Mopidy-Pandora will +ensure that there are always at least two tracks in the playlist to avoid playback gaps when switching tracks. Project resources From 837ca0ab3e89a8ff78543b42c4a0e85b6b8ecda1 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 9 Dec 2015 06:38:03 +0200 Subject: [PATCH 080/311] Update README. --- README.rst | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 15f4a8f..d4e467b 100644 --- a/README.rst +++ b/README.rst @@ -92,9 +92,9 @@ currently playing using the standard pause/play/previous/next buttons. - ``pandora/event_support_enabled``: setting this to ``false`` will disable all event triggers entirely. - ``pandora/double_click_interval``: successive button clicks that occur within this interval (in seconds) will -trigger an event. Defaults to ``2.00`` seconds. -- ``pandora/on_pause_resume_click``: click pause and then play while a song is playing to trigger the event. Defaults to - ``thumbs_up``. + trigger an event. Defaults to ``2.00`` 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 @@ -111,6 +111,10 @@ Mopidy needs `dynamic playlist `_ a Mopidy-Pandora simulates dynamic playlists by adding more tracks to the tracklist as needed. Mopidy-Pandora will ensure that there are always at least two tracks in the playlist to avoid playback gaps when switching tracks. +Pandora radio expects the user to interact with tracks at the time and in the order that it serves them up. For this +reason, trying to create playlist or manually interact with the tracklist queque is probably not a good idea. And not +supported. + Project resources ================= From 771bf60f04d9efde3817f0714e2142115eea294d Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 9 Dec 2015 06:40:43 +0200 Subject: [PATCH 081/311] Update README. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index d4e467b..ce79779 100644 --- a/README.rst +++ b/README.rst @@ -59,7 +59,7 @@ the details of the JSON API endpoint that you would like to use:: partner_device = IP01 username = password = -The following optional configuration values are also available: +The following configuration values are available: - ``pandora/enabled``: If the Pandora extension should be enabled or not. Defaults to ``true``. From a5a8971f5faa07d67516c3248804636d5ed50439 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 9 Dec 2015 08:19:22 +0200 Subject: [PATCH 082/311] Fix handling of unplayable tracks skip limit. --- mopidy_pandora/playback.py | 4 ++-- tests/test_playback.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index f2efeeb..40a040d 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -34,9 +34,9 @@ def consecutive_track_skips(self): @consecutive_track_skips.setter def consecutive_track_skips(self, value=1): if value > 0: - self._consecutive_track_skips += value + self._consecutive_track_skips = value - if self.consecutive_track_skips >= self.SKIP_LIMIT-1: + if self.consecutive_track_skips >= self.SKIP_LIMIT: logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) self._trigger_stop() else: diff --git a/tests/test_playback.py b/tests/test_playback.py index a14b22e..5c92b22 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -103,7 +103,9 @@ def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): listener.PandoraListener.send = mock.PropertyMock() for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): - provider.change_track(track) is False + assert provider.change_track(track) is False + if i < PandoraPlaybackProvider.SKIP_LIMIT-1: + assert not listener.PandoraListener.send.called listener.PandoraListener.send.assert_called_with('stop') assert "Maximum track skip limit (%s) exceeded, stopping...", \ From b5283760743247c01a9af357327a3d1381760111 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 9 Dec 2015 18:36:19 +0200 Subject: [PATCH 083/311] Fix expansion of tracklist for unplayable tracks. --- mopidy_pandora/backend.py | 8 ++++---- mopidy_pandora/frontend.py | 23 ++++++----------------- mopidy_pandora/listener.py | 10 ++-------- mopidy_pandora/playback.py | 34 ++++++++++------------------------ 4 files changed, 22 insertions(+), 53 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index d479e12..b8b18b5 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -56,13 +56,13 @@ def on_start(self): except requests.exceptions.RequestException as e: logger.error('Error logging in to Pandora: %s', encoding.locale_decode(e)) - def end_of_tracklist_reached(self): + def prepare_next_track(self, auto_play=False): next_track = self.library.get_next_pandora_track() if next_track: - self._trigger_add_next_pandora_track(next_track) + self._trigger_expand_tracklist(next_track, auto_play) - def _trigger_add_next_pandora_track(self, track): - listener.PandoraListener.send('add_next_pandora_track', track=track) + def _trigger_expand_tracklist(self, track, auto_play): + listener.PandoraListener.send('expand_tracklist', track=track, auto_play=auto_play) def _trigger_event_processed(self, track_uri): listener.PandoraListener.send('event_processed', track_uri=track_uri) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 47a0ee1..bc81d4f 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -75,13 +75,6 @@ def set_options(self): def options_changed(self): self.setup_required = True - def prepare_change(self): - if self.is_playing_last_track(): - self._trigger_end_of_tracklist_reached() - - def stop(self): - self.core.playback.stop() - @only_execute_for_pandora_uris def track_playback_started(self, tl_track): self.set_options() @@ -98,17 +91,13 @@ def track_playback_paused(self, tl_track, time_position): def track_playback_resumed(self, tl_track, time_position): self.set_options() - def is_playing_last_track(self): - current_tl_track = self.core.playback.get_current_tl_track().get() - next_tl_track = self.core.tracklist.next_track(current_tl_track).get() - - return next_tl_track is None - - def add_next_pandora_track(self, track): - self.core.tracklist.add(uris=[track.uri]) + def expand_tracklist(self, track, auto_play): + tl_tracks = self.core.tracklist.add(uris=[track.uri]) + if auto_play: + self.core.playback.play(tl_tracks.get()[0]) - def _trigger_end_of_tracklist_reached(self): - listener.PandoraListener.send('end_of_tracklist_reached') + def _trigger_prepare_next_track(self, auto_play): + listener.PandoraListener.send('prepare_next_track', auto_play=auto_play) class EventSupportPandoraFrontend(PandoraFrontend): diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index eb75555..9338372 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -9,13 +9,10 @@ class PandoraListener(listener.Listener): def send(event, **kwargs): listener.send_async(PandoraListener, event, **kwargs) - def end_of_tracklist_reached(self): + def prepare_next_track(self, auto_play): pass - def add_next_pandora_track(self, track): - pass - - def prepare_change(self): + def expand_tracklist(self, track, auto_play): pass def doubleclicked(self): @@ -26,6 +23,3 @@ def call_event(self, track_uri, pandora_event): def event_processed(self, track_uri): pass - - def stop(self): - pass diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 40a040d..9032b2a 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -27,37 +27,29 @@ def __init__(self, audio, backend): # 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())).get() - @property - def consecutive_track_skips(self): - return self._consecutive_track_skips - - @consecutive_track_skips.setter - def consecutive_track_skips(self, value=1): - if value > 0: - self._consecutive_track_skips = value - - if self.consecutive_track_skips >= self.SKIP_LIMIT: - logger.error('Maximum track skip limit (%s) exceeded, stopping...', self.SKIP_LIMIT) - self._trigger_stop() + def skip_track(self, track): + logger.warning("Skipping unplayable track with URI '%s'.", track.uri) + self._consecutive_track_skips += 1 + if self._consecutive_track_skips >= self.SKIP_LIMIT: + logger.error('Maximum track skip limit (%s) exceeded.', self.SKIP_LIMIT) else: - self._consecutive_track_skips = 0 + self.backend.prepare_next_track(True) def prepare_change(self): - self._trigger_prepare_change() + self.backend.prepare_next_track(False) super(PandoraPlaybackProvider, self).prepare_change() def change_track(self, track): if track.uri is None: logger.warning("No URI for track '%s'. Track cannot be played.", track) - self.consecutive_track_skips += 1 + self.skip_track(track) return False if self.is_playable(track.uri): - self.consecutive_track_skips = 0 + self._consecutive_track_skips = 0 return super(PandoraPlaybackProvider, self).change_track(track) else: - logger.warning("Skipping unplayable track with URI '%s'.", track.uri) - self.consecutive_track_skips += 1 + self.skip_track(track) return False def translate_uri(self, uri): @@ -79,12 +71,6 @@ def is_playable(self, track_uri): finally: return is_playable - def _trigger_prepare_change(self): - listener.PandoraListener.send('prepare_change') - - def _trigger_stop(self): - listener.PandoraListener.send('stop') - class EventSupportPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): From 4208af28525c565179d919a2aba5a26f26e174ef Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 9 Dec 2015 19:13:12 +0200 Subject: [PATCH 084/311] Fix test cases. --- tests/test_playback.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_playback.py b/tests/test_playback.py index 5c92b22..14e2ab9 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -10,7 +10,7 @@ import pytest -from mopidy_pandora import listener, playback +from mopidy_pandora import playback from mopidy_pandora.backend import MopidyAPIClient from mopidy_pandora.library import PandoraLibraryProvider @@ -55,10 +55,12 @@ def test_is_a_playback_provider(provider): assert isinstance(provider, backend_api.PlaybackProvider) -def test_change_track_aborts_if_no_track_uri(provider): +def test_change_track_skips_if_no_track_uri(provider): track = models.Track(uri=None) + provider.skip_track = mock.PropertyMock() assert provider.change_track(track) is False + assert provider.skip_track.called def test_pause_starts_double_click_timer(provider): @@ -100,14 +102,16 @@ def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): provider.previous_tl_track = {'track': {'uri': 'previous_track'}} provider.next_tl_track = {'track': {'uri': track.uri}} - listener.PandoraListener.send = mock.PropertyMock() + provider.backend.prepare_next_track = mock.PropertyMock() for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): assert provider.change_track(track) is False if i < PandoraPlaybackProvider.SKIP_LIMIT-1: - assert not listener.PandoraListener.send.called + assert provider.backend.prepare_next_track.called + provider.backend.prepare_next_track.reset_mock() + else: + assert not provider.backend.prepare_next_track.called - listener.PandoraListener.send.assert_called_with('stop') assert "Maximum track skip limit (%s) exceeded, stopping...", \ PandoraPlaybackProvider.SKIP_LIMIT in caplog.text() From 5686ee120dd1bb4977d03f1fc5f28aca232486b8 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 20 Dec 2015 17:22:06 +0200 Subject: [PATCH 085/311] WIP: idiomatic refactor. --- mopidy_pandora/backend.py | 8 ++++---- mopidy_pandora/frontend.py | 12 ++++++------ mopidy_pandora/library.py | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index b8b18b5..26328ec 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -39,9 +39,9 @@ def __init__(self, config, audio): self.supports_events = False if self.config.get('event_support_enabled', False): self.supports_events = True - self.playback = EventSupportPlaybackProvider(audio=audio, backend=self) + self.playback = EventSupportPlaybackProvider(audio, self) else: - self.playback = PandoraPlaybackProvider(audio=audio, backend=self) + self.playback = PandoraPlaybackProvider(audio, self) self.uri_schemes = ['pandora'] @@ -62,10 +62,10 @@ def prepare_next_track(self, auto_play=False): self._trigger_expand_tracklist(next_track, auto_play) def _trigger_expand_tracklist(self, track, auto_play): - listener.PandoraListener.send('expand_tracklist', track=track, auto_play=auto_play) + listener.PandoraListener.send('expand_tracklist', track, auto_play) def _trigger_event_processed(self, track_uri): - listener.PandoraListener.send('event_processed', track_uri=track_uri) + listener.PandoraListener.send('event_processed', track_uri) def call_event(self, track_uri, pandora_event): func = getattr(self, pandora_event) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index bc81d4f..7b85522 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -97,7 +97,7 @@ def expand_tracklist(self, track, auto_play): self.core.playback.play(tl_tracks.get()[0]) def _trigger_prepare_next_track(self, auto_play): - listener.PandoraListener.send('prepare_next_track', auto_play=auto_play) + listener.PandoraListener.send('prepare_next_track', auto_play) class EventSupportPandoraFrontend(PandoraFrontend): @@ -140,9 +140,9 @@ def track_playback_resumed(self, tl_track, time_position): def _process_events(self, track_uri, time_position): - # Check if there are any events that still require processing + # Check if there are any events that still require processing. if self.event_processed_event.isSet(): - # No events to process + # No events to process. return event_target_uri = self._get_event_target_uri(track_uri, time_position) @@ -165,7 +165,7 @@ def _get_event_target_uri(self, track_uri, time_position): history = self.core.history.get_history().get() return history[1][1].uri else: - # Trigger the event for the track that is playing currently + # Trigger the event for the track that is playing currently. return track_uri def _get_event(self, track_uri, time_position): @@ -188,7 +188,7 @@ def event_processed(self, track_uri): self.event_processed_event.set() if not self.tracklist_changed_event.isSet(): - # Do any 'tracklist_changed' updates that are pending + # Do any 'tracklist_changed' updates that are pending. self.tracklist_changed() def doubleclicked(self): @@ -197,4 +197,4 @@ def doubleclicked(self): self.core.playback.resume() def _trigger_call_event(self, track_uri, event): - listener.PandoraListener.send('call_event', track_uri=track_uri, pandora_event=event) + listener.PandoraListener.send('call_event', track_uri, event) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 3fb89dc..d3b0c08 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -23,7 +23,7 @@ class PandoraLibraryProvider(backend.LibraryProvider): genre_directory = models.Ref.directory(name=GENRE_DIR_NAME, uri=PandoraUri('genres').uri) def __init__(self, backend, sort_order): - self.sort_order = sort_order.upper() + self.sort_order = sort_order.lower() self._station = None self._station_iter = None @@ -79,7 +79,7 @@ def _browse_stations(self): stations = self.backend.api.get_station_list() if any(stations): - if self.sort_order == "A-Z": + if self.sort_order == "a-z": stations.sort(key=lambda x: x.name, reverse=False) self._move_shuffle_to_top(stations) @@ -124,7 +124,7 @@ def _browse_genre_categories(self): def _browse_genre_stations(self, uri): return [models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri) for station in self.backend.api.get_genre_stations() - [GenreUri.parse(uri).category_name]] + [PandoraUri.parse(uri).category_name]] def lookup_pandora_track(self, uri): try: From 72f1bdd064eb1084d601c72a9c57525244c9652c Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 20 Dec 2015 17:29:54 +0200 Subject: [PATCH 086/311] WIP: idiomatic refactor. --- mopidy_pandora/backend.py | 2 +- mopidy_pandora/library.py | 6 +++--- mopidy_pandora/uri.py | 23 ++++++++++++----------- tests/test_uri.py | 4 ++-- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 26328ec..691bb11 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -43,7 +43,7 @@ def __init__(self, config, audio): else: self.playback = PandoraPlaybackProvider(audio, self) - self.uri_schemes = ['pandora'] + self.uri_schemes = [PandoraUri.SCHEME] @rpc.run_async def on_start(self): diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index d3b0c08..8cee156 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -39,17 +39,17 @@ def browse(self, uri): pandora_uri = PandoraUri.parse(uri) - if pandora_uri.scheme == GenreUri.scheme: + if pandora_uri.type == GenreUri.type: return self._browse_genre_stations(uri) - if pandora_uri.scheme == StationUri.scheme: + if pandora_uri.type == StationUri.type: return self._browse_tracks(uri) raise Exception("Unknown or unsupported URI type '%s'", uri) def lookup(self, uri): - if PandoraUri.parse(uri).scheme == TrackUri.scheme: + if PandoraUri.parse(uri).type == TrackUri.type: pandora_track = self.lookup_pandora_track(uri) if pandora_track is not None: diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index dd2f431..5938ff3 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -8,17 +8,18 @@ class _PandoraUriMeta(type): def __init__(cls, name, bases, clsdict): # noqa N805 super(_PandoraUriMeta, cls).__init__(name, bases, clsdict) - if hasattr(cls, 'scheme'): - cls.SCHEMES[cls.scheme] = cls + if hasattr(cls, 'type'): + cls.TYPES[cls.type] = cls class PandoraUri(object): __metaclass__ = _PandoraUriMeta - SCHEMES = {} + TYPES = {} + SCHEME = 'pandora' - def __init__(self, scheme=None): - if scheme is not None: - self.scheme = scheme + def __init__(self, type=None): + if type is not None: + self.type = type def quote(self, value): @@ -31,12 +32,12 @@ def quote(self, value): @property def uri(self): - return "pandora:{}".format(self.quote(self.scheme)) + return "{}:{}".format(self.SCHEME, self.quote(self.type)) @classmethod def parse(cls, uri): parts = [urllib.unquote(p).decode('utf8') for p in uri.split(':')] - uri_cls = cls.SCHEMES.get(parts[1]) + uri_cls = cls.TYPES.get(parts[1]) if uri_cls: return uri_cls(*parts[2:]) else: @@ -44,7 +45,7 @@ def parse(cls, uri): class GenreUri(PandoraUri): - scheme = 'genre' + type = 'genre' def __init__(self, category_name): super(GenreUri, self).__init__() @@ -59,7 +60,7 @@ def uri(self): class StationUri(PandoraUri): - scheme = 'station' + type = 'station' def __init__(self, station_id, token): super(StationUri, self).__init__() @@ -84,7 +85,7 @@ def uri(self): class TrackUri(StationUri): - scheme = 'track' + type = 'track' ADVERTISEMENT_TOKEN = "advertisement" def __init__(self, station_id, token): diff --git a/tests/test_uri.py b/tests/test_uri.py index a44f827..16a03cf 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -69,7 +69,7 @@ def test_station_uri_parse(station_mock): assert isinstance(obj, StationUri) - assert obj.scheme == conftest.MOCK_STATION_SCHEME + assert obj.type == conftest.MOCK_STATION_SCHEME assert obj.station_id == conftest.MOCK_STATION_ID assert obj.token == conftest.MOCK_STATION_TOKEN @@ -103,7 +103,7 @@ def test_track_uri_parse(playlist_item_mock): assert isinstance(obj, TrackUri) - assert obj.scheme == conftest.MOCK_TRACK_SCHEME + assert obj.type == conftest.MOCK_TRACK_SCHEME assert obj.station_id == conftest.MOCK_STATION_ID assert obj.token == conftest.MOCK_TRACK_TOKEN From 30455def76d097e10acc0b1967f442cc0b7a5512 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 20 Dec 2015 17:43:15 +0200 Subject: [PATCH 087/311] WIP: idiomatic refactor. --- mopidy_pandora/backend.py | 26 ++-- mopidy_pandora/client.py | 22 ++-- mopidy_pandora/frontend.py | 8 +- mopidy_pandora/library.py | 10 +- mopidy_pandora/playback.py | 8 +- mopidy_pandora/uri.py | 12 +- tests/conftest.py | 238 ++++++++++++++++++------------------- tests/test_client.py | 50 ++++---- tests/test_library.py | 22 ++-- tests/test_playback.py | 6 +- tests/test_uri.py | 20 ++-- 11 files changed, 211 insertions(+), 211 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 691bb11..6276445 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -23,14 +23,14 @@ def __init__(self, config, audio): super(PandoraBackend, self).__init__() self.config = config['pandora'] settings = { - "CACHE_TTL": self.config.get("cache_time_to_live", 1800), - "API_HOST": self.config.get("api_host", 'tuner.pandora.com/services/json/'), - "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"], - "AUDIO_QUALITY": self.config.get("preferred_audio_quality", BaseAPIClient.HIGH_AUDIO_QUALITY) + 'CACHE_TTL': self.config.get('cache_time_to_live', 1800), + 'API_HOST': self.config.get('api_host', 'tuner.pandora.com/services/json/'), + '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'], + 'AUDIO_QUALITY': self.config.get('preferred_audio_quality', BaseAPIClient.HIGH_AUDIO_QUALITY) } self.api = MopidySettingsDictBuilder(settings, client_class=MopidyAPIClient).build() @@ -48,13 +48,13 @@ def __init__(self, config, audio): @rpc.run_async def on_start(self): try: - self.api.login(self.config["username"], self.config["password"]) + self.api.login(self.config['username'], self.config['password']) # Prefetch list of stations linked to the user's profile self.api.get_station_list() # Prefetch genre category list self.api.get_genre_stations() except requests.exceptions.RequestException as e: - logger.error('Error logging in to Pandora: %s', encoding.locale_decode(e)) + logger.error('Error logging in to Pandora: {}'.format(encoding.locale_decode(e))) def prepare_next_track(self, auto_play=False): next_track = self.library.get_next_pandora_track() @@ -70,12 +70,12 @@ def _trigger_event_processed(self, track_uri): def call_event(self, track_uri, pandora_event): func = getattr(self, pandora_event) try: - logger.info("Triggering event '%s' for song: %s", pandora_event, - self.library.lookup_pandora_track(track_uri).song_name) + logger.info('Triggering event \'{}\' for song: \'{}\''.format(pandora_event, + self.library.lookup_pandora_track(track_uri).song_name)) func(track_uri) self._trigger_event_processed(track_uri) except PandoraException as e: - logger.error('Error calling event: %s', encoding.locale_decode(e)) + logger.error('Error calling event: {}'.format(encoding.locale_decode(e))) return False def thumbs_up(self, track_uri): diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 31ef219..8ff733b 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -17,20 +17,20 @@ class MopidySettingsDictBuilder(SettingsDictBuilder): def build_from_settings_dict(self, settings): - enc = Encryptor(settings["DECRYPTION_KEY"], - settings["ENCRYPTION_KEY"]) + enc = Encryptor(settings['DECRYPTION_KEY'], + settings['ENCRYPTION_KEY']) trans = APITransport(enc, - settings.get("API_HOST", DEFAULT_API_HOST), - settings.get("PROXY", None)) + settings.get('API_HOST', DEFAULT_API_HOST), + settings.get('PROXY', None)) - quality = settings.get("AUDIO_QUALITY", + 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) + return self.client_class(settings['CACHE_TTL'], trans, + settings['PARTNER_USER'], + settings['PARTNER_PASSWORD'], + settings['DEVICE'], quality) class MopidyAPIClient(pandora.APIClient): @@ -57,7 +57,7 @@ def get_station_list(self, force_refresh=False): self._station_list_cache[time.time()] = super(MopidyAPIClient, self).get_station_list() except requests.exceptions.RequestException as e: - logger.error('Error retrieving station list: %s', encoding.locale_decode(e)) + logger.error('Error retrieving station list: {}'.format(encoding.locale_decode(e))) return [] return self._station_list_cache.itervalues().next() @@ -79,7 +79,7 @@ def get_genre_stations(self, force_refresh=False): self._genre_stations_cache[time.time()] = super(MopidyAPIClient, self).get_genre_stations() except requests.exceptions.RequestException as e: - logger.error('Error retrieving genre stations: %s', encoding.locale_decode(e)) + logger.error('Error retrieving genre stations: {}'.format(encoding.locale_decode(e))) return [] return self._genre_stations_cache.itervalues().next() diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 7b85522..cb833f8 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -105,9 +105,9 @@ class EventSupportPandoraFrontend(PandoraFrontend): def __init__(self, config, core): super(EventSupportPandoraFrontend, self).__init__(config, core) - self.on_pause_resume_click = config.get("on_pause_resume_click", "thumbs_up") - self.on_pause_next_click = config.get("on_pause_next_click", "thumbs_down") - self.on_pause_previous_click = config.get("on_pause_previous_click", "sleep") + self.on_pause_resume_click = config.get('on_pause_resume_click', 'thumbs_up') + self.on_pause_next_click = config.get('on_pause_next_click', 'thumbs_down') + self.on_pause_previous_click = config.get('on_pause_previous_click', 'sleep') self.previous_tl_track = None self.current_tl_track = None @@ -156,7 +156,7 @@ def _process_events(self, track_uri, time_position): if event_target_uri and event: self._trigger_call_event(event_target_uri, event) else: - logger.error("Unexpected doubleclick event URI '%s'", track_uri) + logger.error('Unexpected doubleclick event URI \'{}\''.format(track_uri)) self.event_processed_event.set() def _get_event_target_uri(self, track_uri, time_position): diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 8cee156..d4e65e4 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -45,7 +45,7 @@ def browse(self, uri): if pandora_uri.type == StationUri.type: return self._browse_tracks(uri) - raise Exception("Unknown or unsupported URI type '%s'", uri) + raise Exception('Unknown or unsupported URI type \'{}\''.format(uri)) def lookup(self, uri): @@ -64,7 +64,7 @@ def lookup(self, uri): uri=pandora_track.album_detail_url, images=[pandora_track.album_art_url]))] - logger.error("Failed to lookup '%s'", uri) + logger.error('Failed to lookup \'{}\''.format(uri)) return [] def _move_shuffle_to_top(self, list): @@ -79,7 +79,7 @@ def _browse_stations(self): stations = self.backend.api.get_station_list() if any(stations): - if self.sort_order == "a-z": + if self.sort_order == 'a-z': stations.sort(key=lambda x: x.name, reverse=False) self._move_shuffle_to_top(stations) @@ -130,7 +130,7 @@ def lookup_pandora_track(self, uri): try: return self._pandora_history[uri] except KeyError: - logger.error("Failed to lookup '%s' in Pandora track history.", uri) + logger.error('Failed to lookup \'{}\' in Pandora track history.'.format(uri)) return None def get_next_pandora_track(self): @@ -149,5 +149,5 @@ def get_next_pandora_track(self): return track except requests.exceptions.RequestException as e: - logger.error('Error retrieving next Pandora track: %s', encoding.locale_decode(e)) + logger.error('Error retrieving next Pandora track: {}'.format(encoding.locale_decode(e))) return None diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 9032b2a..11b53c9 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -28,10 +28,10 @@ def __init__(self, audio, backend): # self.audio.set_uri(self.translate_uri(self.get_next_track())).get() def skip_track(self, track): - logger.warning("Skipping unplayable track with URI '%s'.", track.uri) + logger.warning('Skipping unplayable track with URI \'{}\'.'.format(track.uri)) self._consecutive_track_skips += 1 if self._consecutive_track_skips >= self.SKIP_LIMIT: - logger.error('Maximum track skip limit (%s) exceeded.', self.SKIP_LIMIT) + logger.error('Maximum track skip limit ({}) exceeded.'.format(self.SKIP_LIMIT)) else: self.backend.prepare_next_track(True) @@ -41,7 +41,7 @@ def prepare_change(self): def change_track(self, track): if track.uri is None: - logger.warning("No URI for track '%s'. Track cannot be played.", track) + logger.warning('No URI for track \'{}\'. Track cannot be played.'.format(track)) self.skip_track(track) return False @@ -67,7 +67,7 @@ def is_playable(self, track_uri): is_playable = pandora_track and pandora_track.audio_url and pandora_track.get_is_playable() except requests.exceptions.RequestException as e: - logger.error('Error checking if track is playable: %s', encoding.locale_decode(e)) + logger.error('Error checking if track is playable: {}'.format(encoding.locale_decode(e))) finally: return is_playable diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 5938ff3..d583c08 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -32,7 +32,7 @@ def quote(self, value): @property def uri(self): - return "{}:{}".format(self.SCHEME, self.quote(self.type)) + return '{}:{}'.format(self.SCHEME, self.quote(self.type)) @classmethod def parse(cls, uri): @@ -53,7 +53,7 @@ def __init__(self, category_name): @property def uri(self): - return "{}:{}".format( + return '{}:{}'.format( super(GenreUri, self).uri, self.quote(self.category_name), ) @@ -77,7 +77,7 @@ def from_station(cls, station): @property def uri(self): - return "{}:{}:{}".format( + return '{}:{}:{}'.format( super(StationUri, self).uri, self.quote(self.station_id), self.quote(self.token), @@ -86,7 +86,7 @@ def uri(self): class TrackUri(StationUri): type = 'track' - ADVERTISEMENT_TOKEN = "advertisement" + ADVERTISEMENT_TOKEN = 'advertisement' def __init__(self, station_id, token): super(TrackUri, self).__init__(station_id, token) @@ -98,11 +98,11 @@ def from_track(cls, track): elif isinstance(track, AdItem): return TrackUri(track.station_id, cls.ADVERTISEMENT_TOKEN) else: - raise NotImplementedError("Unsupported playlist item type") + raise NotImplementedError('Unsupported playlist item type') @property def uri(self): - return "{}".format( + return '{}'.format( super(TrackUri, self).uri, ) diff --git a/tests/conftest.py b/tests/conftest.py index db9fb5a..9a803fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,30 +12,30 @@ from mopidy_pandora import backend -MOCK_STATION_SCHEME = "station" -MOCK_STATION_NAME = "Mock Station" -MOCK_STATION_ID = "0000000000000000001" -MOCK_STATION_TOKEN = "0000000000000000010" -MOCK_STATION_DETAIL_URL = " http://mockup.com/station/detail_url?..." -MOCK_STATION_ART_URL = " http://mockup.com/station/art_url?..." - -MOCK_STATION_LIST_CHECKSUM = "aa00aa00aa00aa00aa00aa00aa00aa00" - -MOCK_TRACK_SCHEME = "track" -MOCK_TRACK_NAME = "Mock Track" -MOCK_TRACK_TOKEN = "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" -MOCK_TRACK_AD_TOKEN = "000000000000000000-none" -MOCK_TRACK_AUDIO_HIGH = "http://mockup.com/high_quality_audiofile.mp4?..." -MOCK_TRACK_AUDIO_MED = "http://mockup.com/medium_quality_audiofile.mp4?..." -MOCK_TRACK_AUDIO_LOW = "http://mockup.com/low_quality_audiofile.mp4?..." -MOCK_TRACK_DETAIL_URL = " http://mockup.com/track/detail_url?..." -MOCK_TRACK_ART_URL = " http://mockup.com/track/art_url?..." -MOCK_TRACK_INDEX = "1" - -MOCK_DEFAULT_AUDIO_QUALITY = "highQuality" - - -@pytest.fixture(scope="session") +MOCK_STATION_SCHEME = 'station' +MOCK_STATION_NAME = 'Mock Station' +MOCK_STATION_ID = '0000000000000000001' +MOCK_STATION_TOKEN = '0000000000000000010' +MOCK_STATION_DETAIL_URL = ' http://mockup.com/station/detail_url?...' +MOCK_STATION_ART_URL = ' http://mockup.com/station/art_url?...' + +MOCK_STATION_LIST_CHECKSUM = 'aa00aa00aa00aa00aa00aa00aa00aa00' + +MOCK_TRACK_SCHEME = 'track' +MOCK_TRACK_NAME = 'Mock Track' +MOCK_TRACK_TOKEN = '000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001' +MOCK_TRACK_AD_TOKEN = '000000000000000000-none' +MOCK_TRACK_AUDIO_HIGH = 'http://mockup.com/high_quality_audiofile.mp4?...' +MOCK_TRACK_AUDIO_MED = 'http://mockup.com/medium_quality_audiofile.mp4?...' +MOCK_TRACK_AUDIO_LOW = 'http://mockup.com/low_quality_audiofile.mp4?...' +MOCK_TRACK_DETAIL_URL = ' http://mockup.com/track/detail_url?...' +MOCK_TRACK_ART_URL = ' http://mockup.com/track/art_url?...' +MOCK_TRACK_INDEX = '1' + +MOCK_DEFAULT_AUDIO_QUALITY = 'highQuality' + + +@pytest.fixture(scope='session') def config(): return { 'http': { @@ -78,64 +78,64 @@ def get_backend(config, simulate_request_exceptions=False): return obj -@pytest.fixture(scope="session") +@pytest.fixture(scope='session') def station_result_mock(): - mock_result = {"stat": "ok", - "result": - {"stationId": MOCK_STATION_ID, - "stationDetailUrl": MOCK_STATION_DETAIL_URL, - "artUrl": MOCK_STATION_ART_URL, - "stationToken": MOCK_STATION_TOKEN, - "stationName": MOCK_STATION_NAME}, + mock_result = {'stat': 'ok', + 'result': + {'stationId': MOCK_STATION_ID, + 'stationDetailUrl': MOCK_STATION_DETAIL_URL, + 'artUrl': MOCK_STATION_ART_URL, + 'stationToken': MOCK_STATION_TOKEN, + 'stationName': MOCK_STATION_NAME}, } return mock_result -@pytest.fixture(scope="session") +@pytest.fixture(scope='session') def station_mock(simulate_request_exceptions=False): return Station.from_json(get_backend(config(), simulate_request_exceptions).api, - station_result_mock()["result"]) + station_result_mock()['result']) -@pytest.fixture(scope="session") +@pytest.fixture(scope='session') def get_station_mock(self, station_token): return station_mock() -@pytest.fixture(scope="session") +@pytest.fixture(scope='session') def playlist_result_mock(): - mock_result = {"stat": "ok", - "result": dict(items=[{ - "trackToken": MOCK_TRACK_TOKEN, - "artistName": "Mock Artist Name", - "albumName": "Mock Album Name", - "albumArtUrl": MOCK_TRACK_ART_URL, - "audioUrlMap": { - "highQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_HIGH, - "protocol": "http" + mock_result = {'stat': 'ok', + 'result': dict(items=[{ + 'trackToken': MOCK_TRACK_TOKEN, + 'artistName': 'Mock Artist Name', + 'albumName': 'Mock Album Name', + 'albumArtUrl': MOCK_TRACK_ART_URL, + 'audioUrlMap': { + 'highQuality': { + 'bitrate': '64', + 'encoding': 'aacplus', + 'audioUrl': MOCK_TRACK_AUDIO_HIGH, + 'protocol': 'http' }, - "mediumQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_MED, - "protocol": "http" + 'mediumQuality': { + 'bitrate': '64', + 'encoding': 'aacplus', + 'audioUrl': MOCK_TRACK_AUDIO_MED, + 'protocol': 'http' }, - "lowQuality": { - "bitrate": "32", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_LOW, - "protocol": "http" + 'lowQuality': { + 'bitrate': '32', + 'encoding': 'aacplus', + 'audioUrl': MOCK_TRACK_AUDIO_LOW, + 'protocol': 'http' } }, - "songName": MOCK_TRACK_NAME, - "songDetailUrl": MOCK_TRACK_DETAIL_URL, - "stationId": MOCK_STATION_ID, - "songRating": 0, - "adToken": None, }, + 'songName': MOCK_TRACK_NAME, + 'songDetailUrl': MOCK_TRACK_DETAIL_URL, + 'stationId': MOCK_STATION_ID, + 'songRating': 0, + 'adToken': None, }, # Also add an advertisement to the playlist. { @@ -144,23 +144,23 @@ def playlist_result_mock(): 'albumName': None, 'albumArtUrl': None, 'audioUrlMap': { - "highQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_HIGH, - "protocol": "http" + 'highQuality': { + 'bitrate': '64', + 'encoding': 'aacplus', + 'audioUrl': MOCK_TRACK_AUDIO_HIGH, + 'protocol': 'http' }, - "mediumQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_MED, - "protocol": "http" + 'mediumQuality': { + 'bitrate': '64', + 'encoding': 'aacplus', + 'audioUrl': MOCK_TRACK_AUDIO_MED, + 'protocol': 'http' }, - "lowQuality": { - "bitrate": "32", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_LOW, - "protocol": "http" + 'lowQuality': { + 'bitrate': '32', + 'encoding': 'aacplus', + 'audioUrl': MOCK_TRACK_AUDIO_LOW, + 'protocol': 'http' } }, 'songName': None, @@ -173,27 +173,27 @@ def playlist_result_mock(): return mock_result -@pytest.fixture(scope="session") +@pytest.fixture(scope='session') def ad_metadata_result_mock(): - mock_result = {"stat": "ok", - "result": dict(title=MOCK_TRACK_NAME, companyName="Mock Company Name", audioUrlMap={ - "highQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_HIGH, - "protocol": "http" + mock_result = {'stat': 'ok', + 'result': dict(title=MOCK_TRACK_NAME, companyName='Mock Company Name', audioUrlMap={ + 'highQuality': { + 'bitrate': '64', + 'encoding': 'aacplus', + 'audioUrl': MOCK_TRACK_AUDIO_HIGH, + 'protocol': 'http' }, - "mediumQuality": { - "bitrate": "64", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_MED, - "protocol": "http" + 'mediumQuality': { + 'bitrate': '64', + 'encoding': 'aacplus', + 'audioUrl': MOCK_TRACK_AUDIO_MED, + 'protocol': 'http' }, - "lowQuality": { - "bitrate": "32", - "encoding": "aacplus", - "audioUrl": MOCK_TRACK_AUDIO_LOW, - "protocol": "http" + 'lowQuality': { + 'bitrate': '32', + 'encoding': 'aacplus', + 'audioUrl': MOCK_TRACK_AUDIO_LOW, + 'protocol': 'http' } }, adTrackingTokens={ MOCK_TRACK_AD_TOKEN, @@ -204,17 +204,17 @@ def ad_metadata_result_mock(): return mock_result -@pytest.fixture(scope="session") +@pytest.fixture(scope='session') def playlist_mock(simulate_request_exceptions=False): - return Playlist.from_json(get_backend(config(), simulate_request_exceptions).api, playlist_result_mock()["result"]) + return Playlist.from_json(get_backend(config(), simulate_request_exceptions).api, playlist_result_mock()['result']) -@pytest.fixture(scope="session") +@pytest.fixture(scope='session') def get_playlist_mock(self, station_token): return playlist_mock() -@pytest.fixture(scope="session") +@pytest.fixture(scope='session') def get_station_playlist_mock(self): return iter(get_playlist_mock(self, MOCK_STATION_TOKEN)) @@ -222,13 +222,13 @@ def get_station_playlist_mock(self): @pytest.fixture def playlist_item_mock(): return PlaylistItem.from_json(get_backend( - config()).api, playlist_result_mock()["result"]["items"][0]) + config()).api, playlist_result_mock()['result']['items'][0]) -@pytest.fixture(scope="session") +@pytest.fixture(scope='session') def ad_item_mock(): return AdItem.from_json(get_backend( - config()).api, ad_metadata_result_mock()["result"]) + config()).api, ad_metadata_result_mock()['result']) @pytest.fixture @@ -236,23 +236,23 @@ def get_ad_item_mock(self, token): return ad_item_mock() -@pytest.fixture(scope="session") +@pytest.fixture(scope='session') def station_list_result_mock(): - mock_result = {"stat": "ok", - "result": {"stations": [ - {"stationId": MOCK_STATION_ID.replace("1", "2"), - "stationToken": MOCK_STATION_TOKEN.replace("010", "100"), - "stationName": MOCK_STATION_NAME + " 2"}, - {"stationId": MOCK_STATION_ID, - "stationToken": MOCK_STATION_TOKEN, - "stationName": MOCK_STATION_NAME + " 1"}, - {"stationId": MOCK_STATION_ID.replace("1", "3"), - "stationToken": MOCK_STATION_TOKEN.replace("0010", "1000"), - "stationName": "QuickMix"}, - ], "checksum": MOCK_STATION_LIST_CHECKSUM}, + mock_result = {'stat': 'ok', + 'result': {'stations': [ + {'stationId': MOCK_STATION_ID.replace('1', '2'), + 'stationToken': MOCK_STATION_TOKEN.replace('010', '100'), + 'stationName': MOCK_STATION_NAME + ' 2'}, + {'stationId': MOCK_STATION_ID, + 'stationToken': MOCK_STATION_TOKEN, + 'stationName': MOCK_STATION_NAME + ' 1'}, + {'stationId': MOCK_STATION_ID.replace('1', '3'), + 'stationToken': MOCK_STATION_TOKEN.replace('0010', '1000'), + 'stationName': 'QuickMix'}, + ], 'checksum': MOCK_STATION_LIST_CHECKSUM}, } - return mock_result["result"] + return mock_result['result'] @pytest.fixture @@ -260,14 +260,14 @@ def get_station_list_mock(self): return StationList.from_json(get_backend(config()).api, station_list_result_mock()) -@pytest.fixture(scope="session") +@pytest.fixture(scope='session') def request_exception_mock(self, *args, **kwargs): raise requests.exceptions.RequestException @pytest.fixture def transport_call_not_implemented_mock(self, method, **data): - raise TransportCallTestNotImplemented(method + "(" + json.dumps(self.remove_empty_values(data)) + ")") + raise TransportCallTestNotImplemented(method + '(' + json.dumps(self.remove_empty_values(data)) + ')') class TransportCallTestNotImplemented(Exception): diff --git a/tests/test_client.py b/tests/test_client.py index 8f6f0cc..35666d5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,9 +23,9 @@ def test_get_station_list(config): station_list = backend.api.get_station_list() assert len(station_list) == len(conftest.station_list_result_mock()['stations']) - assert station_list[0].name == conftest.MOCK_STATION_NAME + " 2" - assert station_list[1].name == conftest.MOCK_STATION_NAME + " 1" - assert station_list[2].name == "QuickMix" + assert station_list[0].name == conftest.MOCK_STATION_NAME + ' 2' + assert station_list[1].name == conftest.MOCK_STATION_NAME + ' 1' + assert station_list[2].name == 'QuickMix' def test_get_station_list_populates_cache(config): @@ -44,24 +44,24 @@ def test_get_station_list_changed_cached(config): with mock.patch.object(StationList, 'has_changed', return_value=True): backend = get_backend(config) - cached_checksum = "zz00aa00aa00aa00aa00aa00aa00aa99" - mock_cached_result = {"stat": "ok", - "result": { - "stations": [ - {"stationId": conftest.MOCK_STATION_ID, - "stationToken": conftest.MOCK_STATION_TOKEN, - "stationName": conftest.MOCK_STATION_NAME + cached_checksum = 'zz00aa00aa00aa00aa00aa00aa00aa99' + mock_cached_result = {'stat': 'ok', + 'result': { + 'stations': [ + {'stationId': conftest.MOCK_STATION_ID, + 'stationToken': conftest.MOCK_STATION_TOKEN, + 'stationName': conftest.MOCK_STATION_NAME }, ], - "checksum": cached_checksum + 'checksum': cached_checksum }} backend.api._station_list_cache[time.time()] = StationList.from_json( - APIClient, mock_cached_result["result"]) + APIClient, mock_cached_result['result']) backend.api.get_station_list() assert backend.api.get_station_list().checksum == cached_checksum assert len(backend.api._station_list_cache.itervalues().next()) == len(StationList.from_json( - APIClient, mock_cached_result["result"])) + APIClient, mock_cached_result['result'])) def test_get_station_list_changed_refreshed(config): @@ -70,19 +70,19 @@ def test_get_station_list_changed_refreshed(config): with mock.patch.object(StationList, 'has_changed', return_value=True): backend = get_backend(config) - cached_checksum = "zz00aa00aa00aa00aa00aa00aa00aa99" - mock_cached_result = {"stat": "ok", - "result": { - "stations": [ - {"stationId": conftest.MOCK_STATION_ID, - "stationToken": conftest.MOCK_STATION_TOKEN, - "stationName": conftest.MOCK_STATION_NAME + cached_checksum = 'zz00aa00aa00aa00aa00aa00aa00aa99' + mock_cached_result = {'stat': 'ok', + 'result': { + 'stations': [ + {'stationId': conftest.MOCK_STATION_ID, + 'stationToken': conftest.MOCK_STATION_TOKEN, + 'stationName': conftest.MOCK_STATION_NAME }, ], - "checksum": cached_checksum + 'checksum': cached_checksum }} backend.api._station_list_cache[time.time()] = StationList.from_json( - APIClient, mock_cached_result["result"]) + APIClient, mock_cached_result['result']) assert backend.api.get_station_list().checksum == cached_checksum @@ -110,10 +110,10 @@ def test_get_station(config): backend.api.get_station_list() assert backend.api.get_station( - conftest.MOCK_STATION_ID).name == conftest.MOCK_STATION_NAME + " 1" + conftest.MOCK_STATION_ID).name == conftest.MOCK_STATION_NAME + ' 1' assert backend.api.get_station( - conftest.MOCK_STATION_ID.replace("1", "2")).name == conftest.MOCK_STATION_NAME + " 2" + conftest.MOCK_STATION_ID.replace('1', '2')).name == conftest.MOCK_STATION_NAME + ' 2' def test_get_invalid_station(config): @@ -124,7 +124,7 @@ def test_get_invalid_station(config): backend = get_backend(config) - backend.api.get_station("9999999999999999999") + backend.api.get_station('9999999999999999999') def test_create_genre_station_invalidates_cache(config): diff --git a/tests/test_library.py b/tests/test_library.py index 5cc4843..8386be5 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -23,7 +23,7 @@ def test_lookup_of_invalid_uri(config, caplog): results = backend.library.lookup('pandora:invalid') assert len(results) == 0 - assert "Failed to lookup 'pandora:invalid'" in caplog.text() + assert 'Failed to lookup \'pandora:invalid\'' in caplog.text() def test_lookup_of_track_uri(config, playlist_item_mock): @@ -51,7 +51,7 @@ def test_lookup_of_missing_track(config, playlist_item_mock, caplog): assert len(results) == 0 - assert "Failed to lookup '%s' in uri translation map: %s", track_uri.uri in caplog.text() + assert 'Failed to lookup \'%s\' in uri translation map: %s', track_uri.uri in caplog.text() def test_browse_directory_uri(config): @@ -69,17 +69,17 @@ def test_browse_directory_uri(config): assert results[1].type == models.Ref.DIRECTORY assert results[1].name == 'Shuffle' assert results[1].uri == StationUri.from_station( - Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][2])).uri + Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][2])).uri assert results[2].type == models.Ref.DIRECTORY - assert results[2].name == conftest.MOCK_STATION_NAME + " 2" + assert results[2].name == conftest.MOCK_STATION_NAME + ' 2' assert results[2].uri == StationUri.from_station( - Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][0])).uri + Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][0])).uri assert results[3].type == models.Ref.DIRECTORY - assert results[3].name == conftest.MOCK_STATION_NAME + " 1" + assert results[3].name == conftest.MOCK_STATION_NAME + ' 1' assert results[3].uri == StationUri.from_station( - Station.from_json(backend.api, conftest.station_list_result_mock()["stations"][1])).uri + Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][1])).uri def test_browse_directory_sort_za(config): @@ -92,8 +92,8 @@ def test_browse_directory_sort_za(config): assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME assert results[1].name == 'Shuffle' - assert results[2].name == conftest.MOCK_STATION_NAME + " 1" - assert results[3].name == conftest.MOCK_STATION_NAME + " 2" + assert results[2].name == conftest.MOCK_STATION_NAME + ' 1' + assert results[3].name == conftest.MOCK_STATION_NAME + ' 2' def test_browse_directory_sort_date(config): @@ -106,8 +106,8 @@ def test_browse_directory_sort_date(config): assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME assert results[1].name == 'Shuffle' - assert results[2].name == conftest.MOCK_STATION_NAME + " 2" - assert results[3].name == conftest.MOCK_STATION_NAME + " 1" + assert results[2].name == conftest.MOCK_STATION_NAME + ' 2' + assert results[3].name == conftest.MOCK_STATION_NAME + ' 1' def test_browse_station_uri(config, station_mock): diff --git a/tests/test_playback.py b/tests/test_playback.py index 14e2ab9..d77f25c 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -45,7 +45,7 @@ def provider(audio_mock, config): return provider -@pytest.fixture(scope="session") +@pytest.fixture(scope='session') def client_mock(): client_mock = mock.Mock(spec=MopidyAPIClient) return client_mock @@ -112,13 +112,13 @@ def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): else: assert not provider.backend.prepare_next_track.called - assert "Maximum track skip limit (%s) exceeded, stopping...", \ + assert 'Maximum track skip limit (%s) exceeded, stopping...', \ PandoraPlaybackProvider.SKIP_LIMIT in caplog.text() def test_translate_uri_returns_audio_url(provider, playlist_item_mock): - test_uri = "pandora:track:test_station_id:test_token" + test_uri = 'pandora:track:test_station_id:test_token' provider.backend.library._pandora_history[test_uri] = playlist_item_mock assert provider.translate_uri(test_uri) == conftest.MOCK_TRACK_AUDIO_HIGH diff --git a/tests/test_uri.py b/tests/test_uri.py index 16a03cf..8b4c19d 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -10,7 +10,7 @@ def test_pandora_parse_mock_uri(): - uri = "pandora:mock" + uri = 'pandora:mock' obj = PandoraUri.parse(uri) @@ -20,7 +20,7 @@ def test_pandora_parse_mock_uri(): def test_pandora_parse_unicode_mock_uri(): - uri = PandoraUri("pandora:Ω≈ç√∫˜µ≤≥÷") + uri = PandoraUri('pandora:Ω≈ç√∫˜µ≤≥÷') obj = PandoraUri.parse(uri.uri) @@ -55,9 +55,9 @@ def test_station_uri_from_station(station_mock): station_uri = StationUri.from_station(station_mock) - assert station_uri.uri == "pandora:" + \ - station_uri.quote(conftest.MOCK_STATION_SCHEME) + ":" + \ - station_uri.quote(conftest.MOCK_STATION_ID) + ":" + \ + assert station_uri.uri == 'pandora:' + \ + station_uri.quote(conftest.MOCK_STATION_SCHEME) + ':' + \ + station_uri.quote(conftest.MOCK_STATION_ID) + ':' + \ station_uri.quote(conftest.MOCK_STATION_TOKEN) @@ -80,9 +80,9 @@ def test_track_uri_from_track(playlist_item_mock): track_uri = TrackUri.from_track(playlist_item_mock) - assert track_uri.uri == "pandora:" + \ - track_uri.quote(conftest.MOCK_TRACK_SCHEME) + ":" + \ - track_uri.quote(conftest.MOCK_STATION_ID) + ":" + \ + assert track_uri.uri == 'pandora:' + \ + track_uri.quote(conftest.MOCK_TRACK_SCHEME) + ':' + \ + track_uri.quote(conftest.MOCK_STATION_ID) + ':' + \ track_uri.quote(conftest.MOCK_TRACK_TOKEN) @@ -90,8 +90,8 @@ def test_track_uri_from_track_for_ads(ad_item_mock): track_uri = TrackUri.from_track(ad_item_mock) - assert track_uri.uri == "pandora:" + \ - track_uri.quote(conftest.MOCK_TRACK_SCHEME) + "::" + \ + assert track_uri.uri == 'pandora:' + \ + track_uri.quote(conftest.MOCK_TRACK_SCHEME) + '::' + \ track_uri.quote(TrackUri.ADVERTISEMENT_TOKEN) From 32bac226b7838c4e0ec192ade4c3c3d1f7d4aa2f Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 20 Dec 2015 18:03:09 +0200 Subject: [PATCH 088/311] WIP: idiomatic refactor. --- mopidy_pandora/backend.py | 1 + mopidy_pandora/client.py | 2 ++ mopidy_pandora/frontend.py | 11 +++++--- mopidy_pandora/library.py | 55 +++++++++++++++++++++++--------------- mopidy_pandora/playback.py | 4 ++- 5 files changed, 47 insertions(+), 26 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 6276445..cb15aa4 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -57,6 +57,7 @@ def on_start(self): logger.error('Error logging in to Pandora: {}'.format(encoding.locale_decode(e))) def prepare_next_track(self, auto_play=False): + # TODO: EAFP, replace with try-except block next_track = self.library.get_next_pandora_track() if next_track: self._trigger_expand_tracklist(next_track, auto_play) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 8ff733b..5e7a4c6 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -58,6 +58,7 @@ def get_station_list(self, force_refresh=False): except requests.exceptions.RequestException as e: logger.error('Error retrieving station list: {}'.format(encoding.locale_decode(e))) + # TODO: Rather raise exception than returning None return [] return self._station_list_cache.itervalues().next() @@ -80,6 +81,7 @@ def get_genre_stations(self, force_refresh=False): except requests.exceptions.RequestException as e: logger.error('Error retrieving genre stations: {}'.format(encoding.locale_decode(e))) + # TODO: Rather raise exception than returning None return [] return self._genre_stations_cache.itervalues().next() diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index cb833f8..b3a030d 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -5,7 +5,7 @@ import pykka from mopidy_pandora import listener, logger -from mopidy_pandora.uri import TrackUri +from mopidy_pandora.uri import PandoraUri def only_execute_for_pandora_uris(func): @@ -36,7 +36,8 @@ def check_pandora(self, *args, **kwargs): if is_pandora_uri(active_uri): return func(self, *args, **kwargs) else: - return None + # Not playing a Pandora track. Don't do anything. + pass return check_pandora @@ -105,6 +106,7 @@ class EventSupportPandoraFrontend(PandoraFrontend): def __init__(self, config, core): super(EventSupportPandoraFrontend, self).__init__(config, core) + # TODO: convert these to a settings dict. self.on_pause_resume_click = config.get('on_pause_resume_click', 'thumbs_up') self.on_pause_next_click = config.get('on_pause_next_click', 'thumbs_down') self.on_pause_previous_click = config.get('on_pause_previous_click', 'sleep') @@ -147,7 +149,8 @@ def _process_events(self, track_uri, time_position): event_target_uri = self._get_event_target_uri(track_uri, time_position) - if TrackUri.parse(event_target_uri).is_ad_uri: + # TODO: rather check for ad on type. + if PandoraUri.parse(event_target_uri).is_ad_uri: logger.info('Ignoring doubleclick event for advertisement') self.event_processed_event.set() return @@ -157,6 +160,7 @@ def _process_events(self, track_uri, time_position): self._trigger_call_event(event_target_uri, event) else: logger.error('Unexpected doubleclick event URI \'{}\''.format(track_uri)) + # TODO: raise exception? self.event_processed_event.set() def _get_event_target_uri(self, track_uri, time_position): @@ -182,6 +186,7 @@ def _get_event(self, track_uri, time_position): elif track_uri == self.next_tl_track.track.uri: return self.on_pause_next_click else: + # TODO: rather raise exception? return None def event_processed(self, track_uri): diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index d4e65e4..c53bcc3 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -27,6 +27,7 @@ def __init__(self, backend, sort_order): self._station = None self._station_iter = None + # TODO: rename, set max size self._pandora_history = OrderedDict() super(PandoraLibraryProvider, self).__init__(backend) @@ -39,6 +40,7 @@ def browse(self, uri): pandora_uri = PandoraUri.parse(uri) + # TODO: perform check on instance type instead of scheme. if pandora_uri.type == GenreUri.type: return self._browse_genre_stations(uri) @@ -49,10 +51,12 @@ def browse(self, uri): def lookup(self, uri): + # TODO: perform check on instance type instead of scheme if PandoraUri.parse(uri).type == TrackUri.type: pandora_track = self.lookup_pandora_track(uri) - + # TODO: EAFP, replace with try-except block if pandora_track is not None: + # TODO: perform check on instance type instead of scheme if pandora_track.is_ad: return[models.Track(name='Advertisement', uri=uri)] @@ -68,31 +72,33 @@ def lookup(self, uri): return [] def _move_shuffle_to_top(self, list): - - for station in list: + # TODO: investigate effect of 'includeShuffleInsteadOfQuickMix' rpc parameter + for i, station in enumerate(list[:]): if station.name == PandoraLibraryProvider.SHUFFLE_STATION_NAME: # Align with 'QuickMix' being renamed to 'Shuffle' in most other Pandora front-ends. station.name = 'Shuffle' - return list.insert(0, list.pop(list.index(station))) + list.insert(0, list.pop(i)) + break + + return list def _browse_stations(self): - stations = self.backend.api.get_station_list() + # Prefetch genre category list + rpc.run_async(self.backend.api.get_genre_stations)() - if any(stations): + stations = self.backend.api.get_station_list() + # TODO: EAFP, replace with try-except block + if stations: if self.sort_order == 'a-z': stations.sort(key=lambda x: x.name, reverse=False) - self._move_shuffle_to_top(stations) - station_directories = [] - for station in stations: + for station in self._move_shuffle_to_top(stations): station_directories.append( models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri)) station_directories.insert(0, self.genre_directory) - # Prefetch genre category list - rpc.run_async(self.backend.api.get_genre_stations)() return station_directories def _browse_tracks(self, uri): @@ -100,6 +106,7 @@ def _browse_tracks(self, uri): if self._station is None or (pandora_uri.station_id != self._station.id): + # TODO: perform check on instance type instead of scheme if pandora_uri.is_genre_station_uri: pandora_uri = self._create_station_for_genre(pandora_uri.token) @@ -131,23 +138,27 @@ def lookup_pandora_track(self, uri): return self._pandora_history[uri] except KeyError: logger.error('Failed to lookup \'{}\' in Pandora track history.'.format(uri)) + # TODO Raise exception, don't return none return None def get_next_pandora_track(self): try: pandora_track = self._station_iter.next() - track_uri = TrackUri.from_track(pandora_track) + # TODO: catch StopIteration exception as well. + except requests.exceptions.RequestException as e: + logger.error('Error retrieving next Pandora track: %s', encoding.locale_decode(e)) + # TODO: Rather raise exception than returning None + return None - if track_uri.is_ad_uri: - track_name = 'Advertisement' - else: - track_name = pandora_track.song_name + track_uri = TrackUri.from_track(pandora_track) - track = models.Ref.track(name=track_name, uri=track_uri.uri) + # TODO: perform check on instance type instead of scheme + if track_uri.is_ad_uri: + track_name = 'Advertisement' + else: + track_name = pandora_track.song_name - self._pandora_history[track.uri] = pandora_track - return track + track = models.Ref.track(name=track_name, uri=track_uri.uri) - except requests.exceptions.RequestException as e: - logger.error('Error retrieving next Pandora track: {}'.format(encoding.locale_decode(e))) - return None + self._pandora_history[track.uri] = pandora_track + return track diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 11b53c9..0ee6279 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -27,6 +27,7 @@ def __init__(self, audio, backend): # 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())).get() + # TODO: rename, check playable, raise not playable and skip limit exceptions def skip_track(self, track): logger.warning('Skipping unplayable track with URI \'{}\'.'.format(track.uri)) self._consecutive_track_skips += 1 @@ -63,6 +64,7 @@ def is_playable(self, track_uri): """ is_playable = False try: + # TODO: EAFP, replace with try-except block pandora_track = self.backend.library.lookup_pandora_track(track_uri) is_playable = pandora_track and pandora_track.audio_url and pandora_track.get_is_playable() @@ -92,7 +94,7 @@ def is_double_click(self): double_clicked = self._click_time > 0 and time.time() - self._click_time < self.double_click_interval if not double_clicked: - self._click_time = 0 + self.set_click_time(0) return double_clicked From d4e16c098c8fa4611d72e418b13525790c5bb33c Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 20 Dec 2015 20:26:44 +0200 Subject: [PATCH 089/311] WIP: idiomatic refactor. --- mopidy_pandora/library.py | 6 +-- mopidy_pandora/uri.py | 93 +++++++++++++++++++++++++-------------- tests/conftest.py | 12 ++--- tests/test_library.py | 2 +- tests/test_playback.py | 4 +- tests/test_uri.py | 24 +++++----- 6 files changed, 83 insertions(+), 58 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index c53bcc3..0571704 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -41,10 +41,10 @@ def browse(self, uri): pandora_uri = PandoraUri.parse(uri) # TODO: perform check on instance type instead of scheme. - if pandora_uri.type == GenreUri.type: + if pandora_uri.uri_type == GenreUri.uri_type: return self._browse_genre_stations(uri) - if pandora_uri.type == StationUri.type: + if pandora_uri.uri_type == StationUri.uri_type: return self._browse_tracks(uri) raise Exception('Unknown or unsupported URI type \'{}\''.format(uri)) @@ -52,7 +52,7 @@ def browse(self, uri): def lookup(self, uri): # TODO: perform check on instance type instead of scheme - if PandoraUri.parse(uri).type == TrackUri.type: + if PandoraUri.parse(uri).uri_type == TrackUri.uri_type: pandora_track = self.lookup_pandora_track(uri) # TODO: EAFP, replace with try-except block if pandora_track is not None: diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index d583c08..b8d7589 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -8,8 +8,8 @@ class _PandoraUriMeta(type): def __init__(cls, name, bases, clsdict): # noqa N805 super(_PandoraUriMeta, cls).__init__(name, bases, clsdict) - if hasattr(cls, 'type'): - cls.TYPES[cls.type] = cls + if hasattr(cls, 'uri_type'): + cls.TYPES[cls.uri_type] = cls class PandoraUri(object): @@ -17,12 +17,26 @@ class PandoraUri(object): TYPES = {} SCHEME = 'pandora' - def __init__(self, type=None): - if type is not None: - self.type = type + def __init__(self, uri_type=None): + self.uri_type = uri_type - def quote(self, value): + def __repr__(self): + return '{}:{uri_type}'.format(self.SCHEME, **self.__dict__) + @property + def encoded_attributes(self): + encoded_dict = dict(self.__dict__) + for k, v in encoded_dict.items(): + encoded_dict[k] = PandoraUri.encode(v) + + return encoded_dict + + @property + def uri(self): + return repr(self) + + @classmethod + def encode(cls, value): if value is None: value = '' @@ -30,13 +44,13 @@ def quote(self, value): value = str(value) return urllib.quote(value.encode('utf8')) - @property - def uri(self): - return '{}:{}'.format(self.SCHEME, self.quote(self.type)) + @classmethod + def decode(cls, value): + return urllib.unquote(value).decode('utf8') @classmethod def parse(cls, uri): - parts = [urllib.unquote(p).decode('utf8') for p in uri.split(':')] + parts = [cls.decode(p) for p in uri.split(':')] uri_cls = cls.TYPES.get(parts[1]) if uri_cls: return uri_cls(*parts[2:]) @@ -45,28 +59,44 @@ def parse(cls, uri): class GenreUri(PandoraUri): - type = 'genre' + uri_type = 'genre' def __init__(self, category_name): - super(GenreUri, self).__init__() + super(GenreUri, self).__init__(self.uri_type) self.category_name = category_name - @property - def uri(self): - return '{}:{}'.format( - super(GenreUri, self).uri, - self.quote(self.category_name), + def __repr__(self): + return '{}:{category_name}'.format( + super(GenreUri, self).__repr__(), + **self.encoded_attributes ) + @property + def category_name(self): + return PandoraUri.decode(self.category_name) + @category_name.setter + def category_name(self, value): + self.category_name = PandoraUri.encode(value) + + +# TODO: refactor genres and ads into their own types, then check for those types +# in the code rather than using is_* methods. class StationUri(PandoraUri): - type = 'station' + uri_type = 'station' + # TODO: remove station token if it is not used anywhere? def __init__(self, station_id, token): - super(StationUri, self).__init__() + 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 + ) + @property def is_genre_station_uri(self): return self.station_id.startswith('G') and self.station_id == self.token @@ -75,21 +105,16 @@ def is_genre_station_uri(self): def from_station(cls, station): return StationUri(station.id, station.token) - @property - def uri(self): - return '{}:{}:{}'.format( - super(StationUri, self).uri, - self.quote(self.station_id), - self.quote(self.token), - ) - -class TrackUri(StationUri): - type = 'track' +# TODO: switch parent to PandoraUri +class TrackUri(PandoraUri): + uri_type = 'track' ADVERTISEMENT_TOKEN = 'advertisement' def __init__(self, station_id, token): - super(TrackUri, self).__init__(station_id, token) + super(TrackUri, self).__init__(self.uri_type) + self.station_id = station_id + self.token = token @classmethod def from_track(cls, track): @@ -100,10 +125,10 @@ def from_track(cls, track): else: raise NotImplementedError('Unsupported playlist item type') - @property - def uri(self): - return '{}'.format( - super(TrackUri, self).uri, + def __repr__(self): + return '{}:{station_id}:{token}'.format( + super(TrackUri, self).__repr__(), + **self.encoded_attributes ) @property diff --git a/tests/conftest.py b/tests/conftest.py index 9a803fe..e08c2e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,24 +12,24 @@ from mopidy_pandora import backend -MOCK_STATION_SCHEME = 'station' +MOCK_STATION_TYPE = 'station' MOCK_STATION_NAME = 'Mock Station' MOCK_STATION_ID = '0000000000000000001' MOCK_STATION_TOKEN = '0000000000000000010' -MOCK_STATION_DETAIL_URL = ' http://mockup.com/station/detail_url?...' -MOCK_STATION_ART_URL = ' http://mockup.com/station/art_url?...' +MOCK_STATION_DETAIL_URL = 'http://mockup.com/station/detail_url?...' +MOCK_STATION_ART_URL = 'http://mockup.com/station/art_url?...' MOCK_STATION_LIST_CHECKSUM = 'aa00aa00aa00aa00aa00aa00aa00aa00' -MOCK_TRACK_SCHEME = 'track' +MOCK_TRACK_TYPE = 'track' MOCK_TRACK_NAME = 'Mock Track' MOCK_TRACK_TOKEN = '000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001' MOCK_TRACK_AD_TOKEN = '000000000000000000-none' MOCK_TRACK_AUDIO_HIGH = 'http://mockup.com/high_quality_audiofile.mp4?...' MOCK_TRACK_AUDIO_MED = 'http://mockup.com/medium_quality_audiofile.mp4?...' MOCK_TRACK_AUDIO_LOW = 'http://mockup.com/low_quality_audiofile.mp4?...' -MOCK_TRACK_DETAIL_URL = ' http://mockup.com/track/detail_url?...' -MOCK_TRACK_ART_URL = ' http://mockup.com/track/art_url?...' +MOCK_TRACK_DETAIL_URL = 'http://mockup.com/track/detail_url?...' +MOCK_TRACK_ART_URL = 'http://mockup.com/track/art_url?...' MOCK_TRACK_INDEX = '1' MOCK_DEFAULT_AUDIO_QUALITY = 'highQuality' diff --git a/tests/test_library.py b/tests/test_library.py index 8386be5..254227c 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -51,7 +51,7 @@ def test_lookup_of_missing_track(config, playlist_item_mock, caplog): assert len(results) == 0 - assert 'Failed to lookup \'%s\' in uri translation map: %s', track_uri.uri in caplog.text() + assert 'Failed to lookup \'{}\''.format(track_uri.uri) in caplog.text() def test_browse_directory_uri(config): diff --git a/tests/test_playback.py b/tests/test_playback.py index d77f25c..7322fa7 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -112,8 +112,8 @@ def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): else: assert not provider.backend.prepare_next_track.called - assert 'Maximum track skip limit (%s) exceeded, stopping...', \ - PandoraPlaybackProvider.SKIP_LIMIT in caplog.text() + assert 'Maximum track skip limit ({}) exceeded, stopping...'.format( + str(PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text()) def test_translate_uri_returns_audio_url(provider, playlist_item_mock): diff --git a/tests/test_uri.py b/tests/test_uri.py index 8b4c19d..1c2d5f9 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -20,7 +20,7 @@ def test_pandora_parse_mock_uri(): def test_pandora_parse_unicode_mock_uri(): - uri = PandoraUri('pandora:Ω≈ç√∫˜µ≤≥÷') + uri = TrackUri(conftest.MOCK_STATION_ID, 'Ω≈ç√∫:˜µ≤≥÷') obj = PandoraUri.parse(uri.uri) @@ -42,7 +42,7 @@ def test_pandora_parse_none_mock_uri(): uri = PandoraUri() - assert uri.quote(None) == '' + assert uri.encode(None) == '' def test_pandora_parse_invalid_mock_uri(): @@ -56,9 +56,9 @@ def test_station_uri_from_station(station_mock): station_uri = StationUri.from_station(station_mock) assert station_uri.uri == 'pandora:' + \ - station_uri.quote(conftest.MOCK_STATION_SCHEME) + ':' + \ - station_uri.quote(conftest.MOCK_STATION_ID) + ':' + \ - station_uri.quote(conftest.MOCK_STATION_TOKEN) + station_uri.encode(conftest.MOCK_STATION_TYPE) + ':' + \ + station_uri.encode(conftest.MOCK_STATION_ID) + ':' + \ + station_uri.encode(conftest.MOCK_STATION_TOKEN) def test_station_uri_parse(station_mock): @@ -69,7 +69,7 @@ def test_station_uri_parse(station_mock): assert isinstance(obj, StationUri) - assert obj.type == conftest.MOCK_STATION_SCHEME + assert obj.uri_type == conftest.MOCK_STATION_TYPE assert obj.station_id == conftest.MOCK_STATION_ID assert obj.token == conftest.MOCK_STATION_TOKEN @@ -81,9 +81,9 @@ def test_track_uri_from_track(playlist_item_mock): track_uri = TrackUri.from_track(playlist_item_mock) assert track_uri.uri == 'pandora:' + \ - track_uri.quote(conftest.MOCK_TRACK_SCHEME) + ':' + \ - track_uri.quote(conftest.MOCK_STATION_ID) + ':' + \ - track_uri.quote(conftest.MOCK_TRACK_TOKEN) + track_uri.encode(conftest.MOCK_TRACK_TYPE) + ':' + \ + track_uri.encode(conftest.MOCK_STATION_ID) + ':' + \ + track_uri.encode(conftest.MOCK_TRACK_TOKEN) def test_track_uri_from_track_for_ads(ad_item_mock): @@ -91,8 +91,8 @@ def test_track_uri_from_track_for_ads(ad_item_mock): track_uri = TrackUri.from_track(ad_item_mock) assert track_uri.uri == 'pandora:' + \ - track_uri.quote(conftest.MOCK_TRACK_SCHEME) + '::' + \ - track_uri.quote(TrackUri.ADVERTISEMENT_TOKEN) + track_uri.encode(conftest.MOCK_TRACK_TYPE) + '::' + \ + track_uri.encode(TrackUri.ADVERTISEMENT_TOKEN) def test_track_uri_parse(playlist_item_mock): @@ -103,7 +103,7 @@ def test_track_uri_parse(playlist_item_mock): assert isinstance(obj, TrackUri) - assert obj.type == conftest.MOCK_TRACK_SCHEME + assert obj.uri_type == conftest.MOCK_TRACK_TYPE assert obj.station_id == conftest.MOCK_STATION_ID assert obj.token == conftest.MOCK_TRACK_TOKEN From e52e45ba798604612c6173902574d746ebfb6ba1 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 20 Dec 2015 22:18:15 +0200 Subject: [PATCH 090/311] WIP: idiomatic refactor. --- mopidy_pandora/backend.py | 5 ++-- mopidy_pandora/client.py | 2 -- mopidy_pandora/frontend.py | 38 ++++++++++-------------- mopidy_pandora/library.py | 53 +++++++++++++++------------------ mopidy_pandora/uri.py | 60 +++++++++++++++++++++++++------------- tests/conftest.py | 2 ++ tests/test_library.py | 16 +++++----- tests/test_playback.py | 2 +- tests/test_uri.py | 19 ++++++------ 9 files changed, 102 insertions(+), 95 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index cb15aa4..4d43c52 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -57,16 +57,15 @@ def on_start(self): logger.error('Error logging in to Pandora: {}'.format(encoding.locale_decode(e))) def prepare_next_track(self, auto_play=False): - # TODO: EAFP, replace with try-except block next_track = self.library.get_next_pandora_track() if next_track: self._trigger_expand_tracklist(next_track, auto_play) def _trigger_expand_tracklist(self, track, auto_play): - listener.PandoraListener.send('expand_tracklist', track, auto_play) + listener.PandoraListener.send('expand_tracklist', track=track, auto_play=auto_play) def _trigger_event_processed(self, track_uri): - listener.PandoraListener.send('event_processed', track_uri) + listener.PandoraListener.send('event_processed', uri=track_uri) def call_event(self, track_uri, pandora_event): func = getattr(self, pandora_event) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 5e7a4c6..8ff733b 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -58,7 +58,6 @@ def get_station_list(self, force_refresh=False): except requests.exceptions.RequestException as e: logger.error('Error retrieving station list: {}'.format(encoding.locale_decode(e))) - # TODO: Rather raise exception than returning None return [] return self._station_list_cache.itervalues().next() @@ -81,7 +80,6 @@ def get_genre_stations(self, force_refresh=False): except requests.exceptions.RequestException as e: logger.error('Error retrieving genre stations: {}'.format(encoding.locale_decode(e))) - # TODO: Rather raise exception than returning None return [] return self._genre_stations_cache.itervalues().next() diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index b3a030d..7574296 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -5,7 +5,7 @@ import pykka from mopidy_pandora import listener, logger -from mopidy_pandora.uri import PandoraUri +from mopidy_pandora.uri import AdItemUri, PandoraUri def only_execute_for_pandora_uris(func): @@ -98,7 +98,7 @@ def expand_tracklist(self, track, auto_play): self.core.playback.play(tl_tracks.get()[0]) def _trigger_prepare_next_track(self, auto_play): - listener.PandoraListener.send('prepare_next_track', auto_play) + listener.PandoraListener.send('prepare_next_track', auto_play=auto_play) class EventSupportPandoraFrontend(PandoraFrontend): @@ -106,10 +106,11 @@ class EventSupportPandoraFrontend(PandoraFrontend): def __init__(self, config, core): super(EventSupportPandoraFrontend, self).__init__(config, core) - # TODO: convert these to a settings dict. - self.on_pause_resume_click = config.get('on_pause_resume_click', 'thumbs_up') - self.on_pause_next_click = config.get('on_pause_next_click', 'thumbs_down') - self.on_pause_previous_click = config.get('on_pause_previous_click', 'sleep') + self.settings = { + 'OPR_EVENT': config.get('on_pause_resume_click', 'thumbs_up'), + 'OPN_EVENT': config.get('on_pause_next_click', 'thumbs_down'), + 'OPP_EVENT': config.get('on_pause_previous_click', 'sleep') + } self.previous_tl_track = None self.current_tl_track = None @@ -149,19 +150,15 @@ def _process_events(self, track_uri, time_position): event_target_uri = self._get_event_target_uri(track_uri, time_position) - # TODO: rather check for ad on type. - if PandoraUri.parse(event_target_uri).is_ad_uri: + if type(PandoraUri.parse(event_target_uri)) is AdItemUri: logger.info('Ignoring doubleclick event for advertisement') self.event_processed_event.set() return event = self._get_event(track_uri, time_position) - if event_target_uri and event: - self._trigger_call_event(event_target_uri, event) - else: - logger.error('Unexpected doubleclick event URI \'{}\''.format(track_uri)) - # TODO: raise exception? - self.event_processed_event.set() + + assert event_target_uri and event + self._trigger_call_event(event_target_uri, event) def _get_event_target_uri(self, track_uri, time_position): if time_position == 0: @@ -176,18 +173,15 @@ def _get_event(self, track_uri, time_position): if track_uri == self.previous_tl_track.track.uri: if time_position > 0: # Resuming playback on the first track in the tracklist. - return self.on_pause_resume_click + return self.settings['OPR_EVENT'] else: - return self.on_pause_previous_click + return self.settings['OPP_EVENT'] elif track_uri == self.current_tl_track.track.uri: - return self.on_pause_resume_click + return self.settings['OPR_EVENT'] elif track_uri == self.next_tl_track.track.uri: - return self.on_pause_next_click - else: - # TODO: rather raise exception? - return None + return self.settings['OPN_EVENT'] def event_processed(self, track_uri): self.event_processed_event.set() @@ -202,4 +196,4 @@ def doubleclicked(self): self.core.playback.resume() def _trigger_call_event(self, track_uri, event): - listener.PandoraListener.send('call_event', track_uri, event) + listener.PandoraListener.send('call_event', track_uri=track_uri, pandora_event=event) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 0571704..ae4848a 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -11,7 +11,7 @@ import requests from mopidy_pandora import rpc -from mopidy_pandora.uri import GenreUri, logger, PandoraUri, StationUri, TrackUri # noqa I101 +from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, logger, PandoraUri, StationUri, TrackUri # noqa I101 class PandoraLibraryProvider(backend.LibraryProvider): @@ -27,8 +27,7 @@ def __init__(self, backend, sort_order): self._station = None self._station_iter = None - # TODO: rename, set max size - self._pandora_history = OrderedDict() + self._pandora_track_buffer = OrderedDict() super(PandoraLibraryProvider, self).__init__(backend) def browse(self, uri): @@ -40,24 +39,24 @@ def browse(self, uri): pandora_uri = PandoraUri.parse(uri) - # TODO: perform check on instance type instead of scheme. - if pandora_uri.uri_type == GenreUri.uri_type: + if type(pandora_uri) is GenreUri: return self._browse_genre_stations(uri) - if pandora_uri.uri_type == StationUri.uri_type: + if type(pandora_uri) is StationUri: return self._browse_tracks(uri) raise Exception('Unknown or unsupported URI type \'{}\''.format(uri)) def lookup(self, uri): - # TODO: perform check on instance type instead of scheme - if PandoraUri.parse(uri).uri_type == TrackUri.uri_type: - pandora_track = self.lookup_pandora_track(uri) - # TODO: EAFP, replace with try-except block - if pandora_track is not None: - # TODO: perform check on instance type instead of scheme - if pandora_track.is_ad: + if isinstance(PandoraUri.parse(uri), TrackUri): + try: + pandora_track = self.lookup_pandora_track(uri) + except KeyError: + logger.error('Failed to lookup \'{}\''.format(uri)) + return [] + else: + if type(pandora_track) is AdItemUri: return[models.Track(name='Advertisement', uri=uri)] else: @@ -68,8 +67,8 @@ def lookup(self, uri): uri=pandora_track.album_detail_url, images=[pandora_track.album_art_url]))] - logger.error('Failed to lookup \'{}\''.format(uri)) - return [] + else: + raise ValueError('Unexpected URI type: {}'.format(uri)) def _move_shuffle_to_top(self, list): # TODO: investigate effect of 'includeShuffleInsteadOfQuickMix' rpc parameter @@ -106,8 +105,7 @@ def _browse_tracks(self, uri): if self._station is None or (pandora_uri.station_id != self._station.id): - # TODO: perform check on instance type instead of scheme - if pandora_uri.is_genre_station_uri: + if type(pandora_uri) is GenreStationUri: pandora_uri = self._create_station_for_genre(pandora_uri.token) self._station = self.backend.api.get_station(pandora_uri.station_id) @@ -116,7 +114,7 @@ def _browse_tracks(self, uri): return [self.get_next_pandora_track()] def _create_station_for_genre(self, genre_token): - json_result = self.backend.api.create_station(search_token=genre_token) + json_result = self.backend.api.create_station(search_token=genre_token)['result'] new_station = Station.from_json(self.backend.api, json_result) # Invalidate the cache so that it is refreshed on the next request @@ -129,36 +127,33 @@ def _browse_genre_categories(self): 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=StationUri.from_station(station).uri) + return [models.Ref.directory(name=station.name, uri=GenreStationUri.from_station(station).uri) for station in self.backend.api.get_genre_stations() [PandoraUri.parse(uri).category_name]] def lookup_pandora_track(self, uri): - try: - return self._pandora_history[uri] - except KeyError: - logger.error('Failed to lookup \'{}\' in Pandora track history.'.format(uri)) - # TODO Raise exception, don't return none - return None + return self._pandora_track_buffer[uri] def get_next_pandora_track(self): try: pandora_track = self._station_iter.next() # TODO: catch StopIteration exception as well. except requests.exceptions.RequestException as e: - logger.error('Error retrieving next Pandora track: %s', encoding.locale_decode(e)) + logger.error('Error retrieving next Pandora track: {}'.format(encoding.locale_decode(e))) # TODO: Rather raise exception than returning None return None + except StopIteration: + logger.error('Pandora does not have any more tracks for station: {}'.format(self._station)) + return None track_uri = TrackUri.from_track(pandora_track) - # TODO: perform check on instance type instead of scheme - if track_uri.is_ad_uri: + if type(track_uri) is AdItemUri: track_name = 'Advertisement' else: track_name = pandora_track.song_name track = models.Ref.track(name=track_name, uri=track_uri.uri) - self._pandora_history[track.uri] = pandora_track + self._pandora_track_buffer[track.uri] = pandora_track return track diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index b8d7589..d79f138 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -80,12 +80,9 @@ def category_name(self, value): self.category_name = PandoraUri.encode(value) -# TODO: refactor genres and ads into their own types, then check for those types -# in the code rather than using is_* methods. class StationUri(PandoraUri): uri_type = 'station' - # TODO: remove station token if it is not used anywhere? def __init__(self, station_id, token): super(StationUri, self).__init__(self.uri_type) self.station_id = station_id @@ -97,40 +94,63 @@ def __repr__(self): **self.encoded_attributes ) - @property - def is_genre_station_uri(self): - return self.station_id.startswith('G') and self.station_id == self.token - @classmethod def from_station(cls, station): + if station.id.startswith('G') and station.id == station.token: + raise TypeError('Cannot instantiate StationUri from genre station: {}'.format(station)) return StationUri(station.id, station.token) -# TODO: switch parent to PandoraUri -class TrackUri(PandoraUri): - uri_type = 'track' - ADVERTISEMENT_TOKEN = 'advertisement' +class GenreStationUri(PandoraUri): + uri_type = 'genre_station' - def __init__(self, station_id, token): - super(TrackUri, self).__init__(self.uri_type) + def __init__(self, station_id): + super(GenreStationUri, self).__init__(self.uri_type) self.station_id = station_id - self.token = token + + @classmethod + def from_station(cls, station): + if not (station.id.startswith('G') and station.id == station.token): + raise TypeError('Not a genre station: {}'.format(station)) + return GenreStationUri(station.id) + + +class TrackUri(PandoraUri): + uri_type = 'track' @classmethod def from_track(cls, track): if isinstance(track, PlaylistItem): - return TrackUri(track.station_id, track.track_token) + return PlaylistItemUri(track.station_id, track.track_token) elif isinstance(track, AdItem): - return TrackUri(track.station_id, cls.ADVERTISEMENT_TOKEN) + return AdItemUri(track.station_id) else: raise NotImplementedError('Unsupported playlist item type') + +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(TrackUri, self).__repr__(), + super(PlaylistItemUri, self).__repr__(), **self.encoded_attributes ) - @property - def is_ad_uri(self): - return self.token == self.ADVERTISEMENT_TOKEN + +class AdItemUri(TrackUri): + uri_type = 'ad' + + def __init__(self, station_id): + super(AdItemUri, self).__init__(self.uri_type) + self.station_id = station_id + + def __repr__(self): + return '{}:{station_id}'.format( + super(AdItemUri, self).__repr__(), + **self.encoded_attributes + ) diff --git a/tests/conftest.py b/tests/conftest.py index e08c2e3..0082aca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,8 @@ MOCK_DEFAULT_AUDIO_QUALITY = 'highQuality' +MOCK_AD_TYPE = 'ad' + @pytest.fixture(scope='session') def config(): diff --git a/tests/test_library.py b/tests/test_library.py index 254227c..f8ff43c 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -9,29 +9,29 @@ from pandora import APIClient from pandora.models.pandora import Station +import pytest + from mopidy_pandora.client import MopidyAPIClient from mopidy_pandora.library import PandoraLibraryProvider -from mopidy_pandora.uri import PandoraUri, StationUri, TrackUri +from mopidy_pandora.uri import PandoraUri, PlaylistItemUri, StationUri, TrackUri from tests.conftest import get_station_list_mock def test_lookup_of_invalid_uri(config, caplog): - backend = conftest.get_backend(config) - - results = backend.library.lookup('pandora:invalid') + with pytest.raises(ValueError): + backend = conftest.get_backend(config) - assert len(results) == 0 - assert 'Failed to lookup \'pandora:invalid\'' in caplog.text() + backend.library.lookup('pandora:invalid') def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) - track_uri = TrackUri.from_track(playlist_item_mock) - backend.library._pandora_history[track_uri.uri] = playlist_item_mock + track_uri = PlaylistItemUri.from_track(playlist_item_mock) + backend.library._pandora_track_buffer[track_uri.uri] = playlist_item_mock results = backend.library.lookup(track_uri.uri) diff --git a/tests/test_playback.py b/tests/test_playback.py index 7322fa7..2e68812 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -119,7 +119,7 @@ def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): def test_translate_uri_returns_audio_url(provider, playlist_item_mock): test_uri = 'pandora:track:test_station_id:test_token' - provider.backend.library._pandora_history[test_uri] = playlist_item_mock + provider.backend.library._pandora_track_buffer[test_uri] = playlist_item_mock assert provider.translate_uri(test_uri) == conftest.MOCK_TRACK_AUDIO_HIGH diff --git a/tests/test_uri.py b/tests/test_uri.py index 1c2d5f9..95035eb 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -5,7 +5,7 @@ import pytest -from mopidy_pandora.uri import PandoraUri, StationUri, TrackUri +from mopidy_pandora.uri import AdItemUri, PandoraUri, PlaylistItemUri, StationUri, TrackUri def test_pandora_parse_mock_uri(): @@ -20,7 +20,7 @@ def test_pandora_parse_mock_uri(): def test_pandora_parse_unicode_mock_uri(): - uri = TrackUri(conftest.MOCK_STATION_ID, 'Ω≈ç√∫:˜µ≤≥÷') + uri = PlaylistItemUri(conftest.MOCK_STATION_ID, 'Ω≈ç√∫:˜µ≤≥÷') obj = PandoraUri.parse(uri.uri) @@ -65,7 +65,7 @@ def test_station_uri_parse(station_mock): station_uri = StationUri.from_station(station_mock) - obj = StationUri.parse(station_uri.uri) + obj = PandoraUri.parse(station_uri.uri) assert isinstance(obj, StationUri) @@ -91,15 +91,14 @@ def test_track_uri_from_track_for_ads(ad_item_mock): track_uri = TrackUri.from_track(ad_item_mock) assert track_uri.uri == 'pandora:' + \ - track_uri.encode(conftest.MOCK_TRACK_TYPE) + '::' + \ - track_uri.encode(TrackUri.ADVERTISEMENT_TOKEN) + track_uri.encode(conftest.MOCK_AD_TYPE) + ':' def test_track_uri_parse(playlist_item_mock): track_uri = TrackUri.from_track(playlist_item_mock) - obj = TrackUri.parse(track_uri.uri) + obj = PandoraUri.parse(track_uri.uri) assert isinstance(obj, TrackUri) @@ -113,11 +112,11 @@ def test_track_uri_parse(playlist_item_mock): def test_track_uri_is_ad(playlist_item_mock, ad_item_mock): track_uri = TrackUri.from_track(ad_item_mock) - obj = TrackUri.parse(track_uri.uri) + obj = PandoraUri.parse(track_uri.uri) - assert obj.is_ad_uri + assert type(obj) is AdItemUri track_uri = TrackUri.from_track(playlist_item_mock) - obj = TrackUri.parse(track_uri.uri) + obj = PandoraUri.parse(track_uri.uri) - assert not obj.is_ad_uri + assert type(obj) is not AdItemUri From c5b0941b77a25775f4d4ba54e1a5462b6bd09ffb Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 20 Dec 2015 22:45:31 +0200 Subject: [PATCH 091/311] WIP: idiomatic refactor. --- mopidy_pandora/library.py | 9 +++++---- mopidy_pandora/uri.py | 22 ++++++++++------------ tests/test_client.py | 2 +- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index ae4848a..fff697a 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -42,21 +42,22 @@ def browse(self, uri): if type(pandora_uri) is GenreUri: return self._browse_genre_stations(uri) - if type(pandora_uri) is StationUri: + if type(pandora_uri) is StationUri or type(pandora_uri) is GenreStationUri: return self._browse_tracks(uri) raise Exception('Unknown or unsupported URI type \'{}\''.format(uri)) def lookup(self, uri): - if isinstance(PandoraUri.parse(uri), TrackUri): + pandora_uri = PandoraUri.parse(uri) + if isinstance(pandora_uri, TrackUri): try: pandora_track = self.lookup_pandora_track(uri) except KeyError: logger.error('Failed to lookup \'{}\''.format(uri)) return [] else: - if type(pandora_track) is AdItemUri: + if type(pandora_uri) is AdItemUri: return[models.Track(name='Advertisement', uri=uri)] else: @@ -114,7 +115,7 @@ def _browse_tracks(self, uri): return [self.get_next_pandora_track()] def _create_station_for_genre(self, genre_token): - json_result = self.backend.api.create_station(search_token=genre_token)['result'] + json_result = self.backend.api.create_station(search_token=genre_token) new_station = Station.from_json(self.backend.api, json_result) # Invalidate the cache so that it is refreshed on the next request diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index d79f138..78629db 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -63,7 +63,7 @@ class GenreUri(PandoraUri): def __init__(self, category_name): super(GenreUri, self).__init__(self.uri_type) - self.category_name = category_name + self.category_name = self.encode(category_name) def __repr__(self): return '{}:{category_name}'.format( @@ -71,14 +71,6 @@ def __repr__(self): **self.encoded_attributes ) - @property - def category_name(self): - return PandoraUri.decode(self.category_name) - - @category_name.setter - def category_name(self, value): - self.category_name = PandoraUri.encode(value) - class StationUri(PandoraUri): uri_type = 'station' @@ -104,15 +96,21 @@ def from_station(cls, station): class GenreStationUri(PandoraUri): uri_type = 'genre_station' - def __init__(self, station_id): + def __init__(self, token): super(GenreStationUri, self).__init__(self.uri_type) - self.station_id = station_id + self.token = token + + def __repr__(self): + return '{}:{token}'.format( + super(GenreStationUri, self).__repr__(), + **self.encoded_attributes + ) @classmethod def from_station(cls, station): if not (station.id.startswith('G') and station.id == station.token): raise TypeError('Not a genre station: {}'.format(station)) - return GenreStationUri(station.id) + return GenreStationUri(station.token) class TrackUri(PandoraUri): diff --git a/tests/test_client.py b/tests/test_client.py index 35666d5..9123f59 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -130,7 +130,7 @@ def test_get_invalid_station(config): def test_create_genre_station_invalidates_cache(config): backend = get_backend(config) - backend.api.create_station = mock.PropertyMock(return_value=conftest.station_result_mock()) + backend.api.create_station = mock.PropertyMock(return_value=conftest.station_result_mock()['result']) backend.api._station_list_cache[time.time()] = 'test_value' assert backend.api._station_list_cache.currsize == 1 From d6a419c7ff8c93ff236b4b1c89b4c39978c47674 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 20 Dec 2015 22:51:45 +0200 Subject: [PATCH 092/311] WIP: idiomatic refactor. --- mopidy_pandora/uri.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 78629db..42b8719 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -93,24 +93,14 @@ def from_station(cls, station): return StationUri(station.id, station.token) -class GenreStationUri(PandoraUri): +class GenreStationUri(StationUri): uri_type = 'genre_station' - def __init__(self, token): - super(GenreStationUri, self).__init__(self.uri_type) - self.token = token - - def __repr__(self): - return '{}:{token}'.format( - super(GenreStationUri, self).__repr__(), - **self.encoded_attributes - ) - @classmethod def from_station(cls, station): if not (station.id.startswith('G') and station.id == station.token): raise TypeError('Not a genre station: {}'.format(station)) - return GenreStationUri(station.token) + return GenreStationUri(station.id, station.token) class TrackUri(PandoraUri): From 577588480d9e372b56e87bba7d03134be228fe5c Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 21 Dec 2015 00:42:10 +0200 Subject: [PATCH 093/311] Replace 'shuffle' with 'quickmix' stations. Display ad images. --- README.rst | 1 + mopidy_pandora/library.py | 28 ++++++++++++++++++---------- setup.py | 2 +- tests/test_library.py | 6 +++--- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/README.rst b/README.rst index ce79779..41f407a 100644 --- a/README.rst +++ b/README.rst @@ -141,6 +141,7 @@ v0.2.0 (UNRELEASED) ``cache_time_to_live`` can be used to specify when cache items should expire and be refreshed (in seconds). - Better support for users with free Pandora accounts: now plays advertisements which should prevent free accounts from being locked. +- Sort stations to move 'QuickMix' to top of the list. - **Event support does not work at the moment**, so it has been disabled by default until `#1352 `_ is fixed. Alternatively you can patch Mopidy 1.1.1 with `#1356 `_ if you want to keep using events in the interim. diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index fff697a..1be521c 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -17,7 +17,6 @@ class PandoraLibraryProvider(backend.LibraryProvider): ROOT_DIR_NAME = 'Pandora' GENRE_DIR_NAME = 'Browse Genres' - SHUFFLE_STATION_NAME = 'QuickMix' 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) @@ -58,7 +57,16 @@ def lookup(self, uri): return [] else: if type(pandora_uri) is AdItemUri: - return[models.Track(name='Advertisement', uri=uri)] + if not pandora_track.company_name or len(pandora_track.company_name) == 0: + pandora_track.company_name = 'Unknown' + return[models.Track(name='Advertisement', + uri=uri, + artists=[models.Artist(name=pandora_track.company_name)], + album=models.Album(name=pandora_track.company_name, + uri=pandora_track.click_through_url, + images=[pandora_track.image_url]) + ) + ] else: return[models.Track(name=pandora_track.song_name, uri=uri, length=pandora_track.track_length * 1000, @@ -66,17 +74,17 @@ def lookup(self, uri): artists=[models.Artist(name=pandora_track.artist_name)], album=models.Album(name=pandora_track.album_name, uri=pandora_track.album_detail_url, - images=[pandora_track.album_art_url]))] + images=[pandora_track.album_art_url]) + ) + ] else: raise ValueError('Unexpected URI type: {}'.format(uri)) - def _move_shuffle_to_top(self, list): + def _move_quickmix_to_top(self, list): # TODO: investigate effect of 'includeShuffleInsteadOfQuickMix' rpc parameter for i, station in enumerate(list[:]): - if station.name == PandoraLibraryProvider.SHUFFLE_STATION_NAME: - # Align with 'QuickMix' being renamed to 'Shuffle' in most other Pandora front-ends. - station.name = 'Shuffle' + if station.name == 'QuickMix': list.insert(0, list.pop(i)) break @@ -93,7 +101,7 @@ def _browse_stations(self): stations.sort(key=lambda x: x.name, reverse=False) station_directories = [] - for station in self._move_shuffle_to_top(stations): + for station in self._move_quickmix_to_top(stations): station_directories.append( models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri)) @@ -138,13 +146,13 @@ def lookup_pandora_track(self, uri): def get_next_pandora_track(self): try: pandora_track = self._station_iter.next() - # TODO: catch StopIteration exception as well. except requests.exceptions.RequestException as e: logger.error('Error retrieving next Pandora track: {}'.format(encoding.locale_decode(e))) # TODO: Rather raise exception than returning None return None except StopIteration: - logger.error('Pandora does not have any more tracks for station: {}'.format(self._station)) + # Workaround for https://github.com/mcrute/pydora/issues/36 + logger.error('Failed to retrieve next track for station \'{}\''.format(self._station.name)) return None track_uri = TrackUri.from_track(pandora_track) diff --git a/setup.py b/setup.py index 8f41437..c14ef0c 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ def run_tests(self): 'cachetools >= 1.0.0', 'Mopidy >= 1.1.1', 'Pykka >= 1.1', - 'pydora >= 1.6.1', + 'pydora >= 1.6.3', 'requests >= 2.5.0' ], tests_require=['tox'], diff --git a/tests/test_library.py b/tests/test_library.py index f8ff43c..1b027ea 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -67,7 +67,7 @@ def test_browse_directory_uri(config): assert results[0].uri == PandoraUri('genres').uri assert results[1].type == models.Ref.DIRECTORY - assert results[1].name == 'Shuffle' + assert results[1].name == 'QuickMix' assert results[1].uri == StationUri.from_station( Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][2])).uri @@ -91,7 +91,7 @@ def test_browse_directory_sort_za(config): results = backend.library.browse(backend.library.root_directory.uri) assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME - assert results[1].name == 'Shuffle' + assert results[1].name == 'QuickMix' assert results[2].name == conftest.MOCK_STATION_NAME + ' 1' assert results[3].name == conftest.MOCK_STATION_NAME + ' 2' @@ -105,7 +105,7 @@ def test_browse_directory_sort_date(config): results = backend.library.browse(backend.library.root_directory.uri) assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME - assert results[1].name == 'Shuffle' + assert results[1].name == 'QuickMix' assert results[2].name == conftest.MOCK_STATION_NAME + ' 2' assert results[3].name == conftest.MOCK_STATION_NAME + ' 1' From b0852b5ea34772655d74d795611f1c4f0fe3c069 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 21 Dec 2015 15:12:24 +0200 Subject: [PATCH 094/311] Update comments. --- README.rst | 2 +- mopidy_pandora/library.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 41f407a..d12f91b 100644 --- a/README.rst +++ b/README.rst @@ -136,7 +136,7 @@ v0.2.0 (UNRELEASED) - Add support for browsing genre stations. Note that clicking on a genre station will automatically add that station to your profile. At the moment, there is no way to remove stations from within Mopidy-Pandora. - Force Mopidy to stop when skip limit is exceeded (workaround for `#1221 `_). -- Scrobbling tracks to Last.fm should now work +- Scrobbling tracks to Last.fm should now work. - Implemented caching to speed up startup and browsing of the list of stations. Configuration parameter ``cache_time_to_live`` can be used to specify when cache items should expire and be refreshed (in seconds). - Better support for users with free Pandora accounts: now plays advertisements which should prevent free accounts diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 1be521c..959dddb 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -151,7 +151,7 @@ def get_next_pandora_track(self): # TODO: Rather raise exception than returning None return None except StopIteration: - # Workaround for https://github.com/mcrute/pydora/issues/36 + # TODO: workaround for https://github.com/mcrute/pydora/issues/36 logger.error('Failed to retrieve next track for station \'{}\''.format(self._station.name)) return None From 93389935c65f2350a97cf7cd6e95b4376cf8765a Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 21 Dec 2015 16:20:01 +0200 Subject: [PATCH 095/311] Final refactoring of remaining TODOs. --- mopidy_pandora/library.py | 11 +++--- mopidy_pandora/playback.py | 73 ++++++++++++++++++++++---------------- tests/test_playback.py | 4 +-- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 959dddb..e2b1cce 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -82,7 +82,6 @@ def lookup(self, uri): raise ValueError('Unexpected URI type: {}'.format(uri)) def _move_quickmix_to_top(self, list): - # TODO: investigate effect of 'includeShuffleInsteadOfQuickMix' rpc parameter for i, station in enumerate(list[:]): if station.name == 'QuickMix': list.insert(0, list.pop(i)) @@ -94,16 +93,16 @@ def _browse_stations(self): # Prefetch genre category list rpc.run_async(self.backend.api.get_genre_stations)() + station_directories = [] + stations = self.backend.api.get_station_list() - # TODO: EAFP, replace with try-except block if stations: if self.sort_order == 'a-z': stations.sort(key=lambda x: x.name, reverse=False) - station_directories = [] - for station in self._move_quickmix_to_top(stations): - station_directories.append( - models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri)) + for station in self._move_quickmix_to_top(stations): + station_directories.append( + models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri)) station_directories.insert(0, self.genre_directory) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 0ee6279..11341f5 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -27,14 +27,33 @@ def __init__(self, audio, backend): # 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())).get() - # TODO: rename, check playable, raise not playable and skip limit exceptions - def skip_track(self, track): - logger.warning('Skipping unplayable track with URI \'{}\'.'.format(track.uri)) - self._consecutive_track_skips += 1 - if self._consecutive_track_skips >= self.SKIP_LIMIT: - logger.error('Maximum track skip limit ({}) exceeded.'.format(self.SKIP_LIMIT)) - else: - self.backend.prepare_next_track(True) + def change_pandora_track(self, track): + """ Attempt to retrieve and check the Pandora playlist item from the buffer. + + A track is playable if it has been stored in the buffer, has a URL, and the Pandora URL can be accessed. + + :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 not (pandora_track and pandora_track.audio_url and pandora_track.get_is_playable()): + # Track is not playable. + self._consecutive_track_skips += 1 + + if self._consecutive_track_skips >= self.SKIP_LIMIT: + raise MaxSkipLimitExceeded('Maximum track skip limit ({}) exceeded.'.format(self.SKIP_LIMIT)) + + # Prepare the next track to be checked on the next call of 'change_track'. + self.backend.prepare_next_track(True) + raise Unplayable('Track with URI \'{}\' is not playable'.format(track.uri)) + + except requests.exceptions.RequestException as e: + raise Unplayable('Error checking if track is playable: {}'.format(encoding.locale_decode(e))) + + # Success, reset track skip counter. + self._consecutive_track_skips = 0 + return super(PandoraPlaybackProvider, self).change_track(track) def prepare_change(self): self.backend.prepare_next_track(False) @@ -43,36 +62,20 @@ def prepare_change(self): def change_track(self, track): if track.uri is None: logger.warning('No URI for track \'{}\'. Track cannot be played.'.format(track)) - self.skip_track(track) return False - if self.is_playable(track.uri): - self._consecutive_track_skips = 0 - return super(PandoraPlaybackProvider, self).change_track(track) - else: - self.skip_track(track) + try: + return self.change_pandora_track(track) + except KeyError: + logger.error('Error changing track: failed to lookup \'{}\''.format(track.uri)) + return False + except (MaxSkipLimitExceeded, Unplayable) as e: + logger.error('Error changing track: ({})'.format(encoding.locale_decode(e))) return False def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url - def is_playable(self, track_uri): - """ A track is playable if it can be retrieved, has a URL, and the Pandora URL can be accessed. - - :param track_uri: uri of the track to be checked. - :return: True if the track is playable, False otherwise. - """ - is_playable = False - try: - # TODO: EAFP, replace with try-except block - pandora_track = self.backend.library.lookup_pandora_track(track_uri) - is_playable = pandora_track and pandora_track.audio_url and pandora_track.get_is_playable() - - except requests.exceptions.RequestException as e: - logger.error('Error checking if track is playable: {}'.format(encoding.locale_decode(e))) - finally: - return is_playable - class EventSupportPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): @@ -120,3 +123,11 @@ def pause(self): def _trigger_doubleclicked(self): listener.PandoraListener.send('doubleclicked') + + +class MaxSkipLimitExceeded(Exception): + pass + + +class Unplayable(Exception): + pass diff --git a/tests/test_playback.py b/tests/test_playback.py index 2e68812..d5531d2 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -58,9 +58,9 @@ def test_is_a_playback_provider(provider): def test_change_track_skips_if_no_track_uri(provider): track = models.Track(uri=None) - provider.skip_track = mock.PropertyMock() + provider.change_pandora_track = mock.PropertyMock() assert provider.change_track(track) is False - assert provider.skip_track.called + assert not provider.change_pandora_track.called def test_pause_starts_double_click_timer(provider): From 06e75a1b06f5c4d898817293e7ebb6c70cf1da3b Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 21 Dec 2015 16:41:12 +0200 Subject: [PATCH 096/311] Minor optimisations. --- mopidy_pandora/backend.py | 2 +- mopidy_pandora/library.py | 7 ++++--- mopidy_pandora/playback.py | 3 ++- mopidy_pandora/uri.py | 4 ++-- tests/test_playback.py | 4 ++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 4d43c52..0ec95eb 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -65,7 +65,7 @@ def _trigger_expand_tracklist(self, track, auto_play): listener.PandoraListener.send('expand_tracklist', track=track, auto_play=auto_play) def _trigger_event_processed(self, track_uri): - listener.PandoraListener.send('event_processed', uri=track_uri) + listener.PandoraListener.send('event_processed', track_uri=track_uri) def call_event(self, track_uri, pandora_event): func = getattr(self, pandora_event) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index e2b1cce..3508265 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -59,6 +59,7 @@ def lookup(self, uri): if type(pandora_uri) is AdItemUri: if not pandora_track.company_name or len(pandora_track.company_name) == 0: pandora_track.company_name = 'Unknown' + return[models.Track(name='Advertisement', uri=uri, artists=[models.Artist(name=pandora_track.company_name)], @@ -147,11 +148,11 @@ def get_next_pandora_track(self): pandora_track = self._station_iter.next() except requests.exceptions.RequestException as e: logger.error('Error retrieving next Pandora track: {}'.format(encoding.locale_decode(e))) - # TODO: Rather raise exception than returning None return None - except StopIteration: + except StopIteration as e: # TODO: workaround for https://github.com/mcrute/pydora/issues/36 - logger.error('Failed to retrieve next track for station \'{}\''.format(self._station.name)) + logger.error('Failed to retrieve next track for station \'{}\', ({})'.format( + self._station.name, encoding.locale_decode(e))) return None track_uri = TrackUri.from_track(pandora_track) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 11341f5..07b3368 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -42,7 +42,8 @@ def change_pandora_track(self, track): self._consecutive_track_skips += 1 if self._consecutive_track_skips >= self.SKIP_LIMIT: - raise MaxSkipLimitExceeded('Maximum track skip limit ({}) exceeded.'.format(self.SKIP_LIMIT)) + raise MaxSkipLimitExceeded(('Maximum track skip limit ({:d}) exceeded, stopping...' + .format(self.SKIP_LIMIT))) # Prepare the next track to be checked on the next call of 'change_track'. self.backend.prepare_next_track(True) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 42b8719..a9191ce 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -25,8 +25,8 @@ def __repr__(self): @property def encoded_attributes(self): - encoded_dict = dict(self.__dict__) - for k, v in encoded_dict.items(): + encoded_dict = {} + for k, v in self.__dict__.items(): encoded_dict[k] = PandoraUri.encode(v) return encoded_dict diff --git a/tests/test_playback.py b/tests/test_playback.py index d5531d2..12f8d67 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -112,8 +112,8 @@ def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): else: assert not provider.backend.prepare_next_track.called - assert 'Maximum track skip limit ({}) exceeded, stopping...'.format( - str(PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text()) + assert 'Maximum track skip limit ({:d}) exceeded, stopping...'.format( + PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() def test_translate_uri_returns_audio_url(provider, playlist_item_mock): From f846cfa55c3e398b03d415709a1421aa2c2a1495 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 21 Dec 2015 17:32:38 +0200 Subject: [PATCH 097/311] Fix identification of QuickMix station. Add visual indication of which stations are QuickMix stations. --- mopidy_pandora/__init__.py | 2 +- mopidy_pandora/library.py | 15 ++++++++++++--- tests/conftest.py | 4 +++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index f9221bb..5b0622c 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals import logging import os diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 3508265..de83226 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -82,12 +82,20 @@ def lookup(self, uri): else: raise ValueError('Unexpected URI type: {}'.format(uri)) - def _move_quickmix_to_top(self, list): + def _format_station_list(self, list): + # Find QuickMix stations and move QuickMix to top for i, station in enumerate(list[:]): - if station.name == 'QuickMix': + if station.is_quickmix: + quickmix_stations = station.quickmix_stations + station.name += ' (stations marked with *)' list.insert(0, list.pop(i)) break + # Mark QuickMix stations + for station in list: + if station.id in quickmix_stations: + station.name += '*' + return list def _browse_stations(self): @@ -101,7 +109,8 @@ def _browse_stations(self): if self.sort_order == 'a-z': stations.sort(key=lambda x: x.name, reverse=False) - for station in self._move_quickmix_to_top(stations): + for station in self._format_station_list(stations): + station_directories.append( models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri)) diff --git a/tests/conftest.py b/tests/conftest.py index 0082aca..047ed5b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -133,6 +133,7 @@ def playlist_result_mock(): 'protocol': 'http' } }, + 'trackLength': 0, 'songName': MOCK_TRACK_NAME, 'songDetailUrl': MOCK_TRACK_DETAIL_URL, 'stationId': MOCK_STATION_ID, @@ -165,6 +166,7 @@ def playlist_result_mock(): 'protocol': 'http' } }, + 'trackLength': 0, 'songName': None, 'songDetailUrl': None, 'stationId': None, @@ -250,7 +252,7 @@ def station_list_result_mock(): 'stationName': MOCK_STATION_NAME + ' 1'}, {'stationId': MOCK_STATION_ID.replace('1', '3'), 'stationToken': MOCK_STATION_TOKEN.replace('0010', '1000'), - 'stationName': 'QuickMix'}, + 'stationName': 'QuickMix', 'isQuickMix': True}, ], 'checksum': MOCK_STATION_LIST_CHECKSUM}, } From 0d7076aab8316eb890ef0d277f555b0db7bdd8c4 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 00:27:05 +0200 Subject: [PATCH 098/311] Fix handling of category names. --- mopidy_pandora/library.py | 5 ++--- mopidy_pandora/uri.py | 2 +- tests/test_client.py | 2 +- tests/test_library.py | 6 +++--- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index de83226..038972d 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -82,7 +82,7 @@ def lookup(self, uri): else: raise ValueError('Unexpected URI type: {}'.format(uri)) - def _format_station_list(self, list): + def _formatted_station_list(self, list): # Find QuickMix stations and move QuickMix to top for i, station in enumerate(list[:]): if station.is_quickmix: @@ -109,8 +109,7 @@ def _browse_stations(self): if self.sort_order == 'a-z': stations.sort(key=lambda x: x.name, reverse=False) - for station in self._format_station_list(stations): - + for station in self._formatted_station_list(stations): station_directories.append( models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri)) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index a9191ce..d5181c6 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -63,7 +63,7 @@ class GenreUri(PandoraUri): def __init__(self, category_name): super(GenreUri, self).__init__(self.uri_type) - self.category_name = self.encode(category_name) + self.category_name = category_name def __repr__(self): return '{}:{category_name}'.format( diff --git a/tests/test_client.py b/tests/test_client.py index 9123f59..e9ad481 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -25,7 +25,7 @@ def test_get_station_list(config): assert len(station_list) == len(conftest.station_list_result_mock()['stations']) assert station_list[0].name == conftest.MOCK_STATION_NAME + ' 2' assert station_list[1].name == conftest.MOCK_STATION_NAME + ' 1' - assert station_list[2].name == 'QuickMix' + assert station_list[2].name.startswith('QuickMix') def test_get_station_list_populates_cache(config): diff --git a/tests/test_library.py b/tests/test_library.py index 1b027ea..cbee20c 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -67,7 +67,7 @@ def test_browse_directory_uri(config): assert results[0].uri == PandoraUri('genres').uri assert results[1].type == models.Ref.DIRECTORY - assert results[1].name == 'QuickMix' + assert results[1].name.startswith('QuickMix') assert results[1].uri == StationUri.from_station( Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][2])).uri @@ -91,7 +91,7 @@ def test_browse_directory_sort_za(config): results = backend.library.browse(backend.library.root_directory.uri) assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME - assert results[1].name == 'QuickMix' + assert results[1].name.startswith('QuickMix') assert results[2].name == conftest.MOCK_STATION_NAME + ' 1' assert results[3].name == conftest.MOCK_STATION_NAME + ' 2' @@ -105,7 +105,7 @@ def test_browse_directory_sort_date(config): results = backend.library.browse(backend.library.root_directory.uri) assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME - assert results[1].name == 'QuickMix' + assert results[1].name.startswith('QuickMix') assert results[2].name == conftest.MOCK_STATION_NAME + ' 2' assert results[3].name == conftest.MOCK_STATION_NAME + ' 1' From 31f9887a4d18542331167bfb1efa4a92e0273e71 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 00:46:30 +0200 Subject: [PATCH 099/311] Ensure that QuickMix stations are only marked once. --- mopidy_pandora/library.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 038972d..4366979 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -87,14 +87,16 @@ def _formatted_station_list(self, list): for i, station in enumerate(list[:]): if station.is_quickmix: quickmix_stations = station.quickmix_stations - station.name += ' (stations marked with *)' + 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: - station.name += '*' + if not station.name.endswith('*'): + station.name += '*' return list From a8bee6b7590e47f341076e0e425c6e346765d094 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 13:25:55 +0200 Subject: [PATCH 100/311] Refactor code. --- README.rst | 4 ++-- mopidy_pandora/backend.py | 8 ++++---- mopidy_pandora/frontend.py | 2 +- mopidy_pandora/library.py | 6 +++--- mopidy_pandora/listener.py | 2 +- mopidy_pandora/playback.py | 13 +++++++------ mopidy_pandora/uri.py | 6 ++++-- tests/test_library.py | 2 +- tests/test_uri.py | 17 ++++------------- 9 files changed, 27 insertions(+), 33 deletions(-) diff --git a/README.rst b/README.rst index d12f91b..16bdb72 100644 --- a/README.rst +++ b/README.rst @@ -79,8 +79,8 @@ The following configuration values are available: alphabetical order. - ``pandora/auto_setup``: If Mopidy-Pandora should automatically configure the Mopidy player for best compatibility - with the Pandora radio stream. Defaults to ``true`` and turns on ``consume`` mode and ``repeat``, ``random``, and - ``single`` off. + with the Pandora radio stream. Defaults to ``true`` and turns off ``consume``, ``repeat``, ``random``, and ``single`` + modes. - ``pandora/cache_time_to_live``: specifies how long station and genre lists should be cached for between refreshes, which greatly speeds up browsing the library. Setting this to ``0`` will disable caching entirely and ensure that the diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 0ec95eb..22a276c 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -59,10 +59,10 @@ def on_start(self): def prepare_next_track(self, auto_play=False): next_track = self.library.get_next_pandora_track() if next_track: - self._trigger_expand_tracklist(next_track, auto_play) + self._trigger_prepare_tracklist(next_track, auto_play) - def _trigger_expand_tracklist(self, track, auto_play): - listener.PandoraListener.send('expand_tracklist', track=track, auto_play=auto_play) + def _trigger_prepare_tracklist(self, track, auto_play): + listener.PandoraListener.send('prepare_tracklist', track=track, auto_play=auto_play) def _trigger_event_processed(self, track_uri): listener.PandoraListener.send('event_processed', track_uri=track_uri) @@ -70,7 +70,7 @@ def _trigger_event_processed(self, track_uri): def call_event(self, track_uri, pandora_event): func = getattr(self, pandora_event) try: - logger.info('Triggering event \'{}\' for song: \'{}\''.format(pandora_event, + logger.info("Triggering event '{}' for song: '{}'".format(pandora_event, self.library.lookup_pandora_track(track_uri).song_name)) func(track_uri) self._trigger_event_processed(track_uri) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 7574296..3d33abe 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -92,7 +92,7 @@ def track_playback_paused(self, tl_track, time_position): def track_playback_resumed(self, tl_track, time_position): self.set_options() - def expand_tracklist(self, track, auto_play): + def prepare_tracklist(self, track, auto_play): tl_tracks = self.core.tracklist.add(uris=[track.uri]) if auto_play: self.core.playback.play(tl_tracks.get()[0]) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 4366979..b8b202b 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -44,7 +44,7 @@ def browse(self, uri): if type(pandora_uri) is StationUri or type(pandora_uri) is GenreStationUri: return self._browse_tracks(uri) - raise Exception('Unknown or unsupported URI type \'{}\''.format(uri)) + raise Exception("Unknown or unsupported URI type '{}'".format(uri)) def lookup(self, uri): @@ -53,7 +53,7 @@ def lookup(self, uri): try: pandora_track = self.lookup_pandora_track(uri) except KeyError: - logger.error('Failed to lookup \'{}\''.format(uri)) + logger.error("Failed to lookup '{}'".format(uri)) return [] else: if type(pandora_uri) is AdItemUri: @@ -161,7 +161,7 @@ def get_next_pandora_track(self): return None except StopIteration as e: # TODO: workaround for https://github.com/mcrute/pydora/issues/36 - logger.error('Failed to retrieve next track for station \'{}\', ({})'.format( + logger.error("Failed to retrieve next track for station '{}', ({})".format( self._station.name, encoding.locale_decode(e))) return None diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 9338372..03f8fc6 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -12,7 +12,7 @@ def send(event, **kwargs): def prepare_next_track(self, auto_play): pass - def expand_tracklist(self, track, auto_play): + def prepare_tracklist(self, track, auto_play): pass def doubleclicked(self): diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 07b3368..64c82f8 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -28,9 +28,10 @@ def __init__(self, audio, backend): # self.audio.set_uri(self.translate_uri(self.get_next_track())).get() def change_pandora_track(self, track): - """ Attempt to retrieve and check the Pandora playlist item from the buffer. + """ 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 Pandora URL can be accessed. + 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. @@ -47,7 +48,7 @@ def change_pandora_track(self, track): # Prepare the next track to be checked on the next call of 'change_track'. self.backend.prepare_next_track(True) - raise Unplayable('Track with URI \'{}\' is not playable'.format(track.uri)) + raise Unplayable("Track with URI '{}' is not playable".format(track.uri)) except requests.exceptions.RequestException as e: raise Unplayable('Error checking if track is playable: {}'.format(encoding.locale_decode(e))) @@ -62,13 +63,13 @@ def prepare_change(self): def change_track(self, track): if track.uri is None: - logger.warning('No URI for track \'{}\'. Track cannot be played.'.format(track)) + logger.warning("No URI for track '{}'. Track cannot be played.".format(track)) return False try: return self.change_pandora_track(track) except KeyError: - logger.error('Error changing track: failed to lookup \'{}\''.format(track.uri)) + logger.error("Error changing track: failed to lookup '{}'".format(track.uri)) return False except (MaxSkipLimitExceeded, Unplayable) as e: logger.error('Error changing track: ({})'.format(encoding.locale_decode(e))) @@ -106,7 +107,6 @@ def change_track(self, track): if self.is_double_click(): self._trigger_doubleclicked() - self.set_click_time(0) return super(EventSupportPlaybackProvider, self).change_track(track) @@ -123,6 +123,7 @@ def pause(self): return super(EventSupportPlaybackProvider, self).pause() def _trigger_doubleclicked(self): + self.set_click_time(0) listener.PandoraListener.send('doubleclicked') diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index d5181c6..0bf74bd 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -51,11 +51,13 @@ def decode(cls, value): @classmethod def parse(cls, uri): parts = [cls.decode(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: - return cls(*parts[1:]) + raise NotImplementedError("Unsupported Pandora URI type '{}'".format(uri)) class GenreUri(PandoraUri): @@ -113,7 +115,7 @@ def from_track(cls, track): elif isinstance(track, AdItem): return AdItemUri(track.station_id) else: - raise NotImplementedError('Unsupported playlist item type') + raise NotImplementedError("Unsupported playlist item type '{}'".format(track)) class PlaylistItemUri(TrackUri): diff --git a/tests/test_library.py b/tests/test_library.py index cbee20c..e54f574 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -20,7 +20,7 @@ def test_lookup_of_invalid_uri(config, caplog): - with pytest.raises(ValueError): + with pytest.raises(NotImplementedError): backend = conftest.get_backend(config) backend.library.lookup('pandora:invalid') diff --git a/tests/test_uri.py b/tests/test_uri.py index 95035eb..0daf11e 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -10,11 +10,12 @@ def test_pandora_parse_mock_uri(): - uri = 'pandora:mock' + uri = 'pandora:station:mock_id:mock_token' obj = PandoraUri.parse(uri) assert isinstance(obj, PandoraUri) + assert type(obj) is StationUri assert obj.uri == uri @@ -28,16 +29,6 @@ def test_pandora_parse_unicode_mock_uri(): assert obj.uri == uri.uri -def test_pandora_parse_int_mock_uri(): - - uri = PandoraUri(1) - - obj = PandoraUri.parse(uri.uri) - - assert isinstance(obj, PandoraUri) - assert obj.uri == uri.uri - - def test_pandora_parse_none_mock_uri(): uri = PandoraUri() @@ -46,9 +37,9 @@ def test_pandora_parse_none_mock_uri(): def test_pandora_parse_invalid_mock_uri(): - with pytest.raises(IndexError): + with pytest.raises(NotImplementedError): - PandoraUri().parse('invalid') + PandoraUri().parse('pandora:invalid') def test_station_uri_from_station(station_mock): From 3cf31cbc846b4be2e8bbe54ab38b39fe0378eb94 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 13:29:27 +0200 Subject: [PATCH 101/311] Refactor code. --- mopidy_pandora/client.py | 8 ++++---- mopidy_pandora/frontend.py | 29 +++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 8ff733b..191a07c 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -51,8 +51,8 @@ def __init__(self, cache_ttl, transport, partner_user, partner_password, device, def get_station_list(self, force_refresh=False): try: - if self._station_list_cache.currsize == 0 or \ - (force_refresh and self._station_list_cache.itervalues().next().has_changed()): + if (self._station_list_cache.currsize == 0 or + (force_refresh and self._station_list_cache.itervalues().next().has_changed())): self._station_list_cache[time.time()] = super(MopidyAPIClient, self).get_station_list() @@ -73,8 +73,8 @@ def get_station(self, station_id): def get_genre_stations(self, force_refresh=False): try: - if self._genre_stations_cache.currsize == 0 or \ - (force_refresh and self._genre_stations_cache.itervalues().next().has_changed()): + if (self._genre_stations_cache.currsize == 0 or + (force_refresh and self._genre_stations_cache.itervalues().next().has_changed())): self._genre_stations_cache[time.time()] = super(MopidyAPIClient, self).get_genre_stations() diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 3d33abe..1d04865 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -23,20 +23,33 @@ 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: active_uri should contain the uri to be checkrd, all other kwargs + :param kwargs: active_uri should contain the uri to be checked, all other kwargs will be passed to the target function :return: the return value of the function if it was run or 'None' otherwise. """ - active_uri = kwargs.pop('active_uri', None) - if active_uri is None: - active_track = self.core.playback.get_current_tl_track().get() - if active_track: - active_uri = active_track.track.uri - if is_pandora_uri(active_uri): + try: + # Ask Mopidy for the currently playing track + active_uri = self.core.playback.get_current_tl_track().get().track.uri + except AttributeError: + # None available, try kwargs + try: + active_uri = kwargs['tl_track'].track.uri + except KeyError: + # Not there either, see if it was passed as the first argument + try: + if type(args[0]) is TlTrack: + active_uri = args[0].track.uri + except IndexError: + # Giving up + return None + + try: + PandoraUri.parse(active_uri) return func(self, *args, **kwargs) - else: + except (NotImplementedError) as e: # Not playing a Pandora track. Don't do anything. + logger.info('Not a Pandora track: ({}, {})'.format(func.func_name, encoding.locale_decode(e))) pass return check_pandora From 6a9dd2a5fe7ef1f5f5191f10090bc54e0a05cb52 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 13:58:33 +0200 Subject: [PATCH 102/311] Refactor code. --- mopidy_pandora/frontend.py | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 1d04865..49141ba 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -70,15 +70,14 @@ def __init__(self, config, core): self.setup_required = True self.core = core - @only_execute_for_pandora_uris def set_options(self): # Setup playback to mirror behaviour of official Pandora front-ends. if self.auto_setup and self.setup_required: assert isinstance(self.core.tracklist, object) if self.core.tracklist.get_repeat().get() is True: self.core.tracklist.set_repeat(False) - if self.core.tracklist.get_consume().get() is False: - self.core.tracklist.set_consume(True) + if self.core.tracklist.get_consume().get() is True: + self.core.tracklist.set_consume(False) if self.core.tracklist.get_random().get() is True: self.core.tracklist.set_random(False) if self.core.tracklist.get_single().get() is True: @@ -125,9 +124,8 @@ def __init__(self, config, core): 'OPP_EVENT': config.get('on_pause_previous_click', 'sleep') } - self.previous_tl_track = None - self.current_tl_track = None - self.next_tl_track = None + self.current_track_uri = None + self.next_track_uri = None self.event_processed_event = threading.Event() self.event_processed_event.set() @@ -142,9 +140,10 @@ def tracklist_changed(self): # Delay 'tracklist_changed' events until all events have been processed. self.tracklist_changed_event.clear() else: - self.current_tl_track = self.core.playback.get_current_tl_track().get() - self.previous_tl_track = self.core.tracklist.previous_track(self.current_tl_track).get() - self.next_tl_track = self.core.tracklist.next_track(self.current_tl_track).get() + current_tl_track = self.core.playback.get_current_tl_track().get() + self.current_track_uri = current_tl_track.track.uri + # self.previous_tl_track = self.core.tracklist.previous_track(self.current_track_uri).get() + self.next_track_uri = self.core.tracklist.next_track(current_tl_track).get().track.uri self.tracklist_changed_event.set() @@ -183,18 +182,16 @@ def _get_event_target_uri(self, track_uri, time_position): return track_uri def _get_event(self, track_uri, time_position): - if track_uri == self.previous_tl_track.track.uri: - if time_position > 0: - # Resuming playback on the first track in the tracklist. - return self.settings['OPR_EVENT'] - else: - return self.settings['OPP_EVENT'] - - elif track_uri == self.current_tl_track.track.uri: + if track_uri == self.current_track_uri: return self.settings['OPR_EVENT'] - elif track_uri == self.next_tl_track.track.uri: + elif track_uri == self.next_track_uri: return self.settings['OPN_EVENT'] + elif time_position > 0: + # Resuming playback on the first track in the tracklist. + return self.settings['OPR_EVENT'] + else: + return self.settings['OPP_EVENT'] def event_processed(self, track_uri): self.event_processed_event.set() @@ -205,7 +202,7 @@ def event_processed(self, track_uri): def doubleclicked(self): self.event_processed_event.clear() - # Resume playback of the next track so long... + # Resume playback... self.core.playback.resume() def _trigger_call_event(self, track_uri, event): From 9c0594c13f70ac5cac01a6a8d3e74befca84f444 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 14:38:53 +0200 Subject: [PATCH 103/311] Refactor code. --- mopidy_pandora/frontend.py | 33 ++++++++++----------------------- mopidy_pandora/library.py | 6 +++--- mopidy_pandora/listener.py | 3 +++ mopidy_pandora/playback.py | 13 ++++++++----- 4 files changed, 24 insertions(+), 31 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 49141ba..4543d52 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -27,29 +27,11 @@ def check_pandora(self, *args, **kwargs): will be passed to the target function :return: the return value of the function if it was run or 'None' otherwise. """ - - try: - # Ask Mopidy for the currently playing track - active_uri = self.core.playback.get_current_tl_track().get().track.uri - except AttributeError: - # None available, try kwargs - try: - active_uri = kwargs['tl_track'].track.uri - except KeyError: - # Not there either, see if it was passed as the first argument - try: - if type(args[0]) is TlTrack: - active_uri = args[0].track.uri - except IndexError: - # Giving up - return None - try: - PandoraUri.parse(active_uri) + PandoraUri.parse(self.core.playback.get_current_tl_track().get().track.uri) return func(self, *args, **kwargs) - except (NotImplementedError) as e: + except (AttributeError, NotImplementedError): # Not playing a Pandora track. Don't do anything. - logger.info('Not a Pandora track: ({}, {})'.format(func.func_name, encoding.locale_decode(e))) pass return check_pandora @@ -104,10 +86,16 @@ def track_playback_paused(self, tl_track, time_position): def track_playback_resumed(self, tl_track, time_position): self.set_options() + def track_changed(self, track): + if self.core.tracklist.get_length().get() < 2 or track != self.core.tracklist.get_tl_tracks().get()[0].track: + self._trigger_prepare_next_track(auto_play=False) + def prepare_tracklist(self, track, auto_play): - tl_tracks = self.core.tracklist.add(uris=[track.uri]) + self.core.tracklist.add(uris=[track.uri]) + if self.core.tracklist.get_length().get() > 2: + self.core.tracklist.remove({'tlid': [self.core.tracklist.get_tl_tracks().get()[0].tlid]}) if auto_play: - self.core.playback.play(tl_tracks.get()[0]) + self.core.playback.play(self.core.tracklist.get_tl_tracks().get()[0]) def _trigger_prepare_next_track(self, auto_play): listener.PandoraListener.send('prepare_next_track', auto_play=auto_play) @@ -142,7 +130,6 @@ def tracklist_changed(self): else: current_tl_track = self.core.playback.get_current_tl_track().get() self.current_track_uri = current_tl_track.track.uri - # self.previous_tl_track = self.core.tracklist.previous_track(self.current_track_uri).get() self.next_track_uri = self.core.tracklist.next_track(current_tl_track).get().track.uri self.tracklist_changed_event.set() diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index b8b202b..2b8ee79 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -159,10 +159,10 @@ def get_next_pandora_track(self): except requests.exceptions.RequestException as e: logger.error('Error retrieving next Pandora track: {}'.format(encoding.locale_decode(e))) return None - except StopIteration as e: + except StopIteration: # TODO: workaround for https://github.com/mcrute/pydora/issues/36 - logger.error("Failed to retrieve next track for station '{}', ({})".format( - self._station.name, encoding.locale_decode(e))) + logger.error("Failed to retrieve next track for station '{}' from Pandora server".format( + self._station.name)) return None track_uri = TrackUri.from_track(pandora_track) diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 03f8fc6..aa8fe8c 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -23,3 +23,6 @@ def call_event(self, track_uri, pandora_event): def event_processed(self, track_uri): pass + + def track_changed(self, track): + pass diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 64c82f8..c197d22 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -36,6 +36,7 @@ def change_pandora_track(self, track): :param track: the track to retrieve and check the Pandora playlist item for. :return: True if the track is playable, False otherwise. """ + self._trigger_track_changed(track) try: pandora_track = self.backend.library.lookup_pandora_track(track.uri) if not (pandora_track and pandora_track.audio_url and pandora_track.get_is_playable()): @@ -46,8 +47,6 @@ def change_pandora_track(self, track): raise MaxSkipLimitExceeded(('Maximum track skip limit ({:d}) exceeded, stopping...' .format(self.SKIP_LIMIT))) - # Prepare the next track to be checked on the next call of 'change_track'. - self.backend.prepare_next_track(True) raise Unplayable("Track with URI '{}' is not playable".format(track.uri)) except requests.exceptions.RequestException as e: @@ -55,10 +54,9 @@ def change_pandora_track(self, track): # Success, reset track skip counter. self._consecutive_track_skips = 0 - return super(PandoraPlaybackProvider, self).change_track(track) def prepare_change(self): - self.backend.prepare_next_track(False) + # self.backend.prepare_next_track(False) super(PandoraPlaybackProvider, self).prepare_change() def change_track(self, track): @@ -67,7 +65,9 @@ def change_track(self, track): return False try: - return self.change_pandora_track(track) + self.change_pandora_track(track) + return super(PandoraPlaybackProvider, self).change_track(track) + except KeyError: logger.error("Error changing track: failed to lookup '{}'".format(track.uri)) return False @@ -78,6 +78,9 @@ def change_track(self, track): def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url + def _trigger_track_changed(self, track): + listener.PandoraListener.send('track_changed', track=track) + class EventSupportPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): From 5b20942e482fb26a3e6564adcf9833a8eb0a63ba Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 16:20:01 +0200 Subject: [PATCH 104/311] Refactor code. --- README.rst | 4 ++-- mopidy_pandora/frontend.py | 26 +++++++++++++++----------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index 16bdb72..575c776 100644 --- a/README.rst +++ b/README.rst @@ -79,8 +79,8 @@ The following configuration values are available: alphabetical order. - ``pandora/auto_setup``: If Mopidy-Pandora should automatically configure the Mopidy player for best compatibility - with the Pandora radio stream. Defaults to ``true`` and turns off ``consume``, ``repeat``, ``random``, and ``single`` - modes. + with the Pandora radio stream. Defaults to ``true`` and turns ``repeat`` on and ``consume``, ``random``, and + ``single`` modes off. - ``pandora/cache_time_to_live``: specifies how long station and genre lists should be cached for between refreshes, which greatly speeds up browsing the library. Setting this to ``0`` will disable caching entirely and ensure that the diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 4543d52..81886b1 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -1,6 +1,7 @@ import threading from mopidy import core +from mopidy.internal import encoding import pykka @@ -56,8 +57,8 @@ def set_options(self): # Setup playback to mirror behaviour of official Pandora front-ends. if self.auto_setup and self.setup_required: assert isinstance(self.core.tracklist, object) - if self.core.tracklist.get_repeat().get() is True: - self.core.tracklist.set_repeat(False) + if self.core.tracklist.get_repeat().get() is False: + self.core.tracklist.set_repeat(True) if self.core.tracklist.get_consume().get() is True: self.core.tracklist.set_consume(False) if self.core.tracklist.get_random().get() is True: @@ -148,16 +149,18 @@ def _process_events(self, track_uri, time_position): return event_target_uri = self._get_event_target_uri(track_uri, time_position) + assert event_target_uri if type(PandoraUri.parse(event_target_uri)) is AdItemUri: logger.info('Ignoring doubleclick event for advertisement') self.event_processed_event.set() return - event = self._get_event(track_uri, time_position) - - assert event_target_uri and event - self._trigger_call_event(event_target_uri, event) + try: + self._trigger_call_event(event_target_uri, self._get_event(track_uri, time_position)) + except ValueError as e: + logger.error(("Error processing event for URI '{}': ({})" + .format(event_target_uri, encoding.locale_decode(e)))) def _get_event_target_uri(self, track_uri, time_position): if time_position == 0: @@ -170,15 +173,16 @@ def _get_event_target_uri(self, track_uri, time_position): def _get_event(self, track_uri, time_position): if track_uri == self.current_track_uri: - return self.settings['OPR_EVENT'] + if time_position > 0: + # Resuming playback on the first track in the tracklist. + return self.settings['OPR_EVENT'] + else: + return self.settings['OPP_EVENT'] elif track_uri == self.next_track_uri: return self.settings['OPN_EVENT'] - elif time_position > 0: - # Resuming playback on the first track in the tracklist. - return self.settings['OPR_EVENT'] else: - return self.settings['OPP_EVENT'] + raise ValueError('Unexpected event URI: {}'.format(track_uri)) def event_processed(self, track_uri): self.event_processed_event.set() From 0b8ca4a45fccc1c3e9e23724f816001ee21afe36 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 16:57:10 +0200 Subject: [PATCH 105/311] Refactor code. --- mopidy_pandora/playback.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index c197d22..27275ae 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -11,7 +11,7 @@ class PandoraPlaybackProvider(backend.PlaybackProvider): - SKIP_LIMIT = 3 + SKIP_LIMIT = 5 def __init__(self, audio, backend): super(PandoraPlaybackProvider, self).__init__(audio, backend) @@ -36,7 +36,6 @@ def change_pandora_track(self, track): :param track: the track to retrieve and check the Pandora playlist item for. :return: True if the track is playable, False otherwise. """ - self._trigger_track_changed(track) try: pandora_track = self.backend.library.lookup_pandora_track(track.uri) if not (pandora_track and pandora_track.audio_url and pandora_track.get_is_playable()): @@ -54,10 +53,7 @@ def change_pandora_track(self, track): # Success, reset track skip counter. self._consecutive_track_skips = 0 - - def prepare_change(self): - # self.backend.prepare_next_track(False) - super(PandoraPlaybackProvider, self).prepare_change() + self._trigger_track_changed(track) def change_track(self, track): if track.uri is None: @@ -71,7 +67,11 @@ def change_track(self, track): except KeyError: logger.error("Error changing track: failed to lookup '{}'".format(track.uri)) return False - except (MaxSkipLimitExceeded, Unplayable) as e: + except Unplayable as e: + logger.error('Error changing track: ({})'.format(encoding.locale_decode(e))) + self.backend.prepare_next_track(auto_play=True) + return False + except MaxSkipLimitExceeded as e: logger.error('Error changing track: ({})'.format(encoding.locale_decode(e))) return False From afd19232a9c5bb3844805e17b654bcfbcbd6969a Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 17:14:15 +0200 Subject: [PATCH 106/311] Refactor code. --- README.rst | 4 ++-- mopidy_pandora/rpc.py | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 575c776..5a4fbfc 100644 --- a/README.rst +++ b/README.rst @@ -27,12 +27,12 @@ Dependencies - A free, ad supported, Pandora account or a paid Pandora One subscription (provides ad-free playback and higher quality 192 Kbps audio stream). -- ``pydora`` >= 1.6. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. +- ``pydora`` >= 1.6.3. 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. The music server that Mopidy-Pandora extends. +- ``Mopidy`` >= 1.1.2. The music server that Mopidy-Pandora extends. Installation diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/rpc.py index f9d0e87..2cee08e 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/rpc.py @@ -2,8 +2,6 @@ import requests -thread_timeout = 2 - def run_async(func): """ Function decorator intended to make "func" run in a separate thread (asynchronously). From b30f78206379dc1da4623c2c73b3e586ee50c116 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 17:17:05 +0200 Subject: [PATCH 107/311] Refactor code. --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 5a4fbfc..0b06a99 100644 --- a/README.rst +++ b/README.rst @@ -141,7 +141,8 @@ v0.2.0 (UNRELEASED) ``cache_time_to_live`` can be used to specify when cache items should expire and be refreshed (in seconds). - Better support for users with free Pandora accounts: now plays advertisements which should prevent free accounts from being locked. -- Sort stations to move 'QuickMix' to top of the list. +- Sort station list to move 'QuickMix' to top of the list. Stations that will be played as part of QuickMix are marked + with an asterisk (*). - **Event support does not work at the moment**, so it has been disabled by default until `#1352 `_ is fixed. Alternatively you can patch Mopidy 1.1.1 with `#1356 `_ if you want to keep using events in the interim. From 50fa407353c8e4b118645a1be25cea0d8c0b7e62 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 17:18:51 +0200 Subject: [PATCH 108/311] Update README. --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 0b06a99..5ae105b 100644 --- a/README.rst +++ b/README.rst @@ -134,14 +134,14 @@ v0.2.0 (UNRELEASED) during playback (e.g. song and artist names, album covers, track length, bitrate etc.). - Simulate dynamic tracklist (workaround for `#2 `_) - Add support for browsing genre stations. Note that clicking on a genre station will automatically add that station to - your profile. At the moment, there is no way to remove stations from within Mopidy-Pandora. + your profile. At the moment there is no way to remove stations from within Mopidy-Pandora. - Force Mopidy to stop when skip limit is exceeded (workaround for `#1221 `_). - Scrobbling tracks to Last.fm should now work. - Implemented caching to speed up startup and browsing of the list of stations. Configuration parameter ``cache_time_to_live`` can be used to specify when cache items should expire and be refreshed (in seconds). - Better support for users with free Pandora accounts: now plays advertisements which should prevent free accounts from being locked. -- Sort station list to move 'QuickMix' to top of the list. Stations that will be played as part of QuickMix are marked +- Move 'QuickMix' to the top of the station list. Stations that will be played as part of QuickMix are marked with an asterisk (*). - **Event support does not work at the moment**, so it has been disabled by default until `#1352 `_ is fixed. Alternatively you can patch Mopidy 1.1.1 with From 6d25dbb0ca39da864e7a726d31dfd38926e56568 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 17:25:44 +0200 Subject: [PATCH 109/311] Update README. --- README.rst | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index 5ae105b..6e25d83 100644 --- a/README.rst +++ b/README.rst @@ -130,21 +130,20 @@ Changelog v0.2.0 (UNRELEASED) ------------------- -- Major overhaul that completely changes how tracks are handled. Finally allows all track information to be accessible - during playback (e.g. song and artist names, album covers, track length, bitrate etc.). +- Now displays all of the correct track information during playback (e.g. song and artist names, album covers, track + length, bitrate etc.). - Simulate dynamic tracklist (workaround for `#2 `_) - Add support for browsing genre stations. Note that clicking on a genre station will automatically add that station to your profile. At the moment there is no way to remove stations from within Mopidy-Pandora. +- Move 'QuickMix' to the top of the station list. Stations that will be played as part of QuickMix are marked with an + asterisk (*). +- Scrobbling tracks to Last.fm is now supported. +- Station lists are now cached which speeds up startup and browsing of the list of stations dramatically. Configuration + parameter ``cache_time_to_live`` can be used to specify when cache items should expire and be refreshed (in seconds). - Force Mopidy to stop when skip limit is exceeded (workaround for `#1221 `_). -- Scrobbling tracks to Last.fm should now work. -- Implemented caching to speed up startup and browsing of the list of stations. Configuration parameter - ``cache_time_to_live`` can be used to specify when cache items should expire and be refreshed (in seconds). -- Better support for users with free Pandora accounts: now plays advertisements which should prevent free accounts - from being locked. -- Move 'QuickMix' to the top of the station list. Stations that will be played as part of QuickMix are marked - with an asterisk (*). +- Now plays advertisements which should prevent non-Pandora One accounts from being locked after extended use. - **Event support does not work at the moment**, so it has been disabled by default until - `#1352 `_ is fixed. Alternatively you can patch Mopidy 1.1.1 with + `#1352 `_ is fixed. Alternatively, you can patch Mopidy 1.1.1 with `#1356 `_ if you want to keep using events in the interim. v0.1.7 (Oct 31, 2015) From 8b0002092907f1aa4e96db5874c00e3a8f008231 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 17:30:35 +0200 Subject: [PATCH 110/311] Update README. --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 6e25d83..27121dd 100644 --- a/README.rst +++ b/README.rst @@ -142,9 +142,9 @@ v0.2.0 (UNRELEASED) parameter ``cache_time_to_live`` can be used to specify when cache items should expire and be refreshed (in seconds). - Force Mopidy to stop when skip limit is exceeded (workaround for `#1221 `_). - Now plays advertisements which should prevent non-Pandora One accounts from being locked after extended use. -- **Event support does not work at the moment**, so it has been disabled by default until - `#1352 `_ is fixed. Alternatively, you can patch Mopidy 1.1.1 with - `#1356 `_ if you want to keep using events in the interim. +- **Event support does not work at the moment** (see `#35 `_), + so it has been disabled by default. In the interim, you can patch Mopidy 1.1.1 with `#1356 `_ + if you want to keep using events until the fix is available. v0.1.7 (Oct 31, 2015) --------------------- From 34cf9e3cf27f793cdb09e637f85e8752628f0652 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 22 Dec 2015 17:34:38 +0200 Subject: [PATCH 111/311] Update README. --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 27121dd..295f3bb 100644 --- a/README.rst +++ b/README.rst @@ -59,6 +59,7 @@ the details of the JSON API endpoint that you would like to use:: 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``. From 256a30ad721b6f2549958457bacb9875f11b03f1 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 23 Dec 2015 14:30:49 +0200 Subject: [PATCH 112/311] Align with Mopidy extension development guidelines. --- README.rst | 37 +++++++++++++++++++------------------ mopidy_pandora/__init__.py | 9 +++------ mopidy_pandora/backend.py | 19 +++++++++++-------- mopidy_pandora/client.py | 23 +++++++++++++++++------ mopidy_pandora/ext.conf | 1 - mopidy_pandora/frontend.py | 23 ++++++++++++++++++----- mopidy_pandora/library.py | 7 ++++++- mopidy_pandora/playback.py | 6 ++++-- mopidy_pandora/uri.py | 2 ++ tests/__init__.py | 1 + tests/conftest.py | 2 ++ tests/test_extension.py | 2 +- 12 files changed, 84 insertions(+), 48 deletions(-) diff --git a/README.rst b/README.rst index 295f3bb..d20bdd3 100644 --- a/README.rst +++ b/README.rst @@ -18,14 +18,14 @@ Mopidy-Pandora :target: https://coveralls.io/r/rectalogic/mopidy-pandora?branch=develop :alt: Test coverage -Mopidy extension for Pandora Internet Radio (http://www.pandora.com). +`Mopidy `_ extension for playing music from `Pandora Radio `_. Dependencies ============ -- A free, ad supported, Pandora account or a paid Pandora One subscription (provides ad-free playback and higher quality - 192 Kbps audio stream). +- Requires a free (ad supported) Pandora account or a Pandora One subscription (provides ad-free playback and higher + quality 192 Kbps audio stream). - ``pydora`` >= 1.6.3. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. @@ -66,18 +66,19 @@ The following configuration values are available: - ``pandora/api_host``: Which of the JSON API `endpoints `_ to use. -- ``pandora/partner_`` related values: The `credentials `_ to use for the Pandora API entry point. +- ``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/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 the ``date`` that the station was added. Use ``A-Z`` to display the list of stations in - alphabetical order. +- ``pandora/sort_order``: defaults to the ``date`` that the station was added. Use ``a-z`` to display the list of + stations in alphabetical order. - ``pandora/auto_setup``: If Mopidy-Pandora should automatically configure the Mopidy player for best compatibility with the Pandora radio stream. Defaults to ``true`` and turns ``repeat`` on and ``consume``, ``random``, and @@ -88,8 +89,8 @@ The following configuration values are available: latest lists are always retrieved from Pandora. It should not be necessary to fiddle with this unless you want Mopidy-Pandora to immediately detect changes to your Pandora user profile that are made in other players. -**EXPERIMENTAL EVENT HANDLING IMPLEMENTATION:** apply Pandora ratings or perform other actions on the track that is -currently playing using the standard pause/play/previous/next buttons. +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 ``false`` will disable all event triggers entirely. - ``pandora/double_click_interval``: successive button clicks that occur within this interval (in seconds) will @@ -107,14 +108,14 @@ The full list of supported events include: ``thumbs_up``, ``thumbs_down``, ``sle Usage ===== -Mopidy needs `dynamic playlist `_ and -`core extensions `_ support to properly support Pandora. In the meantime, -Mopidy-Pandora simulates dynamic playlists by adding more tracks to the tracklist as needed. Mopidy-Pandora will -ensure that there are always at least two tracks in the playlist to avoid playback gaps when switching tracks. +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 at least two tracks in the playlist to avoid playback gaps when +switching tracks. -Pandora radio expects the user to interact with tracks at the time and in the order that it serves them up. For this -reason, trying to create playlist or manually interact with the tracklist queque is probably not a good idea. And not -supported. +Pandora expects users to interact with tracks at the time and in the sequence that it serves them up. For this reason, +trying to create playlists manually or mess with the tracklist queue is probably not a good idea. And not supported. Project resources diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index 5b0622c..34da1d4 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -1,16 +1,12 @@ from __future__ import absolute_import, division, print_function, unicode_literals -import logging import os from mopidy import config, ext -from pandora import BaseAPIClient __version__ = '0.2.0' -logger = logging.getLogger(__name__) - class Extension(ext.Extension): @@ -23,6 +19,7 @@ def get_default_config(self): 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(optional=True) schema['partner_encryption_key'] = config.String() @@ -48,6 +45,6 @@ def get_config_schema(self): def setup(self, registry): from .backend import PandoraBackend - from .frontend import EventSupportPandoraFrontend + from .frontend import PandoraFrontendFactory registry.add('backend', PandoraBackend) - registry.add('frontend', EventSupportPandoraFrontend) + registry.add('frontend', PandoraFrontendFactory) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 22a276c..1adbd0f 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -1,7 +1,8 @@ +import logging + from mopidy import backend, core from mopidy.internal import encoding -from pandora import BaseAPIClient from pandora.errors import PandoraException import pykka @@ -10,11 +11,13 @@ from mopidy_pandora import listener, rpc - from mopidy_pandora.client import MopidyAPIClient, MopidySettingsDictBuilder from mopidy_pandora.library import PandoraLibraryProvider from mopidy_pandora.playback import EventSupportPlaybackProvider, PandoraPlaybackProvider -from mopidy_pandora.uri import logger, PandoraUri # noqa: I101 +from mopidy_pandora.uri import PandoraUri # noqa: I101 + + +logger = logging.getLogger(__name__) class PandoraBackend(pykka.ThreadingActor, backend.Backend, core.CoreListener, listener.PandoraListener): @@ -23,21 +26,21 @@ def __init__(self, config, audio): super(PandoraBackend, self).__init__() self.config = config['pandora'] settings = { - 'CACHE_TTL': self.config.get('cache_time_to_live', 1800), - 'API_HOST': self.config.get('api_host', 'tuner.pandora.com/services/json/'), + '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'], - 'AUDIO_QUALITY': self.config.get('preferred_audio_quality', BaseAPIClient.HIGH_AUDIO_QUALITY) + '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', 'date')) + self.library = PandoraLibraryProvider(backend=self, sort_order=self.config.get('sort_order')) self.supports_events = False - if self.config.get('event_support_enabled', False): + if self.config.get('event_support_enabled'): self.supports_events = True self.playback = EventSupportPlaybackProvider(audio, self) else: diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 191a07c..5c2cd9e 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -1,5 +1,4 @@ import logging - import time from cachetools import TTLCache @@ -11,6 +10,7 @@ import requests + logger = logging.getLogger(__name__) @@ -50,17 +50,23 @@ def __init__(self, cache_ttl, transport, partner_user, partner_password, device, def get_station_list(self, force_refresh=False): + list = [] try: if (self._station_list_cache.currsize == 0 or (force_refresh and self._station_list_cache.itervalues().next().has_changed())): - self._station_list_cache[time.time()] = super(MopidyAPIClient, self).get_station_list() + list = super(MopidyAPIClient, self).get_station_list() + self._station_list_cache[time.time()] = list except requests.exceptions.RequestException as e: logger.error('Error retrieving station list: {}'.format(encoding.locale_decode(e))) - return [] + return list - return self._station_list_cache.itervalues().next() + try: + return self._station_list_cache.itervalues().next() + except StopIteration: + # Cache disabled + return list def get_station(self, station_id): @@ -72,6 +78,7 @@ def get_station(self, station_id): def get_genre_stations(self, force_refresh=False): + list = [] try: if (self._genre_stations_cache.currsize == 0 or (force_refresh and self._genre_stations_cache.itervalues().next().has_changed())): @@ -80,6 +87,10 @@ def get_genre_stations(self, force_refresh=False): except requests.exceptions.RequestException as e: logger.error('Error retrieving genre stations: {}'.format(encoding.locale_decode(e))) - return [] + return list - return self._genre_stations_cache.itervalues().next() + try: + return self._genre_stations_cache.itervalues().next() + except StopIteration: + # Cache disabled + return list diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index 41cd683..1665571 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -13,7 +13,6 @@ sort_order = date auto_setup = true cache_time_to_live = 1800 -### EXPERIMENTAL RATINGS IMPLEMENTATION ### event_support_enabled = false double_click_interval = 2.00 on_pause_resume_click = thumbs_up diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 81886b1..0c976d0 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -1,3 +1,4 @@ +import logging import threading from mopidy import core @@ -5,10 +6,13 @@ import pykka -from mopidy_pandora import listener, logger +from mopidy_pandora import listener from mopidy_pandora.uri import AdItemUri, PandoraUri +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 @@ -42,13 +46,22 @@ def is_pandora_uri(active_uri): return active_uri and active_uri.startswith('pandora:') +class PandoraFrontendFactory(pykka.ThreadingActor, core.CoreListener, listener.PandoraListener): + + def __new__(cls, config, core): + if config['pandora'].get('event_support_enabled'): + return EventSupportPandoraFrontend(config, core) + else: + return PandoraFrontend(config, core) + + class PandoraFrontend(pykka.ThreadingActor, core.CoreListener, listener.PandoraListener): def __init__(self, config, core): super(PandoraFrontend, self).__init__() self.config = config - self.auto_setup = self.config.get('auto_setup', True) + self.auto_setup = self.config.get('auto_setup') self.setup_required = True self.core = core @@ -108,9 +121,9 @@ def __init__(self, config, core): super(EventSupportPandoraFrontend, self).__init__(config, core) self.settings = { - 'OPR_EVENT': config.get('on_pause_resume_click', 'thumbs_up'), - 'OPN_EVENT': config.get('on_pause_next_click', 'thumbs_down'), - 'OPP_EVENT': config.get('on_pause_previous_click', 'sleep') + 'OPR_EVENT': config.get('on_pause_resume_click'), + 'OPN_EVENT': config.get('on_pause_next_click'), + 'OPP_EVENT': config.get('on_pause_previous_click') } self.current_track_uri = None diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 2b8ee79..b024802 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,3 +1,5 @@ +import logging + from collections import OrderedDict from mopidy import backend, models @@ -11,7 +13,10 @@ import requests from mopidy_pandora import rpc -from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, logger, PandoraUri, StationUri, TrackUri # noqa I101 +from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, StationUri, TrackUri # noqa I101 + + +logger = logging.getLogger(__name__) class PandoraLibraryProvider(backend.LibraryProvider): diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 27275ae..26bd3e8 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -1,3 +1,4 @@ +import logging import time from mopidy import backend @@ -7,7 +8,8 @@ from mopidy_pandora import listener -from mopidy_pandora.uri import logger + +logger = logging.getLogger(__name__) class PandoraPlaybackProvider(backend.PlaybackProvider): @@ -86,7 +88,7 @@ class EventSupportPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): super(EventSupportPlaybackProvider, self).__init__(audio, backend) - self.double_click_interval = float(backend.config.get('double_click_interval', 2.00)) + self.double_click_interval = float(backend.config.get('double_click_interval')) self._click_time = 0 def set_click_time(self, click_time=None): diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 0bf74bd..2b22ea6 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -2,6 +2,8 @@ import urllib from pandora.models.pandora import AdItem, PlaylistItem + + logger = logging.getLogger(__name__) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..bb409a2 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +from __future__ import absolute_import, division, print_function, unicode_literals diff --git a/tests/conftest.py b/tests/conftest.py index 047ed5b..434b33d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,7 @@ def config(): 'port': '6680' }, 'pandora': { + 'enabled': True, 'api_host': 'test_host', 'partner_encryption_key': 'test_encryption_key', 'partner_decryption_key': 'test_decryption_key', @@ -56,6 +57,7 @@ def config(): 'preferred_audio_quality': MOCK_DEFAULT_AUDIO_QUALITY, 'sort_order': 'date', 'auto_setup': True, + 'cache_time_to_live': 1800, 'event_support_enabled': True, 'double_click_interval': '0.1', diff --git a/tests/test_extension.py b/tests/test_extension.py index 34e55bc..6464275 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -66,6 +66,6 @@ def test_setup(self): ext = Extension() ext.setup(registry) - calls = [mock.call('frontend', frontend_lib.EventSupportPandoraFrontend), + calls = [mock.call('frontend', frontend_lib.PandoraFrontendFactory), mock.call('backend', backend_lib.PandoraBackend)] registry.add.assert_has_calls(calls, any_order=True) From 617f83b93c7a0ba336e193e25959b1cd1417d96a Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 23 Dec 2015 15:09:18 +0200 Subject: [PATCH 113/311] Add proxy support. --- mopidy_pandora/backend.py | 5 ++-- mopidy_pandora/library.py | 4 +-- mopidy_pandora/{rpc.py => utils.py} | 40 ++++++++++++++++++++++++++--- tests/conftest.py | 6 ++++- tests/test_backend.py | 2 +- 5 files changed, 48 insertions(+), 9 deletions(-) rename mopidy_pandora/{rpc.py => utils.py} (65%) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 1adbd0f..98d62b0 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -9,7 +9,7 @@ import requests -from mopidy_pandora import listener, rpc +from mopidy_pandora import listener, utils from mopidy_pandora.client import MopidyAPIClient, MopidySettingsDictBuilder from mopidy_pandora.library import PandoraLibraryProvider @@ -33,6 +33,7 @@ def __init__(self, config, audio): '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') } @@ -48,7 +49,7 @@ def __init__(self, config, audio): self.uri_schemes = [PandoraUri.SCHEME] - @rpc.run_async + @utils.run_async def on_start(self): try: self.api.login(self.config['username'], self.config['password']) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index b024802..cc814ef 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -12,7 +12,7 @@ import requests -from mopidy_pandora import rpc +from mopidy_pandora import utils from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, StationUri, TrackUri # noqa I101 @@ -107,7 +107,7 @@ def _formatted_station_list(self, list): def _browse_stations(self): # Prefetch genre category list - rpc.run_async(self.backend.api.get_genre_stations)() + utils.run_async(self.backend.api.get_genre_stations)() station_directories = [] diff --git a/mopidy_pandora/rpc.py b/mopidy_pandora/utils.py similarity index 65% rename from mopidy_pandora/rpc.py rename to mopidy_pandora/utils.py index 2cee08e..12c69df 100644 --- a/mopidy_pandora/rpc.py +++ b/mopidy_pandora/utils.py @@ -1,7 +1,11 @@ import json +from mopidy import httpclient + import requests +import mopidy_pandora + def run_async(func): """ Function decorator intended to make "func" run in a separate thread (asynchronously). @@ -33,6 +37,30 @@ def async_func(*args, **kwargs): return async_func +def format_proxy(proxy_config): + if not proxy_config.get('hostname'): + return None + + port = proxy_config.get('port', 80) + if port < 0: + port = 80 + + template = '{hostname}:{port}' + + return template.format(hostname=proxy_config['hostname'], port=port) + + +def get_requests_session(proxy_config, user_agent): + proxy = httpclient.format_proxy(proxy_config) + full_user_agent = httpclient.format_user_agent(user_agent) + + session = requests.Session() + session.proxies.update({'http': proxy, 'https': proxy}) + session.headers.update({'user-agent': full_user_agent}) + + return session + + class RPCClient(object): hostname = '127.0.0.1' port = '6680' @@ -47,7 +75,7 @@ def configure(cls, hostname, port): @classmethod @run_async - def _do_rpc(cls, method, params=None, queue=None): + def _do_rpc(cls, mopidy_config, 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. @@ -61,8 +89,14 @@ def _do_rpc(cls, method, params=None, queue=None): 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) + session = get_requests_session( + proxy_config=mopidy_config['proxy'], + user_agent='%s/%s' % ( + mopidy_pandora.Extension.dist_name, + mopidy_pandora.__version__)) + + json_data = json.loads(session.get('POST', cls.url, data=json.dumps(data), + headers={'Content-Type': 'application/json'}).text) if queue is not None: queue.put(json_data['result']) diff --git a/tests/conftest.py b/tests/conftest.py index 434b33d..9323ce7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,10 @@ def config(): 'hostname': '127.0.0.1', 'port': '6680' }, + 'proxy': { + 'hostname': 'mock_host', + 'port': 'mock_port' + }, 'pandora': { 'enabled': True, 'api_host': 'test_host', @@ -53,7 +57,7 @@ def config(): 'partner_password': 'partner_password', 'partner_device': 'test_device', 'username': 'john', - 'password': 'doe', + 'password': 'smith', 'preferred_audio_quality': MOCK_DEFAULT_AUDIO_QUALITY, 'sort_order': 'date', 'auto_setup': True, diff --git a/tests/test_backend.py b/tests/test_backend.py index 716c4b7..9282733 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -64,7 +64,7 @@ def test_on_start_logs_in(config): t = backend.on_start() t.join() - backend.api.login.assert_called_once_with('john', 'doe') + backend.api.login.assert_called_once_with('john', 'smith') def test_on_start_pre_fetches_lists(config): From beb7ed8debf84b24c78754f5fd9c525ef32a4ebd Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 23 Dec 2015 15:12:51 +0200 Subject: [PATCH 114/311] RPC calls to Mopidy should not go through the proxy. --- mopidy_pandora/utils.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/mopidy_pandora/utils.py b/mopidy_pandora/utils.py index 12c69df..ea41714 100644 --- a/mopidy_pandora/utils.py +++ b/mopidy_pandora/utils.py @@ -4,8 +4,6 @@ import requests -import mopidy_pandora - def run_async(func): """ Function decorator intended to make "func" run in a separate thread (asynchronously). @@ -75,7 +73,7 @@ def configure(cls, hostname, port): @classmethod @run_async - def _do_rpc(cls, mopidy_config, method, params=None, queue=None): + 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. @@ -89,14 +87,8 @@ def _do_rpc(cls, mopidy_config, method, params=None, queue=None): if params is not None: data['params'] = params - session = get_requests_session( - proxy_config=mopidy_config['proxy'], - user_agent='%s/%s' % ( - mopidy_pandora.Extension.dist_name, - mopidy_pandora.__version__)) - - json_data = json.loads(session.get('POST', cls.url, data=json.dumps(data), - headers={'Content-Type': 'application/json'}).text) + 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']) From 42e4498ebb7f967ff45ba8cd4a7ff82488482a04 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 23 Dec 2015 16:18:45 +0200 Subject: [PATCH 115/311] Split frontend and backend listeners. --- mopidy_pandora/__init__.py | 24 ++++++++++++------------ mopidy_pandora/backend.py | 16 +++++++++------- mopidy_pandora/frontend.py | 23 ++++++++++++++--------- mopidy_pandora/listener.py | 25 +++++++++++++++---------- mopidy_pandora/playback.py | 6 +++--- tests/test_playback.py | 8 ++++---- 6 files changed, 57 insertions(+), 45 deletions(-) diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index 34da1d4..df089e3 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -21,7 +21,7 @@ def get_default_config(self): def get_config_schema(self): from pandora import BaseAPIClient schema = super(Extension, self).get_config_schema() - schema['api_host'] = config.String(optional=True) + schema['api_host'] = config.String() schema['partner_encryption_key'] = config.String() schema['partner_decryption_key'] = config.String() schema['partner_username'] = config.String() @@ -29,18 +29,18 @@ def get_config_schema(self): schema['partner_device'] = config.String() schema['username'] = config.String() schema['password'] = config.Secret() - schema['preferred_audio_quality'] = config.String(optional=True, choices=[BaseAPIClient.LOW_AUDIO_QUALITY, - BaseAPIClient.MED_AUDIO_QUALITY, - BaseAPIClient.HIGH_AUDIO_QUALITY]) - schema['sort_order'] = config.String(optional=True, choices=['date', 'A-Z', 'a-z']) - schema['auto_setup'] = config.Boolean(optional=True) + 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(optional=True) - schema['event_support_enabled'] = config.Boolean(optional=True) - schema['double_click_interval'] = config.String(optional=True) - schema['on_pause_resume_click'] = config.String(optional=True, choices=['thumbs_up', 'thumbs_down', 'sleep']) - schema['on_pause_next_click'] = config.String(optional=True, choices=['thumbs_up', 'thumbs_down', 'sleep']) - schema['on_pause_previous_click'] = config.String(optional=True, choices=['thumbs_up', 'thumbs_down', 'sleep']) + 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']) + schema['on_pause_next_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep']) + schema['on_pause_previous_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep']) return schema def setup(self, registry): diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 98d62b0..35bc195 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) -class PandoraBackend(pykka.ThreadingActor, backend.Backend, core.CoreListener, listener.PandoraListener): +class PandoraBackend(pykka.ThreadingActor, backend.Backend, core.CoreListener, listener.PandoraFrontendListener): def __init__(self, config, audio): super(PandoraBackend, self).__init__() @@ -60,18 +60,20 @@ def on_start(self): except requests.exceptions.RequestException as e: logger.error('Error logging in to Pandora: {}'.format(encoding.locale_decode(e))) - def prepare_next_track(self, auto_play=False): + def more_tracks_needed(self, auto_play=False): next_track = self.library.get_next_pandora_track() if next_track: - self._trigger_prepare_tracklist(next_track, auto_play) + self._trigger_next_track_prepared(next_track, auto_play) - def _trigger_prepare_tracklist(self, track, auto_play): - listener.PandoraListener.send('prepare_tracklist', track=track, auto_play=auto_play) + def _trigger_next_track_prepared(self, track, auto_play): + (listener.PandoraFrontendListener.send(listener.PandoraBackendListener.next_track_prepared.__name__, + track=track, auto_play=auto_play)) def _trigger_event_processed(self, track_uri): - listener.PandoraListener.send('event_processed', track_uri=track_uri) + (listener.PandoraFrontendListener.send(listener.PandoraBackendListener.event_processed.__name__, + track_uri=track_uri)) - def call_event(self, track_uri, pandora_event): + def process_event(self, track_uri, pandora_event): func = getattr(self, pandora_event) try: logger.info("Triggering event '{}' for song: '{}'".format(pandora_event, diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 0c976d0..e700ae4 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -46,7 +46,7 @@ def is_pandora_uri(active_uri): return active_uri and active_uri.startswith('pandora:') -class PandoraFrontendFactory(pykka.ThreadingActor, core.CoreListener, listener.PandoraListener): +class PandoraFrontendFactory(pykka.ThreadingActor, core.CoreListener, listener.PandoraBackendListener): def __new__(cls, config, core): if config['pandora'].get('event_support_enabled'): @@ -55,7 +55,7 @@ def __new__(cls, config, core): return PandoraFrontend(config, core) -class PandoraFrontend(pykka.ThreadingActor, core.CoreListener, listener.PandoraListener): +class PandoraFrontend(pykka.ThreadingActor, core.CoreListener, listener.PandoraBackendListener): def __init__(self, config, core): super(PandoraFrontend, self).__init__() @@ -102,17 +102,21 @@ def track_playback_resumed(self, tl_track, time_position): def track_changed(self, track): if self.core.tracklist.get_length().get() < 2 or track != self.core.tracklist.get_tl_tracks().get()[0].track: - self._trigger_prepare_next_track(auto_play=False) + self._trigger_more_tracks_needed(auto_play=False) - def prepare_tracklist(self, track, auto_play): + def next_track_prepared(self, track, auto_play): + self.sync_tracklist() + + def sync_tracklist(self, track, auto_play): self.core.tracklist.add(uris=[track.uri]) if self.core.tracklist.get_length().get() > 2: self.core.tracklist.remove({'tlid': [self.core.tracklist.get_tl_tracks().get()[0].tlid]}) if auto_play: self.core.playback.play(self.core.tracklist.get_tl_tracks().get()[0]) - def _trigger_prepare_next_track(self, auto_play): - listener.PandoraListener.send('prepare_next_track', auto_play=auto_play) + def _trigger_more_tracks_needed(self, auto_play): + (listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.more_tracks_needed.__name__, + auto_play=auto_play)) class EventSupportPandoraFrontend(PandoraFrontend): @@ -170,7 +174,7 @@ def _process_events(self, track_uri, time_position): return try: - self._trigger_call_event(event_target_uri, self._get_event(track_uri, time_position)) + self._trigger_process_event(event_target_uri, self._get_event(track_uri, time_position)) except ValueError as e: logger.error(("Error processing event for URI '{}': ({})" .format(event_target_uri, encoding.locale_decode(e)))) @@ -209,5 +213,6 @@ def doubleclicked(self): # Resume playback... self.core.playback.resume() - def _trigger_call_event(self, track_uri, event): - listener.PandoraListener.send('call_event', track_uri=track_uri, pandora_event=event) + def _trigger_process_event(self, track_uri, event): + (listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.process_event.__name__, + track_uri=track_uri, pandora_event=event)) diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index aa8fe8c..ab6036e 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -1,28 +1,33 @@ from __future__ import absolute_import, unicode_literals -from mopidy import listener +from mopidy import backend, listener -class PandoraListener(listener.Listener): - +class PandoraFrontendListener(listener.Listener): @staticmethod def send(event, **kwargs): - listener.send_async(PandoraListener, event, **kwargs) + listener.send_async(PandoraFrontendListener, event, **kwargs) - def prepare_next_track(self, auto_play): + def more_tracks_needed(self, auto_play): pass - def prepare_tracklist(self, track, auto_play): + def doubleclicked(self): pass - def doubleclicked(self): + def process_event(self, track_uri, pandora_event): pass - def call_event(self, track_uri, pandora_event): + def track_changed(self, track): pass - def event_processed(self, track_uri): + +class PandoraBackendListener(backend.BackendListener): + @staticmethod + def send(event, **kwargs): + listener.send_async(PandoraBackendListener, event, **kwargs) + + def next_track_prepared(self, track, auto_play): pass - def track_changed(self, track): + def event_processed(self, track_uri): pass diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 26bd3e8..f2c2a53 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -71,7 +71,7 @@ def change_track(self, track): return False except Unplayable as e: logger.error('Error changing track: ({})'.format(encoding.locale_decode(e))) - self.backend.prepare_next_track(auto_play=True) + self.backend.more_tracks_needed(auto_play=True) return False except MaxSkipLimitExceeded as e: logger.error('Error changing track: ({})'.format(encoding.locale_decode(e))) @@ -81,7 +81,7 @@ def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url def _trigger_track_changed(self, track): - listener.PandoraListener.send('track_changed', track=track) + listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.track_changed.__name__, track=track) class EventSupportPlaybackProvider(PandoraPlaybackProvider): @@ -129,7 +129,7 @@ def pause(self): def _trigger_doubleclicked(self): self.set_click_time(0) - listener.PandoraListener.send('doubleclicked') + listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.doubleclicked.__name__) class MaxSkipLimitExceeded(Exception): diff --git a/tests/test_playback.py b/tests/test_playback.py index 12f8d67..df5c7f2 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -102,15 +102,15 @@ def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): provider.previous_tl_track = {'track': {'uri': 'previous_track'}} provider.next_tl_track = {'track': {'uri': track.uri}} - provider.backend.prepare_next_track = mock.PropertyMock() + provider.backend.more_tracks_needed = mock.PropertyMock() for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): assert provider.change_track(track) is False if i < PandoraPlaybackProvider.SKIP_LIMIT-1: - assert provider.backend.prepare_next_track.called - provider.backend.prepare_next_track.reset_mock() + assert provider.backend.more_tracks_needed.called + provider.backend.more_tracks_needed.reset_mock() else: - assert not provider.backend.prepare_next_track.called + assert not provider.backend.more_tracks_needed.called assert 'Maximum track skip limit ({:d}) exceeded, stopping...'.format( PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() From c86d33fa21018388c48a2afcdbc1588a8bec7ef9 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 23 Dec 2015 16:39:16 +0200 Subject: [PATCH 116/311] Refine tracklist syncing. --- mopidy_pandora/frontend.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index e700ae4..b308e58 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -108,11 +108,15 @@ def next_track_prepared(self, track, auto_play): self.sync_tracklist() def sync_tracklist(self, track, auto_play): + # Add the next Pandora track self.core.tracklist.add(uris=[track.uri]) - if self.core.tracklist.get_length().get() > 2: - self.core.tracklist.remove({'tlid': [self.core.tracklist.get_tl_tracks().get()[0].tlid]}) + tl_tracks = self.core.tracklist.get_tl_tracks().get() + if len(tl_tracks) > 2: + # Only need two tracks in the tracklist at any given time, remove the oldest tracks + self.core.tracklist.remove({'tlid': [lambda: tl_tracks[t].tlid for t in range(0, len(tl_tracks)-2)]}) if auto_play: - self.core.playback.play(self.core.tracklist.get_tl_tracks().get()[0]) + # Play the track that was just added + self.core.playback.play(tl_tracks[-1]) def _trigger_more_tracks_needed(self, auto_play): (listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.more_tracks_needed.__name__, From 409c674fd85f965ee1f161555ec96d9154cdb53b Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 23 Dec 2015 17:19:08 +0200 Subject: [PATCH 117/311] Add seperate listeners for frontend, backend, and playback controllers. --- mopidy_pandora/backend.py | 16 ++++++++-------- mopidy_pandora/frontend.py | 10 ++++++---- mopidy_pandora/listener.py | 18 ++++++++++++------ mopidy_pandora/playback.py | 4 ++-- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 35bc195..8820851 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -65,14 +65,6 @@ def more_tracks_needed(self, auto_play=False): if next_track: self._trigger_next_track_prepared(next_track, auto_play) - def _trigger_next_track_prepared(self, track, auto_play): - (listener.PandoraFrontendListener.send(listener.PandoraBackendListener.next_track_prepared.__name__, - track=track, auto_play=auto_play)) - - def _trigger_event_processed(self, track_uri): - (listener.PandoraFrontendListener.send(listener.PandoraBackendListener.event_processed.__name__, - track_uri=track_uri)) - def process_event(self, track_uri, pandora_event): func = getattr(self, pandora_event) try: @@ -98,3 +90,11 @@ def add_artist_bookmark(self, track_uri): def add_song_bookmark(self, track_uri): return self.api.add_song_bookmark(PandoraUri.parse(track_uri).token) + + def _trigger_next_track_prepared(self, track, auto_play): + (listener.PandoraBackendListener.send(listener.PandoraBackendListener.next_track_prepared.__name__, + track=track, auto_play=auto_play)) + + def _trigger_event_processed(self, track_uri): + (listener.PandoraBackendListener.send(listener.PandoraBackendListener.event_processed.__name__, + track_uri=track_uri)) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index b308e58..f8f2ba3 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -46,7 +46,8 @@ def is_pandora_uri(active_uri): return active_uri and active_uri.startswith('pandora:') -class PandoraFrontendFactory(pykka.ThreadingActor, core.CoreListener, listener.PandoraBackendListener): +class PandoraFrontendFactory(pykka.ThreadingActor, core.CoreListener, listener.PandoraBackendListener, + listener.PandoraPlaybackListener): def __new__(cls, config, core): if config['pandora'].get('event_support_enabled'): @@ -55,7 +56,8 @@ def __new__(cls, config, core): return PandoraFrontend(config, core) -class PandoraFrontend(pykka.ThreadingActor, core.CoreListener, listener.PandoraBackendListener): +class PandoraFrontend(pykka.ThreadingActor, core.CoreListener, listener.PandoraBackendListener, + listener.PandoraPlaybackListener): def __init__(self, config, core): super(PandoraFrontend, self).__init__() @@ -105,7 +107,7 @@ def track_changed(self, track): self._trigger_more_tracks_needed(auto_play=False) def next_track_prepared(self, track, auto_play): - self.sync_tracklist() + self.sync_tracklist(track, auto_play) def sync_tracklist(self, track, auto_play): # Add the next Pandora track @@ -113,7 +115,7 @@ def sync_tracklist(self, track, auto_play): tl_tracks = self.core.tracklist.get_tl_tracks().get() if len(tl_tracks) > 2: # Only need two tracks in the tracklist at any given time, remove the oldest tracks - self.core.tracklist.remove({'tlid': [lambda: tl_tracks[t].tlid for t in range(0, len(tl_tracks)-2)]}) + self.core.tracklist.remove({'tlid': [tl_tracks[t].tlid for t in range(0, len(tl_tracks)-2)]}) if auto_play: # Play the track that was just added self.core.playback.play(tl_tracks[-1]) diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index ab6036e..ea50e96 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -11,15 +11,9 @@ def send(event, **kwargs): def more_tracks_needed(self, auto_play): pass - def doubleclicked(self): - pass - def process_event(self, track_uri, pandora_event): pass - def track_changed(self, track): - pass - class PandoraBackendListener(backend.BackendListener): @staticmethod @@ -31,3 +25,15 @@ def next_track_prepared(self, track, auto_play): def event_processed(self, track_uri): pass + + +class PandoraPlaybackListener(listener.Listener): + @staticmethod + def send(event, **kwargs): + listener.send_async(PandoraPlaybackListener, event, **kwargs) + + def doubleclicked(self): + pass + + def track_changed(self, track): + pass diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index f2c2a53..1418e64 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -81,7 +81,7 @@ def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url def _trigger_track_changed(self, track): - listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.track_changed.__name__, track=track) + listener.PandoraPlaybackListener.send(listener.PandoraPlaybackListener.track_changed.__name__, track=track) class EventSupportPlaybackProvider(PandoraPlaybackProvider): @@ -129,7 +129,7 @@ def pause(self): def _trigger_doubleclicked(self): self.set_click_time(0) - listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.doubleclicked.__name__) + listener.PandoraPlaybackListener.send(listener.PandoraPlaybackListener.doubleclicked.__name__) class MaxSkipLimitExceeded(Exception): From d4771dcefcfec399752699763cfbeca49b5a979c Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 23 Dec 2015 18:53:26 +0200 Subject: [PATCH 118/311] Add more logical names for listener events. --- mopidy_pandora/backend.py | 11 +++++++---- mopidy_pandora/frontend.py | 14 +++++++------- mopidy_pandora/listener.py | 6 +++--- tests/test_playback.py | 8 ++++---- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 8820851..da07c63 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -60,10 +60,13 @@ def on_start(self): except requests.exceptions.RequestException as e: logger.error('Error logging in to Pandora: {}'.format(encoding.locale_decode(e))) - def more_tracks_needed(self, auto_play=False): + def end_of_tracklist_reached(self, auto_play=False): next_track = self.library.get_next_pandora_track() if next_track: - self._trigger_next_track_prepared(next_track, auto_play) + self._trigger_next_track_available(next_track, 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) @@ -91,8 +94,8 @@ def add_artist_bookmark(self, track_uri): def add_song_bookmark(self, track_uri): return self.api.add_song_bookmark(PandoraUri.parse(track_uri).token) - def _trigger_next_track_prepared(self, track, auto_play): - (listener.PandoraBackendListener.send(listener.PandoraBackendListener.next_track_prepared.__name__, + def _trigger_next_track_available(self, track, auto_play): + (listener.PandoraBackendListener.send(listener.PandoraBackendListener.next_track_available.__name__, track=track, auto_play=auto_play)) def _trigger_event_processed(self, track_uri): diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index f8f2ba3..440a730 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -104,9 +104,9 @@ def track_playback_resumed(self, tl_track, time_position): def track_changed(self, track): if self.core.tracklist.get_length().get() < 2 or track != self.core.tracklist.get_tl_tracks().get()[0].track: - self._trigger_more_tracks_needed(auto_play=False) + self._trigger_end_of_tracklist_reached(auto_play=False) - def next_track_prepared(self, track, auto_play): + def next_track_available(self, track, auto_play): self.sync_tracklist(track, auto_play) def sync_tracklist(self, track, auto_play): @@ -120,8 +120,8 @@ def sync_tracklist(self, track, auto_play): # Play the track that was just added self.core.playback.play(tl_tracks[-1]) - def _trigger_more_tracks_needed(self, auto_play): - (listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.more_tracks_needed.__name__, + def _trigger_end_of_tracklist_reached(self, auto_play): + (listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.end_of_tracklist_reached.__name__, auto_play=auto_play)) @@ -180,7 +180,7 @@ def _process_events(self, track_uri, time_position): return try: - self._trigger_process_event(event_target_uri, self._get_event(track_uri, time_position)) + self._trigger_event_triggered(event_target_uri, self._get_event(track_uri, time_position)) except ValueError as e: logger.error(("Error processing event for URI '{}': ({})" .format(event_target_uri, encoding.locale_decode(e)))) @@ -219,6 +219,6 @@ def doubleclicked(self): # Resume playback... self.core.playback.resume() - def _trigger_process_event(self, track_uri, event): - (listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.process_event.__name__, + def _trigger_event_triggered(self, track_uri, event): + (listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.event_triggered.__name__, track_uri=track_uri, pandora_event=event)) diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index ea50e96..e8ceaf4 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -8,10 +8,10 @@ class PandoraFrontendListener(listener.Listener): def send(event, **kwargs): listener.send_async(PandoraFrontendListener, event, **kwargs) - def more_tracks_needed(self, auto_play): + def end_of_tracklist_reached(self, auto_play): pass - def process_event(self, track_uri, pandora_event): + def event_triggered(self, track_uri, pandora_event): pass @@ -20,7 +20,7 @@ class PandoraBackendListener(backend.BackendListener): def send(event, **kwargs): listener.send_async(PandoraBackendListener, event, **kwargs) - def next_track_prepared(self, track, auto_play): + def next_track_available(self, track, auto_play): pass def event_processed(self, track_uri): diff --git a/tests/test_playback.py b/tests/test_playback.py index df5c7f2..f55ce75 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -102,15 +102,15 @@ def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): provider.previous_tl_track = {'track': {'uri': 'previous_track'}} provider.next_tl_track = {'track': {'uri': track.uri}} - provider.backend.more_tracks_needed = mock.PropertyMock() + provider.backend.end_of_tracklist_reached = mock.PropertyMock() for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): assert provider.change_track(track) is False if i < PandoraPlaybackProvider.SKIP_LIMIT-1: - assert provider.backend.more_tracks_needed.called - provider.backend.more_tracks_needed.reset_mock() + assert provider.backend.end_of_tracklist_reached.called + provider.backend.end_of_tracklist_reached.reset_mock() else: - assert not provider.backend.more_tracks_needed.called + assert not provider.backend.end_of_tracklist_reached.called assert 'Maximum track skip limit ({:d}) exceeded, stopping...'.format( PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() From cb3627468f7b8b7f122151c0e931b2df505ffc5e Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 23 Dec 2015 20:18:09 +0200 Subject: [PATCH 119/311] Refactor event handling listeners. --- mopidy_pandora/backend.py | 25 ++++++----- mopidy_pandora/frontend.py | 41 +++++++++--------- mopidy_pandora/listener.py | 88 +++++++++++++++++++++++++++++++++++--- mopidy_pandora/playback.py | 15 ++++--- tests/test_backend.py | 2 +- tests/test_playback.py | 17 +++----- 6 files changed, 133 insertions(+), 55 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index da07c63..82c042d 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -13,14 +13,15 @@ from mopidy_pandora.client import MopidyAPIClient, MopidySettingsDictBuilder from mopidy_pandora.library import PandoraLibraryProvider -from mopidy_pandora.playback import EventSupportPlaybackProvider, PandoraPlaybackProvider +from mopidy_pandora.playback import EventHandlingPlaybackProvider, PandoraPlaybackProvider from mopidy_pandora.uri import PandoraUri # noqa: I101 logger = logging.getLogger(__name__) -class PandoraBackend(pykka.ThreadingActor, backend.Backend, core.CoreListener, listener.PandoraFrontendListener): +class PandoraBackend(pykka.ThreadingActor, backend.Backend, core.CoreListener, listener.PandoraFrontendListener, + listener.PandoraEventHandlingFrontendListener): def __init__(self, config, audio): super(PandoraBackend, self).__init__() @@ -43,7 +44,7 @@ def __init__(self, config, audio): self.supports_events = False if self.config.get('event_support_enabled'): self.supports_events = True - self.playback = EventSupportPlaybackProvider(audio, self) + self.playback = EventHandlingPlaybackProvider(audio, self) else: self.playback = PandoraPlaybackProvider(audio, self) @@ -60,10 +61,13 @@ def on_start(self): except requests.exceptions.RequestException as e: logger.error('Error logging in to Pandora: {}'.format(encoding.locale_decode(e))) - def end_of_tracklist_reached(self, auto_play=False): + def end_of_tracklist_reached(self): + self.prepare_next_track() + + def prepare_next_track(self): next_track = self.library.get_next_pandora_track() if next_track: - self._trigger_next_track_available(next_track, auto_play) + self._trigger_next_track_available(next_track) def event_triggered(self, track_uri, pandora_event): self.process_event(track_uri, pandora_event) @@ -74,7 +78,7 @@ def process_event(self, track_uri, pandora_event): logger.info("Triggering event '{}' for song: '{}'".format(pandora_event, self.library.lookup_pandora_track(track_uri).song_name)) func(track_uri) - self._trigger_event_processed(track_uri) + self._trigger_event_processed() except PandoraException as e: logger.error('Error calling event: {}'.format(encoding.locale_decode(e))) return False @@ -94,10 +98,9 @@ def add_artist_bookmark(self, track_uri): def add_song_bookmark(self, track_uri): return self.api.add_song_bookmark(PandoraUri.parse(track_uri).token) - def _trigger_next_track_available(self, track, auto_play): + def _trigger_next_track_available(self, track): (listener.PandoraBackendListener.send(listener.PandoraBackendListener.next_track_available.__name__, - track=track, auto_play=auto_play)) + track=track)) - def _trigger_event_processed(self, track_uri): - (listener.PandoraBackendListener.send(listener.PandoraBackendListener.event_processed.__name__, - track_uri=track_uri)) + def _trigger_event_processed(self): + listener.PandoraBackendListener.send(listener.PandoraBackendListener.event_processed.__name__) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 440a730..94cf3d3 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -2,6 +2,7 @@ import threading from mopidy import core +from mopidy.audio import PlaybackState from mopidy.internal import encoding import pykka @@ -46,12 +47,11 @@ def is_pandora_uri(active_uri): return active_uri and active_uri.startswith('pandora:') -class PandoraFrontendFactory(pykka.ThreadingActor, core.CoreListener, listener.PandoraBackendListener, - listener.PandoraPlaybackListener): +class PandoraFrontendFactory(pykka.ThreadingActor): def __new__(cls, config, core): if config['pandora'].get('event_support_enabled'): - return EventSupportPandoraFrontend(config, core) + return EventHandlingPandoraFrontend(config, core) else: return PandoraFrontend(config, core) @@ -104,31 +104,29 @@ def track_playback_resumed(self, tl_track, time_position): def track_changed(self, track): if self.core.tracklist.get_length().get() < 2 or track != self.core.tracklist.get_tl_tracks().get()[0].track: - self._trigger_end_of_tracklist_reached(auto_play=False) + self._trigger_end_of_tracklist_reached() - def next_track_available(self, track, auto_play): - self.sync_tracklist(track, auto_play) + def next_track_available(self, track): + self.add_track(track) - def sync_tracklist(self, track, auto_play): + def add_track(self, track): # Add the next Pandora track - self.core.tracklist.add(uris=[track.uri]) - tl_tracks = self.core.tracklist.get_tl_tracks().get() + tl_tracks = self.core.tracklist.add(uris=[track.uri]).get() + if self.core.playback.get_state() == PlaybackState.STOPPED: + # Playback stopped after previous track was unplayable. Resume playback with the freshly seeded tracklist. + self.core.playback.play(tl_tracks[-1]) if len(tl_tracks) > 2: # Only need two tracks in the tracklist at any given time, remove the oldest tracks - self.core.tracklist.remove({'tlid': [tl_tracks[t].tlid for t in range(0, len(tl_tracks)-2)]}) - if auto_play: - # Play the track that was just added - self.core.playback.play(tl_tracks[-1]) + self.core.tracklist.remove({'tlid': [tl_tracks[t].tlid for t in range(0, len(tl_tracks)-2)]}).get() - def _trigger_end_of_tracklist_reached(self, auto_play): - (listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.end_of_tracklist_reached.__name__, - auto_play=auto_play)) + def _trigger_end_of_tracklist_reached(self): + listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.end_of_tracklist_reached.__name__) -class EventSupportPandoraFrontend(PandoraFrontend): +class EventHandlingPandoraFrontend(PandoraFrontend, listener.PandoraEventHandlingPlaybackListener): def __init__(self, config, core): - super(EventSupportPandoraFrontend, self).__init__(config, core) + super(EventHandlingPandoraFrontend, self).__init__(config, core) self.settings = { 'OPR_EVENT': config.get('on_pause_resume_click'), @@ -160,7 +158,7 @@ def tracklist_changed(self): @only_execute_for_pandora_uris def track_playback_resumed(self, tl_track, time_position): - super(EventSupportPandoraFrontend, self).track_playback_resumed(tl_track, time_position) + super(EventHandlingPandoraFrontend, self).track_playback_resumed(tl_track, time_position) self._process_events(tl_track.track.uri, time_position) @@ -207,7 +205,7 @@ def _get_event(self, track_uri, time_position): else: raise ValueError('Unexpected event URI: {}'.format(track_uri)) - def event_processed(self, track_uri): + def event_processed(self): self.event_processed_event.set() if not self.tracklist_changed_event.isSet(): @@ -217,7 +215,8 @@ def event_processed(self, track_uri): def doubleclicked(self): self.event_processed_event.clear() # Resume playback... - self.core.playback.resume() + if self.core.playback.get_state() != PlaybackState.PLAYING: + self.core.playback.resume() def _trigger_event_triggered(self, track_uri, event): (listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.event_triggered.__name__, diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index e8ceaf4..8de0aa2 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -4,36 +4,114 @@ class PandoraFrontendListener(listener.Listener): + + """ + Marker interface for recipients of events sent by the frontend actor. + + """ + @staticmethod def send(event, **kwargs): listener.send_async(PandoraFrontendListener, event, **kwargs) - def end_of_tracklist_reached(self, auto_play): + def end_of_tracklist_reached(self): + """ + Called whenever the tracklist contains only one track, or the last track in the tracklist is being played. + + """ pass + +class PandoraEventHandlingFrontendListener(listener.Listener): + + """ + Marker interface for recipients of events sent by the event handling frontend actor. + + """ + + @staticmethod + def send(event, **kwargs): + listener.send_async(PandoraEventHandlingFrontendListener, 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 class PandoraBackendListener(backend.BackendListener): + + """ + Marker interface for recipients of events sent by the backend actor. + + """ + @staticmethod def send(event, **kwargs): listener.send_async(PandoraBackendListener, event, **kwargs) - def next_track_available(self, track, auto_play): + def next_track_available(self, track): + """ + 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.Track` + """ pass - def event_processed(self, track_uri): + def event_processed(self): + """ + Called when the backend has successfully processed the event for the track. This lets the frontend know + that it can process any tracklist changed events that were queued while the Pandora event was being processed. + + """ pass class PandoraPlaybackListener(listener.Listener): + + """ + Marker interface for recipients of events sent by the playback provider. + + """ + @staticmethod def send(event, **kwargs): listener.send_async(PandoraPlaybackListener, event, **kwargs) - def doubleclicked(self): + def track_changed(self, track): + """ + Called when the track has been changed successfully. 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 have + already been played. + + :param track: the Pandora track that was just changed to. + :type track: :class:`mopidy.models.Track` + """ pass - def track_changed(self, track): + +class PandoraEventHandlingPlaybackListener(listener.Listener): + + """ + Marker interface for recipients of events sent by the playback provider. + + """ + + @staticmethod + def send(event, **kwargs): + listener.send_async(PandoraEventHandlingPlaybackListener, event, **kwargs) + + def doubleclicked(self): + """ + Called when the user performed a doubleclick action (i.e. pause/back, pause/resume, pause, next). + + """ pass diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 1418e64..4114f2b 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -71,7 +71,7 @@ def change_track(self, track): return False except Unplayable as e: logger.error('Error changing track: ({})'.format(encoding.locale_decode(e))) - self.backend.more_tracks_needed(auto_play=True) + self.backend.prepare_next_track() return False except MaxSkipLimitExceeded as e: logger.error('Error changing track: ({})'.format(encoding.locale_decode(e))) @@ -84,9 +84,9 @@ def _trigger_track_changed(self, track): listener.PandoraPlaybackListener.send(listener.PandoraPlaybackListener.track_changed.__name__, track=track) -class EventSupportPlaybackProvider(PandoraPlaybackProvider): +class EventHandlingPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): - super(EventSupportPlaybackProvider, self).__init__(audio, backend) + super(EventHandlingPlaybackProvider, self).__init__(audio, backend) self.double_click_interval = float(backend.config.get('double_click_interval')) self._click_time = 0 @@ -113,23 +113,24 @@ def change_track(self, track): if self.is_double_click(): self._trigger_doubleclicked() - return super(EventSupportPlaybackProvider, self).change_track(track) + return super(EventHandlingPlaybackProvider, self).change_track(track) def resume(self): if self.is_double_click() and self.get_time_position() > 0: self._trigger_doubleclicked() - return super(EventSupportPlaybackProvider, self).resume() + return super(EventHandlingPlaybackProvider, self).resume() def pause(self): if self.get_time_position() > 0: self.set_click_time() - return super(EventSupportPlaybackProvider, self).pause() + return super(EventHandlingPlaybackProvider, self).pause() def _trigger_doubleclicked(self): self.set_click_time(0) - listener.PandoraPlaybackListener.send(listener.PandoraPlaybackListener.doubleclicked.__name__) + listener.PandoraEventHandlingPlaybackListener.send( + listener.PandoraEventHandlingPlaybackListener.doubleclicked.__name__) class MaxSkipLimitExceeded(Exception): diff --git a/tests/test_backend.py b/tests/test_backend.py index 9282733..9a328a7 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -53,7 +53,7 @@ def test_playback_provider_selection_events_enabled(config): config['pandora']['event_support_enabled'] = 'true' backend = get_backend(config) - assert isinstance(backend.playback, playback.EventSupportPlaybackProvider) + assert isinstance(backend.playback, playback.EventHandlingPlaybackProvider) def test_on_start_logs_in(config): diff --git a/tests/test_playback.py b/tests/test_playback.py index f55ce75..0d4a68b 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -15,7 +15,7 @@ from mopidy_pandora.backend import MopidyAPIClient from mopidy_pandora.library import PandoraLibraryProvider -from mopidy_pandora.playback import EventSupportPlaybackProvider, PandoraPlaybackProvider +from mopidy_pandora.playback import EventHandlingPlaybackProvider, PandoraPlaybackProvider from mopidy_pandora.uri import TrackUri @@ -32,7 +32,7 @@ def provider(audio_mock, config): provider = None if config['pandora']['event_support_enabled']: - provider = playback.EventSupportPlaybackProvider( + provider = playback.EventHandlingPlaybackProvider( audio=audio_mock, backend=conftest.get_backend(config)) provider.current_tl_track = {'track': {'uri': 'test'}} @@ -92,25 +92,22 @@ def test_resume_checks_for_double_click(provider): def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): - with mock.patch.object(EventSupportPlaybackProvider, 'is_double_click', return_value=False): + with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=None): track = TrackUri.from_track(playlist_item_mock) - process_click_mock = mock.PropertyMock() - provider.process_click = process_click_mock - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} provider.next_tl_track = {'track': {'uri': track.uri}} - provider.backend.end_of_tracklist_reached = mock.PropertyMock() + provider.backend.prepare_next_track = mock.PropertyMock() for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): assert provider.change_track(track) is False if i < PandoraPlaybackProvider.SKIP_LIMIT-1: - assert provider.backend.end_of_tracklist_reached.called - provider.backend.end_of_tracklist_reached.reset_mock() + assert provider.backend.prepare_next_track.called + provider.backend.prepare_next_track.reset_mock() else: - assert not provider.backend.end_of_tracklist_reached.called + assert not provider.backend.prepare_next_track.called assert 'Maximum track skip limit ({:d}) exceeded, stopping...'.format( PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() From 23e05a094c80ab5c029f3829e9a8a7603251183a Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 23 Dec 2015 20:25:00 +0200 Subject: [PATCH 120/311] Fix issue where tracklist length was not determined correctly. --- mopidy_pandora/frontend.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 94cf3d3..3df30de 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -111,7 +111,8 @@ def next_track_available(self, track): def add_track(self, track): # Add the next Pandora track - tl_tracks = self.core.tracklist.add(uris=[track.uri]).get() + self.core.tracklist.add(uris=[track.uri]).get() + tl_tracks = self.core.tracklist.get_tl_tracks().get() if self.core.playback.get_state() == PlaybackState.STOPPED: # Playback stopped after previous track was unplayable. Resume playback with the freshly seeded tracklist. self.core.playback.play(tl_tracks[-1]) From 667a4e95ea716e8f98484c68388725c254197e19 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 23 Dec 2015 21:01:11 +0200 Subject: [PATCH 121/311] Update URI object handling and test cases. --- mopidy_pandora/uri.py | 4 +-- tests/test_uri.py | 82 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 2b22ea6..cffaf9b 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -93,7 +93,7 @@ def __repr__(self): @classmethod def from_station(cls, station): if station.id.startswith('G') and station.id == station.token: - raise TypeError('Cannot instantiate StationUri from genre station: {}'.format(station)) + return GenreStationUri(station.id, station.token) return StationUri(station.id, station.token) @@ -103,7 +103,7 @@ class GenreStationUri(StationUri): @classmethod def from_station(cls, station): if not (station.id.startswith('G') and station.id == station.token): - raise TypeError('Not a genre station: {}'.format(station)) + return StationUri(station.id, station.token) return GenreStationUri(station.id, station.token) diff --git a/tests/test_uri.py b/tests/test_uri.py index 0daf11e..90e82ba 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -3,9 +3,11 @@ import conftest +from mock import mock + import pytest -from mopidy_pandora.uri import AdItemUri, PandoraUri, PlaylistItemUri, StationUri, TrackUri +from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, PlaylistItemUri, StationUri, TrackUri def test_pandora_parse_mock_uri(): @@ -36,12 +38,18 @@ def test_pandora_parse_none_mock_uri(): assert uri.encode(None) == '' -def test_pandora_parse_invalid_mock_uri(): +def test_pandora_parse_invalid_type_raises_exception(): with pytest.raises(NotImplementedError): PandoraUri().parse('pandora:invalid') +def test_pandora_parse_invalid_scheme_raises_exception(): + with pytest.raises(NotImplementedError): + + PandoraUri().parse('not_the_pandora_scheme:invalid') + + def test_station_uri_from_station(station_mock): station_uri = StationUri.from_station(station_mock) @@ -58,7 +66,7 @@ def test_station_uri_parse(station_mock): obj = PandoraUri.parse(station_uri.uri) - assert isinstance(obj, StationUri) + assert type(obj) is StationUri assert obj.uri_type == conftest.MOCK_STATION_TYPE assert obj.station_id == conftest.MOCK_STATION_ID @@ -67,6 +75,72 @@ def test_station_uri_parse(station_mock): assert obj.uri == station_uri.uri +def test_station_uri_parse_returns_correct_type(): + + station_mock = mock.PropertyMock() + station_mock.id = 'Gmock' + station_mock.token = 'Gmock' + + obj = StationUri.from_station(station_mock) + + assert type(obj) is GenreStationUri + + +def test_genre_uri_parse(): + + mock_uri = 'pandora:genre:mock_category' + obj = PandoraUri.parse(mock_uri) + + assert type(obj) is GenreUri + + assert obj.uri_type == 'genre' + assert obj.category_name == 'mock_category' + + assert obj.uri == mock_uri + + +def test_genre_station_uri_parse(): + + mock_uri = 'pandora:genre_station:mock_id:mock_token' + obj = PandoraUri.parse(mock_uri) + + assert type(obj) is GenreStationUri + + assert obj.uri_type == 'genre_station' + assert obj.station_id == 'mock_id' + assert obj.token == 'mock_token' + + assert obj.uri == mock_uri + + +def test_genre_station_uri_from_station_returns_correct_type(): + + genre_mock = mock.PropertyMock() + genre_mock.id = 'mock_id' + genre_mock.token = 'mock_token' + + obj = GenreStationUri.from_station(genre_mock) + + assert type(obj) is StationUri + + +def test_genre_station_uri_from_station(): + + genre_station_mock = mock.PropertyMock() + genre_station_mock.id = 'Gmock' + genre_station_mock.token = 'Gmock' + + obj = GenreStationUri.from_station(genre_station_mock) + + assert type(obj) is GenreStationUri + + assert obj.uri_type == 'genre_station' + assert obj.station_id == 'Gmock' + assert obj.token == 'Gmock' + + assert obj.uri == 'pandora:genre_station:Gmock:Gmock' + + def test_track_uri_from_track(playlist_item_mock): track_uri = TrackUri.from_track(playlist_item_mock) @@ -91,7 +165,7 @@ def test_track_uri_parse(playlist_item_mock): obj = PandoraUri.parse(track_uri.uri) - assert isinstance(obj, TrackUri) + assert type(obj) is PlaylistItemUri assert obj.uri_type == conftest.MOCK_TRACK_TYPE assert obj.station_id == conftest.MOCK_STATION_ID From 71811359317f32f825cc7d08092bffbaee418c3c Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 24 Dec 2015 07:34:26 +0200 Subject: [PATCH 122/311] Refactor PandoraUri into factory. Update test cases. --- mopidy_pandora/backend.py | 10 ++-- mopidy_pandora/frontend.py | 4 +- mopidy_pandora/library.py | 16 +++--- mopidy_pandora/uri.py | 55 ++++++++++++--------- tests/conftest.py | 4 +- tests/test_library.py | 12 ++--- tests/test_playback.py | 2 +- tests/test_uri.py | 99 ++++++++++++++++++++++++-------------- 8 files changed, 119 insertions(+), 83 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 82c042d..8152b61 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -84,19 +84,19 @@ def process_event(self, track_uri, pandora_event): return False def thumbs_up(self, track_uri): - return self.api.add_feedback(PandoraUri.parse(track_uri).token, True) + return self.api.add_feedback(PandoraUri.factory(track_uri).token, True) def thumbs_down(self, track_uri): - return self.api.add_feedback(PandoraUri.parse(track_uri).token, False) + return self.api.add_feedback(PandoraUri.factory(track_uri).token, False) def sleep(self, track_uri): - return self.api.sleep_song(PandoraUri.parse(track_uri).token) + return self.api.sleep_song(PandoraUri.factory(track_uri).token) def add_artist_bookmark(self, track_uri): - return self.api.add_artist_bookmark(PandoraUri.parse(track_uri).token) + 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.parse(track_uri).token) + return self.api.add_song_bookmark(PandoraUri.factory(track_uri).token) def _trigger_next_track_available(self, track): (listener.PandoraBackendListener.send(listener.PandoraBackendListener.next_track_available.__name__, diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 3df30de..8242ba1 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -34,7 +34,7 @@ def check_pandora(self, *args, **kwargs): :return: the return value of the function if it was run or 'None' otherwise. """ try: - PandoraUri.parse(self.core.playback.get_current_tl_track().get().track.uri) + PandoraUri.factory(self.core.playback.get_current_tl_track().get().track.uri) return func(self, *args, **kwargs) except (AttributeError, NotImplementedError): # Not playing a Pandora track. Don't do anything. @@ -173,7 +173,7 @@ def _process_events(self, track_uri, time_position): event_target_uri = self._get_event_target_uri(track_uri, time_position) assert event_target_uri - if type(PandoraUri.parse(event_target_uri)) is AdItemUri: + if type(PandoraUri.factory(event_target_uri)) is AdItemUri: logger.info('Ignoring doubleclick event for advertisement') self.event_processed_event.set() return diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index cc814ef..11694f9 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -41,7 +41,7 @@ def browse(self, uri): if uri == self.genre_directory.uri: return self._browse_genre_categories() - pandora_uri = PandoraUri.parse(uri) + pandora_uri = PandoraUri.factory(uri) if type(pandora_uri) is GenreUri: return self._browse_genre_stations(uri) @@ -53,7 +53,7 @@ def browse(self, uri): def lookup(self, uri): - pandora_uri = PandoraUri.parse(uri) + pandora_uri = PandoraUri.factory(uri) if isinstance(pandora_uri, TrackUri): try: pandora_track = self.lookup_pandora_track(uri) @@ -118,14 +118,14 @@ def _browse_stations(self): for station in self._formatted_station_list(stations): station_directories.append( - models.Ref.directory(name=station.name, uri=StationUri.from_station(station).uri)) + 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.parse(uri) + pandora_uri = PandoraUri.factory(uri) if self._station is None or (pandora_uri.station_id != self._station.id): @@ -144,16 +144,16 @@ def _create_station_for_genre(self, genre_token): # Invalidate the cache so that it is refreshed on the next request self.backend.api._station_list_cache.popitem() - return StationUri.from_station(new_station) + 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=GenreStationUri.from_station(station).uri) + return [models.Ref.directory(name=station.name, uri=PandoraUri.factory(station).uri) for station in self.backend.api.get_genre_stations() - [PandoraUri.parse(uri).category_name]] + [PandoraUri.factory(uri).category_name]] def lookup_pandora_track(self, uri): return self._pandora_track_buffer[uri] @@ -170,7 +170,7 @@ def get_next_pandora_track(self): self._station.name)) return None - track_uri = TrackUri.from_track(pandora_track) + track_uri = PandoraUri.factory(pandora_track) if type(track_uri) is AdItemUri: track_name = 'Advertisement' diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index cffaf9b..f62b64e 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -1,8 +1,7 @@ import logging import urllib -from pandora.models.pandora import AdItem, PlaylistItem - +from pandora.models.pandora import AdItem, PlaylistItem, Station logger = logging.getLogger(__name__) @@ -51,7 +50,18 @@ def decode(cls, value): return urllib.unquote(value).decode('utf8') @classmethod - def parse(cls, uri): + def factory(cls, obj): + if isinstance(obj, basestring): + return PandoraUri._from_uri(obj) + elif isinstance(obj, Station): + return PandoraUri._from_station(obj) + elif isinstance(obj, PlaylistItem) or isinstance(obj, AdItem): + return PandoraUri._from_track(obj) + else: + raise NotImplementedError("Unsupported URI object type '{}'".format(obj)) + + @classmethod + def _from_uri(cls, uri): parts = [cls.decode(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)) @@ -61,6 +71,24 @@ def parse(cls, uri): else: raise NotImplementedError("Unsupported Pandora URI type '{}'".format(uri)) + @classmethod + def _from_station(cls, station): + if isinstance(station, Station): + if station.id.startswith('G') 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) + else: + raise NotImplementedError("Unsupported playlist item type '{}'".format(track)) + class GenreUri(PandoraUri): uri_type = 'genre' @@ -90,35 +118,14 @@ def __repr__(self): **self.encoded_attributes ) - @classmethod - def from_station(cls, station): - if station.id.startswith('G') and station.id == station.token: - return GenreStationUri(station.id, station.token) - return StationUri(station.id, station.token) - class GenreStationUri(StationUri): uri_type = 'genre_station' - @classmethod - def from_station(cls, station): - if not (station.id.startswith('G') and station.id == station.token): - return StationUri(station.id, station.token) - return GenreStationUri(station.id, station.token) - class TrackUri(PandoraUri): uri_type = 'track' - @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) - else: - raise NotImplementedError("Unsupported playlist item type '{}'".format(track)) - class PlaylistItemUri(TrackUri): diff --git a/tests/conftest.py b/tests/conftest.py index 9323ce7..008ebdf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -237,8 +237,10 @@ def playlist_item_mock(): @pytest.fixture(scope='session') def ad_item_mock(): - return AdItem.from_json(get_backend( + ad_item = AdItem.from_json(get_backend( config()).api, ad_metadata_result_mock()['result']) + ad_item.station_id = MOCK_STATION_ID + return ad_item @pytest.fixture diff --git a/tests/test_library.py b/tests/test_library.py index e54f574..de439a5 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -30,7 +30,7 @@ def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) - track_uri = PlaylistItemUri.from_track(playlist_item_mock) + track_uri = PlaylistItemUri._from_track(playlist_item_mock) backend.library._pandora_track_buffer[track_uri.uri] = playlist_item_mock results = backend.library.lookup(track_uri.uri) @@ -46,7 +46,7 @@ def test_lookup_of_missing_track(config, playlist_item_mock, caplog): backend = conftest.get_backend(config) - track_uri = TrackUri.from_track(playlist_item_mock) + track_uri = TrackUri._from_track(playlist_item_mock) results = backend.library.lookup(track_uri.uri) assert len(results) == 0 @@ -68,17 +68,17 @@ def test_browse_directory_uri(config): assert results[1].type == models.Ref.DIRECTORY assert results[1].name.startswith('QuickMix') - assert results[1].uri == StationUri.from_station( + assert results[1].uri == StationUri._from_station( Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][2])).uri assert results[2].type == models.Ref.DIRECTORY assert results[2].name == conftest.MOCK_STATION_NAME + ' 2' - assert results[2].uri == StationUri.from_station( + assert results[2].uri == StationUri._from_station( Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][0])).uri assert results[3].type == models.Ref.DIRECTORY assert results[3].name == conftest.MOCK_STATION_NAME + ' 1' - assert results[3].uri == StationUri.from_station( + assert results[3].uri == StationUri._from_station( Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][1])).uri @@ -115,7 +115,7 @@ def test_browse_station_uri(config, station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): backend = conftest.get_backend(config) - station_uri = StationUri.from_station(station_mock) + station_uri = StationUri._from_station(station_mock) results = backend.library.browse(station_uri.uri) diff --git a/tests/test_playback.py b/tests/test_playback.py index 0d4a68b..1af7673 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -94,7 +94,7 @@ def test_resume_checks_for_double_click(provider): def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=None): - track = TrackUri.from_track(playlist_item_mock) + track = TrackUri._from_track(playlist_item_mock) provider.previous_tl_track = {'track': {'uri': 'previous_track'}} provider.next_tl_track = {'track': {'uri': track.uri}} diff --git a/tests/test_uri.py b/tests/test_uri.py index 90e82ba..2c3d7fe 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -5,16 +5,23 @@ from mock import mock +from pandora.models.pandora import Station + import pytest from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, PlaylistItemUri, StationUri, TrackUri +def test_factory_unsupported_type(): + with pytest.raises(NotImplementedError): + + PandoraUri.factory(0) + + def test_pandora_parse_mock_uri(): uri = 'pandora:station:mock_id:mock_token' - - obj = PandoraUri.parse(uri) + obj = PandoraUri._from_uri(uri) assert isinstance(obj, PandoraUri) assert type(obj) is StationUri @@ -24,47 +31,60 @@ def test_pandora_parse_mock_uri(): def test_pandora_parse_unicode_mock_uri(): uri = PlaylistItemUri(conftest.MOCK_STATION_ID, 'Ω≈ç√∫:˜µ≤≥÷') - - obj = PandoraUri.parse(uri.uri) + obj = PandoraUri._from_uri(uri.uri) assert isinstance(obj, PandoraUri) assert obj.uri == uri.uri +def test_pandora_repr_converts_to_string(): + + uri = 'pandora:station:mock_id:' + obj = PandoraUri._from_uri(uri) + + obj.token = 0 + assert obj.uri == uri + '0' + + def test_pandora_parse_none_mock_uri(): uri = PandoraUri() - assert uri.encode(None) == '' def test_pandora_parse_invalid_type_raises_exception(): with pytest.raises(NotImplementedError): - PandoraUri().parse('pandora:invalid') + PandoraUri()._from_uri('pandora:invalid') def test_pandora_parse_invalid_scheme_raises_exception(): with pytest.raises(NotImplementedError): - PandoraUri().parse('not_the_pandora_scheme:invalid') + PandoraUri()._from_uri('not_the_pandora_scheme:invalid') def test_station_uri_from_station(station_mock): - station_uri = StationUri.from_station(station_mock) + station_uri = StationUri._from_station(station_mock) + + assert station_uri.uri == '{}:{}:{}:{}'.format(PandoraUri.SCHEME, + station_uri.encode(conftest.MOCK_STATION_TYPE), + station_uri.encode(conftest.MOCK_STATION_ID), + station_uri.encode(conftest.MOCK_STATION_TOKEN)) + + +def test_station_uri_from_station_unsupported_type(playlist_result_mock): + with pytest.raises(NotImplementedError): - assert station_uri.uri == 'pandora:' + \ - station_uri.encode(conftest.MOCK_STATION_TYPE) + ':' + \ - station_uri.encode(conftest.MOCK_STATION_ID) + ':' + \ - station_uri.encode(conftest.MOCK_STATION_TOKEN) + PandoraUri._from_station(playlist_result_mock) def test_station_uri_parse(station_mock): - station_uri = StationUri.from_station(station_mock) + station_uri = StationUri._from_station(station_mock) - obj = PandoraUri.parse(station_uri.uri) + obj = PandoraUri._from_uri(station_uri.uri) assert type(obj) is StationUri @@ -77,11 +97,11 @@ def test_station_uri_parse(station_mock): def test_station_uri_parse_returns_correct_type(): - station_mock = mock.PropertyMock() + station_mock = mock.PropertyMock(spec=Station) station_mock.id = 'Gmock' station_mock.token = 'Gmock' - obj = StationUri.from_station(station_mock) + obj = StationUri._from_station(station_mock) assert type(obj) is GenreStationUri @@ -89,7 +109,7 @@ def test_station_uri_parse_returns_correct_type(): def test_genre_uri_parse(): mock_uri = 'pandora:genre:mock_category' - obj = PandoraUri.parse(mock_uri) + obj = PandoraUri._from_uri(mock_uri) assert type(obj) is GenreUri @@ -102,7 +122,7 @@ def test_genre_uri_parse(): def test_genre_station_uri_parse(): mock_uri = 'pandora:genre_station:mock_id:mock_token' - obj = PandoraUri.parse(mock_uri) + obj = PandoraUri._from_uri(mock_uri) assert type(obj) is GenreStationUri @@ -115,22 +135,22 @@ def test_genre_station_uri_parse(): def test_genre_station_uri_from_station_returns_correct_type(): - genre_mock = mock.PropertyMock() + genre_mock = mock.PropertyMock(spec=Station) genre_mock.id = 'mock_id' genre_mock.token = 'mock_token' - obj = GenreStationUri.from_station(genre_mock) + obj = GenreStationUri._from_station(genre_mock) assert type(obj) is StationUri def test_genre_station_uri_from_station(): - genre_station_mock = mock.PropertyMock() + genre_station_mock = mock.PropertyMock(spec=Station) genre_station_mock.id = 'Gmock' genre_station_mock.token = 'Gmock' - obj = GenreStationUri.from_station(genre_station_mock) + obj = GenreStationUri._from_station(genre_station_mock) assert type(obj) is GenreStationUri @@ -143,27 +163,34 @@ def test_genre_station_uri_from_station(): def test_track_uri_from_track(playlist_item_mock): - track_uri = TrackUri.from_track(playlist_item_mock) + track_uri = TrackUri._from_track(playlist_item_mock) + + assert track_uri.uri == '{}:{}:{}:{}'.format(PandoraUri.SCHEME, + track_uri.encode(conftest.MOCK_TRACK_TYPE), + track_uri.encode(conftest.MOCK_STATION_ID), + track_uri.encode(conftest.MOCK_TRACK_TOKEN)) + + +def test_track_uri_from_track_unsupported_type(playlist_result_mock): + with pytest.raises(NotImplementedError): - assert track_uri.uri == 'pandora:' + \ - track_uri.encode(conftest.MOCK_TRACK_TYPE) + ':' + \ - track_uri.encode(conftest.MOCK_STATION_ID) + ':' + \ - track_uri.encode(conftest.MOCK_TRACK_TOKEN) + PandoraUri._from_track(playlist_result_mock) def test_track_uri_from_track_for_ads(ad_item_mock): - track_uri = TrackUri.from_track(ad_item_mock) + track_uri = TrackUri._from_track(ad_item_mock) - assert track_uri.uri == 'pandora:' + \ - track_uri.encode(conftest.MOCK_AD_TYPE) + ':' + assert track_uri.uri == '{}:{}:{}'.format(PandoraUri.SCHEME, + track_uri.encode(conftest.MOCK_AD_TYPE), + conftest.MOCK_STATION_ID) def test_track_uri_parse(playlist_item_mock): - track_uri = TrackUri.from_track(playlist_item_mock) + track_uri = TrackUri._from_track(playlist_item_mock) - obj = PandoraUri.parse(track_uri.uri) + obj = PandoraUri._from_uri(track_uri.uri) assert type(obj) is PlaylistItemUri @@ -176,12 +203,12 @@ def test_track_uri_parse(playlist_item_mock): def test_track_uri_is_ad(playlist_item_mock, ad_item_mock): - track_uri = TrackUri.from_track(ad_item_mock) - obj = PandoraUri.parse(track_uri.uri) + track_uri = TrackUri._from_track(ad_item_mock) + obj = PandoraUri._from_uri(track_uri.uri) assert type(obj) is AdItemUri - track_uri = TrackUri.from_track(playlist_item_mock) - obj = PandoraUri.parse(track_uri.uri) + track_uri = TrackUri._from_track(playlist_item_mock) + obj = PandoraUri._from_uri(track_uri.uri) assert type(obj) is not AdItemUri From 7b3f00050be1ad99a14ea9fd4aac64c4f0dcb151 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 24 Dec 2015 09:27:38 +0200 Subject: [PATCH 123/311] Fixes and test cases for utils functions. --- mopidy_pandora/utils.py | 26 ++--------- tests/test_utils.py | 101 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 21 deletions(-) create mode 100644 tests/test_utils.py diff --git a/mopidy_pandora/utils.py b/mopidy_pandora/utils.py index ea41714..df68339 100644 --- a/mopidy_pandora/utils.py +++ b/mopidy_pandora/utils.py @@ -1,7 +1,5 @@ import json -from mopidy import httpclient - import requests @@ -23,13 +21,13 @@ def async_func(*args, **kwargs): 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. """ - queue = kwargs.get('queue', None) - t = Thread(target=func, args=args, kwargs=kwargs) - t.start() + queue = kwargs.get('queue', None) if queue is not None: t.result_queue = queue + + t.start() return t return async_func @@ -39,8 +37,8 @@ def format_proxy(proxy_config): if not proxy_config.get('hostname'): return None - port = proxy_config.get('port', 80) - if port < 0: + port = proxy_config.get('port') + if not port or port < 0: port = 80 template = '{hostname}:{port}' @@ -48,17 +46,6 @@ def format_proxy(proxy_config): return template.format(hostname=proxy_config['hostname'], port=port) -def get_requests_session(proxy_config, user_agent): - proxy = httpclient.format_proxy(proxy_config) - full_user_agent = httpclient.format_user_agent(user_agent) - - session = requests.Session() - session.proxies.update({'http': proxy, 'https': proxy}) - session.headers.update({'user-agent': full_user_agent}) - - return session - - class RPCClient(object): hostname = '127.0.0.1' port = '6680' @@ -79,7 +66,6 @@ def _do_rpc(cls, method, params=None, queue=None): :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. - :return: the 'result' element of the json results list returned by the remote procedure call. """ cls.id += 1 data = {'method': method, 'jsonrpc': '2.0', 'id': cls.id} @@ -91,5 +77,3 @@ def _do_rpc(cls, method, params=None, queue=None): headers={'Content-Type': 'application/json'}).text) if queue is not None: queue.put(json_data['result']) - - return json_data['result'] diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..72ac9dd --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,101 @@ +import Queue + +import json + +import logging + +from mock import mock + +import requests + +from mopidy_pandora import utils +from mopidy_pandora.utils import run_async + +logger = logging.getLogger(__name__) + + +def test_format_proxy(): + config = { + 'proxy': { + 'hostname': 'mock_host', + 'port': '8080' + } + } + + assert utils.format_proxy(config['proxy']) == 'mock_host:8080' + + +def test_format_proxy_no_hostname(): + config = { + 'proxy': { + 'hostname': '', + 'port': 'mock_port' + } + } + + assert utils.format_proxy(config['proxy']) is None + + +def test_format_proxy_no_port(): + config = { + 'proxy': { + 'hostname': 'mock_host', + 'port': '' + } + } + + assert utils.format_proxy(config['proxy']) == 'mock_host:80' + + +def test_rpc_client_uses_mopidy_defaults(): + + assert utils.RPCClient.hostname == '127.0.0.1' + assert utils.RPCClient.port == '6680' + + assert utils.RPCClient.url == 'http://127.0.0.1:6680/mopidy/rpc' + + +def test_do_rpc(): + utils.RPCClient.configure('mock_host', 'mock_port') + assert utils.RPCClient.hostname == 'mock_host' + assert utils.RPCClient.port == 'mock_port' + + response_mock = mock.PropertyMock(spec=requests.Response) + response_mock.text = '{"result": "mock_result"}' + requests.request = mock.PropertyMock(return_value=response_mock) + + q = Queue.Queue() + utils.RPCClient._do_rpc('mock_method', + params={'mock_param_1': 'mock_value_1', 'mock_param_2': 'mock_value_2'}, + queue=q) + + assert q.get() == 'mock_result' + + +def test_do_rpc_increments_id(): + requests.request = mock.PropertyMock() + json.loads = mock.PropertyMock() + + current_id = utils.RPCClient.id + t = utils.RPCClient._do_rpc('mock_method') + t.join() + assert utils.RPCClient.id == current_id + 1 + + +def test_run_async(caplog): + t = async_func('test_1_async') + t.join() + assert 'test_1_async' in caplog.text() + + +def test_run_async_queue(caplog): + q = Queue.Queue() + async_func('test_2_async', queue=q) + assert 'test_2_async' in caplog.text() + assert q.get() == 'test_value' + + +@run_async +def async_func(text, queue=None): + logger.info(text) + queue.put('test_value') From d2dc185444879983efa628a840fcdcad3932f1a0 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 24 Dec 2015 13:47:03 +0200 Subject: [PATCH 124/311] Reformat test cases. --- tests/test_uri.py | 15 --------------- tests/test_utils.py | 4 ++++ 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/tests/test_uri.py b/tests/test_uri.py index 2c3d7fe..a4400e1 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -19,7 +19,6 @@ def test_factory_unsupported_type(): def test_pandora_parse_mock_uri(): - uri = 'pandora:station:mock_id:mock_token' obj = PandoraUri._from_uri(uri) @@ -29,7 +28,6 @@ def test_pandora_parse_mock_uri(): def test_pandora_parse_unicode_mock_uri(): - uri = PlaylistItemUri(conftest.MOCK_STATION_ID, 'Ω≈ç√∫:˜µ≤≥÷') obj = PandoraUri._from_uri(uri.uri) @@ -38,7 +36,6 @@ def test_pandora_parse_unicode_mock_uri(): def test_pandora_repr_converts_to_string(): - uri = 'pandora:station:mock_id:' obj = PandoraUri._from_uri(uri) @@ -47,7 +44,6 @@ def test_pandora_repr_converts_to_string(): def test_pandora_parse_none_mock_uri(): - uri = PandoraUri() assert uri.encode(None) == '' @@ -65,7 +61,6 @@ def test_pandora_parse_invalid_scheme_raises_exception(): def test_station_uri_from_station(station_mock): - station_uri = StationUri._from_station(station_mock) assert station_uri.uri == '{}:{}:{}:{}'.format(PandoraUri.SCHEME, @@ -81,7 +76,6 @@ def test_station_uri_from_station_unsupported_type(playlist_result_mock): def test_station_uri_parse(station_mock): - station_uri = StationUri._from_station(station_mock) obj = PandoraUri._from_uri(station_uri.uri) @@ -96,7 +90,6 @@ def test_station_uri_parse(station_mock): def test_station_uri_parse_returns_correct_type(): - station_mock = mock.PropertyMock(spec=Station) station_mock.id = 'Gmock' station_mock.token = 'Gmock' @@ -107,7 +100,6 @@ def test_station_uri_parse_returns_correct_type(): def test_genre_uri_parse(): - mock_uri = 'pandora:genre:mock_category' obj = PandoraUri._from_uri(mock_uri) @@ -120,7 +112,6 @@ def test_genre_uri_parse(): def test_genre_station_uri_parse(): - mock_uri = 'pandora:genre_station:mock_id:mock_token' obj = PandoraUri._from_uri(mock_uri) @@ -134,7 +125,6 @@ def test_genre_station_uri_parse(): def test_genre_station_uri_from_station_returns_correct_type(): - genre_mock = mock.PropertyMock(spec=Station) genre_mock.id = 'mock_id' genre_mock.token = 'mock_token' @@ -145,7 +135,6 @@ def test_genre_station_uri_from_station_returns_correct_type(): def test_genre_station_uri_from_station(): - genre_station_mock = mock.PropertyMock(spec=Station) genre_station_mock.id = 'Gmock' genre_station_mock.token = 'Gmock' @@ -162,7 +151,6 @@ def test_genre_station_uri_from_station(): def test_track_uri_from_track(playlist_item_mock): - track_uri = TrackUri._from_track(playlist_item_mock) assert track_uri.uri == '{}:{}:{}:{}'.format(PandoraUri.SCHEME, @@ -178,7 +166,6 @@ def test_track_uri_from_track_unsupported_type(playlist_result_mock): def test_track_uri_from_track_for_ads(ad_item_mock): - track_uri = TrackUri._from_track(ad_item_mock) assert track_uri.uri == '{}:{}:{}'.format(PandoraUri.SCHEME, @@ -187,7 +174,6 @@ def test_track_uri_from_track_for_ads(ad_item_mock): def test_track_uri_parse(playlist_item_mock): - track_uri = TrackUri._from_track(playlist_item_mock) obj = PandoraUri._from_uri(track_uri.uri) @@ -202,7 +188,6 @@ def test_track_uri_parse(playlist_item_mock): def test_track_uri_is_ad(playlist_item_mock, ad_item_mock): - track_uri = TrackUri._from_track(ad_item_mock) obj = PandoraUri._from_uri(track_uri.uri) diff --git a/tests/test_utils.py b/tests/test_utils.py index 72ac9dd..2670d89 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -34,6 +34,8 @@ def test_format_proxy_no_hostname(): } assert utils.format_proxy(config['proxy']) is None + config.pop('hostname') + assert utils.format_proxy(config['proxy']) is None def test_format_proxy_no_port(): @@ -45,6 +47,8 @@ def test_format_proxy_no_port(): } assert utils.format_proxy(config['proxy']) == 'mock_host:80' + config.pop('port') + assert utils.format_proxy(config['proxy']) == 'mock_host:80' def test_rpc_client_uses_mopidy_defaults(): From fc592fc3899b6b52095b3cb48ab707a9299216a1 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 24 Dec 2015 17:18:20 +0200 Subject: [PATCH 125/311] Fix config parameters for performing auto_setup. --- mopidy_pandora/frontend.py | 2 +- tests/test_utils.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 8242ba1..a57f93a 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -62,7 +62,7 @@ class PandoraFrontend(pykka.ThreadingActor, core.CoreListener, listener.PandoraB def __init__(self, config, core): super(PandoraFrontend, self).__init__() - self.config = config + self.config = config['pandora'] self.auto_setup = self.config.get('auto_setup') self.setup_required = True diff --git a/tests/test_utils.py b/tests/test_utils.py index 2670d89..f7d8b25 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -52,7 +52,6 @@ def test_format_proxy_no_port(): def test_rpc_client_uses_mopidy_defaults(): - assert utils.RPCClient.hostname == '127.0.0.1' assert utils.RPCClient.port == '6680' From 0fdc733b23ecf9e775b66058c0f635e719e8395b Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 24 Dec 2015 17:29:56 +0200 Subject: [PATCH 126/311] Initial frontend test case. --- tests/dummy_backend.py | 145 +++++++++++++++++++++++++++++++++++++++++ tests/test_frontend.py | 63 ++++++++++++++++++ tests/test_utils.py | 6 +- 3 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 tests/dummy_backend.py create mode 100644 tests/test_frontend.py diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py new file mode 100644 index 0000000..26d7fbf --- /dev/null +++ b/tests/dummy_backend.py @@ -0,0 +1,145 @@ +"""A dummy backend for use in tests. + +This backend implements the backend API in the simplest way possible. It is +used in tests of the frontends. +""" + +from __future__ import absolute_import, unicode_literals + +from mopidy import backend +from mopidy.models import Playlist, Ref, SearchResult + +import pykka + + +def create_proxy(config=None, audio=None): + return DummyBackend.start(config=config, audio=audio).proxy() + + +class DummyBackend(pykka.ThreadingActor, backend.Backend): + + def __init__(self, config, audio): + super(DummyBackend, self).__init__() + + self.library = DummyLibraryProvider(backend=self) + self.playback = DummyPlaybackProvider(audio=audio, backend=self) + self.playlists = DummyPlaylistsProvider(backend=self) + + self.uri_schemes = ['pandora'] + + +class DummyLibraryProvider(backend.LibraryProvider): + root_directory = Ref.directory(uri='dummy:/', name='dummy') + + def __init__(self, *args, **kwargs): + super(DummyLibraryProvider, self).__init__(*args, **kwargs) + self.dummy_library = [] + self.dummy_get_distinct_result = {} + self.dummy_browse_result = {} + self.dummy_find_exact_result = SearchResult() + self.dummy_search_result = SearchResult() + + def browse(self, path): + return self.dummy_browse_result.get(path, []) + + def get_distinct(self, field, query=None): + return self.dummy_get_distinct_result.get(field, set()) + + def lookup(self, uri): + return [t for t in self.dummy_library if uri == t.uri] + + def refresh(self, uri=None): + pass + + def search(self, query=None, uris=None, exact=False): + if exact: # TODO: remove uses of dummy_find_exact_result + return self.dummy_find_exact_result + return self.dummy_search_result + + +class DummyPlaybackProvider(backend.PlaybackProvider): + + def __init__(self, *args, **kwargs): + super(DummyPlaybackProvider, self).__init__(*args, **kwargs) + self._uri = None + self._time_position = 0 + + def pause(self): + return True + + def play(self): + return self._uri and self._uri != 'dummy:error' + + def change_track(self, track): + """Pass a track with URI 'dummy:error' to force failure""" + self._uri = track.uri + self._time_position = 0 + return True + + def prepare_change(self): + pass + + def resume(self): + return True + + def seek(self, time_position): + self._time_position = time_position + return True + + def stop(self): + self._uri = None + return True + + def get_time_position(self): + return self._time_position + + +class DummyPlaylistsProvider(backend.PlaylistsProvider): + + def __init__(self, backend): + super(DummyPlaylistsProvider, self).__init__(backend) + self._playlists = [] + + def set_dummy_playlists(self, playlists): + """For tests using the dummy provider through an actor proxy.""" + self._playlists = playlists + + def as_list(self): + return [ + Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] + + def get_items(self, uri): + playlist = self.lookup(uri) + if playlist is None: + return + return [ + Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] + + def lookup(self, uri): + for playlist in self._playlists: + if playlist.uri == uri: + return playlist + + def refresh(self): + pass + + def create(self, name): + playlist = Playlist(name=name, uri='dummy:%s' % name) + self._playlists.append(playlist) + return playlist + + def delete(self, uri): + playlist = self.lookup(uri) + if playlist: + self._playlists.remove(playlist) + + def save(self, playlist): + old_playlist = self.lookup(playlist.uri) + + if old_playlist is not None: + index = self._playlists.index(old_playlist) + self._playlists[index] = playlist + else: + self._playlists.append(playlist) + + return playlist diff --git a/tests/test_frontend.py b/tests/test_frontend.py new file mode 100644 index 0000000..2eae11e --- /dev/null +++ b/tests/test_frontend.py @@ -0,0 +1,63 @@ +import unittest + +from mopidy import core + +from mopidy.models import Track + +import pykka + +from mopidy_pandora.frontend import PandoraFrontend + +from tests import conftest, dummy_backend + + +class FrontendTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + + self.backend = dummy_backend.create_proxy() + + self.core = core.Core.start( + config, backends=[self.backend]).proxy() + + self.tracks = [ + Track(uri='pandora:track:mock_id1:mock_token1', length=40000), + Track(uri='pandora:track:mock_id2:mock_token2', length=40000), + Track(uri='pandora:track:mock_id3:mock_token3', length=40000), # Unplayable + Track(uri='pandora:track:mock_id4:mock_token4', length=40000), + Track(uri='pandora:track:mock_id5:mock_token5', length=None), # No duration + ] + + self.uris = [ + 'pandora:track:mock_id1:mock_token1', 'pandora:track:mock_id2:mock_token2', + 'pandora:track:mock_id3:mock_token3', 'pandora:track:mock_id4:mock_token4', + 'pandora:track:mock_id5:mock_token5'] + + def lookup(uris): + result = {uri: [] for uri in uris} + for track in self.tracks: + if track.uri in result: + result[track.uri].append(track) + return result + + self.core.library.lookup = lookup + self.tl_tracks = self.core.tracklist.add(uris=self.uris).get() + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + + def test_set_options_performs_auto_setup(self): + self.core.tracklist.set_repeat(False).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + frontend = PandoraFrontend.start(conftest.config(), self.core).proxy() + frontend.track_playback_started(self.tracks[0]).get() + assert self.core.tracklist.get_repeat().get() is True + assert self.core.tracklist.get_consume().get() is False + assert self.core.tracklist.get_random().get() is False + assert self.core.tracklist.get_single().get() is False diff --git a/tests/test_utils.py b/tests/test_utils.py index f7d8b25..5678d0d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -34,7 +34,7 @@ def test_format_proxy_no_hostname(): } assert utils.format_proxy(config['proxy']) is None - config.pop('hostname') + config['proxy'].pop('hostname') assert utils.format_proxy(config['proxy']) is None @@ -47,7 +47,7 @@ def test_format_proxy_no_port(): } assert utils.format_proxy(config['proxy']) == 'mock_host:80' - config.pop('port') + config['proxy'].pop('port') assert utils.format_proxy(config['proxy']) == 'mock_host:80' @@ -94,8 +94,8 @@ def test_run_async(caplog): def test_run_async_queue(caplog): q = Queue.Queue() async_func('test_2_async', queue=q) - assert 'test_2_async' in caplog.text() assert q.get() == 'test_value' + assert 'test_2_async' in caplog.text() @run_async From f2be0f6587ea7417a94cd98b6e2c6877d82d9fd1 Mon Sep 17 00:00:00 2001 From: John Cass Date: Thu, 24 Dec 2015 23:41:45 +0200 Subject: [PATCH 127/311] TODO's for more lightweight eventing. --- mopidy_pandora/frontend.py | 2 ++ mopidy_pandora/listener.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index a57f93a..2aae54d 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -102,6 +102,7 @@ def track_playback_paused(self, tl_track, time_position): def track_playback_resumed(self, tl_track, time_position): self.set_options() + # TODO: replace with index checking def track_changed(self, track): if self.core.tracklist.get_length().get() < 2 or track != self.core.tracklist.get_tl_tracks().get()[0].track: self._trigger_end_of_tracklist_reached() @@ -109,6 +110,7 @@ def track_changed(self, track): def next_track_available(self, track): self.add_track(track) + # TODO: replace param with just URI def add_track(self, track): # Add the next Pandora track self.core.tracklist.add(uris=[track.uri]).get() diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 8de0aa2..4621200 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -57,6 +57,7 @@ class PandoraBackendListener(backend.BackendListener): def send(event, **kwargs): listener.send_async(PandoraBackendListener, event, **kwargs) + # TODO:.only need uri, not full track def next_track_available(self, track): """ Called when the backend has the next Pandora track available to be added to the tracklist. @@ -86,6 +87,7 @@ class PandoraPlaybackListener(listener.Listener): def send(event, **kwargs): listener.send_async(PandoraPlaybackListener, event, **kwargs) + #TODO: don't need track here. def track_changed(self, track): """ Called when the track has been changed successfully. Let's the frontend know that it should probably From 2e5c501cf62ccf0d4001ea59532c65d79ff91550 Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 25 Dec 2015 08:12:26 +0200 Subject: [PATCH 128/311] Handle ad items that do not have image urls. --- mopidy_pandora/library.py | 11 +++++--- tests/conftest.py | 54 ++++++++++++++++++++++----------------- tests/test_library.py | 23 +++++++++++------ tests/test_playback.py | 4 +-- 4 files changed, 55 insertions(+), 37 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 11694f9..74f49fa 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -65,12 +65,17 @@ def lookup(self, uri): if not pandora_track.company_name or len(pandora_track.company_name) == 0: pandora_track.company_name = 'Unknown' + album = models.Album(name=pandora_track.company_name, + uri=pandora_track.click_through_url) + + if pandora_track.image_url: + # Some advertisements do not have images + album = album.replace(images=[pandora_track.image_url]) + return[models.Track(name='Advertisement', uri=uri, artists=[models.Artist(name=pandora_track.company_name)], - album=models.Album(name=pandora_track.company_name, - uri=pandora_track.click_through_url, - images=[pandora_track.image_url]) + album=album ) ] diff --git a/tests/conftest.py b/tests/conftest.py index 008ebdf..e95060e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -186,30 +186,36 @@ def playlist_result_mock(): @pytest.fixture(scope='session') def ad_metadata_result_mock(): mock_result = {'stat': 'ok', - 'result': dict(title=MOCK_TRACK_NAME, companyName='Mock Company Name', audioUrlMap={ - 'highQuality': { - 'bitrate': '64', - 'encoding': 'aacplus', - 'audioUrl': MOCK_TRACK_AUDIO_HIGH, - 'protocol': 'http' - }, - 'mediumQuality': { - 'bitrate': '64', - 'encoding': 'aacplus', - 'audioUrl': MOCK_TRACK_AUDIO_MED, - 'protocol': 'http' - }, - 'lowQuality': { - 'bitrate': '32', - 'encoding': 'aacplus', - 'audioUrl': MOCK_TRACK_AUDIO_LOW, - 'protocol': 'http' - } - }, adTrackingTokens={ - MOCK_TRACK_AD_TOKEN, - MOCK_TRACK_AD_TOKEN, - MOCK_TRACK_AD_TOKEN - })} + 'result': dict(title=MOCK_TRACK_NAME, + companyName='Mock Company Name', + clickThroughUrl='mock_click_url', + imageUrl='mock_img_url', + trackGain='0.0', + audioUrlMap={ + 'highQuality': { + 'bitrate': '64', + 'encoding': 'aacplus', + 'audioUrl': MOCK_TRACK_AUDIO_HIGH, + 'protocol': 'http' + }, + 'mediumQuality': { + 'bitrate': '64', + 'encoding': 'aacplus', + 'audioUrl': MOCK_TRACK_AUDIO_MED, + 'protocol': 'http' + }, + 'lowQuality': { + 'bitrate': '32', + 'encoding': 'aacplus', + 'audioUrl': MOCK_TRACK_AUDIO_LOW, + 'protocol': 'http' + } + }, + adTrackingTokens={ + MOCK_TRACK_AD_TOKEN + } + ) + } return mock_result diff --git a/tests/test_library.py b/tests/test_library.py index de439a5..beed933 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -14,11 +14,24 @@ from mopidy_pandora.client import MopidyAPIClient from mopidy_pandora.library import PandoraLibraryProvider -from mopidy_pandora.uri import PandoraUri, PlaylistItemUri, StationUri, TrackUri +from mopidy_pandora.uri import PandoraUri, PlaylistItemUri, StationUri from tests.conftest import get_station_list_mock +def test_lookup_of_ad_without_images(config, ad_item_mock): + + backend = conftest.get_backend(config) + + ad_uri = PandoraUri.factory('pandora:ad:' + conftest.MOCK_TRACK_AD_TOKEN) + ad_item_mock.image_url = None + backend.library._pandora_track_buffer[ad_uri.uri] = ad_item_mock + results = backend.library.lookup(ad_uri.uri) + assert len(results) == 1 + + assert results[0].uri == ad_uri.uri + + def test_lookup_of_invalid_uri(config, caplog): with pytest.raises(NotImplementedError): backend = conftest.get_backend(config) @@ -27,30 +40,25 @@ def test_lookup_of_invalid_uri(config, caplog): def test_lookup_of_track_uri(config, playlist_item_mock): - backend = conftest.get_backend(config) track_uri = PlaylistItemUri._from_track(playlist_item_mock) backend.library._pandora_track_buffer[track_uri.uri] = playlist_item_mock results = backend.library.lookup(track_uri.uri) - assert len(results) == 1 track = results[0] - assert track.uri == track_uri.uri def test_lookup_of_missing_track(config, playlist_item_mock, caplog): - backend = conftest.get_backend(config) - track_uri = TrackUri._from_track(playlist_item_mock) + track_uri = PandoraUri.factory(playlist_item_mock) results = backend.library.lookup(track_uri.uri) assert len(results) == 0 - assert 'Failed to lookup \'{}\''.format(track_uri.uri) in caplog.text() @@ -118,6 +126,5 @@ def test_browse_station_uri(config, station_mock): station_uri = StationUri._from_station(station_mock) results = backend.library.browse(station_uri.uri) - # Station should just contain the first track to be played. assert len(results) == 1 diff --git a/tests/test_playback.py b/tests/test_playback.py index 1af7673..a79adbf 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -17,7 +17,7 @@ from mopidy_pandora.playback import EventHandlingPlaybackProvider, PandoraPlaybackProvider -from mopidy_pandora.uri import TrackUri +from mopidy_pandora.uri import PandoraUri @pytest.fixture @@ -94,7 +94,7 @@ def test_resume_checks_for_double_click(provider): def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=None): - track = TrackUri._from_track(playlist_item_mock) + track = PandoraUri.factory(playlist_item_mock) provider.previous_tl_track = {'track': {'uri': 'previous_track'}} provider.next_tl_track = {'track': {'uri': track.uri}} From 46f75c4caa5fd588a991a4b1ee2215101f3a2cff Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 25 Dec 2015 08:24:59 +0200 Subject: [PATCH 129/311] Update docstring to refer to correct type. --- mopidy_pandora/listener.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 4621200..1af25e3 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -63,7 +63,7 @@ def next_track_available(self, track): 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.Track` + :type track: :class:`mopidy.models.Ref` """ pass @@ -95,7 +95,7 @@ def track_changed(self, track): already been played. :param track: the Pandora track that was just changed to. - :type track: :class:`mopidy.models.Track` + :type track: :class:`mopidy.models.Ref` """ pass From 053615d814ff689d412f16cc6a48cf0f275b8b9f Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 25 Dec 2015 08:27:05 +0200 Subject: [PATCH 130/311] Rely on Mopidy core to check for end of tracklist reached. Remove outdated TODOs. --- mopidy_pandora/frontend.py | 4 +--- mopidy_pandora/listener.py | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 2aae54d..b973b53 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -102,15 +102,13 @@ def track_playback_paused(self, tl_track, time_position): def track_playback_resumed(self, tl_track, time_position): self.set_options() - # TODO: replace with index checking def track_changed(self, track): - if self.core.tracklist.get_length().get() < 2 or track != self.core.tracklist.get_tl_tracks().get()[0].track: + if self.core.tracklist.index().get() == self.core.tracklist.get_length().get() - 1: self._trigger_end_of_tracklist_reached() def next_track_available(self, track): self.add_track(track) - # TODO: replace param with just URI def add_track(self, track): # Add the next Pandora track self.core.tracklist.add(uris=[track.uri]).get() diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 1af25e3..9176300 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -57,7 +57,6 @@ class PandoraBackendListener(backend.BackendListener): def send(event, **kwargs): listener.send_async(PandoraBackendListener, event, **kwargs) - # TODO:.only need uri, not full track def next_track_available(self, track): """ Called when the backend has the next Pandora track available to be added to the tracklist. From d35da640c5aa60b3cddf704152661b5cfd1efe7d Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 25 Dec 2015 10:33:19 +0200 Subject: [PATCH 131/311] Better handling of errors when changing track. Add more test cases. --- mopidy_pandora/listener.py | 1 - mopidy_pandora/playback.py | 29 +++++++------ tests/test_playback.py | 83 +++++++++++++++++++++++++++++++++----- 3 files changed, 87 insertions(+), 26 deletions(-) diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 9176300..aafedb7 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -86,7 +86,6 @@ class PandoraPlaybackListener(listener.Listener): def send(event, **kwargs): listener.send_async(PandoraPlaybackListener, event, **kwargs) - #TODO: don't need track here. def track_changed(self, track): """ Called when the track has been changed successfully. Let's the frontend know that it should probably diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 4114f2b..1f4211c 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -40,22 +40,21 @@ def change_pandora_track(self, track): """ try: pandora_track = self.backend.library.lookup_pandora_track(track.uri) - if not (pandora_track and pandora_track.audio_url and pandora_track.get_is_playable()): - # Track is not playable. - self._consecutive_track_skips += 1 - - if self._consecutive_track_skips >= self.SKIP_LIMIT: - raise MaxSkipLimitExceeded(('Maximum track skip limit ({:d}) exceeded, stopping...' - .format(self.SKIP_LIMIT))) - + if pandora_track.get_is_playable(): + # Success, reset track skip counter. + self._consecutive_track_skips = 0 + self._trigger_track_changed(track) + else: raise Unplayable("Track with URI '{}' is not playable".format(track.uri)) - except requests.exceptions.RequestException as e: - raise Unplayable('Error checking if track is playable: {}'.format(encoding.locale_decode(e))) + except (AttributeError, Unplayable, requests.exceptions.RequestException) as e: + # Track is not playable. + self._consecutive_track_skips += 1 - # Success, reset track skip counter. - self._consecutive_track_skips = 0 - self._trigger_track_changed(track) + if self._consecutive_track_skips >= self.SKIP_LIMIT: + raise MaxSkipLimitExceeded(('Maximum track skip limit ({:d}) exceeded.' + .format(self.SKIP_LIMIT))) + raise Unplayable("Cannot change to Pandora track '{}', ({}).".format(track.uri, encoding.locale_decode(e))) def change_track(self, track): if track.uri is None: @@ -70,11 +69,11 @@ def change_track(self, track): logger.error("Error changing track: failed to lookup '{}'".format(track.uri)) return False except Unplayable as e: - logger.error('Error changing track: ({})'.format(encoding.locale_decode(e))) + logger.error("{} Skipping to next track...".format(encoding.locale_decode(e))) self.backend.prepare_next_track() return False except MaxSkipLimitExceeded as e: - logger.error('Error changing track: ({})'.format(encoding.locale_decode(e))) + logger.error('{} Stopping...'.format(encoding.locale_decode(e))) return False def translate_uri(self, uri): diff --git a/tests/test_playback.py b/tests/test_playback.py index a79adbf..aee8833 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -8,6 +8,8 @@ from mopidy import audio, backend as backend_api, models +from pandora import APITransport + import pytest from mopidy_pandora import playback @@ -55,14 +57,6 @@ def test_is_a_playback_provider(provider): assert isinstance(provider, backend_api.PlaybackProvider) -def test_change_track_skips_if_no_track_uri(provider): - track = models.Track(uri=None) - - provider.change_pandora_track = mock.PropertyMock() - assert provider.change_track(track) is False - assert not provider.change_pandora_track.called - - def test_pause_starts_double_click_timer(provider): with mock.patch.object(PandoraPlaybackProvider, 'get_time_position', return_value=100): assert provider.backend.supports_events @@ -91,7 +85,7 @@ def test_resume_checks_for_double_click(provider): provider.is_double_click.assert_called_once_with() -def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): +def test_change_track_enforces_skip_limit_if_no_track_available(provider, playlist_item_mock, caplog): with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=None): track = PandoraUri.factory(playlist_item_mock) @@ -109,10 +103,79 @@ def test_change_track_enforces_skip_limit(provider, playlist_item_mock, caplog): else: assert not provider.backend.prepare_next_track.called - assert 'Maximum track skip limit ({:d}) exceeded, stopping...'.format( + assert 'Maximum track skip limit ({:d}) exceeded.'.format( + PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() + + +def test_change_track_enforces_skip_limit_if_no_audi_url(provider, playlist_item_mock, caplog): + with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): + track = PandoraUri.factory(playlist_item_mock) + + provider.previous_tl_track = {'track': {'uri': 'previous_track'}} + provider.next_tl_track = {'track': {'uri': track.uri}} + + provider.backend.prepare_next_track = mock.PropertyMock() + + playlist_item_mock.audio_url = None + + for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): + assert provider.change_track(track) is False + if i < PandoraPlaybackProvider.SKIP_LIMIT-1: + assert provider.backend.prepare_next_track.called + provider.backend.prepare_next_track.reset_mock() + else: + assert not provider.backend.prepare_next_track.called + + assert 'Maximum track skip limit ({:d}) exceeded.'.format( PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() +def test_change_track_enforces_skip_limit_on_request_exceptions(provider, playlist_item_mock, caplog): + with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): + with mock.patch.object(APITransport, '__call__', side_effect=conftest.request_exception_mock): + track = PandoraUri.factory(playlist_item_mock) + + provider.previous_tl_track = {'track': {'uri': 'previous_track'}} + provider.next_tl_track = {'track': {'uri': track.uri}} + + provider.backend.prepare_next_track = mock.PropertyMock() + playlist_item_mock.audio_url = 'pandora:track:mock_id:mock_token' + + for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): + assert provider.change_track(track) is False + if i < PandoraPlaybackProvider.SKIP_LIMIT-1: + assert provider.backend.prepare_next_track.called + provider.backend.prepare_next_track.reset_mock() + else: + assert not provider.backend.prepare_next_track.called + + assert 'Maximum track skip limit ({:d}) exceeded.'.format( + PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() + + +def test_change_track_skips_if_no_track_uri(provider): + track = models.Track(uri=None) + + provider.change_pandora_track = mock.PropertyMock() + assert provider.change_track(track) is False + assert not provider.change_pandora_track.called + + +def test_change_track_skips_if_track_not_available_in_buffer(provider, playlist_item_mock, caplog): + with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): + track = PandoraUri.factory(playlist_item_mock) + + provider.previous_tl_track = {'track': {'uri': 'previous_track'}} + provider.next_tl_track = {'track': {'uri': track.uri}} + + provider.backend.prepare_next_track = mock.PropertyMock() + + assert provider.change_track(track) is False + assert "Error changing track: failed to lookup '{}'".format(track.uri) in caplog.text() + + def test_translate_uri_returns_audio_url(provider, playlist_item_mock): test_uri = 'pandora:track:test_station_id:test_token' From c2df3dbeb117bf228443be37ece782e93a9ad0cc Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 25 Dec 2015 10:40:56 +0200 Subject: [PATCH 132/311] Test case for pre-fetching next track on track change errors. --- tests/test_playback.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_playback.py b/tests/test_playback.py index aee8833..3a75209 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -107,7 +107,7 @@ def test_change_track_enforces_skip_limit_if_no_track_available(provider, playli PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() -def test_change_track_enforces_skip_limit_if_no_audi_url(provider, playlist_item_mock, caplog): +def test_change_track_enforces_skip_limit_if_no_audio_url(provider, playlist_item_mock, caplog): with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): track = PandoraUri.factory(playlist_item_mock) @@ -155,6 +155,22 @@ def test_change_track_enforces_skip_limit_on_request_exceptions(provider, playli PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() +def test_change_track_fetches_next_track_if_unplayable(provider, playlist_item_mock, caplog): + with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=None): + track = PandoraUri.factory(playlist_item_mock) + + provider.previous_tl_track = {'track': {'uri': 'previous_track'}} + provider.next_tl_track = {'track': {'uri': track.uri}} + + provider.backend.prepare_next_track = mock.PropertyMock() + + assert provider.change_track(track) is False + assert provider.backend.prepare_next_track.called + + assert 'Skipping to next track...' in caplog.text() + + def test_change_track_skips_if_no_track_uri(provider): track = models.Track(uri=None) From 2d8d8b60200b916d40474974e934da717d2eca02 Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 25 Dec 2015 17:47:18 +0200 Subject: [PATCH 133/311] Frontend tests and fixes. --- mopidy_pandora/frontend.py | 10 ++- tests/dummy_backend.py | 71 +++++---------------- tests/test_frontend.py | 122 +++++++++++++++++++++++++++++++++---- 3 files changed, 129 insertions(+), 74 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index b973b53..16d1ce5 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -43,10 +43,6 @@ def check_pandora(self, *args, **kwargs): return check_pandora -def is_pandora_uri(active_uri): - return active_uri and active_uri.startswith('pandora:') - - class PandoraFrontendFactory(pykka.ThreadingActor): def __new__(cls, config, core): @@ -181,8 +177,10 @@ def _process_events(self, track_uri, time_position): try: self._trigger_event_triggered(event_target_uri, self._get_event(track_uri, time_position)) except ValueError as e: - logger.error(("Error processing event for URI '{}': ({})" + logger.error(("Error processing event for URI '{}': ({}). Ignoring event..." .format(event_target_uri, encoding.locale_decode(e)))) + self.event_processed_event.set() + return def _get_event_target_uri(self, track_uri, time_position): if time_position == 0: @@ -220,5 +218,5 @@ def doubleclicked(self): self.core.playback.resume() def _trigger_event_triggered(self, track_uri, event): - (listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.event_triggered.__name__, + (listener.PandoraFrontendListener.send(listener.PandoraEventHandlingFrontendListener.event_triggered.__name__, track_uri=track_uri, pandora_event=event)) diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 26d7fbf..8b072e3 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -7,13 +7,24 @@ from __future__ import absolute_import, unicode_literals from mopidy import backend -from mopidy.models import Playlist, Ref, SearchResult +from mopidy.models import Ref, SearchResult import pykka -def create_proxy(config=None, audio=None): - return DummyBackend.start(config=config, audio=audio).proxy() +def create_proxy(cls, config=None, audio=None): + return cls.start(config=config, audio=audio).proxy() + + +class DummyPandoraBackend(pykka.ThreadingActor, backend.Backend): + + def __init__(self, config, audio): + super(DummyPandoraBackend, self).__init__() + + self.library = DummyLibraryProvider(backend=self) + self.playback = DummyPlaybackProvider(audio=audio, backend=self) + + self.uri_schemes = ['pandora'] class DummyBackend(pykka.ThreadingActor, backend.Backend): @@ -23,9 +34,8 @@ def __init__(self, config, audio): self.library = DummyLibraryProvider(backend=self) self.playback = DummyPlaybackProvider(audio=audio, backend=self) - self.playlists = DummyPlaylistsProvider(backend=self) - self.uri_schemes = ['pandora'] + self.uri_schemes = ['dummy'] class DummyLibraryProvider(backend.LibraryProvider): @@ -92,54 +102,3 @@ def stop(self): def get_time_position(self): return self._time_position - - -class DummyPlaylistsProvider(backend.PlaylistsProvider): - - def __init__(self, backend): - super(DummyPlaylistsProvider, self).__init__(backend) - self._playlists = [] - - def set_dummy_playlists(self, playlists): - """For tests using the dummy provider through an actor proxy.""" - self._playlists = playlists - - def as_list(self): - return [ - Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] - - def get_items(self, uri): - playlist = self.lookup(uri) - if playlist is None: - return - return [ - Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] - - def lookup(self, uri): - for playlist in self._playlists: - if playlist.uri == uri: - return playlist - - def refresh(self): - pass - - def create(self, name): - playlist = Playlist(name=name, uri='dummy:%s' % name) - self._playlists.append(playlist) - return playlist - - def delete(self, uri): - playlist = self.lookup(uri) - if playlist: - self._playlists.remove(playlist) - - def save(self, playlist): - old_playlist = self.lookup(playlist.uri) - - if old_playlist is not None: - index = self._playlists.index(old_playlist) - self._playlists[index] = playlist - else: - self._playlists.append(playlist) - - return playlist diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 2eae11e..b22923e 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,41 +1,64 @@ +from __future__ import absolute_import, unicode_literals + import unittest +from mock import mock + from mopidy import core from mopidy.models import Track import pykka -from mopidy_pandora.frontend import PandoraFrontend +from mopidy_pandora import frontend + +from mopidy_pandora.frontend import EventHandlingPandoraFrontend, PandoraFrontend, PandoraFrontendFactory from tests import conftest, dummy_backend +from tests.dummy_backend import DummyBackend, DummyPandoraBackend -class FrontendTest(unittest.TestCase): +class TestPandoraFrontendFactory(unittest.TestCase): - def setUp(self): # noqa: N802 + def test_events_supported_returns_event_handler_frontend(self): + frontend = PandoraFrontendFactory(conftest.config(), mock.PropertyMock()) + + assert type(frontend) is EventHandlingPandoraFrontend + + def test_events_not_supported_returns_regular_frontend(self): + config = conftest.config() + config['pandora']['event_support_enabled'] = False + frontend = PandoraFrontendFactory(config, mock.PropertyMock()) + + assert type(frontend) is PandoraFrontend + + +class BaseTestFrontend(unittest.TestCase): + + def setUp(self): config = { 'core': { 'max_tracklist_length': 10000, } } - self.backend = dummy_backend.create_proxy() + self.backend = dummy_backend.create_proxy(DummyPandoraBackend) + self.non_pandora_backend = dummy_backend.create_proxy(DummyBackend) self.core = core.Core.start( - config, backends=[self.backend]).proxy() + config, backends=[self.backend, self.non_pandora_backend]).proxy() self.tracks = [ - Track(uri='pandora:track:mock_id1:mock_token1', length=40000), - Track(uri='pandora:track:mock_id2:mock_token2', length=40000), - Track(uri='pandora:track:mock_id3:mock_token3', length=40000), # Unplayable + Track(uri='pandora:track:mock_id1:mock_token1', length=40000), # Regular track + Track(uri='pandora:ad:mock_id2', length=40000), # Advertisement + Track(uri='dummy:track:mock_id3:mock_token3', length=40000), # Not a pandora track Track(uri='pandora:track:mock_id4:mock_token4', length=40000), - Track(uri='pandora:track:mock_id5:mock_token5', length=None), # No duration + Track(uri='pandora:track:mock_id5:mock_token5', length=None), # No duration ] self.uris = [ - 'pandora:track:mock_id1:mock_token1', 'pandora:track:mock_id2:mock_token2', - 'pandora:track:mock_id3:mock_token3', 'pandora:track:mock_id4:mock_token4', + 'pandora:track:mock_id1:mock_token1', 'pandora:ad:mock_id2', + 'dummy:track:mock_id3:mock_token3', 'pandora:track:mock_id4:mock_token4', 'pandora:track:mock_id5:mock_token5'] def lookup(uris): @@ -48,11 +71,43 @@ def lookup(uris): self.core.library.lookup = lookup self.tl_tracks = self.core.tracklist.add(uris=self.uris).get() - def tearDown(self): # noqa: N802 + def tearDown(self): pykka.ActorRegistry.stop_all() + +class TestFrontend(BaseTestFrontend): + + def setUp(self): # noqa: N802 + return super(TestFrontend, self).setUp() + + def tearDown(self): # noqa: N802 + super(TestFrontend, self).tearDown() + + def test_only_execute_for_pandora_executes_for_pandora_uri(self): + func_mock = mock.PropertyMock() + func_mock.__name__ = str('func_mock') + func_mock.return_value = True + + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + frontend.only_execute_for_pandora_uris(func_mock)(self) + + assert func_mock.called + + def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): + func_mock = mock.PropertyMock() + func_mock.__name__ = str('func_mock') + func_mock.return_value = True + + self.core.playback.play(tlid=self.tl_tracks[2].tlid).get() + frontend.only_execute_for_pandora_uris(func_mock)(self) + + assert not func_mock.called + def test_set_options_performs_auto_setup(self): self.core.tracklist.set_repeat(False).get() + self.core.tracklist.set_consume(True).get() + self.core.tracklist.set_random(True).get() + self.core.tracklist.set_single(True).get() self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() frontend = PandoraFrontend.start(conftest.config(), self.core).proxy() @@ -61,3 +116,46 @@ def test_set_options_performs_auto_setup(self): assert self.core.tracklist.get_consume().get() is False assert self.core.tracklist.get_random().get() is False assert self.core.tracklist.get_single().get() is False + + +class TestEventHandlingFrontend(BaseTestFrontend): + + def setUp(self): # noqa: N802 + super(TestEventHandlingFrontend, self).setUp() + + def tearDown(self): # noqa: N802 + super(TestEventHandlingFrontend, self).tearDown() + + def test_process_events_ignores_ads(self): + self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() + + frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + frontend._trigger_event_triggered = mock.PropertyMock() + frontend.event_processed_event.get().clear() + frontend.track_playback_resumed(self.tl_tracks[1], 100).get() + + assert frontend.event_processed_event.get().isSet() + assert not frontend._trigger_event_triggered.called + + def test_process_events_handles_exception(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + frontend._trigger_event_triggered = mock.PropertyMock() + frontend._get_event = mock.PropertyMock(side_effect=ValueError('dummy_error')) + frontend.event_processed_event.get().clear() + frontend.track_playback_resumed(self.tl_tracks[0], 100).get() + + assert frontend.event_processed_event.get().isSet() + assert not frontend._trigger_event_triggered.called + + def test_process_events_does_nothing_if_no_events_are_queued(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + frontend._trigger_event_triggered = mock.PropertyMock() + frontend.event_processed_event.get().set() + frontend.track_playback_resumed(self.tl_tracks[0], 100).get() + + assert frontend.event_processed_event.get().isSet() + assert not frontend._trigger_event_triggered.called From c19dd806c764738aa558cbe31138410d922ea716 Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 25 Dec 2015 21:01:38 +0200 Subject: [PATCH 134/311] Force playback stop when skip limit is exceeded. --- mopidy_pandora/frontend.py | 11 +++++++---- mopidy_pandora/listener.py | 10 ++++++++++ mopidy_pandora/playback.py | 4 ++++ tests/test_playback.py | 9 +++++++++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 16d1ce5..5275e5d 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -105,13 +105,16 @@ def track_changed(self, track): def next_track_available(self, track): self.add_track(track) + def skip_limit_exceeded(self): + self.core.playback.stop() + def add_track(self, track): # Add the next Pandora track self.core.tracklist.add(uris=[track.uri]).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() - if self.core.playback.get_state() == PlaybackState.STOPPED: + if self.core.playback.get_state().get() == PlaybackState.STOPPED: # Playback stopped after previous track was unplayable. Resume playback with the freshly seeded tracklist. - self.core.playback.play(tl_tracks[-1]) + self.core.playback.play(tl_tracks[-1]).get() if len(tl_tracks) > 2: # Only need two tracks in the tracklist at any given time, remove the oldest tracks self.core.tracklist.remove({'tlid': [tl_tracks[t].tlid for t in range(0, len(tl_tracks)-2)]}).get() @@ -214,8 +217,8 @@ def event_processed(self): def doubleclicked(self): self.event_processed_event.clear() # Resume playback... - if self.core.playback.get_state() != PlaybackState.PLAYING: - self.core.playback.resume() + if self.core.playback.get_state().get() != PlaybackState.PLAYING: + self.core.playback.resume().get() def _trigger_event_triggered(self, track_uri, event): (listener.PandoraFrontendListener.send(listener.PandoraEventHandlingFrontendListener.event_triggered.__name__, diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index aafedb7..cb1f26e 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -97,6 +97,16 @@ def track_changed(self, track): """ 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 (which should still be + in 'repeat' mode. + + """ + pass + class PandoraEventHandlingPlaybackListener(listener.Listener): diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 1f4211c..1c934c9 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -74,6 +74,7 @@ def change_track(self, track): return False except MaxSkipLimitExceeded as e: logger.error('{} Stopping...'.format(encoding.locale_decode(e))) + self._trigger_skip_limit_exceeded() return False def translate_uri(self, uri): @@ -82,6 +83,9 @@ def translate_uri(self, uri): def _trigger_track_changed(self, track): listener.PandoraPlaybackListener.send(listener.PandoraPlaybackListener.track_changed.__name__, track=track) + def _trigger_skip_limit_exceeded(self): + listener.PandoraPlaybackListener.send(listener.PandoraPlaybackListener.skip_limit_exceeded.__name__) + class EventHandlingPlaybackProvider(PandoraPlaybackProvider): def __init__(self, audio, backend): diff --git a/tests/test_playback.py b/tests/test_playback.py index 3a75209..e3c9a31 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -94,14 +94,17 @@ def test_change_track_enforces_skip_limit_if_no_track_available(provider, playli provider.next_tl_track = {'track': {'uri': track.uri}} provider.backend.prepare_next_track = mock.PropertyMock() + provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): assert provider.change_track(track) is False if i < PandoraPlaybackProvider.SKIP_LIMIT-1: assert provider.backend.prepare_next_track.called provider.backend.prepare_next_track.reset_mock() + assert not provider._trigger_skip_limit_exceeded.called else: assert not provider.backend.prepare_next_track.called + assert provider._trigger_skip_limit_exceeded.called assert 'Maximum track skip limit ({:d}) exceeded.'.format( PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() @@ -116,6 +119,7 @@ def test_change_track_enforces_skip_limit_if_no_audio_url(provider, playlist_ite provider.next_tl_track = {'track': {'uri': track.uri}} provider.backend.prepare_next_track = mock.PropertyMock() + provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) playlist_item_mock.audio_url = None @@ -124,8 +128,10 @@ def test_change_track_enforces_skip_limit_if_no_audio_url(provider, playlist_ite if i < PandoraPlaybackProvider.SKIP_LIMIT-1: assert provider.backend.prepare_next_track.called provider.backend.prepare_next_track.reset_mock() + assert not provider._trigger_skip_limit_exceeded.called else: assert not provider.backend.prepare_next_track.called + assert provider._trigger_skip_limit_exceeded.called assert 'Maximum track skip limit ({:d}) exceeded.'.format( PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() @@ -141,6 +147,7 @@ def test_change_track_enforces_skip_limit_on_request_exceptions(provider, playli provider.next_tl_track = {'track': {'uri': track.uri}} provider.backend.prepare_next_track = mock.PropertyMock() + provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) playlist_item_mock.audio_url = 'pandora:track:mock_id:mock_token' for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): @@ -148,8 +155,10 @@ def test_change_track_enforces_skip_limit_on_request_exceptions(provider, playli if i < PandoraPlaybackProvider.SKIP_LIMIT-1: assert provider.backend.prepare_next_track.called provider.backend.prepare_next_track.reset_mock() + assert not provider._trigger_skip_limit_exceeded.called else: assert not provider.backend.prepare_next_track.called + assert provider._trigger_skip_limit_exceeded.called assert 'Maximum track skip limit ({:d}) exceeded.'.format( PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() From baf8c7a9d647ae9d9cc782a7d88c44de9f60b4bb Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 26 Dec 2015 08:21:43 +0200 Subject: [PATCH 135/311] Fix build errors and package dependencies to allow merge to main develop branch. --- mopidy_pandora/frontend.py | 8 ++++++++ mopidy_pandora/library.py | 15 ++++++++++----- setup.py | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 5275e5d..17c6737 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -169,6 +169,14 @@ def _process_events(self, track_uri, time_position): # No events to process. return + # TODO: temporarily disabling events due to bug in Mopidy that prevents tracks from being added to the + # history correctly. Revert when Mopidy 1.1.2 is released. + logger.info('NOTICE: Event support has been disabled pending the fix ' + 'of: https://github.com/mopidy/mopidy/issues/1352') + + self.event_processed_event.set() + return + event_target_uri = self._get_event_target_uri(track_uri, time_position) assert event_target_uri diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 74f49fa..885cc74 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -65,12 +65,17 @@ def lookup(self, uri): if not pandora_track.company_name or len(pandora_track.company_name) == 0: pandora_track.company_name = 'Unknown' - album = models.Album(name=pandora_track.company_name, - uri=pandora_track.click_through_url) + # TODO: image and clickthrough urls for ads will only be available in pydora 1.6.3 and above. + # Wait for https://github.com/mcrute/pydora/pull/37/files to be merged and then + # put this back: + album = models.Album(name=pandora_track.company_name) - if pandora_track.image_url: - # Some advertisements do not have images - album = album.replace(images=[pandora_track.image_url]) + # album = models.Album(name=pandora_track.company_name, + # uri=pandora_track.click_through_url) + + # if pandora_track.image_url: + # # Some advertisements do not have images + # album = album.replace(images=[pandora_track.image_url]) return[models.Track(name='Advertisement', uri=uri, diff --git a/setup.py b/setup.py index c14ef0c..b6ef20b 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ def run_tests(self): 'cachetools >= 1.0.0', 'Mopidy >= 1.1.1', 'Pykka >= 1.1', - 'pydora >= 1.6.3', + 'pydora >= 1.6.2', 'requests >= 2.5.0' ], tests_require=['tox'], From cdc7e23b8e3cc71660abaed2f9bde66b6a881d83 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 26 Dec 2015 08:39:57 +0200 Subject: [PATCH 136/311] Disable station sorting features and tests that rely on pydora >= 1.6.2 --- mopidy_pandora/library.py | 31 ++++++++++++++++++------------- tests/test_library.py | 5 +++++ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 885cc74..f7ca5d2 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -99,19 +99,24 @@ def lookup(self, uri): 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 += '*' + + # TODO: identifying quickmix stations will only be available in pydora 1.6.3 and above. + # Wait for https://github.com/mcrute/pydora/pull/37/files to be merged and then + # put this back: + + # 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 diff --git a/tests/test_library.py b/tests/test_library.py index beed933..5d8ce7f 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import unittest + import conftest import mock @@ -62,6 +64,7 @@ def test_lookup_of_missing_track(config, playlist_item_mock, caplog): assert 'Failed to lookup \'{}\''.format(track_uri.uri) in caplog.text() +@unittest.skip("Wait for pydora 1.6.3") def test_browse_directory_uri(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): @@ -90,6 +93,7 @@ def test_browse_directory_uri(config): Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][1])).uri +@unittest.skip("Wait for pydora 1.6.3") def test_browse_directory_sort_za(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): @@ -104,6 +108,7 @@ def test_browse_directory_sort_za(config): assert results[3].name == conftest.MOCK_STATION_NAME + ' 2' +@unittest.skip("Wait for pydora 1.6.3") def test_browse_directory_sort_date(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): From dfa5b334120b46937e1a780a5d90cfaf47ca67ce Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 26 Dec 2015 09:30:29 +0200 Subject: [PATCH 137/311] Bleeding changes based on 3rd party PRs. --- mopidy_pandora/frontend.py | 8 -------- mopidy_pandora/library.py | 31 +++++++++++++------------------ tests/test_library.py | 5 ----- 3 files changed, 13 insertions(+), 31 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 17c6737..5275e5d 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -169,14 +169,6 @@ def _process_events(self, track_uri, time_position): # No events to process. return - # TODO: temporarily disabling events due to bug in Mopidy that prevents tracks from being added to the - # history correctly. Revert when Mopidy 1.1.2 is released. - logger.info('NOTICE: Event support has been disabled pending the fix ' - 'of: https://github.com/mopidy/mopidy/issues/1352') - - self.event_processed_event.set() - return - event_target_uri = self._get_event_target_uri(track_uri, time_position) assert event_target_uri diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index f7ca5d2..885cc74 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -99,24 +99,19 @@ def lookup(self, uri): def _formatted_station_list(self, list): # Find QuickMix stations and move QuickMix to top - - # TODO: identifying quickmix stations will only be available in pydora 1.6.3 and above. - # Wait for https://github.com/mcrute/pydora/pull/37/files to be merged and then - # put this back: - - # 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 += '*' + 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 diff --git a/tests/test_library.py b/tests/test_library.py index 5d8ce7f..beed933 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1,7 +1,5 @@ from __future__ import unicode_literals -import unittest - import conftest import mock @@ -64,7 +62,6 @@ def test_lookup_of_missing_track(config, playlist_item_mock, caplog): assert 'Failed to lookup \'{}\''.format(track_uri.uri) in caplog.text() -@unittest.skip("Wait for pydora 1.6.3") def test_browse_directory_uri(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): @@ -93,7 +90,6 @@ def test_browse_directory_uri(config): Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][1])).uri -@unittest.skip("Wait for pydora 1.6.3") def test_browse_directory_sort_za(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): @@ -108,7 +104,6 @@ def test_browse_directory_sort_za(config): assert results[3].name == conftest.MOCK_STATION_NAME + ' 2' -@unittest.skip("Wait for pydora 1.6.3") def test_browse_directory_sort_date(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): From 838be2a33ec5163914f48d64e65a31749b027781 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 26 Dec 2015 14:25:38 +0200 Subject: [PATCH 138/311] Fix config file references. --- mopidy_pandora/frontend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 5275e5d..ed1c950 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -129,9 +129,9 @@ def __init__(self, config, core): super(EventHandlingPandoraFrontend, self).__init__(config, core) self.settings = { - 'OPR_EVENT': config.get('on_pause_resume_click'), - 'OPN_EVENT': config.get('on_pause_next_click'), - 'OPP_EVENT': config.get('on_pause_previous_click') + 'OPR_EVENT': config['pandora'].get('on_pause_resume_click'), + 'OPN_EVENT': config['pandora'].get('on_pause_next_click'), + 'OPP_EVENT': config['pandora'].get('on_pause_previous_click') } self.current_track_uri = None From cbba0441baf6754fb07b72b3a0e8ff20fad5bd14 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 26 Dec 2015 14:26:33 +0200 Subject: [PATCH 139/311] Fix config file references. --- mopidy_pandora/frontend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 17c6737..9ccfdb7 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -129,9 +129,9 @@ def __init__(self, config, core): super(EventHandlingPandoraFrontend, self).__init__(config, core) self.settings = { - 'OPR_EVENT': config.get('on_pause_resume_click'), - 'OPN_EVENT': config.get('on_pause_next_click'), - 'OPP_EVENT': config.get('on_pause_previous_click') + 'OPR_EVENT': config['pandora'].get('on_pause_resume_click'), + 'OPN_EVENT': config['pandora'].get('on_pause_next_click'), + 'OPP_EVENT': config['pandora'].get('on_pause_previous_click') } self.current_track_uri = None From d82aaee67067cd9d3e1a1d1350335c54b9ef026a Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 26 Dec 2015 14:48:47 +0200 Subject: [PATCH 140/311] Refactor and test eventing handling on change_track event. --- mopidy_pandora/frontend.py | 21 +++++++++++---------- tests/test_frontend.py | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index ed1c950..57dde0d 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -134,8 +134,8 @@ def __init__(self, config, core): 'OPP_EVENT': config['pandora'].get('on_pause_previous_click') } - self.current_track_uri = None - self.next_track_uri = None + self.last_played_track_uri = None + self.upcoming_track_uri = None self.event_processed_event = threading.Event() self.event_processed_event.set() @@ -146,15 +146,16 @@ def __init__(self, config, core): @only_execute_for_pandora_uris def tracklist_changed(self): - if not self.event_processed_event.isSet(): - # Delay 'tracklist_changed' events until all events have been processed. - self.tracklist_changed_event.clear() - else: + if self.event_processed_event.isSet(): + # Keep track of current and next tracks so that we can determine direction of future track changes. current_tl_track = self.core.playback.get_current_tl_track().get() - self.current_track_uri = current_tl_track.track.uri - self.next_track_uri = self.core.tracklist.next_track(current_tl_track).get().track.uri + self.last_played_track_uri = current_tl_track.track.uri + self.upcoming_track_uri = self.core.tracklist.next_track(current_tl_track).get().track.uri self.tracklist_changed_event.set() + else: + # Delay 'tracklist_changed' events until all events have been processed. + self.tracklist_changed_event.clear() @only_execute_for_pandora_uris def track_playback_resumed(self, tl_track, time_position): @@ -195,14 +196,14 @@ def _get_event_target_uri(self, track_uri, time_position): return track_uri def _get_event(self, track_uri, time_position): - if track_uri == self.current_track_uri: + if track_uri == self.last_played_track_uri: if time_position > 0: # Resuming playback on the first track in the tracklist. return self.settings['OPR_EVENT'] else: return self.settings['OPP_EVENT'] - elif track_uri == self.next_track_uri: + elif track_uri == self.upcoming_track_uri: return self.settings['OPN_EVENT'] else: raise ValueError('Unexpected event URI: {}'.format(track_uri)) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index b22923e..01135c4 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -159,3 +159,30 @@ def test_process_events_does_nothing_if_no_events_are_queued(self): assert frontend.event_processed_event.get().isSet() assert not frontend._trigger_event_triggered.called + + def test_tracklist_changed_blocks_if_events_are_queued(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + frontend.event_processed_event.get().clear() + frontend.last_played_track_uri = 'dummy_uri' + frontend.upcoming_track_uri = 'dummy_ury' + + frontend.tracklist_changed().get() + assert not frontend.tracklist_changed_event.get().isSet() + assert frontend.last_played_track_uri.get() == 'dummy_uri' + assert frontend.upcoming_track_uri.get() == 'dummy_ury' + + def test_tracklist_changed_updates_uris_after_event_is_processed(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + frontend.event_processed_event.get().set() + frontend.last_played_track_uri = 'dummy_uri' + frontend.upcoming_track_uri = 'dummy_ury' + + frontend.tracklist_changed().get() + assert frontend.tracklist_changed_event.get().isSet() + current_track_uri = self.core.playback.get_current_tl_track().get() + assert frontend.last_played_track_uri.get() == current_track_uri.track.uri + assert frontend.upcoming_track_uri.get() == self.core.tracklist.next_track(current_track_uri).get().track.uri From 78f9092b181e033656484b8307641e8e137467c4 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 26 Dec 2015 14:50:06 +0200 Subject: [PATCH 141/311] Refactor and test eventing handling on change_track event. --- mopidy_pandora/frontend.py | 21 +++++++++++---------- tests/test_frontend.py | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 9ccfdb7..ed089ce 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -134,8 +134,8 @@ def __init__(self, config, core): 'OPP_EVENT': config['pandora'].get('on_pause_previous_click') } - self.current_track_uri = None - self.next_track_uri = None + self.last_played_track_uri = None + self.upcoming_track_uri = None self.event_processed_event = threading.Event() self.event_processed_event.set() @@ -146,15 +146,16 @@ def __init__(self, config, core): @only_execute_for_pandora_uris def tracklist_changed(self): - if not self.event_processed_event.isSet(): - # Delay 'tracklist_changed' events until all events have been processed. - self.tracklist_changed_event.clear() - else: + if self.event_processed_event.isSet(): + # Keep track of current and next tracks so that we can determine direction of future track changes. current_tl_track = self.core.playback.get_current_tl_track().get() - self.current_track_uri = current_tl_track.track.uri - self.next_track_uri = self.core.tracklist.next_track(current_tl_track).get().track.uri + self.last_played_track_uri = current_tl_track.track.uri + self.upcoming_track_uri = self.core.tracklist.next_track(current_tl_track).get().track.uri self.tracklist_changed_event.set() + else: + # Delay 'tracklist_changed' events until all events have been processed. + self.tracklist_changed_event.clear() @only_execute_for_pandora_uris def track_playback_resumed(self, tl_track, time_position): @@ -203,14 +204,14 @@ def _get_event_target_uri(self, track_uri, time_position): return track_uri def _get_event(self, track_uri, time_position): - if track_uri == self.current_track_uri: + if track_uri == self.last_played_track_uri: if time_position > 0: # Resuming playback on the first track in the tracklist. return self.settings['OPR_EVENT'] else: return self.settings['OPP_EVENT'] - elif track_uri == self.next_track_uri: + elif track_uri == self.upcoming_track_uri: return self.settings['OPN_EVENT'] else: raise ValueError('Unexpected event URI: {}'.format(track_uri)) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index b22923e..01135c4 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -159,3 +159,30 @@ def test_process_events_does_nothing_if_no_events_are_queued(self): assert frontend.event_processed_event.get().isSet() assert not frontend._trigger_event_triggered.called + + def test_tracklist_changed_blocks_if_events_are_queued(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + frontend.event_processed_event.get().clear() + frontend.last_played_track_uri = 'dummy_uri' + frontend.upcoming_track_uri = 'dummy_ury' + + frontend.tracklist_changed().get() + assert not frontend.tracklist_changed_event.get().isSet() + assert frontend.last_played_track_uri.get() == 'dummy_uri' + assert frontend.upcoming_track_uri.get() == 'dummy_ury' + + def test_tracklist_changed_updates_uris_after_event_is_processed(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + frontend.event_processed_event.get().set() + frontend.last_played_track_uri = 'dummy_uri' + frontend.upcoming_track_uri = 'dummy_ury' + + frontend.tracklist_changed().get() + assert frontend.tracklist_changed_event.get().isSet() + current_track_uri = self.core.playback.get_current_tl_track().get() + assert frontend.last_played_track_uri.get() == current_track_uri.track.uri + assert frontend.upcoming_track_uri.get() == self.core.tracklist.next_track(current_track_uri).get().track.uri From 531b83252caa218b174f6af53b42af309c6e40cd Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 26 Dec 2015 19:00:09 +0200 Subject: [PATCH 142/311] Displaying of ads were mistakenly removed in previous commits. --- mopidy_pandora/library.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 885cc74..74f49fa 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -65,17 +65,12 @@ def lookup(self, uri): if not pandora_track.company_name or len(pandora_track.company_name) == 0: pandora_track.company_name = 'Unknown' - # TODO: image and clickthrough urls for ads will only be available in pydora 1.6.3 and above. - # Wait for https://github.com/mcrute/pydora/pull/37/files to be merged and then - # put this back: - album = models.Album(name=pandora_track.company_name) + album = models.Album(name=pandora_track.company_name, + uri=pandora_track.click_through_url) - # album = models.Album(name=pandora_track.company_name, - # uri=pandora_track.click_through_url) - - # if pandora_track.image_url: - # # Some advertisements do not have images - # album = album.replace(images=[pandora_track.image_url]) + if pandora_track.image_url: + # Some advertisements do not have images + album = album.replace(images=[pandora_track.image_url]) return[models.Track(name='Advertisement', uri=uri, From 9ffda90979dcd769765ae318d86b349f8e38358a Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 26 Dec 2015 20:53:19 +0200 Subject: [PATCH 143/311] Implementation of new LibraryController.get_images(uris) method for retrieving track images. --- mopidy_pandora/library.py | 40 +++++++++++++++++++---------- tests/conftest.py | 4 +-- tests/test_library.py | 53 ++++++++++++++++++++++++++++++++++----- tests/test_playback.py | 2 +- 4 files changed, 77 insertions(+), 22 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 74f49fa..cb8ee26 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -31,7 +31,7 @@ def __init__(self, backend, sort_order): self._station = None self._station_iter = None - self._pandora_track_buffer = OrderedDict() + self._pandora_track_cache = OrderedDict() super(PandoraLibraryProvider, self).__init__(backend) def browse(self, uri): @@ -65,17 +65,11 @@ def lookup(self, uri): if not pandora_track.company_name or len(pandora_track.company_name) == 0: pandora_track.company_name = 'Unknown' - album = models.Album(name=pandora_track.company_name, - uri=pandora_track.click_through_url) - - if pandora_track.image_url: - # Some advertisements do not have images - album = album.replace(images=[pandora_track.image_url]) - return[models.Track(name='Advertisement', uri=uri, artists=[models.Artist(name=pandora_track.company_name)], - album=album + album=models.Album(name=pandora_track.company_name, + uri=pandora_track.click_through_url) ) ] @@ -84,14 +78,34 @@ def lookup(self, uri): bitrate=int(pandora_track.bitrate), artists=[models.Artist(name=pandora_track.artist_name)], album=models.Album(name=pandora_track.album_name, - uri=pandora_track.album_detail_url, - images=[pandora_track.album_art_url]) + uri=pandora_track.album_detail_url) ) ] else: raise ValueError('Unexpected URI type: {}'.format(uri)) + def get_images(self, uris): + result = {} + for uri in uris: + image_uris = set() + try: + pandora_track = self.lookup_pandora_track(uri) + if pandora_track.is_ad is True: + image_uri = pandora_track.image_url + else: + image_uri = pandora_track.album_art_url + if image_uri: + image_uris.update([image_uri]) + except (TypeError, KeyError): + logger.error("Failed to lookup image for URI '{}'".format(uri)) + pass + result[uri] = [models.Image(uri=u) for u in image_uris] + return result + + def _cache_pandora_track(self, track, pandora_track): + self._pandora_track_cache[track.uri] = pandora_track + def _formatted_station_list(self, list): # Find QuickMix stations and move QuickMix to top for i, station in enumerate(list[:]): @@ -161,7 +175,7 @@ def _browse_genre_stations(self, uri): [PandoraUri.factory(uri).category_name]] def lookup_pandora_track(self, uri): - return self._pandora_track_buffer[uri] + return self._pandora_track_cache[uri] def get_next_pandora_track(self): try: @@ -184,5 +198,5 @@ def get_next_pandora_track(self): track = models.Ref.track(name=track_name, uri=track_uri.uri) - self._pandora_track_buffer[track.uri] = pandora_track + self._cache_pandora_track(track, pandora_track) return track diff --git a/tests/conftest.py b/tests/conftest.py index e95060e..e7fcee3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -151,7 +151,7 @@ def playlist_result_mock(): 'trackToken': None, 'artistName': None, 'albumName': None, - 'albumArtUrl': None, + 'imageUrl': None, 'audioUrlMap': { 'highQuality': { 'bitrate': '64', @@ -241,7 +241,7 @@ def playlist_item_mock(): config()).api, playlist_result_mock()['result']['items'][0]) -@pytest.fixture(scope='session') +@pytest.fixture def ad_item_mock(): ad_item = AdItem.from_json(get_backend( config()).api, ad_metadata_result_mock()['result']) diff --git a/tests/test_library.py b/tests/test_library.py index beed933..72a488b 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -19,17 +19,58 @@ from tests.conftest import get_station_list_mock -def test_lookup_of_ad_without_images(config, ad_item_mock): +def test_get_images_for_ad_without_images(config, ad_item_mock): backend = conftest.get_backend(config) ad_uri = PandoraUri.factory('pandora:ad:' + conftest.MOCK_TRACK_AD_TOKEN) ad_item_mock.image_url = None - backend.library._pandora_track_buffer[ad_uri.uri] = ad_item_mock - results = backend.library.lookup(ad_uri.uri) - assert len(results) == 1 + backend.library._pandora_track_cache[ad_uri.uri] = ad_item_mock + results = backend.library.get_images([ad_uri.uri]) + assert len(results[ad_uri.uri]) == 0 + + +def test_get_images_for_ad_with_images(config, ad_item_mock): + + backend = conftest.get_backend(config) + + ad_uri = PandoraUri.factory('pandora:ad:' + conftest.MOCK_TRACK_AD_TOKEN) + backend.library._pandora_track_cache[ad_uri.uri] = ad_item_mock + results = backend.library.get_images([ad_uri.uri]) + assert len(results[ad_uri.uri]) == 1 + assert results[ad_uri.uri][0].uri == ad_item_mock.image_url + + +def test_get_images_for_unknown_uri_returns_empty_list(config, caplog): + + backend = conftest.get_backend(config) + + track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') + results = backend.library.get_images([track_uri.uri]) + assert len(results[track_uri.uri]) == 0 + assert "Failed to lookup image for URI '{}'".format(track_uri.uri) in caplog.text() + + +def test_get_images_for_track_without_images(config, playlist_item_mock): + + backend = conftest.get_backend(config) + + track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') + playlist_item_mock.album_art_url = None + backend.library._pandora_track_cache[track_uri.uri] = playlist_item_mock + results = backend.library.get_images([track_uri.uri]) + assert len(results[track_uri.uri]) == 0 + + +def test_get_images_for_track_with_images(config, playlist_item_mock): + + backend = conftest.get_backend(config) - assert results[0].uri == ad_uri.uri + track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') + backend.library._pandora_track_cache[track_uri.uri] = playlist_item_mock + results = backend.library.get_images([track_uri.uri]) + assert len(results[track_uri.uri]) == 1 + assert results[track_uri.uri][0].uri == playlist_item_mock.album_art_url def test_lookup_of_invalid_uri(config, caplog): @@ -43,7 +84,7 @@ def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) track_uri = PlaylistItemUri._from_track(playlist_item_mock) - backend.library._pandora_track_buffer[track_uri.uri] = playlist_item_mock + backend.library._pandora_track_cache[track_uri.uri] = playlist_item_mock results = backend.library.lookup(track_uri.uri) assert len(results) == 1 diff --git a/tests/test_playback.py b/tests/test_playback.py index e3c9a31..bc9b9da 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -204,7 +204,7 @@ def test_change_track_skips_if_track_not_available_in_buffer(provider, playlist_ def test_translate_uri_returns_audio_url(provider, playlist_item_mock): test_uri = 'pandora:track:test_station_id:test_token' - provider.backend.library._pandora_track_buffer[test_uri] = playlist_item_mock + provider.backend.library._pandora_track_cache[test_uri] = playlist_item_mock assert provider.translate_uri(test_uri) == conftest.MOCK_TRACK_AUDIO_HIGH From 44b54d79c0cd5e633eee1569d298587b81292c01 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 26 Dec 2015 21:18:28 +0200 Subject: [PATCH 144/311] Make images available for extensions that still rely on the old Album.images attribute. --- mopidy_pandora/library.py | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index cb8ee26..fddc0a8 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -65,26 +65,33 @@ def lookup(self, uri): if not pandora_track.company_name or len(pandora_track.company_name) == 0: pandora_track.company_name = 'Unknown' - return[models.Track(name='Advertisement', - uri=uri, - artists=[models.Artist(name=pandora_track.company_name)], - album=models.Album(name=pandora_track.company_name, - uri=pandora_track.click_through_url) - ) - ] + track = models.Track(name='Advertisement', + uri=uri, + artists=[models.Artist(name=pandora_track.company_name)], + album=models.Album(name=pandora_track.company_name, + uri=pandora_track.click_through_url) + ) else: - return[models.Track(name=pandora_track.song_name, uri=uri, length=pandora_track.track_length * 1000, - bitrate=int(pandora_track.bitrate), - artists=[models.Artist(name=pandora_track.artist_name)], - album=models.Album(name=pandora_track.album_name, - uri=pandora_track.album_detail_url) - ) - ] - + track = models.Track(name=pandora_track.song_name, + uri=uri, + length=pandora_track.track_length * 1000, + bitrate=int(pandora_track.bitrate), + artists=[models.Artist(name=pandora_track.artist_name)], + album=models.Album(name=pandora_track.album_name, + uri=pandora_track.album_detail_url) + ) else: raise ValueError('Unexpected URI type: {}'.format(uri)) + # 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 = track.album.replace(images=[images[0].uri]) + track = track.replace(album=album) + return [track] + def get_images(self, uris): result = {} for uri in uris: From 9846e2b5cedf2fbecc8d7ccf938e307706f85cb7 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 27 Dec 2015 09:16:26 +0200 Subject: [PATCH 145/311] Refactor code for looking up tracks to avoid copying objects on every lookup. Add test cases for looking up advertisement tracks. --- mopidy_pandora/library.py | 49 ++++++++++++++++++--------------------- tests/test_library.py | 28 +++++++++++++++++----- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index fddc0a8..3bb98e9 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -61,36 +61,33 @@ def lookup(self, uri): logger.error("Failed to lookup '{}'".format(uri)) return [] else: - if type(pandora_uri) is AdItemUri: - if not pandora_track.company_name or len(pandora_track.company_name) == 0: - pandora_track.company_name = 'Unknown' + track_kwargs = {'uri': uri} + (album_kwargs, artist_kwargs) = {}, {} - track = models.Track(name='Advertisement', - uri=uri, - artists=[models.Artist(name=pandora_track.company_name)], - album=models.Album(name=pandora_track.company_name, - uri=pandora_track.click_through_url) - ) + # 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' + artist_kwargs['name'] = getattr(pandora_track, 'company_name', 'Advertisement') + album_kwargs['name'] = getattr(pandora_track, 'company_name', 'Advertisement') + album_kwargs['uri'] = pandora_track.click_through_url else: - track = models.Track(name=pandora_track.song_name, - uri=uri, - length=pandora_track.track_length * 1000, - bitrate=int(pandora_track.bitrate), - artists=[models.Artist(name=pandora_track.artist_name)], - album=models.Album(name=pandora_track.album_name, - uri=pandora_track.album_detail_url) - ) + track_kwargs['name'] = pandora_track.song_name + track_kwargs['length'] = pandora_track.track_length * 1000 + track_kwargs['bitrate'] = int(pandora_track.bitrate) + artist_kwargs['name'] = pandora_track.artist_name + album_kwargs['name'] = pandora_track.album_name + album_kwargs['uri'] = pandora_track.album_detail_url else: - raise ValueError('Unexpected URI type: {}'.format(uri)) - - # 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 = track.album.replace(images=[images[0].uri]) - track = track.replace(album=album) - return [track] + raise ValueError("Unexpected type to perform 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 = {} diff --git a/tests/test_library.py b/tests/test_library.py index 72a488b..863601f 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -20,7 +20,6 @@ def test_get_images_for_ad_without_images(config, ad_item_mock): - backend = conftest.get_backend(config) ad_uri = PandoraUri.factory('pandora:ad:' + conftest.MOCK_TRACK_AD_TOKEN) @@ -31,7 +30,6 @@ def test_get_images_for_ad_without_images(config, ad_item_mock): def test_get_images_for_ad_with_images(config, ad_item_mock): - backend = conftest.get_backend(config) ad_uri = PandoraUri.factory('pandora:ad:' + conftest.MOCK_TRACK_AD_TOKEN) @@ -42,7 +40,6 @@ def test_get_images_for_ad_with_images(config, ad_item_mock): def test_get_images_for_unknown_uri_returns_empty_list(config, caplog): - backend = conftest.get_backend(config) track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') @@ -52,7 +49,6 @@ def test_get_images_for_unknown_uri_returns_empty_list(config, caplog): def test_get_images_for_track_without_images(config, playlist_item_mock): - backend = conftest.get_backend(config) track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') @@ -63,7 +59,6 @@ def test_get_images_for_track_without_images(config, playlist_item_mock): def test_get_images_for_track_with_images(config, playlist_item_mock): - backend = conftest.get_backend(config) track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') @@ -73,13 +68,34 @@ def test_get_images_for_track_with_images(config, playlist_item_mock): assert results[track_uri.uri][0].uri == playlist_item_mock.album_art_url -def test_lookup_of_invalid_uri(config, caplog): +def test_lookup_of_invalid_uri(config): with pytest.raises(NotImplementedError): backend = conftest.get_backend(config) backend.library.lookup('pandora:invalid') +def test_lookup_of_invalid_uri_type(config, caplog): + with pytest.raises(ValueError): + backend = conftest.get_backend(config) + + backend.library.lookup('pandora:station:dummy_id:dummy_token') + assert 'Unexpected type to perform track lookup: station' in caplog.text() + + +def test_lookup_of_ad_uri(config, ad_item_mock): + backend = conftest.get_backend(config) + + track_uri = PlaylistItemUri._from_track(ad_item_mock) + backend.library._pandora_track_cache[track_uri.uri] = ad_item_mock + + results = backend.library.lookup(track_uri.uri) + assert len(results) == 1 + + track = results[0] + assert track.uri == track_uri.uri + + def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) From 5161074f2308a4b1f450a8337b9a3223f9e7aa6a Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 27 Dec 2015 09:25:41 +0200 Subject: [PATCH 146/311] Refactor code for looking up tracks to avoid copying objects on every lookup. Add test cases for looking up advertisement tracks. --- mopidy_pandora/library.py | 81 ++++++++++++++++++++++++--------------- tests/conftest.py | 4 +- tests/test_library.py | 74 +++++++++++++++++++++++++++++++---- tests/test_playback.py | 2 +- 4 files changed, 119 insertions(+), 42 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index f7ca5d2..b1a6f63 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -31,7 +31,7 @@ def __init__(self, backend, sort_order): self._station = None self._station_iter = None - self._pandora_track_buffer = OrderedDict() + self._pandora_track_cache = OrderedDict() super(PandoraLibraryProvider, self).__init__(backend) def browse(self, uri): @@ -61,41 +61,60 @@ def lookup(self, uri): logger.error("Failed to lookup '{}'".format(uri)) return [] else: - if type(pandora_uri) is AdItemUri: - if not pandora_track.company_name or len(pandora_track.company_name) == 0: - pandora_track.company_name = 'Unknown' + 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' + artist_kwargs['name'] = getattr(pandora_track, 'company_name', 'Advertisement') + album_kwargs['name'] = getattr(pandora_track, 'company_name', 'Advertisement') # TODO: image and clickthrough urls for ads will only be available in pydora 1.6.3 and above. # Wait for https://github.com/mcrute/pydora/pull/37/files to be merged and then # put this back: - album = models.Album(name=pandora_track.company_name) - - # album = models.Album(name=pandora_track.company_name, - # uri=pandora_track.click_through_url) - - # if pandora_track.image_url: - # # Some advertisements do not have images - # album = album.replace(images=[pandora_track.image_url]) + # album_kwargs['uri'] = pandora_track.click_through_url + else: + track_kwargs['name'] = pandora_track.song_name + track_kwargs['length'] = pandora_track.track_length * 1000 + track_kwargs['bitrate'] = int(pandora_track.bitrate) + artist_kwargs['name'] = pandora_track.artist_name + album_kwargs['name'] = pandora_track.album_name + album_kwargs['uri'] = pandora_track.album_detail_url + else: + raise ValueError("Unexpected type to perform track lookup: {}".format(pandora_uri.uri_type)) - return[models.Track(name='Advertisement', - uri=uri, - artists=[models.Artist(name=pandora_track.company_name)], - album=album - ) - ] + 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: + pandora_track = self.lookup_pandora_track(uri) + if pandora_track.is_ad is True: + # TODO: image and clickthrough urls for ads will only be available in pydora 1.6.3 and above. + # Wait for https://github.com/mcrute/pydora/pull/37/files to be merged and then + # put this back: + # image_uri = pandora_track.image_url + image_uri = None else: - return[models.Track(name=pandora_track.song_name, uri=uri, length=pandora_track.track_length * 1000, - bitrate=int(pandora_track.bitrate), - artists=[models.Artist(name=pandora_track.artist_name)], - album=models.Album(name=pandora_track.album_name, - uri=pandora_track.album_detail_url, - images=[pandora_track.album_art_url]) - ) - ] - - else: - raise ValueError('Unexpected URI type: {}'.format(uri)) + image_uri = pandora_track.album_art_url + if image_uri: + image_uris.update([image_uri]) + except (TypeError, KeyError): + logger.error("Failed to lookup image for URI '{}'".format(uri)) + pass + result[uri] = [models.Image(uri=u) for u in image_uris] + return result + + def _cache_pandora_track(self, track, pandora_track): + self._pandora_track_cache[track.uri] = pandora_track def _formatted_station_list(self, list): # Find QuickMix stations and move QuickMix to top @@ -171,7 +190,7 @@ def _browse_genre_stations(self, uri): [PandoraUri.factory(uri).category_name]] def lookup_pandora_track(self, uri): - return self._pandora_track_buffer[uri] + return self._pandora_track_cache[uri] def get_next_pandora_track(self): try: @@ -194,5 +213,5 @@ def get_next_pandora_track(self): track = models.Ref.track(name=track_name, uri=track_uri.uri) - self._pandora_track_buffer[track.uri] = pandora_track + self._cache_pandora_track(track, pandora_track) return track diff --git a/tests/conftest.py b/tests/conftest.py index e95060e..e7fcee3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -151,7 +151,7 @@ def playlist_result_mock(): 'trackToken': None, 'artistName': None, 'albumName': None, - 'albumArtUrl': None, + 'imageUrl': None, 'audioUrlMap': { 'highQuality': { 'bitrate': '64', @@ -241,7 +241,7 @@ def playlist_item_mock(): config()).api, playlist_result_mock()['result']['items'][0]) -@pytest.fixture(scope='session') +@pytest.fixture def ad_item_mock(): ad_item = AdItem.from_json(get_backend( config()).api, ad_metadata_result_mock()['result']) diff --git a/tests/test_library.py b/tests/test_library.py index 5d8ce7f..88dbee0 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -21,31 +21,89 @@ from tests.conftest import get_station_list_mock -def test_lookup_of_ad_without_images(config, ad_item_mock): - +def test_get_images_for_ad_without_images(config, ad_item_mock): backend = conftest.get_backend(config) ad_uri = PandoraUri.factory('pandora:ad:' + conftest.MOCK_TRACK_AD_TOKEN) ad_item_mock.image_url = None - backend.library._pandora_track_buffer[ad_uri.uri] = ad_item_mock - results = backend.library.lookup(ad_uri.uri) - assert len(results) == 1 + backend.library._pandora_track_cache[ad_uri.uri] = ad_item_mock + results = backend.library.get_images([ad_uri.uri]) + assert len(results[ad_uri.uri]) == 0 + - assert results[0].uri == ad_uri.uri +@unittest.skip("Wait for pydora 1.6.3") +def test_get_images_for_ad_with_images(config, ad_item_mock): + backend = conftest.get_backend(config) + ad_uri = PandoraUri.factory('pandora:ad:' + conftest.MOCK_TRACK_AD_TOKEN) + backend.library._pandora_track_cache[ad_uri.uri] = ad_item_mock + results = backend.library.get_images([ad_uri.uri]) + assert len(results[ad_uri.uri]) == 1 + assert results[ad_uri.uri][0].uri == ad_item_mock.image_url + + +def test_get_images_for_unknown_uri_returns_empty_list(config, caplog): + backend = conftest.get_backend(config) + + track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') + results = backend.library.get_images([track_uri.uri]) + assert len(results[track_uri.uri]) == 0 + assert "Failed to lookup image for URI '{}'".format(track_uri.uri) in caplog.text() + + +def test_get_images_for_track_without_images(config, playlist_item_mock): + backend = conftest.get_backend(config) + + track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') + playlist_item_mock.album_art_url = None + backend.library._pandora_track_cache[track_uri.uri] = playlist_item_mock + results = backend.library.get_images([track_uri.uri]) + assert len(results[track_uri.uri]) == 0 + + +def test_get_images_for_track_with_images(config, playlist_item_mock): + backend = conftest.get_backend(config) -def test_lookup_of_invalid_uri(config, caplog): + track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') + backend.library._pandora_track_cache[track_uri.uri] = playlist_item_mock + results = backend.library.get_images([track_uri.uri]) + assert len(results[track_uri.uri]) == 1 + assert results[track_uri.uri][0].uri == playlist_item_mock.album_art_url + + +def test_lookup_of_invalid_uri(config): with pytest.raises(NotImplementedError): backend = conftest.get_backend(config) backend.library.lookup('pandora:invalid') +def test_lookup_of_invalid_uri_type(config, caplog): + with pytest.raises(ValueError): + backend = conftest.get_backend(config) + + backend.library.lookup('pandora:station:dummy_id:dummy_token') + assert 'Unexpected type to perform track lookup: station' in caplog.text() + + +def test_lookup_of_ad_uri(config, ad_item_mock): + backend = conftest.get_backend(config) + + track_uri = PlaylistItemUri._from_track(ad_item_mock) + backend.library._pandora_track_cache[track_uri.uri] = ad_item_mock + + results = backend.library.lookup(track_uri.uri) + assert len(results) == 1 + + track = results[0] + assert track.uri == track_uri.uri + + def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) track_uri = PlaylistItemUri._from_track(playlist_item_mock) - backend.library._pandora_track_buffer[track_uri.uri] = playlist_item_mock + backend.library._pandora_track_cache[track_uri.uri] = playlist_item_mock results = backend.library.lookup(track_uri.uri) assert len(results) == 1 diff --git a/tests/test_playback.py b/tests/test_playback.py index e3c9a31..bc9b9da 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -204,7 +204,7 @@ def test_change_track_skips_if_track_not_available_in_buffer(provider, playlist_ def test_translate_uri_returns_audio_url(provider, playlist_item_mock): test_uri = 'pandora:track:test_station_id:test_token' - provider.backend.library._pandora_track_buffer[test_uri] = playlist_item_mock + provider.backend.library._pandora_track_cache[test_uri] = playlist_item_mock assert provider.translate_uri(test_uri) == conftest.MOCK_TRACK_AUDIO_HIGH From dcdc1e1bffe6736e2477445886bac7d8f710de66 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 27 Dec 2015 09:29:55 +0200 Subject: [PATCH 147/311] Fix whitespace. --- mopidy_pandora/library.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 3bb98e9..7f44e67 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -63,7 +63,6 @@ def lookup(self, uri): 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] From afad61e31ae03f0534ec114364b9c1804f0fdb06 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 27 Dec 2015 13:05:36 +0200 Subject: [PATCH 148/311] Refactor setting ad attributes. Try to preserve original traceback on 'iterateforever'. --- mopidy_pandora/library.py | 30 +++++++++++++++++++++--------- tests/test_library.py | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 7f44e67..a225df3 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,6 +1,7 @@ import logging from collections import OrderedDict +import traceback from mopidy import backend, models @@ -15,7 +16,6 @@ from mopidy_pandora import utils from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, StationUri, TrackUri # noqa I101 - logger = logging.getLogger(__name__) @@ -71,8 +71,15 @@ def lookup(self, uri): if type(pandora_uri) is AdItemUri: track_kwargs['name'] = 'Advertisement' - artist_kwargs['name'] = getattr(pandora_track, 'company_name', 'Advertisement') - album_kwargs['name'] = getattr(pandora_track, 'company_name', 'Advertisement') + + if not pandora_track.title: + pandora_track.title = '(Title not specified)' + artist_kwargs['name'] = pandora_track.title + + if not pandora_track.company_name: + pandora_track.company_name = '(Company name not specified)' + album_kwargs['name'] = pandora_track.company_name + album_kwargs['uri'] = pandora_track.click_through_url else: track_kwargs['name'] = pandora_track.song_name @@ -183,13 +190,18 @@ def lookup_pandora_track(self, uri): def get_next_pandora_track(self): try: pandora_track = self._station_iter.next() - except requests.exceptions.RequestException as e: + # except requests.exceptions.RequestException as e: + # logger.error('Error retrieving next Pandora track: {}'.format(encoding.locale_decode(e))) + # return None + # except StopIteration: + # # TODO: workaround for https://github.com/mcrute/pydora/issues/36 + # logger.error("Failed to retrieve next track for station '{}' from Pandora server".format( + # self._station.name)) + # return None + except Exception as e: + # TODO: Remove this catch-all exception once we've figured out how to deal with all of them logger.error('Error retrieving next Pandora track: {}'.format(encoding.locale_decode(e))) - return None - except StopIteration: - # TODO: workaround for https://github.com/mcrute/pydora/issues/36 - logger.error("Failed to retrieve next track for station '{}' from Pandora server".format( - self._station.name)) + logger.error('TRACEBACK INFO: {}'.format(traceback.format_exc())) return None track_uri = PandoraUri.factory(pandora_track) diff --git a/tests/test_library.py b/tests/test_library.py index 863601f..b3e9509 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -96,6 +96,24 @@ def test_lookup_of_ad_uri(config, ad_item_mock): assert track.uri == track_uri.uri +def test_lookup_of_ad_uri_defaults_missing_values(config, ad_item_mock): + backend = conftest.get_backend(config) + + ad_item_mock.title = '' + ad_item_mock.company_name = None + + track_uri = PlaylistItemUri._from_track(ad_item_mock) + backend.library._pandora_track_cache[track_uri.uri] = ad_item_mock + + results = backend.library.lookup(track_uri.uri) + assert len(results) == 1 + + track = results[0] + assert track.name == 'Advertisement' + assert '(Title not specified)' in next(iter(track.artists)).name + assert track.album.name == '(Company name not specified)' + + def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) From 18bd6567cf6e70759629b3a95710c9e5b56023b0 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 27 Dec 2015 13:09:35 +0200 Subject: [PATCH 149/311] Fix flake8 violations. --- mopidy_pandora/library.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index a225df3..9162143 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,8 +1,9 @@ import logging -from collections import OrderedDict import traceback +from collections import OrderedDict + from mopidy import backend, models from mopidy.internal import encoding @@ -11,8 +12,6 @@ from pydora.utils import iterate_forever -import requests - from mopidy_pandora import utils from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, StationUri, TrackUri # noqa I101 From 605714f8f367993caeccdf88a44f6ffe1696a9f4 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 27 Dec 2015 15:11:47 +0200 Subject: [PATCH 150/311] Remove dependency on mopidy.internal. Identify exception logs as coming from Mopidy-Pandora. --- mopidy_pandora/backend.py | 11 +++++------ mopidy_pandora/client.py | 10 ++++------ mopidy_pandora/frontend.py | 6 ++---- mopidy_pandora/library.py | 9 ++------- mopidy_pandora/playback.py | 15 +++++++-------- tests/test_client.py | 2 +- tests/test_playback.py | 4 ++-- 7 files changed, 23 insertions(+), 34 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 8152b61..b1ccbef 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -1,7 +1,6 @@ import logging from mopidy import backend, core -from mopidy.internal import encoding from pandora.errors import PandoraException @@ -58,8 +57,8 @@ def on_start(self): self.api.get_station_list() # Prefetch genre category list self.api.get_genre_stations() - except requests.exceptions.RequestException as e: - logger.error('Error logging in to Pandora: {}'.format(encoding.locale_decode(e))) + except requests.exceptions.RequestException: + logger.exception('Error logging in to Pandora.') def end_of_tracklist_reached(self): self.prepare_next_track() @@ -75,12 +74,12 @@ def event_triggered(self, track_uri, pandora_event): def process_event(self, track_uri, pandora_event): func = getattr(self, pandora_event) try: - logger.info("Triggering event '{}' for song: '{}'".format(pandora_event, + 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() - except PandoraException as e: - logger.error('Error calling event: {}'.format(encoding.locale_decode(e))) + except PandoraException: + logger.exception('Error calling Pandora event: {}'.format(pandora_event)) return False def thumbs_up(self, track_uri): diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 5c2cd9e..61b7ad7 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -3,8 +3,6 @@ from cachetools import TTLCache -from mopidy.internal import encoding - import pandora from pandora.clientbuilder import APITransport, DEFAULT_API_HOST, Encryptor, SettingsDictBuilder @@ -58,8 +56,8 @@ def get_station_list(self, force_refresh=False): list = super(MopidyAPIClient, self).get_station_list() self._station_list_cache[time.time()] = list - except requests.exceptions.RequestException as e: - logger.error('Error retrieving station list: {}'.format(encoding.locale_decode(e))) + except requests.exceptions.RequestException: + logger.exception('Error retrieving Pandora station list.') return list try: @@ -85,8 +83,8 @@ def get_genre_stations(self, force_refresh=False): self._genre_stations_cache[time.time()] = super(MopidyAPIClient, self).get_genre_stations() - except requests.exceptions.RequestException as e: - logger.error('Error retrieving genre stations: {}'.format(encoding.locale_decode(e))) + except requests.exceptions.RequestException: + logger.exception('Error retrieving Pandora genre stations.') return list try: diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 57dde0d..50a96d4 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -3,7 +3,6 @@ from mopidy import core from mopidy.audio import PlaybackState -from mopidy.internal import encoding import pykka @@ -180,9 +179,8 @@ def _process_events(self, track_uri, time_position): try: self._trigger_event_triggered(event_target_uri, self._get_event(track_uri, time_position)) - except ValueError as e: - logger.error(("Error processing event for URI '{}': ({}). Ignoring event..." - .format(event_target_uri, encoding.locale_decode(e)))) + except ValueError: + logger.exception("Error processing Pandora event for URI '{}'. Ignoring event...".format(event_target_uri)) self.event_processed_event.set() return diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 9162143..d3ba6e5 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,13 +1,9 @@ import logging -import traceback - from collections import OrderedDict from mopidy import backend, models -from mopidy.internal import encoding - from pandora.models.pandora import Station from pydora.utils import iterate_forever @@ -197,10 +193,9 @@ def get_next_pandora_track(self): # logger.error("Failed to retrieve next track for station '{}' from Pandora server".format( # self._station.name)) # return None - except Exception as e: + except Exception: # TODO: Remove this catch-all exception once we've figured out how to deal with all of them - logger.error('Error retrieving next Pandora track: {}'.format(encoding.locale_decode(e))) - logger.error('TRACEBACK INFO: {}'.format(traceback.format_exc())) + logger.exception('Error retrieving next Pandora track.') return None track_uri = PandoraUri.factory(pandora_track) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 1c934c9..9e1a05e 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -2,7 +2,6 @@ import time from mopidy import backend -from mopidy.internal import encoding import requests @@ -45,7 +44,7 @@ def change_pandora_track(self, track): self._consecutive_track_skips = 0 self._trigger_track_changed(track) else: - raise Unplayable("Track with URI '{}' is not playable".format(track.uri)) + raise Unplayable("Track with URI '{}' is not playable.".format(track.uri)) except (AttributeError, Unplayable, requests.exceptions.RequestException) as e: # Track is not playable. @@ -54,7 +53,7 @@ def change_pandora_track(self, track): if self._consecutive_track_skips >= self.SKIP_LIMIT: raise MaxSkipLimitExceeded(('Maximum track skip limit ({:d}) exceeded.' .format(self.SKIP_LIMIT))) - raise Unplayable("Cannot change to Pandora track '{}', ({}).".format(track.uri, encoding.locale_decode(e))) + raise Unplayable("Cannot change to Pandora track '{}', ({}).".format(track.uri, e)) def change_track(self, track): if track.uri is None: @@ -66,14 +65,14 @@ def change_track(self, track): return super(PandoraPlaybackProvider, self).change_track(track) except KeyError: - logger.error("Error changing track: failed to lookup '{}'".format(track.uri)) + logger.exception("Error changing Pandora track: failed to lookup '{}'.".format(track.uri)) return False - except Unplayable as e: - logger.error("{} Skipping to next track...".format(encoding.locale_decode(e))) + except Unplayable: + logger.exception('Skipping to next Pandora track...') self.backend.prepare_next_track() return False - except MaxSkipLimitExceeded as e: - logger.error('{} Stopping...'.format(encoding.locale_decode(e))) + except MaxSkipLimitExceeded: + logger.exception('Stopping Playback of Pandora track...') self._trigger_skip_limit_exceeded() return False diff --git a/tests/test_client.py b/tests/test_client.py index e9ad481..7c97977 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -98,7 +98,7 @@ def test_get_station_list_handles_request_exception(config, caplog): assert backend.api.get_station_list() == [] # Check that request exceptions are caught and logged - assert 'Error retrieving station list' in caplog.text() + assert 'Error retrieving Pandora station list.' in caplog.text() def test_get_station(config): diff --git a/tests/test_playback.py b/tests/test_playback.py index bc9b9da..88e644d 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -177,7 +177,7 @@ def test_change_track_fetches_next_track_if_unplayable(provider, playlist_item_m assert provider.change_track(track) is False assert provider.backend.prepare_next_track.called - assert 'Skipping to next track...' in caplog.text() + assert 'Skipping to next Pandora track...' in caplog.text() def test_change_track_skips_if_no_track_uri(provider): @@ -198,7 +198,7 @@ def test_change_track_skips_if_track_not_available_in_buffer(provider, playlist_ provider.backend.prepare_next_track = mock.PropertyMock() assert provider.change_track(track) is False - assert "Error changing track: failed to lookup '{}'".format(track.uri) in caplog.text() + assert "Error changing Pandora track: failed to lookup '{}'.".format(track.uri) in caplog.text() def test_translate_uri_returns_audio_url(provider, playlist_item_mock): From bec5d22cabd5be228081d576eacd961e50ac0167 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 27 Dec 2015 15:18:28 +0200 Subject: [PATCH 151/311] More descriptive log messages. --- mopidy_pandora/backend.py | 2 +- mopidy_pandora/frontend.py | 2 +- mopidy_pandora/library.py | 6 +++--- mopidy_pandora/playback.py | 2 +- tests/test_library.py | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index b1ccbef..c851dc1 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -79,7 +79,7 @@ def process_event(self, track_uri, pandora_event): func(track_uri) self._trigger_event_processed() except PandoraException: - logger.exception('Error calling Pandora event: {}'.format(pandora_event)) + logger.exception('Error calling Pandora event: {}.'.format(pandora_event)) return False def thumbs_up(self, track_uri): diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 50a96d4..8d06ded 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -173,7 +173,7 @@ def _process_events(self, track_uri, time_position): assert event_target_uri if type(PandoraUri.factory(event_target_uri)) is AdItemUri: - logger.info('Ignoring doubleclick event for advertisement') + logger.info('Ignoring doubleclick event for Pandora advertisement...') self.event_processed_event.set() return diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index d3ba6e5..54003c5 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -53,7 +53,7 @@ def lookup(self, uri): try: pandora_track = self.lookup_pandora_track(uri) except KeyError: - logger.error("Failed to lookup '{}'".format(uri)) + logger.exception("Failed to lookup Pandora URI '{}'.".format(uri)) return [] else: track_kwargs = {'uri': uri} @@ -84,7 +84,7 @@ def lookup(self, uri): album_kwargs['name'] = pandora_track.album_name album_kwargs['uri'] = pandora_track.album_detail_url else: - raise ValueError("Unexpected type to perform track lookup: {}".format(pandora_uri.uri_type)) + 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) @@ -103,7 +103,7 @@ def get_images(self, uris): if image_uri: image_uris.update([image_uri]) except (TypeError, KeyError): - logger.error("Failed to lookup image for URI '{}'".format(uri)) + 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 diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 9e1a05e..77ce63a 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -57,7 +57,7 @@ def change_pandora_track(self, track): def change_track(self, track): if track.uri is None: - logger.warning("No URI for track '{}'. Track cannot be played.".format(track)) + logger.warning("No URI for Pandora track '{}'. Track cannot be played.".format(track)) return False try: diff --git a/tests/test_library.py b/tests/test_library.py index b3e9509..4c40836 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -45,7 +45,7 @@ def test_get_images_for_unknown_uri_returns_empty_list(config, caplog): track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') results = backend.library.get_images([track_uri.uri]) assert len(results[track_uri.uri]) == 0 - assert "Failed to lookup image for URI '{}'".format(track_uri.uri) in caplog.text() + assert "Failed to lookup image for Pandora URI '{}'.".format(track_uri.uri) in caplog.text() def test_get_images_for_track_without_images(config, playlist_item_mock): @@ -80,7 +80,7 @@ def test_lookup_of_invalid_uri_type(config, caplog): backend = conftest.get_backend(config) backend.library.lookup('pandora:station:dummy_id:dummy_token') - assert 'Unexpected type to perform track lookup: station' in caplog.text() + assert 'Unexpected type to perform Pandora track lookup: station.' in caplog.text() def test_lookup_of_ad_uri(config, ad_item_mock): @@ -134,7 +134,7 @@ def test_lookup_of_missing_track(config, playlist_item_mock, caplog): results = backend.library.lookup(track_uri.uri) assert len(results) == 0 - assert 'Failed to lookup \'{}\''.format(track_uri.uri) in caplog.text() + assert "Failed to lookup Pandora URI '{}'.".format(track_uri.uri) in caplog.text() def test_browse_directory_uri(config): From 09cfb4c474925e5cb8db50a53d855f336be40e86 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 27 Dec 2015 15:35:20 +0200 Subject: [PATCH 152/311] Fix flake8 violation. --- tests/test_library.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_library.py b/tests/test_library.py index 672cae1..e1d36ca 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -30,6 +30,7 @@ def test_get_images_for_ad_without_images(config, ad_item_mock): results = backend.library.get_images([ad_uri.uri]) assert len(results[ad_uri.uri]) == 0 + @unittest.skip("Wait for pydora 1.6.3") def test_get_images_for_ad_with_images(config, ad_item_mock): backend = conftest.get_backend(config) From c673cb1e49513fb07454d45fd82811e4427124b0 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 27 Dec 2015 20:30:01 +0200 Subject: [PATCH 153/311] Log all unplayable exceptions as warnings. Force stopping player if no tracks are available. --- mopidy_pandora/backend.py | 4 +--- mopidy_pandora/frontend.py | 6 +++++- mopidy_pandora/playback.py | 14 ++++++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index c851dc1..268a623 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -64,9 +64,7 @@ def end_of_tracklist_reached(self): self.prepare_next_track() def prepare_next_track(self): - next_track = self.library.get_next_pandora_track() - if next_track: - self._trigger_next_track_available(next_track) + self._trigger_next_track_available(self.library.get_next_pandora_track()) def event_triggered(self, track_uri, pandora_event): self.process_event(track_uri, pandora_event) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 8d06ded..bdb3b7c 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -102,7 +102,11 @@ def track_changed(self, track): self._trigger_end_of_tracklist_reached() def next_track_available(self, track): - self.add_track(track) + if track: + self.add_track(track) + else: + logger.warning('No more Pandora tracks available to play.') + self.core.playback.stop() def skip_limit_exceeded(self): self.core.playback.stop() diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 77ce63a..c1cd8dc 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -46,14 +46,16 @@ def change_pandora_track(self, track): else: raise Unplayable("Track with URI '{}' is not playable.".format(track.uri)) - except (AttributeError, Unplayable, requests.exceptions.RequestException) as e: + except (AttributeError, requests.exceptions.RequestException) as e: + logger.warning('Error changing Pandora track: {}, ({})'.format(track), e) # Track is not playable. self._consecutive_track_skips += 1 if self._consecutive_track_skips >= self.SKIP_LIMIT: raise MaxSkipLimitExceeded(('Maximum track skip limit ({:d}) exceeded.' .format(self.SKIP_LIMIT))) - raise Unplayable("Cannot change to Pandora track '{}', ({}).".format(track.uri, e)) + raise Unplayable("Cannot change to Pandora track '{}', ({}:{}).".format(track.uri, + type(e).__name__, e.args)) def change_track(self, track): if track.uri is None: @@ -67,12 +69,12 @@ def change_track(self, track): except KeyError: logger.exception("Error changing Pandora track: failed to lookup '{}'.".format(track.uri)) return False - except Unplayable: - logger.exception('Skipping to next Pandora track...') + except Unplayable as e: + logger.error(e) self.backend.prepare_next_track() return False - except MaxSkipLimitExceeded: - logger.exception('Stopping Playback of Pandora track...') + except MaxSkipLimitExceeded as e: + logger.error(e) self._trigger_skip_limit_exceeded() return False From 247226bf4e7a0c58d9b9ce3c0c5257d73567d4de Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 27 Dec 2015 21:05:40 +0200 Subject: [PATCH 154/311] Increment pydora version dependency. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b6ef20b..8644d42 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ def run_tests(self): 'cachetools >= 1.0.0', 'Mopidy >= 1.1.1', 'Pykka >= 1.1', - 'pydora >= 1.6.2', + 'pydora >= 1.6.4', 'requests >= 2.5.0' ], tests_require=['tox'], From 83dbc07068166652d1f2e625e90647f9ad5961f9 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 27 Dec 2015 21:12:20 +0200 Subject: [PATCH 155/311] Fix error logging messages. --- mopidy_pandora/playback.py | 2 +- tests/test_playback.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index c1cd8dc..d62fa52 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -47,7 +47,7 @@ def change_pandora_track(self, track): raise Unplayable("Track with URI '{}' is not playable.".format(track.uri)) except (AttributeError, requests.exceptions.RequestException) as e: - logger.warning('Error changing Pandora track: {}, ({})'.format(track), e) + logger.warning('Error changing Pandora track: {}, ({})'.format(track, e)) # Track is not playable. self._consecutive_track_skips += 1 diff --git a/tests/test_playback.py b/tests/test_playback.py index 88e644d..42ee45d 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -177,7 +177,7 @@ def test_change_track_fetches_next_track_if_unplayable(provider, playlist_item_m assert provider.change_track(track) is False assert provider.backend.prepare_next_track.called - assert 'Skipping to next Pandora track...' in caplog.text() + assert 'Cannot change to Pandora track' in caplog.text() def test_change_track_skips_if_no_track_uri(provider): From b9c2e17edc54939a321d2e572d404125a7e8de00 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 27 Dec 2015 21:23:50 +0200 Subject: [PATCH 156/311] Uncomment code that can be enabled again with release of pydora 1.6.4. --- mopidy_pandora/library.py | 43 +++++++++++++++------------------------ tests/test_library.py | 6 ------ 2 files changed, 16 insertions(+), 33 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 48ad481..54003c5 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -74,10 +74,8 @@ def lookup(self, uri): if not pandora_track.company_name: pandora_track.company_name = '(Company name not specified)' album_kwargs['name'] = pandora_track.company_name - # TODO: image and clickthrough urls for ads will only be available in pydora 1.6.3 and above. - # Wait for https://github.com/mcrute/pydora/pull/37/files to be merged and then - # put this back: - # album_kwargs['uri'] = pandora_track.click_through_url + + album_kwargs['uri'] = pandora_track.click_through_url else: track_kwargs['name'] = pandora_track.song_name track_kwargs['length'] = pandora_track.track_length * 1000 @@ -99,11 +97,7 @@ def get_images(self, uris): try: pandora_track = self.lookup_pandora_track(uri) if pandora_track.is_ad is True: - # TODO: image and clickthrough urls for ads will only be available in pydora 1.6.3 and above. - # Wait for https://github.com/mcrute/pydora/pull/37/files to be merged and then - # put this back: - # image_uri = pandora_track.image_url - image_uri = None + image_uri = pandora_track.image_url else: image_uri = pandora_track.album_art_url if image_uri: @@ -119,24 +113,19 @@ def _cache_pandora_track(self, track, pandora_track): def _formatted_station_list(self, list): # Find QuickMix stations and move QuickMix to top - - # TODO: identifying quickmix stations will only be available in pydora 1.6.3 and above. - # Wait for https://github.com/mcrute/pydora/pull/37/files to be merged and then - # put this back: - - # 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 += '*' + 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 diff --git a/tests/test_library.py b/tests/test_library.py index e1d36ca..4c40836 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1,7 +1,5 @@ from __future__ import unicode_literals -import unittest - import conftest import mock @@ -31,7 +29,6 @@ def test_get_images_for_ad_without_images(config, ad_item_mock): assert len(results[ad_uri.uri]) == 0 -@unittest.skip("Wait for pydora 1.6.3") def test_get_images_for_ad_with_images(config, ad_item_mock): backend = conftest.get_backend(config) @@ -140,7 +137,6 @@ def test_lookup_of_missing_track(config, playlist_item_mock, caplog): assert "Failed to lookup Pandora URI '{}'.".format(track_uri.uri) in caplog.text() -@unittest.skip("Wait for pydora 1.6.3") def test_browse_directory_uri(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): @@ -169,7 +165,6 @@ def test_browse_directory_uri(config): Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][1])).uri -@unittest.skip("Wait for pydora 1.6.3") def test_browse_directory_sort_za(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): @@ -184,7 +179,6 @@ def test_browse_directory_sort_za(config): assert results[3].name == conftest.MOCK_STATION_NAME + ' 2' -@unittest.skip("Wait for pydora 1.6.3") def test_browse_directory_sort_date(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): From 709616778d8665af964cd526cadacac69911e468 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 28 Dec 2015 10:14:42 +0200 Subject: [PATCH 157/311] Ensure that GenreStations are processed as if subclasses of Stations. --- mopidy_pandora/playback.py | 7 +++---- mopidy_pandora/uri.py | 6 +++--- tests/test_uri.py | 28 +++++++++++++++++++++++----- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index d62fa52..533c7f6 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -47,7 +47,7 @@ def change_pandora_track(self, track): raise Unplayable("Track with URI '{}' is not playable.".format(track.uri)) except (AttributeError, requests.exceptions.RequestException) as e: - logger.warning('Error changing Pandora track: {}, ({})'.format(track, e)) + logger.warning('Error changing Pandora track: {}, ({})'.format(pandora_track, e)) # Track is not playable. self._consecutive_track_skips += 1 @@ -61,7 +61,6 @@ 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.change_pandora_track(track) return super(PandoraPlaybackProvider, self).change_track(track) @@ -70,11 +69,11 @@ def change_track(self, track): logger.exception("Error changing Pandora track: failed to lookup '{}'.".format(track.uri)) return False except Unplayable as e: - logger.error(e) + logger.warning(e) self.backend.prepare_next_track() return False except MaxSkipLimitExceeded as e: - logger.error(e) + logger.warning(e) self._trigger_skip_limit_exceeded() return False diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index f62b64e..fa8b0c2 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -1,7 +1,7 @@ import logging import urllib -from pandora.models.pandora import AdItem, PlaylistItem, Station +from pandora.models.pandora import AdItem, GenreStation, PlaylistItem, Station logger = logging.getLogger(__name__) @@ -53,7 +53,7 @@ def decode(cls, value): def factory(cls, obj): if isinstance(obj, basestring): return PandoraUri._from_uri(obj) - elif isinstance(obj, Station): + elif isinstance(obj, Station) or isinstance(obj, GenreStation): return PandoraUri._from_station(obj) elif isinstance(obj, PlaylistItem) or isinstance(obj, AdItem): return PandoraUri._from_track(obj) @@ -73,7 +73,7 @@ def _from_uri(cls, uri): @classmethod def _from_station(cls, station): - if isinstance(station, Station): + if isinstance(station, Station) or isinstance(station, GenreStation): if station.id.startswith('G') and station.id == station.token: return GenreStationUri(station.id, station.token) return StationUri(station.id, station.token) diff --git a/tests/test_uri.py b/tests/test_uri.py index a4400e1..24f246e 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -5,7 +5,7 @@ from mock import mock -from pandora.models.pandora import Station +from pandora.models.pandora import GenreStation, Station import pytest @@ -18,6 +18,18 @@ def test_factory_unsupported_type(): PandoraUri.factory(0) +def test_factory_returns_correct_station_uri_types(): + station_mock = mock.PropertyMock(spec=GenreStation) + station_mock.id = 'Gmock' + station_mock.token = 'Gmock' + assert type(PandoraUri.factory(station_mock)) is GenreStationUri + + station_mock = mock.PropertyMock(spec=Station) + station_mock.id = 'mock_id' + station_mock.token = 'mock_token' + assert type(PandoraUri.factory(station_mock)) is StationUri + + def test_pandora_parse_mock_uri(): uri = 'pandora:station:mock_id:mock_token' obj = PandoraUri._from_uri(uri) @@ -90,7 +102,7 @@ def test_station_uri_parse(station_mock): def test_station_uri_parse_returns_correct_type(): - station_mock = mock.PropertyMock(spec=Station) + station_mock = mock.PropertyMock(spec=GenreStation) station_mock.id = 'Gmock' station_mock.token = 'Gmock' @@ -129,13 +141,19 @@ def test_genre_station_uri_from_station_returns_correct_type(): genre_mock.id = 'mock_id' genre_mock.token = 'mock_token' - obj = GenreStationUri._from_station(genre_mock) + obj = StationUri._from_station(genre_mock) assert type(obj) is StationUri + assert obj.uri_type == 'station' + assert obj.station_id == 'mock_id' + assert obj.token == 'mock_token' + + assert obj.uri == 'pandora:station:mock_id:mock_token' + -def test_genre_station_uri_from_station(): - genre_station_mock = mock.PropertyMock(spec=Station) +def test_genre_station_uri_from_genre_station_returns_correct_type(): + genre_station_mock = mock.PropertyMock(spec=GenreStation) genre_station_mock.id = 'Gmock' genre_station_mock.token = 'Gmock' From ed67030d5bfbdb87484ebcfc10690b6bc93e7b22 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 28 Dec 2015 10:28:11 +0200 Subject: [PATCH 158/311] Type check genre stations in constructor to avoid confusion with regular stations. --- mopidy_pandora/uri.py | 7 +++++++ tests/test_uri.py | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index fa8b0c2..2d6e7b7 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -122,6 +122,13 @@ def __repr__(self): class GenreStationUri(StationUri): uri_type = 'genre_station' + def __init__(self, station_id, token): + # Check that this really is a Genre station ass opposed to a regular station. + # Genre station IDs always start with 'G'. + assert station_id.startswith('G') + assert station_id == token + super(GenreStationUri, self).__init__(station_id, token) + class TrackUri(PandoraUri): uri_type = 'track' diff --git a/tests/test_uri.py b/tests/test_uri.py index 24f246e..2e34850 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -124,14 +124,14 @@ def test_genre_uri_parse(): def test_genre_station_uri_parse(): - mock_uri = 'pandora:genre_station:mock_id:mock_token' + mock_uri = 'pandora:genre_station:Gmock:Gmock' obj = PandoraUri._from_uri(mock_uri) assert type(obj) is GenreStationUri assert obj.uri_type == 'genre_station' - assert obj.station_id == 'mock_id' - assert obj.token == 'mock_token' + assert obj.station_id == 'Gmock' + assert obj.token == 'Gmock' assert obj.uri == mock_uri From 465a13a1678dc515b44128d709be70ae56ffb3b8 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 28 Dec 2015 18:31:33 +0200 Subject: [PATCH 159/311] Adapt unplayable tracks logic for 'repeat' mode. Default behaviour is for the first track to automatically start again. Need to stop, add tracks, and start playback of the new tracklist explicitly. --- mopidy_pandora/backend.py | 12 ++++++------ mopidy_pandora/frontend.py | 35 +++++++++++++++++++++++++---------- mopidy_pandora/library.py | 1 + mopidy_pandora/listener.py | 19 ++++++++++++++++--- mopidy_pandora/playback.py | 14 +++++++------- tests/dummy_backend.py | 6 +++--- tests/test_frontend.py | 18 +++++++++--------- tests/test_library.py | 8 ++++---- tests/test_playback.py | 28 ++++++++++++++-------------- 9 files changed, 85 insertions(+), 56 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 268a623..a3df584 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -60,11 +60,11 @@ def on_start(self): except requests.exceptions.RequestException: logger.exception('Error logging in to Pandora.') - def end_of_tracklist_reached(self): - self.prepare_next_track() + def end_of_tracklist_reached(self, auto_play=False): + self.prepare_next_track(auto_play=auto_play) - def prepare_next_track(self): - self._trigger_next_track_available(self.library.get_next_pandora_track()) + def prepare_next_track(self, auto_play=False): + self._trigger_next_track_available(self.library.get_next_pandora_track(), auto_play) def event_triggered(self, track_uri, pandora_event): self.process_event(track_uri, pandora_event) @@ -95,9 +95,9 @@ def add_artist_bookmark(self, track_uri): def add_song_bookmark(self, track_uri): return self.api.add_song_bookmark(PandoraUri.factory(track_uri).token) - def _trigger_next_track_available(self, track): + def _trigger_next_track_available(self, track, auto_play=False): (listener.PandoraBackendListener.send(listener.PandoraBackendListener.next_track_available.__name__, - track=track)) + track=track, auto_play=auto_play)) def _trigger_event_processed(self): listener.PandoraBackendListener.send(listener.PandoraBackendListener.event_processed.__name__) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index bdb3b7c..d024cb9 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -97,13 +97,28 @@ def track_playback_paused(self, tl_track, time_position): def track_playback_resumed(self, tl_track, time_position): self.set_options() + def end_of_tracklist_reached(self, track=None): + if track: + tl_track = self.core.tracklist.filter({'uri': [track.uri]}).get()[0] + index = self.core.tracklist.index(tl_track).get() + else: + index = self.core.tracklist.index().get() + + return index == self.core.tracklist.get_length().get() - 1 + def track_changed(self, track): - if self.core.tracklist.index().get() == self.core.tracklist.get_length().get() - 1: - self._trigger_end_of_tracklist_reached() + if self.end_of_tracklist_reached(track): + self._trigger_end_of_tracklist_reached(auto_play=False) - def next_track_available(self, track): + def track_unplayable(self, track): + if self.end_of_tracklist_reached(track): + self.core.playback.stop() + self._trigger_end_of_tracklist_reached(auto_play=True) + self.core.tracklist.remove({'uri': [track.uri]}).get() + + def next_track_available(self, track, auto_play=False): if track: - self.add_track(track) + self.add_track(track, auto_play) else: logger.warning('No more Pandora tracks available to play.') self.core.playback.stop() @@ -111,19 +126,19 @@ def next_track_available(self, track): def skip_limit_exceeded(self): self.core.playback.stop() - def add_track(self, track): + def add_track(self, track, auto_play=False): # Add the next Pandora track self.core.tracklist.add(uris=[track.uri]).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() - if self.core.playback.get_state().get() == PlaybackState.STOPPED: - # Playback stopped after previous track was unplayable. Resume playback with the freshly seeded tracklist. - self.core.playback.play(tl_tracks[-1]).get() if len(tl_tracks) > 2: # Only need two tracks in the tracklist at any given time, remove the oldest tracks self.core.tracklist.remove({'tlid': [tl_tracks[t].tlid for t in range(0, len(tl_tracks)-2)]}).get() + if auto_play: + self.core.playback.play(tl_tracks[-1]).get() - def _trigger_end_of_tracklist_reached(self): - listener.PandoraFrontendListener.send(listener.PandoraFrontendListener.end_of_tracklist_reached.__name__) + def _trigger_end_of_tracklist_reached(self, auto_play=False): + listener.PandoraFrontendListener.send( + listener.PandoraFrontendListener.end_of_tracklist_reached.__name__, auto_play=auto_play) class EventHandlingPandoraFrontend(PandoraFrontend, listener.PandoraEventHandlingPlaybackListener): diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 54003c5..62e7bc3 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -154,6 +154,7 @@ def _browse_tracks(self, uri): if self._station is None or (pandora_uri.station_id != self._station.id): if type(pandora_uri) is GenreStationUri: + # TODO: Check if station exists before creating? pandora_uri = self._create_station_for_genre(pandora_uri.token) self._station = self.backend.api.get_station(pandora_uri.station_id) diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index cb1f26e..802c1da 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -14,10 +14,11 @@ class PandoraFrontendListener(listener.Listener): def send(event, **kwargs): listener.send_async(PandoraFrontendListener, event, **kwargs) - def end_of_tracklist_reached(self): + def end_of_tracklist_reached(self, auto_play=False): """ Called whenever the tracklist contains only one track, or the last track in the tracklist is being played. - + :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 @@ -57,12 +58,14 @@ class PandoraBackendListener(backend.BackendListener): def send(event, **kwargs): listener.send_async(PandoraBackendListener, event, **kwargs) - def next_track_available(self, track): + 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 @@ -97,6 +100,16 @@ def track_changed(self, track): """ 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 diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 533c7f6..43d969e 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -46,14 +46,16 @@ def change_pandora_track(self, track): else: raise Unplayable("Track with URI '{}' is not playable.".format(track.uri)) - except (AttributeError, requests.exceptions.RequestException) as e: + except (AttributeError, requests.exceptions.RequestException, Unplayable) as e: logger.warning('Error changing Pandora track: {}, ({})'.format(pandora_track, e)) # Track is not playable. self._consecutive_track_skips += 1 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))) + self._trigger_track_unplayable(track) raise Unplayable("Cannot change to Pandora track '{}', ({}:{}).".format(track.uri, type(e).__name__, e.args)) @@ -68,13 +70,8 @@ def change_track(self, track): except KeyError: logger.exception("Error changing Pandora track: failed to lookup '{}'.".format(track.uri)) return False - except Unplayable as e: + except (MaxSkipLimitExceeded, Unplayable) as e: logger.warning(e) - self.backend.prepare_next_track() - return False - except MaxSkipLimitExceeded as e: - logger.warning(e) - self._trigger_skip_limit_exceeded() return False def translate_uri(self, uri): @@ -83,6 +80,9 @@ def translate_uri(self, uri): def _trigger_track_changed(self, track): listener.PandoraPlaybackListener.send(listener.PandoraPlaybackListener.track_changed.__name__, track=track) + def _trigger_track_unplayable(self, track): + listener.PandoraPlaybackListener.send(listener.PandoraPlaybackListener.track_unplayable.__name__, track=track) + def _trigger_skip_limit_exceeded(self): listener.PandoraPlaybackListener.send(listener.PandoraPlaybackListener.skip_limit_exceeded.__name__) diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 8b072e3..f108a04 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -35,11 +35,11 @@ def __init__(self, config, audio): self.library = DummyLibraryProvider(backend=self) self.playback = DummyPlaybackProvider(audio=audio, backend=self) - self.uri_schemes = ['dummy'] + self.uri_schemes = ['mock'] class DummyLibraryProvider(backend.LibraryProvider): - root_directory = Ref.directory(uri='dummy:/', name='dummy') + root_directory = Ref.directory(uri='mock:/', name='mock') def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) @@ -78,7 +78,7 @@ def pause(self): return True def play(self): - return self._uri and self._uri != 'dummy:error' + return self._uri and self._uri != 'mock:error' def change_track(self, track): """Pass a track with URI 'dummy:error' to force failure""" diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 01135c4..2cd7f38 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -51,14 +51,14 @@ def setUp(self): self.tracks = [ Track(uri='pandora:track:mock_id1:mock_token1', length=40000), # Regular track Track(uri='pandora:ad:mock_id2', length=40000), # Advertisement - Track(uri='dummy:track:mock_id3:mock_token3', length=40000), # Not a pandora track + Track(uri='mock:track:mock_id3:mock_token3', length=40000), # Not a pandora track Track(uri='pandora:track:mock_id4:mock_token4', length=40000), Track(uri='pandora:track:mock_id5:mock_token5', length=None), # No duration ] self.uris = [ 'pandora:track:mock_id1:mock_token1', 'pandora:ad:mock_id2', - 'dummy:track:mock_id3:mock_token3', 'pandora:track:mock_id4:mock_token4', + 'mock:track:mock_id3:mock_token3', 'pandora:track:mock_id4:mock_token4', 'pandora:track:mock_id5:mock_token5'] def lookup(uris): @@ -142,7 +142,7 @@ def test_process_events_handles_exception(self): frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() frontend._trigger_event_triggered = mock.PropertyMock() - frontend._get_event = mock.PropertyMock(side_effect=ValueError('dummy_error')) + frontend._get_event = mock.PropertyMock(side_effect=ValueError('mock_error')) frontend.event_processed_event.get().clear() frontend.track_playback_resumed(self.tl_tracks[0], 100).get() @@ -165,21 +165,21 @@ def test_tracklist_changed_blocks_if_events_are_queued(self): frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() frontend.event_processed_event.get().clear() - frontend.last_played_track_uri = 'dummy_uri' - frontend.upcoming_track_uri = 'dummy_ury' + frontend.last_played_track_uri = 'mock_uri' + frontend.upcoming_track_uri = 'mock_ury' frontend.tracklist_changed().get() assert not frontend.tracklist_changed_event.get().isSet() - assert frontend.last_played_track_uri.get() == 'dummy_uri' - assert frontend.upcoming_track_uri.get() == 'dummy_ury' + assert frontend.last_played_track_uri.get() == 'mock_uri' + assert frontend.upcoming_track_uri.get() == 'mock_ury' def test_tracklist_changed_updates_uris_after_event_is_processed(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() frontend.event_processed_event.get().set() - frontend.last_played_track_uri = 'dummy_uri' - frontend.upcoming_track_uri = 'dummy_ury' + frontend.last_played_track_uri = 'mock_uri' + frontend.upcoming_track_uri = 'mock_ury' frontend.tracklist_changed().get() assert frontend.tracklist_changed_event.get().isSet() diff --git a/tests/test_library.py b/tests/test_library.py index 4c40836..c9b1491 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -42,7 +42,7 @@ def test_get_images_for_ad_with_images(config, ad_item_mock): def test_get_images_for_unknown_uri_returns_empty_list(config, caplog): backend = conftest.get_backend(config) - track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') + track_uri = PandoraUri.factory('pandora:track:mock_id:mock_token') results = backend.library.get_images([track_uri.uri]) assert len(results[track_uri.uri]) == 0 assert "Failed to lookup image for Pandora URI '{}'.".format(track_uri.uri) in caplog.text() @@ -51,7 +51,7 @@ def test_get_images_for_unknown_uri_returns_empty_list(config, caplog): def test_get_images_for_track_without_images(config, playlist_item_mock): backend = conftest.get_backend(config) - track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') + track_uri = PandoraUri.factory('pandora:track:mock_id:mock_token') playlist_item_mock.album_art_url = None backend.library._pandora_track_cache[track_uri.uri] = playlist_item_mock results = backend.library.get_images([track_uri.uri]) @@ -61,7 +61,7 @@ def test_get_images_for_track_without_images(config, playlist_item_mock): def test_get_images_for_track_with_images(config, playlist_item_mock): backend = conftest.get_backend(config) - track_uri = PandoraUri.factory('pandora:track:dummy_id:dummy_token') + track_uri = PandoraUri.factory('pandora:track:mock_id:mock_token') backend.library._pandora_track_cache[track_uri.uri] = playlist_item_mock results = backend.library.get_images([track_uri.uri]) assert len(results[track_uri.uri]) == 1 @@ -79,7 +79,7 @@ def test_lookup_of_invalid_uri_type(config, caplog): with pytest.raises(ValueError): backend = conftest.get_backend(config) - backend.library.lookup('pandora:station:dummy_id:dummy_token') + backend.library.lookup('pandora:station:mock_id:mock_token') assert 'Unexpected type to perform Pandora track lookup: station.' in caplog.text() diff --git a/tests/test_playback.py b/tests/test_playback.py index 42ee45d..8d91a4f 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -93,17 +93,17 @@ def test_change_track_enforces_skip_limit_if_no_track_available(provider, playli provider.previous_tl_track = {'track': {'uri': 'previous_track'}} provider.next_tl_track = {'track': {'uri': track.uri}} - provider.backend.prepare_next_track = mock.PropertyMock() + provider._trigger_track_unplayable = mock.PropertyMock() provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): assert provider.change_track(track) is False if i < PandoraPlaybackProvider.SKIP_LIMIT-1: - assert provider.backend.prepare_next_track.called - provider.backend.prepare_next_track.reset_mock() + assert provider._trigger_track_unplayable.called + provider._trigger_track_unplayable.reset_mock() assert not provider._trigger_skip_limit_exceeded.called else: - assert not provider.backend.prepare_next_track.called + assert not provider._trigger_track_unplayable.called assert provider._trigger_skip_limit_exceeded.called assert 'Maximum track skip limit ({:d}) exceeded.'.format( @@ -118,7 +118,7 @@ def test_change_track_enforces_skip_limit_if_no_audio_url(provider, playlist_ite provider.previous_tl_track = {'track': {'uri': 'previous_track'}} provider.next_tl_track = {'track': {'uri': track.uri}} - provider.backend.prepare_next_track = mock.PropertyMock() + provider._trigger_track_unplayable = mock.PropertyMock() provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) playlist_item_mock.audio_url = None @@ -126,11 +126,11 @@ def test_change_track_enforces_skip_limit_if_no_audio_url(provider, playlist_ite for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): assert provider.change_track(track) is False if i < PandoraPlaybackProvider.SKIP_LIMIT-1: - assert provider.backend.prepare_next_track.called - provider.backend.prepare_next_track.reset_mock() + assert provider._trigger_track_unplayable.called + provider._trigger_track_unplayable.reset_mock() assert not provider._trigger_skip_limit_exceeded.called else: - assert not provider.backend.prepare_next_track.called + assert not provider._trigger_track_unplayable.called assert provider._trigger_skip_limit_exceeded.called assert 'Maximum track skip limit ({:d}) exceeded.'.format( @@ -146,18 +146,18 @@ def test_change_track_enforces_skip_limit_on_request_exceptions(provider, playli provider.previous_tl_track = {'track': {'uri': 'previous_track'}} provider.next_tl_track = {'track': {'uri': track.uri}} - provider.backend.prepare_next_track = mock.PropertyMock() + provider._trigger_track_unplayable = mock.PropertyMock() provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) playlist_item_mock.audio_url = 'pandora:track:mock_id:mock_token' for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): assert provider.change_track(track) is False if i < PandoraPlaybackProvider.SKIP_LIMIT-1: - assert provider.backend.prepare_next_track.called - provider.backend.prepare_next_track.reset_mock() + assert provider._trigger_track_unplayable.called + provider._trigger_track_unplayable.reset_mock() assert not provider._trigger_skip_limit_exceeded.called else: - assert not provider.backend.prepare_next_track.called + assert not provider._trigger_track_unplayable.called assert provider._trigger_skip_limit_exceeded.called assert 'Maximum track skip limit ({:d}) exceeded.'.format( @@ -172,10 +172,10 @@ def test_change_track_fetches_next_track_if_unplayable(provider, playlist_item_m provider.previous_tl_track = {'track': {'uri': 'previous_track'}} provider.next_tl_track = {'track': {'uri': track.uri}} - provider.backend.prepare_next_track = mock.PropertyMock() + provider._trigger_track_unplayable = mock.PropertyMock() assert provider.change_track(track) is False - assert provider.backend.prepare_next_track.called + assert provider._trigger_track_unplayable.called assert 'Cannot change to Pandora track' in caplog.text() From c7a542ce7a8ffbf79d3207823e8c67b9fea15b2a Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 28 Dec 2015 19:01:51 +0200 Subject: [PATCH 160/311] Ensure that change_track does not do anything once the skip limit is exceeded. --- mopidy_pandora/playback.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 43d969e..1b3c02d 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -47,14 +47,10 @@ def change_pandora_track(self, track): raise Unplayable("Track with URI '{}' is not playable.".format(track.uri)) except (AttributeError, requests.exceptions.RequestException, Unplayable) as e: - logger.warning('Error changing Pandora track: {}, ({})'.format(pandora_track, e)) + logger.warning('Error changing Pandora track: {}, ({})'.format(track, e)) # Track is not playable. self._consecutive_track_skips += 1 - - 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))) + self.check_skip_limit() self._trigger_track_unplayable(track) raise Unplayable("Cannot change to Pandora track '{}', ({}:{}).".format(track.uri, type(e).__name__, e.args)) @@ -64,6 +60,7 @@ def change_track(self, track): logger.warning("No URI for Pandora track '{}'. Track cannot be played.".format(track)) return False try: + self.check_skip_limit() self.change_pandora_track(track) return super(PandoraPlaybackProvider, self).change_track(track) @@ -74,6 +71,12 @@ def change_track(self, track): 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 translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url From 0abbdf98c7e5a14033523b21ca87b480f9d67f3c Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 2 Jan 2016 17:49:17 +0200 Subject: [PATCH 161/311] Add 'on_pause_stop_click' event container. Add ability to delete stations. Revert to using consume instead of repeat mode. --- README.rst | 15 ++++++------ mopidy_pandora/__init__.py | 27 ++++++++++++++++++--- mopidy_pandora/backend.py | 17 ++++++++++---- mopidy_pandora/ext.conf | 1 + mopidy_pandora/frontend.py | 48 ++++++++++++++++++++++++-------------- mopidy_pandora/listener.py | 10 +++++--- mopidy_pandora/playback.py | 6 +++++ tests/conftest.py | 3 ++- tests/test_extension.py | 2 ++ tests/test_frontend.py | 4 ++-- 10 files changed, 94 insertions(+), 39 deletions(-) diff --git a/README.rst b/README.rst index d20bdd3..f7ec9ff 100644 --- a/README.rst +++ b/README.rst @@ -27,7 +27,7 @@ Dependencies - Requires a free (ad supported) Pandora account or a Pandora One subscription (provides ad-free playback and higher quality 192 Kbps audio stream). -- ``pydora`` >= 1.6.3. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. +- ``pydora`` >= 1.6.4. 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. @@ -81,7 +81,7 @@ The following configuration values are available: stations in alphabetical order. - ``pandora/auto_setup``: If Mopidy-Pandora should automatically configure the Mopidy player for best compatibility - with the Pandora radio stream. Defaults to ``true`` and turns ``repeat`` on and ``consume``, ``random``, and + 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 how long station and genre lists should be cached for between refreshes, @@ -101,9 +101,10 @@ pause/play/previous/next buttons. 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_stop_click``: click pause and then stop in quick succession. Calls event. Defaults to ``delete_station``. -The full list of supported events include: ``thumbs_up``, ``thumbs_down``, ``sleep``, ``add_artist_bookmark``, and -``add_song_bookmark``. +The full list of supported events include: ``thumbs_up``, ``thumbs_down``, ``sleep``, ``add_artist_bookmark``, +``add_song_bookmark``, and ``delete_station``. Usage ===== @@ -136,7 +137,8 @@ v0.2.0 (UNRELEASED) length, bitrate etc.). - Simulate dynamic tracklist (workaround for `#2 `_) - Add support for browsing genre stations. Note that clicking on a genre station will automatically add that station to - your profile. At the moment there is no way to remove stations from within Mopidy-Pandora. + your profile. +- Add ability to delete a station by setting one of the doubleclick event parameters to ``delete_station``. - Move 'QuickMix' to the top of the station list. Stations that will be played as part of QuickMix are marked with an asterisk (*). - Scrobbling tracks to Last.fm is now supported. @@ -144,9 +146,6 @@ v0.2.0 (UNRELEASED) parameter ``cache_time_to_live`` can be used to specify when cache items should expire and be refreshed (in seconds). - Force Mopidy to stop when skip limit is exceeded (workaround for `#1221 `_). - Now plays advertisements which should prevent non-Pandora One accounts from being locked after extended use. -- **Event support does not work at the moment** (see `#35 `_), - so it has been disabled by default. In the interim, you can patch Mopidy 1.1.1 with `#1356 `_ - if you want to keep using events until the fix is available. v0.1.7 (Oct 31, 2015) --------------------- diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index df089e3..f5f1794 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -38,9 +38,30 @@ def get_config_schema(self): 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']) - schema['on_pause_next_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep']) - schema['on_pause_previous_click'] = config.String(choices=['thumbs_up', 'thumbs_down', 'sleep']) + 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_stop_click'] = config.String(choices=['thumbs_up', + 'thumbs_down', + 'sleep', + 'add_artist_bookmark', + 'add_song_bookmark', + 'delete_station']) return schema def setup(self, registry): diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index a3df584..ffa17ca 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -72,10 +72,9 @@ def event_triggered(self, track_uri, pandora_event): def process_event(self, track_uri, pandora_event): func = getattr(self, pandora_event) try: - logger.info("Triggering event '{}' for Pandora song: '{}'.".format(pandora_event, - self.library.lookup_pandora_track(track_uri).song_name)) + logger.info("Triggering event '{}' for URI: '{}'.".format(pandora_event, track_uri)) func(track_uri) - self._trigger_event_processed() + self._trigger_event_processed(track_uri, pandora_event) except PandoraException: logger.exception('Error calling Pandora event: {}.'.format(pandora_event)) return False @@ -95,9 +94,17 @@ def add_artist_bookmark(self, track_uri): 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) + # Invalidate the cache so that it is refreshed on the next request + self.api._station_list_cache.popitem() + self.library.browse(self.library.root_directory.uri) + return r + def _trigger_next_track_available(self, track, auto_play=False): (listener.PandoraBackendListener.send(listener.PandoraBackendListener.next_track_available.__name__, track=track, auto_play=auto_play)) - def _trigger_event_processed(self): - listener.PandoraBackendListener.send(listener.PandoraBackendListener.event_processed.__name__) + def _trigger_event_processed(self, track_uri, pandora_event): + listener.PandoraBackendListener.send(listener.PandoraBackendListener.event_processed.__name__, + track_uri=track_uri, pandora_event=pandora_event) diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index 1665571..40d95d9 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -18,3 +18,4 @@ double_click_interval = 2.00 on_pause_resume_click = thumbs_up on_pause_next_click = thumbs_down on_pause_previous_click = sleep +on_pause_stop_click = delete_station diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index d024cb9..4a9d6e2 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -67,10 +67,10 @@ def set_options(self): # Setup playback to mirror behaviour of official Pandora front-ends. if self.auto_setup and self.setup_required: assert isinstance(self.core.tracklist, object) - if self.core.tracklist.get_repeat().get() is False: - self.core.tracklist.set_repeat(True) - if self.core.tracklist.get_consume().get() is True: - self.core.tracklist.set_consume(False) + if self.core.tracklist.get_repeat().get() is True: + self.core.tracklist.set_repeat(False) + if self.core.tracklist.get_consume().get() is False: + self.core.tracklist.set_consume(True) if self.core.tracklist.get_random().get() is True: self.core.tracklist.set_random(False) if self.core.tracklist.get_single().get() is True: @@ -98,13 +98,16 @@ def track_playback_resumed(self, tl_track, time_position): self.set_options() def 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] index = self.core.tracklist.index(tl_track).get() else: index = self.core.tracklist.index().get() - return index == self.core.tracklist.get_length().get() - 1 + return index == length - 1 def track_changed(self, track): if self.end_of_tracklist_reached(track): @@ -149,7 +152,8 @@ def __init__(self, config, core): self.settings = { 'OPR_EVENT': config['pandora'].get('on_pause_resume_click'), 'OPN_EVENT': config['pandora'].get('on_pause_next_click'), - 'OPP_EVENT': config['pandora'].get('on_pause_previous_click') + 'OPP_EVENT': config['pandora'].get('on_pause_previous_click'), + 'OPS_EVENT': config['pandora'].get('on_pause_stop_click') } self.last_played_track_uri = None @@ -175,13 +179,19 @@ def tracklist_changed(self): # Delay 'tracklist_changed' events until all events have been processed. self.tracklist_changed_event.clear() + @only_execute_for_pandora_uris + def track_playback_ended(self, tl_track, time_position): + super(EventHandlingPandoraFrontend, self).track_playback_ended(tl_track, time_position) + + self._process_events(tl_track.track.uri, time_position, action=self.track_playback_ended.__name__) + @only_execute_for_pandora_uris def track_playback_resumed(self, tl_track, time_position): super(EventHandlingPandoraFrontend, self).track_playback_resumed(tl_track, time_position) - self._process_events(tl_track.track.uri, time_position) + self._process_events(tl_track.track.uri, time_position, action=self.track_playback_resumed.__name__) - def _process_events(self, track_uri, time_position): + def _process_events(self, track_uri, time_position, action=None): # Check if there are any events that still require processing. if self.event_processed_event.isSet(): @@ -197,7 +207,7 @@ def _process_events(self, track_uri, time_position): return try: - self._trigger_event_triggered(event_target_uri, self._get_event(track_uri, time_position)) + self._trigger_event_triggered(event_target_uri, self._get_event(track_uri, time_position, action=action)) except ValueError: logger.exception("Error processing Pandora event for URI '{}'. Ignoring event...".format(event_target_uri)) self.event_processed_event.set() @@ -212,25 +222,29 @@ def _get_event_target_uri(self, track_uri, time_position): # Trigger the event for the track that is playing currently. return track_uri - def _get_event(self, track_uri, time_position): - if track_uri == self.last_played_track_uri: + def _get_event(self, track_uri, time_position, action=None): + if action == self.track_playback_resumed.__name__ and track_uri == self.last_played_track_uri: if time_position > 0: # Resuming playback on the first track in the tracklist. return self.settings['OPR_EVENT'] else: return self.settings['OPP_EVENT'] - elif track_uri == self.upcoming_track_uri: + elif action == self.track_playback_resumed.__name__ and track_uri == self.upcoming_track_uri: return self.settings['OPN_EVENT'] + elif action == self.track_playback_ended.__name__: + return self.settings['OPS_EVENT'] else: raise ValueError('Unexpected event URI: {}'.format(track_uri)) - def event_processed(self): + def event_processed(self, track_uri, pandora_event): self.event_processed_event.set() - - if not self.tracklist_changed_event.isSet(): - # Do any 'tracklist_changed' updates that are pending. - self.tracklist_changed() + if pandora_event == 'delete_station': + self.core.tracklist.clear() + else: + if not self.tracklist_changed_event.isSet(): + # Do any 'tracklist_changed' updates that are pending. + self.tracklist_changed() def doubleclicked(self): self.event_processed_event.clear() diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 802c1da..1457ace 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -69,10 +69,14 @@ def next_track_available(self, track, auto_play=False): """ pass - def event_processed(self): + def event_processed(self, track_uri, pandora_event): """ - Called when the backend has successfully processed the event for the track. This lets the frontend know - that it can process any tracklist changed events that were queued while the Pandora event was being processed. + 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 diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 1b3c02d..7d3d6d3 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -133,6 +133,12 @@ def pause(self): return super(EventHandlingPlaybackProvider, self).pause() + def stop(self): + if self.is_double_click(): + self._trigger_doubleclicked() + + return super(EventHandlingPlaybackProvider, self).stop() + def _trigger_doubleclicked(self): self.set_click_time(0) listener.PandoraEventHandlingPlaybackListener.send( diff --git a/tests/conftest.py b/tests/conftest.py index e7fcee3..290bdb1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ MOCK_STATION_TYPE = 'station' MOCK_STATION_NAME = 'Mock Station' MOCK_STATION_ID = '0000000000000000001' -MOCK_STATION_TOKEN = '0000000000000000010' +MOCK_STATION_TOKEN = '0000000000000000001' MOCK_STATION_DETAIL_URL = 'http://mockup.com/station/detail_url?...' MOCK_STATION_ART_URL = 'http://mockup.com/station/art_url?...' @@ -68,6 +68,7 @@ def config(): 'on_pause_resume_click': 'thumbs_up', 'on_pause_next_click': 'thumbs_down', 'on_pause_previous_click': 'sleep', + 'on_pause_stop_click': 'delete_station', } } diff --git a/tests/test_extension.py b/tests/test_extension.py index 6464275..fc7ed7b 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -36,6 +36,7 @@ def test_get_default_config(self): self.assertIn('on_pause_resume_click = thumbs_up', config) self.assertIn('on_pause_next_click = thumbs_down', config) self.assertIn('on_pause_previous_click = sleep', config) + self.assertIn('on_pause_stop_click = delete_station', config) def test_get_config_schema(self): ext = Extension() @@ -60,6 +61,7 @@ def test_get_config_schema(self): self.assertIn('on_pause_resume_click', schema) self.assertIn('on_pause_next_click', schema) self.assertIn('on_pause_previous_click', schema) + self.assertIn('on_pause_stop_click', schema) def test_setup(self): registry = mock.Mock() diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 2cd7f38..e50b798 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -112,8 +112,8 @@ def test_set_options_performs_auto_setup(self): frontend = PandoraFrontend.start(conftest.config(), self.core).proxy() frontend.track_playback_started(self.tracks[0]).get() - assert self.core.tracklist.get_repeat().get() is True - assert self.core.tracklist.get_consume().get() is False + assert self.core.tracklist.get_repeat().get() is False + assert self.core.tracklist.get_consume().get() is True assert self.core.tracklist.get_random().get() is False assert self.core.tracklist.get_single().get() is False From 0339c229f698bd5bbd3948a74f8c547f7ca5d436 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 2 Jan 2016 19:40:02 +0200 Subject: [PATCH 162/311] Test cases for browsing genres. --- mopidy_pandora/backend.py | 2 +- mopidy_pandora/library.py | 3 +-- tests/conftest.py | 30 +++++++++++++++++++++++++++++- tests/test_library.py | 37 ++++++++++++++++++++++++++++++++++++- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index ffa17ca..a6fe75a 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -97,7 +97,7 @@ def add_song_bookmark(self, track_uri): def delete_station(self, track_uri): r = self.api.delete_station(PandoraUri.factory(track_uri).station_id) # Invalidate the cache so that it is refreshed on the next request - self.api._station_list_cache.popitem() + self.api._station_list_cache.clear() self.library.browse(self.library.root_directory.uri) return r diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 62e7bc3..4d92d51 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -154,7 +154,6 @@ def _browse_tracks(self, uri): if self._station is None or (pandora_uri.station_id != self._station.id): if type(pandora_uri) is GenreStationUri: - # TODO: Check if station exists before creating? pandora_uri = self._create_station_for_genre(pandora_uri.token) self._station = self.backend.api.get_station(pandora_uri.station_id) @@ -167,7 +166,7 @@ def _create_station_for_genre(self, genre_token): new_station = Station.from_json(self.backend.api, json_result) # Invalidate the cache so that it is refreshed on the next request - self.backend.api._station_list_cache.popitem() + self.backend.api._station_list_cache.clear() return PandoraUri.factory(new_station) diff --git a/tests/conftest.py b/tests/conftest.py index 290bdb1..3754d33 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ from mock import Mock -from pandora.models.pandora import AdItem, Playlist, PlaylistItem, Station, StationList +from pandora.models.pandora import AdItem, GenreStation, GenreStationList, Playlist, PlaylistItem, Station, StationList import pytest @@ -87,6 +87,12 @@ def get_backend(config, simulate_request_exceptions=False): return obj +@pytest.fixture(scope='session') +def genre_station_mock(simulate_request_exceptions=False): + return GenreStation.from_json(get_backend(config(), simulate_request_exceptions).api, + genre_stations_result_mock()['categories'][0]['stations'][0]) + + @pytest.fixture(scope='session') def station_result_mock(): mock_result = {'stat': 'ok', @@ -255,6 +261,23 @@ def get_ad_item_mock(self, token): return ad_item_mock() +@pytest.fixture(scope='session') +def genre_stations_result_mock(): + mock_result = {'stat': 'ok', + 'result': { + 'categories': [{ + 'stations': [{ + 'stationToken': 'G100', + 'stationName': 'Genre mock', + 'stationId': 'G100' + }], + 'categoryName': 'Category mock' + }], + }} + + return mock_result['result'] + + @pytest.fixture(scope='session') def station_list_result_mock(): mock_result = {'stat': 'ok', @@ -279,6 +302,11 @@ def get_station_list_mock(self): return StationList.from_json(get_backend(config()).api, station_list_result_mock()) +@pytest.fixture +def get_genre_stations_mock(self): + return GenreStationList.from_json(get_backend(config()).api, genre_stations_result_mock()) + + @pytest.fixture(scope='session') def request_exception_mock(self, *args, **kwargs): raise requests.exceptions.RequestException diff --git a/tests/test_library.py b/tests/test_library.py index c9b1491..902cf4a 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -14,7 +14,7 @@ from mopidy_pandora.client import MopidyAPIClient from mopidy_pandora.library import PandoraLibraryProvider -from mopidy_pandora.uri import PandoraUri, PlaylistItemUri, StationUri +from mopidy_pandora.uri import GenreUri, PandoraUri, PlaylistItemUri, StationUri from tests.conftest import get_station_list_mock @@ -193,6 +193,41 @@ def test_browse_directory_sort_date(config): assert results[3].name == conftest.MOCK_STATION_NAME + ' 1' +def test_browse_genres(config): + with mock.patch.object(MopidyAPIClient, 'get_genre_stations', conftest.get_genre_stations_mock): + + backend = conftest.get_backend(config) + results = backend.library.browse(backend.library.genre_directory.uri) + assert len(results) == 1 + assert results[0].name == 'Category mock' + + +def test_browse_genre_category(config): + with mock.patch.object(MopidyAPIClient, 'get_genre_stations', conftest.get_genre_stations_mock): + + backend = conftest.get_backend(config) + category_uri = 'pandora:genre:Category mock' + results = backend.library.browse(category_uri) + assert len(results) == 1 + assert results[0].name == 'Genre mock' + + +def test_browse_genre_station_uri(config, genre_station_mock): + with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): + with mock.patch.object(APIClient, 'create_station', + mock.Mock(return_value=conftest.station_result_mock()['result'])) as create_station_mock: + + backend = conftest.get_backend(config) + genre_uri = GenreUri._from_station(genre_station_mock) + backend.api._station_list_cache['1'] = 'cache_item_mock' + assert backend.api._station_list_cache.currsize == 1 + + results = backend.library.browse(genre_uri.uri) + assert len(results) == 1 + assert backend.api._station_list_cache.currsize == 0 + assert create_station_mock.called + + def test_browse_station_uri(config, station_mock): with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): From 0908e87d4c8647285afe9da2d6e1afcd108a16d2 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 2 Jan 2016 19:52:54 +0200 Subject: [PATCH 163/311] Make event trigger log messages more precise. --- mopidy_pandora/backend.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index a6fe75a..ce7cf64 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -72,7 +72,12 @@ def event_triggered(self, track_uri, pandora_event): def process_event(self, track_uri, pandora_event): func = getattr(self, pandora_event) try: - logger.info("Triggering event '{}' for URI: '{}'.".format(pandora_event, track_uri)) + 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) except PandoraException: From 760c0b05fe1c44b07cfdfc785d3688cba73d3a65 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 2 Jan 2016 20:13:43 +0200 Subject: [PATCH 164/311] Fix flake8 error. --- mopidy_pandora/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index ce7cf64..9421878 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -77,7 +77,7 @@ def process_event(self, track_uri, pandora_event): .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)) + .format(pandora_event, self.library.lookup_pandora_track(track_uri).song_name)) func(track_uri) self._trigger_event_processed(track_uri, pandora_event) except PandoraException: From 631db426f912f9c1fb34f07cd815a5a408ba7dc9 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 2 Jan 2016 20:17:30 +0200 Subject: [PATCH 165/311] Re-enable event support by default. --- mopidy_pandora/ext.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index 40d95d9..c6ffd73 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -13,7 +13,7 @@ sort_order = date auto_setup = true cache_time_to_live = 1800 -event_support_enabled = false +event_support_enabled = true double_click_interval = 2.00 on_pause_resume_click = thumbs_up on_pause_next_click = thumbs_down From 7609e48f3cc13d7b3687a7be292367c6dd74050a Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 2 Jan 2016 20:24:36 +0200 Subject: [PATCH 166/311] Update README. --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index f7ec9ff..82c5608 100644 --- a/README.rst +++ b/README.rst @@ -24,8 +24,8 @@ Mopidy-Pandora Dependencies ============ -- Requires a free (ad supported) Pandora account or a Pandora One subscription (provides ad-free playback and higher - quality 192 Kbps audio stream). +- Requires a free, ad supported. Pandora account or a Pandora One subscription (which provides ad-free playback and a + higher quality 192 Kbps audio stream). - ``pydora`` >= 1.6.4. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. @@ -135,7 +135,7 @@ v0.2.0 (UNRELEASED) - Now displays all of the correct track information during playback (e.g. song and artist names, album covers, track length, bitrate etc.). -- Simulate dynamic tracklist (workaround for `#2 `_) +- Simulate dynamic tracklist (workaround for `#2 `_) - Add support for browsing genre stations. Note that clicking on a genre station will automatically add that station to your profile. - Add ability to delete a station by setting one of the doubleclick event parameters to ``delete_station``. From 15203e8d02f47a78a8e085fcd1f5e82ceb69116c Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 2 Jan 2016 21:08:48 +0200 Subject: [PATCH 167/311] Implement LibraryProvider.refresh() method. --- mopidy_pandora/backend.py | 3 +-- mopidy_pandora/library.py | 10 +++++-- tests/conftest.py | 4 +-- tests/test_client.py | 55 ++++++++++++++++++++------------------- tests/test_extension.py | 2 +- tests/test_library.py | 35 +++++++++++++------------ 6 files changed, 59 insertions(+), 50 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 9421878..ec81226 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -101,8 +101,7 @@ def add_song_bookmark(self, track_uri): def delete_station(self, track_uri): r = self.api.delete_station(PandoraUri.factory(track_uri).station_id) - # Invalidate the cache so that it is refreshed on the next request - self.api._station_list_cache.clear() + self.library.refresh() self.library.browse(self.library.root_directory.uri) return r diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 4d92d51..c272794 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -165,8 +165,7 @@ 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) - # Invalidate the cache so that it is refreshed on the next request - self.backend.api._station_list_cache.clear() + self.refresh() return PandoraUri.factory(new_station) @@ -209,3 +208,10 @@ def get_next_pandora_track(self): self._cache_pandora_track(track, pandora_track) return track + + def refresh(self, uri=None): + if not uri or uri == self.root_directory.uri: + self.backend.api.get_station_list(force_refresh=True) + self.backend.api.get_genre_stations(force_refresh=True) + elif uri == self.genre_directory: + self.backend.api.get_genre_stations(force_refresh=True) diff --git a/tests/conftest.py b/tests/conftest.py index 3754d33..47e440e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -298,12 +298,12 @@ def station_list_result_mock(): @pytest.fixture -def get_station_list_mock(self): +def get_station_list_mock(self, force_refresh=False): return StationList.from_json(get_backend(config()).api, station_list_result_mock()) @pytest.fixture -def get_genre_stations_mock(self): +def get_genre_stations_mock(self, force_refresh=False): return GenreStationList.from_json(get_backend(config()).api, genre_stations_result_mock()) diff --git a/tests/test_client.py b/tests/test_client.py index 7c97977..af6208e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,14 +11,12 @@ import pytest - -from tests.conftest import get_backend -from tests.conftest import get_station_list_mock +from mopidy_pandora.client import MopidyAPIClient def test_get_station_list(config): - with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): - backend = get_backend(config) + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): + backend = conftest.get_backend(config) station_list = backend.api.get_station_list() @@ -29,8 +27,8 @@ def test_get_station_list(config): def test_get_station_list_populates_cache(config): - with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): - backend = get_backend(config) + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): + backend = conftest.get_backend(config) assert backend.api._station_list_cache.currsize == 0 @@ -39,10 +37,10 @@ def test_get_station_list_populates_cache(config): def test_get_station_list_changed_cached(config): - with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): # Ensure that the cache is re-used between calls with mock.patch.object(StationList, 'has_changed', return_value=True): - backend = get_backend(config) + backend = conftest.get_backend(config) cached_checksum = 'zz00aa00aa00aa00aa00aa00aa00aa99' mock_cached_result = {'stat': 'ok', @@ -65,10 +63,10 @@ def test_get_station_list_changed_cached(config): def test_get_station_list_changed_refreshed(config): - with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): # Ensure that the cache is invalidated if 'force_refresh' is True with mock.patch.object(StationList, 'has_changed', return_value=True): - backend = get_backend(config) + backend = conftest.get_backend(config) cached_checksum = 'zz00aa00aa00aa00aa00aa00aa00aa99' mock_cached_result = {'stat': 'ok', @@ -88,12 +86,12 @@ def test_get_station_list_changed_refreshed(config): backend.api.get_station_list(force_refresh=True) assert backend.api.get_station_list().checksum == conftest.MOCK_STATION_LIST_CHECKSUM - assert len(backend.api._station_list_cache.itervalues().next()) == \ - len(conftest.station_list_result_mock()['stations']) + assert (len(backend.api._station_list_cache.itervalues().next()) == + len(conftest.station_list_result_mock()['stations'])) def test_get_station_list_handles_request_exception(config, caplog): - backend = get_backend(config, True) + backend = conftest.get_backend(config, True) assert backend.api.get_station_list() == [] @@ -102,10 +100,10 @@ def test_get_station_list_handles_request_exception(config, caplog): def test_get_station(config): - with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): # Make sure we re-use the cached station list between calls with mock.patch.object(StationList, 'has_changed', return_value=False): - backend = get_backend(config) + backend = conftest.get_backend(config) backend.api.get_station_list() @@ -117,23 +115,26 @@ def test_get_station(config): def test_get_invalid_station(config): - with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): # Check that a call to the Pandora server is triggered if station is # not found in the cache with pytest.raises(conftest.TransportCallTestNotImplemented): - backend = get_backend(config) + backend = conftest.get_backend(config) backend.api.get_station('9999999999999999999') def test_create_genre_station_invalidates_cache(config): - backend = get_backend(config) - - backend.api.create_station = mock.PropertyMock(return_value=conftest.station_result_mock()['result']) - backend.api._station_list_cache[time.time()] = 'test_value' - assert backend.api._station_list_cache.currsize == 1 - - backend.library._create_station_for_genre('test_token') - - assert backend.api._station_list_cache.currsize == 0 + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): + with mock.patch.object(MopidyAPIClient, 'get_genre_stations', conftest.get_genre_stations_mock): + backend = conftest.get_backend(config) + + backend.api.create_station = mock.PropertyMock(return_value=conftest.station_result_mock()['result']) + t = time.time() + backend.api._station_list_cache[t] = mock.Mock(spec=StationList) + assert t in backend.api._station_list_cache.keys() + + backend.library._create_station_for_genre('test_token') + assert t not in backend.api._station_list_cache.keys() + assert backend.api._station_list_cache.currsize == 1 diff --git a/tests/test_extension.py b/tests/test_extension.py index fc7ed7b..4c60663 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -31,7 +31,7 @@ def test_get_default_config(self): self.assertIn('sort_order = date', config) self.assertIn('auto_setup = true', config) self.assertIn('cache_time_to_live = 1800', config) - self.assertIn('event_support_enabled = false', config) + self.assertIn('event_support_enabled = true', config) self.assertIn('double_click_interval = 2.00', config) self.assertIn('on_pause_resume_click = thumbs_up', config) self.assertIn('on_pause_next_click = thumbs_down', config) diff --git a/tests/test_library.py b/tests/test_library.py index 902cf4a..6a91cd9 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import time + import conftest import mock @@ -7,7 +9,7 @@ from mopidy import models from pandora import APIClient -from pandora.models.pandora import Station +from pandora.models.pandora import Station, StationList import pytest @@ -16,8 +18,6 @@ from mopidy_pandora.uri import GenreUri, PandoraUri, PlaylistItemUri, StationUri -from tests.conftest import get_station_list_mock - def test_get_images_for_ad_without_images(config, ad_item_mock): backend = conftest.get_backend(config) @@ -138,7 +138,7 @@ def test_lookup_of_missing_track(config, playlist_item_mock, caplog): def test_browse_directory_uri(config): - with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): backend = conftest.get_backend(config) results = backend.library.browse(backend.library.root_directory.uri) @@ -166,7 +166,7 @@ def test_browse_directory_uri(config): def test_browse_directory_sort_za(config): - with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): config['pandora']['sort_order'] = 'A-Z' backend = conftest.get_backend(config) @@ -180,7 +180,7 @@ def test_browse_directory_sort_za(config): def test_browse_directory_sort_date(config): - with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): config['pandora']['sort_order'] = 'date' backend = conftest.get_backend(config) @@ -216,16 +216,19 @@ def test_browse_genre_station_uri(config, genre_station_mock): with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): with mock.patch.object(APIClient, 'create_station', mock.Mock(return_value=conftest.station_result_mock()['result'])) as create_station_mock: - - backend = conftest.get_backend(config) - genre_uri = GenreUri._from_station(genre_station_mock) - backend.api._station_list_cache['1'] = 'cache_item_mock' - assert backend.api._station_list_cache.currsize == 1 - - results = backend.library.browse(genre_uri.uri) - assert len(results) == 1 - assert backend.api._station_list_cache.currsize == 0 - assert create_station_mock.called + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): + with mock.patch.object(MopidyAPIClient, 'get_genre_stations', conftest.get_genre_stations_mock): + + backend = conftest.get_backend(config) + genre_uri = GenreUri._from_station(genre_station_mock) + t = time.time() + backend.api._station_list_cache[t] = mock.Mock(spec=StationList) + + results = backend.library.browse(genre_uri.uri) + assert len(results) == 1 + assert backend.api._station_list_cache.currsize == 1 + assert t not in backend.api._station_list_cache.keys() + assert create_station_mock.called def test_browse_station_uri(config, station_mock): From 9b39f8edea8f0371871e8f8700e37e0940f6d1b3 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 2 Jan 2016 21:17:13 +0200 Subject: [PATCH 168/311] Add ability to refresh library based on station URI. --- mopidy_pandora/library.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index c272794..e6f9e4e 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -215,3 +215,8 @@ def refresh(self, uri=None): self.backend.api.get_genre_stations(force_refresh=True) elif uri == self.genre_directory: self.backend.api.get_genre_stations(force_refresh=True) + else: + pandora_uri = PandoraUri.factory(uri) + if type(pandora_uri) is StationUri: + self._station = self.backend.api.get_station(pandora_uri.station_id) + self._station_iter = iterate_forever(self._station.get_playlist) From 7282d772cf396f5d8220ebeb7b305b7a5db8d918 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 2 Jan 2016 22:58:49 +0200 Subject: [PATCH 169/311] Add ad_token Field to AdItem. --- mopidy_pandora/uri.py | 7 ++++--- tests/conftest.py | 1 + tests/test_frontend.py | 6 +++--- tests/test_library.py | 4 ++-- tests/test_uri.py | 16 ++++++++++++++-- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 2d6e7b7..68deeaa 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -85,7 +85,7 @@ 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) + return AdItemUri(track.station_id, track.ad_token) else: raise NotImplementedError("Unsupported playlist item type '{}'".format(track)) @@ -151,12 +151,13 @@ def __repr__(self): class AdItemUri(TrackUri): uri_type = 'ad' - def __init__(self, station_id): + 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}'.format( + return '{}:{station_id}:{ad_token}'.format( super(AdItemUri, self).__repr__(), **self.encoded_attributes ) diff --git a/tests/conftest.py b/tests/conftest.py index 47e440e..c20baa4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -253,6 +253,7 @@ def ad_item_mock(): ad_item = AdItem.from_json(get_backend( config()).api, ad_metadata_result_mock()['result']) ad_item.station_id = MOCK_STATION_ID + ad_item.ad_token = MOCK_TRACK_AD_TOKEN return ad_item diff --git a/tests/test_frontend.py b/tests/test_frontend.py index e50b798..4b432b0 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -50,14 +50,14 @@ def setUp(self): self.tracks = [ Track(uri='pandora:track:mock_id1:mock_token1', length=40000), # Regular track - Track(uri='pandora:ad:mock_id2', length=40000), # Advertisement - Track(uri='mock:track:mock_id3:mock_token3', length=40000), # Not a pandora track + Track(uri='pandora:ad:mock_id2:mock_token2', length=40000), # Advertisement + Track(uri='mock:track:mock_id3:mock_token3', length=40000), # Not a pandora track Track(uri='pandora:track:mock_id4:mock_token4', length=40000), Track(uri='pandora:track:mock_id5:mock_token5', length=None), # No duration ] self.uris = [ - 'pandora:track:mock_id1:mock_token1', 'pandora:ad:mock_id2', + 'pandora:track:mock_id1:mock_token1', 'pandora:ad:mock_id2:mock_token2', 'mock:track:mock_id3:mock_token3', 'pandora:track:mock_id4:mock_token4', 'pandora:track:mock_id5:mock_token5'] diff --git a/tests/test_library.py b/tests/test_library.py index 6a91cd9..484095a 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -22,7 +22,7 @@ def test_get_images_for_ad_without_images(config, ad_item_mock): backend = conftest.get_backend(config) - ad_uri = PandoraUri.factory('pandora:ad:' + conftest.MOCK_TRACK_AD_TOKEN) + ad_uri = PandoraUri.factory('pandora:ad:{}:{}'.format(conftest.MOCK_STATION_ID, conftest.MOCK_TRACK_AD_TOKEN)) ad_item_mock.image_url = None backend.library._pandora_track_cache[ad_uri.uri] = ad_item_mock results = backend.library.get_images([ad_uri.uri]) @@ -32,7 +32,7 @@ def test_get_images_for_ad_without_images(config, ad_item_mock): def test_get_images_for_ad_with_images(config, ad_item_mock): backend = conftest.get_backend(config) - ad_uri = PandoraUri.factory('pandora:ad:' + conftest.MOCK_TRACK_AD_TOKEN) + ad_uri = PandoraUri.factory('pandora:ad:{}:{}'.format(conftest.MOCK_STATION_ID, conftest.MOCK_TRACK_AD_TOKEN)) backend.library._pandora_track_cache[ad_uri.uri] = ad_item_mock results = backend.library.get_images([ad_uri.uri]) assert len(results[ad_uri.uri]) == 1 diff --git a/tests/test_uri.py b/tests/test_uri.py index 2e34850..b6b82d8 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -17,6 +17,18 @@ def test_factory_unsupported_type(): PandoraUri.factory(0) +def test_ad_uri_parse(): + mock_uri = 'pandora:ad:id_mock:ad_token_mock' + obj = PandoraUri._from_uri(mock_uri) + + assert type(obj) is AdItemUri + + assert obj.uri_type == 'ad' + assert obj.station_id == 'id_mock' + assert obj.ad_token == 'ad_token_mock' + + assert obj.uri == mock_uri + def test_factory_returns_correct_station_uri_types(): station_mock = mock.PropertyMock(spec=GenreStation) @@ -186,9 +198,9 @@ def test_track_uri_from_track_unsupported_type(playlist_result_mock): def test_track_uri_from_track_for_ads(ad_item_mock): track_uri = TrackUri._from_track(ad_item_mock) - assert track_uri.uri == '{}:{}:{}'.format(PandoraUri.SCHEME, + assert track_uri.uri == '{}:{}:{}:{}'.format(PandoraUri.SCHEME, track_uri.encode(conftest.MOCK_AD_TYPE), - conftest.MOCK_STATION_ID) + conftest.MOCK_STATION_ID, conftest.MOCK_TRACK_AD_TOKEN) def test_track_uri_parse(playlist_item_mock): From db51472768875bb6eec923d592855a39f32f0692 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 2 Jan 2016 23:00:04 +0200 Subject: [PATCH 170/311] Fix pep8 violations. --- tests/test_uri.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_uri.py b/tests/test_uri.py index b6b82d8..fbef53a 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -17,6 +17,7 @@ def test_factory_unsupported_type(): PandoraUri.factory(0) + def test_ad_uri_parse(): mock_uri = 'pandora:ad:id_mock:ad_token_mock' obj = PandoraUri._from_uri(mock_uri) @@ -199,8 +200,8 @@ def test_track_uri_from_track_for_ads(ad_item_mock): track_uri = TrackUri._from_track(ad_item_mock) assert track_uri.uri == '{}:{}:{}:{}'.format(PandoraUri.SCHEME, - track_uri.encode(conftest.MOCK_AD_TYPE), - conftest.MOCK_STATION_ID, conftest.MOCK_TRACK_AD_TOKEN) + track_uri.encode(conftest.MOCK_AD_TYPE), + conftest.MOCK_STATION_ID, conftest.MOCK_TRACK_AD_TOKEN) def test_track_uri_parse(playlist_item_mock): From 0fcda88c5f6bee2f31188d4abec3a05cf5f64b7b Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 3 Jan 2016 12:14:20 +0200 Subject: [PATCH 171/311] Refactor eventing to be handled in frontend. Correct usage of station.station_id and station.token. --- mopidy_pandora/backend.py | 24 ++--- mopidy_pandora/client.py | 8 +- mopidy_pandora/frontend.py | 147 +++++++++++++---------------- mopidy_pandora/library.py | 4 +- mopidy_pandora/listener.py | 7 +- mopidy_pandora/playback.py | 62 +----------- mopidy_pandora/uri.py | 6 +- tests/conftest.py | 8 +- tests/test_backend.py | 21 ----- tests/test_client.py | 4 +- tests/test_frontend.py | 74 +++++++-------- tests/test_playback.py | 189 ++++++++++++------------------------- tests/test_uri.py | 24 ++--- tests/test_utils.py | 28 +++--- 14 files changed, 217 insertions(+), 389 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index ec81226..efaa3bd 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -12,7 +12,7 @@ from mopidy_pandora.client import MopidyAPIClient, MopidySettingsDictBuilder from mopidy_pandora.library import PandoraLibraryProvider -from mopidy_pandora.playback import EventHandlingPlaybackProvider, PandoraPlaybackProvider +from mopidy_pandora.playback import PandoraPlaybackProvider from mopidy_pandora.uri import PandoraUri # noqa: I101 @@ -39,14 +39,7 @@ def __init__(self, config, audio): self.api = MopidySettingsDictBuilder(settings, client_class=MopidyAPIClient).build() self.library = PandoraLibraryProvider(backend=self, sort_order=self.config.get('sort_order')) - - self.supports_events = False - if self.config.get('event_support_enabled'): - self.supports_events = True - self.playback = EventHandlingPlaybackProvider(audio, self) - else: - self.playback = PandoraPlaybackProvider(audio, self) - + self.playback = PandoraPlaybackProvider(audio, self) self.uri_schemes = [PandoraUri.SCHEME] @utils.run_async @@ -100,15 +93,18 @@ 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) + # 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 PandoraUri.factory(track_uri).station_id == self.library._station.token + + r = self.api.delete_station(self.library._station.token) 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(listener.PandoraBackendListener.next_track_available.__name__, - track=track, auto_play=auto_play)) + (listener.PandoraBackendListener.send('next_track_available', track=track, auto_play=auto_play)) def _trigger_event_processed(self, track_uri, pandora_event): - listener.PandoraBackendListener.send(listener.PandoraBackendListener.event_processed.__name__, - track_uri=track_uri, pandora_event=pandora_event) + listener.PandoraBackendListener.send('event_processed', track_uri=track_uri, pandora_event=pandora_event) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 61b7ad7..1cb0eaa 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -66,13 +66,13 @@ def get_station_list(self, force_refresh=False): # Cache disabled return list - def get_station(self, station_id): + def get_station(self, station_token): try: - return self.get_station_list()[station_id] + return self.get_station_list()[station_token] except TypeError: - # Could not find station_id in cached list, try retrieving from Pandora server. - return super(MopidyAPIClient, self).get_station(station_id) + # 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): diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 4a9d6e2..1fbd2df 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -1,5 +1,6 @@ import logging -import threading + +import time from mopidy import core from mopidy.audio import PlaybackState @@ -140,8 +141,7 @@ def add_track(self, track, auto_play=False): self.core.playback.play(tl_tracks[-1]).get() def _trigger_end_of_tracklist_reached(self, auto_play=False): - listener.PandoraFrontendListener.send( - listener.PandoraFrontendListener.end_of_tracklist_reached.__name__, auto_play=auto_play) + listener.PandoraFrontendListener.send('end_of_tracklist_reached', auto_play=auto_play) class EventHandlingPandoraFrontend(PandoraFrontend, listener.PandoraEventHandlingPlaybackListener): @@ -150,108 +150,89 @@ def __init__(self, config, core): super(EventHandlingPandoraFrontend, self).__init__(config, core) self.settings = { - 'OPR_EVENT': config['pandora'].get('on_pause_resume_click'), - 'OPN_EVENT': config['pandora'].get('on_pause_next_click'), - 'OPP_EVENT': config['pandora'].get('on_pause_previous_click'), - 'OPS_EVENT': config['pandora'].get('on_pause_stop_click') + 'resume': config['pandora'].get('on_pause_resume_click'), + 'change_track_next': config['pandora'].get('on_pause_next_click'), + 'change_track_previous': config['pandora'].get('on_pause_previous_click'), + 'stop': config['pandora'].get('on_pause_stop_click') } - self.last_played_track_uri = None - self.upcoming_track_uri = None - - self.event_processed_event = threading.Event() - self.event_processed_event.set() + self.double_click_interval = float(config['pandora'].get('double_click_interval')) + self._click_time = 0 - self.tracklist_changed_event = threading.Event() - self.tracklist_changed_event.set() + @only_execute_for_pandora_uris + def track_playback_paused(self, tl_track, time_position): + super(EventHandlingPandoraFrontend, self).track_playback_paused(tl_track, time_position) + if time_position > 0: + self.set_click_time() @only_execute_for_pandora_uris - def tracklist_changed(self): + def track_playback_resumed(self, tl_track, time_position): + super(EventHandlingPandoraFrontend, self).track_playback_resumed(tl_track, time_position) + self.check_doubleclicked(action='resume') - if self.event_processed_event.isSet(): - # Keep track of current and next tracks so that we can determine direction of future track changes. - current_tl_track = self.core.playback.get_current_tl_track().get() - self.last_played_track_uri = current_tl_track.track.uri - self.upcoming_track_uri = self.core.tracklist.next_track(current_tl_track).get().track.uri + def track_changed(self, track): + super(EventHandlingPandoraFrontend, self).track_changed(track) + self.check_doubleclicked(action='change_track') - self.tracklist_changed_event.set() + def set_click_time(self, click_time=None): + if click_time is None: + self._click_time = time.time() else: - # Delay 'tracklist_changed' events until all events have been processed. - self.tracklist_changed_event.clear() + self._click_time = click_time - @only_execute_for_pandora_uris - def track_playback_ended(self, tl_track, time_position): - super(EventHandlingPandoraFrontend, self).track_playback_ended(tl_track, time_position) + def get_click_time(self): + return self._click_time - self._process_events(tl_track.track.uri, time_position, action=self.track_playback_ended.__name__) + def check_doubleclicked(self, action=None): + if self._is_double_click(): + self._process_event(action=action) - @only_execute_for_pandora_uris - def track_playback_resumed(self, tl_track, time_position): - super(EventHandlingPandoraFrontend, self).track_playback_resumed(tl_track, time_position) - - self._process_events(tl_track.track.uri, time_position, action=self.track_playback_resumed.__name__) + def event_processed(self, track_uri, pandora_event): + if pandora_event == 'delete_station': + self.core.tracklist.clear() - def _process_events(self, track_uri, time_position, action=None): + def _is_double_click(self): + double_clicked = self._click_time > 0 and time.time() - self._click_time < self.double_click_interval + self.set_click_time(0) - # Check if there are any events that still require processing. - if self.event_processed_event.isSet(): - # No events to process. - return + return double_clicked - event_target_uri = self._get_event_target_uri(track_uri, time_position) - assert event_target_uri + def _process_event(self, action=None): + try: + event_target_uri, event_target_action = self._get_event_targets(action=action) - if type(PandoraUri.factory(event_target_uri)) is AdItemUri: - logger.info('Ignoring doubleclick event for Pandora advertisement...') - self.event_processed_event.set() - return + if type(PandoraUri.factory(event_target_uri)) is AdItemUri: + logger.info('Ignoring doubleclick event for Pandora advertisement...') + return - try: - self._trigger_event_triggered(event_target_uri, self._get_event(track_uri, time_position, action=action)) + self._trigger_event_triggered(event_target_uri, event_target_action) + # Resume playback... + if action in ['stop', 'change_track'] and self.core.playback.get_state().get() != PlaybackState.PLAYING: + self.core.playback.resume().get() except ValueError: - logger.exception("Error processing Pandora event for URI '{}'. Ignoring event...".format(event_target_uri)) - self.event_processed_event.set() + logger.exception("Error processing Pandora event '{}', ignoring...".format(action)) return - def _get_event_target_uri(self, track_uri, time_position): - if time_position == 0: - # Track was just changed, trigger the event for the previously played track. - history = self.core.history.get_history().get() - return history[1][1].uri - else: - # Trigger the event for the track that is playing currently. - return track_uri - - def _get_event(self, track_uri, time_position, action=None): - if action == self.track_playback_resumed.__name__ and track_uri == self.last_played_track_uri: - if time_position > 0: - # Resuming playback on the first track in the tracklist. - return self.settings['OPR_EVENT'] + def _get_event_targets(self, action=None): + current_track_uri = self.core.playback.get_current_tl_track().get().track.uri + + if action == 'change_track': + previous_track_uri = self.core.history.get_history().get()[1][1].uri + if current_track_uri == previous_track_uri: + # Replaying last played track, user clicked 'previous'. + action = self.settings['change_track_previous'] else: - return self.settings['OPP_EVENT'] + # Track not in recent tracklist history, user clicked 'next'. + action = self.settings['change_track_next'] - elif action == self.track_playback_resumed.__name__ and track_uri == self.upcoming_track_uri: - return self.settings['OPN_EVENT'] - elif action == self.track_playback_ended.__name__: - return self.settings['OPS_EVENT'] - else: - raise ValueError('Unexpected event URI: {}'.format(track_uri)) + return previous_track_uri, action - def event_processed(self, track_uri, pandora_event): - self.event_processed_event.set() - if pandora_event == 'delete_station': - self.core.tracklist.clear() - else: - if not self.tracklist_changed_event.isSet(): - # Do any 'tracklist_changed' updates that are pending. - self.tracklist_changed() + elif action in ['resume', 'stop']: + return current_track_uri, self.settings[action] - def doubleclicked(self): - self.event_processed_event.clear() - # Resume playback... - if self.core.playback.get_state().get() != PlaybackState.PLAYING: - self.core.playback.resume().get() + raise ValueError('Unexpected event: {}'.format(action)) def _trigger_event_triggered(self, track_uri, event): - (listener.PandoraFrontendListener.send(listener.PandoraEventHandlingFrontendListener.event_triggered.__name__, - track_uri=track_uri, pandora_event=event)) + (listener.PandoraEventHandlingFrontendListener.send('event_triggered', + track_uri=track_uri, + pandora_event=event)) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index e6f9e4e..3c8f795 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -156,7 +156,7 @@ def _browse_tracks(self, uri): if type(pandora_uri) is GenreStationUri: pandora_uri = self._create_station_for_genre(pandora_uri.token) - self._station = self.backend.api.get_station(pandora_uri.station_id) + self._station = self.backend.api.get_station(pandora_uri.token) self._station_iter = iterate_forever(self._station.get_playlist) return [self.get_next_pandora_track()] @@ -218,5 +218,5 @@ def refresh(self, uri=None): else: pandora_uri = PandoraUri.factory(uri) if type(pandora_uri) is StationUri: - self._station = self.backend.api.get_station(pandora_uri.station_id) + self._station = self.backend.api.get_station(pandora_uri.station_token) self._station_iter = iterate_forever(self._station.get_playlist) diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 1457ace..7d69168 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -136,9 +136,12 @@ class PandoraEventHandlingPlaybackListener(listener.Listener): def send(event, **kwargs): listener.send_async(PandoraEventHandlingPlaybackListener, event, **kwargs) - def doubleclicked(self): + def check_doubleclicked(self, action=None): """ - Called when the user performed a doubleclick action (i.e. pause/back, pause/resume, pause, next). + Called to check if a doubleclick action was performed on one of the playback actions (i.e. pause/back, + pause/resume, pause, next). + :param action: The playback action that occurred. + :type action: string """ pass diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 7d3d6d3..7308d9e 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -1,5 +1,4 @@ import logging -import time from mopidy import backend @@ -81,68 +80,13 @@ def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url def _trigger_track_changed(self, track): - listener.PandoraPlaybackListener.send(listener.PandoraPlaybackListener.track_changed.__name__, track=track) + listener.PandoraPlaybackListener.send('track_changed', track=track) def _trigger_track_unplayable(self, track): - listener.PandoraPlaybackListener.send(listener.PandoraPlaybackListener.track_unplayable.__name__, track=track) + listener.PandoraPlaybackListener.send('track_unplayable', track=track) def _trigger_skip_limit_exceeded(self): - listener.PandoraPlaybackListener.send(listener.PandoraPlaybackListener.skip_limit_exceeded.__name__) - - -class EventHandlingPlaybackProvider(PandoraPlaybackProvider): - def __init__(self, audio, backend): - super(EventHandlingPlaybackProvider, self).__init__(audio, backend) - - self.double_click_interval = float(backend.config.get('double_click_interval')) - self._click_time = 0 - - def set_click_time(self, click_time=None): - if click_time is None: - self._click_time = time.time() - else: - self._click_time = click_time - - def get_click_time(self): - return self._click_time - - def is_double_click(self): - double_clicked = self._click_time > 0 and time.time() - self._click_time < self.double_click_interval - - if not double_clicked: - self.set_click_time(0) - - return double_clicked - - def change_track(self, track): - - if self.is_double_click(): - self._trigger_doubleclicked() - - return super(EventHandlingPlaybackProvider, self).change_track(track) - - def resume(self): - if self.is_double_click() and self.get_time_position() > 0: - self._trigger_doubleclicked() - - return super(EventHandlingPlaybackProvider, self).resume() - - def pause(self): - if self.get_time_position() > 0: - self.set_click_time() - - return super(EventHandlingPlaybackProvider, self).pause() - - def stop(self): - if self.is_double_click(): - self._trigger_doubleclicked() - - return super(EventHandlingPlaybackProvider, self).stop() - - def _trigger_doubleclicked(self): - self.set_click_time(0) - listener.PandoraEventHandlingPlaybackListener.send( - listener.PandoraEventHandlingPlaybackListener.doubleclicked.__name__) + listener.PandoraPlaybackListener.send('skip_limit_exceeded') class MaxSkipLimitExceeded(Exception): diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 68deeaa..bcf2cac 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -123,10 +123,10 @@ class GenreStationUri(StationUri): uri_type = 'genre_station' def __init__(self, station_id, token): - # Check that this really is a Genre station ass opposed to a regular station. - # Genre station IDs always start with 'G'. + # Check that this really is a Genre station as opposed to a regular station. + # Genre station IDs and tokens always start with 'G'. assert station_id.startswith('G') - assert station_id == token + assert token.startswith('G') super(GenreStationUri, self).__init__(station_id, token) diff --git a/tests/conftest.py b/tests/conftest.py index c20baa4..31cefe5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,8 +45,8 @@ def config(): 'port': '6680' }, 'proxy': { - 'hostname': 'mock_host', - 'port': 'mock_port' + 'hostname': 'host_mock', + 'port': 'port_mock' }, 'pandora': { 'enabled': True, @@ -195,8 +195,8 @@ def ad_metadata_result_mock(): mock_result = {'stat': 'ok', 'result': dict(title=MOCK_TRACK_NAME, companyName='Mock Company Name', - clickThroughUrl='mock_click_url', - imageUrl='mock_img_url', + clickThroughUrl='click_url_mock', + imageUrl='img_url_mock', trackGain='0.0', audioUrlMap={ 'highQuality': { diff --git a/tests/test_backend.py b/tests/test_backend.py index 9a328a7..7f69600 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -35,27 +35,6 @@ def test_init_sets_preferred_audio_quality(config): assert backend.api.default_audio_quality == BaseAPIClient.LOW_AUDIO_QUALITY -def test_playback_provider_selection_events_disabled(config): - config['pandora']['event_support_enabled'] = 'false' - backend = get_backend(config) - - assert isinstance(backend.playback, playback.PandoraPlaybackProvider) - - -def test_playback_provider_selection_events_default(config): - config['pandora']['event_support_enabled'] = '' - backend = get_backend(config) - - assert isinstance(backend.playback, playback.PandoraPlaybackProvider) - - -def test_playback_provider_selection_events_enabled(config): - config['pandora']['event_support_enabled'] = 'true' - backend = get_backend(config) - - assert isinstance(backend.playback, playback.EventHandlingPlaybackProvider) - - def test_on_start_logs_in(config): backend = get_backend(config) diff --git a/tests/test_client.py b/tests/test_client.py index af6208e..47c5523 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -108,10 +108,10 @@ def test_get_station(config): backend.api.get_station_list() assert backend.api.get_station( - conftest.MOCK_STATION_ID).name == conftest.MOCK_STATION_NAME + ' 1' + conftest.MOCK_STATION_TOKEN).name == conftest.MOCK_STATION_NAME + ' 1' assert backend.api.get_station( - conftest.MOCK_STATION_ID.replace('1', '2')).name == conftest.MOCK_STATION_NAME + ' 2' + conftest.MOCK_STATION_TOKEN.replace('1', '2')).name == conftest.MOCK_STATION_NAME + ' 2' def test_get_invalid_station(config): diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 4b432b0..e8613c3 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, unicode_literals +import time + import unittest from mock import mock @@ -131,58 +133,54 @@ def test_process_events_ignores_ads(self): frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() frontend._trigger_event_triggered = mock.PropertyMock() - frontend.event_processed_event.get().clear() - frontend.track_playback_resumed(self.tl_tracks[1], 100).get() + frontend.check_doubleclicked(action='resume').get() - assert frontend.event_processed_event.get().isSet() assert not frontend._trigger_event_triggered.called - def test_process_events_handles_exception(self): + def test_pause_starts_double_click_timer(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - frontend._trigger_event_triggered = mock.PropertyMock() - frontend._get_event = mock.PropertyMock(side_effect=ValueError('mock_error')) - frontend.event_processed_event.get().clear() - frontend.track_playback_resumed(self.tl_tracks[0], 100).get() - - assert frontend.event_processed_event.get().isSet() - assert not frontend._trigger_event_triggered.called + assert frontend.get_click_time().get() == 0 + frontend.track_playback_paused(mock.Mock(), 100).get() + assert frontend.get_click_time().get() > 0 - def test_process_events_does_nothing_if_no_events_are_queued(self): + def test_pause_does_not_start_timer_at_track_start(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - frontend._trigger_event_triggered = mock.PropertyMock() - frontend.event_processed_event.get().set() - frontend.track_playback_resumed(self.tl_tracks[0], 100).get() + assert frontend.get_click_time().get() == 0 + frontend.track_playback_paused(mock.Mock(), 0).get() + assert frontend.get_click_time().get() == 0 + + def test_process_events_handles_exception(self): + with mock.patch.object(EventHandlingPandoraFrontend, '_get_event_targets', + mock.PropertyMock(return_value=None, side_effect=ValueError('error_mock'))): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + frontend._trigger_event_triggered = mock.PropertyMock() + frontend.check_doubleclicked(action='resume').get() - assert frontend.event_processed_event.get().isSet() assert not frontend._trigger_event_triggered.called - def test_tracklist_changed_blocks_if_events_are_queued(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + def test_is_double_click(self): - frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - frontend.event_processed_event.get().clear() - frontend.last_played_track_uri = 'mock_uri' - frontend.upcoming_track_uri = 'mock_ury' + frontend = EventHandlingPandoraFrontend(conftest.config(), self.core) + frontend.set_click_time() + assert frontend._is_double_click() + assert frontend.get_click_time() == 0 - frontend.tracklist_changed().get() - assert not frontend.tracklist_changed_event.get().isSet() - assert frontend.last_played_track_uri.get() == 'mock_uri' - assert frontend.upcoming_track_uri.get() == 'mock_ury' + time.sleep(float(frontend.double_click_interval) + 0.1) + assert frontend._is_double_click() is False - def test_tracklist_changed_updates_uris_after_event_is_processed(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + def test_is_double_click_resets_click_time(self): - frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - frontend.event_processed_event.get().set() - frontend.last_played_track_uri = 'mock_uri' - frontend.upcoming_track_uri = 'mock_ury' - - frontend.tracklist_changed().get() - assert frontend.tracklist_changed_event.get().isSet() - current_track_uri = self.core.playback.get_current_tl_track().get() - assert frontend.last_played_track_uri.get() == current_track_uri.track.uri - assert frontend.upcoming_track_uri.get() == self.core.tracklist.next_track(current_track_uri).get().track.uri + frontend = EventHandlingPandoraFrontend(conftest.config(), self.core) + frontend.set_click_time() + assert frontend._is_double_click() + + time.sleep(float(frontend.double_click_interval) + 0.1) + assert frontend._is_double_click() is False + + assert frontend.get_click_time() == 0 diff --git a/tests/test_playback.py b/tests/test_playback.py index 8d91a4f..85b6de7 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -1,12 +1,10 @@ from __future__ import unicode_literals -import time - import conftest import mock -from mopidy import audio, backend as backend_api, models +from mopidy import audio, models from pandora import APITransport @@ -17,7 +15,7 @@ from mopidy_pandora.backend import MopidyAPIClient from mopidy_pandora.library import PandoraLibraryProvider -from mopidy_pandora.playback import EventHandlingPlaybackProvider, PandoraPlaybackProvider +from mopidy_pandora.playback import PandoraPlaybackProvider from mopidy_pandora.uri import PandoraUri @@ -30,21 +28,7 @@ def audio_mock(): @pytest.fixture def provider(audio_mock, config): - - provider = None - - if config['pandora']['event_support_enabled']: - provider = playback.EventHandlingPlaybackProvider( - audio=audio_mock, backend=conftest.get_backend(config)) - - provider.current_tl_track = {'track': {'uri': 'test'}} - - provider._sync_tracklist = mock.PropertyMock() - else: - provider = playback.PandoraPlaybackProvider( - audio=audio_mock, backend=conftest.get_backend(config)) - - return provider + return playback.PandoraPlaybackProvider(audio=audio_mock, backend=conftest.get_backend(config)) @pytest.fixture(scope='session') @@ -53,66 +37,59 @@ def client_mock(): return client_mock -def test_is_a_playback_provider(provider): - assert isinstance(provider, backend_api.PlaybackProvider) - - -def test_pause_starts_double_click_timer(provider): - with mock.patch.object(PandoraPlaybackProvider, 'get_time_position', return_value=100): - assert provider.backend.supports_events - assert provider.get_click_time() == 0 - provider.pause() - assert provider.get_click_time() > 0 +def test_change_track_enforces_skip_limit_if_no_track_available(provider, playlist_item_mock, caplog): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=None): + track = PandoraUri.factory(playlist_item_mock) + provider.previous_tl_track = {'track': {'uri': 'previous_track'}} + provider.next_tl_track = {'track': {'uri': track.uri}} -def test_pause_does_not_start_timer_at_track_start(provider): - with mock.patch.object(PandoraPlaybackProvider, 'get_time_position', return_value=0): - assert provider.backend.supports_events - assert provider.get_click_time() == 0 - provider.pause() - assert provider.get_click_time() == 0 + provider._trigger_track_unplayable = mock.PropertyMock() + provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) + for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): + assert provider.change_track(track) is False + if i < PandoraPlaybackProvider.SKIP_LIMIT-1: + assert provider._trigger_track_unplayable.called + provider._trigger_track_unplayable.reset_mock() + assert not provider._trigger_skip_limit_exceeded.called + else: + assert not provider._trigger_track_unplayable.called + assert provider._trigger_skip_limit_exceeded.called -def test_resume_checks_for_double_click(provider): - with mock.patch.object(PandoraPlaybackProvider, 'get_time_position', return_value=100): - assert provider.backend.supports_events - is_double_click_mock = mock.PropertyMock() - process_click_mock = mock.PropertyMock() - provider.is_double_click = is_double_click_mock - provider.process_click = process_click_mock - provider.resume() + assert 'Maximum track skip limit ({:d}) exceeded.'.format( + PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() - provider.is_double_click.assert_called_once_with() +def test_change_track_enforces_skip_limit_if_no_audio_url(provider, playlist_item_mock, caplog): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): + track = PandoraUri.factory(playlist_item_mock) -def test_change_track_enforces_skip_limit_if_no_track_available(provider, playlist_item_mock, caplog): - with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): - with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=None): - track = PandoraUri.factory(playlist_item_mock) + provider.previous_tl_track = {'track': {'uri': 'previous_track'}} + provider.next_tl_track = {'track': {'uri': track.uri}} - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} - provider.next_tl_track = {'track': {'uri': track.uri}} + provider._trigger_track_unplayable = mock.PropertyMock() + provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) - provider._trigger_track_unplayable = mock.PropertyMock() - provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) + playlist_item_mock.audio_url = None - for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): - assert provider.change_track(track) is False - if i < PandoraPlaybackProvider.SKIP_LIMIT-1: - assert provider._trigger_track_unplayable.called - provider._trigger_track_unplayable.reset_mock() - assert not provider._trigger_skip_limit_exceeded.called - else: - assert not provider._trigger_track_unplayable.called - assert provider._trigger_skip_limit_exceeded.called + for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): + assert provider.change_track(track) is False + if i < PandoraPlaybackProvider.SKIP_LIMIT-1: + assert provider._trigger_track_unplayable.called + provider._trigger_track_unplayable.reset_mock() + assert not provider._trigger_skip_limit_exceeded.called + else: + assert not provider._trigger_track_unplayable.called + assert provider._trigger_skip_limit_exceeded.called - assert 'Maximum track skip limit ({:d}) exceeded.'.format( - PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() + assert 'Maximum track skip limit ({:d}) exceeded.'.format( + PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() -def test_change_track_enforces_skip_limit_if_no_audio_url(provider, playlist_item_mock, caplog): - with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): - with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): +def test_change_track_enforces_skip_limit_on_request_exceptions(provider, playlist_item_mock, caplog): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): + with mock.patch.object(APITransport, '__call__', side_effect=conftest.request_exception_mock): track = PandoraUri.factory(playlist_item_mock) provider.previous_tl_track = {'track': {'uri': 'previous_track'}} @@ -120,8 +97,7 @@ def test_change_track_enforces_skip_limit_if_no_audio_url(provider, playlist_ite provider._trigger_track_unplayable = mock.PropertyMock() provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) - - playlist_item_mock.audio_url = None + playlist_item_mock.audio_url = 'pandora:track:mock_id:mock_token' for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): assert provider.change_track(track) is False @@ -137,47 +113,19 @@ def test_change_track_enforces_skip_limit_if_no_audio_url(provider, playlist_ite PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() -def test_change_track_enforces_skip_limit_on_request_exceptions(provider, playlist_item_mock, caplog): - with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): - with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): - with mock.patch.object(APITransport, '__call__', side_effect=conftest.request_exception_mock): - track = PandoraUri.factory(playlist_item_mock) - - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} - provider.next_tl_track = {'track': {'uri': track.uri}} - - provider._trigger_track_unplayable = mock.PropertyMock() - provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) - playlist_item_mock.audio_url = 'pandora:track:mock_id:mock_token' - - for i in range(PandoraPlaybackProvider.SKIP_LIMIT+1): - assert provider.change_track(track) is False - if i < PandoraPlaybackProvider.SKIP_LIMIT-1: - assert provider._trigger_track_unplayable.called - provider._trigger_track_unplayable.reset_mock() - assert not provider._trigger_skip_limit_exceeded.called - else: - assert not provider._trigger_track_unplayable.called - assert provider._trigger_skip_limit_exceeded.called - - assert 'Maximum track skip limit ({:d}) exceeded.'.format( - PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() - - def test_change_track_fetches_next_track_if_unplayable(provider, playlist_item_mock, caplog): - with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): - with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=None): - track = PandoraUri.factory(playlist_item_mock) + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=None): + track = PandoraUri.factory(playlist_item_mock) - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} - provider.next_tl_track = {'track': {'uri': track.uri}} + provider.previous_tl_track = {'track': {'uri': 'previous_track'}} + provider.next_tl_track = {'track': {'uri': track.uri}} - provider._trigger_track_unplayable = mock.PropertyMock() + provider._trigger_track_unplayable = mock.PropertyMock() - assert provider.change_track(track) is False - assert provider._trigger_track_unplayable.called + assert provider.change_track(track) is False + assert provider._trigger_track_unplayable.called - assert 'Cannot change to Pandora track' in caplog.text() + assert 'Cannot change to Pandora track' in caplog.text() def test_change_track_skips_if_no_track_uri(provider): @@ -189,16 +137,15 @@ def test_change_track_skips_if_no_track_uri(provider): def test_change_track_skips_if_track_not_available_in_buffer(provider, playlist_item_mock, caplog): - with mock.patch.object(EventHandlingPlaybackProvider, 'is_double_click', return_value=False): - track = PandoraUri.factory(playlist_item_mock) + track = PandoraUri.factory(playlist_item_mock) - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} - provider.next_tl_track = {'track': {'uri': track.uri}} + provider.previous_tl_track = {'track': {'uri': 'previous_track'}} + provider.next_tl_track = {'track': {'uri': track.uri}} - provider.backend.prepare_next_track = mock.PropertyMock() + provider.backend.prepare_next_track = mock.PropertyMock() - assert provider.change_track(track) is False - assert "Error changing Pandora track: failed to lookup '{}'.".format(track.uri) in caplog.text() + assert provider.change_track(track) is False + assert "Error changing Pandora track: failed to lookup '{}'.".format(track.uri) in caplog.text() def test_translate_uri_returns_audio_url(provider, playlist_item_mock): @@ -209,26 +156,6 @@ def test_translate_uri_returns_audio_url(provider, playlist_item_mock): assert provider.translate_uri(test_uri) == conftest.MOCK_TRACK_AUDIO_HIGH -def test_is_double_click(provider): - - provider.set_click_time() - assert provider.is_double_click() - - time.sleep(float(provider.double_click_interval) + 0.1) - assert provider.is_double_click() is False - - -def test_is_double_click_resets_click_time(provider): - - provider.set_click_time() - assert provider.is_double_click() - - time.sleep(float(provider.double_click_interval) + 0.1) - assert provider.is_double_click() is False - - assert provider.get_click_time() == 0 - - def test_resume_click_ignored_if_start_of_track(provider): with mock.patch.object(PandoraPlaybackProvider, 'get_time_position', return_value=0): diff --git a/tests/test_uri.py b/tests/test_uri.py index fbef53a..132a764 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -38,13 +38,13 @@ def test_factory_returns_correct_station_uri_types(): assert type(PandoraUri.factory(station_mock)) is GenreStationUri station_mock = mock.PropertyMock(spec=Station) - station_mock.id = 'mock_id' - station_mock.token = 'mock_token' + station_mock.id = 'id_mock' + station_mock.token = 'token_mock' assert type(PandoraUri.factory(station_mock)) is StationUri def test_pandora_parse_mock_uri(): - uri = 'pandora:station:mock_id:mock_token' + uri = 'pandora:station:id_mock:token_mock' obj = PandoraUri._from_uri(uri) assert isinstance(obj, PandoraUri) @@ -61,7 +61,7 @@ def test_pandora_parse_unicode_mock_uri(): def test_pandora_repr_converts_to_string(): - uri = 'pandora:station:mock_id:' + uri = 'pandora:station:id_mock:' obj = PandoraUri._from_uri(uri) obj.token = 0 @@ -125,13 +125,13 @@ def test_station_uri_parse_returns_correct_type(): def test_genre_uri_parse(): - mock_uri = 'pandora:genre:mock_category' + mock_uri = 'pandora:genre:category_mock' obj = PandoraUri._from_uri(mock_uri) assert type(obj) is GenreUri assert obj.uri_type == 'genre' - assert obj.category_name == 'mock_category' + assert obj.category_name == 'category_mock' assert obj.uri == mock_uri @@ -151,18 +151,18 @@ def test_genre_station_uri_parse(): def test_genre_station_uri_from_station_returns_correct_type(): genre_mock = mock.PropertyMock(spec=Station) - genre_mock.id = 'mock_id' - genre_mock.token = 'mock_token' + genre_mock.id = 'id_mock' + genre_mock.token = 'token_mock' obj = StationUri._from_station(genre_mock) assert type(obj) is StationUri assert obj.uri_type == 'station' - assert obj.station_id == 'mock_id' - assert obj.token == 'mock_token' + assert obj.station_id == 'id_mock' + assert obj.token == 'token_mock' - assert obj.uri == 'pandora:station:mock_id:mock_token' + assert obj.uri == 'pandora:station:id_mock:token_mock' def test_genre_station_uri_from_genre_station_returns_correct_type(): @@ -186,7 +186,7 @@ def test_track_uri_from_track(playlist_item_mock): assert track_uri.uri == '{}:{}:{}:{}'.format(PandoraUri.SCHEME, track_uri.encode(conftest.MOCK_TRACK_TYPE), - track_uri.encode(conftest.MOCK_STATION_ID), + track_uri.encode(conftest.MOCK_STATION_TOKEN), track_uri.encode(conftest.MOCK_TRACK_TOKEN)) diff --git a/tests/test_utils.py b/tests/test_utils.py index 5678d0d..a638a8c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -17,19 +17,19 @@ def test_format_proxy(): config = { 'proxy': { - 'hostname': 'mock_host', + 'hostname': 'host_mock', 'port': '8080' } } - assert utils.format_proxy(config['proxy']) == 'mock_host:8080' + assert utils.format_proxy(config['proxy']) == 'host_mock:8080' def test_format_proxy_no_hostname(): config = { 'proxy': { 'hostname': '', - 'port': 'mock_port' + 'port': 'port_mock' } } @@ -41,14 +41,14 @@ def test_format_proxy_no_hostname(): def test_format_proxy_no_port(): config = { 'proxy': { - 'hostname': 'mock_host', + 'hostname': 'host_mock', 'port': '' } } - assert utils.format_proxy(config['proxy']) == 'mock_host:80' + assert utils.format_proxy(config['proxy']) == 'host_mock:80' config['proxy'].pop('port') - assert utils.format_proxy(config['proxy']) == 'mock_host:80' + assert utils.format_proxy(config['proxy']) == 'host_mock:80' def test_rpc_client_uses_mopidy_defaults(): @@ -59,20 +59,20 @@ def test_rpc_client_uses_mopidy_defaults(): def test_do_rpc(): - utils.RPCClient.configure('mock_host', 'mock_port') - assert utils.RPCClient.hostname == 'mock_host' - assert utils.RPCClient.port == 'mock_port' + utils.RPCClient.configure('host_mock', 'port_mock') + assert utils.RPCClient.hostname == 'host_mock' + assert utils.RPCClient.port == 'port_mock' response_mock = mock.PropertyMock(spec=requests.Response) - response_mock.text = '{"result": "mock_result"}' + response_mock.text = '{"result": "result_mock"}' requests.request = mock.PropertyMock(return_value=response_mock) q = Queue.Queue() - utils.RPCClient._do_rpc('mock_method', - params={'mock_param_1': 'mock_value_1', 'mock_param_2': 'mock_value_2'}, + utils.RPCClient._do_rpc('method_mock', + params={'param_mock_1': 'value_mock_1', 'param_mock_2': 'value_mock_2'}, queue=q) - assert q.get() == 'mock_result' + assert q.get() == 'result_mock' def test_do_rpc_increments_id(): @@ -80,7 +80,7 @@ def test_do_rpc_increments_id(): json.loads = mock.PropertyMock() current_id = utils.RPCClient.id - t = utils.RPCClient._do_rpc('mock_method') + t = utils.RPCClient._do_rpc('method_mock') t.join() assert utils.RPCClient.id == current_id + 1 From aaf559a42666d275bce47ef971cacfbecb4d6881 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 3 Jan 2016 16:53:36 +0200 Subject: [PATCH 172/311] __future__ imports. --- mopidy_pandora/backend.py | 2 ++ mopidy_pandora/client.py | 2 ++ mopidy_pandora/frontend.py | 31 ++++++++++++++++++++----- mopidy_pandora/library.py | 2 ++ mopidy_pandora/listener.py | 2 +- mopidy_pandora/playback.py | 2 ++ mopidy_pandora/uri.py | 47 ++++++++++++++++++++++++++++++++------ mopidy_pandora/utils.py | 2 ++ tests/conftest.py | 2 +- tests/dummy_backend.py | 2 +- tests/test_backend.py | 2 +- tests/test_client.py | 4 ++-- tests/test_extension.py | 2 +- tests/test_frontend.py | 44 +++++++++++++---------------------- tests/test_library.py | 4 ++-- tests/test_playback.py | 4 ++-- tests/test_uri.py | 10 ++++---- tests/test_utils.py | 2 ++ 18 files changed, 109 insertions(+), 57 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index efaa3bd..44f230a 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + import logging from mopidy import backend, core diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 1cb0eaa..fc67f99 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + import logging import time diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 1fbd2df..de2baf5 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -1,4 +1,7 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + import logging +import threading import time @@ -9,7 +12,7 @@ from mopidy_pandora import listener from mopidy_pandora.uri import AdItemUri, PandoraUri - +from mopidy_pandora.utils import run_async logger = logging.getLogger(__name__) @@ -159,6 +162,20 @@ def __init__(self, config, core): self.double_click_interval = float(config['pandora'].get('double_click_interval')) self._click_time = 0 + self.track_changed_event = threading.Event() + self.track_changed_event.set() + + @only_execute_for_pandora_uris + def track_playback_ended(self, tl_track, time_position): + super(EventHandlingPandoraFrontend, self).track_playback_ended(tl_track, time_position) + self.check_doubleclicked(action='stop') + + @run_async + def _wait_for_track_change(self): + self.track_changed_event.clear() + if not self.track_changed_event.wait(timeout=self.double_click_interval): + self._process_event(action='stop') + @only_execute_for_pandora_uris def track_playback_paused(self, tl_track, time_position): super(EventHandlingPandoraFrontend, self).track_playback_paused(tl_track, time_position) @@ -171,6 +188,7 @@ def track_playback_resumed(self, tl_track, time_position): self.check_doubleclicked(action='resume') def track_changed(self, track): + self.track_changed_event.set() super(EventHandlingPandoraFrontend, self).track_changed(track) self.check_doubleclicked(action='change_track') @@ -185,20 +203,21 @@ def get_click_time(self): def check_doubleclicked(self, action=None): if self._is_double_click(): - self._process_event(action=action) + if action == 'stop': + self._wait_for_track_change() + else: + self._process_event(action=action) def event_processed(self, track_uri, pandora_event): if pandora_event == 'delete_station': self.core.tracklist.clear() def _is_double_click(self): - double_clicked = self._click_time > 0 and time.time() - self._click_time < self.double_click_interval - self.set_click_time(0) - - return double_clicked + return self._click_time > 0 and time.time() - self._click_time < self.double_click_interval def _process_event(self, action=None): try: + self.set_click_time(0) event_target_uri, event_target_action = self._get_event_targets(action=action) if type(PandoraUri.factory(event_target_uri)) is AdItemUri: diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 3c8f795..00fd158 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + import logging from collections import OrderedDict diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 7d69168..39e1f19 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals from mopidy import backend, listener diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 7308d9e..9ac2058 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + import logging from mopidy import backend diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index bcf2cac..a9e9636 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -1,5 +1,8 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + import logging import urllib +from mopidy import compat from pandora.models.pandora import AdItem, GenreStation, PlaylistItem, Station @@ -24,6 +27,9 @@ def __init__(self, uri_type=None): def __repr__(self): return '{}:{uri_type}'.format(self.SCHEME, **self.__dict__) + def __str__(self): + return '{}:{uri_type}'.format(self.SCHEME, **self.encoded_attributes) + @property def encoded_attributes(self): encoded_dict = {} @@ -34,24 +40,27 @@ def encoded_attributes(self): @property def uri(self): - return repr(self) + return str(self) @classmethod def encode(cls, value): if value is None: value = '' - - if not isinstance(value, basestring): - value = str(value) - return urllib.quote(value.encode('utf8')) + if isinstance(value, compat.text_type): + value = value.encode('utf-8') + value = urllib.quote(value) + return value @classmethod def decode(cls, value): - return urllib.unquote(value).decode('utf8') + try: + return urllib.unquote(compat.text_type(value)) + except UnicodeError: + return urllib.unquote(bytes(value).decode('utf-8')) @classmethod def factory(cls, obj): - if isinstance(obj, basestring): + if isinstance(obj, compat.text_type) or isinstance(obj, compat.string_types): return PandoraUri._from_uri(obj) elif isinstance(obj, Station) or isinstance(obj, GenreStation): return PandoraUri._from_station(obj) @@ -100,6 +109,12 @@ def __init__(self, category_name): def __repr__(self): return '{}:{category_name}'.format( super(GenreUri, self).__repr__(), + **self.__dict__ + ) + + def __str__(self): + return '{}:{category_name}'.format( + super(GenreUri, self).__str__(), **self.encoded_attributes ) @@ -115,6 +130,12 @@ def __init__(self, station_id, token): def __repr__(self): return '{}:{station_id}:{token}'.format( super(StationUri, self).__repr__(), + **self.__dict__ + ) + + def __str__(self): + return '{}:{station_id}:{token}'.format( + super(StationUri, self).__str__(), **self.encoded_attributes ) @@ -144,6 +165,12 @@ def __init__(self, station_id, token): def __repr__(self): return '{}:{station_id}:{token}'.format( super(PlaylistItemUri, self).__repr__(), + **self.__dict__ + ) + + def __str__(self): + return '{}:{station_id}:{token}'.format( + super(PlaylistItemUri, self).__str__(), **self.encoded_attributes ) @@ -159,5 +186,11 @@ def __init__(self, station_id, ad_token): def __repr__(self): return '{}:{station_id}:{ad_token}'.format( super(AdItemUri, self).__repr__(), + **self.__dict__ + ) + + def __str__(self): + return '{}:{station_id}:{ad_token}'.format( + super(AdItemUri, self).__str__(), **self.encoded_attributes ) diff --git a/mopidy_pandora/utils.py b/mopidy_pandora/utils.py index df68339..98d3628 100644 --- a/mopidy_pandora/utils.py +++ b/mopidy_pandora/utils.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + import json import requests diff --git a/tests/conftest.py b/tests/conftest.py index 31cefe5..74995b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals import json diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index f108a04..b6887e8 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -4,7 +4,7 @@ used in tests of the frontends. """ -from __future__ import absolute_import, unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals from mopidy import backend from mopidy.models import Ref, SearchResult diff --git a/tests/test_backend.py b/tests/test_backend.py index 7f69600..92144e2 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals import mock diff --git a/tests/test_client.py b/tests/test_client.py index 47c5523..5ea5878 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,8 +1,8 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals import time -import conftest +from . import conftest import mock diff --git a/tests/test_extension.py b/tests/test_extension.py index 4c60663..aff40e8 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals import unittest diff --git a/tests/test_frontend.py b/tests/test_frontend.py index e8613c3..f5bb4f3 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals import time @@ -137,21 +137,21 @@ def test_process_events_ignores_ads(self): assert not frontend._trigger_event_triggered.called - def test_pause_starts_double_click_timer(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - - frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - assert frontend.get_click_time().get() == 0 - frontend.track_playback_paused(mock.Mock(), 100).get() - assert frontend.get_click_time().get() > 0 - - def test_pause_does_not_start_timer_at_track_start(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - - frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - assert frontend.get_click_time().get() == 0 - frontend.track_playback_paused(mock.Mock(), 0).get() - assert frontend.get_click_time().get() == 0 + # def test_pause_starts_double_click_timer(self): + # self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + # + # frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + # assert frontend.get_click_time().get() == 0 + # frontend.track_playback_paused(mock.Mock(), 100).get() + # assert frontend.get_click_time().get() > 0 + # + # def test_pause_does_not_start_timer_at_track_start(self): + # self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + # + # frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + # assert frontend.get_click_time().get() == 0 + # frontend.track_playback_paused(mock.Mock(), 0).get() + # assert frontend.get_click_time().get() == 0 def test_process_events_handles_exception(self): with mock.patch.object(EventHandlingPandoraFrontend, '_get_event_targets', @@ -166,21 +166,9 @@ def test_process_events_handles_exception(self): def test_is_double_click(self): - frontend = EventHandlingPandoraFrontend(conftest.config(), self.core) - frontend.set_click_time() - assert frontend._is_double_click() - assert frontend.get_click_time() == 0 - - time.sleep(float(frontend.double_click_interval) + 0.1) - assert frontend._is_double_click() is False - - def test_is_double_click_resets_click_time(self): - frontend = EventHandlingPandoraFrontend(conftest.config(), self.core) frontend.set_click_time() assert frontend._is_double_click() time.sleep(float(frontend.double_click_interval) + 0.1) assert frontend._is_double_click() is False - - assert frontend.get_click_time() == 0 diff --git a/tests/test_library.py b/tests/test_library.py index 484095a..beeea61 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1,8 +1,8 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals import time -import conftest +from . import conftest import mock diff --git a/tests/test_playback.py b/tests/test_playback.py index 85b6de7..0bb6bc1 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -1,6 +1,6 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals -import conftest +from . import conftest import mock diff --git a/tests/test_uri.py b/tests/test_uri.py index 132a764..6ef1a8c 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -1,7 +1,7 @@ # coding=utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals -import conftest +from . import conftest from mock import mock @@ -53,10 +53,10 @@ def test_pandora_parse_mock_uri(): def test_pandora_parse_unicode_mock_uri(): - uri = PlaylistItemUri(conftest.MOCK_STATION_ID, 'Ω≈ç√∫:˜µ≤≥÷') - obj = PandoraUri._from_uri(uri.uri) + uri = PlaylistItemUri(conftest.MOCK_STATION_ID, 'Ω≈ç√∫˜µ≤≥÷') + obj = PandoraUri._from_uri('pandora:track:{}:{}'.format(conftest.MOCK_STATION_ID, 'Ω≈ç√∫˜µ≤≥÷')) - assert isinstance(obj, PandoraUri) + assert type(obj) is PlaylistItemUri assert obj.uri == uri.uri diff --git a/tests/test_utils.py b/tests/test_utils.py index a638a8c..159128d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + import Queue import json From 60cfe3f405355f47e1816fdc5c75b983bc38ee2c Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 3 Jan 2016 17:04:40 +0200 Subject: [PATCH 173/311] Add Mopidy 1.1 test cases. --- tests/test_frontend.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index f5bb4f3..9355af3 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -137,21 +137,21 @@ def test_process_events_ignores_ads(self): assert not frontend._trigger_event_triggered.called - # def test_pause_starts_double_click_timer(self): - # self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - # - # frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - # assert frontend.get_click_time().get() == 0 - # frontend.track_playback_paused(mock.Mock(), 100).get() - # assert frontend.get_click_time().get() > 0 - # - # def test_pause_does_not_start_timer_at_track_start(self): - # self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - # - # frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - # assert frontend.get_click_time().get() == 0 - # frontend.track_playback_paused(mock.Mock(), 0).get() - # assert frontend.get_click_time().get() == 0 + def test_pause_starts_double_click_timer(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + assert frontend.get_click_time().get() == 0 + frontend.track_playback_paused(mock.Mock(), 100).get() + assert frontend.get_click_time().get() > 0 + + def test_pause_does_not_start_timer_at_track_start(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + assert frontend.get_click_time().get() == 0 + frontend.track_playback_paused(mock.Mock(), 0).get() + assert frontend.get_click_time().get() == 0 def test_process_events_handles_exception(self): with mock.patch.object(EventHandlingPandoraFrontend, '_get_event_targets', From d4215e09db109a423fad6bc8f8016fc9e2796d0d Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 3 Jan 2016 17:07:01 +0200 Subject: [PATCH 174/311] Fix pep8 violations. --- mopidy_pandora/uri.py | 1 + tests/test_client.py | 4 ++-- tests/test_library.py | 4 ++-- tests/test_playback.py | 4 ++-- tests/test_uri.py | 4 ++-- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index a9e9636..ce730f3 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -2,6 +2,7 @@ import logging import urllib + from mopidy import compat from pandora.models.pandora import AdItem, GenreStation, PlaylistItem, Station diff --git a/tests/test_client.py b/tests/test_client.py index 5ea5878..275b00a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,8 +2,6 @@ import time -from . import conftest - import mock from pandora import APIClient @@ -13,6 +11,8 @@ from mopidy_pandora.client import MopidyAPIClient +from . import conftest + def test_get_station_list(config): with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): diff --git a/tests/test_library.py b/tests/test_library.py index beeea61..75aed8c 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -2,8 +2,6 @@ import time -from . import conftest - import mock from mopidy import models @@ -18,6 +16,8 @@ from mopidy_pandora.uri import GenreUri, PandoraUri, PlaylistItemUri, StationUri +from . import conftest + def test_get_images_for_ad_without_images(config, ad_item_mock): backend = conftest.get_backend(config) diff --git a/tests/test_playback.py b/tests/test_playback.py index 0bb6bc1..9a82ac0 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -1,7 +1,5 @@ from __future__ import absolute_import, division, print_function, unicode_literals -from . import conftest - import mock from mopidy import audio, models @@ -19,6 +17,8 @@ from mopidy_pandora.uri import PandoraUri +from . import conftest + @pytest.fixture def audio_mock(): diff --git a/tests/test_uri.py b/tests/test_uri.py index 6ef1a8c..af85259 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -1,8 +1,6 @@ # coding=utf-8 from __future__ import absolute_import, division, print_function, unicode_literals -from . import conftest - from mock import mock from pandora.models.pandora import GenreStation, Station @@ -11,6 +9,8 @@ from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, PlaylistItemUri, StationUri, TrackUri +from . import conftest + def test_factory_unsupported_type(): with pytest.raises(NotImplementedError): From f4ce9cf31abba28d93f8917e875cbaf6e1615eec Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 3 Jan 2016 18:29:02 +0200 Subject: [PATCH 175/311] Tests and fixes for retrieving list of genre stations. --- mopidy_pandora/client.py | 6 +- tests/conftest.py | 2 +- tests/test_client.py | 119 ++++++++++++++++++++++++++++++++------- 3 files changed, 101 insertions(+), 26 deletions(-) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index fc67f99..1118960 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -49,7 +49,6 @@ def __init__(self, cache_ttl, transport, partner_user, partner_password, device, self._genre_stations_cache = TTLCache(1, cache_ttl) def get_station_list(self, force_refresh=False): - list = [] try: if (self._station_list_cache.currsize == 0 or @@ -69,7 +68,6 @@ def get_station_list(self, force_refresh=False): return list def get_station(self, station_token): - try: return self.get_station_list()[station_token] except TypeError: @@ -77,13 +75,13 @@ def get_station(self, station_token): return super(MopidyAPIClient, self).get_station(station_token) def get_genre_stations(self, force_refresh=False): - list = [] try: if (self._genre_stations_cache.currsize == 0 or (force_refresh and self._genre_stations_cache.itervalues().next().has_changed())): - self._genre_stations_cache[time.time()] = super(MopidyAPIClient, self).get_genre_stations() + list = super(MopidyAPIClient, self).get_genre_stations() + self._genre_stations_cache[time.time()] = list except requests.exceptions.RequestException: logger.exception('Error retrieving Pandora genre stations.') diff --git a/tests/conftest.py b/tests/conftest.py index 74995b1..330b68a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,7 +37,7 @@ MOCK_AD_TYPE = 'ad' -@pytest.fixture(scope='session') +@pytest.fixture() def config(): return { 'http': { diff --git a/tests/test_client.py b/tests/test_client.py index 275b00a..aa9b8e4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,7 +5,7 @@ import mock from pandora import APIClient -from pandora.models.pandora import StationList +from pandora.models.pandora import GenreStationList, StationList import pytest @@ -14,6 +14,74 @@ from . import conftest +def test_get_genre_stations(config): + with mock.patch.object(APIClient, 'get_genre_stations', conftest.get_genre_stations_mock): + backend = conftest.get_backend(config) + + genre_stations = backend.api.get_genre_stations() + + assert len(genre_stations) == len(conftest.genre_stations_result_mock()['categories']) + assert 'Category mock' in genre_stations.keys() + + +def test_get_genre_stations_handles_request_exception(config, caplog): + backend = conftest.get_backend(config, True) + + assert backend.api.get_genre_stations() == [] + + # Check that request exceptions are caught and logged + assert 'Error retrieving Pandora genre stations.' in caplog.text() + + +def test_get_genre_stations_populates_cache(config): + with mock.patch.object(APIClient, 'get_genre_stations', conftest.get_genre_stations_mock): + backend = conftest.get_backend(config) + + assert backend.api._genre_stations_cache.currsize == 0 + + backend.api.get_genre_stations() + assert backend.api._genre_stations_cache.currsize == 1 + + +def test_get_genre_stations_changed_cached(config): + with mock.patch.object(APIClient, 'get_genre_stations', conftest.get_genre_stations_mock): + # Ensure that the cache is re-used between calls + backend = conftest.get_backend(config) + + cached_checksum = 'zz00aa00aa00aa00aa00aa00aa00aa99' + mock_cached_result = {'stat': 'ok', + 'result': { + 'categories': [{ + 'stations': [{ + 'stationToken': 'G200', + 'stationName': 'Genre mock2', + 'stationId': 'G200' + }], + 'categoryName': 'Category mock2' + }], + }} + + station_list = GenreStationList.from_json(APIClient, mock_cached_result['result']) + station_list.checksum = cached_checksum + backend.api._genre_stations_cache[time.time()] = station_list + + assert backend.api.get_genre_stations().checksum == cached_checksum + assert len(backend.api._genre_stations_cache.itervalues().next()) == len(GenreStationList.from_json( + APIClient, mock_cached_result['result'])) + + +def test_get_genre_stations_cache_disabled(config): + with mock.patch.object(APIClient, 'get_genre_stations', conftest.get_genre_stations_mock): + cache_config = config + cache_config['pandora']['cache_time_to_live'] = 0 + backend = conftest.get_backend(cache_config) + + assert backend.api._genre_stations_cache.currsize == 0 + + assert len(backend.api.get_genre_stations()) == 1 + assert backend.api._genre_stations_cache.currsize == 0 + + def test_get_station_list(config): with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): backend = conftest.get_backend(config) @@ -39,27 +107,37 @@ def test_get_station_list_populates_cache(config): def test_get_station_list_changed_cached(config): with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): # Ensure that the cache is re-used between calls - with mock.patch.object(StationList, 'has_changed', return_value=True): - backend = conftest.get_backend(config) + backend = conftest.get_backend(config) - cached_checksum = 'zz00aa00aa00aa00aa00aa00aa00aa99' - mock_cached_result = {'stat': 'ok', - 'result': { - 'stations': [ - {'stationId': conftest.MOCK_STATION_ID, - 'stationToken': conftest.MOCK_STATION_TOKEN, - 'stationName': conftest.MOCK_STATION_NAME - }, ], - 'checksum': cached_checksum - }} + cached_checksum = 'zz00aa00aa00aa00aa00aa00aa00aa99' + mock_cached_result = {'stat': 'ok', + 'result': { + 'stations': [ + {'stationId': conftest.MOCK_STATION_ID, + 'stationToken': conftest.MOCK_STATION_TOKEN, + 'stationName': conftest.MOCK_STATION_NAME + }, ], + 'checksum': cached_checksum + }} - backend.api._station_list_cache[time.time()] = StationList.from_json( - APIClient, mock_cached_result['result']) + backend.api._station_list_cache[time.time()] = StationList.from_json( + APIClient, mock_cached_result['result']) - backend.api.get_station_list() - assert backend.api.get_station_list().checksum == cached_checksum - assert len(backend.api._station_list_cache.itervalues().next()) == len(StationList.from_json( - APIClient, mock_cached_result['result'])) + assert backend.api.get_station_list().checksum == cached_checksum + assert len(backend.api._station_list_cache.itervalues().next()) == len(StationList.from_json( + APIClient, mock_cached_result['result'])) + + +def test_get_station_list_cache_disabled(config): + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): + cache_config = config + cache_config['pandora']['cache_time_to_live'] = 0 + backend = conftest.get_backend(cache_config) + + assert backend.api._station_list_cache.currsize == 0 + + assert len(backend.api.get_station_list()) == 3 + assert backend.api._station_list_cache.currsize == 0 def test_get_station_list_changed_refreshed(config): @@ -84,8 +162,7 @@ def test_get_station_list_changed_refreshed(config): assert backend.api.get_station_list().checksum == cached_checksum - backend.api.get_station_list(force_refresh=True) - assert backend.api.get_station_list().checksum == conftest.MOCK_STATION_LIST_CHECKSUM + assert backend.api.get_station_list(force_refresh=True).checksum == conftest.MOCK_STATION_LIST_CHECKSUM assert (len(backend.api._station_list_cache.itervalues().next()) == len(conftest.station_list_result_mock()['stations'])) From 383d5d0ab0c7986d97d9942cccc3586bc661d842 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 3 Jan 2016 18:35:40 +0200 Subject: [PATCH 176/311] Disable eventing by default. --- README.rst | 2 +- mopidy_pandora/ext.conf | 2 +- tests/conftest.py | 2 +- tests/test_extension.py | 2 +- tests/test_frontend.py | 4 +++- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 82c5608..cf76da1 100644 --- a/README.rst +++ b/README.rst @@ -92,7 +92,7 @@ The following configuration values are available: 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 ``false`` will disable all event triggers entirely. +- ``pandora/event_support_enabled``: setting this to ``true`` will enable the event triggers. - ``pandora/double_click_interval``: successive button clicks that occur within this interval (in seconds) will trigger an event. Defaults to ``2.00`` seconds. - ``pandora/on_pause_resume_click``: click pause and then play while a song is playing to trigger the event. Defaults diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index c6ffd73..40d95d9 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -13,7 +13,7 @@ sort_order = date auto_setup = true cache_time_to_live = 1800 -event_support_enabled = true +event_support_enabled = false double_click_interval = 2.00 on_pause_resume_click = thumbs_up on_pause_next_click = thumbs_down diff --git a/tests/conftest.py b/tests/conftest.py index 330b68a..bf8a24f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,7 +63,7 @@ def config(): 'auto_setup': True, 'cache_time_to_live': 1800, - 'event_support_enabled': True, + 'event_support_enabled': False, 'double_click_interval': '0.1', 'on_pause_resume_click': 'thumbs_up', 'on_pause_next_click': 'thumbs_down', diff --git a/tests/test_extension.py b/tests/test_extension.py index aff40e8..0e5ead9 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -31,7 +31,7 @@ def test_get_default_config(self): self.assertIn('sort_order = date', config) self.assertIn('auto_setup = true', config) self.assertIn('cache_time_to_live = 1800', config) - self.assertIn('event_support_enabled = true', config) + self.assertIn('event_support_enabled = false', config) self.assertIn('double_click_interval = 2.00', config) self.assertIn('on_pause_resume_click = thumbs_up', config) self.assertIn('on_pause_next_click = thumbs_down', config) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 9355af3..60cfb3f 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -23,7 +23,9 @@ class TestPandoraFrontendFactory(unittest.TestCase): def test_events_supported_returns_event_handler_frontend(self): - frontend = PandoraFrontendFactory(conftest.config(), mock.PropertyMock()) + config = conftest.config() + config['pandora']['event_support_enabled'] = True + frontend = PandoraFrontendFactory(config, mock.PropertyMock()) assert type(frontend) is EventHandlingPandoraFrontend From a6e6c2f87e96249888318329fbee2de03271f6dd Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 3 Jan 2016 19:33:00 +0200 Subject: [PATCH 177/311] Rever to non-unicode processing of URIs. --- mopidy_pandora/uri.py | 48 ++++++++----------------------------------- tests/test_uri.py | 6 +++--- 2 files changed, 11 insertions(+), 43 deletions(-) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index ce730f3..c005462 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -1,10 +1,8 @@ -from __future__ import absolute_import, division, print_function, unicode_literals +from __future__ import absolute_import, division, print_function import logging import urllib -from mopidy import compat - from pandora.models.pandora import AdItem, GenreStation, PlaylistItem, Station logger = logging.getLogger(__name__) @@ -17,6 +15,7 @@ def __init__(cls, name, bases, clsdict): # noqa N805 cls.TYPES[cls.uri_type] = cls +# TODO: Make this class work with unicode_literals. class PandoraUri(object): __metaclass__ = _PandoraUriMeta TYPES = {} @@ -28,9 +27,6 @@ def __init__(self, uri_type=None): def __repr__(self): return '{}:{uri_type}'.format(self.SCHEME, **self.__dict__) - def __str__(self): - return '{}:{uri_type}'.format(self.SCHEME, **self.encoded_attributes) - @property def encoded_attributes(self): encoded_dict = {} @@ -41,27 +37,23 @@ def encoded_attributes(self): @property def uri(self): - return str(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') - value = urllib.quote(value) - return value + if not isinstance(value, basestring): + value = str(value) + return urllib.quote(value.encode('utf8')) @classmethod def decode(cls, value): - try: - return urllib.unquote(compat.text_type(value)) - except UnicodeError: - return urllib.unquote(bytes(value).decode('utf-8')) + return urllib.unquote(value).decode('utf8') @classmethod def factory(cls, obj): - if isinstance(obj, compat.text_type) or isinstance(obj, compat.string_types): + if isinstance(obj, basestring): return PandoraUri._from_uri(obj) elif isinstance(obj, Station) or isinstance(obj, GenreStation): return PandoraUri._from_station(obj) @@ -110,12 +102,6 @@ def __init__(self, category_name): def __repr__(self): return '{}:{category_name}'.format( super(GenreUri, self).__repr__(), - **self.__dict__ - ) - - def __str__(self): - return '{}:{category_name}'.format( - super(GenreUri, self).__str__(), **self.encoded_attributes ) @@ -131,12 +117,6 @@ def __init__(self, station_id, token): def __repr__(self): return '{}:{station_id}:{token}'.format( super(StationUri, self).__repr__(), - **self.__dict__ - ) - - def __str__(self): - return '{}:{station_id}:{token}'.format( - super(StationUri, self).__str__(), **self.encoded_attributes ) @@ -166,12 +146,6 @@ def __init__(self, station_id, token): def __repr__(self): return '{}:{station_id}:{token}'.format( super(PlaylistItemUri, self).__repr__(), - **self.__dict__ - ) - - def __str__(self): - return '{}:{station_id}:{token}'.format( - super(PlaylistItemUri, self).__str__(), **self.encoded_attributes ) @@ -187,11 +161,5 @@ def __init__(self, station_id, ad_token): def __repr__(self): return '{}:{station_id}:{ad_token}'.format( super(AdItemUri, self).__repr__(), - **self.__dict__ - ) - - def __str__(self): - return '{}:{station_id}:{ad_token}'.format( - super(AdItemUri, self).__str__(), **self.encoded_attributes ) diff --git a/tests/test_uri.py b/tests/test_uri.py index af85259..0ffca5a 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -53,10 +53,10 @@ def test_pandora_parse_mock_uri(): def test_pandora_parse_unicode_mock_uri(): - uri = PlaylistItemUri(conftest.MOCK_STATION_ID, 'Ω≈ç√∫˜µ≤≥÷') - obj = PandoraUri._from_uri('pandora:track:{}:{}'.format(conftest.MOCK_STATION_ID, 'Ω≈ç√∫˜µ≤≥÷')) + uri = PlaylistItemUri(conftest.MOCK_STATION_ID, 'Ω≈ç√∫:˜µ≤≥÷') + obj = PandoraUri._from_uri(uri.uri) - assert type(obj) is PlaylistItemUri + assert isinstance(obj, PandoraUri) assert obj.uri == uri.uri From 3a1eff6975f29ec3c9a65a6faf60a357a64566e3 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 4 Jan 2016 04:24:20 +0200 Subject: [PATCH 178/311] Refactor URI unicode handling. --- mopidy_pandora/uri.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index c005462..a479dcc 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -1,7 +1,8 @@ -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals import logging import urllib +from mopidy import compat from pandora.models.pandora import AdItem, GenreStation, PlaylistItem, Station @@ -31,7 +32,7 @@ def __repr__(self): def encoded_attributes(self): encoded_dict = {} for k, v in self.__dict__.items(): - encoded_dict[k] = PandoraUri.encode(v) + encoded_dict[k] = urllib.quote(PandoraUri.encode(v)) return encoded_dict @@ -43,13 +44,9 @@ def uri(self): def encode(cls, value): if value is None: value = '' - if not isinstance(value, basestring): - value = str(value) - return urllib.quote(value.encode('utf8')) - - @classmethod - def decode(cls, value): - return urllib.unquote(value).decode('utf8') + if isinstance(value, compat.text_type): + value = value.encode('utf-8') + return value @classmethod def factory(cls, obj): @@ -64,7 +61,7 @@ def factory(cls, obj): @classmethod def _from_uri(cls, uri): - parts = [cls.decode(p) for p in uri.split(':')] + parts = [urllib.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]) From 4a919da1c8832f64377b80a7c7ee8ade3972aca1 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 4 Jan 2016 04:25:03 +0200 Subject: [PATCH 179/311] Remove completed todo. --- mopidy_pandora/uri.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index a479dcc..3584a28 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -2,6 +2,7 @@ import logging import urllib + from mopidy import compat from pandora.models.pandora import AdItem, GenreStation, PlaylistItem, Station @@ -16,7 +17,6 @@ def __init__(cls, name, bases, clsdict): # noqa N805 cls.TYPES[cls.uri_type] = cls -# TODO: Make this class work with unicode_literals. class PandoraUri(object): __metaclass__ = _PandoraUriMeta TYPES = {} From 9656e1df452ee5d2435bb8b012065c3b546351f7 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 4 Jan 2016 08:25:38 +0200 Subject: [PATCH 180/311] Switch to using next() function for Python 3 compatibility. --- mopidy_pandora/client.py | 8 ++++---- mopidy_pandora/library.py | 2 +- tests/test_client.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 1118960..47b2390 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -52,7 +52,7 @@ def get_station_list(self, force_refresh=False): list = [] try: if (self._station_list_cache.currsize == 0 or - (force_refresh and self._station_list_cache.itervalues().next().has_changed())): + (force_refresh and next(self._station_list_cache.itervalues()).has_changed())): list = super(MopidyAPIClient, self).get_station_list() self._station_list_cache[time.time()] = list @@ -62,7 +62,7 @@ def get_station_list(self, force_refresh=False): return list try: - return self._station_list_cache.itervalues().next() + return next(self._station_list_cache.itervalues()) except StopIteration: # Cache disabled return list @@ -78,7 +78,7 @@ def get_genre_stations(self, force_refresh=False): list = [] try: if (self._genre_stations_cache.currsize == 0 or - (force_refresh and self._genre_stations_cache.itervalues().next().has_changed())): + (force_refresh and next(self._genre_stations_cache.itervalues()).has_changed())): list = super(MopidyAPIClient, self).get_genre_stations() self._genre_stations_cache[time.time()] = list @@ -88,7 +88,7 @@ def get_genre_stations(self, force_refresh=False): return list try: - return self._genre_stations_cache.itervalues().next() + return next(self._genre_stations_cache.itervalues()) except StopIteration: # Cache disabled return list diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 00fd158..bc050d6 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -185,7 +185,7 @@ def lookup_pandora_track(self, uri): def get_next_pandora_track(self): try: - pandora_track = self._station_iter.next() + pandora_track = next(self._station_iter) # except requests.exceptions.RequestException as e: # logger.error('Error retrieving next Pandora track: {}'.format(encoding.locale_decode(e))) # return None diff --git a/tests/test_client.py b/tests/test_client.py index aa9b8e4..1a7ca0d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -66,7 +66,7 @@ def test_get_genre_stations_changed_cached(config): backend.api._genre_stations_cache[time.time()] = station_list assert backend.api.get_genre_stations().checksum == cached_checksum - assert len(backend.api._genre_stations_cache.itervalues().next()) == len(GenreStationList.from_json( + assert len(next(backend.api._genre_stations_cache.itervalues())) == len(GenreStationList.from_json( APIClient, mock_cached_result['result'])) @@ -124,7 +124,7 @@ def test_get_station_list_changed_cached(config): APIClient, mock_cached_result['result']) assert backend.api.get_station_list().checksum == cached_checksum - assert len(backend.api._station_list_cache.itervalues().next()) == len(StationList.from_json( + assert len(next(backend.api._station_list_cache.itervalues())) == len(StationList.from_json( APIClient, mock_cached_result['result'])) @@ -163,7 +163,7 @@ def test_get_station_list_changed_refreshed(config): assert backend.api.get_station_list().checksum == cached_checksum assert backend.api.get_station_list(force_refresh=True).checksum == conftest.MOCK_STATION_LIST_CHECKSUM - assert (len(backend.api._station_list_cache.itervalues().next()) == + assert (len(next(backend.api._station_list_cache.itervalues())) == len(conftest.station_list_result_mock()['stations'])) From 2e349dd5d50d4011b336e42f61bef62ca6834273 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 4 Jan 2016 09:12:59 +0200 Subject: [PATCH 181/311] Improved Python 2/3 compatibility. --- mopidy_pandora/__init__.py | 1 - mopidy_pandora/client.py | 12 ++++++------ mopidy_pandora/uri.py | 16 ++++++++++------ tests/test_client.py | 12 ++++++------ tests/test_library.py | 2 +- tests/test_utils.py | 9 ++++++--- 6 files changed, 29 insertions(+), 23 deletions(-) diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index f5f1794..cee46cc 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -4,7 +4,6 @@ from mopidy import config, ext - __version__ = '0.2.0' diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 47b2390..43b7bc0 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -52,7 +52,7 @@ def get_station_list(self, force_refresh=False): list = [] try: if (self._station_list_cache.currsize == 0 or - (force_refresh and next(self._station_list_cache.itervalues()).has_changed())): + (force_refresh and next(iter(self._station_list_cache.values())).has_changed())): list = super(MopidyAPIClient, self).get_station_list() self._station_list_cache[time.time()] = list @@ -62,8 +62,8 @@ def get_station_list(self, force_refresh=False): return list try: - return next(self._station_list_cache.itervalues()) - except StopIteration: + return self._station_list_cache.values()[0] + except IndexError: # Cache disabled return list @@ -78,7 +78,7 @@ def get_genre_stations(self, force_refresh=False): list = [] try: if (self._genre_stations_cache.currsize == 0 or - (force_refresh and next(self._genre_stations_cache.itervalues()).has_changed())): + (force_refresh and next(iter(self._genre_stations_cache.values())).has_changed())): list = super(MopidyAPIClient, self).get_genre_stations() self._genre_stations_cache[time.time()] = list @@ -88,7 +88,7 @@ def get_genre_stations(self, force_refresh=False): return list try: - return next(self._genre_stations_cache.itervalues()) - except StopIteration: + return self._genre_stations_cache.values()[0] + except IndexError: # Cache disabled return list diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 3584a28..76c934b 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -1,15 +1,20 @@ from __future__ import absolute_import, division, print_function, unicode_literals import logging -import urllib from mopidy import compat 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) @@ -17,8 +22,7 @@ def __init__(cls, name, bases, clsdict): # noqa N805 cls.TYPES[cls.uri_type] = cls -class PandoraUri(object): - __metaclass__ = _PandoraUriMeta +class PandoraUri(with_metaclass(_PandoraUriMeta, object)): TYPES = {} SCHEME = 'pandora' @@ -31,8 +35,8 @@ def __repr__(self): @property def encoded_attributes(self): encoded_dict = {} - for k, v in self.__dict__.items(): - encoded_dict[k] = urllib.quote(PandoraUri.encode(v)) + for k, v in list(self.__dict__.items()): + encoded_dict[k] = quote(PandoraUri.encode(v)) return encoded_dict @@ -61,7 +65,7 @@ def factory(cls, obj): @classmethod def _from_uri(cls, uri): - parts = [urllib.unquote(cls.encode(p)) for p in uri.split(':')] + 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]) diff --git a/tests/test_client.py b/tests/test_client.py index 1a7ca0d..bda1d54 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -21,7 +21,7 @@ def test_get_genre_stations(config): genre_stations = backend.api.get_genre_stations() assert len(genre_stations) == len(conftest.genre_stations_result_mock()['categories']) - assert 'Category mock' in genre_stations.keys() + assert 'Category mock' in list(genre_stations) def test_get_genre_stations_handles_request_exception(config, caplog): @@ -66,7 +66,7 @@ def test_get_genre_stations_changed_cached(config): backend.api._genre_stations_cache[time.time()] = station_list assert backend.api.get_genre_stations().checksum == cached_checksum - assert len(next(backend.api._genre_stations_cache.itervalues())) == len(GenreStationList.from_json( + assert len(backend.api._genre_stations_cache.values()[0]) == len(GenreStationList.from_json( APIClient, mock_cached_result['result'])) @@ -124,7 +124,7 @@ def test_get_station_list_changed_cached(config): APIClient, mock_cached_result['result']) assert backend.api.get_station_list().checksum == cached_checksum - assert len(next(backend.api._station_list_cache.itervalues())) == len(StationList.from_json( + assert len(backend.api._station_list_cache.values()[0]) == len(StationList.from_json( APIClient, mock_cached_result['result'])) @@ -163,7 +163,7 @@ def test_get_station_list_changed_refreshed(config): assert backend.api.get_station_list().checksum == cached_checksum assert backend.api.get_station_list(force_refresh=True).checksum == conftest.MOCK_STATION_LIST_CHECKSUM - assert (len(next(backend.api._station_list_cache.itervalues())) == + assert (len(backend.api._station_list_cache.values()[0]) == len(conftest.station_list_result_mock()['stations'])) @@ -210,8 +210,8 @@ def test_create_genre_station_invalidates_cache(config): backend.api.create_station = mock.PropertyMock(return_value=conftest.station_result_mock()['result']) t = time.time() backend.api._station_list_cache[t] = mock.Mock(spec=StationList) - assert t in backend.api._station_list_cache.keys() + assert t in list(backend.api._station_list_cache) backend.library._create_station_for_genre('test_token') - assert t not in backend.api._station_list_cache.keys() + assert t not in list(backend.api._station_list_cache) assert backend.api._station_list_cache.currsize == 1 diff --git a/tests/test_library.py b/tests/test_library.py index 75aed8c..37f0d4a 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -227,7 +227,7 @@ def test_browse_genre_station_uri(config, genre_station_mock): results = backend.library.browse(genre_uri.uri) assert len(results) == 1 assert backend.api._station_list_cache.currsize == 1 - assert t not in backend.api._station_list_cache.keys() + assert t not in list(backend.api._station_list_cache) assert create_station_mock.called diff --git a/tests/test_utils.py b/tests/test_utils.py index 159128d..12a4446 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,9 @@ from __future__ import absolute_import, division, print_function, unicode_literals -import Queue +try: + import queue +except ImportError: + import Queue as queue import json @@ -69,7 +72,7 @@ def test_do_rpc(): response_mock.text = '{"result": "result_mock"}' requests.request = mock.PropertyMock(return_value=response_mock) - q = Queue.Queue() + q = queue.Queue() utils.RPCClient._do_rpc('method_mock', params={'param_mock_1': 'value_mock_1', 'param_mock_2': 'value_mock_2'}, queue=q) @@ -94,7 +97,7 @@ def test_run_async(caplog): def test_run_async_queue(caplog): - q = Queue.Queue() + q = queue.Queue() async_func('test_2_async', queue=q) assert q.get() == 'test_value' assert 'test_2_async' in caplog.text() From fb89300fbb217081d6cb0f369fd1bb88356a9598 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 4 Jan 2016 09:22:02 +0200 Subject: [PATCH 182/311] Add Python 3 to Travis build --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index a090495..3706e4f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,9 @@ language: python python: - "2.7_with_system_site_packages" + - "3.3_with_system_site_packages" + - "3.4_with_system_site_packages" + - "3.5_with_system_site_packages" addons: apt: From 88cf604c0c21e425d9a6a9c5eb2897b945792efa Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 4 Jan 2016 09:28:28 +0200 Subject: [PATCH 183/311] Revert to Python 2.7 for tests until Mopidy is Python 3 ready. --- .travis.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3706e4f..a090495 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,9 +4,6 @@ language: python python: - "2.7_with_system_site_packages" - - "3.3_with_system_site_packages" - - "3.4_with_system_site_packages" - - "3.5_with_system_site_packages" addons: apt: From 11156ae993cb2e46b7c350665bd1c40b65a340a3 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 4 Jan 2016 16:38:40 +0200 Subject: [PATCH 184/311] Implemented LRU caching for stations and tracks. --- mopidy_pandora/backend.py | 8 +++--- mopidy_pandora/client.py | 20 +++++++------- mopidy_pandora/frontend.py | 45 ++++++++++++++++++++++---------- mopidy_pandora/library.py | 53 ++++++++++++++++++++------------------ mopidy_pandora/listener.py | 4 ++- mopidy_pandora/uri.py | 10 ++++--- tests/test_backend.py | 6 ++--- tests/test_client.py | 40 ++++++++++++++-------------- tests/test_library.py | 25 ++++++++++-------- tests/test_playback.py | 5 ++-- tests/test_uri.py | 24 ++++++++--------- 11 files changed, 136 insertions(+), 104 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 44f230a..e855588 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -55,11 +55,11 @@ def on_start(self): except requests.exceptions.RequestException: logger.exception('Error logging in to Pandora.') - def end_of_tracklist_reached(self, auto_play=False): - self.prepare_next_track(auto_play=auto_play) + def end_of_tracklist_reached(self, station_id=None, auto_play=False): + self.prepare_next_track(station_id, auto_play=auto_play) - def prepare_next_track(self, auto_play=False): - self._trigger_next_track_available(self.library.get_next_pandora_track(), 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) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 43b7bc0..62b09d8 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -45,24 +45,24 @@ def __init__(self, cache_ttl, transport, partner_user, partner_password, device, 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) + self.station_list_cache = TTLCache(1, cache_ttl) + self.genre_stations_cache = TTLCache(1, cache_ttl) def get_station_list(self, force_refresh=False): list = [] try: - if (self._station_list_cache.currsize == 0 or - (force_refresh and next(iter(self._station_list_cache.values())).has_changed())): + if (self.station_list_cache.currsize == 0 or + (force_refresh and next(iter(self.station_list_cache.values())).has_changed())): list = super(MopidyAPIClient, self).get_station_list() - self._station_list_cache[time.time()] = list + self.station_list_cache[time.time()] = list except requests.exceptions.RequestException: logger.exception('Error retrieving Pandora station list.') return list try: - return self._station_list_cache.values()[0] + return self.station_list_cache.values()[0] except IndexError: # Cache disabled return list @@ -77,18 +77,18 @@ def get_station(self, station_token): def get_genre_stations(self, force_refresh=False): list = [] try: - if (self._genre_stations_cache.currsize == 0 or - (force_refresh and next(iter(self._genre_stations_cache.values())).has_changed())): + if (self.genre_stations_cache.currsize == 0 or + (force_refresh and next(iter(self.genre_stations_cache.values())).has_changed())): list = super(MopidyAPIClient, self).get_genre_stations() - self._genre_stations_cache[time.time()] = list + self.genre_stations_cache[time.time()] = list except requests.exceptions.RequestException: logger.exception('Error retrieving Pandora genre stations.') return list try: - return self._genre_stations_cache.values()[0] + return self.genre_stations_cache.values()[0] except IndexError: # Cache disabled return list diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index de2baf5..bfe8ce5 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -101,26 +101,40 @@ def track_playback_paused(self, tl_track, time_position): def track_playback_resumed(self, tl_track, time_position): self.set_options() - def end_of_tracklist_reached(self, track=None): + def check_end_of_tracklist_reached(self, track=None, auto_play=False): length = self.core.tracklist.get_length().get() if length <= 1: + self._trigger_end_of_tracklist_reached(PandoraUri.factory(track).station_id, + auto_play=auto_play) return True if track: tl_track = self.core.tracklist.filter({'uri': [track.uri]}).get()[0] - index = self.core.tracklist.index(tl_track).get() + track_index = self.core.tracklist.index(tl_track).get() else: - index = self.core.tracklist.index().get() + track_index = self.core.tracklist.index().get() - return index == length - 1 + if track_index == length - 1: + self._trigger_end_of_tracklist_reached(PandoraUri.factory(track).station_id, + auto_play=auto_play) + return True + return False def track_changed(self, track): - if self.end_of_tracklist_reached(track): - self._trigger_end_of_tracklist_reached(auto_play=False) + self.check_end_of_tracklist_reached(track, auto_play=False) + try: + previous_track_uri = PandoraUri.factory(self.core.history.get_history().get()[1][1].uri) + current_track_uri = PandoraUri.factory(track.uri) + if previous_track_uri.station_id != current_track_uri.station_id: + # Another frontend has added a track, remove the older tracks + self._trim_tracklist() + except IndexError: + # No tracks in history, continue + pass def track_unplayable(self, track): - if self.end_of_tracklist_reached(track): + if self.check_end_of_tracklist_reached(track, auto_play=True): self.core.playback.stop() - self._trigger_end_of_tracklist_reached(auto_play=True) + self.core.tracklist.remove({'uri': [track.uri]}).get() def next_track_available(self, track, auto_play=False): @@ -135,16 +149,21 @@ def skip_limit_exceeded(self): def add_track(self, track, auto_play=False): # Add the next Pandora track - self.core.tracklist.add(uris=[track.uri]).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() + self.core.tracklist.add(uris=[track.uri]).get() + self._trim_tracklist() + if auto_play: + self.core.playback.play(tl_tracks[-1]).get() + + def _trim_tracklist(self, tl_tracks=None): + if tl_tracks is None: + tl_tracks = self.core.tracklist.get_tl_tracks().get() if len(tl_tracks) > 2: # Only need two tracks in the tracklist at any given time, remove the oldest tracks self.core.tracklist.remove({'tlid': [tl_tracks[t].tlid for t in range(0, len(tl_tracks)-2)]}).get() - if auto_play: - self.core.playback.play(tl_tracks[-1]).get() - def _trigger_end_of_tracklist_reached(self, auto_play=False): - listener.PandoraFrontendListener.send('end_of_tracklist_reached', auto_play=auto_play) + 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) class EventHandlingPandoraFrontend(PandoraFrontend, listener.PandoraEventHandlingPlaybackListener): diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index bc050d6..672c78d 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -2,7 +2,9 @@ import logging -from collections import OrderedDict +from collections import namedtuple + +from cachetools import LRUCache from mopidy import backend, models @@ -15,6 +17,9 @@ logger = logging.getLogger(__name__) +StationCacheItem = namedtuple('StationCacheItem', 'station, iter') +TrackCacheItem = namedtuple('TrackCacheItem', 'track_ref, pandora_track') + class PandoraLibraryProvider(backend.LibraryProvider): ROOT_DIR_NAME = 'Pandora' @@ -24,12 +29,11 @@ class PandoraLibraryProvider(backend.LibraryProvider): 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._station = None - self._station_iter = None - self._pandora_track_cache = OrderedDict() - super(PandoraLibraryProvider, self).__init__(backend) + self.pandora_station_cache = LRUCache(maxsize=25, missing=self.get_station_cache_item) + self.pandora_track_cache = LRUCache(maxsize=50) def browse(self, uri): if uri == self.root_directory.uri: @@ -110,9 +114,6 @@ def get_images(self, uris): result[uri] = [models.Image(uri=u) for u in image_uris] return result - def _cache_pandora_track(self, track, pandora_track): - self._pandora_track_cache[track.uri] = pandora_track - def _formatted_station_list(self, list): # Find QuickMix stations and move QuickMix to top for i, station in enumerate(list[:]): @@ -152,16 +153,7 @@ def _browse_stations(self): def _browse_tracks(self, uri): pandora_uri = PandoraUri.factory(uri) - - if self._station is None or (pandora_uri.station_id != self._station.id): - - if type(pandora_uri) is GenreStationUri: - pandora_uri = self._create_station_for_genre(pandora_uri.token) - - self._station = self.backend.api.get_station(pandora_uri.token) - self._station_iter = iterate_forever(self._station.get_playlist) - - return [self.get_next_pandora_track()] + 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) @@ -181,11 +173,20 @@ def _browse_genre_stations(self, uri): [PandoraUri.factory(uri).category_name]] def lookup_pandora_track(self, uri): - return self._pandora_track_cache[uri] + return self.pandora_track_cache[uri].pandora_track - def get_next_pandora_track(self): + def get_station_cache_item(self, station_id): + if len(station_id) == 4 and station_id.startswith('G'): + pandora_uri = self._create_station_for_genre(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: - pandora_track = next(self._station_iter) + station_iter = self.pandora_station_cache[station_id].iter + pandora_track = next(station_iter) # except requests.exceptions.RequestException as e: # logger.error('Error retrieving next Pandora track: {}'.format(encoding.locale_decode(e))) # return None @@ -200,7 +201,6 @@ def get_next_pandora_track(self): return None track_uri = PandoraUri.factory(pandora_track) - if type(track_uri) is AdItemUri: track_name = 'Advertisement' else: @@ -208,7 +208,7 @@ def get_next_pandora_track(self): track = models.Ref.track(name=track_name, uri=track_uri.uri) - self._cache_pandora_track(track, pandora_track) + self.pandora_track_cache[track_uri.uri] = TrackCacheItem(track, pandora_track) return track def refresh(self, uri=None): @@ -220,5 +220,8 @@ def refresh(self, uri=None): else: pandora_uri = PandoraUri.factory(uri) if type(pandora_uri) is StationUri: - self._station = self.backend.api.get_station(pandora_uri.station_token) - self._station_iter = iterate_forever(self._station.get_playlist) + try: + self.pandora_station_cache.pop(uri.station_id) + except KeyError: + # Item not in cache, ignore + pass diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 39e1f19..97ef16d 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -14,9 +14,11 @@ class PandoraFrontendListener(listener.Listener): def send(event, **kwargs): listener.send_async(PandoraFrontendListener, event, **kwargs) - def end_of_tracklist_reached(self, auto_play=False): + 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 """ diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 76c934b..91a82e3 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -2,7 +2,7 @@ import logging -from mopidy import compat +from mopidy import compat, models from pandora.models.pandora import AdItem, GenreStation, PlaylistItem, Station @@ -56,12 +56,16 @@ def encode(cls, value): def factory(cls, obj): if isinstance(obj, basestring): return PandoraUri._from_uri(obj) + if isinstance(obj, models.Ref): + return PandoraUri._from_uri(obj.uri) + if isinstance(obj, models.Track): + return PandoraUri._from_uri(obj.uri) elif isinstance(obj, Station) or isinstance(obj, GenreStation): return PandoraUri._from_station(obj) elif isinstance(obj, PlaylistItem) or isinstance(obj, AdItem): return PandoraUri._from_track(obj) else: - raise NotImplementedError("Unsupported URI object type '{}'".format(obj)) + raise NotImplementedError("Unsupported URI object type '{}'".format(type(obj))) @classmethod def _from_uri(cls, uri): @@ -77,7 +81,7 @@ def _from_uri(cls, uri): @classmethod def _from_station(cls, station): if isinstance(station, Station) or isinstance(station, GenreStation): - if station.id.startswith('G') and station.id == station.token: + if len(station.id) == 4 and station.id.startswith('G') and station.id == station.token: return GenreStationUri(station.id, station.token) return StationUri(station.id, station.token) else: diff --git a/tests/test_backend.py b/tests/test_backend.py index 92144e2..2081f24 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -53,13 +53,13 @@ def test_on_start_pre_fetches_lists(config): backend.api.login = mock.PropertyMock() backend.api.get_genre_stations = mock.PropertyMock() - assert backend.api._station_list_cache.currsize == 0 - assert backend.api._genre_stations_cache.currsize == 0 + assert backend.api.station_list_cache.currsize == 0 + assert backend.api.genre_stations_cache.currsize == 0 t = backend.on_start() t.join() - assert backend.api._station_list_cache.currsize == 1 + assert backend.api.station_list_cache.currsize == 1 assert backend.api.get_genre_stations.called diff --git a/tests/test_client.py b/tests/test_client.py index bda1d54..1cdc965 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -37,10 +37,10 @@ def test_get_genre_stations_populates_cache(config): with mock.patch.object(APIClient, 'get_genre_stations', conftest.get_genre_stations_mock): backend = conftest.get_backend(config) - assert backend.api._genre_stations_cache.currsize == 0 + assert backend.api.genre_stations_cache.currsize == 0 backend.api.get_genre_stations() - assert backend.api._genre_stations_cache.currsize == 1 + assert backend.api.genre_stations_cache.currsize == 1 def test_get_genre_stations_changed_cached(config): @@ -63,23 +63,23 @@ def test_get_genre_stations_changed_cached(config): station_list = GenreStationList.from_json(APIClient, mock_cached_result['result']) station_list.checksum = cached_checksum - backend.api._genre_stations_cache[time.time()] = station_list + backend.api.genre_stations_cache[time.time()] = station_list assert backend.api.get_genre_stations().checksum == cached_checksum - assert len(backend.api._genre_stations_cache.values()[0]) == len(GenreStationList.from_json( + assert len(backend.api.genre_stations_cache.values()[0]) == len(GenreStationList.from_json( APIClient, mock_cached_result['result'])) -def test_get_genre_stations_cache_disabled(config): +def test_getgenre_stations_cache_disabled(config): with mock.patch.object(APIClient, 'get_genre_stations', conftest.get_genre_stations_mock): cache_config = config cache_config['pandora']['cache_time_to_live'] = 0 backend = conftest.get_backend(cache_config) - assert backend.api._genre_stations_cache.currsize == 0 + assert backend.api.genre_stations_cache.currsize == 0 assert len(backend.api.get_genre_stations()) == 1 - assert backend.api._genre_stations_cache.currsize == 0 + assert backend.api.genre_stations_cache.currsize == 0 def test_get_station_list(config): @@ -98,10 +98,10 @@ def test_get_station_list_populates_cache(config): with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): backend = conftest.get_backend(config) - assert backend.api._station_list_cache.currsize == 0 + assert backend.api.station_list_cache.currsize == 0 backend.api.get_station_list() - assert backend.api._station_list_cache.currsize == 1 + assert backend.api.station_list_cache.currsize == 1 def test_get_station_list_changed_cached(config): @@ -120,24 +120,24 @@ def test_get_station_list_changed_cached(config): 'checksum': cached_checksum }} - backend.api._station_list_cache[time.time()] = StationList.from_json( + backend.api.station_list_cache[time.time()] = StationList.from_json( APIClient, mock_cached_result['result']) assert backend.api.get_station_list().checksum == cached_checksum - assert len(backend.api._station_list_cache.values()[0]) == len(StationList.from_json( + assert len(backend.api.station_list_cache.values()[0]) == len(StationList.from_json( APIClient, mock_cached_result['result'])) -def test_get_station_list_cache_disabled(config): +def test_getstation_list_cache_disabled(config): with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): cache_config = config cache_config['pandora']['cache_time_to_live'] = 0 backend = conftest.get_backend(cache_config) - assert backend.api._station_list_cache.currsize == 0 + assert backend.api.station_list_cache.currsize == 0 assert len(backend.api.get_station_list()) == 3 - assert backend.api._station_list_cache.currsize == 0 + assert backend.api.station_list_cache.currsize == 0 def test_get_station_list_changed_refreshed(config): @@ -157,13 +157,13 @@ def test_get_station_list_changed_refreshed(config): 'checksum': cached_checksum }} - backend.api._station_list_cache[time.time()] = StationList.from_json( + backend.api.station_list_cache[time.time()] = StationList.from_json( APIClient, mock_cached_result['result']) assert backend.api.get_station_list().checksum == cached_checksum assert backend.api.get_station_list(force_refresh=True).checksum == conftest.MOCK_STATION_LIST_CHECKSUM - assert (len(backend.api._station_list_cache.values()[0]) == + assert (len(backend.api.station_list_cache.values()[0]) == len(conftest.station_list_result_mock()['stations'])) @@ -209,9 +209,9 @@ def test_create_genre_station_invalidates_cache(config): backend.api.create_station = mock.PropertyMock(return_value=conftest.station_result_mock()['result']) t = time.time() - backend.api._station_list_cache[t] = mock.Mock(spec=StationList) - assert t in list(backend.api._station_list_cache) + backend.api.station_list_cache[t] = mock.Mock(spec=StationList) + assert t in list(backend.api.station_list_cache) backend.library._create_station_for_genre('test_token') - assert t not in list(backend.api._station_list_cache) - assert backend.api._station_list_cache.currsize == 1 + assert t not in list(backend.api.station_list_cache) + assert backend.api.station_list_cache.currsize == 1 diff --git a/tests/test_library.py b/tests/test_library.py index 37f0d4a..afe0ef5 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -12,7 +12,7 @@ import pytest from mopidy_pandora.client import MopidyAPIClient -from mopidy_pandora.library import PandoraLibraryProvider +from mopidy_pandora.library import PandoraLibraryProvider, TrackCacheItem from mopidy_pandora.uri import GenreUri, PandoraUri, PlaylistItemUri, StationUri @@ -24,7 +24,7 @@ def test_get_images_for_ad_without_images(config, ad_item_mock): ad_uri = PandoraUri.factory('pandora:ad:{}:{}'.format(conftest.MOCK_STATION_ID, conftest.MOCK_TRACK_AD_TOKEN)) ad_item_mock.image_url = None - backend.library._pandora_track_cache[ad_uri.uri] = ad_item_mock + backend.library.pandora_track_cache[ad_uri.uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), ad_item_mock) results = backend.library.get_images([ad_uri.uri]) assert len(results[ad_uri.uri]) == 0 @@ -33,7 +33,7 @@ def test_get_images_for_ad_with_images(config, ad_item_mock): backend = conftest.get_backend(config) ad_uri = PandoraUri.factory('pandora:ad:{}:{}'.format(conftest.MOCK_STATION_ID, conftest.MOCK_TRACK_AD_TOKEN)) - backend.library._pandora_track_cache[ad_uri.uri] = ad_item_mock + backend.library.pandora_track_cache[ad_uri.uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), ad_item_mock) results = backend.library.get_images([ad_uri.uri]) assert len(results[ad_uri.uri]) == 1 assert results[ad_uri.uri][0].uri == ad_item_mock.image_url @@ -53,7 +53,8 @@ def test_get_images_for_track_without_images(config, playlist_item_mock): track_uri = PandoraUri.factory('pandora:track:mock_id:mock_token') playlist_item_mock.album_art_url = None - backend.library._pandora_track_cache[track_uri.uri] = playlist_item_mock + backend.library.pandora_track_cache[track_uri.uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), + playlist_item_mock) results = backend.library.get_images([track_uri.uri]) assert len(results[track_uri.uri]) == 0 @@ -62,7 +63,8 @@ def test_get_images_for_track_with_images(config, playlist_item_mock): backend = conftest.get_backend(config) track_uri = PandoraUri.factory('pandora:track:mock_id:mock_token') - backend.library._pandora_track_cache[track_uri.uri] = playlist_item_mock + backend.library.pandora_track_cache[track_uri.uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), + playlist_item_mock) results = backend.library.get_images([track_uri.uri]) assert len(results[track_uri.uri]) == 1 assert results[track_uri.uri][0].uri == playlist_item_mock.album_art_url @@ -87,7 +89,7 @@ def test_lookup_of_ad_uri(config, ad_item_mock): backend = conftest.get_backend(config) track_uri = PlaylistItemUri._from_track(ad_item_mock) - backend.library._pandora_track_cache[track_uri.uri] = ad_item_mock + backend.library.pandora_track_cache[track_uri.uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), ad_item_mock) results = backend.library.lookup(track_uri.uri) assert len(results) == 1 @@ -103,7 +105,7 @@ def test_lookup_of_ad_uri_defaults_missing_values(config, ad_item_mock): ad_item_mock.company_name = None track_uri = PlaylistItemUri._from_track(ad_item_mock) - backend.library._pandora_track_cache[track_uri.uri] = ad_item_mock + backend.library.pandora_track_cache[track_uri.uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), ad_item_mock) results = backend.library.lookup(track_uri.uri) assert len(results) == 1 @@ -118,7 +120,8 @@ def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) track_uri = PlaylistItemUri._from_track(playlist_item_mock) - backend.library._pandora_track_cache[track_uri.uri] = playlist_item_mock + backend.library.pandora_track_cache[track_uri.uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), + playlist_item_mock) results = backend.library.lookup(track_uri.uri) assert len(results) == 1 @@ -222,12 +225,12 @@ def test_browse_genre_station_uri(config, genre_station_mock): backend = conftest.get_backend(config) genre_uri = GenreUri._from_station(genre_station_mock) t = time.time() - backend.api._station_list_cache[t] = mock.Mock(spec=StationList) + backend.api.station_list_cache[t] = mock.Mock(spec=StationList) results = backend.library.browse(genre_uri.uri) assert len(results) == 1 - assert backend.api._station_list_cache.currsize == 1 - assert t not in list(backend.api._station_list_cache) + assert backend.api.station_list_cache.currsize == 1 + assert t not in list(backend.api.station_list_cache) assert create_station_mock.called diff --git a/tests/test_playback.py b/tests/test_playback.py index 9a82ac0..3b54e14 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -11,7 +11,7 @@ from mopidy_pandora import playback from mopidy_pandora.backend import MopidyAPIClient -from mopidy_pandora.library import PandoraLibraryProvider +from mopidy_pandora.library import PandoraLibraryProvider, TrackCacheItem from mopidy_pandora.playback import PandoraPlaybackProvider @@ -151,7 +151,8 @@ def test_change_track_skips_if_track_not_available_in_buffer(provider, playlist_ def test_translate_uri_returns_audio_url(provider, playlist_item_mock): test_uri = 'pandora:track:test_station_id:test_token' - provider.backend.library._pandora_track_cache[test_uri] = playlist_item_mock + provider.backend.library.pandora_track_cache[test_uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), + playlist_item_mock) assert provider.translate_uri(test_uri) == conftest.MOCK_TRACK_AUDIO_HIGH diff --git a/tests/test_uri.py b/tests/test_uri.py index 0ffca5a..fb2bb0c 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -33,8 +33,8 @@ def test_ad_uri_parse(): def test_factory_returns_correct_station_uri_types(): station_mock = mock.PropertyMock(spec=GenreStation) - station_mock.id = 'Gmock' - station_mock.token = 'Gmock' + station_mock.id = 'G100' + station_mock.token = 'G100' assert type(PandoraUri.factory(station_mock)) is GenreStationUri station_mock = mock.PropertyMock(spec=Station) @@ -116,8 +116,8 @@ def test_station_uri_parse(station_mock): def test_station_uri_parse_returns_correct_type(): station_mock = mock.PropertyMock(spec=GenreStation) - station_mock.id = 'Gmock' - station_mock.token = 'Gmock' + station_mock.id = 'G100' + station_mock.token = 'G100' obj = StationUri._from_station(station_mock) @@ -137,14 +137,14 @@ def test_genre_uri_parse(): def test_genre_station_uri_parse(): - mock_uri = 'pandora:genre_station:Gmock:Gmock' + mock_uri = 'pandora:genre_station:G100:G100' obj = PandoraUri._from_uri(mock_uri) assert type(obj) is GenreStationUri assert obj.uri_type == 'genre_station' - assert obj.station_id == 'Gmock' - assert obj.token == 'Gmock' + assert obj.station_id == 'G100' + assert obj.token == 'G100' assert obj.uri == mock_uri @@ -167,18 +167,18 @@ def test_genre_station_uri_from_station_returns_correct_type(): def test_genre_station_uri_from_genre_station_returns_correct_type(): genre_station_mock = mock.PropertyMock(spec=GenreStation) - genre_station_mock.id = 'Gmock' - genre_station_mock.token = 'Gmock' + genre_station_mock.id = 'G100' + genre_station_mock.token = 'G100' obj = GenreStationUri._from_station(genre_station_mock) assert type(obj) is GenreStationUri assert obj.uri_type == 'genre_station' - assert obj.station_id == 'Gmock' - assert obj.token == 'Gmock' + assert obj.station_id == 'G100' + assert obj.token == 'G100' - assert obj.uri == 'pandora:genre_station:Gmock:Gmock' + assert obj.uri == 'pandora:genre_station:G100:G100' def test_track_uri_from_track(playlist_item_mock): From e1ae1a8d1face42610ad6f3cb61f6784f59f5cfc Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 4 Jan 2016 18:53:14 +0200 Subject: [PATCH 185/311] Clear tracklist of old tracks when user changes to another station. --- mopidy_pandora/backend.py | 7 +---- mopidy_pandora/frontend.py | 59 ++++++++++++++++++++++++-------------- mopidy_pandora/library.py | 8 ++++-- mopidy_pandora/uri.py | 10 +++++-- tests/conftest.py | 4 +-- 5 files changed, 53 insertions(+), 35 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index e855588..5678996 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -95,12 +95,7 @@ def add_song_bookmark(self, track_uri): return self.api.add_song_bookmark(PandoraUri.factory(track_uri).token) def delete_station(self, track_uri): - # 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 PandoraUri.factory(track_uri).station_id == self.library._station.token - - r = self.api.delete_station(self.library._station.token) + r = self.api.delete_station(PandoraUri.factory(track_uri).station_id) self.library.refresh() self.library.browse(self.library.root_directory.uri) return r diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index bfe8ce5..8a6fc03 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -101,11 +101,9 @@ def track_playback_paused(self, tl_track, time_position): def track_playback_resumed(self, tl_track, time_position): self.set_options() - def check_end_of_tracklist_reached(self, track=None, auto_play=False): + def is_end_of_tracklist_reached(self, track=None): length = self.core.tracklist.get_length().get() if length <= 1: - self._trigger_end_of_tracklist_reached(PandoraUri.factory(track).station_id, - auto_play=auto_play) return True if track: tl_track = self.core.tracklist.filter({'uri': [track.uri]}).get()[0] @@ -113,27 +111,35 @@ def check_end_of_tracklist_reached(self, track=None, auto_play=False): else: track_index = self.core.tracklist.index().get() - if track_index == length - 1: - self._trigger_end_of_tracklist_reached(PandoraUri.factory(track).station_id, - auto_play=auto_play) - return True + return track_index == length - 1 + + def is_station_changed(self, track=None): + 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: + # No tracks in history, ignore + pass return False def track_changed(self, track): - self.check_end_of_tracklist_reached(track, auto_play=False) try: - previous_track_uri = PandoraUri.factory(self.core.history.get_history().get()[1][1].uri) - current_track_uri = PandoraUri.factory(track.uri) - if previous_track_uri.station_id != current_track_uri.station_id: - # Another frontend has added a track, remove the older tracks - self._trim_tracklist() + if self.is_station_changed(track): + # Another frontend has added a track, remove all tracks after it + self._trim_tracklist(around_track=track) + if self.is_end_of_tracklist_reached(track): + self._trigger_end_of_tracklist_reached(PandoraUri.factory(track).station_id, + auto_play=False) except IndexError: # No tracks in history, continue pass def track_unplayable(self, track): - if self.check_end_of_tracklist_reached(track, auto_play=True): + 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]}).get() @@ -149,18 +155,27 @@ def skip_limit_exceeded(self): def add_track(self, track, auto_play=False): # Add the next Pandora track - tl_tracks = self.core.tracklist.get_tl_tracks().get() self.core.tracklist.add(uris=[track.uri]).get() - self._trim_tracklist() if auto_play: + tl_tracks = self.core.tracklist.get_tl_tracks().get() self.core.playback.play(tl_tracks[-1]).get() + self._trim_tracklist(maxsize=2) - def _trim_tracklist(self, tl_tracks=None): - if tl_tracks is None: - tl_tracks = self.core.tracklist.get_tl_tracks().get() - if len(tl_tracks) > 2: + def _trim_tracklist(self, around_track=None, maxsize=2): + tl_tracks = self.core.tracklist.get_tl_tracks().get() + if around_track: + trim_tlids = [] + for t in tl_tracks: + if t.track.uri != around_track.uri: + trim_tlids.append(t.tlid) + + return len(self.core.tracklist.remove({'tlid': trim_tlids}).get()) + + elif len(tl_tracks) > maxsize: # Only need two tracks in the tracklist at any given time, remove the oldest tracks - self.core.tracklist.remove({'tlid': [tl_tracks[t].tlid for t in range(0, len(tl_tracks)-2)]}).get() + return len(self.core.tracklist.remove( + {'tlid': [tl_tracks[t].tlid for t in range(0, len(tl_tracks)-maxsize)]} + ).get()) 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) @@ -207,8 +222,8 @@ def track_playback_resumed(self, tl_track, time_position): self.check_doubleclicked(action='resume') def track_changed(self, track): - self.track_changed_event.set() super(EventHandlingPandoraFrontend, self).track_changed(track) + self.track_changed_event.set() self.check_doubleclicked(action='change_track') def set_click_time(self, click_time=None): diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 672c78d..41266ff 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -144,6 +144,10 @@ def _browse_stations(self): 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)) @@ -160,7 +164,6 @@ def _create_station_for_genre(self, genre_token): new_station = Station.from_json(self.backend.api, json_result) self.refresh() - return PandoraUri.factory(new_station) def _browse_genre_categories(self): @@ -176,8 +179,9 @@ def lookup_pandora_track(self, uri): return self.pandora_track_cache[uri].pandora_track def get_station_cache_item(self, station_id): - if len(station_id) == 4 and station_id.startswith('G'): + 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) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 91a82e3..16d3220 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -2,6 +2,8 @@ import logging +import re + from mopidy import compat, models from pandora.models.pandora import AdItem, GenreStation, PlaylistItem, Station @@ -81,7 +83,7 @@ def _from_uri(cls, uri): @classmethod def _from_station(cls, station): if isinstance(station, Station) or isinstance(station, GenreStation): - if len(station.id) == 4 and station.id.startswith('G') and station.id == station.token: + if GenreStationUri.pattern.match(station.id) and station.id == station.token: return GenreStationUri(station.id, station.token) return StationUri(station.id, station.token) else: @@ -128,12 +130,14 @@ def __repr__(self): 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 station_id.startswith('G') - assert token.startswith('G') + + assert GenreStationUri.pattern.match(station_id) + assert GenreStationUri.pattern.match(token) super(GenreStationUri, self).__init__(station_id, token) diff --git a/tests/conftest.py b/tests/conftest.py index bf8a24f..c7d2307 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -284,13 +284,13 @@ def station_list_result_mock(): mock_result = {'stat': 'ok', 'result': {'stations': [ {'stationId': MOCK_STATION_ID.replace('1', '2'), - 'stationToken': MOCK_STATION_TOKEN.replace('010', '100'), + 'stationToken': MOCK_STATION_TOKEN.replace('1', '2'), 'stationName': MOCK_STATION_NAME + ' 2'}, {'stationId': MOCK_STATION_ID, 'stationToken': MOCK_STATION_TOKEN, 'stationName': MOCK_STATION_NAME + ' 1'}, {'stationId': MOCK_STATION_ID.replace('1', '3'), - 'stationToken': MOCK_STATION_TOKEN.replace('0010', '1000'), + 'stationToken': MOCK_STATION_TOKEN.replace('1', '3'), 'stationName': 'QuickMix', 'isQuickMix': True}, ], 'checksum': MOCK_STATION_LIST_CHECKSUM}, } From ec26c7d0c0881a37e9851b575894afd53d9de3a5 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 5 Jan 2016 07:22:46 +0200 Subject: [PATCH 186/311] Reduce size of library caches. Refactor code for retrieving stations and genre stations. --- mopidy_pandora/client.py | 24 ++++++++++++------------ mopidy_pandora/library.py | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index 62b09d8..d2e6e10 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -49,23 +49,23 @@ def __init__(self, cache_ttl, transport, partner_user, partner_password, device, self.genre_stations_cache = TTLCache(1, cache_ttl) def get_station_list(self, force_refresh=False): - list = [] + station_list = [] try: if (self.station_list_cache.currsize == 0 or - (force_refresh and next(iter(self.station_list_cache.values())).has_changed())): + (force_refresh and self.station_list_cache.values()[0].has_changed())): - list = super(MopidyAPIClient, self).get_station_list() - self.station_list_cache[time.time()] = list + 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.') - return list + station_list = [] try: return self.station_list_cache.values()[0] except IndexError: # Cache disabled - return list + return station_list def get_station(self, station_token): try: @@ -75,20 +75,20 @@ def get_station(self, station_token): return super(MopidyAPIClient, self).get_station(station_token) def get_genre_stations(self, force_refresh=False): - list = [] + genre_stations = [] try: if (self.genre_stations_cache.currsize == 0 or - (force_refresh and next(iter(self.genre_stations_cache.values())).has_changed())): + (force_refresh and self.genre_stations_cache.values()[0].has_changed())): - list = super(MopidyAPIClient, self).get_genre_stations() - self.genre_stations_cache[time.time()] = list + 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 list + return genre_stations try: return self.genre_stations_cache.values()[0] except IndexError: # Cache disabled - return list + return genre_stations diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 41266ff..2f0ffff 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -32,8 +32,8 @@ def __init__(self, backend, sort_order): super(PandoraLibraryProvider, self).__init__(backend) self.sort_order = sort_order.lower() - self.pandora_station_cache = LRUCache(maxsize=25, missing=self.get_station_cache_item) - self.pandora_track_cache = LRUCache(maxsize=50) + self.pandora_station_cache = LRUCache(maxsize=5, missing=self.get_station_cache_item) + self.pandora_track_cache = LRUCache(maxsize=10) def browse(self, uri): if uri == self.root_directory.uri: From 35fc130449ceadd7174ee1a2e7133f7e26e83038 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 5 Jan 2016 07:41:08 +0200 Subject: [PATCH 187/311] Additional test cases for PandoraURI.factory() --- mopidy_pandora/uri.py | 11 ++++++++--- tests/test_uri.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 16d3220..0f3b088 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -57,14 +57,19 @@ def encode(cls, value): @classmethod def factory(cls, obj): if isinstance(obj, basestring): + # A string return PandoraUri._from_uri(obj) - if isinstance(obj, models.Ref): - return PandoraUri._from_uri(obj.uri) - if isinstance(obj, models.Track): + + 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))) diff --git a/tests/test_uri.py b/tests/test_uri.py index fb2bb0c..ba14222 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -3,6 +3,8 @@ from mock import mock +from mopidy import models + from pandora.models.pandora import GenreStation, Station import pytest @@ -31,6 +33,38 @@ def test_ad_uri_parse(): assert obj.uri == mock_uri +def test_factory_ad(ad_item_mock): + obj = PandoraUri.factory(ad_item_mock) + + assert type(obj) is AdItemUri + assert obj.uri == 'pandora:ad:{}:{}'.format(conftest.MOCK_STATION_ID, conftest.MOCK_TRACK_AD_TOKEN) + + +def test_factory_playlist_item(playlist_item_mock): + obj = PandoraUri.factory(playlist_item_mock) + + assert type(obj) is PlaylistItemUri + assert obj.uri == 'pandora:track:{}:{}'.format(conftest.MOCK_STATION_ID, conftest.MOCK_TRACK_TOKEN) + + +def test_factory_track_ref(): + track_ref = models.Ref(name='name_mock', uri='pandora:track:station_id_mock:track_token_mock') + + obj = PandoraUri.factory(track_ref) + + assert type(obj) is PlaylistItemUri + assert obj.uri == track_ref.uri + + +def test_factory_track(): + track = models.Track(name='name_mock', uri='pandora:track:station_id_mock:track_token_mock') + + obj = PandoraUri.factory(track) + + assert type(obj) is PlaylistItemUri + assert obj.uri == track.uri + + def test_factory_returns_correct_station_uri_types(): station_mock = mock.PropertyMock(spec=GenreStation) station_mock.id = 'G100' From 6e5181f8e8aa1fe1dacee7f683de18213ae3311c Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 5 Jan 2016 08:19:15 +0200 Subject: [PATCH 188/311] Switch to using 'send' instead of 'send_async' as per latest Mopidy develop branch. Test cases for Listeners. --- mopidy_pandora/listener.py | 10 ++-- tests/test_listener.py | 104 +++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 tests/test_listener.py diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 97ef16d..d0c59e0 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -12,7 +12,7 @@ class PandoraFrontendListener(listener.Listener): @staticmethod def send(event, **kwargs): - listener.send_async(PandoraFrontendListener, event, **kwargs) + listener.send(PandoraFrontendListener, event, **kwargs) def end_of_tracklist_reached(self, station_id, auto_play=False): """ @@ -34,7 +34,7 @@ class PandoraEventHandlingFrontendListener(listener.Listener): @staticmethod def send(event, **kwargs): - listener.send_async(PandoraEventHandlingFrontendListener, event, **kwargs) + listener.send(PandoraEventHandlingFrontendListener, event, **kwargs) def event_triggered(self, track_uri, pandora_event): """ @@ -58,7 +58,7 @@ class PandoraBackendListener(backend.BackendListener): @staticmethod def send(event, **kwargs): - listener.send_async(PandoraBackendListener, event, **kwargs) + listener.send(PandoraBackendListener, event, **kwargs) def next_track_available(self, track, auto_play=False): """ @@ -93,7 +93,7 @@ class PandoraPlaybackListener(listener.Listener): @staticmethod def send(event, **kwargs): - listener.send_async(PandoraPlaybackListener, event, **kwargs) + listener.send(PandoraPlaybackListener, event, **kwargs) def track_changed(self, track): """ @@ -136,7 +136,7 @@ class PandoraEventHandlingPlaybackListener(listener.Listener): @staticmethod def send(event, **kwargs): - listener.send_async(PandoraEventHandlingPlaybackListener, event, **kwargs) + listener.send(PandoraEventHandlingPlaybackListener, event, **kwargs) def check_doubleclicked(self, action=None): """ diff --git a/tests/test_listener.py b/tests/test_listener.py new file mode 100644 index 0000000..8c6ff73 --- /dev/null +++ b/tests/test_listener.py @@ -0,0 +1,104 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +import mock + +from mopidy import models + +import mopidy_pandora.listener as listener + + +class PandoraFrontendListenerTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.listener = listener.PandoraFrontendListener() + + def test_on_event_forwards_to_specific_handler(self): + self.listener.end_of_tracklist_reached = mock.Mock() + + self.listener.on_event( + 'end_of_tracklist_reached', station_id='id_mock', auto_play=False) + + self.listener.end_of_tracklist_reached.assert_called_with(station_id='id_mock', auto_play=False) + + def test_listener_has_default_impl_for_end_of_tracklist_reached(self): + self.listener.end_of_tracklist_reached(station_id='id_mock', auto_play=False) + + +class PandoraEventHandlingFrontendListenerTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.listener = listener.PandoraEventHandlingFrontendListener() + + def test_on_event_forwards_to_specific_handler(self): + self.listener.event_triggered = mock.Mock() + + self.listener.on_event('event_triggered', track_uri='pandora:track:id_mock:token_mock', + pandora_event='event_mock') + + self.listener.event_triggered.assert_called_with(track_uri='pandora:track:id_mock:token_mock', + pandora_event='event_mock') + + def test_listener_has_default_impl_for_event_triggered(self): + self.listener.event_triggered('pandora:track:id_mock:token_mock', 'event_mock') + + +class PandoraBackendListenerTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.listener = listener.PandoraBackendListener() + + def test_on_event_forwards_to_specific_handler(self): + self.listener.next_track_available = mock.Mock() + + self.listener.on_event( + 'next_track_available', track=models.Ref(name='name_mock'), auto_play=False) + + self.listener.next_track_available.assert_called_with(track=models.Ref(name='name_mock'), auto_play=False) + + def test_listener_has_default_impl_for_next_track_available(self): + self.listener.next_track_available(track=models.Ref(name='name_mock'), auto_play=False) + + def test_listener_has_default_impl_for_event_processed(self): + self.listener.event_processed(track_uri='pandora:track:id_mock:token_mock', + pandora_event='event_mock') + + +class PandoraPlaybackListenerTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.listener = listener.PandoraPlaybackListener() + + def test_on_event_forwards_to_specific_handler(self): + self.listener.track_changed = mock.Mock() + + self.listener.on_event( + 'track_changed', track=models.Ref(name='name_mock')) + + self.listener.track_changed.assert_called_with(track=models.Ref(name='name_mock')) + + def test_listener_has_default_impl_for_track_changed(self): + self.listener.track_changed(track=models.Ref(name='name_mock')) + + def test_listener_has_default_impl_for_track_unplayable(self): + self.listener.track_unplayable(track=models.Ref(name='name_mock')) + + def test_listener_has_default_impl_for_skip_limit_exceeded(self): + self.listener.skip_limit_exceeded() + + +class PandoraEventHandlingPlaybackListenerTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.listener = listener.PandoraEventHandlingPlaybackListener() + + def test_on_event_forwards_to_specific_handler(self): + self.listener.check_doubleclicked = mock.Mock() + + self.listener.on_event('check_doubleclicked', action='action_mock') + + self.listener.check_doubleclicked.assert_called_with(action='action_mock') + + def test_listener_has_default_impl_for_check_doubleclicked(self): + self.listener.check_doubleclicked(action='action_mock') From 86161e1cd00283a5637b7f82467f95b0e8b19427 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 5 Jan 2016 09:48:51 +0200 Subject: [PATCH 189/311] Remove unreachable exception. Add test cases for library functions. --- mopidy_pandora/library.py | 2 -- tests/conftest.py | 53 ++++++++++++--------------------------- tests/test_library.py | 43 +++++++++++++++++++++++++++---- tests/test_uri.py | 2 +- 4 files changed, 55 insertions(+), 45 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 2f0ffff..47ccfec 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -50,8 +50,6 @@ def browse(self, uri): if type(pandora_uri) is StationUri or type(pandora_uri) is GenreStationUri: return self._browse_tracks(uri) - raise Exception("Unknown or unsupported URI type '{}'".format(uri)) - def lookup(self, uri): pandora_uri = PandoraUri.factory(uri) diff --git a/tests/conftest.py b/tests/conftest.py index c7d2307..9a82d64 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,11 @@ import json -from mock import Mock +import mock -from pandora.models.pandora import AdItem, GenreStation, GenreStationList, Playlist, PlaylistItem, Station, StationList +from pandora import APIClient + +from pandora.models.pandora import AdItem, GenreStation, GenreStationList, PlaylistItem, Station, StationList import pytest @@ -74,7 +76,7 @@ def config(): def get_backend(config, simulate_request_exceptions=False): - obj = backend.PandoraBackend(config=config, audio=Mock()) + obj = backend.PandoraBackend(config=config, audio=mock.Mock()) if simulate_request_exceptions: type(obj.api.transport).__call__ = request_exception_mock @@ -83,7 +85,7 @@ def get_backend(config, simulate_request_exceptions=False): # running tests type(obj.api.transport).__call__ = transport_call_not_implemented_mock - obj._event_loop = Mock() + obj._event_loop = mock.Mock() return obj @@ -155,36 +157,8 @@ def playlist_result_mock(): # Also add an advertisement to the playlist. { - 'trackToken': None, - 'artistName': None, - 'albumName': None, - 'imageUrl': None, - 'audioUrlMap': { - 'highQuality': { - 'bitrate': '64', - 'encoding': 'aacplus', - 'audioUrl': MOCK_TRACK_AUDIO_HIGH, - 'protocol': 'http' - }, - 'mediumQuality': { - 'bitrate': '64', - 'encoding': 'aacplus', - 'audioUrl': MOCK_TRACK_AUDIO_MED, - 'protocol': 'http' - }, - 'lowQuality': { - 'bitrate': '32', - 'encoding': 'aacplus', - 'audioUrl': MOCK_TRACK_AUDIO_LOW, - 'protocol': 'http' - } - }, - 'trackLength': 0, - 'songName': None, - 'songDetailUrl': None, - 'stationId': None, - 'songRating': None, - 'adToken': MOCK_TRACK_AD_TOKEN} + 'adToken': MOCK_TRACK_AD_TOKEN + }, ])} return mock_result @@ -229,7 +203,10 @@ def ad_metadata_result_mock(): @pytest.fixture(scope='session') def playlist_mock(simulate_request_exceptions=False): - return Playlist.from_json(get_backend(config(), simulate_request_exceptions).api, playlist_result_mock()['result']) + with mock.patch.object(APIClient, '__call__', mock.Mock()) as call_mock: + + call_mock.return_value = playlist_result_mock()['result'] + return get_backend(config(), simulate_request_exceptions).api.get_playlist(MOCK_STATION_TOKEN) @pytest.fixture(scope='session') @@ -281,9 +258,10 @@ def genre_stations_result_mock(): @pytest.fixture(scope='session') def station_list_result_mock(): + quickmix_station_id = MOCK_STATION_ID.replace('1', '2') mock_result = {'stat': 'ok', 'result': {'stations': [ - {'stationId': MOCK_STATION_ID.replace('1', '2'), + {'stationId': quickmix_station_id, 'stationToken': MOCK_STATION_TOKEN.replace('1', '2'), 'stationName': MOCK_STATION_NAME + ' 2'}, {'stationId': MOCK_STATION_ID, @@ -291,7 +269,8 @@ def station_list_result_mock(): 'stationName': MOCK_STATION_NAME + ' 1'}, {'stationId': MOCK_STATION_ID.replace('1', '3'), 'stationToken': MOCK_STATION_TOKEN.replace('1', '3'), - 'stationName': 'QuickMix', 'isQuickMix': True}, + 'stationName': 'QuickMix', 'isQuickMix': True, + 'quickMixStationIds': [quickmix_station_id]}, ], 'checksum': MOCK_STATION_LIST_CHECKSUM}, } diff --git a/tests/test_library.py b/tests/test_library.py index afe0ef5..719c700 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -149,7 +149,6 @@ def test_browse_directory_uri(config): assert len(results) == 4 assert results[0].type == models.Ref.DIRECTORY - assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME assert results[0].uri == PandoraUri('genres').uri assert results[1].type == models.Ref.DIRECTORY @@ -158,16 +157,28 @@ def test_browse_directory_uri(config): Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][2])).uri assert results[2].type == models.Ref.DIRECTORY - assert results[2].name == conftest.MOCK_STATION_NAME + ' 2' assert results[2].uri == StationUri._from_station( Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][0])).uri assert results[3].type == models.Ref.DIRECTORY - assert results[3].name == conftest.MOCK_STATION_NAME + ' 1' assert results[3].uri == StationUri._from_station( Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][1])).uri +def test_browse_directory_marks_quickmix_stations(config): + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): + + quickmix_station_uri = 'pandora:track:{}:{}'.format(conftest.MOCK_STATION_ID.replace('1', '2'), + conftest.MOCK_STATION_TOKEN.replace('1', '2'),) + + backend = conftest.get_backend(config) + results = backend.library.browse(backend.library.root_directory.uri) + + for result in results: + if result.uri == quickmix_station_uri: + assert result.name.endswith('*') + + def test_browse_directory_sort_za(config): with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): @@ -179,7 +190,7 @@ def test_browse_directory_sort_za(config): assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME assert results[1].name.startswith('QuickMix') assert results[2].name == conftest.MOCK_STATION_NAME + ' 1' - assert results[3].name == conftest.MOCK_STATION_NAME + ' 2' + assert results[3].name == conftest.MOCK_STATION_NAME + ' 2' + '*' def test_browse_directory_sort_date(config): @@ -192,7 +203,7 @@ def test_browse_directory_sort_date(config): assert results[0].name == PandoraLibraryProvider.GENRE_DIR_NAME assert results[1].name.startswith('QuickMix') - assert results[2].name == conftest.MOCK_STATION_NAME + ' 2' + assert results[2].name == conftest.MOCK_STATION_NAME + ' 2' + '*' assert results[3].name == conftest.MOCK_STATION_NAME + ' 1' @@ -205,6 +216,12 @@ def test_browse_genres(config): assert results[0].name == 'Category mock' +def test_browse_raises_exception_for_unsupported_uri_type(config): + with pytest.raises(NotImplementedError): + backend = conftest.get_backend(config) + backend.library.browse('pandora:invalid_uri') + + def test_browse_genre_category(config): with mock.patch.object(MopidyAPIClient, 'get_genre_stations', conftest.get_genre_stations_mock): @@ -244,3 +261,19 @@ def test_browse_station_uri(config, station_mock): results = backend.library.browse(station_uri.uri) # Station should just contain the first track to be played. assert len(results) == 1 + + +def test_browse_station_uri_renames_advertisements(config, station_mock): + with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): + with mock.patch.object(Station, 'get_playlist', mock.Mock()) as get_playlist_mock: + + backend = conftest.get_backend(config) + station_uri = StationUri._from_station(station_mock) + + playlist = conftest.playlist_mock() + playlist.pop(0) + get_playlist_mock.return_value = iter(playlist) + results = backend.library.browse(station_uri.uri) + # Station should just contain the first track to be played. + assert len(results) == 1 + assert results[0].name == 'Advertisement' diff --git a/tests/test_uri.py b/tests/test_uri.py index ba14222..443d80a 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -110,7 +110,7 @@ def test_pandora_parse_none_mock_uri(): def test_pandora_parse_invalid_type_raises_exception(): with pytest.raises(NotImplementedError): - PandoraUri()._from_uri('pandora:invalid') + PandoraUri()._from_uri('pandora:invalid_uri') def test_pandora_parse_invalid_scheme_raises_exception(): From 872fea67b6173c921d84cec940de9ce588448fad Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 5 Jan 2016 12:07:06 +0200 Subject: [PATCH 190/311] Fixes and tests for refreshing directories. --- mopidy_pandora/library.py | 17 +++------- tests/test_library.py | 68 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 47ccfec..324735c 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -189,16 +189,7 @@ def get_next_pandora_track(self, station_id): try: station_iter = self.pandora_station_cache[station_id].iter pandora_track = next(station_iter) - # except requests.exceptions.RequestException as e: - # logger.error('Error retrieving next Pandora track: {}'.format(encoding.locale_decode(e))) - # return None - # except StopIteration: - # # TODO: workaround for https://github.com/mcrute/pydora/issues/36 - # logger.error("Failed to retrieve next track for station '{}' from Pandora server".format( - # self._station.name)) - # return None except Exception: - # TODO: Remove this catch-all exception once we've figured out how to deal with all of them logger.exception('Error retrieving next Pandora track.') return None @@ -216,14 +207,16 @@ def get_next_pandora_track(self, station_id): def refresh(self, uri=None): if not uri or uri == self.root_directory.uri: self.backend.api.get_station_list(force_refresh=True) - self.backend.api.get_genre_stations(force_refresh=True) - elif uri == self.genre_directory: + 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(uri.station_id) + 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)) diff --git a/tests/test_library.py b/tests/test_library.py index 719c700..63aba87 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -12,7 +12,7 @@ import pytest from mopidy_pandora.client import MopidyAPIClient -from mopidy_pandora.library import PandoraLibraryProvider, TrackCacheItem +from mopidy_pandora.library import PandoraLibraryProvider, StationCacheItem, TrackCacheItem from mopidy_pandora.uri import GenreUri, PandoraUri, PlaylistItemUri, StationUri @@ -277,3 +277,69 @@ def test_browse_station_uri_renames_advertisements(config, station_mock): # Station should just contain the first track to be played. assert len(results) == 1 assert results[0].name == 'Advertisement' + + +def test_refresh_without_uri_refreshes_root(config): + backend = conftest.get_backend(config) + backend.api.get_station_list = mock.Mock() + backend.api.get_genre_stations = mock.Mock() + + backend.library.refresh() + backend.api.get_station_list.assert_called_with(force_refresh=True) + assert not backend.api.get_genre_stations.called + + +def test_refresh_root_directory(config): + backend = conftest.get_backend(config) + backend.api.get_station_list = mock.Mock() + backend.api.get_genre_stations = mock.Mock() + + backend.library.refresh(backend.library.root_directory.uri) + backend.api.get_station_list.assert_called_with(force_refresh=True) + assert not backend.api.get_genre_stations.called + + +def test_refresh_genre_directory(config): + backend = conftest.get_backend(config) + backend.api.get_station_list = mock.Mock() + backend.api.get_genre_stations = mock.Mock() + + backend.library.refresh(backend.library.genre_directory.uri) + backend.api.get_genre_stations.assert_called_with(force_refresh=True) + assert not backend.api.get_station_list.called + + +def test_refresh_station_directory_invalid_uri_type_raises_exception(config): + with pytest.raises(ValueError): + backend = conftest.get_backend(config) + backend.api.get_station_list = mock.Mock() + backend.api.get_genre_stations = mock.Mock() + + backend.library.refresh('pandora:track:id_token_mock:id_token_mock') + + +def test_refresh_station_directory(config): + backend = conftest.get_backend(config) + backend.api.get_station_list = mock.Mock() + backend.api.get_genre_stations = mock.Mock() + + station_mock = mock.Mock(spec=Station) + station_mock.id = 'id_token_mock' + station_mock.id = 'id_token_mock' + backend.library.pandora_station_cache[station_mock.id] = StationCacheItem(station_mock, iter([])) + + backend.library.refresh('pandora:station:id_token_mock:id_token_mock') + assert backend.library.pandora_station_cache.currsize == 0 + assert not backend.api.get_station_list.called + assert not backend.api.get_genre_stations.called + + +def test_refresh_station_directory_not_in_cache_handles_key_error(config): + backend = conftest.get_backend(config) + backend.api.get_station_list = mock.Mock() + backend.api.get_genre_stations = mock.Mock() + + backend.library.refresh('pandora:station:id_token_mock:id_token_mock') + assert backend.library.pandora_station_cache.currsize == 0 + assert not backend.api.get_station_list.called + assert not backend.api.get_genre_stations.called From 24d9f0abf86377f0cb8a913bb53e6f95c2966428 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 5 Jan 2016 12:16:49 +0200 Subject: [PATCH 191/311] Test cases for successful track changes. --- tests/test_playback.py | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/tests/test_playback.py b/tests/test_playback.py index 3b54e14..7d08f68 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -5,6 +5,7 @@ from mopidy import audio, models from pandora import APITransport +from pandora.models.pandora import PlaylistItem import pytest @@ -41,9 +42,6 @@ def test_change_track_enforces_skip_limit_if_no_track_available(provider, playli with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=None): track = PandoraUri.factory(playlist_item_mock) - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} - provider.next_tl_track = {'track': {'uri': track.uri}} - provider._trigger_track_unplayable = mock.PropertyMock() provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) @@ -65,9 +63,6 @@ def test_change_track_enforces_skip_limit_if_no_audio_url(provider, playlist_ite with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): track = PandoraUri.factory(playlist_item_mock) - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} - provider.next_tl_track = {'track': {'uri': track.uri}} - provider._trigger_track_unplayable = mock.PropertyMock() provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) @@ -92,9 +87,6 @@ def test_change_track_enforces_skip_limit_on_request_exceptions(provider, playli with mock.patch.object(APITransport, '__call__', side_effect=conftest.request_exception_mock): track = PandoraUri.factory(playlist_item_mock) - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} - provider.next_tl_track = {'track': {'uri': track.uri}} - provider._trigger_track_unplayable = mock.PropertyMock() provider._trigger_skip_limit_exceeded = mock.PropertyMock(0) playlist_item_mock.audio_url = 'pandora:track:mock_id:mock_token' @@ -117,9 +109,6 @@ def test_change_track_fetches_next_track_if_unplayable(provider, playlist_item_m with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=None): track = PandoraUri.factory(playlist_item_mock) - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} - provider.next_tl_track = {'track': {'uri': track.uri}} - provider._trigger_track_unplayable = mock.PropertyMock() assert provider.change_track(track) is False @@ -139,17 +128,35 @@ def test_change_track_skips_if_no_track_uri(provider): def test_change_track_skips_if_track_not_available_in_buffer(provider, playlist_item_mock, caplog): track = PandoraUri.factory(playlist_item_mock) - provider.previous_tl_track = {'track': {'uri': 'previous_track'}} - provider.next_tl_track = {'track': {'uri': track.uri}} - provider.backend.prepare_next_track = mock.PropertyMock() assert provider.change_track(track) is False assert "Error changing Pandora track: failed to lookup '{}'.".format(track.uri) in caplog.text() -def test_translate_uri_returns_audio_url(provider, playlist_item_mock): +def test_change_track_resets_skips_on_success(provider, playlist_item_mock): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): + with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=True): + track = PandoraUri.factory(playlist_item_mock) + + provider._consecutive_track_skips = 1 + + assert provider.change_track(track) is True + assert provider._consecutive_track_skips == 0 + +def test_change_track_triggers_event_on_success(provider, playlist_item_mock): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', return_value=playlist_item_mock): + with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=True): + track = PandoraUri.factory(playlist_item_mock) + + provider._trigger_track_changed = mock.PropertyMock() + + assert provider.change_track(track) is True + assert provider._trigger_track_changed.called + + +def test_translate_uri_returns_audio_url(provider, playlist_item_mock): test_uri = 'pandora:track:test_station_id:test_token' provider.backend.library.pandora_track_cache[test_uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), playlist_item_mock) @@ -169,12 +176,10 @@ def test_resume_click_ignored_if_start_of_track(provider): def add_artist_bookmark(provider): - provider.add_artist_bookmark(conftest.MOCK_TRACK_TOKEN) provider.client.add_artist_bookmark.assert_called_once_with(conftest.MOCK_TRACK_TOKEN) def add_song_bookmark(provider): - provider.add_song_bookmark(conftest.MOCK_TRACK_TOKEN) provider.client.add_song_bookmark.assert_called_once_with(conftest.MOCK_TRACK_TOKEN) From 46bdbeeb161f42cbb9c83928f2ab42b94416e831 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 5 Jan 2016 13:49:03 +0200 Subject: [PATCH 192/311] More test cases for backend and library. --- mopidy_pandora/backend.py | 3 +- mopidy_pandora/library.py | 1 - tests/test_backend.py | 83 +++++++++++++++++++++++++++++++++++++-- tests/test_library.py | 57 +++++++++++++++++++-------- 4 files changed, 123 insertions(+), 21 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 5678996..cfae13c 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -56,7 +56,7 @@ def on_start(self): logger.exception('Error logging in to Pandora.') def end_of_tracklist_reached(self, station_id=None, auto_play=False): - self.prepare_next_track(station_id, auto_play=auto_play) + 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) @@ -75,6 +75,7 @@ def process_event(self, track_uri, pandora_event): .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 diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 324735c..023ed55 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -200,7 +200,6 @@ def get_next_pandora_track(self, station_id): track_name = pandora_track.song_name track = models.Ref.track(name=track_name, uri=track_uri.uri) - self.pandora_track_cache[track_uri.uri] = TrackCacheItem(track, pandora_track) return track diff --git a/tests/test_backend.py b/tests/test_backend.py index 2081f24..0bdea5a 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -3,10 +3,14 @@ import mock from mopidy import backend as backend_api +from mopidy import models from pandora import APIClient, BaseAPIClient +from pandora.errors import PandoraException from mopidy_pandora import client, library, playback +from mopidy_pandora.backend import PandoraBackend +from mopidy_pandora.library import PandoraLibraryProvider from tests.conftest import get_backend, get_station_list_mock, request_exception_mock @@ -28,6 +32,22 @@ def test_init_sets_up_the_providers(config): assert isinstance(backend.playback, backend_api.PlaybackProvider) +def test_end_of_tracklist_reached_prepares_next_track(config): + backend = get_backend(config) + + backend.prepare_next_track = mock.Mock() + backend.end_of_tracklist_reached('id_token_mock', False) + backend.prepare_next_track.assert_called_with('id_token_mock', False) + + +def test_event_triggered_processes_event(config): + backend = get_backend(config) + + backend.process_event = mock.Mock() + backend.event_triggered('pandora:track:id_token_mock:id_token_mock', 'thumbs_up') + backend.process_event.assert_called_with('pandora:track:id_token_mock:id_token_mock', 'thumbs_up') + + def test_init_sets_preferred_audio_quality(config): config['pandora']['preferred_audio_quality'] = 'lowQuality' backend = get_backend(config) @@ -38,7 +58,7 @@ def test_init_sets_preferred_audio_quality(config): def test_on_start_logs_in(config): backend = get_backend(config) - login_mock = mock.PropertyMock() + login_mock = mock.Mock() backend.api.login = login_mock t = backend.on_start() t.join() @@ -50,8 +70,8 @@ def test_on_start_pre_fetches_lists(config): with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): backend = get_backend(config) - backend.api.login = mock.PropertyMock() - backend.api.get_genre_stations = mock.PropertyMock() + backend.api.login = mock.Mock() + backend.api.get_genre_stations = mock.Mock() assert backend.api.station_list_cache.currsize == 0 assert backend.api.genre_stations_cache.currsize == 0 @@ -72,3 +92,60 @@ def test_on_start_handles_request_exception(config, caplog): # Check that request exceptions are caught and logged assert 'Error logging in to Pandora' in caplog.text() + + +def test_prepare_next_track_triggers_event(config): + with mock.patch.object(PandoraLibraryProvider, + 'get_next_pandora_track', + mock.Mock()) as get_next_pandora_track_mock: + + backend = get_backend(config) + + backend.prepare_next_track('id_token_mock') + track = models.Ref.track(name='name_mock', uri='pandora:track:id_token_mock:id_token_mock') + get_next_pandora_track_mock.return_value = track + backend._trigger_next_track_available = mock.Mock() + backend.end_of_tracklist_reached() + + backend._trigger_next_track_available.assert_called_with(track, False) + + +def test_process_event_calls_method(config, caplog): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', mock.Mock()): + with mock.patch.object(APIClient, '__call__', mock.Mock()) as mock_call: + + backend = get_backend(config) + uri_mock = 'pandora:track:id_token_mock:id_token_mock' + backend._trigger_event_processed = mock.Mock() + + for event in ['thumbs_up', 'thumbs_down', 'sleep', 'add_artist_bookmark', + 'add_song_bookmark', 'delete_station']: + + if event == 'delete_station': + backend.library.refresh = mock.Mock() + backend.library.browse = mock.Mock() + + backend.process_event(uri_mock, event) + + assert mock_call.called + mock_call.reset() + assert backend._trigger_event_processed.called + backend._trigger_event_processed.reset() + + assert "Triggering event '{}'".format(event) in caplog.text() + + +def test_process_event_handles_pandora_exception(config, caplog): + with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', mock.Mock()): + with mock.patch.object(PandoraBackend, 'thumbs_up', mock.Mock()) as mock_call: + + backend = get_backend(config) + uri_mock = 'pandora:track:id_token_mock:id_token_mock' + backend._trigger_event_processed = mock.Mock() + mock_call.side_effect = PandoraException('exception_mock') + + assert not backend.process_event(uri_mock, 'thumbs_up') + assert mock_call.called + assert not backend._trigger_event_processed.called + + assert 'Error calling Pandora event: thumbs_up.' in caplog.text() diff --git a/tests/test_library.py b/tests/test_library.py index 63aba87..5027aef 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -70,6 +70,47 @@ def test_get_images_for_track_with_images(config, playlist_item_mock): assert results[track_uri.uri][0].uri == playlist_item_mock.album_art_url +def test_get_next_pandora_track_fetches_track(config, playlist_item_mock): + backend = conftest.get_backend(config) + + station_mock = mock.Mock(spec=Station) + station_mock.id = 'id_token_mock' + station_mock.id = 'id_token_mock' + backend.library.pandora_station_cache[station_mock.id] = StationCacheItem(station_mock, iter([playlist_item_mock])) + + track = backend.library.get_next_pandora_track('id_token_mock') + assert track.uri == PandoraUri.factory(playlist_item_mock).uri + assert backend.library.pandora_track_cache[track.uri].track_ref == track + assert backend.library.pandora_track_cache[track.uri].pandora_track == playlist_item_mock + + +def test_get_next_pandora_track_handles_no_more_tracks_available(config, caplog): + backend = conftest.get_backend(config) + + station_mock = mock.Mock(spec=Station) + station_mock.id = 'id_token_mock' + station_mock.id = 'id_token_mock' + backend.library.pandora_station_cache[station_mock.id] = StationCacheItem(station_mock, iter([])) + + track = backend.library.get_next_pandora_track('id_token_mock') + assert track is None + assert 'Error retrieving next Pandora track.' in caplog.text() + + +def test_get_next_pandora_track_renames_advertisements(config, station_mock): + with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): + with mock.patch.object(Station, 'get_playlist', mock.Mock()) as get_playlist_mock: + + backend = conftest.get_backend(config) + + playlist = conftest.playlist_mock() + playlist.pop(0) + get_playlist_mock.return_value = iter(playlist) + + track = backend.library.get_next_pandora_track(station_mock.id) + assert track.name == 'Advertisement' + + def test_lookup_of_invalid_uri(config): with pytest.raises(NotImplementedError): backend = conftest.get_backend(config) @@ -263,22 +304,6 @@ def test_browse_station_uri(config, station_mock): assert len(results) == 1 -def test_browse_station_uri_renames_advertisements(config, station_mock): - with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): - with mock.patch.object(Station, 'get_playlist', mock.Mock()) as get_playlist_mock: - - backend = conftest.get_backend(config) - station_uri = StationUri._from_station(station_mock) - - playlist = conftest.playlist_mock() - playlist.pop(0) - get_playlist_mock.return_value = iter(playlist) - results = backend.library.browse(station_uri.uri) - # Station should just contain the first track to be played. - assert len(results) == 1 - assert results[0].name == 'Advertisement' - - def test_refresh_without_uri_refreshes_root(config): backend = conftest.get_backend(config) backend.api.get_station_list = mock.Mock() From ddd3eec1daeb5dc515cb78676ccd876065da8577 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 5 Jan 2016 18:09:34 +0200 Subject: [PATCH 193/311] Fixes and test cases for frontend and library. --- mopidy_pandora/frontend.py | 18 ++-- mopidy_pandora/library.py | 52 +++++------ tests/test_frontend.py | 183 +++++++++++++++++++++++++------------ tests/test_library.py | 8 +- 4 files changed, 163 insertions(+), 98 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 8a6fc03..a6ced74 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -113,7 +113,7 @@ def is_end_of_tracklist_reached(self, track=None): return track_index == length - 1 - def is_station_changed(self, track=None): + 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: @@ -124,16 +124,12 @@ def is_station_changed(self, track=None): return False def track_changed(self, track): - try: - if self.is_station_changed(track): - # Another frontend has added a track, remove all tracks after it - self._trim_tracklist(around_track=track) - if self.is_end_of_tracklist_reached(track): - self._trigger_end_of_tracklist_reached(PandoraUri.factory(track).station_id, - auto_play=False) - except IndexError: - # No tracks in history, continue - pass + if self.is_station_changed(track): + # Another frontend has added a track, remove all other tracks from the tracklist + self._trim_tracklist(around_track=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): diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 023ed55..c35ea47 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) StationCacheItem = namedtuple('StationCacheItem', 'station, iter') -TrackCacheItem = namedtuple('TrackCacheItem', 'track_ref, pandora_track') +TrackCacheItem = namedtuple('TrackCacheItem', 'ref, track') class PandoraLibraryProvider(backend.LibraryProvider): @@ -55,7 +55,7 @@ def lookup(self, uri): pandora_uri = PandoraUri.factory(uri) if isinstance(pandora_uri, TrackUri): try: - pandora_track = self.lookup_pandora_track(uri) + track = self.lookup_pandora_track(uri) except KeyError: logger.exception("Failed to lookup Pandora URI '{}'.".format(uri)) return [] @@ -71,22 +71,22 @@ def lookup(self, uri): if type(pandora_uri) is AdItemUri: track_kwargs['name'] = 'Advertisement' - if not pandora_track.title: - pandora_track.title = '(Title not specified)' - artist_kwargs['name'] = pandora_track.title + if not track.title: + track.title = '(Title not specified)' + artist_kwargs['name'] = track.title - if not pandora_track.company_name: - pandora_track.company_name = '(Company name not specified)' - album_kwargs['name'] = pandora_track.company_name + if not track.company_name: + track.company_name = '(Company name not specified)' + album_kwargs['name'] = track.company_name - album_kwargs['uri'] = pandora_track.click_through_url + album_kwargs['uri'] = track.click_through_url else: - track_kwargs['name'] = pandora_track.song_name - track_kwargs['length'] = pandora_track.track_length * 1000 - track_kwargs['bitrate'] = int(pandora_track.bitrate) - artist_kwargs['name'] = pandora_track.artist_name - album_kwargs['name'] = pandora_track.album_name - album_kwargs['uri'] = pandora_track.album_detail_url + 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)) @@ -99,11 +99,11 @@ def get_images(self, uris): for uri in uris: image_uris = set() try: - pandora_track = self.lookup_pandora_track(uri) - if pandora_track.is_ad is True: - image_uri = pandora_track.image_url + track = self.lookup_pandora_track(uri) + if track.is_ad is True: + image_uri = track.image_url else: - image_uri = pandora_track.album_art_url + image_uri = track.album_art_url if image_uri: image_uris.update([image_uri]) except (TypeError, KeyError): @@ -174,7 +174,7 @@ def _browse_genre_stations(self, uri): [PandoraUri.factory(uri).category_name]] def lookup_pandora_track(self, uri): - return self.pandora_track_cache[uri].pandora_track + return self.pandora_track_cache[uri].track def get_station_cache_item(self, station_id): if GenreStationUri.pattern.match(station_id): @@ -188,20 +188,20 @@ def get_station_cache_item(self, station_id): def get_next_pandora_track(self, station_id): try: station_iter = self.pandora_station_cache[station_id].iter - pandora_track = next(station_iter) + track = next(station_iter) except Exception: logger.exception('Error retrieving next Pandora track.') return None - track_uri = PandoraUri.factory(pandora_track) + track_uri = PandoraUri.factory(track) if type(track_uri) is AdItemUri: track_name = 'Advertisement' else: - track_name = pandora_track.song_name + track_name = track.song_name - track = models.Ref.track(name=track_name, uri=track_uri.uri) - self.pandora_track_cache[track_uri.uri] = TrackCacheItem(track, pandora_track) - return track + 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: diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 60cfb3f..f470988 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -8,43 +8,51 @@ from mopidy import core -from mopidy.models import Track +from mopidy import models +from mopidy.audio import PlaybackState import pykka from mopidy_pandora import frontend -from mopidy_pandora.frontend import EventHandlingPandoraFrontend, PandoraFrontend, PandoraFrontendFactory - from tests import conftest, dummy_backend from tests.dummy_backend import DummyBackend, DummyPandoraBackend class TestPandoraFrontendFactory(unittest.TestCase): - def test_events_supported_returns_event_handler_frontend(self): config = conftest.config() config['pandora']['event_support_enabled'] = True - frontend = PandoraFrontendFactory(config, mock.PropertyMock()) + f = frontend.PandoraFrontendFactory(config, mock.PropertyMock()) - assert type(frontend) is EventHandlingPandoraFrontend + assert type(f) is frontend.EventHandlingPandoraFrontend def test_events_not_supported_returns_regular_frontend(self): config = conftest.config() config['pandora']['event_support_enabled'] = False - frontend = PandoraFrontendFactory(config, mock.PropertyMock()) + f = frontend.PandoraFrontendFactory(config, mock.PropertyMock()) + + assert type(f) is frontend.PandoraFrontend - assert type(frontend) is PandoraFrontend +class BaseTest(unittest.TestCase): + config = {'core': {'max_tracklist_length': 10000}} -class BaseTestFrontend(unittest.TestCase): + tracks = [ + models.Track(uri='pandora:track:id_mock:token_mock1', length=40000), # Regular track + models.Track(uri='pandora:ad:id_mock:token_mock2', length=40000), # Advertisement + models.Track(uri='mock:track:id_mock:token_mock3', length=40000), # Not a pandora track + models.Track(uri='pandora:track:id_mock_other:token_mock4', length=40000), # Different station + models.Track(uri='pandora:track:id_mock:token_mock5', length=None), # No duration + ] + + uris = [ + 'pandora:track:id_mock:token_mock1', 'pandora:ad:id_mock:token_mock2', + 'mock:track:id_mock:token_mock3', 'pandora:track:id_mock_other:token_mock4', + 'pandora:track:id_mock:token_mock5'] def setUp(self): - config = { - 'core': { - 'max_tracklist_length': 10000, - } - } + config = {'core': {'max_tracklist_length': 10000}} self.backend = dummy_backend.create_proxy(DummyPandoraBackend) self.non_pandora_backend = dummy_backend.create_proxy(DummyBackend) @@ -52,19 +60,6 @@ def setUp(self): self.core = core.Core.start( config, backends=[self.backend, self.non_pandora_backend]).proxy() - self.tracks = [ - Track(uri='pandora:track:mock_id1:mock_token1', length=40000), # Regular track - Track(uri='pandora:ad:mock_id2:mock_token2', length=40000), # Advertisement - Track(uri='mock:track:mock_id3:mock_token3', length=40000), # Not a pandora track - Track(uri='pandora:track:mock_id4:mock_token4', length=40000), - Track(uri='pandora:track:mock_id5:mock_token5', length=None), # No duration - ] - - self.uris = [ - 'pandora:track:mock_id1:mock_token1', 'pandora:ad:mock_id2:mock_token2', - 'mock:track:mock_id3:mock_token3', 'pandora:track:mock_id4:mock_token4', - 'pandora:track:mock_id5:mock_token5'] - def lookup(uris): result = {uri: [] for uri in uris} for track in self.tracks: @@ -75,14 +70,31 @@ def lookup(uris): self.core.library.lookup = lookup self.tl_tracks = self.core.tracklist.add(uris=self.uris).get() + self.events = [] + self.patcher = mock.patch('mopidy.listener.send') + self.send_mock = self.patcher.start() + + def send(cls, event, **kwargs): + self.events.append((event, kwargs)) + + self.send_mock.side_effect = send + def tearDown(self): pykka.ActorRegistry.stop_all() + self.patcher.stop() + def replay_events(self, until=None): + while self.events: + if self.events[0][0] == until: + break + event, kwargs = self.events.pop(0) + self.core.on_event(event, **kwargs) -class TestFrontend(BaseTestFrontend): +class TestFrontend(BaseTest): def setUp(self): # noqa: N802 - return super(TestFrontend, self).setUp() + super(TestFrontend, self).setUp() + self.frontend = frontend.PandoraFrontend.start(conftest.config(), self.core).proxy() def tearDown(self): # noqa: N802 super(TestFrontend, self).tearDown() @@ -108,69 +120,126 @@ def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): assert not func_mock.called def test_set_options_performs_auto_setup(self): - self.core.tracklist.set_repeat(False).get() - self.core.tracklist.set_consume(True).get() + self.core.tracklist.set_repeat(True).get() + self.core.tracklist.set_consume(False).get() self.core.tracklist.set_random(True).get() self.core.tracklist.set_single(True).get() self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - frontend = PandoraFrontend.start(conftest.config(), self.core).proxy() - frontend.track_playback_started(self.tracks[0]).get() + assert self.frontend.setup_required.get() + self.frontend.track_playback_started(self.tracks[0]).get() assert self.core.tracklist.get_repeat().get() is False assert self.core.tracklist.get_consume().get() is True assert self.core.tracklist.get_random().get() is False assert self.core.tracklist.get_single().get() is False + assert not self.frontend.setup_required.get() + + def test_skip_limit_exceed_stops_playback(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + assert self.core.playback.get_state().get() == PlaybackState.PLAYING + + self.frontend.skip_limit_exceeded().get() + assert self.core.playback.get_state().get() == PlaybackState.STOPPED + + def test_is_end_of_tracklist_reached(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + assert not self.frontend.is_end_of_tracklist_reached().get() + + def test_is_end_of_tracklist_reached_last_track(self): + self.core.playback.play(tlid=self.tl_tracks[4].tlid).get() + + assert self.frontend.is_end_of_tracklist_reached().get() + def test_is_end_of_tracklist_reached_no_tracks(self): + self.core.tracklist.clear().get() -class TestEventHandlingFrontend(BaseTestFrontend): + assert self.frontend.is_end_of_tracklist_reached().get() + def test_is_end_of_tracklist_reached_second_last_track(self): + self.core.playback.play(tlid=self.tl_tracks[3].tlid).get() + + assert not self.frontend.is_end_of_tracklist_reached(self.tl_tracks[3].track).get() + + def test_is_station_changed(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.next().get() # Add track to history + + assert self.frontend.is_station_changed(self.tl_tracks[3].track).get() + + def test_is_station_changed_no_history(self): + assert not self.frontend.is_station_changed(self.tl_tracks[0].track).get() + + def test_track_changed_no_op(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.next().get() # Add track to history + + assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) + + self.frontend.track_changed(self.tl_tracks[1].track).get() + assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) + assert len(self.events) == 0 + + def test_track_changed_station_changed(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.next().get() # Add track to history + + assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) + + self.frontend.track_changed(self.tl_tracks[3].track).get() + tl_tracks = self.core.tracklist.get_tl_tracks().get() + assert len(tl_tracks) == 1 + assert tl_tracks[0] == self.tl_tracks[3] + + assert self.events[0] == ('end_of_tracklist_reached', {'station_id': 'id_mock_other', 'auto_play': False}) + + +class TestEventHandlingFrontend(BaseTest): def setUp(self): # noqa: N802 super(TestEventHandlingFrontend, self).setUp() + self.frontend = frontend.EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() def tearDown(self): # noqa: N802 super(TestEventHandlingFrontend, self).tearDown() +# TODO: called is broken def test_process_events_ignores_ads(self): self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() - frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - frontend._trigger_event_triggered = mock.PropertyMock() - frontend.check_doubleclicked(action='resume').get() + self.frontend._trigger_event_triggered = mock.PropertyMock() + self.frontend.check_doubleclicked(action='resume').get() - assert not frontend._trigger_event_triggered.called + assert not self.frontend._trigger_event_triggered.called def test_pause_starts_double_click_timer(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - assert frontend.get_click_time().get() == 0 - frontend.track_playback_paused(mock.Mock(), 100).get() - assert frontend.get_click_time().get() > 0 + assert self.frontend.get_click_time().get() == 0 + self.frontend.track_playback_paused(mock.Mock(), 100).get() + assert self.frontend.get_click_time().get() > 0 def test_pause_does_not_start_timer_at_track_start(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - assert frontend.get_click_time().get() == 0 - frontend.track_playback_paused(mock.Mock(), 0).get() - assert frontend.get_click_time().get() == 0 + assert self.frontend.get_click_time().get() == 0 + self.frontend.track_playback_paused(mock.Mock(), 0).get() + assert self.frontend.get_click_time().get() == 0 +# TODO: called is broken def test_process_events_handles_exception(self): - with mock.patch.object(EventHandlingPandoraFrontend, '_get_event_targets', + with mock.patch.object(frontend.EventHandlingPandoraFrontend, '_get_event_targets', mock.PropertyMock(return_value=None, side_effect=ValueError('error_mock'))): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - frontend = EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - frontend._trigger_event_triggered = mock.PropertyMock() - frontend.check_doubleclicked(action='resume').get() + self.frontend._trigger_event_triggered = mock.PropertyMock() + self.frontend.check_doubleclicked(action='resume').get() - assert not frontend._trigger_event_triggered.called + assert not self.frontend._trigger_event_triggered.called def test_is_double_click(self): + static_frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), self.core) + static_frontend.set_click_time() + assert static_frontend._is_double_click() - frontend = EventHandlingPandoraFrontend(conftest.config(), self.core) - frontend.set_click_time() - assert frontend._is_double_click() - - time.sleep(float(frontend.double_click_interval) + 0.1) - assert frontend._is_double_click() is False + time.sleep(float(static_frontend.double_click_interval) + 0.1) + assert static_frontend._is_double_click() is False diff --git a/tests/test_library.py b/tests/test_library.py index 5027aef..9993b61 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -78,10 +78,10 @@ def test_get_next_pandora_track_fetches_track(config, playlist_item_mock): station_mock.id = 'id_token_mock' backend.library.pandora_station_cache[station_mock.id] = StationCacheItem(station_mock, iter([playlist_item_mock])) - track = backend.library.get_next_pandora_track('id_token_mock') - assert track.uri == PandoraUri.factory(playlist_item_mock).uri - assert backend.library.pandora_track_cache[track.uri].track_ref == track - assert backend.library.pandora_track_cache[track.uri].pandora_track == playlist_item_mock + ref = backend.library.get_next_pandora_track('id_token_mock') + assert ref.uri == PandoraUri.factory(playlist_item_mock).uri + assert backend.library.pandora_track_cache[ref.uri].ref == ref + assert backend.library.pandora_track_cache[ref.uri].track == playlist_item_mock def test_get_next_pandora_track_handles_no_more_tracks_available(config, caplog): From 80fe202650dfe3411db058fb901bdccf1927b0a1 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 5 Jan 2016 18:42:45 +0200 Subject: [PATCH 194/311] Refactored frontend tracklist trimming logic. More test cases. --- mopidy_pandora/frontend.py | 12 ++++-------- tests/test_frontend.py | 28 ++++++++++++++++++++++------ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index a6ced74..8e505b0 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -126,7 +126,7 @@ def is_station_changed(self, track): def track_changed(self, track): if self.is_station_changed(track): # Another frontend has added a track, remove all other tracks from the tracklist - self._trim_tracklist(around_track=track) + 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) @@ -157,14 +157,10 @@ def add_track(self, track, auto_play=False): self.core.playback.play(tl_tracks[-1]).get() self._trim_tracklist(maxsize=2) - def _trim_tracklist(self, around_track=None, maxsize=2): + def _trim_tracklist(self, keep_only=None, maxsize=2): tl_tracks = self.core.tracklist.get_tl_tracks().get() - if around_track: - trim_tlids = [] - for t in tl_tracks: - if t.track.uri != around_track.uri: - trim_tlids.append(t.tlid) - + if keep_only: + trim_tlids = [t.tlid for t in tl_tracks if t.track.uri != keep_only.uri] return len(self.core.tracklist.remove({'tlid': trim_tlids}).get()) elif len(tl_tracks) > maxsize: diff --git a/tests/test_frontend.py b/tests/test_frontend.py index f470988..65a0655 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -99,6 +99,26 @@ def setUp(self): # noqa: N802 def tearDown(self): # noqa: N802 super(TestFrontend, self).tearDown() + def test_add_track_starts_playback(self): + new_track = models.Track(uri='pandora:track:id_mock:new_token_mock', length=40000) + self.tracks.append(new_track) # Add to internal list for lookup to work + + assert self.core.playback.get_state().get() == PlaybackState.STOPPED + self.frontend.add_track(new_track, auto_play=True).get() + + assert self.core.playback.get_state().get() == PlaybackState.PLAYING + assert self.core.playback.get_current_track().get() == new_track + + def test_add_track_trims_tracklist(self): + new_track = models.Track(uri='pandora:track:id_mock:new_token_mock', length=40000) + self.tracks.append(new_track) # Add to internal list for lookup to work + + assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) + self.frontend.add_track(new_track).get() + tl_tracks = self.core.tracklist.get_tl_tracks().get() + assert len(tl_tracks) == 2 + assert tl_tracks[-1].track == new_track + def test_only_execute_for_pandora_executes_for_pandora_uri(self): func_mock = mock.PropertyMock() func_mock.__name__ = str('func_mock') @@ -202,14 +222,11 @@ def setUp(self): # noqa: N802 def tearDown(self): # noqa: N802 super(TestEventHandlingFrontend, self).tearDown() -# TODO: called is broken def test_process_events_ignores_ads(self): self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() - self.frontend._trigger_event_triggered = mock.PropertyMock() self.frontend.check_doubleclicked(action='resume').get() - - assert not self.frontend._trigger_event_triggered.called + assert len(self.events) == 0 # Check that no events were triggered def test_pause_starts_double_click_timer(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -225,7 +242,6 @@ def test_pause_does_not_start_timer_at_track_start(self): self.frontend.track_playback_paused(mock.Mock(), 0).get() assert self.frontend.get_click_time().get() == 0 -# TODO: called is broken def test_process_events_handles_exception(self): with mock.patch.object(frontend.EventHandlingPandoraFrontend, '_get_event_targets', mock.PropertyMock(return_value=None, side_effect=ValueError('error_mock'))): @@ -234,7 +250,7 @@ def test_process_events_handles_exception(self): self.frontend._trigger_event_triggered = mock.PropertyMock() self.frontend.check_doubleclicked(action='resume').get() - assert not self.frontend._trigger_event_triggered.called + assert len(self.events) == 0 # Check that no events were triggered def test_is_double_click(self): static_frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), self.core) From 11754b2215b267cfc01e96f396d132e80f54ce8c Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 6 Jan 2016 06:36:37 +0200 Subject: [PATCH 195/311] Update README. Split out changelog. --- CHANGES.rst | 81 ++++++++++++++++++++++++++++++++++++ README.rst | 117 +++++++++++++--------------------------------------- 2 files changed, 109 insertions(+), 89 deletions(-) create mode 100644 CHANGES.rst diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 0000000..2104e11 --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,81 @@ +Changelog +========= + +v0.2.0 (UNRELEASED) +------------------- + +**Features and improvements** + +- Now displays all of the correct track information during playback (e.g. song and artist names, album covers, track + length, bitrate etc.). +- Simulate dynamic tracklist (workaround for `#2 `_) +- Add support for browsing genre stations. Note that clicking on a genre station will automatically add that station to + your profile. +- Add ability to delete a station by setting one of the doubleclick event parameters to ``delete_station``. +- Move 'QuickMix' to the top of the station list. Stations that will be played as part of QuickMix are marked with an + asterisk (*). +- Scrobbling tracks to Last.fm is now supported. +- Station lists are now cached which speeds up startup and browsing of the list of stations dramatically. Configuration + parameter ``cache_time_to_live`` can be used to specify when cache items should expire and be refreshed (in seconds). +- Force Mopidy to stop when skip limit is exceeded (workaround for `#1221 `_). +- Now plays advertisements which should prevent non-Pandora One accounts from being locked after extended use. +- Tracks are now played in ``consume`` instead of ``repeat`` mode. This is more in line with how Pandora deals with + track playback. It also avoids infinite loops on unplayable tracks, which is still an issue in Mopidy 1.1.2. + +v0.1.7 (Oct 31, 2015) +--------------------- + +- Configuration parameter ``auto_set_repeat`` has been renamed to ``auto_setup`` - please update your Mopidy + configuration file. +- Now resumes playback after a track has been rated. +- Enhanced auto_setup routines to ensure that ``consume``, ``random``, and ``single`` modes are disabled as well. +- Optimized auto_setup routines: now only called when the Mopidy tracklist changes. + +v0.1.6 (Oct 26, 2015) +--------------------- + +- Release to pypi + +v0.1.5 (Aug 20, 2015) +--------------------- + +- Add option to automatically set tracks to play in repeat mode when Mopidy-Pandora starts. +- Add experimental support for rating songs by re-using buttons available in the current front-end Mopidy extensions. +- Audio quality now defaults to the highest setting. +- Improved caching to revert to Pandora server if station cannot be found in the local cache. +- Fix to retrieve stations by ID instead of token. +- Add unit tests to increase test coverage. + +v0.1.4 (Aug 17, 2015) +--------------------- + +- Limit number of consecutive track skips to prevent Mopidy's skip-to-next-on-error behaviour from locking the user's + Pandora account. +- Better handling of exceptions that occur in the backend to prevent Mopidy actor crashes. +- Add support for unicode characters in station and track names. + +v0.1.3 (Jul 11, 2015) +--------------------- + +- Update to work with release of Mopidy version 1.0 +- Update to work with pydora version >= 1.4.0: now keeps the Pandora session alive in tha API itself. +- Implement station list caching to speed up browsing. +- Get rid of 'Stations' root directory. Browsing now displays all of the available stations immediately. +- Fill artist name to improve how tracks are displayed in various Mopidy front-end extensions. + +v0.1.2 (Jun 20, 2015) +--------------------- + +- Enhancement to handle ``Invalid Auth Token`` exceptions when the Pandora session expires after long periods of + inactivity. Allows Mopidy-Pandora to run indefinitely on dedicated music servers like the Pi MusicBox. +- Add configuration option to sort stations alphabetically, instead of by date. + +v0.1.1 (Mar 22, 2015) +--------------------- + +- Added ability to make preferred audio quality user-configurable. + +v0.1.0 (Dec 28, 2014) +--------------------- + +- Initial release. diff --git a/README.rst b/README.rst index cf76da1..cd4afa1 100644 --- a/README.rst +++ b/README.rst @@ -21,19 +21,34 @@ Mopidy-Pandora `Mopidy `_ extension for playing music from `Pandora Radio `_. +Features +======== + +- Supports **Pandora One** as well as **free** ad-supported Pandora accounts. +- Add ratings to tracks (thumbs up, thumbs down, sleep) +- Bookmark songs or artists +- Browse and add genre stations +- Play QuickMix stations +- Sort stations by date added or alphabetically +- Delete stations from user's profile +- The usual features provided by the Mopidy music server (album covers, scrobbling to last.fm, etc.) + + Dependencies ============ -- Requires a free, ad supported. Pandora account or a Pandora One subscription (which provides ad-free playback and a - higher quality 192 Kbps audio stream). +- 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.6.4. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. +- ``pydora`` >= 1.6.5. 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 ============ @@ -46,8 +61,8 @@ Install by running:: Configuration ============= -Before starting Mopidy, you must add your Pandora username and password to your Mopidy configuration file, and provide -the details of the JSON API endpoint that you would like to use:: +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 @@ -92,7 +107,8 @@ The following configuration values are available: 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. +- ``pandora/event_support_enabled``: setting this to ``true`` will enable the event triggers. Eventing is disabled by + default. - ``pandora/double_click_interval``: successive button clicks that occur within this interval (in seconds) will trigger an event. Defaults to ``2.00`` seconds. - ``pandora/on_pause_resume_click``: click pause and then play while a song is playing to trigger the event. Defaults @@ -112,95 +128,18 @@ Usage 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 at least two tracks in the playlist to avoid playback gaps when -switching tracks. +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 have Pandora and non-Pandora tracks in the tracklist at the same time. -Pandora expects users to interact with tracks at the time and in the sequence that it serves them up. For this reason, -trying to create playlists manually or mess with the tracklist queue is probably not a good idea. And not supported. +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. Project resources ================= +- `Change log `_ - `Source code `_ - `Issue tracker `_ - `Development branch tarball `_ - - -Changelog -========= - -v0.2.0 (UNRELEASED) -------------------- - -- Now displays all of the correct track information during playback (e.g. song and artist names, album covers, track - length, bitrate etc.). -- Simulate dynamic tracklist (workaround for `#2 `_) -- Add support for browsing genre stations. Note that clicking on a genre station will automatically add that station to - your profile. -- Add ability to delete a station by setting one of the doubleclick event parameters to ``delete_station``. -- Move 'QuickMix' to the top of the station list. Stations that will be played as part of QuickMix are marked with an - asterisk (*). -- Scrobbling tracks to Last.fm is now supported. -- Station lists are now cached which speeds up startup and browsing of the list of stations dramatically. Configuration - parameter ``cache_time_to_live`` can be used to specify when cache items should expire and be refreshed (in seconds). -- Force Mopidy to stop when skip limit is exceeded (workaround for `#1221 `_). -- Now plays advertisements which should prevent non-Pandora One accounts from being locked after extended use. - -v0.1.7 (Oct 31, 2015) ---------------------- - -- Configuration parameter ``auto_set_repeat`` has been renamed to ``auto_setup`` - please update your Mopidy - configuration file. -- Now resumes playback after a track has been rated. -- Enhanced auto_setup routines to ensure that ``consume``, ``random``, and ``single`` modes are disabled as well. -- Optimized auto_setup routines: now only called when the Mopidy tracklist changes. - -v0.1.6 (Oct 26, 2015) ---------------------- - -- Release to pypi - -v0.1.5 (Aug 20, 2015) ---------------------- - -- Add option to automatically set tracks to play in repeat mode when Mopidy-Pandora starts. -- Add experimental support for rating songs by re-using buttons available in the current front-end Mopidy extensions. -- Audio quality now defaults to the highest setting. -- Improved caching to revert to Pandora server if station cannot be found in the local cache. -- Fix to retrieve stations by ID instead of token. -- Add unit tests to increase test coverage. - -v0.1.4 (Aug 17, 2015) ---------------------- - -- Limit number of consecutive track skips to prevent Mopidy's skip-to-next-on-error behaviour from locking the user's - Pandora account. -- Better handling of exceptions that occur in the backend to prevent Mopidy actor crashes. -- Add support for unicode characters in station and track names. - -v0.1.3 (Jul 11, 2015) ---------------------- - -- Update to work with release of Mopidy version 1.0 -- Update to work with pydora version >= 1.4.0: now keeps the Pandora session alive in tha API itself. -- Implement station list caching to speed up browsing. -- Get rid of 'Stations' root directory. Browsing now displays all of the available stations immediately. -- Fill artist name to improve how tracks are displayed in various Mopidy front-end extensions. - -v0.1.2 (Jun 20, 2015) ---------------------- - -- Enhancement to handle ``Invalid Auth Token`` exceptions when the Pandora session expires after long periods of - inactivity. Allows Mopidy-Pandora to run indefinitely on dedicated music servers like the Pi MusicBox. -- Add configuration option to sort stations alphabetically, instead of by date. - -v0.1.1 (Mar 22, 2015) ---------------------- - -- Added ability to make preferred audio quality user-configurable. - -v0.1.0 (Dec 28, 2014) ---------------------- - -- Initial release. From 08b27f28615afd7ac4794496dc40dee281916cb2 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 6 Jan 2016 06:49:44 +0200 Subject: [PATCH 196/311] Update README. --- README.rst | 65 +++++++++++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/README.rst b/README.rst index cd4afa1..48c0a6c 100644 --- a/README.rst +++ b/README.rst @@ -25,13 +25,28 @@ Features ======== - Supports **Pandora One** as well as **free** ad-supported Pandora accounts. -- Add ratings to tracks (thumbs up, thumbs down, sleep) -- Bookmark songs or artists -- Browse and add genre stations -- Play QuickMix stations -- Sort stations by date added or alphabetically -- Delete stations from user's profile -- The usual features provided by the Mopidy music server (album covers, scrobbling to last.fm, etc.) +- Add ratings to tracks (thumbs up, thumbs down, sleep). +- 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. +- Also supports the usual features provided by the Mopidy music server (displaying album covers, scrobbling to last.fm, + etc.). + + +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 have Pandora and non-Pandora tracks in the tracklist at the same time. + +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 @@ -95,46 +110,36 @@ The following configuration values are available: - ``pandora/sort_order``: defaults to the ``date`` that the station was added. Use ``a-z`` to display the list of stations in alphabetical order. -- ``pandora/auto_setup``: 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/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 how long station and genre lists should be cached for between refreshes, - which greatly speeds up browsing the library. Setting this to ``0`` will disable caching entirely and ensure that the - latest lists are always retrieved from Pandora. It should not be necessary to fiddle with this unless you want - Mopidy-Pandora to immediately detect changes to your Pandora user profile that are made in other players. +- ``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. 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. Eventing is disabled by default. -- ``pandora/double_click_interval``: successive button clicks that occur within this interval (in seconds) will - trigger an event. Defaults to ``2.00`` seconds. +- ``pandora/double_click_interval``: successive button clicks that occur within this interval will trigger an event. + Defaults to ``2.00`` 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_stop_click``: click pause and then stop in quick succession. Calls event. Defaults to ``delete_station``. +- ``pandora/on_pause_stop_click``: click pause and then stop in quick succession. Calls event. Defaults to + ``delete_station``. The full list of supported events include: ``thumbs_up``, ``thumbs_down``, ``sleep``, ``add_artist_bookmark``, ``add_song_bookmark``, and ``delete_station``. -Usage -===== - -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 have Pandora and non-Pandora tracks in the tracklist at the same time. - -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. - Project resources ================= From 703cfbdb3c86f485c8af7a9f884d7c59e5f7b41b Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 6 Jan 2016 06:55:17 +0200 Subject: [PATCH 197/311] Update README. --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 48c0a6c..0b4ba8a 100644 --- a/README.rst +++ b/README.rst @@ -42,7 +42,8 @@ Ideally, Mopidy needs `dynamic playlists `_ 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 have Pandora and non-Pandora tracks in the tracklist at the same time. +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 From da1ba353c1d730c525b60443ecb88875b43459ec Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 6 Jan 2016 06:58:32 +0200 Subject: [PATCH 198/311] Update README - link to development changelog. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 0b4ba8a..a44b17f 100644 --- a/README.rst +++ b/README.rst @@ -145,7 +145,7 @@ The full list of supported events include: ``thumbs_up``, ``thumbs_down``, ``sle Project resources ================= -- `Change log `_ +- `Change log `_ - `Source code `_ - `Issue tracker `_ - `Development branch tarball `_ From 2b53883f1cc4147cf844d23b8c06408fbf0915e2 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 6 Jan 2016 07:29:50 +0200 Subject: [PATCH 199/311] Add event handling for frontend tests. --- tests/test_frontend.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 65a0655..0ae9e75 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -6,10 +6,10 @@ from mock import mock -from mopidy import core +from mopidy import core, listener, models -from mopidy import models from mopidy.audio import PlaybackState +from mopidy.core import CoreListener import pykka @@ -83,12 +83,12 @@ def tearDown(self): pykka.ActorRegistry.stop_all() self.patcher.stop() - def replay_events(self, until=None): + def replay_events(self, frontend, until=None): while self.events: if self.events[0][0] == until: break event, kwargs = self.events.pop(0) - self.core.on_event(event, **kwargs) + frontend.on_event(event, **kwargs).get() class TestFrontend(BaseTest): @@ -99,6 +99,9 @@ def setUp(self): # noqa: N802 def tearDown(self): # noqa: N802 super(TestFrontend, self).tearDown() + def replay_events(self, until=None): + super(TestFrontend, self).replay_events(self.frontend, until) + def test_add_track_starts_playback(self): new_track = models.Track(uri='pandora:track:id_mock:new_token_mock', length=40000) self.tracks.append(new_track) # Add to internal list for lookup to work @@ -139,6 +142,12 @@ def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): assert not func_mock.called + def test_options_changed_requires_setup(self): + self.frontend.setup_required = False + listener.send(CoreListener, 'options_changed') + self.replay_events() + assert self.frontend.setup_required.get() + def test_set_options_performs_auto_setup(self): self.core.tracklist.set_repeat(True).get() self.core.tracklist.set_consume(False).get() @@ -147,7 +156,8 @@ def test_set_options_performs_auto_setup(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() assert self.frontend.setup_required.get() - self.frontend.track_playback_started(self.tracks[0]).get() + listener.send(CoreListener, 'track_playback_started', tl_track=self.tracks[0]) + self.replay_events() assert self.core.tracklist.get_repeat().get() is False assert self.core.tracklist.get_consume().get() is True assert self.core.tracklist.get_random().get() is False @@ -222,6 +232,9 @@ def setUp(self): # noqa: N802 def tearDown(self): # noqa: N802 super(TestEventHandlingFrontend, self).tearDown() + def replay_events(self, until=None): + super(TestEventHandlingFrontend, self).replay_events(self.frontend, until) + def test_process_events_ignores_ads(self): self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() From 443248a68dca14249ad2bb739943b94fb8a38ab5 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 6 Jan 2016 10:16:42 +0200 Subject: [PATCH 200/311] Frontend test cases for unplayable tracks and auto-setup of player. --- tests/test_frontend.py | 56 +++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 0ae9e75..5afee35 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -36,8 +36,6 @@ def test_events_not_supported_returns_regular_frontend(self): class BaseTest(unittest.TestCase): - config = {'core': {'max_tracklist_length': 10000}} - tracks = [ models.Track(uri='pandora:track:id_mock:token_mock1', length=40000), # Regular track models.Track(uri='pandora:ad:id_mock:token_mock2', length=40000), # Advertisement @@ -99,9 +97,6 @@ def setUp(self): # noqa: N802 def tearDown(self): # noqa: N802 super(TestFrontend, self).tearDown() - def replay_events(self, until=None): - super(TestFrontend, self).replay_events(self.frontend, until) - def test_add_track_starts_playback(self): new_track = models.Track(uri='pandora:track:id_mock:new_token_mock', length=40000) self.tracks.append(new_track) # Add to internal list for lookup to work @@ -145,7 +140,7 @@ def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): def test_options_changed_requires_setup(self): self.frontend.setup_required = False listener.send(CoreListener, 'options_changed') - self.replay_events() + self.replay_events(self.frontend) assert self.frontend.setup_required.get() def test_set_options_performs_auto_setup(self): @@ -156,14 +151,40 @@ def test_set_options_performs_auto_setup(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() assert self.frontend.setup_required.get() - listener.send(CoreListener, 'track_playback_started', tl_track=self.tracks[0]) - self.replay_events() + self.frontend.set_options().get() assert self.core.tracklist.get_repeat().get() is False assert self.core.tracklist.get_consume().get() is True assert self.core.tracklist.get_random().get() is False assert self.core.tracklist.get_single().get() is False assert not self.frontend.setup_required.get() + def test_set_options_skips_auto_setup_if_not_configured(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + config = conftest.config() + config['pandora']['auto_setup'] = False + self.frontend = frontend.PandoraFrontend.start(config, self.core).proxy() + self.frontend.setup_required = True + self.core.tracklist.set_repeat(True).get() # Set a mode that we know will usually be changed + + self.frontend.set_options().get() + assert self.core.tracklist.get_repeat().get() # Confirm mode was not changed + + def test_set_options_triggered_on_core_events(self): + core_events = {'track_playback_started': {'tl_track': self.tracks[0]}, + 'track_playback_ended': {'tl_track': self.tracks[0], 'time_position': 100}, + 'track_playback_paused': {'tl_track': self.tracks[0], 'time_position': 100}, + 'track_playback_resumed': {'tl_track': self.tracks[0], 'time_position': 100}, + } + + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + for (event, kwargs) in core_events.items(): + self.frontend.setup_required = True + listener.send(CoreListener, event, **kwargs) + self.replay_events(self.frontend) + self.assertEqual(self.frontend.setup_required.get(), False, "Setup not done for event '{}'".format(event)) + def test_skip_limit_exceed_stops_playback(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() assert self.core.playback.get_state().get() == PlaybackState.PLAYING @@ -223,6 +244,22 @@ def test_track_changed_station_changed(self): assert self.events[0] == ('end_of_tracklist_reached', {'station_id': 'id_mock_other', 'auto_play': False}) + def test_track_unplayable_removes_tracks_from_tracklist(self): + tl_tracks = self.core.tracklist.get_tl_tracks().get() + unplayable_track = tl_tracks[0] + self.frontend.track_unplayable(unplayable_track.track).get() + + self.assertEqual(unplayable_track in self.core.tracklist.get_tl_tracks().get(), False) + + def test_track_unplayable_triggers_end_of_tracklist_event(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + self.frontend.track_unplayable(self.tl_tracks[-1].track).get() + is_event = [e[0] == 'end_of_tracklist_reached' for e in self.events] + + assert any(is_event) + assert self.core.playback.get_state().get() == PlaybackState.STOPPED + class TestEventHandlingFrontend(BaseTest): def setUp(self): # noqa: N802 @@ -232,9 +269,6 @@ def setUp(self): # noqa: N802 def tearDown(self): # noqa: N802 super(TestEventHandlingFrontend, self).tearDown() - def replay_events(self, until=None): - super(TestEventHandlingFrontend, self).replay_events(self.frontend, until) - def test_process_events_ignores_ads(self): self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() From c58ac1e09f251cad316948d74dd2dfecfa268de3 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 6 Jan 2016 10:25:08 +0200 Subject: [PATCH 201/311] Test cases for adding the next Pandora track to the tracklist. --- tests/test_frontend.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 5afee35..d9d8e79 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -127,6 +127,24 @@ def test_only_execute_for_pandora_executes_for_pandora_uri(self): assert func_mock.called + def test_next_track_available_adds_track_to_playlist(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + new_track = models.Track(uri='pandora:track:id_mock:new_token_mock', length=40000) + self.tracks.append(new_track) # Add to internal list for lookup to work + + self.frontend.next_track_available(new_track, True).get() + tl_tracks = self.core.tracklist.get_tl_tracks().get() + assert tl_tracks[-1].track == new_track + assert self.core.playback.get_current_track().get() == new_track + + def test_next_track_available_forces_stop_if_no_more_tracks(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + assert self.core.playback.get_state().get() == PlaybackState.PLAYING + self.frontend.next_track_available(None).get() + assert self.core.playback.get_state().get() == PlaybackState.STOPPED + def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): func_mock = mock.PropertyMock() func_mock.__name__ = str('func_mock') From 226b2cc923459090602cd653fb8be12ebfa3767f Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 6 Jan 2016 15:15:40 +0200 Subject: [PATCH 202/311] Test cases for doubleclick event handling. --- mopidy_pandora/frontend.py | 2 +- tests/test_frontend.py | 91 +++++++++++++++++++++++++++++--------- 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 8e505b0..7d43c34 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -70,7 +70,7 @@ def __init__(self, config, core): def set_options(self): # Setup playback to mirror behaviour of official Pandora front-ends. if self.auto_setup and self.setup_required: - assert isinstance(self.core.tracklist, object) + if self.core.tracklist.get_repeat().get() is True: self.core.tracklist.set_repeat(False) if self.core.tracklist.get_consume().get() is False: diff --git a/tests/test_frontend.py b/tests/test_frontend.py index d9d8e79..76ff835 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -14,6 +14,7 @@ import pykka from mopidy_pandora import frontend +from mopidy_pandora.frontend import EventHandlingPandoraFrontend, PandoraFrontend from tests import conftest, dummy_backend from tests.dummy_backend import DummyBackend, DummyPandoraBackend @@ -183,25 +184,29 @@ def test_set_options_skips_auto_setup_if_not_configured(self): config['pandora']['auto_setup'] = False self.frontend = frontend.PandoraFrontend.start(config, self.core).proxy() self.frontend.setup_required = True - self.core.tracklist.set_repeat(True).get() # Set a mode that we know will usually be changed self.frontend.set_options().get() - assert self.core.tracklist.get_repeat().get() # Confirm mode was not changed + assert self.frontend.setup_required def test_set_options_triggered_on_core_events(self): - core_events = {'track_playback_started': {'tl_track': self.tracks[0]}, - 'track_playback_ended': {'tl_track': self.tracks[0], 'time_position': 100}, - 'track_playback_paused': {'tl_track': self.tracks[0], 'time_position': 100}, - 'track_playback_resumed': {'tl_track': self.tracks[0], 'time_position': 100}, - } + with mock.patch.object(PandoraFrontend, 'set_options', mock.Mock()) as set_options_mock: - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + tl_tracks = self.core.tracklist.get_tl_tracks().get() + core_events = { + 'track_playback_started': {'tl_track': tl_tracks[0]}, + 'track_playback_ended': {'tl_track': tl_tracks[0], 'time_position': 100}, + 'track_playback_paused': {'tl_track': tl_tracks[0], 'time_position': 100}, + 'track_playback_resumed': {'tl_track': tl_tracks[0], 'time_position': 100}, + } + + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - for (event, kwargs) in core_events.items(): - self.frontend.setup_required = True - listener.send(CoreListener, event, **kwargs) - self.replay_events(self.frontend) - self.assertEqual(self.frontend.setup_required.get(), False, "Setup not done for event '{}'".format(event)) + for (event, kwargs) in core_events.items(): + self.frontend.setup_required = True + listener.send(CoreListener, event, **kwargs) + self.replay_events(self.frontend) + self.assertEqual(set_options_mock.called, True, "Setup not done for event '{}'".format(event)) + set_options_mock.reset() def test_skip_limit_exceed_stops_playback(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -257,8 +262,8 @@ def test_track_changed_station_changed(self): self.frontend.track_changed(self.tl_tracks[3].track).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() - assert len(tl_tracks) == 1 - assert tl_tracks[0] == self.tl_tracks[3] + assert len(tl_tracks) == 1 # Tracks were trimmed from the tracklist + assert tl_tracks[0] == self.tl_tracks[3] # Only the track recently changed to is left in the tracklist assert self.events[0] == ('end_of_tracklist_reached', {'station_id': 'id_mock_other', 'auto_play': False}) @@ -287,6 +292,27 @@ def setUp(self): # noqa: N802 def tearDown(self): # noqa: N802 super(TestEventHandlingFrontend, self).tearDown() + def test_events_check_for_doubleclick(self): + with mock.patch.object(EventHandlingPandoraFrontend, 'check_doubleclicked', mock.Mock()) as click_mock: + + click_mock.return_value = False + + tl_tracks = self.core.tracklist.get_tl_tracks().get() + core_events = { + 'track_playback_ended': {'tl_track': tl_tracks[0], 'time_position': 100}, + 'track_playback_resumed': {'tl_track': tl_tracks[0], 'time_position': 100}, + 'track_changed': {'track': tl_tracks[0].track}, + } + + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + for (event, kwargs) in core_events.items(): + self.frontend.set_click_time().get() + listener.send(CoreListener, event, **kwargs) + self.replay_events(self.frontend) + self.assertEqual(click_mock.called, True, "Doubleclick not checked for event '{}'".format(event)) + click_mock.reset() + def test_process_events_ignores_ads(self): self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() @@ -317,10 +343,33 @@ def test_process_events_handles_exception(self): assert len(self.events) == 0 # Check that no events were triggered - def test_is_double_click(self): - static_frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), self.core) - static_frontend.set_click_time() - assert static_frontend._is_double_click() + def test_wait_for_track_change_processes_stop_event(self): + with mock.patch.object(EventHandlingPandoraFrontend, '_process_event', mock.Mock()) as mock_process_event: + + self.frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), mock.Mock()) + self.frontend.set_click_time() + self.frontend.check_doubleclicked(action='stop') + time.sleep(float(self.frontend.double_click_interval + 0.1)) + + assert mock_process_event.called + + def test_wait_for_track_change_aborts_stop_event_on_track_change(self): + with mock.patch.object(EventHandlingPandoraFrontend, '_process_event', mock.Mock()) as mock_process_event: + + self.frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), mock.Mock()) + self.frontend.set_click_time() + self.frontend.check_doubleclicked(action='stop') + self.frontend.track_changed_event.set() + + assert not mock_process_event.called + + +# Test private methods that are not available in the pykka actor. + +def test_is_double_click(): + static_frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), mock.Mock()) + static_frontend.set_click_time() + assert static_frontend._is_double_click() - time.sleep(float(static_frontend.double_click_interval) + 0.1) - assert static_frontend._is_double_click() is False + time.sleep(float(static_frontend.double_click_interval) + 0.1) + assert static_frontend._is_double_click() is False From 64b3242e9c448e99f9944b0e1b277158c56f6ce2 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 6 Jan 2016 22:00:17 +0200 Subject: [PATCH 203/311] Rely on 'playback_state_changed' in favor of 'track_playback_ended' for detecting 'stop' doubleclicks. Increase default doubleclick interval. Fix mock resets. More frontend test cases. --- README.rst | 2 +- mopidy_pandora/ext.conf | 2 +- mopidy_pandora/frontend.py | 16 +++--- tests/test_backend.py | 4 +- tests/test_extension.py | 2 +- tests/test_frontend.py | 109 ++++++++++++++++++++++++++----------- 6 files changed, 92 insertions(+), 43 deletions(-) diff --git a/README.rst b/README.rst index a44b17f..8d35c59 100644 --- a/README.rst +++ b/README.rst @@ -128,7 +128,7 @@ pause/play/previous/next buttons. - ``pandora/event_support_enabled``: setting this to ``true`` will enable the event triggers. Eventing is disabled by default. - ``pandora/double_click_interval``: successive button clicks that occur within this interval will trigger an event. - Defaults to ``2.00`` seconds. + 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. diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index 40d95d9..647dbbd 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -14,7 +14,7 @@ auto_setup = true cache_time_to_live = 1800 event_support_enabled = false -double_click_interval = 2.00 +double_click_interval = 2.50 on_pause_resume_click = thumbs_up on_pause_next_click = thumbs_down on_pause_previous_click = sleep diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 7d43c34..cd7adb9 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -191,11 +191,6 @@ def __init__(self, config, core): self.track_changed_event = threading.Event() self.track_changed_event.set() - @only_execute_for_pandora_uris - def track_playback_ended(self, tl_track, time_position): - super(EventHandlingPandoraFrontend, self).track_playback_ended(tl_track, time_position) - self.check_doubleclicked(action='stop') - @run_async def _wait_for_track_change(self): self.track_changed_event.clear() @@ -213,6 +208,12 @@ def track_playback_resumed(self, tl_track, time_position): super(EventHandlingPandoraFrontend, self).track_playback_resumed(tl_track, time_position) self.check_doubleclicked(action='resume') + @only_execute_for_pandora_uris + def playback_state_changed(self, old_state, new_state): + super(EventHandlingPandoraFrontend, self).playback_state_changed(old_state, new_state) + if old_state == PlaybackState.PAUSED and new_state == PlaybackState.STOPPED: + self.check_doubleclicked(action='stop') + def track_changed(self, track): super(EventHandlingPandoraFrontend, self).track_changed(track) self.track_changed_event.set() @@ -252,8 +253,9 @@ def _process_event(self, action=None): self._trigger_event_triggered(event_target_uri, event_target_action) # Resume playback... - if action in ['stop', 'change_track'] and self.core.playback.get_state().get() != PlaybackState.PLAYING: - self.core.playback.resume().get() + if action in ['stop', 'change_track'] and event_target_action != 'delete_station': + if self.core.playback.get_state().get() != PlaybackState.PLAYING: + self.core.playback.resume().get() except ValueError: logger.exception("Error processing Pandora event '{}', ignoring...".format(action)) return diff --git a/tests/test_backend.py b/tests/test_backend.py index 0bdea5a..c317136 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -128,9 +128,9 @@ def test_process_event_calls_method(config, caplog): backend.process_event(uri_mock, event) assert mock_call.called - mock_call.reset() + mock_call.reset_mock() assert backend._trigger_event_processed.called - backend._trigger_event_processed.reset() + backend._trigger_event_processed.reset_mock() assert "Triggering event '{}'".format(event) in caplog.text() diff --git a/tests/test_extension.py b/tests/test_extension.py index 0e5ead9..a92a5b4 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -32,7 +32,7 @@ def test_get_default_config(self): self.assertIn('auto_setup = true', config) self.assertIn('cache_time_to_live = 1800', config) self.assertIn('event_support_enabled = false', config) - self.assertIn('double_click_interval = 2.00', config) + self.assertIn('double_click_interval = 2.50', config) self.assertIn('on_pause_resume_click = thumbs_up', config) self.assertIn('on_pause_next_click = thumbs_down', config) self.assertIn('on_pause_previous_click = sleep', config) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 76ff835..e8af839 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -15,6 +15,7 @@ from mopidy_pandora import frontend from mopidy_pandora.frontend import EventHandlingPandoraFrontend, PandoraFrontend +from mopidy_pandora.listener import PandoraBackendListener from tests import conftest, dummy_backend from tests.dummy_backend import DummyBackend, DummyPandoraBackend @@ -39,16 +40,17 @@ def test_events_not_supported_returns_regular_frontend(self): class BaseTest(unittest.TestCase): tracks = [ models.Track(uri='pandora:track:id_mock:token_mock1', length=40000), # Regular track - models.Track(uri='pandora:ad:id_mock:token_mock2', length=40000), # Advertisement - models.Track(uri='mock:track:id_mock:token_mock3', length=40000), # Not a pandora track - models.Track(uri='pandora:track:id_mock_other:token_mock4', length=40000), # Different station - models.Track(uri='pandora:track:id_mock:token_mock5', length=None), # No duration + models.Track(uri='pandora:track:id_mock:token_mock2', length=40000), # Regular track + models.Track(uri='pandora:ad:id_mock:token_mock3', length=40000), # Advertisement + models.Track(uri='mock:track:id_mock:token_mock4', length=40000), # Not a pandora track + models.Track(uri='pandora:track:id_mock_other:token_mock5', length=40000), # Different station + models.Track(uri='pandora:track:id_mock:token_mock6', length=None), # No duration ] uris = [ - 'pandora:track:id_mock:token_mock1', 'pandora:ad:id_mock:token_mock2', - 'mock:track:id_mock:token_mock3', 'pandora:track:id_mock_other:token_mock4', - 'pandora:track:id_mock:token_mock5'] + 'pandora:track:id_mock:token_mock1', 'pandora:track:id_mock:token_mock2', + 'pandora:ad:id_mock:token_mock3', 'mock:track:id_mock:token_mock4', + 'pandora:track:id_mock_other:token_mock5', 'pandora:track:id_mock:token_mock6'] def setUp(self): config = {'core': {'max_tracklist_length': 10000}} @@ -151,7 +153,7 @@ def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): func_mock.__name__ = str('func_mock') func_mock.return_value = True - self.core.playback.play(tlid=self.tl_tracks[2].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[3].tlid).get() frontend.only_execute_for_pandora_uris(func_mock)(self) assert not func_mock.called @@ -182,7 +184,6 @@ def test_set_options_skips_auto_setup_if_not_configured(self): config = conftest.config() config['pandora']['auto_setup'] = False - self.frontend = frontend.PandoraFrontend.start(config, self.core).proxy() self.frontend.setup_required = True self.frontend.set_options().get() @@ -206,7 +207,7 @@ def test_set_options_triggered_on_core_events(self): listener.send(CoreListener, event, **kwargs) self.replay_events(self.frontend) self.assertEqual(set_options_mock.called, True, "Setup not done for event '{}'".format(event)) - set_options_mock.reset() + set_options_mock.reset_mock() def test_skip_limit_exceed_stops_playback(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -221,7 +222,7 @@ def test_is_end_of_tracklist_reached(self): assert not self.frontend.is_end_of_tracklist_reached().get() def test_is_end_of_tracklist_reached_last_track(self): - self.core.playback.play(tlid=self.tl_tracks[4].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[-1].tlid).get() assert self.frontend.is_end_of_tracklist_reached().get() @@ -239,7 +240,7 @@ def test_is_station_changed(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.next().get() # Add track to history - assert self.frontend.is_station_changed(self.tl_tracks[3].track).get() + assert self.frontend.is_station_changed(self.tl_tracks[4].track).get() def test_is_station_changed_no_history(self): assert not self.frontend.is_station_changed(self.tl_tracks[0].track).get() @@ -260,10 +261,10 @@ def test_track_changed_station_changed(self): assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - self.frontend.track_changed(self.tl_tracks[3].track).get() + self.frontend.track_changed(self.tl_tracks[4].track).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 1 # Tracks were trimmed from the tracklist - assert tl_tracks[0] == self.tl_tracks[3] # Only the track recently changed to is left in the tracklist + assert tl_tracks[0] == self.tl_tracks[4] # Only the track recently changed to is left in the tracklist assert self.events[0] == ('end_of_tracklist_reached', {'station_id': 'id_mock_other', 'auto_play': False}) @@ -292,6 +293,16 @@ def setUp(self): # noqa: N802 def tearDown(self): # noqa: N802 super(TestEventHandlingFrontend, self).tearDown() + def test_delete_station_clears_tracklist_on_finish(self): + assert len(self.core.tracklist.get_tl_tracks().get()) > 0 + + listener.send(PandoraBackendListener, 'event_processed', + track_uri=self.tracks[0].uri, + pandora_event='delete_station') + self.replay_events(self.frontend) + + assert len(self.core.tracklist.get_tl_tracks().get()) == 0 + def test_events_check_for_doubleclick(self): with mock.patch.object(EventHandlingPandoraFrontend, 'check_doubleclicked', mock.Mock()) as click_mock: @@ -299,9 +310,9 @@ def test_events_check_for_doubleclick(self): tl_tracks = self.core.tracklist.get_tl_tracks().get() core_events = { - 'track_playback_ended': {'tl_track': tl_tracks[0], 'time_position': 100}, 'track_playback_resumed': {'tl_track': tl_tracks[0], 'time_position': 100}, 'track_changed': {'track': tl_tracks[0].track}, + 'playback_state_changed': {'old_state': PlaybackState.PAUSED, 'new_state': PlaybackState.STOPPED} } self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -311,13 +322,7 @@ def test_events_check_for_doubleclick(self): listener.send(CoreListener, event, **kwargs) self.replay_events(self.frontend) self.assertEqual(click_mock.called, True, "Doubleclick not checked for event '{}'".format(event)) - click_mock.reset() - - def test_process_events_ignores_ads(self): - self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() - - self.frontend.check_doubleclicked(action='resume').get() - assert len(self.events) == 0 # Check that no events were triggered + click_mock.reset_mock() def test_pause_starts_double_click_timer(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -338,28 +343,70 @@ def test_process_events_handles_exception(self): mock.PropertyMock(return_value=None, side_effect=ValueError('error_mock'))): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.frontend._trigger_event_triggered = mock.PropertyMock() + self.frontend.set_click_time().get() self.frontend.check_doubleclicked(action='resume').get() + assert len(self.events) == 0 # Check that no events were triggered + + def test_process_events_ignores_ads(self): + self.core.playback.play(tlid=self.tl_tracks[2].tlid).get() + + self.frontend.set_click_time().get() + self.frontend.check_doubleclicked(action='resume').get() + assert len(self.events) == 0 # Check that no events were triggered + def test_process_events_resumes_playback_for_change_track(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.next().get() # Ensure that there is at least one track in the playback history. + actions = ['stop', 'change_track', 'resume'] + + for action in actions: + self.core.playback.pause().get() + assert self.core.playback.get_state().get() == PlaybackState.PAUSED + self.frontend.set_click_time().get() + self.frontend.check_doubleclicked(action=action).get() + if action == 'stop': + time.sleep(self.frontend.double_click_interval.get() + 0.1) + if action == 'change_track': + self.assertEqual(self.core.playback.get_state().get(), + PlaybackState.PLAYING, + "Failed to set playback for action '{}'".format(action)) + else: + self.assertEqual(self.core.playback.get_state().get(), + PlaybackState.PAUSED, + "Failed to set playback for action '{}'".format(action)) + + def test_process_events_triggers_event(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.next().get() + self.core.playback.pause().get() + assert self.core.playback.get_state().get() == PlaybackState.PAUSED + + self.frontend.set_click_time().get() + self.frontend.check_doubleclicked(action='resume').get() + time.sleep(self.frontend.double_click_interval.get() + 0.1) + + assert len(self.events) == 1 + assert self.events[0][0] == 'event_triggered' + assert self.events[0][1]['track_uri'] == self.tl_tracks[1].track.uri + assert self.events[0][1]['pandora_event'] == 'thumbs_up' + def test_wait_for_track_change_processes_stop_event(self): with mock.patch.object(EventHandlingPandoraFrontend, '_process_event', mock.Mock()) as mock_process_event: - self.frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), mock.Mock()) - self.frontend.set_click_time() - self.frontend.check_doubleclicked(action='stop') - time.sleep(float(self.frontend.double_click_interval + 0.1)) + self.frontend.set_click_time().get() + self.frontend.check_doubleclicked(action='stop').get() + time.sleep(float(self.frontend.double_click_interval.get() + 0.1)) assert mock_process_event.called def test_wait_for_track_change_aborts_stop_event_on_track_change(self): with mock.patch.object(EventHandlingPandoraFrontend, '_process_event', mock.Mock()) as mock_process_event: - self.frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), mock.Mock()) - self.frontend.set_click_time() - self.frontend.check_doubleclicked(action='stop') - self.frontend.track_changed_event.set() + self.frontend.set_click_time().get() + self.frontend.check_doubleclicked(action='stop').get() + self.frontend.track_changed_event.get().set() assert not mock_process_event.called From ebc1c7e5a5ad45677f41bd550372133cfd6fc1d2 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 6 Jan 2016 22:39:21 +0200 Subject: [PATCH 204/311] Test cases for determining target events and URIs. --- tests/test_frontend.py | 51 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index e8af839..f8b6866 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -289,6 +289,7 @@ class TestEventHandlingFrontend(BaseTest): def setUp(self): # noqa: N802 super(TestEventHandlingFrontend, self).setUp() self.frontend = frontend.EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() + self.core.tracklist.set_consume(True).get() # Set consume mode so that tracklist behaves as expected. def tearDown(self): # noqa: N802 super(TestEventHandlingFrontend, self).tearDown() @@ -324,6 +325,55 @@ def test_events_check_for_doubleclick(self): self.assertEqual(click_mock.called, True, "Doubleclick not checked for event '{}'".format(event)) click_mock.reset_mock() + def test_get_event_targets_invalid_event_no_op(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.next().get() + self.core.playback.pause().get() + + self.frontend.set_click_time().get() + self.frontend.check_doubleclicked(action='invalid').get() + + assert len(self.events) == 0 + + def test_get_event_targets_change_next(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.next().get() + self.core.playback.pause().get() + + self.frontend.set_click_time().get() + self.frontend.check_doubleclicked(action='change_track').get() + + assert len(self.events) == 1 + assert self.events[0][0] == 'event_triggered' + assert self.events[0][1]['track_uri'] == self.tl_tracks[0].track.uri + assert self.events[0][1]['pandora_event'] == self.frontend.settings.get()['change_track_next'] + + def test_get_event_targets_change_previous(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.previous().get() + self.core.playback.pause().get() + + self.frontend.set_click_time().get() + self.frontend.check_doubleclicked(action='change_track').get() + + assert len(self.events) == 1 + assert self.events[0][0] == 'event_triggered' + assert self.events[0][1]['track_uri'] == self.tl_tracks[0].track.uri + assert self.events[0][1]['pandora_event'] == self.frontend.settings.get()['change_track_previous'] + + def test_get_event_targets_resume(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.next().get() + self.core.playback.pause().get() + + self.frontend.set_click_time().get() + self.frontend.check_doubleclicked(action='resume').get() + + assert len(self.events) == 1 + assert self.events[0][0] == 'event_triggered' + assert self.events[0][1]['track_uri'] == self.tl_tracks[1].track.uri + assert self.events[0][1]['pandora_event'] == self.frontend.settings.get()['resume'] + def test_pause_starts_double_click_timer(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -381,7 +431,6 @@ def test_process_events_triggers_event(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.next().get() self.core.playback.pause().get() - assert self.core.playback.get_state().get() == PlaybackState.PAUSED self.frontend.set_click_time().get() self.frontend.check_doubleclicked(action='resume').get() From 4b219b66f1a0366d61f41e1fc58fce29765894db Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 7 Jan 2016 09:03:28 +0200 Subject: [PATCH 205/311] Handle exception caused when history contains non-Pandora tracks. --- mopidy_pandora/frontend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index cd7adb9..9b37ecf 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -118,8 +118,8 @@ def is_station_changed(self, track): 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: - # No tracks in history, ignore + except (IndexError, NotImplementedError): + # No tracks in history, or last played track was not a Pandora track. Ignore pass return False From b6c704e27c255a6dc4e6cb4b1074258ee535856f Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 7 Jan 2016 23:05:29 +0200 Subject: [PATCH 206/311] Fix issue where changing stations would remove currently playing track from tracklist. Cleanup test cases to ensure state is consistent after setup. --- mopidy_pandora/frontend.py | 5 ++++- tests/test_frontend.py | 37 +++++++++++++++++++++---------------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 9b37ecf..d36489d 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -161,7 +161,10 @@ 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] - return len(self.core.tracklist.remove({'tlid': trim_tlids}).get()) + if len(trim_tlids) > 0: + return len(self.core.tracklist.remove({'tlid': trim_tlids}).get()) + else: + return 0 elif len(tl_tracks) > maxsize: # Only need two tracks in the tracklist at any given time, remove the oldest tracks diff --git a/tests/test_frontend.py b/tests/test_frontend.py index f8b6866..e583fc1 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -101,24 +101,20 @@ def tearDown(self): # noqa: N802 super(TestFrontend, self).tearDown() def test_add_track_starts_playback(self): - new_track = models.Track(uri='pandora:track:id_mock:new_token_mock', length=40000) - self.tracks.append(new_track) # Add to internal list for lookup to work - assert self.core.playback.get_state().get() == PlaybackState.STOPPED - self.frontend.add_track(new_track, auto_play=True).get() + self.core.tracklist.clear().get() + self.frontend.add_track(self.tl_tracks[0].track, auto_play=True).get() assert self.core.playback.get_state().get() == PlaybackState.PLAYING - assert self.core.playback.get_current_track().get() == new_track + assert self.core.playback.get_current_track().get() == self.tl_tracks[0].track def test_add_track_trims_tracklist(self): - new_track = models.Track(uri='pandora:track:id_mock:new_token_mock', length=40000) - self.tracks.append(new_track) # Add to internal list for lookup to work - assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - self.frontend.add_track(new_track).get() + self.core.tracklist.remove({'tlid': [self.tl_tracks[0].tlid]}).get() # Remove first track so we can add it again + self.frontend.add_track(self.tl_tracks[0].track).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 2 - assert tl_tracks[-1].track == new_track + assert tl_tracks[-1].track == self.tl_tracks[0].track def test_only_execute_for_pandora_executes_for_pandora_uri(self): func_mock = mock.PropertyMock() @@ -131,15 +127,14 @@ def test_only_execute_for_pandora_executes_for_pandora_uri(self): assert func_mock.called def test_next_track_available_adds_track_to_playlist(self): + self.core.tracklist.clear().get() + self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - new_track = models.Track(uri='pandora:track:id_mock:new_token_mock', length=40000) - self.tracks.append(new_track) # Add to internal list for lookup to work - - self.frontend.next_track_available(new_track, True).get() + self.frontend.next_track_available(self.tl_tracks[1].track, True).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() - assert tl_tracks[-1].track == new_track - assert self.core.playback.get_current_track().get() == new_track + assert tl_tracks[-1].track == self.tl_tracks[1].track + assert self.core.playback.get_current_track().get() == self.tl_tracks[1].track def test_next_track_available_forces_stop_if_no_more_tracks(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -216,6 +211,16 @@ def test_skip_limit_exceed_stops_playback(self): self.frontend.skip_limit_exceeded().get() assert self.core.playback.get_state().get() == PlaybackState.STOPPED + def test_station_change_does_not_trim_currently_playing_track_from_tracklist(self): + with mock.patch.object(PandoraFrontend, 'is_station_changed', mock.Mock(return_value=True)): + + self.core.tracklist.clear().get() + self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) + self.frontend.track_changed(self.tl_tracks[0].track).get() + tl_tracks = self.core.tracklist.get_tl_tracks().get() + assert len(tl_tracks) == 1 + assert tl_tracks[0].track == self.tl_tracks[0].track + def test_is_end_of_tracklist_reached(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() From 37efa039c10997cf08928089562de88ce75acb82 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 7 Jan 2016 23:16:37 +0200 Subject: [PATCH 207/311] Update README to highlight that endpoints are different for Pandora One and free accounts. --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 8d35c59..5d38706 100644 --- a/README.rst +++ b/README.rst @@ -95,7 +95,8 @@ 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. +- ``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. From de65f9c8b4a415f092fd2554890133c3d9eb630c Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 8 Jan 2016 00:32:12 +0200 Subject: [PATCH 208/311] Fix dependency reference: requires at least pydora 1.5.1. --- README.rst | 5 +++++ mopidy_pandora/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index c746651..cdf2e4a 100644 --- a/README.rst +++ b/README.rst @@ -107,6 +107,11 @@ Project resources Changelog ========= +v0.1.8 (Jan 8, 2016) +---------------------------------------- + +- Update dependencies: requires at least pydora 1.5.1. + v0.1.7 (Oct 31, 2015) ---------------------------------------- diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index 15a4870..5306c19 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -9,7 +9,7 @@ from pandora import BaseAPIClient -__version__ = '0.1.7' +__version__ = '0.1.8' logger = logging.getLogger(__name__) diff --git a/setup.py b/setup.py index 6f6d7b7..6ee554f 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,7 @@ def run_tests(self): 'setuptools', 'Mopidy >= 1.0.7', 'Pykka >= 1.1', - 'pydora >= 1.4.0', + 'pydora >= 1.5.1', 'requests >= 2.5.0' ], tests_require=['tox'], From 5613d283969e2a608fd9ec6edb4a1e1254b70adb Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 8 Jan 2016 00:35:52 +0200 Subject: [PATCH 209/311] Update changelog. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index cdf2e4a..f750402 100644 --- a/README.rst +++ b/README.rst @@ -107,7 +107,7 @@ Project resources Changelog ========= -v0.1.8 (Jan 8, 2016) +v0.1.8 (UNRELEASED) ---------------------------------------- - Update dependencies: requires at least pydora 1.5.1. From eceb0a455e9efa4819ca4f9988a8920ff61d683a Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 8 Jan 2016 00:37:16 +0200 Subject: [PATCH 210/311] Merge changelog with release-0.1 branch. --- CHANGES.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 2104e11..caa6678 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -22,6 +22,11 @@ v0.2.0 (UNRELEASED) - Tracks are now played in ``consume`` instead of ``repeat`` mode. This is more in line with how Pandora deals with track playback. It also avoids infinite loops on unplayable tracks, which is still an issue in Mopidy 1.1.2. +v0.1.8 (UNRELEASED) +---------------------------------------- + +- Update dependencies: requires at least pydora 1.5.1. + v0.1.7 (Oct 31, 2015) --------------------- From b508ab06efc80118fa4fdf8c0524518908ed5966 Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 8 Jan 2016 14:51:19 +0200 Subject: [PATCH 211/311] Update changelog. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index f750402..cdf2e4a 100644 --- a/README.rst +++ b/README.rst @@ -107,7 +107,7 @@ Project resources Changelog ========= -v0.1.8 (UNRELEASED) +v0.1.8 (Jan 8, 2016) ---------------------------------------- - Update dependencies: requires at least pydora 1.5.1. From 0b56c1ae262d2b424a81a8fc7f26048fed800787 Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 8 Jan 2016 15:00:34 +0200 Subject: [PATCH 212/311] Update changelog: merge with master. --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index caa6678..78a43d8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -22,7 +22,7 @@ v0.2.0 (UNRELEASED) - Tracks are now played in ``consume`` instead of ``repeat`` mode. This is more in line with how Pandora deals with track playback. It also avoids infinite loops on unplayable tracks, which is still an issue in Mopidy 1.1.2. -v0.1.8 (UNRELEASED) +v0.1.8 (Jan 8, 2016) ---------------------------------------- - Update dependencies: requires at least pydora 1.5.1. From abd0d71508f0729d15d34dc4596fbde6eb1e740c Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 8 Jan 2016 16:50:34 +0200 Subject: [PATCH 213/311] Add troubleshooting docs. --- CHANGES.rst | 2 +- README.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 78a43d8..b71eee4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -23,7 +23,7 @@ v0.2.0 (UNRELEASED) track playback. It also avoids infinite loops on unplayable tracks, which is still an issue in Mopidy 1.1.2. v0.1.8 (Jan 8, 2016) ----------------------------------------- +-------------------- - Update dependencies: requires at least pydora 1.5.1. diff --git a/README.rst b/README.rst index 5d38706..1dc8f75 100644 --- a/README.rst +++ b/README.rst @@ -147,6 +147,7 @@ Project resources ================= - `Change log `_ +- `Troubleshooting guide `_ - `Source code `_ - `Issue tracker `_ - `Development branch tarball `_ From ec42ee13c093f4576ac7e275ee6617614e69f6a5 Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 8 Jan 2016 16:57:48 +0200 Subject: [PATCH 214/311] Add troubleshooting docs. --- docs/troubleshoot.rst | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/troubleshoot.rst diff --git a/docs/troubleshoot.rst b/docs/troubleshoot.rst new file mode 100644 index 0000000..f37b616 --- /dev/null +++ b/docs/troubleshoot.rst @@ -0,0 +1,53 @@ +Troubleshooting +=============== + + +These are the recommended steps to follow if you run into any issues using +Mopidy-Pandora. + +Check the logs +-------------- + +Have a look at the contents of ``mopidy.log`` to see if there are any obvious +issues that require attention. This could range from ``mopidy.conf`` parsing +errors, or problems with the Pandora account that you are using. + +Ensure that Mopidy is running +----------------------------- + +Make sure that Mopidy itself is working correctly and that it is accessible +via the browser. Disable the Mopidy-Pandora extension by setting +``enabled = false`` in the ``pandora`` section of your configuration file, +restart Mopidy, and confirm that the other Mopidy extensions that you have +installed work as expected. + +Ensure that you are connected to the internet +--------------------------------------------- + +This sounds rather obvious but Mopidy-Pandora relies on a working internet +connection to log on to the Pandora servers and retrieve station information. +If you are behind a proxy, you may have to configure some of Mopidy's +`proxy settings `_. + +Run pydora directly +------------------- + +Mopidy-Pandora makes use of the pydora API, which comes bundled with its own +command-line player that can be run completely independently of Mopidy. This +is often useful for isolating issues to determine if they are Mopidy related, +or due to problems with your Pandora user account or any of a range of +technical issues in reaching and logging in to the Pandora servers. + +Follow the `installation instructions `_ +and use ``pydora-configure`` to create the necessary configuration file in +``~/.pydora.cfg``. Once that is done running ``pydora`` from the command line will +give you a quick indication of whether the issues are Mopidy-specific or not. + +Try a different Pandora user account +------------------------------------ + +It sometimes happens that Pandora will temporarily block a user account if you +exceed any of the internal skip or station request limits. It may be a good +idea to register a separate free account at `www.pandora.com `_ +for testing - just to make sure that the problem is not somehow related to an +issue with your primary Pandora user account. From 2dd0e8182ad72b0507b78b30a4f87e270cf3f81f Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 8 Jan 2016 17:01:30 +0200 Subject: [PATCH 215/311] Add troubleshooting docs. --- docs/{troubleshoot.rst => troubleshooting.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{troubleshoot.rst => troubleshooting.rst} (100%) diff --git a/docs/troubleshoot.rst b/docs/troubleshooting.rst similarity index 100% rename from docs/troubleshoot.rst rename to docs/troubleshooting.rst From 71acd3e415b8769558c8773e53aa1d7fd4f4538a Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 8 Jan 2016 17:02:55 +0200 Subject: [PATCH 216/311] Fix typo. --- docs/troubleshooting.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index f37b616..aa44045 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -27,7 +27,7 @@ Ensure that you are connected to the internet This sounds rather obvious but Mopidy-Pandora relies on a working internet connection to log on to the Pandora servers and retrieve station information. If you are behind a proxy, you may have to configure some of Mopidy's -`proxy settings `_. +`proxy settings `_. Run pydora directly ------------------- From 201113d798187056070b6fccf2a7e3d058285a5c Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 8 Jan 2016 17:05:01 +0200 Subject: [PATCH 217/311] Update changelog. --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index b71eee4..4fbd1df 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -21,6 +21,7 @@ v0.2.0 (UNRELEASED) - Now plays advertisements which should prevent non-Pandora One accounts from being locked after extended use. - Tracks are now played in ``consume`` instead of ``repeat`` mode. This is more in line with how Pandora deals with track playback. It also avoids infinite loops on unplayable tracks, which is still an issue in Mopidy 1.1.2. +- Added link to a short troubleshooting guide on the README page. v0.1.8 (Jan 8, 2016) -------------------- From 313a0c150887829ecbe17d04d1773e50cc8862c3 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 9 Jan 2016 18:39:41 +0200 Subject: [PATCH 218/311] Double-click detection based on comparison with timestamps in Mopidy's history controller. - Earlier detection of double clicks by moving to 'prepare_change' method. - New listener event-based test cases. --- mopidy_pandora/frontend.py | 141 +++++++++-------- mopidy_pandora/listener.py | 33 +--- mopidy_pandora/playback.py | 6 +- tests/conftest.py | 2 +- tests/test_frontend.py | 311 +++++++++++++++++++++++-------------- tests/test_listener.py | 26 +--- tests/test_playback.py | 4 +- 7 files changed, 290 insertions(+), 233 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index d36489d..4ddea96 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -1,10 +1,13 @@ from __future__ import absolute_import, division, print_function, unicode_literals import logging + import threading import time +from collections import namedtuple + from mopidy import core from mopidy.audio import PlaybackState @@ -70,7 +73,6 @@ def __init__(self, config, core): 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_repeat().get() is True: self.core.tracklist.set_repeat(False) if self.core.tracklist.get_consume().get() is False: @@ -84,6 +86,7 @@ def set_options(self): def options_changed(self): self.setup_required = True + self.set_options() @only_execute_for_pandora_uris def track_playback_started(self, tl_track): @@ -123,9 +126,9 @@ def is_station_changed(self, track): pass return False - def track_changed(self, track): + def changing_track(self, track): if self.is_station_changed(track): - # Another frontend has added a track, remove all other tracks from the tracklist + # 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, @@ -176,7 +179,10 @@ 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) -class EventHandlingPandoraFrontend(PandoraFrontend, listener.PandoraEventHandlingPlaybackListener): +ClickMarker = namedtuple('ClickMarker', 'uri, time') + + +class EventHandlingPandoraFrontend(PandoraFrontend): def __init__(self, config, core): super(EventHandlingPandoraFrontend, self).__init__(config, core) @@ -189,98 +195,111 @@ def __init__(self, config, core): } self.double_click_interval = float(config['pandora'].get('double_click_interval')) - self._click_time = 0 - - self.track_changed_event = threading.Event() - self.track_changed_event.set() + self._click_marker = ClickMarker(None, 0) - @run_async - def _wait_for_track_change(self): - self.track_changed_event.clear() - if not self.track_changed_event.wait(timeout=self.double_click_interval): - self._process_event(action='stop') + self.change_track_event = threading.Event() + self.change_track_event.set() @only_execute_for_pandora_uris def track_playback_paused(self, tl_track, time_position): - super(EventHandlingPandoraFrontend, self).track_playback_paused(tl_track, time_position) + """ + Clicking 'pause' is always the first step in detecting a double click. It also sets the timer that will be used + to check for double clicks later on. + + """ if time_position > 0: - self.set_click_time() + self.set_click_marker(tl_track) + super(EventHandlingPandoraFrontend, self).track_playback_paused(tl_track, time_position) @only_execute_for_pandora_uris def track_playback_resumed(self, tl_track, time_position): + """ + Used to detect pause -> resume double click events. + + """ + if self._is_double_click(): + self._queue_event(event='resume') super(EventHandlingPandoraFrontend, self).track_playback_resumed(tl_track, time_position) - self.check_doubleclicked(action='resume') @only_execute_for_pandora_uris def playback_state_changed(self, old_state, new_state): - super(EventHandlingPandoraFrontend, self).playback_state_changed(old_state, new_state) + """ + Used to detect pause -> stop, pause -> previous, and pause -> next double click events. + + """ if old_state == PlaybackState.PAUSED and new_state == PlaybackState.STOPPED: - self.check_doubleclicked(action='stop') + if self._is_double_click(): + self._queue_event('stop', self.change_track_event, 'change_track') + super(EventHandlingPandoraFrontend, self).playback_state_changed(old_state, new_state) - def track_changed(self, track): - super(EventHandlingPandoraFrontend, self).track_changed(track) - self.track_changed_event.set() - self.check_doubleclicked(action='change_track') + def changing_track(self, track): + self.change_track_event.set() + super(EventHandlingPandoraFrontend, self).changing_track(track) - def set_click_time(self, click_time=None): + def set_click_marker(self, tl_track, click_time=None): if click_time is None: - self._click_time = time.time() - else: - self._click_time = click_time + click_time = time.time() - def get_click_time(self): - return self._click_time + self._click_marker = ClickMarker(tl_track.track.uri, click_time) - def check_doubleclicked(self, action=None): - if self._is_double_click(): - if action == 'stop': - self._wait_for_track_change() - else: - self._process_event(action=action) + def get_click_marker(self): + return self._click_marker def event_processed(self, track_uri, pandora_event): if pandora_event == 'delete_station': self.core.tracklist.clear() def _is_double_click(self): - return self._click_time > 0 and time.time() - self._click_time < self.double_click_interval + return self._click_marker.time > 0 and time.time() - self._click_marker.time < self.double_click_interval - def _process_event(self, action=None): - try: - self.set_click_time(0) - event_target_uri, event_target_action = self._get_event_targets(action=action) + @run_async + def _queue_event(self, event, threading_event=None, override_event=None): + """ + Mopidy (as of 1.1.2) always forces a call to core.playback.stop() when the track changes, even + if the user did not click stop explicitly. We need to wait for a 'change_track' event + immediately thereafter to know if this is a real track stop, or just a transition to the + next/previous track. + """ + if threading_event: + threading_event.clear() + if threading_event.wait(timeout=self.double_click_interval): + event = override_event + + self.process_event(event=event) + def process_event(self, event): + try: + event_target_uri, event_target_action = self._get_event_targets(action=event) + except KeyError: + logger.exception("Error processing Pandora event '{}', ignoring...".format(event)) + return + else: if type(PandoraUri.factory(event_target_uri)) is AdItemUri: logger.info('Ignoring doubleclick event for Pandora advertisement...') return self._trigger_event_triggered(event_target_uri, event_target_action) # Resume playback... - if action in ['stop', 'change_track'] and event_target_action != 'delete_station': - if self.core.playback.get_state().get() != PlaybackState.PLAYING: - self.core.playback.resume().get() - except ValueError: - logger.exception("Error processing Pandora event '{}', ignoring...".format(action)) - return + if event == 'change_track' and self.core.playback.get_state().get() != PlaybackState.PLAYING: + self.core.playback.resume().get() def _get_event_targets(self, action=None): - current_track_uri = self.core.playback.get_current_tl_track().get().track.uri - if action == 'change_track': - previous_track_uri = self.core.history.get_history().get()[1][1].uri - if current_track_uri == previous_track_uri: - # Replaying last played track, user clicked 'previous'. - action = self.settings['change_track_previous'] - else: - # Track not in recent tracklist history, user clicked 'next'. - action = self.settings['change_track_next'] - - return previous_track_uri, action - - elif action in ['resume', 'stop']: - return current_track_uri, self.settings[action] - - raise ValueError('Unexpected event: {}'.format(action)) + history = self.core.history.get_history().get() + for i, h in enumerate(history): + if h[0] < int(self._click_marker.time * 1000): + if h[1].uri == self._click_marker.uri: + # This is the point in time in the history that the track was played + # before the double_click event occurred. + if history[i-1][1].uri == self._click_marker.uri: + # Track was played again immediately after double_click. + # User clicked 'previous' in consume mode. + action = 'change_track_previous' + else: + # Switched to another track, user clicked 'next'. + action = 'change_track_next' + + return self._click_marker.uri, self.settings[action] def _trigger_event_triggered(self, track_uri, event): (listener.PandoraEventHandlingFrontendListener.send('event_triggered', diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index d0c59e0..089258f 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -95,13 +95,14 @@ class PandoraPlaybackListener(listener.Listener): def send(event, **kwargs): listener.send(PandoraPlaybackListener, event, **kwargs) - def track_changed(self, track): + def changing_track(self, track): """ - Called when the track has been changed successfully. 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 have - already been played. + Called when a track change has been initiated. 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. This is also the earliest point at which we can detect a 'previous' or 'next' + action performed by the user. - :param track: the Pandora track that was just changed to. + :param track: the Pandora track that is being changed to. :type track: :class:`mopidy.models.Ref` """ pass @@ -125,25 +126,3 @@ def skip_limit_exceeded(self): """ pass - - -class PandoraEventHandlingPlaybackListener(listener.Listener): - - """ - Marker interface for recipients of events sent by the playback provider. - - """ - - @staticmethod - def send(event, **kwargs): - listener.send(PandoraEventHandlingPlaybackListener, event, **kwargs) - - def check_doubleclicked(self, action=None): - """ - Called to check if a doubleclick action was performed on one of the playback actions (i.e. pause/back, - pause/resume, pause, next). - - :param action: The playback action that occurred. - :type action: string - """ - pass diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 9ac2058..32002a0 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -43,7 +43,6 @@ def change_pandora_track(self, track): if pandora_track.get_is_playable(): # Success, reset track skip counter. self._consecutive_track_skips = 0 - self._trigger_track_changed(track) else: raise Unplayable("Track with URI '{}' is not playable.".format(track.uri)) @@ -61,6 +60,7 @@ def change_track(self, track): logger.warning("No URI for Pandora track '{}'. Track cannot be played.".format(track)) return False try: + self._trigger_changing_track(track) self.check_skip_limit() self.change_pandora_track(track) return super(PandoraPlaybackProvider, self).change_track(track) @@ -81,8 +81,8 @@ def check_skip_limit(self): def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url - def _trigger_track_changed(self, track): - listener.PandoraPlaybackListener.send('track_changed', track=track) + def _trigger_changing_track(self, track): + listener.PandoraPlaybackListener.send('changing_track', track=track) def _trigger_track_unplayable(self, track): listener.PandoraPlaybackListener.send('track_unplayable', track=track) diff --git a/tests/conftest.py b/tests/conftest.py index 9a82d64..0225deb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -66,7 +66,7 @@ def config(): 'cache_time_to_live': 1800, 'event_support_enabled': False, - 'double_click_interval': '0.1', + 'double_click_interval': '0.5', 'on_pause_resume_click': 'thumbs_up', 'on_pause_next_click': 'thumbs_down', 'on_pause_previous_click': 'sleep', diff --git a/tests/test_frontend.py b/tests/test_frontend.py index e583fc1..849f8e3 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals +import Queue + import time import unittest @@ -71,25 +73,34 @@ def lookup(uris): self.core.library.lookup = lookup self.tl_tracks = self.core.tracklist.add(uris=self.uris).get() - self.events = [] + self.events = Queue.Queue() self.patcher = mock.patch('mopidy.listener.send') + self.core_patcher = mock.patch('mopidy.listener.send_async') + self.send_mock = self.patcher.start() + self.core_send_mock = self.core_patcher.start() def send(cls, event, **kwargs): - self.events.append((event, kwargs)) + self.events.put((event, kwargs)) self.send_mock.side_effect = send + self.core_send_mock.side_effect = send def tearDown(self): pykka.ActorRegistry.stop_all() - self.patcher.stop() - - def replay_events(self, frontend, until=None): - while self.events: - if self.events[0][0] == until: + mock.patch.stopall() + + def replay_events(self, listener, until=None): + while True: + try: + e = self.events.get(timeout=0.1) + event, kwargs = e + listener.on_event(event, **kwargs).get() + if e[0] == until: + break + except Queue.Empty: + # All events replayed. break - event, kwargs = self.events.pop(0) - frontend.on_event(event, **kwargs).get() class TestFrontend(BaseTest): @@ -110,7 +121,10 @@ def test_add_track_starts_playback(self): def test_add_track_trims_tracklist(self): assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - self.core.tracklist.remove({'tlid': [self.tl_tracks[0].tlid]}).get() # Remove first track so we can add it again + + # Remove first track so we can add it again + self.core.tracklist.remove({'tlid': [self.tl_tracks[0].tlid]}).get() + self.frontend.add_track(self.tl_tracks[0].track).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 2 @@ -153,25 +167,28 @@ def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): assert not func_mock.called - def test_options_changed_requires_setup(self): - self.frontend.setup_required = False - listener.send(CoreListener, 'options_changed') - self.replay_events(self.frontend) - assert self.frontend.setup_required.get() + def test_options_changed_triggers_etup(self): + with mock.patch.object(PandoraFrontend, 'set_options', mock.Mock()) as set_options_mock: + self.frontend.setup_required = False + listener.send(CoreListener, 'options_changed') + self.replay_events(self.frontend) + assert set_options_mock.called def test_set_options_performs_auto_setup(self): + assert self.frontend.setup_required.get() self.core.tracklist.set_repeat(True).get() self.core.tracklist.set_consume(False).get() self.core.tracklist.set_random(True).get() self.core.tracklist.set_single(True).get() self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events(self.frontend) - assert self.frontend.setup_required.get() - self.frontend.set_options().get() assert self.core.tracklist.get_repeat().get() is False assert self.core.tracklist.get_consume().get() is True assert self.core.tracklist.get_random().get() is False assert self.core.tracklist.get_single().get() is False + self.replay_events(self.frontend) + assert not self.frontend.setup_required.get() def test_set_options_skips_auto_setup_if_not_configured(self): @@ -181,7 +198,7 @@ def test_set_options_skips_auto_setup_if_not_configured(self): config['pandora']['auto_setup'] = False self.frontend.setup_required = True - self.frontend.set_options().get() + self.replay_events(self.frontend) assert self.frontend.setup_required def test_set_options_triggered_on_core_events(self): @@ -216,7 +233,7 @@ def test_station_change_does_not_trim_currently_playing_track_from_tracklist(sel self.core.tracklist.clear().get() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) - self.frontend.track_changed(self.tl_tracks[0].track).get() + self.frontend.changing_track(self.tl_tracks[0].track).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 1 assert tl_tracks[0].track == self.tl_tracks[0].track @@ -245,33 +262,45 @@ def test_is_station_changed(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.next().get() # Add track to history + # Check against track of a different station assert self.frontend.is_station_changed(self.tl_tracks[4].track).get() def test_is_station_changed_no_history(self): assert not self.frontend.is_station_changed(self.tl_tracks[0].track).get() - def test_track_changed_no_op(self): + def test_changing_track_no_op(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.next().get() # Add track to history assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) + self.replay_events(self.frontend) - self.frontend.track_changed(self.tl_tracks[1].track).get() + self.frontend.changing_track(self.tl_tracks[1].track).get() assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - assert len(self.events) == 0 + assert self.events.qsize() == 0 - def test_track_changed_station_changed(self): + def test_changing_track_station_changed(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.next().get() # Add track to history + self.core.playback.play(tlid=self.tl_tracks[4].tlid).get() + self.replay_events(self.frontend) assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - self.frontend.track_changed(self.tl_tracks[4].track).get() + self.frontend.changing_track(self.tl_tracks[4].track).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 1 # Tracks were trimmed from the tracklist assert tl_tracks[0] == self.tl_tracks[4] # Only the track recently changed to is left in the tracklist - assert self.events[0] == ('end_of_tracklist_reached', {'station_id': 'id_mock_other', 'auto_play': False}) + is_event = [] + while True: + try: + e = self.events.get(timeout=0.1) + is_event.append(e == ('end_of_tracklist_reached', {'station_id': 'id_mock_other', + 'auto_play': False})) + except Queue.Empty: + # All events processed. + break + assert any(is_event) def test_track_unplayable_removes_tracks_from_tracklist(self): tl_tracks = self.core.tracklist.get_tl_tracks().get() @@ -282,10 +311,16 @@ def test_track_unplayable_removes_tracks_from_tracklist(self): def test_track_unplayable_triggers_end_of_tracklist_event(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events(self.frontend) self.frontend.track_unplayable(self.tl_tracks[-1].track).get() - is_event = [e[0] == 'end_of_tracklist_reached' for e in self.events] - + is_event = [] + while True: + try: + is_event.append(self.events.get(timeout=0.1)[0] == 'end_of_tracklist_reached') + except Queue.Empty: + # All events processed. + break assert any(is_event) assert self.core.playback.get_state().get() == PlaybackState.STOPPED @@ -309,168 +344,208 @@ def test_delete_station_clears_tracklist_on_finish(self): assert len(self.core.tracklist.get_tl_tracks().get()) == 0 - def test_events_check_for_doubleclick(self): - with mock.patch.object(EventHandlingPandoraFrontend, 'check_doubleclicked', mock.Mock()) as click_mock: + def test_events_processed_on_resume_stop_and_change_track(self): + with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: - click_mock.return_value = False + # Pause -> Resume + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.core.playback.resume().get() + self.replay_events(self.frontend) - tl_tracks = self.core.tracklist.get_tl_tracks().get() - core_events = { - 'track_playback_resumed': {'tl_track': tl_tracks[0], 'time_position': 100}, - 'track_changed': {'track': tl_tracks[0].track}, - 'playback_state_changed': {'old_state': PlaybackState.PAUSED, 'new_state': PlaybackState.STOPPED} - } + assert process_mock.called + process_mock.reset_mock() + self.events = Queue.Queue() + + # Pause -> Stop + self.core.playback.pause().get() + self.core.playback.stop().get() + self.replay_events(self.frontend) + time.sleep(self.frontend.double_click_interval.get() + 0.1) # Wait for 'change_track' timeout + assert process_mock.called + process_mock.reset_mock() + self.events = Queue.Queue() + + # Change track self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.core.playback.next().get() + self.replay_events(self.frontend) - for (event, kwargs) in core_events.items(): - self.frontend.set_click_time().get() - listener.send(CoreListener, event, **kwargs) - self.replay_events(self.frontend) - self.assertEqual(click_mock.called, True, "Doubleclick not checked for event '{}'".format(event)) - click_mock.reset_mock() + self.frontend.changing_track(self.tl_tracks[1].track).get() + # e = self.events.get(timeout=0.1) # Wait for processing to finish + + assert process_mock.called + process_mock.reset_mock() + self.events = Queue.Queue() def test_get_event_targets_invalid_event_no_op(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.next().get() + self.core.playback.seek(100).get() self.core.playback.pause().get() + self.replay_events(self.frontend) - self.frontend.set_click_time().get() - self.frontend.check_doubleclicked(action='invalid').get() - - assert len(self.events) == 0 + self.frontend.process_event(event='invalid').get() + assert self.events.qsize() == 0 def test_get_event_targets_change_next(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.next().get() + self.core.playback.seek(100).get() self.core.playback.pause().get() + self.core.playback.next().get() + self.replay_events(self.frontend) - self.frontend.set_click_time().get() - self.frontend.check_doubleclicked(action='change_track').get() + self.frontend.changing_track(track=self.tl_tracks[1].track).get() - assert len(self.events) == 1 - assert self.events[0][0] == 'event_triggered' - assert self.events[0][1]['track_uri'] == self.tl_tracks[0].track.uri - assert self.events[0][1]['pandora_event'] == self.frontend.settings.get()['change_track_next'] + e = self.events.get(timeout=0.1) + assert e[0] == 'event_triggered' + assert e[1]['track_uri'] == self.tl_tracks[0].track.uri + assert e[1]['pandora_event'] == self.frontend.settings.get()['change_track_next'] def test_get_event_targets_change_previous(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.previous().get() + self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() + self.core.playback.seek(100).get() self.core.playback.pause().get() + self.core.playback.previous().get() + self.replay_events(self.frontend) - self.frontend.set_click_time().get() - self.frontend.check_doubleclicked(action='change_track').get() + self.frontend.changing_track(track=self.tl_tracks[0].track).get() - assert len(self.events) == 1 - assert self.events[0][0] == 'event_triggered' - assert self.events[0][1]['track_uri'] == self.tl_tracks[0].track.uri - assert self.events[0][1]['pandora_event'] == self.frontend.settings.get()['change_track_previous'] + e = self.events.get(timeout=0.1) + assert e[0] == 'event_triggered' + assert e[1]['track_uri'] == self.tl_tracks[1].track.uri + assert e[1]['pandora_event'] == self.frontend.settings.get()['change_track_previous'] def test_get_event_targets_resume(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.next().get() + self.core.playback.seek(100).get() self.core.playback.pause().get() + self.core.playback.resume().get() + self.replay_events(self.frontend, until='track_playback_resumed') - self.frontend.set_click_time().get() - self.frontend.check_doubleclicked(action='resume').get() - - assert len(self.events) == 1 - assert self.events[0][0] == 'event_triggered' - assert self.events[0][1]['track_uri'] == self.tl_tracks[1].track.uri - assert self.events[0][1]['pandora_event'] == self.frontend.settings.get()['resume'] + e = self.events.get(timeout=0.1) + assert e[0] == 'event_triggered' + assert e[1]['track_uri'] == self.tl_tracks[0].track.uri + assert e[1]['pandora_event'] == self.frontend.settings.get()['resume'] def test_pause_starts_double_click_timer(self): + assert self.frontend.get_click_marker().get().time == 0 self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.replay_events(self.frontend) - assert self.frontend.get_click_time().get() == 0 - self.frontend.track_playback_paused(mock.Mock(), 100).get() - assert self.frontend.get_click_time().get() > 0 + assert self.frontend.get_click_marker().get().time > 0 def test_pause_does_not_start_timer_at_track_start(self): + assert self.frontend.get_click_marker().get().time == 0 self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.pause().get() + self.replay_events(self.frontend) - assert self.frontend.get_click_time().get() == 0 self.frontend.track_playback_paused(mock.Mock(), 0).get() - assert self.frontend.get_click_time().get() == 0 + assert self.frontend.get_click_marker().get().time == 0 - def test_process_events_handles_exception(self): + def test_process_event_handles_exception(self): with mock.patch.object(frontend.EventHandlingPandoraFrontend, '_get_event_targets', - mock.PropertyMock(return_value=None, side_effect=ValueError('error_mock'))): + mock.PropertyMock(return_value=None, side_effect=KeyError('error_mock'))): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.replay_events(self.frontend) - self.frontend.set_click_time().get() - self.frontend.check_doubleclicked(action='resume').get() + self.core.playback.resume().get() + self.replay_events(self.frontend, until='track_playback_resumed') - assert len(self.events) == 0 # Check that no events were triggered + assert self.events.qsize() == 0 # Check that no events were triggered - def test_process_events_ignores_ads(self): + def test_process_event_ignores_ads(self): self.core.playback.play(tlid=self.tl_tracks[2].tlid).get() + self.core.playback.seek(100) + self.core.playback.pause().get() + self.replay_events(self.frontend) - self.frontend.set_click_time().get() - self.frontend.check_doubleclicked(action='resume').get() + self.core.playback.resume().get() + self.replay_events(self.frontend, until='track_playback_resumed') - assert len(self.events) == 0 # Check that no events were triggered + assert self.events.qsize() == 0 # Check that no events were triggered - def test_process_events_resumes_playback_for_change_track(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.next().get() # Ensure that there is at least one track in the playback history. + def test_process_event_resumes_playback_for_change_track(self): actions = ['stop', 'change_track', 'resume'] for action in actions: + self.events = Queue.Queue() # Make sure that the queue is empty + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100) self.core.playback.pause().get() + self.replay_events(self.frontend) assert self.core.playback.get_state().get() == PlaybackState.PAUSED - self.frontend.set_click_time().get() - self.frontend.check_doubleclicked(action=action).get() - if action == 'stop': - time.sleep(self.frontend.double_click_interval.get() + 0.1) + if action == 'change_track': + self.core.playback.next().get() + self.frontend.process_event(event=action).get() + self.assertEqual(self.core.playback.get_state().get(), PlaybackState.PLAYING, "Failed to set playback for action '{}'".format(action)) else: + self.frontend.process_event(event=action).get() self.assertEqual(self.core.playback.get_state().get(), PlaybackState.PAUSED, "Failed to set playback for action '{}'".format(action)) - def test_process_events_triggers_event(self): + def test_process_event_triggers_event(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.next().get() + self.core.playback.seek(100).get() self.core.playback.pause().get() + self.replay_events(self.frontend) + self.core.playback.resume().get() + self.replay_events(self.frontend, until='track_playback_resumed') - self.frontend.set_click_time().get() - self.frontend.check_doubleclicked(action='resume').get() - time.sleep(self.frontend.double_click_interval.get() + 0.1) - - assert len(self.events) == 1 - assert self.events[0][0] == 'event_triggered' - assert self.events[0][1]['track_uri'] == self.tl_tracks[1].track.uri - assert self.events[0][1]['pandora_event'] == 'thumbs_up' - - def test_wait_for_track_change_processes_stop_event(self): - with mock.patch.object(EventHandlingPandoraFrontend, '_process_event', mock.Mock()) as mock_process_event: - - self.frontend.set_click_time().get() - self.frontend.check_doubleclicked(action='stop').get() - time.sleep(float(self.frontend.double_click_interval.get() + 0.1)) + e = self.events.get(timeout=0.1) + assert e[0] == 'event_triggered' + assert e[1]['track_uri'] == self.tl_tracks[0].track.uri + assert e[1]['pandora_event'] == 'thumbs_up' + assert self.events.qsize() == 0 - assert mock_process_event.called + def test_playback_state_changed_handles_stop(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.core.playback.stop().get() + self.replay_events(self.frontend) - def test_wait_for_track_change_aborts_stop_event_on_track_change(self): - with mock.patch.object(EventHandlingPandoraFrontend, '_process_event', mock.Mock()) as mock_process_event: + time.sleep(float(self.frontend.double_click_interval.get() + 0.1)) + e = self.events.get(timeout=0.1) # Wait for processing to finish + assert e[0] == 'event_triggered' - self.frontend.set_click_time().get() - self.frontend.check_doubleclicked(action='stop').get() - self.frontend.track_changed_event.get().set() + def test_playback_state_changed_handles_change_track(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.core.playback.next().get() + self.replay_events(self.frontend) - assert not mock_process_event.called + self.frontend.changing_track(self.tl_tracks[1].track).get() + e = self.events.get(timeout=0.1) # Wait for processing to finish + assert e[0] == 'event_triggered' # Test private methods that are not available in the pykka actor. def test_is_double_click(): static_frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), mock.Mock()) - static_frontend.set_click_time() + track_mock = mock.Mock(spec=models.Track) + track_mock.uri = 'pandora:track:id_mock:token_mock' + tl_track_mock = mock.Mock(spec=models.TlTrack) + tl_track_mock.track = track_mock + + static_frontend.set_click_marker(tl_track_mock) assert static_frontend._is_double_click() - time.sleep(float(static_frontend.double_click_interval) + 0.1) + static_frontend.set_click_marker(tl_track_mock) + time.sleep(float(static_frontend.double_click_interval) + 0.5) assert static_frontend._is_double_click() is False diff --git a/tests/test_listener.py b/tests/test_listener.py index 8c6ff73..2bea2a1 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -71,34 +71,18 @@ def setUp(self): # noqa: N802 self.listener = listener.PandoraPlaybackListener() def test_on_event_forwards_to_specific_handler(self): - self.listener.track_changed = mock.Mock() + self.listener.changing_track = mock.Mock() self.listener.on_event( - 'track_changed', track=models.Ref(name='name_mock')) + 'changing_track', track=models.Ref(name='name_mock')) - self.listener.track_changed.assert_called_with(track=models.Ref(name='name_mock')) + self.listener.changing_track.assert_called_with(track=models.Ref(name='name_mock')) - def test_listener_has_default_impl_for_track_changed(self): - self.listener.track_changed(track=models.Ref(name='name_mock')) + def test_listener_has_default_impl_for_changing_track(self): + self.listener.changing_track(track=models.Ref(name='name_mock')) def test_listener_has_default_impl_for_track_unplayable(self): self.listener.track_unplayable(track=models.Ref(name='name_mock')) def test_listener_has_default_impl_for_skip_limit_exceeded(self): self.listener.skip_limit_exceeded() - - -class PandoraEventHandlingPlaybackListenerTest(unittest.TestCase): - - def setUp(self): # noqa: N802 - self.listener = listener.PandoraEventHandlingPlaybackListener() - - def test_on_event_forwards_to_specific_handler(self): - self.listener.check_doubleclicked = mock.Mock() - - self.listener.on_event('check_doubleclicked', action='action_mock') - - self.listener.check_doubleclicked.assert_called_with(action='action_mock') - - def test_listener_has_default_impl_for_check_doubleclicked(self): - self.listener.check_doubleclicked(action='action_mock') diff --git a/tests/test_playback.py b/tests/test_playback.py index 7d08f68..eb9fdb3 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -150,10 +150,10 @@ def test_change_track_triggers_event_on_success(provider, playlist_item_mock): with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=True): track = PandoraUri.factory(playlist_item_mock) - provider._trigger_track_changed = mock.PropertyMock() + provider._trigger_changing_track = mock.PropertyMock() assert provider.change_track(track) is True - assert provider._trigger_track_changed.called + assert provider._trigger_changing_track.called def test_translate_uri_returns_audio_url(provider, playlist_item_mock): From dd83b0da03b238ab09e466c1582acba71a7ab350 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 10 Jan 2016 06:08:44 +0200 Subject: [PATCH 219/311] Update pydora dependency to 1.6.5. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8644d42..2a6268f 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ def run_tests(self): 'cachetools >= 1.0.0', 'Mopidy >= 1.1.1', 'Pykka >= 1.1', - 'pydora >= 1.6.4', + 'pydora >= 1.6.5', 'requests >= 2.5.0' ], tests_require=['tox'], From 54b41d1f692e4b9333bbc0bb42ca773eb2631ba6 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 10 Jan 2016 07:10:35 +0200 Subject: [PATCH 220/311] Make test cases thread-aware. --- tests/conftest.py | 43 +++++++ tests/test_frontend.py | 248 +++++++++++++++++++++++------------------ 2 files changed, 181 insertions(+), 110 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0225deb..edaa02e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals import json +import threading import mock @@ -299,3 +300,45 @@ def transport_call_not_implemented_mock(self, method, **data): class TransportCallTestNotImplemented(Exception): pass + + +# Based on https://pypi.python.org/pypi/tl.testing/0.5 +# Copyright (c) 2011-2012 Thomas Lotze +class ThreadJoiner(object): + """Context manager that tries to join any threads started by its suite. + + This context manager is instantiated with a mandatory ``timeout`` + parameter and an optional ``check_alive`` switch. The time-out is applied + when joining each of the new threads started while executing the context + manager's code suite. If ``check_alive`` has a true value (the default), + a ``RuntimeError`` is raised if a thread is still alive after the attempt + to join timed out. + + Returns an instance of itself upon entering. This instance has a + ``before`` attribute that is a collection of all threads active when the + manager was entered. After the manager exited, the instance has another + attribute, ``left_behind``, that is a collection of any threads that could + not be joined within the time-out period. The latter is obviously only + useful if ``check_alive`` is set to a false value. + + """ + + def __init__(self, timeout, check_alive=True): + self.timeout = timeout + self.check_alive = check_alive + + def __enter__(self): + self.before = set(threading.enumerate()) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + for thread in set(threading.enumerate()) - self.before: + thread.join(self.timeout) + if self.check_alive and thread.is_alive(): + raise RuntimeError('Timeout joining thread %r' % thread) + self.left_behind = sorted( + set(threading.enumerate()) - self.before, key=lambda t: t.name) + + def wait(self, timeout): + for thread in set(threading.enumerate()) - self.before: + thread.join(timeout) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 849f8e3..748fd0e 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -102,6 +102,17 @@ def replay_events(self, listener, until=None): # All events replayed. break + def has_events(self, events): + q = [] + while True: + try: + q.append(self.events.get(timeout=0.1)) + except Queue.Empty: + # All events replayed. + break + + return [e in q for e in events] + class TestFrontend(BaseTest): def setUp(self): # noqa: N802 @@ -229,14 +240,18 @@ def test_skip_limit_exceed_stops_playback(self): assert self.core.playback.get_state().get() == PlaybackState.STOPPED def test_station_change_does_not_trim_currently_playing_track_from_tracklist(self): - with mock.patch.object(PandoraFrontend, 'is_station_changed', mock.Mock(return_value=True)): + with conftest.ThreadJoiner(timeout=1) as thread_joiner: + with mock.patch.object(PandoraFrontend, 'is_station_changed', mock.Mock(return_value=True)): - self.core.tracklist.clear().get() - self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) - self.frontend.changing_track(self.tl_tracks[0].track).get() - tl_tracks = self.core.tracklist.get_tl_tracks().get() - assert len(tl_tracks) == 1 - assert tl_tracks[0].track == self.tl_tracks[0].track + self.core.tracklist.clear().get() + self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) + + self.frontend.changing_track(self.tl_tracks[0].track).get() + thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. + + tl_tracks = self.core.tracklist.get_tl_tracks().get() + assert len(tl_tracks) == 1 + assert tl_tracks[0].track == self.tl_tracks[0].track def test_is_end_of_tracklist_reached(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -269,38 +284,36 @@ def test_is_station_changed_no_history(self): assert not self.frontend.is_station_changed(self.tl_tracks[0].track).get() def test_changing_track_no_op(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.next().get() # Add track to history + with conftest.ThreadJoiner(timeout=1) as thread_joiner: + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.next().get() # Add track to history - assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - self.replay_events(self.frontend) + assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) + self.replay_events(self.frontend) - self.frontend.changing_track(self.tl_tracks[1].track).get() - assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - assert self.events.qsize() == 0 + self.frontend.changing_track(self.tl_tracks[1].track).get() + thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. + + assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) + assert self.events.qsize() == 0 def test_changing_track_station_changed(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.play(tlid=self.tl_tracks[4].tlid).get() - self.replay_events(self.frontend) + with conftest.ThreadJoiner(timeout=1) as thread_joiner: + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[4].tlid).get() + self.replay_events(self.frontend) - assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) + assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - self.frontend.changing_track(self.tl_tracks[4].track).get() - tl_tracks = self.core.tracklist.get_tl_tracks().get() - assert len(tl_tracks) == 1 # Tracks were trimmed from the tracklist - assert tl_tracks[0] == self.tl_tracks[4] # Only the track recently changed to is left in the tracklist + self.frontend.changing_track(self.tl_tracks[4].track).get() + thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. - is_event = [] - while True: - try: - e = self.events.get(timeout=0.1) - is_event.append(e == ('end_of_tracklist_reached', {'station_id': 'id_mock_other', - 'auto_play': False})) - except Queue.Empty: - # All events processed. - break - assert any(is_event) + tl_tracks = self.core.tracklist.get_tl_tracks().get() + assert len(tl_tracks) == 1 # Tracks were trimmed from the tracklist + assert tl_tracks[0] == self.tl_tracks[4] # Only the track recently changed to is left in the tracklist + + assert all(self.has_events([('end_of_tracklist_reached', {'station_id': 'id_mock_other', + 'auto_play': False})])) def test_track_unplayable_removes_tracks_from_tracklist(self): tl_tracks = self.core.tracklist.get_tl_tracks().get() @@ -314,14 +327,7 @@ def test_track_unplayable_triggers_end_of_tracklist_event(self): self.replay_events(self.frontend) self.frontend.track_unplayable(self.tl_tracks[-1].track).get() - is_event = [] - while True: - try: - is_event.append(self.events.get(timeout=0.1)[0] == 'end_of_tracklist_reached') - except Queue.Empty: - # All events processed. - break - assert any(is_event) + assert all([self.has_events('end_of_tracklist_reached')]) assert self.core.playback.get_state().get() == PlaybackState.STOPPED @@ -345,42 +351,41 @@ def test_delete_station_clears_tracklist_on_finish(self): assert len(self.core.tracklist.get_tl_tracks().get()) == 0 def test_events_processed_on_resume_stop_and_change_track(self): - with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: - - # Pause -> Resume - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() - self.core.playback.resume().get() - self.replay_events(self.frontend) - - assert process_mock.called - process_mock.reset_mock() - self.events = Queue.Queue() + with conftest.ThreadJoiner(timeout=1) as thread_joiner: + with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: + + # Pause -> Resume + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.core.playback.resume().get() + self.replay_events(self.frontend) - # Pause -> Stop - self.core.playback.pause().get() - self.core.playback.stop().get() - self.replay_events(self.frontend) - time.sleep(self.frontend.double_click_interval.get() + 0.1) # Wait for 'change_track' timeout + assert process_mock.called + process_mock.reset_mock() + self.events = Queue.Queue() - assert process_mock.called - process_mock.reset_mock() - self.events = Queue.Queue() + # Pause -> Stop + self.core.playback.pause().get() + self.core.playback.stop().get() + self.replay_events(self.frontend) + time.sleep(self.frontend.double_click_interval.get() + 0.1) # Wait for 'change_track' timeout - # Change track - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() - self.core.playback.next().get() - self.replay_events(self.frontend) + assert process_mock.called + process_mock.reset_mock() + self.events = Queue.Queue() - self.frontend.changing_track(self.tl_tracks[1].track).get() - # e = self.events.get(timeout=0.1) # Wait for processing to finish + # Change track + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.core.playback.next().get() + self.replay_events(self.frontend) - assert process_mock.called - process_mock.reset_mock() - self.events = Queue.Queue() + thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. + assert process_mock.called + process_mock.reset_mock() + self.events = Queue.Queue() def test_get_event_targets_invalid_event_no_op(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -392,32 +397,40 @@ def test_get_event_targets_invalid_event_no_op(self): assert self.events.qsize() == 0 def test_get_event_targets_change_next(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() - self.core.playback.next().get() - self.replay_events(self.frontend) + with conftest.ThreadJoiner(timeout=1) as thread_joiner: + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.core.playback.next().get() + self.replay_events(self.frontend) - self.frontend.changing_track(track=self.tl_tracks[1].track).get() + self.frontend.changing_track(track=self.tl_tracks[1].track).get() + thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. - e = self.events.get(timeout=0.1) - assert e[0] == 'event_triggered' - assert e[1]['track_uri'] == self.tl_tracks[0].track.uri - assert e[1]['pandora_event'] == self.frontend.settings.get()['change_track_next'] + assert all(self.has_events([ + ('event_triggered', + { + 'track_uri': self.tl_tracks[0].track.uri, + 'pandora_event': self.frontend.settings.get()['change_track_next'] + })])) def test_get_event_targets_change_previous(self): - self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() - self.core.playback.previous().get() - self.replay_events(self.frontend) + with conftest.ThreadJoiner(timeout=1) as thread_joiner: + self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.core.playback.previous().get() + self.replay_events(self.frontend) - self.frontend.changing_track(track=self.tl_tracks[0].track).get() + self.frontend.changing_track(track=self.tl_tracks[0].track).get() + thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. - e = self.events.get(timeout=0.1) - assert e[0] == 'event_triggered' - assert e[1]['track_uri'] == self.tl_tracks[1].track.uri - assert e[1]['pandora_event'] == self.frontend.settings.get()['change_track_previous'] + assert all(self.has_events([ + ('event_triggered', + { + 'track_uri': self.tl_tracks[1].track.uri, + 'pandora_event': self.frontend.settings.get()['change_track_previous'] + })])) def test_get_event_targets_resume(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -426,10 +439,12 @@ def test_get_event_targets_resume(self): self.core.playback.resume().get() self.replay_events(self.frontend, until='track_playback_resumed') - e = self.events.get(timeout=0.1) - assert e[0] == 'event_triggered' - assert e[1]['track_uri'] == self.tl_tracks[0].track.uri - assert e[1]['pandora_event'] == self.frontend.settings.get()['resume'] + assert all(self.has_events([ + ('event_triggered', + { + 'track_uri': self.tl_tracks[0].track.uri, + 'pandora_event': self.frontend.settings.get()['resume'] + })])) def test_pause_starts_double_click_timer(self): assert self.frontend.get_click_marker().get().time == 0 @@ -505,11 +520,12 @@ def test_process_event_triggers_event(self): self.core.playback.resume().get() self.replay_events(self.frontend, until='track_playback_resumed') - e = self.events.get(timeout=0.1) - assert e[0] == 'event_triggered' - assert e[1]['track_uri'] == self.tl_tracks[0].track.uri - assert e[1]['pandora_event'] == 'thumbs_up' - assert self.events.qsize() == 0 + assert all(self.has_events([ + ('event_triggered', + { + 'track_uri': self.tl_tracks[0].track.uri, + 'pandora_event': self.frontend.settings.get()['resume'] + })])) def test_playback_state_changed_handles_stop(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -519,19 +535,31 @@ def test_playback_state_changed_handles_stop(self): self.replay_events(self.frontend) time.sleep(float(self.frontend.double_click_interval.get() + 0.1)) - e = self.events.get(timeout=0.1) # Wait for processing to finish - assert e[0] == 'event_triggered' + + assert all(self.has_events([ + ('event_triggered', + { + 'track_uri': self.tl_tracks[0].track.uri, + 'pandora_event': self.frontend.settings.get()['stop'] + })])) def test_playback_state_changed_handles_change_track(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() - self.core.playback.next().get() - self.replay_events(self.frontend) + with conftest.ThreadJoiner(timeout=1) as thread_joiner: + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.core.playback.next().get() + self.replay_events(self.frontend) - self.frontend.changing_track(self.tl_tracks[1].track).get() - e = self.events.get(timeout=0.1) # Wait for processing to finish - assert e[0] == 'event_triggered' + self.frontend.changing_track(self.tl_tracks[1].track).get() + thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. + + assert all(self.has_events([ + ('event_triggered', + { + 'track_uri': self.tl_tracks[0].track.uri, + 'pandora_event': self.frontend.settings.get()['change_track_next'] + })])) # Test private methods that are not available in the pykka actor. From e199d91969eb2f9318d45599fbe5a51d35d48f32 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 10 Jan 2016 07:21:12 +0200 Subject: [PATCH 221/311] Increase timeout values to avoid false negative test results. --- tests/conftest.py | 2 +- tests/test_frontend.py | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index edaa02e..1d3335a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,7 +67,7 @@ def config(): 'cache_time_to_live': 1800, 'event_support_enabled': False, - 'double_click_interval': '0.5', + 'double_click_interval': '2.50', 'on_pause_resume_click': 'thumbs_up', 'on_pause_next_click': 'thumbs_down', 'on_pause_previous_click': 'sleep', diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 748fd0e..2bc446d 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -240,14 +240,14 @@ def test_skip_limit_exceed_stops_playback(self): assert self.core.playback.get_state().get() == PlaybackState.STOPPED def test_station_change_does_not_trim_currently_playing_track_from_tracklist(self): - with conftest.ThreadJoiner(timeout=1) as thread_joiner: + with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: with mock.patch.object(PandoraFrontend, 'is_station_changed', mock.Mock(return_value=True)): self.core.tracklist.clear().get() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) self.frontend.changing_track(self.tl_tracks[0].track).get() - thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 1 @@ -284,7 +284,7 @@ def test_is_station_changed_no_history(self): assert not self.frontend.is_station_changed(self.tl_tracks[0].track).get() def test_changing_track_no_op(self): - with conftest.ThreadJoiner(timeout=1) as thread_joiner: + with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.next().get() # Add track to history @@ -292,13 +292,13 @@ def test_changing_track_no_op(self): self.replay_events(self.frontend) self.frontend.changing_track(self.tl_tracks[1].track).get() - thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) assert self.events.qsize() == 0 def test_changing_track_station_changed(self): - with conftest.ThreadJoiner(timeout=1) as thread_joiner: + with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.play(tlid=self.tl_tracks[4].tlid).get() self.replay_events(self.frontend) @@ -306,7 +306,7 @@ def test_changing_track_station_changed(self): assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) self.frontend.changing_track(self.tl_tracks[4].track).get() - thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 1 # Tracks were trimmed from the tracklist @@ -351,7 +351,7 @@ def test_delete_station_clears_tracklist_on_finish(self): assert len(self.core.tracklist.get_tl_tracks().get()) == 0 def test_events_processed_on_resume_stop_and_change_track(self): - with conftest.ThreadJoiner(timeout=1) as thread_joiner: + with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: # Pause -> Resume @@ -382,7 +382,7 @@ def test_events_processed_on_resume_stop_and_change_track(self): self.core.playback.next().get() self.replay_events(self.frontend) - thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. assert process_mock.called process_mock.reset_mock() self.events = Queue.Queue() @@ -397,7 +397,7 @@ def test_get_event_targets_invalid_event_no_op(self): assert self.events.qsize() == 0 def test_get_event_targets_change_next(self): - with conftest.ThreadJoiner(timeout=1) as thread_joiner: + with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.seek(100).get() self.core.playback.pause().get() @@ -405,7 +405,7 @@ def test_get_event_targets_change_next(self): self.replay_events(self.frontend) self.frontend.changing_track(track=self.tl_tracks[1].track).get() - thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. assert all(self.has_events([ ('event_triggered', @@ -415,7 +415,7 @@ def test_get_event_targets_change_next(self): })])) def test_get_event_targets_change_previous(self): - with conftest.ThreadJoiner(timeout=1) as thread_joiner: + with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() self.core.playback.seek(100).get() self.core.playback.pause().get() @@ -423,7 +423,7 @@ def test_get_event_targets_change_previous(self): self.replay_events(self.frontend) self.frontend.changing_track(track=self.tl_tracks[0].track).get() - thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. assert all(self.has_events([ ('event_triggered', @@ -544,7 +544,7 @@ def test_playback_state_changed_handles_stop(self): })])) def test_playback_state_changed_handles_change_track(self): - with conftest.ThreadJoiner(timeout=1) as thread_joiner: + with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.seek(100).get() self.core.playback.pause().get() @@ -552,7 +552,7 @@ def test_playback_state_changed_handles_change_track(self): self.replay_events(self.frontend) self.frontend.changing_track(self.tl_tracks[1].track).get() - thread_joiner.wait(timeout=1) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. assert all(self.has_events([ ('event_triggered', From e6f52344641e46a2ca217fa71f4a67704bd5a2b3 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 10 Jan 2016 07:26:51 +0200 Subject: [PATCH 222/311] Reduce double click interval for testing to 1.0 seconds. --- tests/conftest.py | 2 +- tests/test_frontend.py | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1d3335a..704acab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,7 +67,7 @@ def config(): 'cache_time_to_live': 1800, 'event_support_enabled': False, - 'double_click_interval': '2.50', + 'double_click_interval': '1.0', 'on_pause_resume_click': 'thumbs_up', 'on_pause_next_click': 'thumbs_down', 'on_pause_previous_click': 'sleep', diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 2bc446d..146f4f1 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -240,14 +240,14 @@ def test_skip_limit_exceed_stops_playback(self): assert self.core.playback.get_state().get() == PlaybackState.STOPPED def test_station_change_does_not_trim_currently_playing_track_from_tracklist(self): - with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: with mock.patch.object(PandoraFrontend, 'is_station_changed', mock.Mock(return_value=True)): self.core.tracklist.clear().get() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) self.frontend.changing_track(self.tl_tracks[0].track).get() - thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 1 @@ -284,7 +284,7 @@ def test_is_station_changed_no_history(self): assert not self.frontend.is_station_changed(self.tl_tracks[0].track).get() def test_changing_track_no_op(self): - with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.next().get() # Add track to history @@ -292,13 +292,13 @@ def test_changing_track_no_op(self): self.replay_events(self.frontend) self.frontend.changing_track(self.tl_tracks[1].track).get() - thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) assert self.events.qsize() == 0 def test_changing_track_station_changed(self): - with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.play(tlid=self.tl_tracks[4].tlid).get() self.replay_events(self.frontend) @@ -306,7 +306,7 @@ def test_changing_track_station_changed(self): assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) self.frontend.changing_track(self.tl_tracks[4].track).get() - thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 1 # Tracks were trimmed from the tracklist @@ -351,7 +351,7 @@ def test_delete_station_clears_tracklist_on_finish(self): assert len(self.core.tracklist.get_tl_tracks().get()) == 0 def test_events_processed_on_resume_stop_and_change_track(self): - with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: # Pause -> Resume @@ -382,7 +382,7 @@ def test_events_processed_on_resume_stop_and_change_track(self): self.core.playback.next().get() self.replay_events(self.frontend) - thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert process_mock.called process_mock.reset_mock() self.events = Queue.Queue() @@ -397,7 +397,7 @@ def test_get_event_targets_invalid_event_no_op(self): assert self.events.qsize() == 0 def test_get_event_targets_change_next(self): - with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.seek(100).get() self.core.playback.pause().get() @@ -405,7 +405,7 @@ def test_get_event_targets_change_next(self): self.replay_events(self.frontend) self.frontend.changing_track(track=self.tl_tracks[1].track).get() - thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert all(self.has_events([ ('event_triggered', @@ -415,7 +415,7 @@ def test_get_event_targets_change_next(self): })])) def test_get_event_targets_change_previous(self): - with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() self.core.playback.seek(100).get() self.core.playback.pause().get() @@ -423,7 +423,7 @@ def test_get_event_targets_change_previous(self): self.replay_events(self.frontend) self.frontend.changing_track(track=self.tl_tracks[0].track).get() - thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert all(self.has_events([ ('event_triggered', @@ -544,7 +544,7 @@ def test_playback_state_changed_handles_stop(self): })])) def test_playback_state_changed_handles_change_track(self): - with conftest.ThreadJoiner(timeout=2.50) as thread_joiner: + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.seek(100).get() self.core.playback.pause().get() @@ -552,7 +552,7 @@ def test_playback_state_changed_handles_change_track(self): self.replay_events(self.frontend) self.frontend.changing_track(self.tl_tracks[1].track).get() - thread_joiner.wait(timeout=2.50) # Wait until threads spawned by frontend have finished. + thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert all(self.has_events([ ('event_triggered', From 17641e41f6d48618e08be3e4a7be1d4ad84a6fd6 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 10 Jan 2016 08:16:44 +0200 Subject: [PATCH 223/311] Replace deprecated parameter in method call. --- mopidy_pandora/frontend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 4ddea96..dba60db 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -157,7 +157,7 @@ def add_track(self, track, auto_play=False): self.core.tracklist.add(uris=[track.uri]).get() if auto_play: tl_tracks = self.core.tracklist.get_tl_tracks().get() - self.core.playback.play(tl_tracks[-1]).get() + self.core.playback.play(tlid=tl_tracks[-1].tlid).get() self._trim_tracklist(maxsize=2) def _trim_tracklist(self, keep_only=None, maxsize=2): From 9f0b2ef0699a179def932f925d808afb645a1fda Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 10 Jan 2016 14:12:14 +0200 Subject: [PATCH 224/311] Fix bug in detecting pause->previous track changes. Increase precision of click_marker by three decimal points. --- mopidy_pandora/frontend.py | 37 ++++++++--- tests/test_frontend.py | 133 ++++++++++++++++++++++++++++++++++--- 2 files changed, 150 insertions(+), 20 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index dba60db..9a3ed0b 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -195,7 +195,7 @@ def __init__(self, config, core): } self.double_click_interval = float(config['pandora'].get('double_click_interval')) - self._click_marker = ClickMarker(None, 0) + self._click_marker = None self.change_track_event = threading.Event() self.change_track_event.set() @@ -229,7 +229,11 @@ def playback_state_changed(self, old_state, new_state): """ if old_state == PlaybackState.PAUSED and new_state == PlaybackState.STOPPED: if self._is_double_click(): - self._queue_event('stop', self.change_track_event, 'change_track') + # Mopidy (as of 1.1.2) always forces a call to core.playback.stop() when the track changes, even + # if the user did not click stop explicitly. We need to wait for a 'change_track' event + # immediately thereafter to know if this is a real track stop, or just a transition to the + # next/previous track. + self._queue_event('stop', self.change_track_event, 'change_track', timeout=self.double_click_interval) super(EventHandlingPandoraFrontend, self).playback_state_changed(old_state, new_state) def changing_track(self, track): @@ -238,7 +242,7 @@ def changing_track(self, track): def set_click_marker(self, tl_track, click_time=None): if click_time is None: - click_time = time.time() + click_time = int(time.time() * 1000) self._click_marker = ClickMarker(tl_track.track.uri, click_time) @@ -250,19 +254,27 @@ def event_processed(self, track_uri, pandora_event): self.core.tracklist.clear() def _is_double_click(self): - return self._click_marker.time > 0 and time.time() - self._click_marker.time < self.double_click_interval + if self._click_marker is None: + return False + return (self._click_marker.time > 0 and + int(time.time() * 1000) - self._click_marker.time < self.double_click_interval * 1000) @run_async - def _queue_event(self, event, threading_event=None, override_event=None): + def _queue_event(self, event, threading_event=None, override_event=None, timeout=None): """ - Mopidy (as of 1.1.2) always forces a call to core.playback.stop() when the track changes, even - if the user did not click stop explicitly. We need to wait for a 'change_track' event - immediately thereafter to know if this is a real track stop, or just a transition to the - next/previous track. + Queue an event for processing. If the specified threading event is set, then the event will be overridden with + the one specified. Useful for detecting track change transitions, which always trigger 'stop' first. + + :param event: the original event action that was originally called. + :param threading_event: the threading.Event to monitor. + :param override_event: the new event that should be called instead of the original if the threading event is + set within the timeout specified. + :param timeout: the length of time to wait for the threading.Event to be set before processing the orignal event """ + if threading_event: threading_event.clear() - if threading_event.wait(timeout=self.double_click_interval): + if threading_event.wait(timeout=timeout): event = override_event self.process_event(event=event) @@ -287,7 +299,7 @@ def _get_event_targets(self, action=None): if action == 'change_track': history = self.core.history.get_history().get() for i, h in enumerate(history): - if h[0] < int(self._click_marker.time * 1000): + if h[0] < self._click_marker.time: if h[1].uri == self._click_marker.uri: # This is the point in time in the history that the track was played # before the double_click event occurred. @@ -295,13 +307,16 @@ def _get_event_targets(self, action=None): # Track was played again immediately after double_click. # User clicked 'previous' in consume mode. action = 'change_track_previous' + break else: # Switched to another track, user clicked 'next'. action = 'change_track_next' + break return self._click_marker.uri, self.settings[action] def _trigger_event_triggered(self, track_uri, event): + self._click_marker = None (listener.PandoraEventHandlingFrontendListener.send('event_triggered', track_uri=track_uri, pandora_event=event)) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 146f4f1..3a038e8 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -11,7 +11,8 @@ from mopidy import core, listener, models from mopidy.audio import PlaybackState -from mopidy.core import CoreListener + +from mopidy.core import Core, CoreListener, HistoryController import pykka @@ -350,7 +351,7 @@ def test_delete_station_clears_tracklist_on_finish(self): assert len(self.core.tracklist.get_tl_tracks().get()) == 0 - def test_events_processed_on_resume_stop_and_change_track(self): + def test_events_processed_on_resume_stop_and_change_track_actions(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: @@ -397,6 +398,10 @@ def test_get_event_targets_invalid_event_no_op(self): assert self.events.qsize() == 0 def test_get_event_targets_change_next(self): + """ + Pause -> Next + + """ with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.seek(100).get() @@ -415,8 +420,11 @@ def test_get_event_targets_change_next(self): })])) def test_get_event_targets_change_previous(self): + """ + Pause -> Previous + """ with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[1].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.seek(100).get() self.core.playback.pause().get() self.core.playback.previous().get() @@ -428,11 +436,15 @@ def test_get_event_targets_change_previous(self): assert all(self.has_events([ ('event_triggered', { - 'track_uri': self.tl_tracks[1].track.uri, + 'track_uri': self.tl_tracks[0].track.uri, 'pandora_event': self.frontend.settings.get()['change_track_previous'] })])) def test_get_event_targets_resume(self): + """ + Pause -> Resume + + """ self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.seek(100).get() self.core.playback.pause().get() @@ -447,7 +459,7 @@ def test_get_event_targets_resume(self): })])) def test_pause_starts_double_click_timer(self): - assert self.frontend.get_click_marker().get().time == 0 + assert self.frontend.get_click_marker().get() is None self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.seek(100).get() self.core.playback.pause().get() @@ -456,13 +468,13 @@ def test_pause_starts_double_click_timer(self): assert self.frontend.get_click_marker().get().time > 0 def test_pause_does_not_start_timer_at_track_start(self): - assert self.frontend.get_click_marker().get().time == 0 + assert self.frontend.get_click_marker().get() is None self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.pause().get() self.replay_events(self.frontend) self.frontend.track_playback_paused(mock.Mock(), 0).get() - assert self.frontend.get_click_marker().get().time == 0 + assert self.frontend.get_click_marker().get() is None def test_process_event_handles_exception(self): with mock.patch.object(frontend.EventHandlingPandoraFrontend, '_get_event_targets', @@ -527,7 +539,21 @@ def test_process_event_triggers_event(self): 'pandora_event': self.frontend.settings.get()['resume'] })])) + def test_process_event_resets_click_marker(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.replay_events(self.frontend) + self.core.playback.resume().get() + self.replay_events(self.frontend, until='track_playback_resumed') + + assert self.frontend.get_click_marker().get() is None + def test_playback_state_changed_handles_stop(self): + """ + Pause -> Stop + + """ self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.seek(100).get() self.core.playback.pause().get() @@ -562,9 +588,20 @@ def test_playback_state_changed_handles_change_track(self): })])) -# Test private methods that are not available in the pykka actor. +# Test private methods that are not available in the Pykka actor. -def test_is_double_click(): +def test_is_double_click_true(): + static_frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), mock.Mock()) + track_mock = mock.Mock(spec=models.Track) + track_mock.uri = 'pandora:track:id_mock:token_mock' + tl_track_mock = mock.Mock(spec=models.TlTrack) + tl_track_mock.track = track_mock + + static_frontend.set_click_marker(tl_track_mock) + assert static_frontend._is_double_click() + + +def test_is_double_click_false(): static_frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), mock.Mock()) track_mock = mock.Mock(spec=models.Track) track_mock.uri = 'pandora:track:id_mock:token_mock' @@ -577,3 +614,81 @@ def test_is_double_click(): static_frontend.set_click_marker(tl_track_mock) time.sleep(float(static_frontend.double_click_interval) + 0.5) assert static_frontend._is_double_click() is False + + +class TestEventTargets(BaseTest): + def setUp(self): # noqa: N802 + self.history = [] + self.tracks = [ + models.Track(uri='pandora:track:id_mock:token_mock1', length=40000), + models.Track(uri='pandora:track:id_mock:token_mock2', length=40000), + models.Track(uri='pandora:track:id_mock:token_mock3', length=40000), + ] + + self.tl_tracks = ([models.TlTrack(track=self.tracks[i], tlid=i+1) + for i in range(0, len(self.tracks))]) + + self.refs = ([models.Ref.track(uri=self.tl_tracks[i].track.uri, name='name_mock') + for i in range(0, len(self.tracks))]) + + core_mock = mock.Mock(spec=Core) + history_mock = mock.Mock(spec=HistoryController) + threading_future_mock = mock.Mock(spec=pykka.ThreadingFuture) + core.history = history_mock + threading_future_mock.get.return_value = self.history + core_mock.history.get_history.return_value = threading_future_mock + + self.frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), core_mock) + + def test_get_event_targets_change_track_next(self): + self.history.insert(0, (int(time.time() * 1000), self.refs[0])) + time.sleep(0.01) + self.frontend.set_click_marker(self.tl_tracks[0]) + time.sleep(0.01) + self.history.insert(0, (int(time.time() * 1000), self.refs[1])) + + event_target_uri, event_target_action = self.frontend._get_event_targets('change_track') + assert event_target_uri == self.refs[0].uri + assert event_target_action == self.frontend.settings['change_track_next'] + + def test_get_event_targets_change_track_next_first_track_has_multiple_replays(self): + self.history.insert(0, (int(time.time() * 1000), self.refs[0])) + time.sleep(0.01) + self.history.insert(0, (int(time.time() * 1000), self.refs[0])) + time.sleep(0.01) + self.frontend.set_click_marker(self.tl_tracks[0]) + time.sleep(0.01) + self.history.insert(0, (int(time.time() * 1000), self.refs[1])) + + event_target_uri, event_target_action = self.frontend._get_event_targets('change_track') + assert event_target_uri == self.refs[0].uri + assert event_target_action == self.frontend.settings['change_track_next'] + + def test_get_event_targets_change_track_previous(self): + self.history.insert(0, (int(time.time() * 1000), self.refs[0])) + time.sleep(0.01) + self.frontend.set_click_marker(self.tl_tracks[0]) + time.sleep(0.01) + self.history.insert(0, (int(time.time() * 1000), self.refs[0])) + + event_target_uri, event_target_action = self.frontend._get_event_targets('change_track') + assert event_target_uri == self.refs[0].uri + assert event_target_action == self.frontend.settings['change_track_previous'] + + def test_get_event_targets_resume(self): + self.history.insert(0, (int(time.time() * 1000), self.refs[0])) + time.sleep(0.01) + self.frontend.set_click_marker(self.tl_tracks[0]) + + event_target_uri, event_target_action = self.frontend._get_event_targets('resume') + assert event_target_uri == self.refs[0].uri + assert event_target_action == self.frontend.settings['resume'] + + def test_get_event_targets_stop(self): + self.history.insert(0, (int(time.time() * 1000), self.refs[0])) + time.sleep(0.01) + self.frontend.set_click_marker(self.tl_tracks[0]) + + event_target_uri, event_target_action = self.frontend._get_event_targets('stop') + assert event_target_uri == self.refs[0].uri + assert event_target_action == self.frontend.settings['stop'] From 5da33342525c9852f1411de5e1e961aa6c4e46b3 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 10 Jan 2016 16:22:43 +0200 Subject: [PATCH 225/311] Replace pause->stop event with safer and more reliable triple, click. --- README.rst | 4 +- mopidy_pandora/__init__.py | 12 ++-- mopidy_pandora/ext.conf | 2 +- mopidy_pandora/frontend.py | 39 ++++------ mopidy_pandora/listener.py | 6 +- mopidy_pandora/playback.py | 6 +- tests/conftest.py | 2 +- tests/test_extension.py | 4 +- tests/test_frontend.py | 144 +++++++++++++++++++------------------ tests/test_listener.py | 10 +-- tests/test_playback.py | 4 +- 11 files changed, 114 insertions(+), 119 deletions(-) diff --git a/README.rst b/README.rst index 1dc8f75..112f60a 100644 --- a/README.rst +++ b/README.rst @@ -136,8 +136,8 @@ pause/play/previous/next buttons. 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_stop_click``: click pause and then stop in quick succession. Calls event. Defaults to - ``delete_station``. +- ``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 include: ``thumbs_up``, ``thumbs_down``, ``sleep``, ``add_artist_bookmark``, ``add_song_bookmark``, and ``delete_station``. diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index cee46cc..eed5c3a 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -55,12 +55,12 @@ def get_config_schema(self): 'add_artist_bookmark', 'add_song_bookmark', 'delete_station']) - schema['on_pause_stop_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): diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index 647dbbd..22d2be0 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -18,4 +18,4 @@ double_click_interval = 2.50 on_pause_resume_click = thumbs_up on_pause_next_click = thumbs_down on_pause_previous_click = sleep -on_pause_stop_click = delete_station +on_pause_resume_pause_click = delete_station diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 9a3ed0b..679373b 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -126,7 +126,7 @@ def is_station_changed(self, track): pass return False - def changing_track(self, track): + def track_changed(self, track): if self.is_station_changed(track): # Station has changed, remove tracks from previous station from tracklist. self._trim_tracklist(keep_only=track) @@ -191,14 +191,14 @@ def __init__(self, config, core): 'resume': config['pandora'].get('on_pause_resume_click'), 'change_track_next': config['pandora'].get('on_pause_next_click'), 'change_track_previous': config['pandora'].get('on_pause_previous_click'), - 'stop': config['pandora'].get('on_pause_stop_click') + 'triple_click': config['pandora'].get('on_pause_resume_pause_click') } self.double_click_interval = float(config['pandora'].get('double_click_interval')) self._click_marker = None - self.change_track_event = threading.Event() - self.change_track_event.set() + self._triple_click_event = threading.Event() + self._triple_click_event.set() @only_execute_for_pandora_uris def track_playback_paused(self, tl_track, time_position): @@ -208,7 +208,10 @@ def track_playback_paused(self, tl_track, time_position): """ if time_position > 0: - self.set_click_marker(tl_track) + if not self._triple_click_event.isSet(): + self._triple_click_event.set() + else: + self.set_click_marker(tl_track) super(EventHandlingPandoraFrontend, self).track_playback_paused(tl_track, time_position) @only_execute_for_pandora_uris @@ -218,27 +221,13 @@ def track_playback_resumed(self, tl_track, time_position): """ if self._is_double_click(): - self._queue_event(event='resume') + self._queue_event('resume', self._triple_click_event, 'triple_click', timeout=self.double_click_interval) super(EventHandlingPandoraFrontend, self).track_playback_resumed(tl_track, time_position) - @only_execute_for_pandora_uris - def playback_state_changed(self, old_state, new_state): - """ - Used to detect pause -> stop, pause -> previous, and pause -> next double click events. - - """ - if old_state == PlaybackState.PAUSED and new_state == PlaybackState.STOPPED: - if self._is_double_click(): - # Mopidy (as of 1.1.2) always forces a call to core.playback.stop() when the track changes, even - # if the user did not click stop explicitly. We need to wait for a 'change_track' event - # immediately thereafter to know if this is a real track stop, or just a transition to the - # next/previous track. - self._queue_event('stop', self.change_track_event, 'change_track', timeout=self.double_click_interval) - super(EventHandlingPandoraFrontend, self).playback_state_changed(old_state, new_state) - - def changing_track(self, track): - self.change_track_event.set() - super(EventHandlingPandoraFrontend, self).changing_track(track) + def track_changed(self, track): + if self._is_double_click(): + self._queue_event('change_track') + super(EventHandlingPandoraFrontend, self).track_changed(track) def set_click_marker(self, tl_track, click_time=None): if click_time is None: @@ -276,6 +265,8 @@ def _queue_event(self, event, threading_event=None, override_event=None, timeout threading_event.clear() if threading_event.wait(timeout=timeout): event = override_event + else: + threading_event.set() self.process_event(event=event) diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 089258f..7201864 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -95,14 +95,14 @@ class PandoraPlaybackListener(listener.Listener): def send(event, **kwargs): listener.send(PandoraPlaybackListener, event, **kwargs) - def changing_track(self, track): + def track_changed(self, track): """ - Called when a track change has been initiated. Let's the frontend know that it should probably expand the + Called when a track has been changed. 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. This is also the earliest point at which we can detect a 'previous' or 'next' action performed by the user. - :param track: the Pandora track that is being changed to. + :param track: the Pandora track that was changed to. :type track: :class:`mopidy.models.Ref` """ pass diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 32002a0..7a465bb 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -60,9 +60,9 @@ def change_track(self, track): logger.warning("No URI for Pandora track '{}'. Track cannot be played.".format(track)) return False try: - self._trigger_changing_track(track) self.check_skip_limit() self.change_pandora_track(track) + self._trigger_track_changed(track) return super(PandoraPlaybackProvider, self).change_track(track) except KeyError: @@ -81,8 +81,8 @@ def check_skip_limit(self): def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url - def _trigger_changing_track(self, track): - listener.PandoraPlaybackListener.send('changing_track', track=track) + def _trigger_track_changed(self, track): + listener.PandoraPlaybackListener.send('track_changed', track=track) def _trigger_track_unplayable(self, track): listener.PandoraPlaybackListener.send('track_unplayable', track=track) diff --git a/tests/conftest.py b/tests/conftest.py index 704acab..713677f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,7 +71,7 @@ def config(): 'on_pause_resume_click': 'thumbs_up', 'on_pause_next_click': 'thumbs_down', 'on_pause_previous_click': 'sleep', - 'on_pause_stop_click': 'delete_station', + 'on_pause_resume_pause_click': 'delete_station', } } diff --git a/tests/test_extension.py b/tests/test_extension.py index a92a5b4..fffc763 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -36,7 +36,7 @@ def test_get_default_config(self): self.assertIn('on_pause_resume_click = thumbs_up', config) self.assertIn('on_pause_next_click = thumbs_down', config) self.assertIn('on_pause_previous_click = sleep', config) - self.assertIn('on_pause_stop_click = delete_station', config) + self.assertIn('on_pause_resume_pause_click = delete_station', config) def test_get_config_schema(self): ext = Extension() @@ -61,7 +61,7 @@ def test_get_config_schema(self): self.assertIn('on_pause_resume_click', schema) self.assertIn('on_pause_next_click', schema) self.assertIn('on_pause_previous_click', schema) - self.assertIn('on_pause_stop_click', schema) + self.assertIn('on_pause_resume_pause_click', schema) def test_setup(self): registry = mock.Mock() diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 3a038e8..c781a26 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -18,7 +18,7 @@ from mopidy_pandora import frontend from mopidy_pandora.frontend import EventHandlingPandoraFrontend, PandoraFrontend -from mopidy_pandora.listener import PandoraBackendListener +from mopidy_pandora.listener import PandoraBackendListener, PandoraPlaybackListener from tests import conftest, dummy_backend from tests.dummy_backend import DummyBackend, DummyPandoraBackend @@ -247,7 +247,7 @@ def test_station_change_does_not_trim_currently_playing_track_from_tracklist(sel self.core.tracklist.clear().get() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) - self.frontend.changing_track(self.tl_tracks[0].track).get() + self.frontend.track_changed(self.tl_tracks[0].track).get() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. tl_tracks = self.core.tracklist.get_tl_tracks().get() @@ -292,7 +292,7 @@ def test_changing_track_no_op(self): assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) self.replay_events(self.frontend) - self.frontend.changing_track(self.tl_tracks[1].track).get() + self.frontend.track_changed(self.tl_tracks[1].track).get() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) @@ -306,7 +306,7 @@ def test_changing_track_station_changed(self): assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - self.frontend.changing_track(self.tl_tracks[4].track).get() + self.frontend.track_changed(self.tl_tracks[4].track).get() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. tl_tracks = self.core.tracklist.get_tl_tracks().get() @@ -361,18 +361,22 @@ def test_events_processed_on_resume_stop_and_change_track_actions(self): self.core.playback.pause().get() self.core.playback.resume().get() self.replay_events(self.frontend) + thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) - assert process_mock.called + process_mock.assert_called_with(event='resume') process_mock.reset_mock() self.events = Queue.Queue() - # Pause -> Stop + # Pause -> Resume -> Pause + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.core.playback.resume().get() self.core.playback.pause().get() - self.core.playback.stop().get() self.replay_events(self.frontend) - time.sleep(self.frontend.double_click_interval.get() + 0.1) # Wait for 'change_track' timeout + thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) - assert process_mock.called + process_mock.assert_called_with(event='triple_click') process_mock.reset_mock() self.events = Queue.Queue() @@ -381,6 +385,7 @@ def test_events_processed_on_resume_stop_and_change_track_actions(self): self.core.playback.seek(100).get() self.core.playback.pause().get() self.core.playback.next().get() + listener.send(PandoraPlaybackListener, 'track_changed', track=self.tl_tracks[1].track) self.replay_events(self.frontend) thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. @@ -409,7 +414,7 @@ def test_get_event_targets_change_next(self): self.core.playback.next().get() self.replay_events(self.frontend) - self.frontend.changing_track(track=self.tl_tracks[1].track).get() + self.frontend.track_changed(track=self.tl_tracks[1].track).get() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert all(self.has_events([ @@ -430,7 +435,7 @@ def test_get_event_targets_change_previous(self): self.core.playback.previous().get() self.replay_events(self.frontend) - self.frontend.changing_track(track=self.tl_tracks[0].track).get() + self.frontend.track_changed(track=self.tl_tracks[0].track).get() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert all(self.has_events([ @@ -441,22 +446,24 @@ def test_get_event_targets_change_previous(self): })])) def test_get_event_targets_resume(self): - """ - Pause -> Resume + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + """ + Pause -> Resume - """ - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() - self.core.playback.resume().get() - self.replay_events(self.frontend, until='track_playback_resumed') + """ + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.core.playback.resume().get() + self.replay_events(self.frontend, until='track_playback_resumed') - assert all(self.has_events([ - ('event_triggered', - { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': self.frontend.settings.get()['resume'] - })])) + thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) + assert all(self.has_events([ + ('event_triggered', + { + 'track_uri': self.tl_tracks[0].track.uri, + 'pandora_event': self.frontend.settings.get()['resume'] + })])) def test_pause_starts_double_click_timer(self): assert self.frontend.get_click_marker().get() is None @@ -525,49 +532,55 @@ def test_process_event_resumes_playback_for_change_track(self): "Failed to set playback for action '{}'".format(action)) def test_process_event_triggers_event(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() - self.replay_events(self.frontend) - self.core.playback.resume().get() - self.replay_events(self.frontend, until='track_playback_resumed') + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.replay_events(self.frontend) + self.core.playback.resume().get() + self.replay_events(self.frontend, until='track_playback_resumed') - assert all(self.has_events([ - ('event_triggered', - { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': self.frontend.settings.get()['resume'] - })])) + thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) + assert all(self.has_events([ + ('event_triggered', + { + 'track_uri': self.tl_tracks[0].track.uri, + 'pandora_event': self.frontend.settings.get()['resume'] + })])) def test_process_event_resets_click_marker(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() - self.replay_events(self.frontend) - self.core.playback.resume().get() - self.replay_events(self.frontend, until='track_playback_resumed') + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.replay_events(self.frontend) + self.core.playback.resume().get() + self.replay_events(self.frontend, until='track_playback_resumed') - assert self.frontend.get_click_marker().get() is None + thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) + assert self.frontend.get_click_marker().get() is None def test_playback_state_changed_handles_stop(self): - """ - Pause -> Stop - - """ - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() - self.core.playback.stop().get() - self.replay_events(self.frontend) + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + """ + Pause -> Resume -> Pause - time.sleep(float(self.frontend.double_click_interval.get() + 0.1)) + """ + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.seek(100).get() + self.core.playback.pause().get() + self.core.playback.resume().get() + self.replay_events(self.frontend) + self.core.playback.pause().get() + self.replay_events(self.frontend, until='track_playback_paused') - assert all(self.has_events([ - ('event_triggered', - { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': self.frontend.settings.get()['stop'] - })])) + thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. + assert all(self.has_events([ + ('event_triggered', + { + 'track_uri': self.tl_tracks[0].track.uri, + 'pandora_event': self.frontend.settings.get()['triple_click'] + })])) def test_playback_state_changed_handles_change_track(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: @@ -577,7 +590,7 @@ def test_playback_state_changed_handles_change_track(self): self.core.playback.next().get() self.replay_events(self.frontend) - self.frontend.changing_track(self.tl_tracks[1].track).get() + self.frontend.track_changed(self.tl_tracks[1].track).get() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert all(self.has_events([ @@ -683,12 +696,3 @@ def test_get_event_targets_resume(self): event_target_uri, event_target_action = self.frontend._get_event_targets('resume') assert event_target_uri == self.refs[0].uri assert event_target_action == self.frontend.settings['resume'] - - def test_get_event_targets_stop(self): - self.history.insert(0, (int(time.time() * 1000), self.refs[0])) - time.sleep(0.01) - self.frontend.set_click_marker(self.tl_tracks[0]) - - event_target_uri, event_target_action = self.frontend._get_event_targets('stop') - assert event_target_uri == self.refs[0].uri - assert event_target_action == self.frontend.settings['stop'] diff --git a/tests/test_listener.py b/tests/test_listener.py index 2bea2a1..11a55ad 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -71,15 +71,15 @@ def setUp(self): # noqa: N802 self.listener = listener.PandoraPlaybackListener() def test_on_event_forwards_to_specific_handler(self): - self.listener.changing_track = mock.Mock() + self.listener.track_changed = mock.Mock() self.listener.on_event( - 'changing_track', track=models.Ref(name='name_mock')) + 'track_changed', track=models.Ref(name='name_mock')) - self.listener.changing_track.assert_called_with(track=models.Ref(name='name_mock')) + self.listener.track_changed.assert_called_with(track=models.Ref(name='name_mock')) - def test_listener_has_default_impl_for_changing_track(self): - self.listener.changing_track(track=models.Ref(name='name_mock')) + def test_listener_has_default_impl_for_track_changed(self): + self.listener.track_changed(track=models.Ref(name='name_mock')) def test_listener_has_default_impl_for_track_unplayable(self): self.listener.track_unplayable(track=models.Ref(name='name_mock')) diff --git a/tests/test_playback.py b/tests/test_playback.py index eb9fdb3..7d08f68 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -150,10 +150,10 @@ def test_change_track_triggers_event_on_success(provider, playlist_item_mock): with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=True): track = PandoraUri.factory(playlist_item_mock) - provider._trigger_changing_track = mock.PropertyMock() + provider._trigger_track_changed = mock.PropertyMock() assert provider.change_track(track) is True - assert provider._trigger_changing_track.called + assert provider._trigger_track_changed.called def test_translate_uri_returns_audio_url(provider, playlist_item_mock): From 989fee4943cdf017e1674a932a48730380fea069 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 10 Jan 2016 16:37:34 +0200 Subject: [PATCH 226/311] Better semantic handling of triple click events. --- mopidy_pandora/frontend.py | 7 ++++--- tests/test_frontend.py | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 679373b..33932f6 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -208,10 +208,11 @@ def track_playback_paused(self, tl_track, time_position): """ if time_position > 0: - if not self._triple_click_event.isSet(): - self._triple_click_event.set() - else: + if self.get_click_marker() is None: self.set_click_marker(tl_track) + else: + self._triple_click_event.set() + super(EventHandlingPandoraFrontend, self).track_playback_paused(tl_track, time_position) @only_execute_for_pandora_uris diff --git a/tests/test_frontend.py b/tests/test_frontend.py index c781a26..ea2ada1 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -351,7 +351,7 @@ def test_delete_station_clears_tracklist_on_finish(self): assert len(self.core.tracklist.get_tl_tracks().get()) == 0 - def test_events_processed_on_resume_stop_and_change_track_actions(self): + def test_events_processed_on_resume_action(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: @@ -361,12 +361,13 @@ def test_events_processed_on_resume_stop_and_change_track_actions(self): self.core.playback.pause().get() self.core.playback.resume().get() self.replay_events(self.frontend) - thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) + thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) process_mock.assert_called_with(event='resume') - process_mock.reset_mock() - self.events = Queue.Queue() + def test_events_processed_on_triple_click_action(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: # Pause -> Resume -> Pause self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.core.playback.seek(100).get() @@ -374,11 +375,13 @@ def test_events_processed_on_resume_stop_and_change_track_actions(self): self.core.playback.resume().get() self.core.playback.pause().get() self.replay_events(self.frontend) - thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) + thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) process_mock.assert_called_with(event='triple_click') - process_mock.reset_mock() - self.events = Queue.Queue() + + def test_events_processed_on_change_track_action(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: # Change track self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -390,8 +393,6 @@ def test_events_processed_on_resume_stop_and_change_track_actions(self): thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert process_mock.called - process_mock.reset_mock() - self.events = Queue.Queue() def test_get_event_targets_invalid_event_no_op(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() From 0f0ffa8c5bb2056d4f920d8574efc5dcca362ae0 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 11 Jan 2016 08:55:44 +0200 Subject: [PATCH 227/311] Reduce number of blocking calls to core actor. --- mopidy_pandora/frontend.py | 22 +++++++++++++--------- mopidy_pandora/playback.py | 4 ++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 33932f6..1ef1831 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -73,14 +73,18 @@ def __init__(self, config, core): 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_repeat().get() is True: - self.core.tracklist.set_repeat(False) 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 @@ -140,7 +144,7 @@ def track_unplayable(self, track): self._trigger_end_of_tracklist_reached(PandoraUri.factory(track).station_id, auto_play=True) - self.core.tracklist.remove({'uri': [track.uri]}).get() + self.core.tracklist.remove({'uri': [track.uri]}) def next_track_available(self, track, auto_play=False): if track: @@ -154,10 +158,10 @@ def skip_limit_exceeded(self): def add_track(self, track, auto_play=False): # Add the next Pandora track - self.core.tracklist.add(uris=[track.uri]).get() + 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).get() + self.core.playback.play(tlid=tl_tracks[-1].tlid) self._trim_tracklist(maxsize=2) def _trim_tracklist(self, keep_only=None, maxsize=2): @@ -165,15 +169,15 @@ def _trim_tracklist(self, keep_only=None, maxsize=2): 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 len(self.core.tracklist.remove({'tlid': trim_tlids}).get()) + 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 len(self.core.tracklist.remove( + return self.core.tracklist.remove( {'tlid': [tl_tracks[t].tlid for t in range(0, len(tl_tracks)-maxsize)]} - ).get()) + ) 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) @@ -285,7 +289,7 @@ def process_event(self, event): self._trigger_event_triggered(event_target_uri, event_target_action) # Resume playback... if event == 'change_track' and self.core.playback.get_state().get() != PlaybackState.PLAYING: - self.core.playback.resume().get() + self.core.playback.resume() def _get_event_targets(self, action=None): if action == 'change_track': diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 7a465bb..63df909 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -23,11 +23,11 @@ def __init__(self, audio, backend): 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).get() + # 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())).get() + # 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. From c061f0e0a18018f6868f25183fb99432e4793beb Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 11 Jan 2016 09:13:09 +0200 Subject: [PATCH 228/311] Reduce number of blocking calls to core in test cases. --- tests/test_frontend.py | 126 ++++++++++++++++++++--------------------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index ea2ada1..0f4a4ec 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -125,7 +125,7 @@ def tearDown(self): # noqa: N802 def test_add_track_starts_playback(self): assert self.core.playback.get_state().get() == PlaybackState.STOPPED - self.core.tracklist.clear().get() + self.core.tracklist.clear() self.frontend.add_track(self.tl_tracks[0].track, auto_play=True).get() assert self.core.playback.get_state().get() == PlaybackState.PLAYING @@ -135,7 +135,7 @@ def test_add_track_trims_tracklist(self): assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) # Remove first track so we can add it again - self.core.tracklist.remove({'tlid': [self.tl_tracks[0].tlid]}).get() + self.core.tracklist.remove({'tlid': [self.tl_tracks[0].tlid]}) self.frontend.add_track(self.tl_tracks[0].track).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() @@ -147,15 +147,15 @@ def test_only_execute_for_pandora_executes_for_pandora_uri(self): func_mock.__name__ = str('func_mock') func_mock.return_value = True - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) frontend.only_execute_for_pandora_uris(func_mock)(self) assert func_mock.called def test_next_track_available_adds_track_to_playlist(self): - self.core.tracklist.clear().get() + self.core.tracklist.clear() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.frontend.next_track_available(self.tl_tracks[1].track, True).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() @@ -163,7 +163,7 @@ def test_next_track_available_adds_track_to_playlist(self): assert self.core.playback.get_current_track().get() == self.tl_tracks[1].track def test_next_track_available_forces_stop_if_no_more_tracks(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) assert self.core.playback.get_state().get() == PlaybackState.PLAYING self.frontend.next_track_available(None).get() @@ -174,7 +174,7 @@ def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): func_mock.__name__ = str('func_mock') func_mock.return_value = True - self.core.playback.play(tlid=self.tl_tracks[3].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[3].tlid) frontend.only_execute_for_pandora_uris(func_mock)(self) assert not func_mock.called @@ -188,10 +188,10 @@ def test_options_changed_triggers_etup(self): def test_set_options_performs_auto_setup(self): assert self.frontend.setup_required.get() - self.core.tracklist.set_repeat(True).get() - self.core.tracklist.set_consume(False).get() - self.core.tracklist.set_random(True).get() - self.core.tracklist.set_single(True).get() + self.core.tracklist.set_repeat(True) + self.core.tracklist.set_consume(False) + self.core.tracklist.set_random(True) + self.core.tracklist.set_single(True) self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.replay_events(self.frontend) @@ -234,7 +234,7 @@ def test_set_options_triggered_on_core_events(self): set_options_mock.reset_mock() def test_skip_limit_exceed_stops_playback(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) assert self.core.playback.get_state().get() == PlaybackState.PLAYING self.frontend.skip_limit_exceeded().get() @@ -244,7 +244,7 @@ def test_station_change_does_not_trim_currently_playing_track_from_tracklist(sel with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: with mock.patch.object(PandoraFrontend, 'is_station_changed', mock.Mock(return_value=True)): - self.core.tracklist.clear().get() + self.core.tracklist.clear() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) self.frontend.track_changed(self.tl_tracks[0].track).get() @@ -255,28 +255,28 @@ def test_station_change_does_not_trim_currently_playing_track_from_tracklist(sel assert tl_tracks[0].track == self.tl_tracks[0].track def test_is_end_of_tracklist_reached(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) assert not self.frontend.is_end_of_tracklist_reached().get() def test_is_end_of_tracklist_reached_last_track(self): - self.core.playback.play(tlid=self.tl_tracks[-1].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[-1].tlid) assert self.frontend.is_end_of_tracklist_reached().get() def test_is_end_of_tracklist_reached_no_tracks(self): - self.core.tracklist.clear().get() + self.core.tracklist.clear() assert self.frontend.is_end_of_tracklist_reached().get() def test_is_end_of_tracklist_reached_second_last_track(self): - self.core.playback.play(tlid=self.tl_tracks[3].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[3].tlid) assert not self.frontend.is_end_of_tracklist_reached(self.tl_tracks[3].track).get() def test_is_station_changed(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.next().get() # Add track to history + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.next() # Check against track of a different station assert self.frontend.is_station_changed(self.tl_tracks[4].track).get() @@ -286,8 +286,8 @@ def test_is_station_changed_no_history(self): def test_changing_track_no_op(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.next().get() # Add track to history + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.next() assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) self.replay_events(self.frontend) @@ -300,7 +300,7 @@ def test_changing_track_no_op(self): def test_changing_track_station_changed(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.play(tlid=self.tl_tracks[4].tlid).get() self.replay_events(self.frontend) @@ -336,7 +336,7 @@ class TestEventHandlingFrontend(BaseTest): def setUp(self): # noqa: N802 super(TestEventHandlingFrontend, self).setUp() self.frontend = frontend.EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - self.core.tracklist.set_consume(True).get() # Set consume mode so that tracklist behaves as expected. + self.core.tracklist.set_consume(True) # Set consume mode so that tracklist behaves as expected. def tearDown(self): # noqa: N802 super(TestEventHandlingFrontend, self).tearDown() @@ -356,9 +356,9 @@ def test_events_processed_on_resume_action(self): with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: # Pause -> Resume - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() self.core.playback.resume().get() self.replay_events(self.frontend) @@ -369,10 +369,10 @@ def test_events_processed_on_triple_click_action(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: # Pause -> Resume -> Pause - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() - self.core.playback.resume().get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() + self.core.playback.resume() self.core.playback.pause().get() self.replay_events(self.frontend) @@ -384,9 +384,9 @@ def test_events_processed_on_change_track_action(self): with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: # Change track - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() self.core.playback.next().get() listener.send(PandoraPlaybackListener, 'track_changed', track=self.tl_tracks[1].track) self.replay_events(self.frontend) @@ -395,8 +395,8 @@ def test_events_processed_on_change_track_action(self): assert process_mock.called def test_get_event_targets_invalid_event_no_op(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) self.core.playback.pause().get() self.replay_events(self.frontend) @@ -409,9 +409,9 @@ def test_get_event_targets_change_next(self): """ with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() self.core.playback.next().get() self.replay_events(self.frontend) @@ -430,9 +430,9 @@ def test_get_event_targets_change_previous(self): Pause -> Previous """ with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() self.core.playback.previous().get() self.replay_events(self.frontend) @@ -452,9 +452,9 @@ def test_get_event_targets_resume(self): Pause -> Resume """ - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() self.core.playback.resume().get() self.replay_events(self.frontend, until='track_playback_resumed') @@ -468,8 +468,8 @@ def test_get_event_targets_resume(self): def test_pause_starts_double_click_timer(self): assert self.frontend.get_click_marker().get() is None - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) self.core.playback.pause().get() self.replay_events(self.frontend) @@ -477,18 +477,18 @@ def test_pause_starts_double_click_timer(self): def test_pause_does_not_start_timer_at_track_start(self): assert self.frontend.get_click_marker().get() is None - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.pause().get() self.replay_events(self.frontend) - self.frontend.track_playback_paused(mock.Mock(), 0).get() + self.frontend.track_playback_paused(mock.Mock(), 0) assert self.frontend.get_click_marker().get() is None def test_process_event_handles_exception(self): with mock.patch.object(frontend.EventHandlingPandoraFrontend, '_get_event_targets', mock.PropertyMock(return_value=None, side_effect=KeyError('error_mock'))): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) self.core.playback.pause().get() self.replay_events(self.frontend) @@ -498,7 +498,7 @@ def test_process_event_handles_exception(self): assert self.events.qsize() == 0 # Check that no events were triggered def test_process_event_ignores_ads(self): - self.core.playback.play(tlid=self.tl_tracks[2].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[2].tlid) self.core.playback.seek(100) self.core.playback.pause().get() self.replay_events(self.frontend) @@ -513,14 +513,14 @@ def test_process_event_resumes_playback_for_change_track(self): for action in actions: self.events = Queue.Queue() # Make sure that the queue is empty - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) self.core.playback.pause().get() self.replay_events(self.frontend) assert self.core.playback.get_state().get() == PlaybackState.PAUSED if action == 'change_track': - self.core.playback.next().get() + self.core.playback.next() self.frontend.process_event(event=action).get() self.assertEqual(self.core.playback.get_state().get(), @@ -534,8 +534,8 @@ def test_process_event_resumes_playback_for_change_track(self): def test_process_event_triggers_event(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) self.core.playback.pause().get() self.replay_events(self.frontend) self.core.playback.resume().get() @@ -551,8 +551,8 @@ def test_process_event_triggers_event(self): def test_process_event_resets_click_marker(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) self.core.playback.pause().get() self.replay_events(self.frontend) self.core.playback.resume().get() @@ -567,9 +567,9 @@ def test_playback_state_changed_handles_stop(self): Pause -> Resume -> Pause """ - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() self.core.playback.resume().get() self.replay_events(self.frontend) self.core.playback.pause().get() @@ -585,9 +585,9 @@ def test_playback_state_changed_handles_stop(self): def test_playback_state_changed_handles_change_track(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.core.playback.seek(100).get() - self.core.playback.pause().get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() self.core.playback.next().get() self.replay_events(self.frontend) From 368ad6b31efb00f8c95979a9396d235067af1403 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 14 Jan 2016 06:31:04 +0200 Subject: [PATCH 229/311] WIP: split into seperate threads. --- mopidy_pandora/listener.py | 29 +++++ mopidy_pandora/monitor.py | 227 +++++++++++++++++++++++++++++++++++++ tests/test_frontend.py | 128 --------------------- tests/test_monitor.py | 225 ++++++++++++++++++++++++++++++++++++ 4 files changed, 481 insertions(+), 128 deletions(-) create mode 100644 mopidy_pandora/monitor.py create mode 100644 tests/test_monitor.py diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 7201864..560a810 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -2,6 +2,35 @@ from mopidy import backend, listener +class EventMonitorListener(listener.Listener): + + """ + Marker interface for recipients of events sent by the frontend actor. + + """ + + @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): + pass + + def track_changed_next(self, old_uri, new_uri): + pass + class PandoraFrontendListener(listener.Listener): diff --git a/mopidy_pandora/monitor.py b/mopidy_pandora/monitor.py new file mode 100644 index 0000000..aac104b --- /dev/null +++ b/mopidy_pandora/monitor.py @@ -0,0 +1,227 @@ +from __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 mopidy import core +from mopidy.audio import PlaybackState + +import pykka + +from mopidy_pandora import listener +from mopidy_pandora.frontend import only_execute_for_pandora_uris +from mopidy_pandora.uri import AdItemUri, PandoraUri +from mopidy_pandora.utils import run_async + +logger = logging.getLogger(__name__) + +EventMarker = namedtuple('EventMarker', 'event, uri, time') + +class EventMonitor(pykka.ThreadingActor, core.CoreListener, + listener.PandoraBackendListener, + listener.PandoraPlaybackListener, + listener.PandoraFrontendListener): + + def __init__(self, config, core): + super(EventMonitor, self).__init__() + + self.core = core + self.event_sequences = [] + self.monitor_running_event = threading.Event() + self.monitor_running_event.clear() + self._track_changed_marker = None + + self.config = config['pandora'] + + def on_start(self): + interval = float(self.config['double_click_interval']) + self.event_sequences.append(EventSequence(self.config['on_pause_resume_click'], + ['track_playback_paused', + 'track_playback_resumed'], self, interval=interval)) + + self.event_sequences.append(EventSequence(self.config['on_pause_resume_pause_click'], + ['track_playback_paused', + 'track_playback_resumed', + 'track_playback_paused'], self, interval=interval)) + + self.event_sequences.append(EventSequence(self.config['on_pause_previous_click'], + ['track_playback_paused', + 'preparing_track'], self, + wait_for='track_changed_previous', interval=interval)) + + self.event_sequences.append(EventSequence(self.config['on_pause_next_click'], + ['track_playback_paused', + 'preparing_track'], self, + wait_for='track_changed_next', interval=interval)) + + self.sequence_match_results = Queue.Queue(maxsize=len(self.event_sequences)) + + @only_execute_for_pandora_uris + def on_event(self, event, **kwargs): + # Check if this event is covered by one of the defined event sequences + for es in self.event_sequences: + es.notify(event, **kwargs) + + self._detect_track_change(event, **kwargs) + + def sequence_stopped(self): + if not any([es.is_running() for es in self.event_sequences]): + self.all_stopped.set() + + + self.sequence_match_results.put(result_tup) + if self.sequence_match_results.full(): + ratios = [] + self.sequence_match_results.join() + while True: + try: + ratios.append(self.sequence_match_results.get_nowait()) + self.sequence_match_results.task_done() + except Queue.Empty: + ratios.sort(key=lambda es: es.get_ratio()) + self._trigger_event_triggered(ratios[0].on_match_event, ratios[0].target_uri) + + # @classmethod + # def priority_match(cls, es_list): + # ratios = [] + # for es in es_list: + # ratios.append((es, es.get_ratio())) + # + # ratios.sort(key=lambda tup: tup[1]) + # if ratios[-1][0].strict and ratios[-1][1] != 1.0: + # return [] + # + # return [r[0] for r in ratios if r[0] >= ratios[-1][0]] + + def _detect_track_change(self, event, **kwargs): + if event == 'track_playback_ended': + self._track_changed_marker = EventMarker('track_playback_ended', + kwargs['tl_track'].track.uri, + int(time.time() * 1000)) + + elif event in ['track_playback_started', 'track_playback_resumed']: + try: + change_direction = self._get_track_change_direction(self._track_changed_marker) + self._trigger_track_changed(change_direction, + old_uri=self._track_changed_marker.uri, + new_uri=kwargs['tl_track'].track.uri) + except KeyError: + # Must be playing the first track, ignore + pass + + def process_event(self, event): + try: + event = self._get_track_change_direction(event) + except KeyError: + logger.exception("Error processing Pandora event '{}', ignoring...".format(event)) + return + else: + self._trigger_event_triggered(event, self._event_markers.uri) + # Resume playback... + if event == 'change_track' and self.core.playback.get_state().get() != PlaybackState.PLAYING: + self.core.playback.resume() + + def _get_track_change_direction(self, track_marker): + history = self.core.history.get_history().get() + for i, h in enumerate(history): + if h[0] < 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 clicked 'previous' in consume mode. + return 'track_changed_previous' + else: + # Switched to another track, user clicked 'next'. + return 'track_changed_next' + + def _trigger_event_triggered(self, event, uri): + (listener.PandoraEventHandlingFrontendListener.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, monitor, interval=1.0, strict=False, wait_for=None): + self.on_match_event = on_match_event + self.target_sequence = target_sequence + self.monitor = monitor + 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 + + @classmethod + def match_sequence_list(cls, a, b): + sm = SequenceMatcher(a=' '.join(a), b=' '.join(b)) + return sm.ratio() + + def notify(self, event, **kwargs): + if self.is_running(): + self.events_seen.append(event) + 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: + tl_track = kwargs.get('tl_track', None) + if tl_track: + uri = tl_track.track.uri + else: + uri = None + self.start_monitor(uri) + + if not self.wait_for_event.is_set() and self.wait_for == event: + self.wait_for_event.set() + + def is_running(self): + return (self._timer and self._timer.is_alive()) or not self.wait_for_event.is_set() + + def start_monitor(self, uri): + # TODO: ad checking probably belongs somewhere else. + if type(PandoraUri.factory(uri)) is AdItemUri: + logger.info('Ignoring doubleclick event for Pandora advertisement...') + return + + self.target_uri = uri + self._timer = threading.Timer(self.interval, self.stop_monitor) + self._timer.daemon = True + self._timer.start() + + @run_async + def stop_monitor(self): + if self.wait_for_event.wait(timeout=60): + self.monitor.sequence_stopped() + + def reset(self): + self.wait_for_event.set() + self.events_seen = [] + self._timer = None + + def get_ratio(self): + ratio = EventSequence.match_sequence_list(self.events_seen, self.target_sequence) + if ratio < 1.0 and self.strict: + return 0 + return ratio diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 0f4a4ec..91daad7 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -351,34 +351,6 @@ def test_delete_station_clears_tracklist_on_finish(self): assert len(self.core.tracklist.get_tl_tracks().get()) == 0 - def test_events_processed_on_resume_action(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: - - # Pause -> Resume - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause() - self.core.playback.resume().get() - self.replay_events(self.frontend) - - thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) - process_mock.assert_called_with(event='resume') - - def test_events_processed_on_triple_click_action(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: - # Pause -> Resume -> Pause - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause() - self.core.playback.resume() - self.core.playback.pause().get() - self.replay_events(self.frontend) - - thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) - process_mock.assert_called_with(event='triple_click') - def test_events_processed_on_change_track_action(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: @@ -394,15 +366,6 @@ def test_events_processed_on_change_track_action(self): thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert process_mock.called - def test_get_event_targets_invalid_event_no_op(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause().get() - self.replay_events(self.frontend) - - self.frontend.process_event(event='invalid').get() - assert self.events.qsize() == 0 - def test_get_event_targets_change_next(self): """ Pause -> Next @@ -446,68 +409,6 @@ def test_get_event_targets_change_previous(self): 'pandora_event': self.frontend.settings.get()['change_track_previous'] })])) - def test_get_event_targets_resume(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - """ - Pause -> Resume - - """ - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause() - self.core.playback.resume().get() - self.replay_events(self.frontend, until='track_playback_resumed') - - thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) - assert all(self.has_events([ - ('event_triggered', - { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': self.frontend.settings.get()['resume'] - })])) - - def test_pause_starts_double_click_timer(self): - assert self.frontend.get_click_marker().get() is None - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause().get() - self.replay_events(self.frontend) - - assert self.frontend.get_click_marker().get().time > 0 - - def test_pause_does_not_start_timer_at_track_start(self): - assert self.frontend.get_click_marker().get() is None - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.pause().get() - self.replay_events(self.frontend) - - self.frontend.track_playback_paused(mock.Mock(), 0) - assert self.frontend.get_click_marker().get() is None - - def test_process_event_handles_exception(self): - with mock.patch.object(frontend.EventHandlingPandoraFrontend, '_get_event_targets', - mock.PropertyMock(return_value=None, side_effect=KeyError('error_mock'))): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause().get() - self.replay_events(self.frontend) - - self.core.playback.resume().get() - self.replay_events(self.frontend, until='track_playback_resumed') - - assert self.events.qsize() == 0 # Check that no events were triggered - - def test_process_event_ignores_ads(self): - self.core.playback.play(tlid=self.tl_tracks[2].tlid) - self.core.playback.seek(100) - self.core.playback.pause().get() - self.replay_events(self.frontend) - - self.core.playback.resume().get() - self.replay_events(self.frontend, until='track_playback_resumed') - - assert self.events.qsize() == 0 # Check that no events were triggered - def test_process_event_resumes_playback_for_change_track(self): actions = ['stop', 'change_track', 'resume'] @@ -532,35 +433,6 @@ def test_process_event_resumes_playback_for_change_track(self): PlaybackState.PAUSED, "Failed to set playback for action '{}'".format(action)) - def test_process_event_triggers_event(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause().get() - self.replay_events(self.frontend) - self.core.playback.resume().get() - self.replay_events(self.frontend, until='track_playback_resumed') - - thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) - assert all(self.has_events([ - ('event_triggered', - { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': self.frontend.settings.get()['resume'] - })])) - - def test_process_event_resets_click_marker(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause().get() - self.replay_events(self.frontend) - self.core.playback.resume().get() - self.replay_events(self.frontend, until='track_playback_resumed') - - thread_joiner.wait(timeout=self.frontend.double_click_interval.get() + 1) - assert self.frontend.get_click_marker().get() is None - def test_playback_state_changed_handles_stop(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: """ diff --git a/tests/test_monitor.py b/tests/test_monitor.py new file mode 100644 index 0000000..cc5c29a --- /dev/null +++ b/tests/test_monitor.py @@ -0,0 +1,225 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +import Queue + + +import unittest + +from mock import mock + +from mopidy import core, models + +import pykka +import pytest + +from mopidy_pandora import monitor +from mopidy_pandora.monitor import EventSequence + +from tests import conftest, dummy_backend +from tests.dummy_backend import DummyBackend, DummyPandoraBackend + + +class BaseTest(unittest.TestCase): + tracks = [ + models.Track(uri='pandora:track:id_mock:token_mock1', length=40000), # Regular track + models.Track(uri='pandora:track:id_mock:token_mock2', length=40000), # Regular track + models.Track(uri='pandora:ad:id_mock:token_mock3', length=40000), # Advertisement + models.Track(uri='mock:track:id_mock:token_mock4', length=40000), # Not a pandora track + models.Track(uri='pandora:track:id_mock_other:token_mock5', length=40000), # Different station + models.Track(uri='pandora:track:id_mock:token_mock6', length=None), # No duration + ] + + uris = [ + 'pandora:track:id_mock:token_mock1', 'pandora:track:id_mock:token_mock2', + 'pandora:ad:id_mock:token_mock3', 'mock:track:id_mock:token_mock4', + 'pandora:track:id_mock_other:token_mock5', 'pandora:track:id_mock:token_mock6'] + + def setUp(self): + config = {'core': {'max_tracklist_length': 10000}} + + self.backend = dummy_backend.create_proxy(DummyPandoraBackend) + self.non_pandora_backend = dummy_backend.create_proxy(DummyBackend) + + self.core = core.Core.start( + config, backends=[self.backend, self.non_pandora_backend]).proxy() + + def lookup(uris): + result = {uri: [] for uri in uris} + for track in self.tracks: + if track.uri in result: + result[track.uri].append(track) + return result + + self.core.library.lookup = lookup + self.tl_tracks = self.core.tracklist.add(uris=self.uris).get() + + self.events = Queue.Queue() + self.patcher = mock.patch('mopidy.listener.send') + self.core_patcher = mock.patch('mopidy.listener.send_async') + + self.send_mock = self.patcher.start() + self.core_send_mock = self.core_patcher.start() + + def send(cls, event, **kwargs): + self.events.put((event, kwargs)) + + self.send_mock.side_effect = send + self.core_send_mock.side_effect = send + + def tearDown(self): + pykka.ActorRegistry.stop_all() + mock.patch.stopall() + + def replay_events(self, listener, until=None): + while True: + try: + e = self.events.get(timeout=0.1) + event, kwargs = e + listener.on_event(event, **kwargs).get() + if e[0] == until: + break + except Queue.Empty: + # All events replayed. + break + + def has_events(self, events): + q = [] + while True: + try: + q.append(self.events.get(timeout=0.1)) + except Queue.Empty: + # All events replayed. + break + + return [e in q for e in events] + + +class EventMonitorTest(BaseTest): + def setUp(self): # noqa: N802 + super(EventMonitorTest, self).setUp() + self.monitor = monitor.EventMonitor.start(conftest.config(), self.core).proxy() + + es1 = EventSequence('delete_station', ['track_playback_paused', 'track_playback_resumed', 'track_playback_paused']) + es2 = EventSequence('thumbs_up', ['track_playback_paused', 'track_playback_resumed']) + es3 = EventSequence('thumbs_down', ['track_playback_paused', 'track_playback_ended', 'track_playback_resumed']) + es4 = EventSequence('sleep', ['track_playback_paused', 'track_playback_ended', 'track_playback_resumed']) + + es_list = [es1, es2, es3, es4] + + for es in EventMonitorTest.es_list: + self.monitor.add_event_sequence(es).get() + + def tearDown(self): # noqa: N802 + super(EventMonitorTest, self).tearDown() + + def test_events_processed_on_resume_action(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + # Pause -> Resume + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() + self.core.playback.resume().get() + self.replay_events(self.monitor) + + thread_joiner.wait(timeout=1.0) + assert all(self.has_events([('event_triggered', { + 'track_uri': self.tl_tracks[0].track.uri, + 'pandora_event': conftest.config()['pandora']['on_pause_resume_click'] + })])) + + def test_events_processed_on_triple_click_action(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + # Pause -> Resume -> Pause + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() + self.core.playback.resume() + self.core.playback.pause().get() + self.replay_events(self.monitor) + + thread_joiner.wait(timeout=1.0) + assert all(self.has_events([('event_triggered', { + 'track_uri': self.tl_tracks[0].track.uri, + 'pandora_event': conftest.config()['pandora']['on_pause_resume_pause_click'] + })])) + + def test_process_event_ignores_ads(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + self.core.playback.play(tlid=self.tl_tracks[2].tlid) + self.core.playback.seek(100) + self.core.playback.pause() + self.core.playback.resume().get() + self.replay_events(self.monitor) + + thread_joiner.wait(timeout=1.0) + assert self.events.qsize() == 0 # Check that no events were triggered + + def test_process_event_resets_event_marker(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + with pytest.raises(KeyError): + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() + self.core.playback.resume().get() + self.replay_events(self.monitor) + + thread_joiner.wait(timeout=self.monitor.double_click_interval.get() + 1) + self.monitor.get_event_marker('track_playback_paused').get() + + def test_process_event_handles_exception(self): + with mock.patch.object(monitor.EventMonitor, '_get_event', + mock.PropertyMock(return_value=None, side_effect=KeyError('error_mock'))): + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() + self.core.playback.resume().get() + self.replay_events(self.monitor) + + assert self.events.qsize() == 0 # Check that no events were triggered + + def test_trigger_starts_double_click_timer(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause().get() + self.replay_events(self.monitor) + + assert self.monitor.get_event_marker('track_playback_paused').get().time > 0 + + def test_trigger_does_not_start_timer_at_track_start(self): + with pytest.raises(KeyError): + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.pause().get() + self.replay_events(self.monitor) + + assert self.monitor.get_event_marker('track_playback_paused').get() + + +def test_match_sequence_on_longest(): + es1 = EventSequence('match_event_1', ['e1']) + es2 = EventSequence('match_event_1', ['e1', 'e2']) + es3 = EventSequence('match_event_1', ['e1', 'e2', 'e3']) + + es_list = [es1, es2, es3] + + es1.events_seen = ['e1'] + es2.events_seen = ['e1', 'e2'] + es3.events_seen = ['e1', 'e2', 'e3'] + + assert es1 in EventSequence.match_sequence_list(es_list) + + +def test_match_sequence_strict(): + es_list = [EventSequence('match_event_1', ['e1', 'e2', 'e3'], True)] + assert EventSequence.match_sequence_list(es_list, ['e1', 'e3']) is None + + +def test_match_sequence_partial(): + es1 = EventSequence('match_event_1', ['e1', 'e3']) + es2 = EventSequence('match_event_1', ['e1', 'e2', 'e3']) + es3 = EventSequence('match_event_1', ['e1', 'e2', 'e3', 'e4']) + + es_list = [es1, es2, es3] + + assert EventSequence.match_sequence_list(es_list, ['e1', 'e3']) == es1 + assert EventSequence.match_sequence_list(es_list, ['e1', 'e2', 'e3']) == es2 + assert EventSequence.match_sequence_list(es_list, ['e1', 'e3', 'e4']) == es3 From 0fe887d0ecb055121dcd2111dd355be840d4f6e9 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 14 Jan 2016 06:34:03 +0200 Subject: [PATCH 230/311] Fix README formatting. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 7859dbe..8740422 100644 --- a/README.rst +++ b/README.rst @@ -149,7 +149,7 @@ v0.2.0 (UNRELEASED) if you want to keep using events until the fix is available. v0.1.8 (Jan 8, 2016) ----------------------------------------- +-------------------- - Update dependencies: requires at least pydora 1.5.1. From f37a1d655a87747c6d36594999178a7a2c2a0929 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 14 Jan 2016 06:52:05 +0200 Subject: [PATCH 231/311] Fix maximum skip limit exception handling routine. --- mopidy_pandora/playback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 533c7f6..619d676 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -46,7 +46,7 @@ def change_pandora_track(self, track): else: raise Unplayable("Track with URI '{}' is not playable.".format(track.uri)) - except (AttributeError, requests.exceptions.RequestException) as e: + except (Unplayable, AttributeError, requests.exceptions.RequestException) as e: logger.warning('Error changing Pandora track: {}, ({})'.format(pandora_track, e)) # Track is not playable. self._consecutive_track_skips += 1 From 84dc925743632524b2a8c16cadf1a034b4370bdd Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 16 Jan 2016 07:27:03 +0200 Subject: [PATCH 232/311] WIP: migrate to using new event monitor. All tests passing. --- mopidy_pandora/__init__.py | 4 +- mopidy_pandora/frontend.py | 172 ++----------------- mopidy_pandora/listener.py | 28 +++- mopidy_pandora/monitor.py | 203 ++++++++++++++--------- mopidy_pandora/playback.py | 6 +- tests/test_extension.py | 2 +- tests/test_frontend.py | 283 +++---------------------------- tests/test_listener.py | 10 +- tests/test_monitor.py | 329 +++++++++++++++++++++++++++---------- tests/test_playback.py | 4 +- 10 files changed, 439 insertions(+), 602 deletions(-) diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index eed5c3a..a52c619 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -65,6 +65,6 @@ def get_config_schema(self): def setup(self, registry): from .backend import PandoraBackend - from .frontend import PandoraFrontendFactory + from .frontend import PandoraFrontend registry.add('backend', PandoraBackend) - registry.add('frontend', PandoraFrontendFactory) + registry.add('frontend', PandoraFrontend) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 1ef1831..3d22dde 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -2,20 +2,13 @@ import logging -import threading - -import time - -from collections import namedtuple - from mopidy import 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 +from mopidy_pandora.monitor import EventMonitor +from mopidy_pandora.uri import PandoraUri logger = logging.getLogger(__name__) @@ -49,17 +42,8 @@ def check_pandora(self, *args, **kwargs): return check_pandora -class PandoraFrontendFactory(pykka.ThreadingActor): - - def __new__(cls, config, core): - if config['pandora'].get('event_support_enabled'): - return EventHandlingPandoraFrontend(config, core) - else: - return PandoraFrontend(config, core) - - class PandoraFrontend(pykka.ThreadingActor, core.CoreListener, listener.PandoraBackendListener, - listener.PandoraPlaybackListener): + listener.PandoraPlaybackListener, listener.EventMonitorListener): def __init__(self, config, core): super(PandoraFrontend, self).__init__() @@ -70,6 +54,8 @@ def __init__(self, config, core): self.setup_required = True self.core = core + self.event_monitor = EventMonitor(config, core) + def set_options(self): # Setup playback to mirror behaviour of official Pandora front-ends. if self.auto_setup and self.setup_required: @@ -93,21 +79,26 @@ def options_changed(self): self.set_options() @only_execute_for_pandora_uris + def on_event(self, event, **kwargs): + self.event_monitor.on_event(event, **kwargs) + getattr(self, event)(**kwargs) + def track_playback_started(self, tl_track): self.set_options() - @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() - @only_execute_for_pandora_uris def track_playback_resumed(self, tl_track, time_position): self.set_options() + def event_processed(self, track_uri, pandora_event): + if pandora_event == 'delete_station': + self.core.tracklist.clear() + def is_end_of_tracklist_reached(self, track=None): length = self.core.tracklist.get_length().get() if length <= 1: @@ -130,7 +121,7 @@ def is_station_changed(self, track): pass return False - def track_changed(self, track): + def track_changing(self, track): if self.is_station_changed(track): # Station has changed, remove tracks from previous station from tracklist. self._trim_tracklist(keep_only=track) @@ -181,138 +172,3 @@ def _trim_tracklist(self, keep_only=None, maxsize=2): 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) - - -ClickMarker = namedtuple('ClickMarker', 'uri, time') - - -class EventHandlingPandoraFrontend(PandoraFrontend): - - def __init__(self, config, core): - super(EventHandlingPandoraFrontend, self).__init__(config, core) - - self.settings = { - 'resume': config['pandora'].get('on_pause_resume_click'), - 'change_track_next': config['pandora'].get('on_pause_next_click'), - 'change_track_previous': config['pandora'].get('on_pause_previous_click'), - 'triple_click': config['pandora'].get('on_pause_resume_pause_click') - } - - self.double_click_interval = float(config['pandora'].get('double_click_interval')) - self._click_marker = None - - self._triple_click_event = threading.Event() - self._triple_click_event.set() - - @only_execute_for_pandora_uris - def track_playback_paused(self, tl_track, time_position): - """ - Clicking 'pause' is always the first step in detecting a double click. It also sets the timer that will be used - to check for double clicks later on. - - """ - if time_position > 0: - if self.get_click_marker() is None: - self.set_click_marker(tl_track) - else: - self._triple_click_event.set() - - super(EventHandlingPandoraFrontend, self).track_playback_paused(tl_track, time_position) - - @only_execute_for_pandora_uris - def track_playback_resumed(self, tl_track, time_position): - """ - Used to detect pause -> resume double click events. - - """ - if self._is_double_click(): - self._queue_event('resume', self._triple_click_event, 'triple_click', timeout=self.double_click_interval) - super(EventHandlingPandoraFrontend, self).track_playback_resumed(tl_track, time_position) - - def track_changed(self, track): - if self._is_double_click(): - self._queue_event('change_track') - super(EventHandlingPandoraFrontend, self).track_changed(track) - - def set_click_marker(self, tl_track, click_time=None): - if click_time is None: - click_time = int(time.time() * 1000) - - self._click_marker = ClickMarker(tl_track.track.uri, click_time) - - def get_click_marker(self): - return self._click_marker - - def event_processed(self, track_uri, pandora_event): - if pandora_event == 'delete_station': - self.core.tracklist.clear() - - def _is_double_click(self): - if self._click_marker is None: - return False - return (self._click_marker.time > 0 and - int(time.time() * 1000) - self._click_marker.time < self.double_click_interval * 1000) - - @run_async - def _queue_event(self, event, threading_event=None, override_event=None, timeout=None): - """ - Queue an event for processing. If the specified threading event is set, then the event will be overridden with - the one specified. Useful for detecting track change transitions, which always trigger 'stop' first. - - :param event: the original event action that was originally called. - :param threading_event: the threading.Event to monitor. - :param override_event: the new event that should be called instead of the original if the threading event is - set within the timeout specified. - :param timeout: the length of time to wait for the threading.Event to be set before processing the orignal event - """ - - if threading_event: - threading_event.clear() - if threading_event.wait(timeout=timeout): - event = override_event - else: - threading_event.set() - - self.process_event(event=event) - - def process_event(self, event): - try: - event_target_uri, event_target_action = self._get_event_targets(action=event) - except KeyError: - logger.exception("Error processing Pandora event '{}', ignoring...".format(event)) - return - else: - if type(PandoraUri.factory(event_target_uri)) is AdItemUri: - logger.info('Ignoring doubleclick event for Pandora advertisement...') - return - - self._trigger_event_triggered(event_target_uri, event_target_action) - # Resume playback... - if event == 'change_track' and self.core.playback.get_state().get() != PlaybackState.PLAYING: - self.core.playback.resume() - - def _get_event_targets(self, action=None): - if action == 'change_track': - history = self.core.history.get_history().get() - for i, h in enumerate(history): - if h[0] < self._click_marker.time: - if h[1].uri == self._click_marker.uri: - # This is the point in time in the history that the track was played - # before the double_click event occurred. - if history[i-1][1].uri == self._click_marker.uri: - # Track was played again immediately after double_click. - # User clicked 'previous' in consume mode. - action = 'change_track_previous' - break - else: - # Switched to another track, user clicked 'next'. - action = 'change_track_next' - break - - return self._click_marker.uri, self.settings[action] - - def _trigger_event_triggered(self, track_uri, event): - self._click_marker = None - (listener.PandoraEventHandlingFrontendListener.send('event_triggered', - track_uri=track_uri, - pandora_event=event)) diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 560a810..a5cc037 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -2,6 +2,7 @@ from mopidy import backend, listener + class EventMonitorListener(listener.Listener): """ @@ -26,9 +27,27 @@ def event_triggered(self, track_uri, pandora_event): 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: :class:`mopidy.models.Ref` + :param new_uri: the URI of the Pandora track that was changed to. + :type new_uri: :class:`mopidy.models.Ref` + """ 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: :class:`mopidy.models.Ref` + :param new_uri: the URI of the Pandora track that was changed to. + :type new_uri: :class:`mopidy.models.Ref` + """ pass @@ -124,14 +143,11 @@ class PandoraPlaybackListener(listener.Listener): def send(event, **kwargs): listener.send(PandoraPlaybackListener, event, **kwargs) - def track_changed(self, track): + def track_changing(self, track): """ - Called when a track has been changed. 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. This is also the earliest point at which we can detect a 'previous' or 'next' - action performed by the user. + Called when a track is being changed to. - :param track: the Pandora track that was changed to. + :param track: the Pandora track that is being changed to. :type track: :class:`mopidy.models.Ref` """ pass diff --git a/mopidy_pandora/monitor.py b/mopidy_pandora/monitor.py index aac104b..b772bd6 100644 --- a/mopidy_pandora/monitor.py +++ b/mopidy_pandora/monitor.py @@ -1,4 +1,5 @@ from __future__ import absolute_import, division, print_function, unicode_literals + import Queue import logging @@ -11,13 +12,11 @@ from difflib import SequenceMatcher -from mopidy import core -from mopidy.audio import PlaybackState +from functools import total_ordering -import pykka +from mopidy import core from mopidy_pandora import listener -from mopidy_pandora.frontend import only_execute_for_pandora_uris from mopidy_pandora.uri import AdItemUri, PandoraUri from mopidy_pandora.utils import run_async @@ -25,109 +24,125 @@ EventMarker = namedtuple('EventMarker', 'event, uri, time') -class EventMonitor(pykka.ThreadingActor, core.CoreListener, + +@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 + + +class EventMonitor(core.CoreListener, listener.PandoraBackendListener, listener.PandoraPlaybackListener, listener.PandoraFrontendListener): + pykka_traversable = True + def __init__(self, config, core): super(EventMonitor, self).__init__() - self.core = core self.event_sequences = [] - self.monitor_running_event = threading.Event() - self.monitor_running_event.clear() + self.sequence_match_results = None self._track_changed_marker = None + self._monitor_lock = threading.Lock() self.config = config['pandora'] + self.on_start() def on_start(self): 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, interval=interval)) + 'playback_state_changed', + 'track_playback_resumed'], self.sequence_match_results, + interval=interval)) self.event_sequences.append(EventSequence(self.config['on_pause_resume_pause_click'], ['track_playback_paused', + 'playback_state_changed', 'track_playback_resumed', - 'track_playback_paused'], self, interval=interval)) + 'playback_state_changed', + 'track_playback_paused'], self.sequence_match_results, + interval=interval)) self.event_sequences.append(EventSequence(self.config['on_pause_previous_click'], ['track_playback_paused', - 'preparing_track'], self, - wait_for='track_changed_previous', interval=interval)) + 'playback_state_changed', + 'track_playback_ended', + 'playback_state_changed', + 'track_playback_paused', + 'track_changing'], 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', - 'preparing_track'], self, - wait_for='track_changed_next', interval=interval)) + 'playback_state_changed', + 'track_playback_ended', + 'playback_state_changed', + 'track_playback_paused', + 'track_changing'], self.sequence_match_results, + wait_for='track_changed_next', + interval=interval)) - self.sequence_match_results = Queue.Queue(maxsize=len(self.event_sequences)) - - @only_execute_for_pandora_uris def on_event(self, event, **kwargs): - # Check if this event is covered by one of the defined event sequences + self._detect_track_change(event, **kwargs) + for es in self.event_sequences: es.notify(event, **kwargs) - self._detect_track_change(event, **kwargs) - - def sequence_stopped(self): - if not any([es.is_running() for es in self.event_sequences]): - self.all_stopped.set() - - - self.sequence_match_results.put(result_tup) - if self.sequence_match_results.full(): - ratios = [] - self.sequence_match_results.join() - while True: - try: - ratios.append(self.sequence_match_results.get_nowait()) - self.sequence_match_results.task_done() - except Queue.Empty: - ratios.sort(key=lambda es: es.get_ratio()) - self._trigger_event_triggered(ratios[0].on_match_event, ratios[0].target_uri) - - # @classmethod - # def priority_match(cls, es_list): - # ratios = [] - # for es in es_list: - # ratios.append((es, es.get_ratio())) - # - # ratios.sort(key=lambda tup: tup[1]) - # if ratios[-1][0].strict and ratios[-1][1] != 1.0: - # return [] - # - # return [r[0] for r in ratios if r[0] >= ratios[-1][0]] + if self._monitor_lock.acquire(False): + self.monitor_sequences() def _detect_track_change(self, event, **kwargs): - if event == 'track_playback_ended': - self._track_changed_marker = EventMarker('track_playback_ended', + if event in ['track_playback_ended']: + self._track_changed_marker = EventMarker(event, kwargs['tl_track'].track.uri, int(time.time() * 1000)) - elif event in ['track_playback_started', 'track_playback_resumed']: + elif self._track_changed_marker and event in ['track_playback_paused', 'track_playback_started']: try: change_direction = self._get_track_change_direction(self._track_changed_marker) 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 except KeyError: # Must be playing the first track, ignore pass - def process_event(self, event): - try: - event = self._get_track_change_direction(event) - except KeyError: - logger.exception("Error processing Pandora event '{}', ignoring...".format(event)) - return - else: - self._trigger_event_triggered(event, self._event_markers.uri) + @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 > 0.85: + self._trigger_event_triggered(match.marker.event, match.marker.uri) + # Resume playback... - if event == 'change_track' and self.core.playback.get_state().get() != PlaybackState.PLAYING: - self.core.playback.resume() + # TODO: this is matching to the wrong event. + # if match.marker.event == 'change_track' and self.core.playback.get_state().get() != PlaybackState.PLAYING: + # self.core.playback.resume() + + self._monitor_lock.release() def _get_track_change_direction(self, track_marker): history = self.core.history.get_history().get() @@ -157,10 +172,10 @@ def _trigger_track_changed(self, track_change_event, old_uri, new_uri): class EventSequence(object): pykka_traversable = True - def __init__(self, on_match_event, target_sequence, monitor, interval=1.0, strict=False, wait_for=None): + 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.monitor = monitor + self.result_queue = result_queue self.interval = interval self.strict = strict self.wait_for = wait_for @@ -173,14 +188,20 @@ def __init__(self, on_match_event, target_sequence, monitor, interval=1.0, stric self._timer = None self.target_uri = None + self.monitoring_completed = threading.Event() + self.monitoring_completed.set() + @classmethod - def match_sequence_list(cls, a, b): + 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_running(): + 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. @@ -192,36 +213,62 @@ def notify(self, event, **kwargs): else: uri = None self.start_monitor(uri) + self.events_seen.append(event) - if not self.wait_for_event.is_set() and self.wait_for == event: - self.wait_for_event.set() - - def is_running(self): - return (self._timer and self._timer.is_alive()) or not self.wait_for_event.is_set() + def is_monitoring(self): + return not self.monitoring_completed.is_set() def start_monitor(self, uri): + self.monitoring_completed.clear() # TODO: ad checking probably belongs somewhere else. - if type(PandoraUri.factory(uri)) is AdItemUri: + if uri and type(PandoraUri.factory(uri)) is AdItemUri: logger.info('Ignoring doubleclick event for Pandora advertisement...') + self.monitoring_completed.set() return self.target_uri = uri - self._timer = threading.Timer(self.interval, self.stop_monitor) + self._timer = threading.Timer(self.interval, self.stop_monitor, args=(5.0,)) self._timer.daemon = True self._timer.start() @run_async - def stop_monitor(self): - if self.wait_for_event.wait(timeout=60): - self.monitor.sequence_stopped() + def stop_monitor(self, timeout): + i = 0 + # Make sure that we have seen every event in the target sequence, and in the right order + try: + for e in self.target_sequence: + i = self.events_seen[i:].index(e) + 1 + except ValueError: + # Sequence does not match, ignore + pass + else: + 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): - self.wait_for_event.set() + if self.wait_for: + self.wait_for_event.clear() + else: + self.wait_for_event.set() + self.events_seen = [] - self._timer = None def get_ratio(self): - ratio = EventSequence.match_sequence_list(self.events_seen, self.target_sequence) + if self.wait_for: + # Add 'wait_for' event as well to make ratio more accurate. + self.target_sequence.append(self.wait_for) + ratio = EventSequence.match_sequence(self.events_seen, self.target_sequence) if ratio < 1.0 and self.strict: return 0 return ratio + + def wait(self, timeout=None): + return self.monitoring_completed.wait(timeout=timeout) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 63df909..2fda7c8 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -60,9 +60,9 @@ def change_track(self, track): 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) - self._trigger_track_changed(track) return super(PandoraPlaybackProvider, self).change_track(track) except KeyError: @@ -81,8 +81,8 @@ def check_skip_limit(self): def translate_uri(self, uri): return self.backend.library.lookup_pandora_track(uri).audio_url - def _trigger_track_changed(self, track): - listener.PandoraPlaybackListener.send('track_changed', track=track) + 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) diff --git a/tests/test_extension.py b/tests/test_extension.py index fffc763..1cfb9b4 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -68,6 +68,6 @@ def test_setup(self): ext = Extension() ext.setup(registry) - calls = [mock.call('frontend', frontend_lib.PandoraFrontendFactory), + calls = [mock.call('frontend', frontend_lib.PandoraFrontend), mock.call('backend', backend_lib.PandoraBackend)] registry.add.assert_has_calls(calls, any_order=True) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 91daad7..811af69 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -2,8 +2,6 @@ import Queue -import time - import unittest from mock import mock @@ -12,34 +10,18 @@ from mopidy.audio import PlaybackState -from mopidy.core import Core, CoreListener, HistoryController +from mopidy.core import CoreListener import pykka from mopidy_pandora import frontend -from mopidy_pandora.frontend import EventHandlingPandoraFrontend, PandoraFrontend -from mopidy_pandora.listener import PandoraBackendListener, PandoraPlaybackListener +from mopidy_pandora.frontend import PandoraFrontend +from mopidy_pandora.listener import PandoraBackendListener from tests import conftest, dummy_backend from tests.dummy_backend import DummyBackend, DummyPandoraBackend -class TestPandoraFrontendFactory(unittest.TestCase): - def test_events_supported_returns_event_handler_frontend(self): - config = conftest.config() - config['pandora']['event_support_enabled'] = True - f = frontend.PandoraFrontendFactory(config, mock.PropertyMock()) - - assert type(f) is frontend.EventHandlingPandoraFrontend - - def test_events_not_supported_returns_regular_frontend(self): - config = conftest.config() - config['pandora']['event_support_enabled'] = False - f = frontend.PandoraFrontendFactory(config, mock.PropertyMock()) - - assert type(f) is frontend.PandoraFrontend - - class BaseTest(unittest.TestCase): tracks = [ models.Track(uri='pandora:track:id_mock:token_mock1', length=40000), # Regular track @@ -179,8 +161,9 @@ def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): assert not func_mock.called - def test_options_changed_triggers_etup(self): + def test_options_changed_triggers_setup(self): with mock.patch.object(PandoraFrontend, 'set_options', mock.Mock()) as set_options_mock: + self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.frontend.setup_required = False listener.send(CoreListener, 'options_changed') self.replay_events(self.frontend) @@ -247,7 +230,7 @@ def test_station_change_does_not_trim_currently_playing_track_from_tracklist(sel self.core.tracklist.clear() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) - self.frontend.track_changed(self.tl_tracks[0].track).get() + self.frontend.track_changing(self.tl_tracks[0].track).get() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. tl_tracks = self.core.tracklist.get_tl_tracks().get() @@ -292,7 +275,7 @@ def test_changing_track_no_op(self): assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) self.replay_events(self.frontend) - self.frontend.track_changed(self.tl_tracks[1].track).get() + self.frontend.track_changing(self.tl_tracks[1].track).get() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) @@ -306,7 +289,7 @@ def test_changing_track_station_changed(self): assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - self.frontend.track_changed(self.tl_tracks[4].track).get() + self.frontend.track_changing(self.tl_tracks[4].track).get() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. tl_tracks = self.core.tracklist.get_tl_tracks().get() @@ -316,6 +299,17 @@ def test_changing_track_station_changed(self): assert all(self.has_events([('end_of_tracklist_reached', {'station_id': 'id_mock_other', 'auto_play': False})])) + def test_delete_station_clears_tracklist_on_finish(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + assert len(self.core.tracklist.get_tl_tracks().get()) > 0 + + listener.send(PandoraBackendListener, 'event_processed', + track_uri=self.tracks[0].uri, + pandora_event='delete_station') + self.replay_events(self.frontend) + + assert len(self.core.tracklist.get_tl_tracks().get()) == 0 + def test_track_unplayable_removes_tracks_from_tracklist(self): tl_tracks = self.core.tracklist.get_tl_tracks().get() unplayable_track = tl_tracks[0] @@ -330,242 +324,3 @@ def test_track_unplayable_triggers_end_of_tracklist_event(self): self.frontend.track_unplayable(self.tl_tracks[-1].track).get() assert all([self.has_events('end_of_tracklist_reached')]) assert self.core.playback.get_state().get() == PlaybackState.STOPPED - - -class TestEventHandlingFrontend(BaseTest): - def setUp(self): # noqa: N802 - super(TestEventHandlingFrontend, self).setUp() - self.frontend = frontend.EventHandlingPandoraFrontend.start(conftest.config(), self.core).proxy() - self.core.tracklist.set_consume(True) # Set consume mode so that tracklist behaves as expected. - - def tearDown(self): # noqa: N802 - super(TestEventHandlingFrontend, self).tearDown() - - def test_delete_station_clears_tracklist_on_finish(self): - assert len(self.core.tracklist.get_tl_tracks().get()) > 0 - - listener.send(PandoraBackendListener, 'event_processed', - track_uri=self.tracks[0].uri, - pandora_event='delete_station') - self.replay_events(self.frontend) - - assert len(self.core.tracklist.get_tl_tracks().get()) == 0 - - def test_events_processed_on_change_track_action(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - with mock.patch.object(EventHandlingPandoraFrontend, 'process_event', mock.Mock()) as process_mock: - - # Change track - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause() - self.core.playback.next().get() - listener.send(PandoraPlaybackListener, 'track_changed', track=self.tl_tracks[1].track) - self.replay_events(self.frontend) - - thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. - assert process_mock.called - - def test_get_event_targets_change_next(self): - """ - Pause -> Next - - """ - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause() - self.core.playback.next().get() - self.replay_events(self.frontend) - - self.frontend.track_changed(track=self.tl_tracks[1].track).get() - thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. - - assert all(self.has_events([ - ('event_triggered', - { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': self.frontend.settings.get()['change_track_next'] - })])) - - def test_get_event_targets_change_previous(self): - """ - Pause -> Previous - """ - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause() - self.core.playback.previous().get() - self.replay_events(self.frontend) - - self.frontend.track_changed(track=self.tl_tracks[0].track).get() - thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. - - assert all(self.has_events([ - ('event_triggered', - { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': self.frontend.settings.get()['change_track_previous'] - })])) - - def test_process_event_resumes_playback_for_change_track(self): - actions = ['stop', 'change_track', 'resume'] - - for action in actions: - self.events = Queue.Queue() # Make sure that the queue is empty - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause().get() - self.replay_events(self.frontend) - assert self.core.playback.get_state().get() == PlaybackState.PAUSED - - if action == 'change_track': - self.core.playback.next() - self.frontend.process_event(event=action).get() - - self.assertEqual(self.core.playback.get_state().get(), - PlaybackState.PLAYING, - "Failed to set playback for action '{}'".format(action)) - else: - self.frontend.process_event(event=action).get() - self.assertEqual(self.core.playback.get_state().get(), - PlaybackState.PAUSED, - "Failed to set playback for action '{}'".format(action)) - - def test_playback_state_changed_handles_stop(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - """ - Pause -> Resume -> Pause - - """ - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause() - self.core.playback.resume().get() - self.replay_events(self.frontend) - self.core.playback.pause().get() - self.replay_events(self.frontend, until='track_playback_paused') - - thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. - assert all(self.has_events([ - ('event_triggered', - { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': self.frontend.settings.get()['triple_click'] - })])) - - def test_playback_state_changed_handles_change_track(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause() - self.core.playback.next().get() - self.replay_events(self.frontend) - - self.frontend.track_changed(self.tl_tracks[1].track).get() - thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. - - assert all(self.has_events([ - ('event_triggered', - { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': self.frontend.settings.get()['change_track_next'] - })])) - - -# Test private methods that are not available in the Pykka actor. - -def test_is_double_click_true(): - static_frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), mock.Mock()) - track_mock = mock.Mock(spec=models.Track) - track_mock.uri = 'pandora:track:id_mock:token_mock' - tl_track_mock = mock.Mock(spec=models.TlTrack) - tl_track_mock.track = track_mock - - static_frontend.set_click_marker(tl_track_mock) - assert static_frontend._is_double_click() - - -def test_is_double_click_false(): - static_frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), mock.Mock()) - track_mock = mock.Mock(spec=models.Track) - track_mock.uri = 'pandora:track:id_mock:token_mock' - tl_track_mock = mock.Mock(spec=models.TlTrack) - tl_track_mock.track = track_mock - - static_frontend.set_click_marker(tl_track_mock) - assert static_frontend._is_double_click() - - static_frontend.set_click_marker(tl_track_mock) - time.sleep(float(static_frontend.double_click_interval) + 0.5) - assert static_frontend._is_double_click() is False - - -class TestEventTargets(BaseTest): - def setUp(self): # noqa: N802 - self.history = [] - self.tracks = [ - models.Track(uri='pandora:track:id_mock:token_mock1', length=40000), - models.Track(uri='pandora:track:id_mock:token_mock2', length=40000), - models.Track(uri='pandora:track:id_mock:token_mock3', length=40000), - ] - - self.tl_tracks = ([models.TlTrack(track=self.tracks[i], tlid=i+1) - for i in range(0, len(self.tracks))]) - - self.refs = ([models.Ref.track(uri=self.tl_tracks[i].track.uri, name='name_mock') - for i in range(0, len(self.tracks))]) - - core_mock = mock.Mock(spec=Core) - history_mock = mock.Mock(spec=HistoryController) - threading_future_mock = mock.Mock(spec=pykka.ThreadingFuture) - core.history = history_mock - threading_future_mock.get.return_value = self.history - core_mock.history.get_history.return_value = threading_future_mock - - self.frontend = frontend.EventHandlingPandoraFrontend(conftest.config(), core_mock) - - def test_get_event_targets_change_track_next(self): - self.history.insert(0, (int(time.time() * 1000), self.refs[0])) - time.sleep(0.01) - self.frontend.set_click_marker(self.tl_tracks[0]) - time.sleep(0.01) - self.history.insert(0, (int(time.time() * 1000), self.refs[1])) - - event_target_uri, event_target_action = self.frontend._get_event_targets('change_track') - assert event_target_uri == self.refs[0].uri - assert event_target_action == self.frontend.settings['change_track_next'] - - def test_get_event_targets_change_track_next_first_track_has_multiple_replays(self): - self.history.insert(0, (int(time.time() * 1000), self.refs[0])) - time.sleep(0.01) - self.history.insert(0, (int(time.time() * 1000), self.refs[0])) - time.sleep(0.01) - self.frontend.set_click_marker(self.tl_tracks[0]) - time.sleep(0.01) - self.history.insert(0, (int(time.time() * 1000), self.refs[1])) - - event_target_uri, event_target_action = self.frontend._get_event_targets('change_track') - assert event_target_uri == self.refs[0].uri - assert event_target_action == self.frontend.settings['change_track_next'] - - def test_get_event_targets_change_track_previous(self): - self.history.insert(0, (int(time.time() * 1000), self.refs[0])) - time.sleep(0.01) - self.frontend.set_click_marker(self.tl_tracks[0]) - time.sleep(0.01) - self.history.insert(0, (int(time.time() * 1000), self.refs[0])) - - event_target_uri, event_target_action = self.frontend._get_event_targets('change_track') - assert event_target_uri == self.refs[0].uri - assert event_target_action == self.frontend.settings['change_track_previous'] - - def test_get_event_targets_resume(self): - self.history.insert(0, (int(time.time() * 1000), self.refs[0])) - time.sleep(0.01) - self.frontend.set_click_marker(self.tl_tracks[0]) - - event_target_uri, event_target_action = self.frontend._get_event_targets('resume') - assert event_target_uri == self.refs[0].uri - assert event_target_action == self.frontend.settings['resume'] diff --git a/tests/test_listener.py b/tests/test_listener.py index 11a55ad..98a3983 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -71,15 +71,15 @@ def setUp(self): # noqa: N802 self.listener = listener.PandoraPlaybackListener() def test_on_event_forwards_to_specific_handler(self): - self.listener.track_changed = mock.Mock() + self.listener.track_changing = mock.Mock() self.listener.on_event( - 'track_changed', track=models.Ref(name='name_mock')) + 'track_changing', track=models.Ref(name='name_mock')) - self.listener.track_changed.assert_called_with(track=models.Ref(name='name_mock')) + self.listener.track_changing.assert_called_with(track=models.Ref(name='name_mock')) - def test_listener_has_default_impl_for_track_changed(self): - self.listener.track_changed(track=models.Ref(name='name_mock')) + def test_listener_has_default_impl_for_track_changing(self): + self.listener.track_changing(track=models.Ref(name='name_mock')) def test_listener_has_default_impl_for_track_unplayable(self): self.listener.track_unplayable(track=models.Ref(name='name_mock')) diff --git a/tests/test_monitor.py b/tests/test_monitor.py index cc5c29a..b191e1b 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -2,20 +2,24 @@ import Queue +import time import unittest from mock import mock -from mopidy import core, models +from mopidy import core, listener, models import pykka -import pytest from mopidy_pandora import monitor -from mopidy_pandora.monitor import EventSequence + +from mopidy_pandora.listener import PandoraPlaybackListener + +from mopidy_pandora.monitor import EventMarker, EventSequence, MatchResult from tests import conftest, dummy_backend + from tests.dummy_backend import DummyBackend, DummyPandoraBackend @@ -75,7 +79,7 @@ def replay_events(self, listener, until=None): try: e = self.events.get(timeout=0.1) event, kwargs = e - listener.on_event(event, **kwargs).get() + listener.on_event(event, **kwargs) if e[0] == until: break except Queue.Empty: @@ -97,29 +101,114 @@ def has_events(self, events): class EventMonitorTest(BaseTest): def setUp(self): # noqa: N802 super(EventMonitorTest, self).setUp() - self.monitor = monitor.EventMonitor.start(conftest.config(), self.core).proxy() + self.monitor = monitor.EventMonitor(conftest.config(), self.core) + # Consume more needs to be enabled to detect 'previous' track changes + self.core.tracklist.set_consume(True) - es1 = EventSequence('delete_station', ['track_playback_paused', 'track_playback_resumed', 'track_playback_paused']) - es2 = EventSequence('thumbs_up', ['track_playback_paused', 'track_playback_resumed']) - es3 = EventSequence('thumbs_down', ['track_playback_paused', 'track_playback_ended', 'track_playback_resumed']) - es4 = EventSequence('sleep', ['track_playback_paused', 'track_playback_ended', 'track_playback_resumed']) + def test_detect_track_change_next(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + # Next + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.replay_events(self.monitor) + self.core.playback.next().get() + self.replay_events(self.monitor, until='track_playback_started') - es_list = [es1, es2, es3, es4] + thread_joiner.wait(timeout=1.0) + assert all(self.has_events([('track_changed_next', { + 'old_uri': self.tl_tracks[0].track.uri, + 'new_uri': self.tl_tracks[1].track.uri + })])) - for es in EventMonitorTest.es_list: - self.monitor.add_event_sequence(es).get() + def test_detect_track_change_next_from_paused(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + # Next + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause().get() + self.replay_events(self.monitor) + self.core.playback.next().get() + self.replay_events(self.monitor, until='track_playback_paused') - def tearDown(self): # noqa: N802 - super(EventMonitorTest, self).tearDown() + thread_joiner.wait(timeout=1.0) + assert all(self.has_events([('track_changed_next', { + 'old_uri': self.tl_tracks[0].track.uri, + 'new_uri': self.tl_tracks[1].track.uri + })])) - def test_events_processed_on_resume_action(self): + def test_detect_track_change_previous(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + # Next + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100).get() + self.replay_events(self.monitor) + self.core.playback.previous().get() + self.replay_events(self.monitor, until='track_playback_started') + + thread_joiner.wait(timeout=1.0) + assert all(self.has_events([('track_changed_previous', { + 'old_uri': self.tl_tracks[0].track.uri, + 'new_uri': self.tl_tracks[0].track.uri + })])) + + def test_detect_track_change_previous_from_paused(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + # Next + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause().get() + self.replay_events(self.monitor) + self.core.playback.previous().get() + self.replay_events(self.monitor, until='track_playback_paused') + + thread_joiner.wait(timeout=1.0) + assert all(self.has_events([('track_changed_previous', { + 'old_uri': self.tl_tracks[0].track.uri, + 'new_uri': self.tl_tracks[0].track.uri + })])) + + def test_events_triggered_on_next_action(self): + with conftest.ThreadJoiner(timeout=10.0) as thread_joiner: + # Pause -> Next + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause().get() + self.replay_events(self.monitor) + self.core.playback.next().get() + listener.send(PandoraPlaybackListener, 'track_changing', track=self.tl_tracks[1].track) + self.replay_events(self.monitor) + + thread_joiner.wait(timeout=10.0) + assert all(self.has_events([('event_triggered', { + 'track_uri': self.tl_tracks[0].track.uri, + 'pandora_event': conftest.config()['pandora']['on_pause_next_click'] + })])) + + def test_events_triggered_on_previous_action(self): + with conftest.ThreadJoiner(timeout=10.0) as thread_joiner: + # Pause -> Previous + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause().get() + self.replay_events(self.monitor) + self.core.playback.previous().get() + listener.send(PandoraPlaybackListener, 'track_changing', track=self.tl_tracks[0].track) + self.replay_events(self.monitor) + + thread_joiner.wait(timeout=10.0) + assert all(self.has_events([('event_triggered', { + 'track_uri': self.tl_tracks[0].track.uri, + 'pandora_event': conftest.config()['pandora']['on_pause_previous_click'] + })])) + + def test_events_triggered_on_resume_action(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Pause -> Resume self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) self.core.playback.pause() self.core.playback.resume().get() - self.replay_events(self.monitor) + self.replay_events(self.monitor, until='track_playback_resumed') thread_joiner.wait(timeout=1.0) assert all(self.has_events([('event_triggered', { @@ -127,15 +216,16 @@ def test_events_processed_on_resume_action(self): 'pandora_event': conftest.config()['pandora']['on_pause_resume_click'] })])) - def test_events_processed_on_triple_click_action(self): + def test_events_triggered_on_triple_click_action(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Pause -> Resume -> Pause self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) self.core.playback.pause() self.core.playback.resume() - self.core.playback.pause().get() self.replay_events(self.monitor) + self.core.playback.pause().get() + self.replay_events(self.monitor, until='track_playback_resumed') thread_joiner.wait(timeout=1.0) assert all(self.has_events([('event_triggered', { @@ -143,7 +233,7 @@ def test_events_processed_on_triple_click_action(self): 'pandora_event': conftest.config()['pandora']['on_pause_resume_pause_click'] })])) - def test_process_event_ignores_ads(self): + def test_monitor_ignores_ads(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[2].tlid) self.core.playback.seek(100) @@ -154,72 +244,145 @@ def test_process_event_ignores_ads(self): thread_joiner.wait(timeout=1.0) assert self.events.qsize() == 0 # Check that no events were triggered - def test_process_event_resets_event_marker(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - with pytest.raises(KeyError): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause() - self.core.playback.resume().get() - self.replay_events(self.monitor) - - thread_joiner.wait(timeout=self.monitor.double_click_interval.get() + 1) - self.monitor.get_event_marker('track_playback_paused').get() - - def test_process_event_handles_exception(self): - with mock.patch.object(monitor.EventMonitor, '_get_event', - mock.PropertyMock(return_value=None, side_effect=KeyError('error_mock'))): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause() - self.core.playback.resume().get() - self.replay_events(self.monitor) - - assert self.events.qsize() == 0 # Check that no events were triggered - - def test_trigger_starts_double_click_timer(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause().get() - self.replay_events(self.monitor) - - assert self.monitor.get_event_marker('track_playback_paused').get().time > 0 + # TODO: Add this test back again + # def test_process_event_resumes_playback_for_change_track(self): + # actions = ['stop', 'change_track', 'resume'] + # + # for action in actions: + # self.events = Queue.Queue() # Make sure that the queue is empty + # self.core.playback.play(tlid=self.tl_tracks[0].tlid) + # self.core.playback.seek(100) + # self.core.playback.pause().get() + # self.replay_events(self.frontend) + # assert self.core.playback.get_state().get() == PlaybackState.PAUSED + # + # if action == 'change_track': + # self.core.playback.next() + # self.frontend.process_event(event=action).get() + # + # self.assertEqual(self.core.playback.get_state().get(), + # PlaybackState.PLAYING, + # "Failed to set playback for action '{}'".format(action)) + # else: + # self.frontend.process_event(event=action).get() + # self.assertEqual(self.core.playback.get_state().get(), + # PlaybackState.PAUSED, + # "Failed to set playback for action '{}'".format(action)) + + +class EventSequenceTest(unittest.TestCase): - def test_trigger_does_not_start_timer_at_track_start(self): - with pytest.raises(KeyError): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.pause().get() - self.replay_events(self.monitor) - - assert self.monitor.get_event_marker('track_playback_paused').get() - - -def test_match_sequence_on_longest(): - es1 = EventSequence('match_event_1', ['e1']) - es2 = EventSequence('match_event_1', ['e1', 'e2']) - es3 = EventSequence('match_event_1', ['e1', 'e2', 'e3']) - - es_list = [es1, es2, es3] - - es1.events_seen = ['e1'] - es2.events_seen = ['e1', 'e2'] - es3.events_seen = ['e1', 'e2', 'e3'] - - assert es1 in EventSequence.match_sequence_list(es_list) - - -def test_match_sequence_strict(): - es_list = [EventSequence('match_event_1', ['e1', 'e2', 'e3'], True)] - assert EventSequence.match_sequence_list(es_list, ['e1', 'e3']) is None + def setUp(self): + self.rq = Queue.PriorityQueue() + self.es = EventSequence('match_mock', ['e1', 'e2', 'e3'], self.rq, 0.1, False) + self.es_strict = EventSequence('match_mock', ['e1', 'e2', 'e3'], self.rq, 0.1, True) + self.es_wait = EventSequence('match_mock', ['e1', 'e2', 'e3'], self.rq, 0.1, False, 'w1') + + self.event_sequences = [self.es, self.es_strict, self.es_wait] + track_mock = mock.Mock(spec=models.Track) + track_mock.uri = 'pandora:track:id_mock:token_mock' + self.tl_track_mock = mock.Mock(spec=models.TlTrack) + self.tl_track_mock.track = track_mock + + def test_events_ignored_if_time_position_is_zero(self): + for es in self.event_sequences: + es.notify('e1') + for es in self.event_sequences: + assert not es.is_monitoring() + + def test_start_monitor_on_event(self): + for es in self.event_sequences: + es.notify('e1', tl_track=self.tl_track_mock, time_position=100) + for es in self.event_sequences: + assert es.is_monitoring() + + def test_start_monitor_handles_no_tl_track(self): + for es in self.event_sequences: + es.notify('e1', time_position=100) + for es in self.event_sequences: + assert es.is_monitoring() + + def test_stop_monitor_adds_result_to_queue(self): + for es in self.event_sequences[0:2]: + es.notify('e1', time_position=100) + es.notify('e2', time_position=100) + es.notify('e3', time_position=100) + + for es in self.event_sequences[0:2]: + es.wait(1.0) + assert not es.is_monitoring() + + assert self.rq.qsize() == 2 + + def test_stop_monitor_only_waits_for_matched_events(self): + self.es_wait.notify('e1', time_position=100) + self.es_wait.notify('e_not_in_monitored_sequence', time_position=100) + + time.sleep(0.1 * 1.1) + assert not self.es_wait.is_monitoring() + assert self.rq.qsize() == 0 + + def test_stop_monitor_waits_for_event(self): + self.es_wait.notify('e1', time_position=100) + self.es_wait.notify('e2', time_position=100) + self.es_wait.notify('e3', time_position=100) + + assert self.es_wait.is_monitoring() + assert self.rq.qsize() == 0 + + self.es_wait.notify('w1', time_position=100) + self.es_wait.wait(timeout=1.0) + + assert not self.es_wait.is_monitoring() + assert self.rq.qsize() == 1 + + def test_get_stop_monitor_that_all_events_occurred(self): + self.es.notify('e1', time_position=100) + self.es.notify('e2', time_position=100) + self.es.notify('e3', time_position=100) + assert self.rq.qsize() == 0 + + self.es.wait(timeout=1.0) + self.es.events_seen = ['e1', 'e2', 'e3'] + assert self.rq.qsize() > 0 + + def test_get_stop_monitor_that_events_were_seen_in_order(self): + self.es.notify('e1', time_position=100) + self.es.notify('e3', time_position=100) + self.es.notify('e2', time_position=100) + self.es.wait(timeout=1.0) + assert self.rq.qsize() == 0 + + self.es.notify('e1', time_position=100) + self.es.notify('e2', time_position=100) + self.es.notify('e3', time_position=100) + self.es.wait(timeout=1.0) + assert self.rq.qsize() > 0 + + def test_get_ratio_handles_repeating_events(self): + self.es.target_sequence = ['e1', 'e2', 'e3', 'e1'] + self.es.events_seen = ['e1', 'e2', 'e3', 'e1'] + assert self.es.get_ratio() > 0 + + def test_get_ratio_enforces_strict_matching(self): + self.es_strict.events_seen = ['e1', 'e2', 'e3', 'e4'] + assert self.es_strict.get_ratio() == 0 + + self.es_strict.events_seen = ['e1', 'e2', 'e3'] + assert self.es_strict.get_ratio() == 1 + + +class MatchResultTest(unittest.TestCase): + + def test_match_result_comparison(self): -def test_match_sequence_partial(): - es1 = EventSequence('match_event_1', ['e1', 'e3']) - es2 = EventSequence('match_event_1', ['e1', 'e2', 'e3']) - es3 = EventSequence('match_event_1', ['e1', 'e2', 'e3', 'e4']) + mr1 = MatchResult(EventMarker('e1', 'u1', 0), 1) + mr2 = MatchResult(EventMarker('e1', 'u1', 0), 2) - es_list = [es1, es2, es3] + assert mr1 < mr2 + assert mr2 > mr1 + assert mr1 != mr2 - assert EventSequence.match_sequence_list(es_list, ['e1', 'e3']) == es1 - assert EventSequence.match_sequence_list(es_list, ['e1', 'e2', 'e3']) == es2 - assert EventSequence.match_sequence_list(es_list, ['e1', 'e3', 'e4']) == es3 + mr2.ratio = 1 + assert mr1 == mr2 diff --git a/tests/test_playback.py b/tests/test_playback.py index 7d08f68..1c6a257 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -150,10 +150,10 @@ def test_change_track_triggers_event_on_success(provider, playlist_item_mock): with mock.patch.object(PlaylistItem, 'get_is_playable', return_value=True): track = PandoraUri.factory(playlist_item_mock) - provider._trigger_track_changed = mock.PropertyMock() + provider._trigger_track_changing = mock.PropertyMock() assert provider.change_track(track) is True - assert provider._trigger_track_changed.called + assert provider._trigger_track_changing.called def test_translate_uri_returns_audio_url(provider, playlist_item_mock): From d778780f65ba9837533d72654f3fc21ca93c5405 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 16 Jan 2016 18:11:32 +0200 Subject: [PATCH 233/311] WIP: integrate and test event monitor. --- mopidy_pandora/backend.py | 2 +- mopidy_pandora/frontend.py | 14 +++--- mopidy_pandora/listener.py | 32 ++----------- mopidy_pandora/monitor.py | 50 +++++++++++++------ tests/dummy_backend.py | 4 ++ tests/test_frontend.py | 27 ++++------- tests/test_listener.py | 42 +++++++++------- tests/test_monitor.py | 98 +++++++++++++++++++++----------------- 8 files changed, 139 insertions(+), 130 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index cfae13c..e274259 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -22,7 +22,7 @@ class PandoraBackend(pykka.ThreadingActor, backend.Backend, core.CoreListener, listener.PandoraFrontendListener, - listener.PandoraEventHandlingFrontendListener): + listener.EventMonitorListener): def __init__(self, config, audio): super(PandoraBackend, self).__init__() diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 3d22dde..6af7959 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -13,6 +13,7 @@ logger = logging.getLogger(__name__) +# TODO: profile this function for optimisation? 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 @@ -33,8 +34,10 @@ def check_pandora(self, *args, **kwargs): :return: the return value of the function if it was run or 'None' otherwise. """ try: - PandoraUri.factory(self.core.playback.get_current_tl_track().get().track.uri) - return func(self, *args, **kwargs) + tl_track = kwargs.get('tl_track', self.core.playback.get_current_tl_track().get()) + uri = tl_track.track.uri + if uri.startswith(PandoraUri.SCHEME) and PandoraUri.factory(uri): + return func(self, *args, **kwargs) except (AttributeError, NotImplementedError): # Not playing a Pandora track. Don't do anything. pass @@ -78,6 +81,7 @@ def options_changed(self): self.setup_required = True self.set_options() + # TODO: add toggle to disable event monitoring 'event_support_enabled'? @only_execute_for_pandora_uris def on_event(self, event, **kwargs): self.event_monitor.on_event(event, **kwargs) @@ -95,10 +99,6 @@ def track_playback_paused(self, tl_track, time_position): def track_playback_resumed(self, tl_track, time_position): self.set_options() - def event_processed(self, track_uri, pandora_event): - if pandora_event == 'delete_station': - self.core.tracklist.clear() - def is_end_of_tracklist_reached(self, track=None): length = self.core.tracklist.get_length().get() if length <= 1: @@ -121,6 +121,8 @@ def is_station_changed(self, track): pass return False + # TODO: ideally all of this should be delayed until after the track change has been completed. + # Not sure the tracklist / history will always be up to date at this point. def track_changing(self, track): if self.is_station_changed(track): # Station has changed, remove tracks from previous station from tracklist. diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index a5cc037..2839256 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -31,9 +31,9 @@ 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: :class:`mopidy.models.Ref` + :type old_uri: string :param new_uri: the URI of the Pandora track that was changed to. - :type new_uri: :class:`mopidy.models.Ref` + :type new_uri: string """ pass @@ -44,9 +44,9 @@ def track_changed_next(self, old_uri, new_uri): the currently selected station. :param old_uri: the URI of the Pandora track that was changed from. - :type old_uri: :class:`mopidy.models.Ref` + :type old_uri: string :param new_uri: the URI of the Pandora track that was changed to. - :type new_uri: :class:`mopidy.models.Ref` + :type new_uri: string """ pass @@ -73,30 +73,6 @@ def end_of_tracklist_reached(self, station_id, auto_play=False): pass -class PandoraEventHandlingFrontendListener(listener.Listener): - - """ - Marker interface for recipients of events sent by the event handling frontend actor. - - """ - - @staticmethod - def send(event, **kwargs): - listener.send(PandoraEventHandlingFrontendListener, 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 - - class PandoraBackendListener(backend.BackendListener): """ diff --git a/mopidy_pandora/monitor.py b/mopidy_pandora/monitor.py index b772bd6..e1cf678 100644 --- a/mopidy_pandora/monitor.py +++ b/mopidy_pandora/monitor.py @@ -43,7 +43,8 @@ def __lt__(self, other): class EventMonitor(core.CoreListener, listener.PandoraBackendListener, listener.PandoraPlaybackListener, - listener.PandoraFrontendListener): + listener.PandoraFrontendListener, + listener.EventMonitorListener): pykka_traversable = True @@ -80,9 +81,9 @@ def on_start(self): ['track_playback_paused', 'playback_state_changed', 'track_playback_ended', + 'track_changing', 'playback_state_changed', - 'track_playback_paused', - 'track_changing'], self.sequence_match_results, + 'track_playback_paused'], self.sequence_match_results, wait_for='track_changed_previous', interval=interval)) @@ -90,15 +91,25 @@ def on_start(self): ['track_playback_paused', 'playback_state_changed', 'track_playback_ended', + 'track_changing', 'playback_state_changed', - 'track_playback_paused', - 'track_changing'], self.sequence_match_results, + '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) + def on_event(self, event, **kwargs): + super(EventMonitor, self).on_event(event, **kwargs) self._detect_track_change(event, **kwargs) + if self._monitor_lock.acquire(False): + if event not in self.trigger_events: + # Optimisation: monitor not running and current event will not trigger any starts either, ignore + self._monitor_lock.release() + return + self._monitor_lock.release() + for es in self.event_sequences: es.notify(event, **kwargs) @@ -106,7 +117,7 @@ def on_event(self, event, **kwargs): self.monitor_sequences() def _detect_track_change(self, event, **kwargs): - if event in ['track_playback_ended']: + if not self._track_changed_marker and event in ['track_playback_ended']: self._track_changed_marker = EventMarker(event, kwargs['tl_track'].track.uri, int(time.time() * 1000)) @@ -114,10 +125,11 @@ def _detect_track_change(self, event, **kwargs): elif self._track_changed_marker and event in ['track_playback_paused', 'track_playback_started']: try: change_direction = self._get_track_change_direction(self._track_changed_marker) - 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 + 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 except KeyError: # Must be playing the first track, ignore pass @@ -144,12 +156,20 @@ def monitor_sequences(self): 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): - if h[0] < track_marker.time: + # 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 i == 0: + return None if history[i-1][1].uri == track_marker.uri: # Track was played again immediately. # User clicked 'previous' in consume mode. @@ -159,9 +179,9 @@ def _get_track_change_direction(self, track_marker): return 'track_changed_next' def _trigger_event_triggered(self, event, uri): - (listener.PandoraEventHandlingFrontendListener.send('event_triggered', - track_uri=uri, - pandora_event=event)) + (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, @@ -227,7 +247,7 @@ def start_monitor(self, uri): return self.target_uri = uri - self._timer = threading.Timer(self.interval, self.stop_monitor, args=(5.0,)) + self._timer = threading.Timer(self.interval, self.stop_monitor, args=(self.interval,)) self._timer.daemon = True self._timer.start() diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index b6887e8..424f762 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -7,10 +7,13 @@ from __future__ import absolute_import, division, print_function, unicode_literals from mopidy import backend +from mopidy import listener from mopidy.models import Ref, SearchResult import pykka +from mopidy_pandora.listener import PandoraPlaybackListener + def create_proxy(cls, config=None, audio=None): return cls.start(config=config, audio=audio).proxy() @@ -84,6 +87,7 @@ def change_track(self, track): """Pass a track with URI 'dummy:error' to force failure""" self._uri = track.uri self._time_position = 0 + listener.send(PandoraPlaybackListener, 'track_changing', track=track) return True def prepare_change(self): diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 811af69..b9498ce 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -16,7 +16,6 @@ from mopidy_pandora import frontend from mopidy_pandora.frontend import PandoraFrontend -from mopidy_pandora.listener import PandoraBackendListener from tests import conftest, dummy_backend from tests.dummy_backend import DummyBackend, DummyPandoraBackend @@ -230,7 +229,6 @@ def test_station_change_does_not_trim_currently_playing_track_from_tracklist(sel self.core.tracklist.clear() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) - self.frontend.track_changing(self.tl_tracks[0].track).get() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. tl_tracks = self.core.tracklist.get_tl_tracks().get() @@ -275,7 +273,6 @@ def test_changing_track_no_op(self): assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) self.replay_events(self.frontend) - self.frontend.track_changing(self.tl_tracks[1].track).get() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) @@ -283,33 +280,27 @@ def test_changing_track_no_op(self): def test_changing_track_station_changed(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + self.core.tracklist.clear() + self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri, self.tl_tracks[4].track.uri]) + assert len(self.core.tracklist.get_tl_tracks().get()) == 2 + self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.play(tlid=self.tl_tracks[4].tlid).get() + self.core.playback.seek(100) self.replay_events(self.frontend) + self.core.playback.next().get() - assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) + self.replay_events(self.frontend, until='tracklist_changed') - self.frontend.track_changing(self.tl_tracks[4].track).get() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 1 # Tracks were trimmed from the tracklist - assert tl_tracks[0] == self.tl_tracks[4] # Only the track recently changed to is left in the tracklist + # Only the track recently changed to is left in the tracklist + assert tl_tracks[0].track.uri == self.tl_tracks[4].track.uri assert all(self.has_events([('end_of_tracklist_reached', {'station_id': 'id_mock_other', 'auto_play': False})])) - def test_delete_station_clears_tracklist_on_finish(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - assert len(self.core.tracklist.get_tl_tracks().get()) > 0 - - listener.send(PandoraBackendListener, 'event_processed', - track_uri=self.tracks[0].uri, - pandora_event='delete_station') - self.replay_events(self.frontend) - - assert len(self.core.tracklist.get_tl_tracks().get()) == 0 - def test_track_unplayable_removes_tracks_from_tracklist(self): tl_tracks = self.core.tracklist.get_tl_tracks().get() unplayable_track = tl_tracks[0] diff --git a/tests/test_listener.py b/tests/test_listener.py index 98a3983..540d73a 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -9,39 +9,47 @@ import mopidy_pandora.listener as listener -class PandoraFrontendListenerTest(unittest.TestCase): +class EventMonitorListenerTest(unittest.TestCase): def setUp(self): # noqa: N802 - self.listener = listener.PandoraFrontendListener() + self.listener = listener.EventMonitorListener() def test_on_event_forwards_to_specific_handler(self): - self.listener.end_of_tracklist_reached = mock.Mock() + self.listener.event_triggered = mock.Mock() - self.listener.on_event( - 'end_of_tracklist_reached', station_id='id_mock', auto_play=False) + self.listener.on_event('event_triggered', track_uri='pandora:track:id_mock:token_mock', + pandora_event='event_mock') - self.listener.end_of_tracklist_reached.assert_called_with(station_id='id_mock', auto_play=False) + self.listener.event_triggered.assert_called_with(track_uri='pandora:track:id_mock:token_mock', + pandora_event='event_mock') - def test_listener_has_default_impl_for_end_of_tracklist_reached(self): - self.listener.end_of_tracklist_reached(station_id='id_mock', auto_play=False) + def test_listener_has_default_impl_for_event_triggered(self): + self.listener.event_triggered('pandora:track:id_mock:token_mock', 'event_mock') + + def test_listener_has_default_impl_for_track_changed_previous(self): + self.listener.track_changed_previous(old_uri='pandora:track:id_mock:token_mock2', + new_uri='pandora:track:id_mock:token_mock1') + def test_listener_has_default_impl_for_track_changed_next(self): + self.listener.track_changed_next(old_uri='pandora:track:id_mock:token_mock1', + new_uri='pandora:track:id_mock:token_mock2') -class PandoraEventHandlingFrontendListenerTest(unittest.TestCase): + +class PandoraFrontendListenerTest(unittest.TestCase): def setUp(self): # noqa: N802 - self.listener = listener.PandoraEventHandlingFrontendListener() + self.listener = listener.PandoraFrontendListener() def test_on_event_forwards_to_specific_handler(self): - self.listener.event_triggered = mock.Mock() + self.listener.end_of_tracklist_reached = mock.Mock() - self.listener.on_event('event_triggered', track_uri='pandora:track:id_mock:token_mock', - pandora_event='event_mock') + self.listener.on_event( + 'end_of_tracklist_reached', station_id='id_mock', auto_play=False) - self.listener.event_triggered.assert_called_with(track_uri='pandora:track:id_mock:token_mock', - pandora_event='event_mock') + self.listener.end_of_tracklist_reached.assert_called_with(station_id='id_mock', auto_play=False) - def test_listener_has_default_impl_for_event_triggered(self): - self.listener.event_triggered('pandora:track:id_mock:token_mock', 'event_mock') + def test_listener_has_default_impl_for_end_of_tracklist_reached(self): + self.listener.end_of_tracklist_reached(station_id='id_mock', auto_play=False) class PandoraBackendListenerTest(unittest.TestCase): diff --git a/tests/test_monitor.py b/tests/test_monitor.py index b191e1b..4f62eb5 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -13,8 +13,7 @@ import pykka from mopidy_pandora import monitor - -from mopidy_pandora.listener import PandoraPlaybackListener +from mopidy_pandora.listener import PandoraBackendListener from mopidy_pandora.monitor import EventMarker, EventSequence, MatchResult @@ -102,9 +101,20 @@ class EventMonitorTest(BaseTest): def setUp(self): # noqa: N802 super(EventMonitorTest, self).setUp() self.monitor = monitor.EventMonitor(conftest.config(), self.core) - # Consume more needs to be enabled to detect 'previous' track changes + # Consume mode needs to be enabled to detect 'previous' track changes self.core.tracklist.set_consume(True) + def test_delete_station_clears_tracklist_on_finish(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + assert len(self.core.tracklist.get_tl_tracks().get()) > 0 + + listener.send(PandoraBackendListener, 'event_processed', + track_uri=self.tracks[0].uri, + pandora_event='delete_station') + self.replay_events(self.monitor) + + assert len(self.core.tracklist.get_tl_tracks().get()) == 0 + def test_detect_track_change_next(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Next @@ -121,16 +131,16 @@ def test_detect_track_change_next(self): })])) def test_detect_track_change_next_from_paused(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: # Next self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) - self.core.playback.pause().get() + self.core.playback.pause() self.replay_events(self.monitor) self.core.playback.next().get() self.replay_events(self.monitor, until='track_playback_paused') - thread_joiner.wait(timeout=1.0) + thread_joiner.wait(timeout=5.0) assert all(self.has_events([('track_changed_next', { 'old_uri': self.tl_tracks[0].track.uri, 'new_uri': self.tl_tracks[1].track.uri @@ -140,7 +150,7 @@ def test_detect_track_change_previous(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Next self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100).get() + self.core.playback.seek(100) self.replay_events(self.monitor) self.core.playback.previous().get() self.replay_events(self.monitor, until='track_playback_started') @@ -152,50 +162,48 @@ def test_detect_track_change_previous(self): })])) def test_detect_track_change_previous_from_paused(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: # Next self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) - self.core.playback.pause().get() + self.core.playback.pause() self.replay_events(self.monitor) self.core.playback.previous().get() self.replay_events(self.monitor, until='track_playback_paused') - thread_joiner.wait(timeout=1.0) + thread_joiner.wait(timeout=5.0) assert all(self.has_events([('track_changed_previous', { 'old_uri': self.tl_tracks[0].track.uri, 'new_uri': self.tl_tracks[0].track.uri })])) def test_events_triggered_on_next_action(self): - with conftest.ThreadJoiner(timeout=10.0) as thread_joiner: + with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: # Pause -> Next self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) - self.core.playback.pause().get() + self.core.playback.pause() self.replay_events(self.monitor) self.core.playback.next().get() - listener.send(PandoraPlaybackListener, 'track_changing', track=self.tl_tracks[1].track) - self.replay_events(self.monitor) + self.replay_events(self.monitor, until='track_changed_next') - thread_joiner.wait(timeout=10.0) + thread_joiner.wait(timeout=5.0) assert all(self.has_events([('event_triggered', { 'track_uri': self.tl_tracks[0].track.uri, 'pandora_event': conftest.config()['pandora']['on_pause_next_click'] })])) def test_events_triggered_on_previous_action(self): - with conftest.ThreadJoiner(timeout=10.0) as thread_joiner: + with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: # Pause -> Previous self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) - self.core.playback.pause().get() + self.core.playback.pause() self.replay_events(self.monitor) self.core.playback.previous().get() - listener.send(PandoraPlaybackListener, 'track_changing', track=self.tl_tracks[0].track) - self.replay_events(self.monitor) + self.replay_events(self.monitor, until='track_changed_previous') - thread_joiner.wait(timeout=10.0) + thread_joiner.wait(timeout=5.0) assert all(self.has_events([('event_triggered', { 'track_uri': self.tl_tracks[0].track.uri, 'pandora_event': conftest.config()['pandora']['on_pause_previous_click'] @@ -243,31 +251,31 @@ def test_monitor_ignores_ads(self): thread_joiner.wait(timeout=1.0) assert self.events.qsize() == 0 # Check that no events were triggered - - # TODO: Add this test back again - # def test_process_event_resumes_playback_for_change_track(self): - # actions = ['stop', 'change_track', 'resume'] - # - # for action in actions: - # self.events = Queue.Queue() # Make sure that the queue is empty - # self.core.playback.play(tlid=self.tl_tracks[0].tlid) - # self.core.playback.seek(100) - # self.core.playback.pause().get() - # self.replay_events(self.frontend) - # assert self.core.playback.get_state().get() == PlaybackState.PAUSED - # - # if action == 'change_track': - # self.core.playback.next() - # self.frontend.process_event(event=action).get() - # - # self.assertEqual(self.core.playback.get_state().get(), - # PlaybackState.PLAYING, - # "Failed to set playback for action '{}'".format(action)) - # else: - # self.frontend.process_event(event=action).get() - # self.assertEqual(self.core.playback.get_state().get(), - # PlaybackState.PAUSED, - # "Failed to set playback for action '{}'".format(action)) +# +# # TODO: Add this test back again +# # def test_process_event_resumes_playback_for_change_track(self): +# # actions = ['stop', 'change_track', 'resume'] +# # +# # for action in actions: +# # self.events = Queue.Queue() # Make sure that the queue is empty +# # self.core.playback.play(tlid=self.tl_tracks[0].tlid) +# # self.core.playback.seek(100) +# # self.core.playback.pause().get() +# # self.replay_events(self.frontend) +# # assert self.core.playback.get_state().get() == PlaybackState.PAUSED +# # +# # if action == 'change_track': +# # self.core.playback.next() +# # self.frontend.process_event(event=action).get() +# # +# # self.assertEqual(self.core.playback.get_state().get(), +# # PlaybackState.PLAYING, +# # "Failed to set playback for action '{}'".format(action)) +# # else: +# # self.frontend.process_event(event=action).get() +# # self.assertEqual(self.core.playback.get_state().get(), +# # PlaybackState.PAUSED, +# # "Failed to set playback for action '{}'".format(action)) class EventSequenceTest(unittest.TestCase): From a43c0768eae7642af78fafb4b90fdd432ac5e9bd Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 16 Jan 2016 18:37:03 +0200 Subject: [PATCH 234/311] WIP: add functionality again to resume playback after event trigger. --- mopidy_pandora/monitor.py | 25 ++++++++++++------------ tests/conftest.py | 2 +- tests/test_monitor.py | 40 +++++++++++++++------------------------ 3 files changed, 29 insertions(+), 38 deletions(-) diff --git a/mopidy_pandora/monitor.py b/mopidy_pandora/monitor.py index e1cf678..58c5a25 100644 --- a/mopidy_pandora/monitor.py +++ b/mopidy_pandora/monitor.py @@ -15,6 +15,7 @@ from functools import total_ordering from mopidy import core +from mopidy.audio import PlaybackState from mopidy_pandora import listener from mopidy_pandora.uri import AdItemUri, PandoraUri @@ -104,18 +105,21 @@ def on_event(self, event, **kwargs): self._detect_track_change(event, **kwargs) if self._monitor_lock.acquire(False): - if event not in self.trigger_events: - # Optimisation: monitor not running and current event will not trigger any starts either, ignore + if event in self.trigger_events: + # Monitor not running and current event will not trigger any starts either, ignore + self.notify_all(event, **kwargs) + self.monitor_sequences() + else: self._monitor_lock.release() return - self._monitor_lock.release() + 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) - if self._monitor_lock.acquire(False): - self.monitor_sequences() - def _detect_track_change(self, event, **kwargs): if not self._track_changed_marker and event in ['track_playback_ended']: self._track_changed_marker = EventMarker(event, @@ -148,11 +152,9 @@ def monitor_sequences(self): if match and match.ratio > 0.85: self._trigger_event_triggered(match.marker.event, match.marker.uri) - # Resume playback... - # TODO: this is matching to the wrong event. - # if match.marker.event == 'change_track' and self.core.playback.get_state().get() != PlaybackState.PLAYING: - # self.core.playback.resume() + if self.core.playback.get_state().get() != PlaybackState.PLAYING: + self.core.playback.resume() self._monitor_lock.release() @@ -228,10 +230,9 @@ def notify(self, event, **kwargs): return else: tl_track = kwargs.get('tl_track', None) + uri = None if tl_track: uri = tl_track.track.uri - else: - uri = None self.start_monitor(uri) self.events_seen.append(event) diff --git a/tests/conftest.py b/tests/conftest.py index 713677f..944607e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,7 +67,7 @@ def config(): 'cache_time_to_live': 1800, 'event_support_enabled': False, - 'double_click_interval': '1.0', + 'double_click_interval': '0.5', 'on_pause_resume_click': 'thumbs_up', 'on_pause_next_click': 'thumbs_down', 'on_pause_previous_click': 'sleep', diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 4f62eb5..40877d1 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -9,6 +9,7 @@ from mock import mock from mopidy import core, listener, models +from mopidy.audio import PlaybackState import pykka @@ -251,31 +252,20 @@ def test_monitor_ignores_ads(self): thread_joiner.wait(timeout=1.0) assert self.events.qsize() == 0 # Check that no events were triggered -# -# # TODO: Add this test back again -# # def test_process_event_resumes_playback_for_change_track(self): -# # actions = ['stop', 'change_track', 'resume'] -# # -# # for action in actions: -# # self.events = Queue.Queue() # Make sure that the queue is empty -# # self.core.playback.play(tlid=self.tl_tracks[0].tlid) -# # self.core.playback.seek(100) -# # self.core.playback.pause().get() -# # self.replay_events(self.frontend) -# # assert self.core.playback.get_state().get() == PlaybackState.PAUSED -# # -# # if action == 'change_track': -# # self.core.playback.next() -# # self.frontend.process_event(event=action).get() -# # -# # self.assertEqual(self.core.playback.get_state().get(), -# # PlaybackState.PLAYING, -# # "Failed to set playback for action '{}'".format(action)) -# # else: -# # self.frontend.process_event(event=action).get() -# # self.assertEqual(self.core.playback.get_state().get(), -# # PlaybackState.PAUSED, -# # "Failed to set playback for action '{}'".format(action)) + + def test_monitor_resumes_playback_after_event_trigger(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.pause() + self.replay_events(self.monitor) + assert self.core.playback.get_state().get() == PlaybackState.PAUSED + + self.core.playback.next().get() + self.replay_events(self.monitor, until='track_changed_next') + + thread_joiner.wait(timeout=5.0) + assert self.core.playback.get_state().get() == PlaybackState.PLAYING class EventSequenceTest(unittest.TestCase): From d4fb36c8aa0eba6daca7817deebe3592dabe379d Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 16 Jan 2016 18:47:07 +0200 Subject: [PATCH 235/311] WIP: no-op test for detecting false positive track change. --- mopidy_pandora/monitor.py | 4 ++-- tests/test_monitor.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/mopidy_pandora/monitor.py b/mopidy_pandora/monitor.py index 58c5a25..674ef5e 100644 --- a/mopidy_pandora/monitor.py +++ b/mopidy_pandora/monitor.py @@ -166,7 +166,7 @@ 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. + # 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. @@ -241,9 +241,9 @@ def is_monitoring(self): def start_monitor(self, uri): self.monitoring_completed.clear() - # TODO: ad checking probably belongs somewhere else. if uri and type(PandoraUri.factory(uri)) is AdItemUri: logger.info('Ignoring doubleclick event for Pandora advertisement...') + self.reset() self.monitoring_completed.set() return diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 40877d1..eec9e53 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -147,6 +147,19 @@ def test_detect_track_change_next_from_paused(self): 'new_uri': self.tl_tracks[1].track.uri })])) + def test_detect_track_change_no_op(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + # Next + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.seek(100) + self.core.playback.stop() + self.replay_events(self.monitor) + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + + thread_joiner.wait(timeout=1.0) + self.replay_events(self.monitor, until='track_playback_started') + assert self.events.empty() + def test_detect_track_change_previous(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Next From 1fc68dd428d678cb062ed855c80e92520da0aea1 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 16 Jan 2016 19:29:53 +0200 Subject: [PATCH 236/311] WIP: add configuration option for disabling event monitor. Associated test cases. --- mopidy_pandora/frontend.py | 11 ++++++----- tests/test_frontend.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 6af7959..6d7b82e 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -13,7 +13,6 @@ logger = logging.getLogger(__name__) -# TODO: profile this function for optimisation? 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 @@ -57,7 +56,9 @@ def __init__(self, config, core): self.setup_required = True self.core = core - self.event_monitor = EventMonitor(config, core) + self.event_monitor = None + if self.config['event_support_enabled']: + self.event_monitor = EventMonitor(config, core) def set_options(self): # Setup playback to mirror behaviour of official Pandora front-ends. @@ -81,11 +82,11 @@ def options_changed(self): self.setup_required = True self.set_options() - # TODO: add toggle to disable event monitoring 'event_support_enabled'? @only_execute_for_pandora_uris def on_event(self, event, **kwargs): - self.event_monitor.on_event(event, **kwargs) - getattr(self, event)(**kwargs) + super(PandoraFrontend, self).on_event(event, **kwargs) + if self.event_monitor: + self.event_monitor.on_event(event, **kwargs) def track_playback_started(self, tl_track): self.set_options() diff --git a/tests/test_frontend.py b/tests/test_frontend.py index b9498ce..4e1cf15 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -16,6 +16,7 @@ from mopidy_pandora import frontend from mopidy_pandora.frontend import PandoraFrontend +from mopidy_pandora.monitor import EventMonitor from tests import conftest, dummy_backend from tests.dummy_backend import DummyBackend, DummyPandoraBackend @@ -150,6 +151,18 @@ def test_next_track_available_forces_stop_if_no_more_tracks(self): self.frontend.next_track_available(None).get() assert self.core.playback.get_state().get() == PlaybackState.STOPPED + def test_on_event_passes_on_calls_to_monitor(self): + config = conftest.config() + config['pandora']['event_support_enabled'] = True + + self.frontend = frontend.PandoraFrontend.start(config, self.core).proxy() + + assert self.frontend.event_monitor.core.get() + monitor_mock = mock.Mock(spec=EventMonitor) + self.frontend.event_monitor = monitor_mock + self.frontend.on_event('track_playback_started', tl_track=self.core.tracklist.get_tl_tracks().get()[0]).get() + assert monitor_mock.on_event.called + def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): func_mock = mock.PropertyMock() func_mock.__name__ = str('func_mock') @@ -240,6 +253,9 @@ def test_is_end_of_tracklist_reached(self): assert not self.frontend.is_end_of_tracklist_reached().get() + def test_event_support_disabled_does_not_initialize_monitor(self): + assert not self.frontend.event_monitor.get() + def test_is_end_of_tracklist_reached_last_track(self): self.core.playback.play(tlid=self.tl_tracks[-1].tlid) From 8801cc2c994ce03113cd1fb1515ba23ab29ec721 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 16 Jan 2016 20:24:37 +0200 Subject: [PATCH 237/311] WIP: relace sequence matching to ignore order by default. --- mopidy_pandora/monitor.py | 31 ++++++++++++++++--------------- tests/test_monitor.py | 23 ++++++++++++----------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/mopidy_pandora/monitor.py b/mopidy_pandora/monitor.py index 674ef5e..322914e 100644 --- a/mopidy_pandora/monitor.py +++ b/mopidy_pandora/monitor.py @@ -150,8 +150,11 @@ def monitor_sequences(self): match = self.sequence_match_results.get() self.sequence_match_results.task_done() - if match and match.ratio > 0.85: - self._trigger_event_triggered(match.marker.event, match.marker.uri) + if match and match.ratio >= 0.80: + 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() @@ -241,11 +244,6 @@ def is_monitoring(self): def start_monitor(self, uri): self.monitoring_completed.clear() - if uri and type(PandoraUri.factory(uri)) is AdItemUri: - logger.info('Ignoring doubleclick event for Pandora advertisement...') - self.reset() - self.monitoring_completed.set() - return self.target_uri = uri self._timer = threading.Timer(self.interval, self.stop_monitor, args=(self.interval,)) @@ -254,15 +252,18 @@ def start_monitor(self, uri): @run_async def stop_monitor(self, timeout): - i = 0 - # Make sure that we have seen every event in the target sequence, and in the right order try: - for e in self.target_sequence: - i = self.events_seen[i:].index(e) + 1 - except ValueError: - # Sequence does not match, ignore - pass - else: + 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( diff --git a/tests/test_monitor.py b/tests/test_monitor.py index eec9e53..576acfb 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -260,8 +260,9 @@ def test_monitor_ignores_ads(self): self.core.playback.play(tlid=self.tl_tracks[2].tlid) self.core.playback.seek(100) self.core.playback.pause() - self.core.playback.resume().get() self.replay_events(self.monitor) + self.core.playback.resume().get() + self.replay_events(self.monitor, until='track_playback_resumed') thread_joiner.wait(timeout=1.0) assert self.events.qsize() == 0 # Check that no events were triggered @@ -348,7 +349,7 @@ def test_stop_monitor_waits_for_event(self): assert not self.es_wait.is_monitoring() assert self.rq.qsize() == 1 - def test_get_stop_monitor_that_all_events_occurred(self): + def test_get_stop_monitor_ensures_that_all_events_occurred(self): self.es.notify('e1', time_position=100) self.es.notify('e2', time_position=100) self.es.notify('e3', time_position=100) @@ -358,17 +359,17 @@ def test_get_stop_monitor_that_all_events_occurred(self): self.es.events_seen = ['e1', 'e2', 'e3'] assert self.rq.qsize() > 0 - def test_get_stop_monitor_that_events_were_seen_in_order(self): - self.es.notify('e1', time_position=100) - self.es.notify('e3', time_position=100) - self.es.notify('e2', time_position=100) - self.es.wait(timeout=1.0) + def test_get_stop_monitor_strict_ensures_that_events_were_seen_in_order(self): + self.es_strict.notify('e1', time_position=100) + self.es_strict.notify('e3', time_position=100) + self.es_strict.notify('e2', time_position=100) + self.es_strict.wait(timeout=1.0) assert self.rq.qsize() == 0 - self.es.notify('e1', time_position=100) - self.es.notify('e2', time_position=100) - self.es.notify('e3', time_position=100) - self.es.wait(timeout=1.0) + self.es_strict.notify('e1', time_position=100) + self.es_strict.notify('e2', time_position=100) + self.es_strict.notify('e3', time_position=100) + self.es_strict.wait(timeout=1.0) assert self.rq.qsize() > 0 def test_get_ratio_handles_repeating_events(self): From fa97124339172ca7fc3d559182db95717e8d8e5a Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 16 Jan 2016 20:49:49 +0200 Subject: [PATCH 238/311] WIP: ensure that track change is completed before updating tracklist. --- mopidy_pandora/frontend.py | 16 ++++++++++++++-- mopidy_pandora/monitor.py | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 6d7b82e..c15615a 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals import logging +import threading from mopidy import core @@ -60,6 +61,9 @@ def __init__(self, config, core): if self.config['event_support_enabled']: self.event_monitor = EventMonitor(config, 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: @@ -90,12 +94,18 @@ def on_event(self, event, **kwargs): 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) def track_playback_ended(self, tl_track, time_position): self.set_options() 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) def track_playback_resumed(self, tl_track, time_position): self.set_options() @@ -122,9 +132,11 @@ def is_station_changed(self, track): pass return False - # TODO: ideally all of this should be delayed until after the track change has been completed. - # Not sure the tracklist / history will always be up to date at this point. def track_changing(self, track): + self.track_change_completed_event.clear() + + def update_tracklist(self, track): + self.track_change_completed_event.wait(timeout=10) if self.is_station_changed(track): # Station has changed, remove tracks from previous station from tracklist. self._trim_tracklist(keep_only=track) diff --git a/mopidy_pandora/monitor.py b/mopidy_pandora/monitor.py index 322914e..079cac3 100644 --- a/mopidy_pandora/monitor.py +++ b/mopidy_pandora/monitor.py @@ -121,7 +121,7 @@ def notify_all(self, event, **kwargs): es.notify(event, **kwargs) def _detect_track_change(self, event, **kwargs): - if not self._track_changed_marker and event in ['track_playback_ended']: + 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)) From c12a02a6eaab4b8d9e23038ecca7dab88a799ad1 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 16 Jan 2016 21:00:56 +0200 Subject: [PATCH 239/311] WIP: add cautionary note on the use of event features in the README. --- README.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 112f60a..93468d4 100644 --- a/README.rst +++ b/README.rst @@ -127,7 +127,9 @@ It is also possible to apply Pandora ratings and perform other actions on the cu pause/play/previous/next buttons. - ``pandora/event_support_enabled``: setting this to ``true`` will enable the event triggers. Eventing is disabled by - default. + default as this feature is fairly experimental. 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 From 0ad94c1b0dde26cf45d51a9e742c4d88097818dc Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 16 Jan 2016 21:02:53 +0200 Subject: [PATCH 240/311] WIP: add cautionary note on the use of event features in the README. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 93468d4..096500b 100644 --- a/README.rst +++ b/README.rst @@ -129,7 +129,7 @@ pause/play/previous/next buttons. - ``pandora/event_support_enabled``: setting this to ``true`` will enable the event triggers. Eventing is disabled by default as this feature is fairly experimental. 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. + 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 From d83194b997a2e8185aac5d0c893224e5f7bf55fa Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 17 Jan 2016 08:56:22 +0200 Subject: [PATCH 241/311] Make updating of tracklist non-blocking and thread safe. --- mopidy_pandora/backend.py | 2 +- mopidy_pandora/frontend.py | 41 +++++++++++++++------- tests/test_backend.py | 4 +-- tests/test_frontend.py | 72 ++++++++++++++++++++++++-------------- 4 files changed, 76 insertions(+), 43 deletions(-) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index e274259..4bcee06 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -102,7 +102,7 @@ def delete_station(self, track_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)) + 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) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index c15615a..ea4803c 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals +import Queue + import logging import threading @@ -10,6 +12,7 @@ from mopidy_pandora import listener from mopidy_pandora.monitor import EventMonitor from mopidy_pandora.uri import PandoraUri +from mopidy_pandora.utils import run_async logger = logging.getLogger(__name__) @@ -64,6 +67,9 @@ def __init__(self, config, core): self.track_change_completed_event = threading.Event() self.track_change_completed_event.set() + self.update_tracklist_lock = threading.Lock() + self.update_tracklist_queue = Queue.Queue() + def set_options(self): # Setup playback to mirror behaviour of official Pandora front-ends. if self.auto_setup and self.setup_required: @@ -94,18 +100,14 @@ def on_event(self, event, **kwargs): 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) + self.track_change_completed_event.set() def track_playback_ended(self, tl_track, time_position): self.set_options() 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) + self.track_change_completed_event.set() def track_playback_resumed(self, tl_track, time_position): self.set_options() @@ -135,14 +137,27 @@ def is_station_changed(self, track): def track_changing(self, track): self.track_change_completed_event.clear() + self.update_tracklist_queue.put(track) + self.update_tracklist(track) + + @run_async def update_tracklist(self, track): - self.track_change_completed_event.wait(timeout=10) - 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) + # Wait up to one minute for a track to change successfully + if self.track_change_completed_event.wait(timeout=60.0): + + if self.update_tracklist_lock.acquire(): + + while not self.update_tracklist_queue.empty(): + # Only need to perform the update on the most recent track that was queued. + track = self.update_tracklist_queue.get() + + 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) + self.update_tracklist_lock.release() def track_unplayable(self, track): if self.is_end_of_tracklist_reached(track): diff --git a/tests/test_backend.py b/tests/test_backend.py index c317136..8e5742b 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -129,7 +129,7 @@ def test_process_event_calls_method(config, caplog): assert mock_call.called mock_call.reset_mock() - assert backend._trigger_event_processed.called + backend._trigger_event_processed.assert_called_with(uri_mock, event) backend._trigger_event_processed.reset_mock() assert "Triggering event '{}'".format(event) in caplog.text() @@ -145,7 +145,7 @@ def test_process_event_handles_pandora_exception(config, caplog): mock_call.side_effect = PandoraException('exception_mock') assert not backend.process_event(uri_mock, 'thumbs_up') - assert mock_call.called + mock_call.assert_called_with(uri_mock) assert not backend._trigger_event_processed.called assert 'Error calling Pandora event: thumbs_up.' in caplog.text() diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 4e1cf15..774b09a 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -124,23 +124,15 @@ def test_add_track_trims_tracklist(self): assert len(tl_tracks) == 2 assert tl_tracks[-1].track == self.tl_tracks[0].track - def test_only_execute_for_pandora_executes_for_pandora_uri(self): - func_mock = mock.PropertyMock() - func_mock.__name__ = str('func_mock') - func_mock.return_value = True - - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - frontend.only_execute_for_pandora_uris(func_mock)(self) - - assert func_mock.called - def test_next_track_available_adds_track_to_playlist(self): self.core.tracklist.clear() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) - self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events(self.frontend) self.frontend.next_track_available(self.tl_tracks[1].track, True).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() + assert tl_tracks[-1].track == self.tl_tracks[1].track assert self.core.playback.get_current_track().get() == self.tl_tracks[1].track @@ -173,30 +165,56 @@ def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): assert not func_mock.called + def test_only_execute_for_pandora_does_not_execute_for_malformed_pandora_uri(self): + func_mock = mock.PropertyMock() + func_mock.__name__ = str('func_mock') + func_mock.return_value = True + + tl_track_mock = mock.Mock(spec=models.TlTrack) + track_mock = mock.Mock(spec=models.Track) + track_mock.uri = 'pandora:invalid_uri' + tl_track_mock.track = track_mock + frontend.only_execute_for_pandora_uris(func_mock)(self, tl_track=tl_track_mock) + + assert not func_mock.called + + def test_only_execute_for_pandora_executes_for_pandora_uri(self): + func_mock = mock.PropertyMock() + func_mock.__name__ = str('func_mock') + func_mock.return_value = True + + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + frontend.only_execute_for_pandora_uris(func_mock)(self) + + assert func_mock.called + def test_options_changed_triggers_setup(self): with mock.patch.object(PandoraFrontend, 'set_options', mock.Mock()) as set_options_mock: - self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.frontend.setup_required = False listener.send(CoreListener, 'options_changed') self.replay_events(self.frontend) assert set_options_mock.called def test_set_options_performs_auto_setup(self): - assert self.frontend.setup_required.get() - self.core.tracklist.set_repeat(True) - self.core.tracklist.set_consume(False) - self.core.tracklist.set_random(True) - self.core.tracklist.set_single(True) - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events(self.frontend) + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + assert self.frontend.setup_required.get() + self.core.tracklist.set_repeat(True) + self.core.tracklist.set_consume(False) + self.core.tracklist.set_random(True) + self.core.tracklist.set_single(True) + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events(self.frontend) - assert self.core.tracklist.get_repeat().get() is False - assert self.core.tracklist.get_consume().get() is True - assert self.core.tracklist.get_random().get() is False - assert self.core.tracklist.get_single().get() is False - self.replay_events(self.frontend) + thread_joiner.wait(timeout=1.0) + + assert self.core.tracklist.get_repeat().get() is False + assert self.core.tracklist.get_consume().get() is True + assert self.core.tracklist.get_random().get() is False + assert self.core.tracklist.get_single().get() is False + self.replay_events(self.frontend) - assert not self.frontend.setup_required.get() + assert not self.frontend.setup_required.get() def test_set_options_skips_auto_setup_if_not_configured(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -305,7 +323,7 @@ def test_changing_track_station_changed(self): self.replay_events(self.frontend) self.core.playback.next().get() - self.replay_events(self.frontend, until='tracklist_changed') + self.replay_events(self.frontend, until='track_playback_started') thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. @@ -314,7 +332,7 @@ def test_changing_track_station_changed(self): # Only the track recently changed to is left in the tracklist assert tl_tracks[0].track.uri == self.tl_tracks[4].track.uri - assert all(self.has_events([('end_of_tracklist_reached', {'station_id': 'id_mock_other', + assert any(self.has_events([('end_of_tracklist_reached', {'station_id': 'id_mock_other', 'auto_play': False})])) def test_track_unplayable_removes_tracks_from_tracklist(self): From 2e337f42c6c717106485c8766f2ac0667b95f812 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 17 Jan 2016 12:09:43 +0200 Subject: [PATCH 242/311] Simplify tracklist updating to be handled synchronously. --- mopidy_pandora/frontend.py | 39 ++++++++++++-------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index ea4803c..34e82d4 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, division, print_function, unicode_literals -import Queue import logging import threading @@ -12,7 +11,6 @@ from mopidy_pandora import listener from mopidy_pandora.monitor import EventMonitor from mopidy_pandora.uri import PandoraUri -from mopidy_pandora.utils import run_async logger = logging.getLogger(__name__) @@ -67,9 +65,6 @@ def __init__(self, config, core): self.track_change_completed_event = threading.Event() self.track_change_completed_event.set() - self.update_tracklist_lock = threading.Lock() - self.update_tracklist_queue = Queue.Queue() - def set_options(self): # Setup playback to mirror behaviour of official Pandora front-ends. if self.auto_setup and self.setup_required: @@ -100,14 +95,18 @@ def on_event(self, event, **kwargs): def track_playback_started(self, tl_track): self.set_options() - self.track_change_completed_event.set() + if not self.track_change_completed_event.is_set(): + self.track_change_completed_event.set() + self.update_tracklist(tl_track.track) def track_playback_ended(self, tl_track, time_position): self.set_options() def track_playback_paused(self, tl_track, time_position): self.set_options() - self.track_change_completed_event.set() + if not self.track_change_completed_event.is_set(): + self.track_change_completed_event.set() + self.update_tracklist(tl_track.track) def track_playback_resumed(self, tl_track, time_position): self.set_options() @@ -137,27 +136,13 @@ def is_station_changed(self, track): def track_changing(self, track): self.track_change_completed_event.clear() - self.update_tracklist_queue.put(track) - self.update_tracklist(track) - - @run_async def update_tracklist(self, track): - # Wait up to one minute for a track to change successfully - if self.track_change_completed_event.wait(timeout=60.0): - - if self.update_tracklist_lock.acquire(): - - while not self.update_tracklist_queue.empty(): - # Only need to perform the update on the most recent track that was queued. - track = self.update_tracklist_queue.get() - - 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) - self.update_tracklist_lock.release() + 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): From ce02850945768013f4309fc40709746188fb8c3a Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 18 Jan 2016 17:50:24 +0200 Subject: [PATCH 243/311] Consult additional sources to check if a Pandora track is currently active. Filter event triggers based on active track. --- mopidy_pandora/frontend.py | 52 +++++++++++++++++++++++++++++--------- mopidy_pandora/monitor.py | 12 ++++----- mopidy_pandora/uri.py | 7 +++++ tests/test_frontend.py | 2 +- tests/test_monitor.py | 12 ++++----- 5 files changed, 59 insertions(+), 26 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 34e82d4..badcc30 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -9,7 +9,6 @@ import pykka from mopidy_pandora import listener -from mopidy_pandora.monitor import EventMonitor from mopidy_pandora.uri import PandoraUri logger = logging.getLogger(__name__) @@ -29,27 +28,51 @@ def only_execute_for_pandora_uris(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: active_uri should contain the uri to be checked, all other kwargs - will be passed to the target function + :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. """ - try: - tl_track = kwargs.get('tl_track', self.core.playback.get_current_tl_track().get()) - uri = tl_track.track.uri - if uri.startswith(PandoraUri.SCHEME) and PandoraUri.factory(uri): - return func(self, *args, **kwargs) - except (AttributeError, NotImplementedError): - # Not playing a Pandora track. Don't do anything. - pass + 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): + from mopidy_pandora.monitor import EventMonitor super(PandoraFrontend, self).__init__() self.config = config['pandora'] @@ -83,6 +106,7 @@ def set_options(self): self.setup_required = False + @only_execute_for_pandora_uris def options_changed(self): self.setup_required = True self.set_options() @@ -93,21 +117,25 @@ def on_event(self, event, **kwargs): if self.event_monitor: self.event_monitor.on_event(event, **kwargs) + @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() diff --git a/mopidy_pandora/monitor.py b/mopidy_pandora/monitor.py index 079cac3..e1096f6 100644 --- a/mopidy_pandora/monitor.py +++ b/mopidy_pandora/monitor.py @@ -101,13 +101,14 @@ def on_start(self): self.trigger_events = set(e.target_sequence[0] for e in self.event_sequences) def on_event(self, event, **kwargs): + from mopidy_pandora import frontend super(EventMonitor, 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, **kwargs) + self.notify_all(event, uri=frontend.get_active_uri(self.core, event, **kwargs), **kwargs) self.monitor_sequences() else: self._monitor_lock.release() @@ -174,8 +175,9 @@ def _get_track_change_direction(self, track_marker): if h[1].uri == track_marker.uri: # This is the point in time in the history that the track was played. if i == 0: + # Just resuming playback of current track without change. return None - if history[i-1][1].uri == track_marker.uri: + elif history[i-1][1].uri == track_marker.uri: # Track was played again immediately. # User clicked 'previous' in consume mode. return 'track_changed_previous' @@ -232,11 +234,7 @@ def notify(self, event, **kwargs): # Don't do anything if track playback has not yet started. return else: - tl_track = kwargs.get('tl_track', None) - uri = None - if tl_track: - uri = tl_track.track.uri - self.start_monitor(uri) + self.start_monitor(kwargs.get('uri', None)) self.events_seen.append(event) def is_monitoring(self): diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 0f3b088..aae9b75 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -103,6 +103,13 @@ def _from_track(cls, track): 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' diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 774b09a..7e73773 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -128,7 +128,7 @@ def test_next_track_available_adds_track_to_playlist(self): self.core.tracklist.clear() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events(self.frontend) + self.replay_events(self.frontend, until='track_playback_started') self.frontend.next_track_available(self.tl_tracks[1].track, True).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 576acfb..d05d381 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -299,7 +299,7 @@ def setUp(self): def test_events_ignored_if_time_position_is_zero(self): for es in self.event_sequences: - es.notify('e1') + es.notify('e1', tl_track=self.tl_track_mock) for es in self.event_sequences: assert not es.is_monitoring() @@ -311,13 +311,13 @@ def test_start_monitor_on_event(self): def test_start_monitor_handles_no_tl_track(self): for es in self.event_sequences: - es.notify('e1', time_position=100) + es.notify('e1', tl_track=self.tl_track_mock, time_position=100) for es in self.event_sequences: assert es.is_monitoring() def test_stop_monitor_adds_result_to_queue(self): for es in self.event_sequences[0:2]: - es.notify('e1', time_position=100) + es.notify('e1', tl_track=self.tl_track_mock, time_position=100) es.notify('e2', time_position=100) es.notify('e3', time_position=100) @@ -350,7 +350,7 @@ def test_stop_monitor_waits_for_event(self): assert self.rq.qsize() == 1 def test_get_stop_monitor_ensures_that_all_events_occurred(self): - self.es.notify('e1', time_position=100) + self.es.notify('e1', tl_track=self.tl_track_mock, time_position=100) self.es.notify('e2', time_position=100) self.es.notify('e3', time_position=100) assert self.rq.qsize() == 0 @@ -360,13 +360,13 @@ def test_get_stop_monitor_ensures_that_all_events_occurred(self): assert self.rq.qsize() > 0 def test_get_stop_monitor_strict_ensures_that_events_were_seen_in_order(self): - self.es_strict.notify('e1', time_position=100) + self.es_strict.notify('e1', tl_track=self.tl_track_mock, time_position=100) self.es_strict.notify('e3', time_position=100) self.es_strict.notify('e2', time_position=100) self.es_strict.wait(timeout=1.0) assert self.rq.qsize() == 0 - self.es_strict.notify('e1', time_position=100) + self.es_strict.notify('e1', tl_track=self.tl_track_mock, time_position=100) self.es_strict.notify('e2', time_position=100) self.es_strict.notify('e3', time_position=100) self.es_strict.wait(timeout=1.0) From 7c6854f835400bd9843096279359529c68a0221e Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 18 Jan 2016 19:20:18 +0200 Subject: [PATCH 244/311] Refactor code for determining track change direction. Add test for getting active track. Standardise test class names to end on 'Tests'. --- mopidy_pandora/monitor.py | 24 +++++++++--------------- tests/test_extension.py | 2 +- tests/test_frontend.py | 33 +++++++++++++++++++++++++++------ tests/test_listener.py | 8 ++++---- tests/test_monitor.py | 10 +++++----- 5 files changed, 46 insertions(+), 31 deletions(-) diff --git a/mopidy_pandora/monitor.py b/mopidy_pandora/monitor.py index e1096f6..bbcd006 100644 --- a/mopidy_pandora/monitor.py +++ b/mopidy_pandora/monitor.py @@ -128,16 +128,12 @@ def _detect_track_change(self, event, **kwargs): int(time.time() * 1000)) elif self._track_changed_marker and event in ['track_playback_paused', 'track_playback_started']: - try: - 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 - except KeyError: - # Must be playing the first track, ignore - pass + 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): @@ -174,12 +170,10 @@ def _get_track_change_direction(self, track_marker): 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 i == 0: - # Just resuming playback of current track without change. - return None - elif history[i-1][1].uri == track_marker.uri: + if history[i-1][1].uri == track_marker.uri: # Track was played again immediately. - # User clicked 'previous' in consume mode. + # 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'. diff --git a/tests/test_extension.py b/tests/test_extension.py index 1cfb9b4..93734fb 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -10,7 +10,7 @@ from mopidy_pandora import frontend as frontend_lib -class ExtensionTest(unittest.TestCase): +class ExtensionTests(unittest.TestCase): def test_get_default_config(self): ext = Extension() diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 7e73773..3b25e56 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -97,13 +97,13 @@ def has_events(self, events): return [e in q for e in events] -class TestFrontend(BaseTest): +class FrontendTests(BaseTest): def setUp(self): # noqa: N802 - super(TestFrontend, self).setUp() + super(FrontendTests, self).setUp() self.frontend = frontend.PandoraFrontend.start(conftest.config(), self.core).proxy() def tearDown(self): # noqa: N802 - super(TestFrontend, self).tearDown() + super(FrontendTests, self).tearDown() def test_add_track_starts_playback(self): assert self.core.playback.get_state().get() == PlaybackState.STOPPED @@ -257,14 +257,35 @@ def test_station_change_does_not_trim_currently_playing_track_from_tracklist(sel with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: with mock.patch.object(PandoraFrontend, 'is_station_changed', mock.Mock(return_value=True)): - self.core.tracklist.clear() - self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) + self.core.playback.play(tlid=self.tl_tracks[4].tlid).get() + self.replay_events(self.frontend) thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 1 - assert tl_tracks[0].track == self.tl_tracks[0].track + assert tl_tracks[0].track == self.tl_tracks[4].track + + def test_get_active_uri_order_of_precedence(self): + # Should be 'track' -> 'tl_track' -> 'current_tl_track' -> 'history[0]' + kwargs = {} + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events(self.frontend) + assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[0].track.uri + + # No easy way to test retrieving from history as it is not possible to set core.playback_current_tl_track + # to None + + # self.core.playback.next() + # self.core.playback.stop() + # self.replay_events(self.frontend) + # assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[1].track.uri + + kwargs['tl_track'] = self.tl_tracks[2] + assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[2].track.uri + + kwargs = {'track': self.tl_tracks[3].track} + assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[3].track.uri def test_is_end_of_tracklist_reached(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid) diff --git a/tests/test_listener.py b/tests/test_listener.py index 540d73a..f1d7982 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -9,7 +9,7 @@ import mopidy_pandora.listener as listener -class EventMonitorListenerTest(unittest.TestCase): +class EventMonitorListenerTests(unittest.TestCase): def setUp(self): # noqa: N802 self.listener = listener.EventMonitorListener() @@ -35,7 +35,7 @@ def test_listener_has_default_impl_for_track_changed_next(self): new_uri='pandora:track:id_mock:token_mock2') -class PandoraFrontendListenerTest(unittest.TestCase): +class PandoraFrontendListenerTests(unittest.TestCase): def setUp(self): # noqa: N802 self.listener = listener.PandoraFrontendListener() @@ -52,7 +52,7 @@ def test_listener_has_default_impl_for_end_of_tracklist_reached(self): self.listener.end_of_tracklist_reached(station_id='id_mock', auto_play=False) -class PandoraBackendListenerTest(unittest.TestCase): +class PandoraBackendListenerTests(unittest.TestCase): def setUp(self): # noqa: N802 self.listener = listener.PandoraBackendListener() @@ -73,7 +73,7 @@ def test_listener_has_default_impl_for_event_processed(self): pandora_event='event_mock') -class PandoraPlaybackListenerTest(unittest.TestCase): +class PandoraPlaybackListenerTests(unittest.TestCase): def setUp(self): # noqa: N802 self.listener = listener.PandoraPlaybackListener() diff --git a/tests/test_monitor.py b/tests/test_monitor.py index d05d381..518920e 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -98,9 +98,9 @@ def has_events(self, events): return [e in q for e in events] -class EventMonitorTest(BaseTest): +class EventMonitorTests(BaseTest): def setUp(self): # noqa: N802 - super(EventMonitorTest, self).setUp() + super(EventMonitorTests, self).setUp() self.monitor = monitor.EventMonitor(conftest.config(), self.core) # Consume mode needs to be enabled to detect 'previous' track changes self.core.tracklist.set_consume(True) @@ -155,9 +155,9 @@ def test_detect_track_change_no_op(self): self.core.playback.stop() self.replay_events(self.monitor) self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events(self.monitor, until='track_playback_started') thread_joiner.wait(timeout=1.0) - self.replay_events(self.monitor, until='track_playback_started') assert self.events.empty() def test_detect_track_change_previous(self): @@ -282,7 +282,7 @@ def test_monitor_resumes_playback_after_event_trigger(self): assert self.core.playback.get_state().get() == PlaybackState.PLAYING -class EventSequenceTest(unittest.TestCase): +class EventSequenceTests(unittest.TestCase): def setUp(self): self.rq = Queue.PriorityQueue() @@ -385,7 +385,7 @@ def test_get_ratio_enforces_strict_matching(self): assert self.es_strict.get_ratio() == 1 -class MatchResultTest(unittest.TestCase): +class MatchResultTests(unittest.TestCase): def test_match_result_comparison(self): From 2af04fbf9bd169959d4be419c661edfcfb785725 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 19 Jan 2016 12:58:15 +0200 Subject: [PATCH 245/311] Refactor replaying of events to work for actors and actor proxies. --- tests/test_frontend.py | 74 ++++++++++++-------- tests/test_monitor.py | 155 ++++++++++++++++++++++------------------- 2 files changed, 129 insertions(+), 100 deletions(-) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 3b25e56..0ef7f6c 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -16,6 +16,7 @@ from mopidy_pandora import frontend from mopidy_pandora.frontend import PandoraFrontend +from mopidy_pandora.listener import PandoraFrontendListener from mopidy_pandora.monitor import EventMonitor from tests import conftest, dummy_backend @@ -64,37 +65,39 @@ def lookup(uris): self.core_send_mock = self.core_patcher.start() def send(cls, event, **kwargs): - self.events.put((event, kwargs)) + self.events.put((cls, event, kwargs)) self.send_mock.side_effect = send self.core_send_mock.side_effect = send + self.actor_register = [self.backend, self.core] + def tearDown(self): pykka.ActorRegistry.stop_all() mock.patch.stopall() - def replay_events(self, listener, until=None): + def replay_events(self, until=None): while True: try: e = self.events.get(timeout=0.1) - event, kwargs = e - listener.on_event(event, **kwargs).get() + cls, event, kwargs = e + for actor in self.actor_register: + if isinstance(actor, pykka.ActorProxy): + if isinstance(actor._actor, cls): + actor.on_event(event, **kwargs).get() + else: + actor.on_event(event, **kwargs) if e[0] == until: break except Queue.Empty: # All events replayed. break - def has_events(self, events): - q = [] - while True: - try: - q.append(self.events.get(timeout=0.1)) - except Queue.Empty: - # All events replayed. - break - - return [e in q for e in events] + def trigger_about_to_finish(self, replay_until=None): + self.replay_events() + callback = self.audio.get_about_to_finish_callback().get() + callback() + self.replay_events(until=replay_until) class FrontendTests(BaseTest): @@ -102,6 +105,8 @@ def setUp(self): # noqa: N802 super(FrontendTests, self).setUp() self.frontend = frontend.PandoraFrontend.start(conftest.config(), self.core).proxy() + self.actor_register.append(self.frontend) + def tearDown(self): # noqa: N802 super(FrontendTests, self).tearDown() @@ -128,7 +133,7 @@ def test_next_track_available_adds_track_to_playlist(self): self.core.tracklist.clear() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events(self.frontend, until='track_playback_started') + self.replay_events(until='track_playback_started') self.frontend.next_track_available(self.tl_tracks[1].track, True).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() @@ -193,7 +198,7 @@ def test_options_changed_triggers_setup(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() self.frontend.setup_required = False listener.send(CoreListener, 'options_changed') - self.replay_events(self.frontend) + self.replay_events() assert set_options_mock.called def test_set_options_performs_auto_setup(self): @@ -204,7 +209,7 @@ def test_set_options_performs_auto_setup(self): self.core.tracklist.set_random(True) self.core.tracklist.set_single(True) self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events(self.frontend) + self.replay_events() thread_joiner.wait(timeout=1.0) @@ -212,7 +217,7 @@ def test_set_options_performs_auto_setup(self): assert self.core.tracklist.get_consume().get() is True assert self.core.tracklist.get_random().get() is False assert self.core.tracklist.get_single().get() is False - self.replay_events(self.frontend) + self.replay_events() assert not self.frontend.setup_required.get() @@ -223,7 +228,7 @@ def test_set_options_skips_auto_setup_if_not_configured(self): config['pandora']['auto_setup'] = False self.frontend.setup_required = True - self.replay_events(self.frontend) + self.replay_events() assert self.frontend.setup_required def test_set_options_triggered_on_core_events(self): @@ -242,7 +247,7 @@ def test_set_options_triggered_on_core_events(self): for (event, kwargs) in core_events.items(): self.frontend.setup_required = True listener.send(CoreListener, event, **kwargs) - self.replay_events(self.frontend) + self.replay_events() self.assertEqual(set_options_mock.called, True, "Setup not done for event '{}'".format(event)) set_options_mock.reset_mock() @@ -258,7 +263,7 @@ def test_station_change_does_not_trim_currently_playing_track_from_tracklist(sel with mock.patch.object(PandoraFrontend, 'is_station_changed', mock.Mock(return_value=True)): self.core.playback.play(tlid=self.tl_tracks[4].tlid).get() - self.replay_events(self.frontend) + self.replay_events() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. @@ -270,7 +275,7 @@ def test_get_active_uri_order_of_precedence(self): # Should be 'track' -> 'tl_track' -> 'current_tl_track' -> 'history[0]' kwargs = {} self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events(self.frontend) + self.replay_events() assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[0].track.uri # No easy way to test retrieving from history as it is not possible to set core.playback_current_tl_track @@ -278,7 +283,7 @@ def test_get_active_uri_order_of_precedence(self): # self.core.playback.next() # self.core.playback.stop() - # self.replay_events(self.frontend) + # self.replay_events() # assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[1].track.uri kwargs['tl_track'] = self.tl_tracks[2] @@ -326,7 +331,7 @@ def test_changing_track_no_op(self): self.core.playback.next() assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - self.replay_events(self.frontend) + self.replay_events() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. @@ -341,10 +346,10 @@ def test_changing_track_station_changed(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) - self.replay_events(self.frontend) + self.replay_events() self.core.playback.next().get() - self.replay_events(self.frontend, until='track_playback_started') + self.replay_events(until='track_playback_started') thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. @@ -353,8 +358,10 @@ def test_changing_track_station_changed(self): # Only the track recently changed to is left in the tracklist assert tl_tracks[0].track.uri == self.tl_tracks[4].track.uri - assert any(self.has_events([('end_of_tracklist_reached', {'station_id': 'id_mock_other', - 'auto_play': False})])) + call = mock.call(PandoraFrontendListener, + 'end_of_tracklist_reached', station_id='id_mock_other', auto_play=False) + + assert call in self.send_mock.mock_calls def test_track_unplayable_removes_tracks_from_tracklist(self): tl_tracks = self.core.tracklist.get_tl_tracks().get() @@ -365,8 +372,15 @@ def test_track_unplayable_removes_tracks_from_tracklist(self): def test_track_unplayable_triggers_end_of_tracklist_event(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events(self.frontend) + self.replay_events() self.frontend.track_unplayable(self.tl_tracks[-1].track).get() - assert all([self.has_events('end_of_tracklist_reached')]) + + call = mock.call(PandoraFrontendListener, + 'end_of_tracklist_reached', + station_id='id_mock', + auto_play=True) + + assert call in self.send_mock.mock_calls + assert self.core.playback.get_state().get() == PlaybackState.STOPPED diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 518920e..839330e 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -14,7 +14,7 @@ import pykka from mopidy_pandora import monitor -from mopidy_pandora.listener import PandoraBackendListener +from mopidy_pandora.listener import EventMonitorListener, PandoraBackendListener from mopidy_pandora.monitor import EventMarker, EventSequence, MatchResult @@ -65,43 +65,42 @@ def lookup(uris): self.core_send_mock = self.core_patcher.start() def send(cls, event, **kwargs): - self.events.put((event, kwargs)) + self.events.put((cls, event, kwargs)) self.send_mock.side_effect = send self.core_send_mock.side_effect = send + self.actor_register = [self.backend, self.non_pandora_backend, self.core] + def tearDown(self): pykka.ActorRegistry.stop_all() mock.patch.stopall() - def replay_events(self, listener, until=None): + def replay_events(self, until=None): while True: try: e = self.events.get(timeout=0.1) - event, kwargs = e - listener.on_event(event, **kwargs) + cls, event, kwargs = e + for actor in self.actor_register: + if isinstance(actor, pykka.ActorProxy): + if isinstance(actor._actor, cls): + actor.on_event(event, **kwargs).get() + else: + actor.on_event(event, **kwargs) if e[0] == until: break except Queue.Empty: # All events replayed. break - def has_events(self, events): - q = [] - while True: - try: - q.append(self.events.get(timeout=0.1)) - except Queue.Empty: - # All events replayed. - break - - return [e in q for e in events] - class EventMonitorTests(BaseTest): def setUp(self): # noqa: N802 super(EventMonitorTests, self).setUp() self.monitor = monitor.EventMonitor(conftest.config(), self.core) + + self.actor_register.append(self.monitor) + # Consume mode needs to be enabled to detect 'previous' track changes self.core.tracklist.set_consume(True) @@ -112,7 +111,7 @@ def test_delete_station_clears_tracklist_on_finish(self): listener.send(PandoraBackendListener, 'event_processed', track_uri=self.tracks[0].uri, pandora_event='delete_station') - self.replay_events(self.monitor) + self.replay_events() assert len(self.core.tracklist.get_tl_tracks().get()) == 0 @@ -121,15 +120,17 @@ def test_detect_track_change_next(self): # Next self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) - self.replay_events(self.monitor) + self.replay_events() self.core.playback.next().get() - self.replay_events(self.monitor, until='track_playback_started') + self.replay_events(until='track_playback_started') thread_joiner.wait(timeout=1.0) - assert all(self.has_events([('track_changed_next', { - 'old_uri': self.tl_tracks[0].track.uri, - 'new_uri': self.tl_tracks[1].track.uri - })])) + call = mock.call(EventMonitorListener, + 'track_changed_next', + old_uri=self.tl_tracks[0].track.uri, + new_uri=self.tl_tracks[1].track.uri) + + assert call in self.send_mock.mock_calls def test_detect_track_change_next_from_paused(self): with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: @@ -137,15 +138,17 @@ def test_detect_track_change_next_from_paused(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) self.core.playback.pause() - self.replay_events(self.monitor) + self.replay_events() self.core.playback.next().get() - self.replay_events(self.monitor, until='track_playback_paused') + self.replay_events(until='track_playback_paused') thread_joiner.wait(timeout=5.0) - assert all(self.has_events([('track_changed_next', { - 'old_uri': self.tl_tracks[0].track.uri, - 'new_uri': self.tl_tracks[1].track.uri - })])) + call = mock.call(EventMonitorListener, + 'track_changed_next', + old_uri=self.tl_tracks[0].track.uri, + new_uri=self.tl_tracks[1].track.uri) + + assert call in self.send_mock.mock_calls def test_detect_track_change_no_op(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: @@ -153,9 +156,9 @@ def test_detect_track_change_no_op(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) self.core.playback.stop() - self.replay_events(self.monitor) + self.replay_events() self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events(self.monitor, until='track_playback_started') + self.replay_events(until='track_playback_started') thread_joiner.wait(timeout=1.0) assert self.events.empty() @@ -165,15 +168,17 @@ def test_detect_track_change_previous(self): # Next self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) - self.replay_events(self.monitor) + self.replay_events() self.core.playback.previous().get() - self.replay_events(self.monitor, until='track_playback_started') + self.replay_events(until='track_playback_started') thread_joiner.wait(timeout=1.0) - assert all(self.has_events([('track_changed_previous', { - 'old_uri': self.tl_tracks[0].track.uri, - 'new_uri': self.tl_tracks[0].track.uri - })])) + call = mock.call(EventMonitorListener, + 'track_changed_previous', + old_uri=self.tl_tracks[0].track.uri, + new_uri=self.tl_tracks[0].track.uri) + + assert call in self.send_mock.mock_calls def test_detect_track_change_previous_from_paused(self): with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: @@ -181,15 +186,17 @@ def test_detect_track_change_previous_from_paused(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) self.core.playback.pause() - self.replay_events(self.monitor) + self.replay_events() self.core.playback.previous().get() - self.replay_events(self.monitor, until='track_playback_paused') + self.replay_events(until='track_playback_paused') thread_joiner.wait(timeout=5.0) - assert all(self.has_events([('track_changed_previous', { - 'old_uri': self.tl_tracks[0].track.uri, - 'new_uri': self.tl_tracks[0].track.uri - })])) + call = mock.call(EventMonitorListener, + 'track_changed_previous', + old_uri=self.tl_tracks[0].track.uri, + new_uri=self.tl_tracks[0].track.uri) + + assert call in self.send_mock.mock_calls def test_events_triggered_on_next_action(self): with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: @@ -197,15 +204,17 @@ def test_events_triggered_on_next_action(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) self.core.playback.pause() - self.replay_events(self.monitor) + self.replay_events() self.core.playback.next().get() - self.replay_events(self.monitor, until='track_changed_next') + self.replay_events(until='track_changed_next') thread_joiner.wait(timeout=5.0) - assert all(self.has_events([('event_triggered', { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': conftest.config()['pandora']['on_pause_next_click'] - })])) + call = mock.call(EventMonitorListener, + 'event_triggered', + track_uri=self.tl_tracks[0].track.uri, + pandora_event=conftest.config()['pandora']['on_pause_next_click']) + + assert call in self.send_mock.mock_calls def test_events_triggered_on_previous_action(self): with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: @@ -213,15 +222,17 @@ def test_events_triggered_on_previous_action(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) self.core.playback.pause() - self.replay_events(self.monitor) + self.replay_events() self.core.playback.previous().get() - self.replay_events(self.monitor, until='track_changed_previous') + self.replay_events(until='track_changed_previous') thread_joiner.wait(timeout=5.0) - assert all(self.has_events([('event_triggered', { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': conftest.config()['pandora']['on_pause_previous_click'] - })])) + call = mock.call(EventMonitorListener, + 'event_triggered', + track_uri=self.tl_tracks[0].track.uri, + pandora_event=conftest.config()['pandora']['on_pause_previous_click']) + + assert call in self.send_mock.mock_calls def test_events_triggered_on_resume_action(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: @@ -230,13 +241,15 @@ def test_events_triggered_on_resume_action(self): self.core.playback.seek(100) self.core.playback.pause() self.core.playback.resume().get() - self.replay_events(self.monitor, until='track_playback_resumed') + self.replay_events(until='track_playback_resumed') thread_joiner.wait(timeout=1.0) - assert all(self.has_events([('event_triggered', { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': conftest.config()['pandora']['on_pause_resume_click'] - })])) + call = mock.call(EventMonitorListener, + 'event_triggered', + track_uri=self.tl_tracks[0].track.uri, + pandora_event=conftest.config()['pandora']['on_pause_resume_click']) + + assert call in self.send_mock.mock_calls def test_events_triggered_on_triple_click_action(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: @@ -245,24 +258,26 @@ def test_events_triggered_on_triple_click_action(self): self.core.playback.seek(100) self.core.playback.pause() self.core.playback.resume() - self.replay_events(self.monitor) + self.replay_events() self.core.playback.pause().get() - self.replay_events(self.monitor, until='track_playback_resumed') + self.replay_events(until='track_playback_resumed') thread_joiner.wait(timeout=1.0) - assert all(self.has_events([('event_triggered', { - 'track_uri': self.tl_tracks[0].track.uri, - 'pandora_event': conftest.config()['pandora']['on_pause_resume_pause_click'] - })])) + call = mock.call(EventMonitorListener, + 'event_triggered', + track_uri=self.tl_tracks[0].track.uri, + pandora_event=conftest.config()['pandora']['on_pause_resume_pause_click']) + + assert call in self.send_mock.mock_calls def test_monitor_ignores_ads(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[2].tlid) self.core.playback.seek(100) self.core.playback.pause() - self.replay_events(self.monitor) + self.replay_events() self.core.playback.resume().get() - self.replay_events(self.monitor, until='track_playback_resumed') + self.replay_events(until='track_playback_resumed') thread_joiner.wait(timeout=1.0) assert self.events.qsize() == 0 # Check that no events were triggered @@ -272,11 +287,11 @@ def test_monitor_resumes_playback_after_event_trigger(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.core.playback.seek(100) self.core.playback.pause() - self.replay_events(self.monitor) + self.replay_events() assert self.core.playback.get_state().get() == PlaybackState.PAUSED self.core.playback.next().get() - self.replay_events(self.monitor, until='track_changed_next') + self.replay_events(until='track_changed_next') thread_joiner.wait(timeout=5.0) assert self.core.playback.get_state().get() == PlaybackState.PLAYING From 6c010d5e280266569bf74816b3bdebed6932bd08 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 19 Jan 2016 22:00:22 +0200 Subject: [PATCH 246/311] Bump Mopidy dependency to the bugfixes needed in the new 1.1.2 release. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2a6268f..e1f95f7 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ def run_tests(self): install_requires=[ 'setuptools', 'cachetools >= 1.0.0', - 'Mopidy >= 1.1.1', + 'Mopidy >= 1.1.2', 'Pykka >= 1.1', 'pydora >= 1.6.5', 'requests >= 2.5.0' From 583ab4c94010d9e906d6311118337a872d73d1fb Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 20 Jan 2016 07:07:48 +0200 Subject: [PATCH 247/311] Update feature list in README. --- README.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index 096500b..179677e 100644 --- a/README.rst +++ b/README.rst @@ -24,15 +24,15 @@ Mopidy-Pandora Features ======== -- Supports **Pandora One** as well as **free** ad-supported Pandora accounts. -- Add ratings to tracks (thumbs up, thumbs down, sleep). +- Supports both Pandora One and ad-supported free accounts. +- Display album covers. +- 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. -- Also supports the usual features provided by the Mopidy music server (displaying album covers, scrobbling to last.fm, - etc.). +- Scrobbling to last.fm using the `Mopidy scrobbler `_. Usage @@ -126,10 +126,10 @@ The following configuration values are available: 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. Eventing is disabled by - default as this feature is fairly experimental. 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/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 From d6a3e6988fc423896987f1afcfbae07a13f62a3f Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 20 Jan 2016 09:27:12 +0200 Subject: [PATCH 248/311] De-clutter warning messages for unplayable tracks. --- mopidy_pandora/playback.py | 4 +--- tests/test_playback.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 2fda7c8..a15e388 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -47,13 +47,11 @@ def change_pandora_track(self, track): raise Unplayable("Track with URI '{}' is not playable.".format(track.uri)) except (AttributeError, requests.exceptions.RequestException, Unplayable) as e: - logger.warning('Error changing Pandora track: {}, ({})'.format(track, e)) # Track is not playable. self._consecutive_track_skips += 1 self.check_skip_limit() self._trigger_track_unplayable(track) - raise Unplayable("Cannot change to Pandora track '{}', ({}:{}).".format(track.uri, - type(e).__name__, e.args)) + raise Unplayable('Error changing Pandora track: {}, ({})'.format(track, e)) def change_track(self, track): if track.uri is None: diff --git a/tests/test_playback.py b/tests/test_playback.py index 1c6a257..2d4a4df 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -114,7 +114,7 @@ def test_change_track_fetches_next_track_if_unplayable(provider, playlist_item_m assert provider.change_track(track) is False assert provider._trigger_track_unplayable.called - assert 'Cannot change to Pandora track' in caplog.text() + assert 'Error changing Pandora track' in caplog.text() def test_change_track_skips_if_no_track_uri(provider): From 2d0003eeea586f4a13c9353d754e6370abfd45f5 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 20 Jan 2016 12:15:49 +0200 Subject: [PATCH 249/311] Switch to using pytest assert statements. --- tests/test_extension.py | 78 ++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/tests/test_extension.py b/tests/test_extension.py index 93734fb..cc1e7fc 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -17,51 +17,51 @@ def test_get_default_config(self): config = ext.get_default_config() - self.assertIn('[pandora]', config) - self.assertIn('enabled = true', config) - self.assertIn('api_host = tuner.pandora.com/services/json/', config) - self.assertIn('partner_encryption_key =', config) - self.assertIn('partner_decryption_key =', config) - self.assertIn('partner_username =', config) - self.assertIn('partner_password =', config) - self.assertIn('partner_device =', config) - self.assertIn('username =', config) - self.assertIn('password =', config) - self.assertIn('preferred_audio_quality = highQuality', config) - self.assertIn('sort_order = date', config) - self.assertIn('auto_setup = true', config) - self.assertIn('cache_time_to_live = 1800', config) - self.assertIn('event_support_enabled = false', config) - self.assertIn('double_click_interval = 2.50', config) - self.assertIn('on_pause_resume_click = thumbs_up', config) - self.assertIn('on_pause_next_click = thumbs_down', config) - self.assertIn('on_pause_previous_click = sleep', config) - self.assertIn('on_pause_resume_pause_click = delete_station', config) + assert '[pandora]'in config + assert 'enabled = true'in config + assert 'api_host = tuner.pandora.com/services/json/'in config + assert 'partner_encryption_key ='in config + assert 'partner_decryption_key ='in config + assert 'partner_username ='in config + assert 'partner_password ='in config + assert 'partner_device ='in config + assert 'username ='in config + assert 'password ='in config + assert 'preferred_audio_quality = highQuality'in config + assert 'sort_order = date'in config + assert 'auto_setup = true'in config + assert 'cache_time_to_live = 1800'in config + assert 'event_support_enabled = false'in config + assert 'double_click_interval = 2.50'in config + assert 'on_pause_resume_click = thumbs_up'in config + assert 'on_pause_next_click = thumbs_down'in config + assert 'on_pause_previous_click = sleep'in config + assert 'on_pause_resume_pause_click = delete_station'in config def test_get_config_schema(self): ext = Extension() schema = ext.get_config_schema() - self.assertIn('enabled', schema) - self.assertIn('api_host', schema) - self.assertIn('partner_encryption_key', schema) - self.assertIn('partner_decryption_key', schema) - self.assertIn('partner_username', schema) - self.assertIn('partner_password', schema) - self.assertIn('partner_device', schema) - self.assertIn('username', schema) - self.assertIn('password', schema) - self.assertIn('preferred_audio_quality', schema) - self.assertIn('sort_order', schema) - self.assertIn('auto_setup', schema) - self.assertIn('cache_time_to_live', schema) - self.assertIn('event_support_enabled', schema) - self.assertIn('double_click_interval', schema) - self.assertIn('on_pause_resume_click', schema) - self.assertIn('on_pause_next_click', schema) - self.assertIn('on_pause_previous_click', schema) - self.assertIn('on_pause_resume_pause_click', schema) + assert 'enabled'in schema + assert 'api_host'in schema + assert 'partner_encryption_key'in schema + assert 'partner_decryption_key'in schema + assert 'partner_username'in schema + assert 'partner_password'in schema + assert 'partner_device'in schema + assert 'username'in schema + assert 'password'in schema + assert 'preferred_audio_quality'in schema + assert 'sort_order'in schema + assert 'auto_setup'in schema + assert 'cache_time_to_live'in schema + assert 'event_support_enabled'in schema + assert 'double_click_interval'in schema + assert 'on_pause_resume_click'in schema + assert 'on_pause_next_click'in schema + assert 'on_pause_previous_click'in schema + assert 'on_pause_resume_pause_click'in schema def test_setup(self): registry = mock.Mock() From 986005d4e312b3cc965345299ea83055d84eee89 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 20 Jan 2016 12:16:55 +0200 Subject: [PATCH 250/311] Fix dosctring. --- mopidy_pandora/listener.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy_pandora/listener.py b/mopidy_pandora/listener.py index 2839256..72679c1 100644 --- a/mopidy_pandora/listener.py +++ b/mopidy_pandora/listener.py @@ -6,7 +6,7 @@ class EventMonitorListener(listener.Listener): """ - Marker interface for recipients of events sent by the frontend actor. + Marker interface for recipients of events sent by the event monitor. """ @@ -142,8 +142,8 @@ 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 (which should still be - in 'repeat' mode. + 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 From d2899fe6124ffba3f4d93636d11e8cdb5dd5ea3e Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 20 Jan 2016 12:17:24 +0200 Subject: [PATCH 251/311] Fix pytest decorator. --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 944607e..e82675c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,7 +40,7 @@ MOCK_AD_TYPE = 'ad' -@pytest.fixture() +@pytest.fixture def config(): return { 'http': { From 468a8262acb3551366dd6171552cd5459b6d7905 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 20 Jan 2016 12:18:06 +0200 Subject: [PATCH 252/311] Bump pydora dependency to version 1.6.6. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 179677e..558919d 100644 --- a/README.rst +++ b/README.rst @@ -56,7 +56,7 @@ 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.6.5. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. +- ``pydora`` >= 1.6.6. 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. From be1fba0639b2c671df55ffc926d2db6cb4f9fd92 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 20 Jan 2016 14:34:58 +0200 Subject: [PATCH 253/311] Add audio actor to test cases to enable audio event processing. --- tests/dummy_audio.py | 137 +++++++++++++++++++++++++++++++++++++++++ tests/dummy_backend.py | 42 +++++++++---- tests/test_frontend.py | 51 ++++++++------- 3 files changed, 195 insertions(+), 35 deletions(-) create mode 100644 tests/dummy_audio.py diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py new file mode 100644 index 0000000..fdd57d7 --- /dev/null +++ b/tests/dummy_audio.py @@ -0,0 +1,137 @@ +"""A dummy audio actor for use in tests. + +This class implements the audio API in the simplest way possible. It is used in +tests of the core and backends. +""" + +from __future__ import absolute_import, unicode_literals + +import pykka + +from mopidy import audio + + +def create_proxy(config=None, mixer=None): + return DummyAudio.start(config, mixer).proxy() + + +# TODO: reset position on track change? +class DummyAudio(pykka.ThreadingActor): + + def __init__(self, config=None, mixer=None): + super(DummyAudio, self).__init__() + self.state = audio.PlaybackState.STOPPED + self._volume = 0 + self._position = 0 + self._callback = None + self._uri = None + self._stream_changed = False + self._tags = {} + self._bad_uris = set() + + def set_uri(self, uri): + assert self._uri is None, 'prepare change not called before set' + self._tags = {} + self._uri = uri + self._stream_changed = True + + def set_appsrc(self, *args, **kwargs): + pass + + def emit_data(self, buffer_): + pass + + def emit_end_of_stream(self): + pass + + def get_position(self): + return self._position + + def set_position(self, position): + self._position = position + audio.AudioListener.send('position_changed', position=position) + return True + + def start_playback(self): + return self._change_state(audio.PlaybackState.PLAYING) + + def pause_playback(self): + return self._change_state(audio.PlaybackState.PAUSED) + + def prepare_change(self): + self._uri = None + return True + + def stop_playback(self): + return self._change_state(audio.PlaybackState.STOPPED) + + def get_volume(self): + return self._volume + + def set_volume(self, volume): + self._volume = volume + return True + + def set_metadata(self, track): + pass + + def get_current_tags(self): + return self._tags + + def set_about_to_finish_callback(self, callback): + self._callback = callback + + def enable_sync_handler(self): + pass + + def wait_for_state_change(self): + pass + + def _change_state(self, new_state): + if not self._uri: + return False + + if new_state == audio.PlaybackState.STOPPED and self._uri: + self._stream_changed = True + self._uri = None + + if self._uri is not None: + audio.AudioListener.send('position_changed', position=0) + + if self._stream_changed: + self._stream_changed = False + audio.AudioListener.send('stream_changed', uri=self._uri) + + old_state, self.state = self.state, new_state + audio.AudioListener.send( + 'state_changed', + old_state=old_state, new_state=new_state, target_state=None) + + if new_state == audio.PlaybackState.PLAYING: + self._tags['audio-codec'] = [u'fake info...'] + audio.AudioListener.send('tags_changed', tags=['audio-codec']) + + return self._uri not in self._bad_uris + + def trigger_fake_playback_failure(self, uri): + self._bad_uris.add(uri) + + def trigger_fake_tags_changed(self, tags): + self._tags.update(tags) + audio.AudioListener.send('tags_changed', tags=self._tags.keys()) + + def get_about_to_finish_callback(self): + # This needs to be called from outside the actor or we lock up. + def wrapper(): + if self._callback: + self.prepare_change() + self._callback() + + if not self._uri or not self._callback: + self._tags = {} + audio.AudioListener.send('reached_end_of_stream') + else: + audio.AudioListener.send('position_changed', position=0) + audio.AudioListener.send('stream_changed', uri=self._uri) + + return wrapper diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 424f762..6c09f66 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -19,26 +19,25 @@ def create_proxy(cls, config=None, audio=None): return cls.start(config=config, audio=audio).proxy() -class DummyPandoraBackend(pykka.ThreadingActor, backend.Backend): +class DummyBackend(pykka.ThreadingActor, backend.Backend): def __init__(self, config, audio): - super(DummyPandoraBackend, self).__init__() + super(DummyBackend, self).__init__() self.library = DummyLibraryProvider(backend=self) - self.playback = DummyPlaybackProvider(audio=audio, backend=self) + if audio is None: + self.playback = DummyPandoraPlaybackProvider(audio=audio, backend=self) + else: + self.playback = DummyPandoraPlaybackProviderWithAudioEvents(audio=audio, backend=self) - self.uri_schemes = ['pandora'] + self.uri_schemes = ['mock'] -class DummyBackend(pykka.ThreadingActor, backend.Backend): +class DummyPandoraBackend(DummyBackend): def __init__(self, config, audio): - super(DummyBackend, self).__init__() - - self.library = DummyLibraryProvider(backend=self) - self.playback = DummyPlaybackProvider(audio=audio, backend=self) - - self.uri_schemes = ['mock'] + super(DummyPandoraBackend, self).__init__(config, audio) + self.uri_schemes = ['pandora'] class DummyLibraryProvider(backend.LibraryProvider): @@ -87,7 +86,6 @@ def change_track(self, track): """Pass a track with URI 'dummy:error' to force failure""" self._uri = track.uri self._time_position = 0 - listener.send(PandoraPlaybackListener, 'track_changing', track=track) return True def prepare_change(self): @@ -106,3 +104,23 @@ def stop(self): def get_time_position(self): return self._time_position + + +class DummyPandoraPlaybackProvider(DummyPlaybackProvider): + + def __init__(self, *args, **kwargs): + super(DummyPandoraPlaybackProvider, self).__init__(*args, **kwargs) + + def change_track(self, track): + listener.send(PandoraPlaybackListener, 'track_changing', track=track) + return super(DummyPandoraPlaybackProvider, self).change_track(track) + + +class DummyPandoraPlaybackProviderWithAudioEvents(backend.PlaybackProvider): + + def __init__(self, *args, **kwargs): + super(DummyPandoraPlaybackProviderWithAudioEvents, self).__init__(*args, **kwargs) + + def change_track(self, track): + listener.send(PandoraPlaybackListener, 'track_changing', track=track) + return super(DummyPandoraPlaybackProviderWithAudioEvents, self).change_track(track) \ No newline at end of file diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 0ef7f6c..60f0b16 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -19,7 +19,8 @@ from mopidy_pandora.listener import PandoraFrontendListener from mopidy_pandora.monitor import EventMonitor -from tests import conftest, dummy_backend +from tests import conftest, dummy_audio, dummy_backend +from tests.dummy_audio import DummyAudio from tests.dummy_backend import DummyBackend, DummyPandoraBackend @@ -41,11 +42,12 @@ class BaseTest(unittest.TestCase): def setUp(self): config = {'core': {'max_tracklist_length': 10000}} - self.backend = dummy_backend.create_proxy(DummyPandoraBackend) - self.non_pandora_backend = dummy_backend.create_proxy(DummyBackend) + self.audio = dummy_audio.create_proxy(DummyAudio) + self.backend = dummy_backend.create_proxy(DummyPandoraBackend, audio=self.audio) + self.non_pandora_backend = dummy_backend.create_proxy(DummyBackend, audio=self.audio) self.core = core.Core.start( - config, backends=[self.backend, self.non_pandora_backend]).proxy() + config, audio=self.audio, backends=[self.backend, self.non_pandora_backend]).proxy() def lookup(uris): result = {uri: [] for uri in uris} @@ -59,18 +61,15 @@ def lookup(uris): self.events = Queue.Queue() self.patcher = mock.patch('mopidy.listener.send') - self.core_patcher = mock.patch('mopidy.listener.send_async') self.send_mock = self.patcher.start() - self.core_send_mock = self.core_patcher.start() def send(cls, event, **kwargs): self.events.put((cls, event, kwargs)) self.send_mock.side_effect = send - self.core_send_mock.side_effect = send - self.actor_register = [self.backend, self.core] + self.actor_register = [self.backend, self.core, self.audio] def tearDown(self): pykka.ActorRegistry.stop_all() @@ -93,12 +92,6 @@ def replay_events(self, until=None): # All events replayed. break - def trigger_about_to_finish(self, replay_until=None): - self.replay_events() - callback = self.audio.get_about_to_finish_callback().get() - callback() - self.replay_events(until=replay_until) - class FrontendTests(BaseTest): def setUp(self): # noqa: N802 @@ -114,6 +107,7 @@ def test_add_track_starts_playback(self): assert self.core.playback.get_state().get() == PlaybackState.STOPPED self.core.tracklist.clear() self.frontend.add_track(self.tl_tracks[0].track, auto_play=True).get() + self.replay_events() assert self.core.playback.get_state().get() == PlaybackState.PLAYING assert self.core.playback.get_current_track().get() == self.tl_tracks[0].track @@ -132,17 +126,20 @@ def test_add_track_trims_tracklist(self): def test_next_track_available_adds_track_to_playlist(self): self.core.tracklist.clear() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + tl_tracks = self.core.tracklist.get_tl_tracks().get() + self.core.playback.play(tlid=tl_tracks[0].tlid).get() self.replay_events(until='track_playback_started') self.frontend.next_track_available(self.tl_tracks[1].track, True).get() tl_tracks = self.core.tracklist.get_tl_tracks().get() + self.replay_events() assert tl_tracks[-1].track == self.tl_tracks[1].track assert self.core.playback.get_current_track().get() == self.tl_tracks[1].track def test_next_track_available_forces_stop_if_no_more_tracks(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events() assert self.core.playback.get_state().get() == PlaybackState.PLAYING self.frontend.next_track_available(None).get() @@ -188,7 +185,8 @@ def test_only_execute_for_pandora_executes_for_pandora_uri(self): func_mock.__name__ = str('func_mock') func_mock.return_value = True - self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events() frontend.only_execute_for_pandora_uris(func_mock)(self) assert func_mock.called @@ -252,7 +250,8 @@ def test_set_options_triggered_on_core_events(self): set_options_mock.reset_mock() def test_skip_limit_exceed_stops_playback(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events() assert self.core.playback.get_state().get() == PlaybackState.PLAYING self.frontend.skip_limit_exceeded().get() @@ -302,6 +301,7 @@ def test_event_support_disabled_does_not_initialize_monitor(self): def test_is_end_of_tracklist_reached_last_track(self): self.core.playback.play(tlid=self.tl_tracks[-1].tlid) + self.replay_events() assert self.frontend.is_end_of_tracklist_reached().get() @@ -316,8 +316,10 @@ def test_is_end_of_tracklist_reached_second_last_track(self): assert not self.frontend.is_end_of_tracklist_reached(self.tl_tracks[3].track).get() def test_is_station_changed(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.next() + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events() + self.core.playback.next().get() + self.replay_events() # Check against track of a different station assert self.frontend.is_station_changed(self.tl_tracks[4].track).get() @@ -342,10 +344,12 @@ def test_changing_track_station_changed(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: self.core.tracklist.clear() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri, self.tl_tracks[4].track.uri]) - assert len(self.core.tracklist.get_tl_tracks().get()) == 2 + tl_tracks = self.core.tracklist.get_tl_tracks().get() + assert len(tl_tracks) == 2 - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) + self.core.playback.play(tlid=tl_tracks[0].tlid).get() + self.replay_events() + self.core.playback.seek(100).get() self.replay_events() self.core.playback.next().get() @@ -360,6 +364,7 @@ def test_changing_track_station_changed(self): call = mock.call(PandoraFrontendListener, 'end_of_tracklist_reached', station_id='id_mock_other', auto_play=False) + calls = self.send_mock.mock_calls assert call in self.send_mock.mock_calls From 3ad6c849c616394d7c541dad2e613758668e513f Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 20 Jan 2016 14:38:26 +0200 Subject: [PATCH 254/311] Add audio actor to test cases to enable audio event processing. --- tests/dummy_audio.py | 4 ++-- tests/dummy_backend.py | 2 +- tests/test_frontend.py | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py index fdd57d7..3c061ce 100644 --- a/tests/dummy_audio.py +++ b/tests/dummy_audio.py @@ -6,10 +6,10 @@ from __future__ import absolute_import, unicode_literals -import pykka - from mopidy import audio +import pykka + def create_proxy(config=None, mixer=None): return DummyAudio.start(config, mixer).proxy() diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 6c09f66..592e138 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -123,4 +123,4 @@ def __init__(self, *args, **kwargs): def change_track(self, track): listener.send(PandoraPlaybackListener, 'track_changing', track=track) - return super(DummyPandoraPlaybackProviderWithAudioEvents, self).change_track(track) \ No newline at end of file + return super(DummyPandoraPlaybackProviderWithAudioEvents, self).change_track(track) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 60f0b16..9a2cd95 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -61,13 +61,16 @@ def lookup(uris): self.events = Queue.Queue() self.patcher = mock.patch('mopidy.listener.send') + self.core_patcher = mock.patch('mopidy.listener.send_async') self.send_mock = self.patcher.start() + self.core_send_mock = self.core_patcher.start() def send(cls, event, **kwargs): self.events.put((cls, event, kwargs)) self.send_mock.side_effect = send + self.core_send_mock.side_effect = send self.actor_register = [self.backend, self.core, self.audio] @@ -364,7 +367,6 @@ def test_changing_track_station_changed(self): call = mock.call(PandoraFrontendListener, 'end_of_tracklist_reached', station_id='id_mock_other', auto_play=False) - calls = self.send_mock.mock_calls assert call in self.send_mock.mock_calls From dde795438e1058195a175be52c7885f1a00b4385 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 20 Jan 2016 17:05:04 +0200 Subject: [PATCH 255/311] Update event matching patterns. Regression tests for Mopidy 1.2 --- mopidy_pandora/monitor.py | 31 +++++++++++++++++++++------- tests/test_frontend.py | 43 ++++++++++++++++++++++----------------- tests/test_monitor.py | 37 +++++++++++++++++++++------------ 3 files changed, 72 insertions(+), 39 deletions(-) diff --git a/mopidy_pandora/monitor.py b/mopidy_pandora/monitor.py index bbcd006..c5e40e2 100644 --- a/mopidy_pandora/monitor.py +++ b/mopidy_pandora/monitor.py @@ -14,7 +14,6 @@ from functools import total_ordering -from mopidy import core from mopidy.audio import PlaybackState from mopidy_pandora import listener @@ -41,11 +40,7 @@ def __lt__(self, other): return self.ratio < other.ratio -class EventMonitor(core.CoreListener, - listener.PandoraBackendListener, - listener.PandoraPlaybackListener, - listener.PandoraFrontendListener, - listener.EventMonitorListener): +class EventMonitor(listener.PandoraBackendListener): pykka_traversable = True @@ -66,23 +61,40 @@ def on_start(self): self.event_sequences.append(EventSequence(self.config['on_pause_resume_click'], ['track_playback_paused', + 'position_changed', + 'state_changed', + 'tags_changed', 'playback_state_changed', - 'track_playback_resumed'], self.sequence_match_results, + 'track_playback_resumed', + 'playback_state_changed', + 'track_playback_paused'], self.sequence_match_results, interval=interval)) self.event_sequences.append(EventSequence(self.config['on_pause_resume_pause_click'], ['track_playback_paused', + 'position_changed', + 'state_changed', + 'tags_changed', 'playback_state_changed', 'track_playback_resumed', 'playback_state_changed', + 'track_playback_paused', + 'position_changed', + 'state_changed', + 'playback_state_changed', 'track_playback_paused'], self.sequence_match_results, interval=interval)) self.event_sequences.append(EventSequence(self.config['on_pause_previous_click'], ['track_playback_paused', + 'stream_changed', + 'state_changed', 'playback_state_changed', 'track_playback_ended', 'track_changing', + 'position_changed', + 'stream_changed', + 'state_changed', 'playback_state_changed', 'track_playback_paused'], self.sequence_match_results, wait_for='track_changed_previous', @@ -90,9 +102,14 @@ def on_start(self): self.event_sequences.append(EventSequence(self.config['on_pause_next_click'], ['track_playback_paused', + 'stream_changed', + 'state_changed', 'playback_state_changed', 'track_playback_ended', 'track_changing', + 'position_changed', + 'stream_changed', + 'state_changed', 'playback_state_changed', 'track_playback_paused'], self.sequence_match_results, wait_for='track_changed_next', diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 9a2cd95..99b7a13 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -60,17 +60,22 @@ def lookup(uris): self.tl_tracks = self.core.tracklist.add(uris=self.uris).get() self.events = Queue.Queue() - self.patcher = mock.patch('mopidy.listener.send') - self.core_patcher = mock.patch('mopidy.listener.send_async') - - self.send_mock = self.patcher.start() - self.core_send_mock = self.core_patcher.start() def send(cls, event, **kwargs): self.events.put((cls, event, kwargs)) + self.patcher = mock.patch('mopidy.listener.send') + self.send_mock = self.patcher.start() self.send_mock.side_effect = send - self.core_send_mock.side_effect = send + + # TODO: Remove this patch once Mopidy 1.2 has been released. + try: + self.core_patcher = mock.patch('mopidy.listener.send_async') + self.core_send_mock = self.core_patcher.start() + self.core_send_mock.side_effect = send + except AttributeError: + # Mopidy > 1.1 no longer has mopidy.listener.send_async + pass self.actor_register = [self.backend, self.core, self.audio] @@ -130,7 +135,7 @@ def test_next_track_available_adds_track_to_playlist(self): self.core.tracklist.clear() self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) tl_tracks = self.core.tracklist.get_tl_tracks().get() - self.core.playback.play(tlid=tl_tracks[0].tlid).get() + self.core.playback.play(tlid=tl_tracks[0].tlid) self.replay_events(until='track_playback_started') self.frontend.next_track_available(self.tl_tracks[1].track, True).get() @@ -141,7 +146,7 @@ def test_next_track_available_adds_track_to_playlist(self): assert self.core.playback.get_current_track().get() == self.tl_tracks[1].track def test_next_track_available_forces_stop_if_no_more_tracks(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.replay_events() assert self.core.playback.get_state().get() == PlaybackState.PLAYING @@ -223,7 +228,7 @@ def test_set_options_performs_auto_setup(self): assert not self.frontend.setup_required.get() def test_set_options_skips_auto_setup_if_not_configured(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) config = conftest.config() config['pandora']['auto_setup'] = False @@ -243,7 +248,7 @@ def test_set_options_triggered_on_core_events(self): 'track_playback_resumed': {'tl_track': tl_tracks[0], 'time_position': 100}, } - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) for (event, kwargs) in core_events.items(): self.frontend.setup_required = True @@ -253,7 +258,7 @@ def test_set_options_triggered_on_core_events(self): set_options_mock.reset_mock() def test_skip_limit_exceed_stops_playback(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.replay_events() assert self.core.playback.get_state().get() == PlaybackState.PLAYING @@ -264,7 +269,7 @@ def test_station_change_does_not_trim_currently_playing_track_from_tracklist(sel with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: with mock.patch.object(PandoraFrontend, 'is_station_changed', mock.Mock(return_value=True)): - self.core.playback.play(tlid=self.tl_tracks[4].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[4].tlid) self.replay_events() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. @@ -276,7 +281,7 @@ def test_station_change_does_not_trim_currently_playing_track_from_tracklist(sel def test_get_active_uri_order_of_precedence(self): # Should be 'track' -> 'tl_track' -> 'current_tl_track' -> 'history[0]' kwargs = {} - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.replay_events() assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[0].track.uri @@ -319,9 +324,9 @@ def test_is_end_of_tracklist_reached_second_last_track(self): assert not self.frontend.is_end_of_tracklist_reached(self.tl_tracks[3].track).get() def test_is_station_changed(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.replay_events() - self.core.playback.next().get() + self.core.playback.next() self.replay_events() # Check against track of a different station @@ -350,11 +355,11 @@ def test_changing_track_station_changed(self): tl_tracks = self.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 2 - self.core.playback.play(tlid=tl_tracks[0].tlid).get() + self.core.playback.play(tlid=tl_tracks[0].tlid) self.replay_events() - self.core.playback.seek(100).get() + self.core.playback.seek(100) self.replay_events() - self.core.playback.next().get() + self.core.playback.next() self.replay_events(until='track_playback_started') @@ -378,7 +383,7 @@ def test_track_unplayable_removes_tracks_from_tracklist(self): self.assertEqual(unplayable_track in self.core.tracklist.get_tl_tracks().get(), False) def test_track_unplayable_triggers_end_of_tracklist_event(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.replay_events() self.frontend.track_unplayable(self.tl_tracks[-1].track).get() diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 839330e..268a36b 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -18,8 +18,9 @@ from mopidy_pandora.monitor import EventMarker, EventSequence, MatchResult -from tests import conftest, dummy_backend +from tests import conftest, dummy_audio, dummy_backend +from tests.dummy_audio import DummyAudio from tests.dummy_backend import DummyBackend, DummyPandoraBackend @@ -41,11 +42,12 @@ class BaseTest(unittest.TestCase): def setUp(self): config = {'core': {'max_tracklist_length': 10000}} - self.backend = dummy_backend.create_proxy(DummyPandoraBackend) - self.non_pandora_backend = dummy_backend.create_proxy(DummyBackend) + self.audio = dummy_audio.create_proxy(DummyAudio) + self.backend = dummy_backend.create_proxy(DummyPandoraBackend, audio=self.audio) + self.non_pandora_backend = dummy_backend.create_proxy(DummyBackend, audio=self.audio) self.core = core.Core.start( - config, backends=[self.backend, self.non_pandora_backend]).proxy() + config, audio=self.audio, backends=[self.backend, self.non_pandora_backend]).proxy() def lookup(uris): result = {uri: [] for uri in uris} @@ -58,17 +60,22 @@ def lookup(uris): self.tl_tracks = self.core.tracklist.add(uris=self.uris).get() self.events = Queue.Queue() - self.patcher = mock.patch('mopidy.listener.send') - self.core_patcher = mock.patch('mopidy.listener.send_async') - - self.send_mock = self.patcher.start() - self.core_send_mock = self.core_patcher.start() def send(cls, event, **kwargs): self.events.put((cls, event, kwargs)) + self.patcher = mock.patch('mopidy.listener.send') + self.send_mock = self.patcher.start() self.send_mock.side_effect = send - self.core_send_mock.side_effect = send + + # TODO: Remove this patch once Mopidy 1.2 has been released. + try: + self.core_patcher = mock.patch('mopidy.listener.send_async') + self.core_send_mock = self.core_patcher.start() + self.core_send_mock.side_effect = send + except AttributeError: + # Mopidy > 1.1 no longer has mopidy.listener.send_async + pass self.actor_register = [self.backend, self.non_pandora_backend, self.core] @@ -106,6 +113,7 @@ def setUp(self): # noqa: N802 def test_delete_station_clears_tracklist_on_finish(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() assert len(self.core.tracklist.get_tl_tracks().get()) > 0 listener.send(PandoraBackendListener, 'event_processed', @@ -118,13 +126,16 @@ def test_delete_station_clears_tracklist_on_finish(self): def test_detect_track_change_next(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events() + self.core.playback.seek(100).get() self.replay_events() self.core.playback.next().get() - self.replay_events(until='track_playback_started') + self.replay_events() thread_joiner.wait(timeout=1.0) + + self.replay_events() call = mock.call(EventMonitorListener, 'track_changed_next', old_uri=self.tl_tracks[0].track.uri, From 9eba6d87373382f959e1fd7a7efcc18a83d9ff48 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 07:02:37 +0200 Subject: [PATCH 256/311] Refactor event monitor and test cases. --- mopidy_pandora/__init__.py | 2 ++ mopidy_pandora/frontend.py | 73 ++++---------------------------------- mopidy_pandora/monitor.py | 64 ++++++++++++++------------------- mopidy_pandora/utils.py | 56 +++++++++++++++++++++++++++++ tests/conftest.py | 2 +- tests/test_frontend.py | 30 +++++----------- tests/test_monitor.py | 50 ++++++++++++++++++-------- 7 files changed, 135 insertions(+), 142 deletions(-) diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index a52c619..5819f7f 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -66,5 +66,7 @@ def get_config_schema(self): def setup(self, registry): from .backend import PandoraBackend from .frontend import PandoraFrontend + from .monitor import EventMonitor registry.add('backend', PandoraBackend) registry.add('frontend', PandoraFrontend) + registry.add('frontend', EventMonitor) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index badcc30..0d3af82 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -10,69 +10,18 @@ from mopidy_pandora import listener from mopidy_pandora.uri import PandoraUri +from mopidy_pandora.utils import only_execute_for_pandora_uris 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): +class PandoraFrontend(pykka.ThreadingActor, + core.CoreListener, + listener.PandoraBackendListener, + listener.PandoraPlaybackListener, + listener.EventMonitorListener): def __init__(self, config, core): - from mopidy_pandora.monitor import EventMonitor super(PandoraFrontend, self).__init__() self.config = config['pandora'] @@ -81,10 +30,6 @@ def __init__(self, config, core): self.setup_required = True self.core = core - self.event_monitor = None - if self.config['event_support_enabled']: - self.event_monitor = EventMonitor(config, core) - self.track_change_completed_event = threading.Event() self.track_change_completed_event.set() @@ -111,12 +56,6 @@ def options_changed(self): self.setup_required = True self.set_options() - @only_execute_for_pandora_uris - def on_event(self, event, **kwargs): - super(PandoraFrontend, self).on_event(event, **kwargs) - if self.event_monitor: - self.event_monitor.on_event(event, **kwargs) - @only_execute_for_pandora_uris def track_playback_started(self, tl_track): self.set_options() diff --git a/mopidy_pandora/monitor.py b/mopidy_pandora/monitor.py index c5e40e2..55b5152 100644 --- a/mopidy_pandora/monitor.py +++ b/mopidy_pandora/monitor.py @@ -14,11 +14,14 @@ 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 +from mopidy_pandora.utils import get_active_uri, only_execute_for_pandora_uris, run_async logger = logging.getLogger(__name__) @@ -40,9 +43,13 @@ def __lt__(self, other): return self.ratio < other.ratio -class EventMonitor(listener.PandoraBackendListener): - - pykka_traversable = True +class EventMonitor(pykka.ThreadingActor, + core.CoreListener, + audio.AudioListener, + listener.PandoraFrontendListener, + listener.PandoraBackendListener, + listener.PandoraPlaybackListener, + listener.EventMonitorListener): def __init__(self, config, core): super(EventMonitor, self).__init__() @@ -53,79 +60,56 @@ def __init__(self, config, core): self._monitor_lock = threading.Lock() self.config = config['pandora'] - self.on_start() + 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', - 'position_changed', - 'state_changed', - 'tags_changed', - 'playback_state_changed', - 'track_playback_resumed', - 'playback_state_changed', - 'track_playback_paused'], self.sequence_match_results, + 'track_playback_resumed'], self.sequence_match_results, interval=interval)) self.event_sequences.append(EventSequence(self.config['on_pause_resume_pause_click'], ['track_playback_paused', - 'position_changed', - 'state_changed', - 'tags_changed', - 'playback_state_changed', 'track_playback_resumed', - 'playback_state_changed', - 'track_playback_paused', - 'position_changed', - 'state_changed', - 'playback_state_changed', 'track_playback_paused'], self.sequence_match_results, interval=interval)) self.event_sequences.append(EventSequence(self.config['on_pause_previous_click'], ['track_playback_paused', - 'stream_changed', - 'state_changed', - 'playback_state_changed', 'track_playback_ended', 'track_changing', - 'position_changed', - 'stream_changed', - 'state_changed', - 'playback_state_changed', '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', - 'stream_changed', - 'state_changed', - 'playback_state_changed', 'track_playback_ended', 'track_changing', - 'position_changed', - 'stream_changed', - 'state_changed', - 'playback_state_changed', '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): - from mopidy_pandora import frontend + if not self.is_active: + return + super(EventMonitor, 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=frontend.get_active_uri(self.core, event, **kwargs), **kwargs) + self.notify_all(event, uri=get_active_uri(self.core, event, **kwargs), **kwargs) self.monitor_sequences() else: self._monitor_lock.release() @@ -296,7 +280,11 @@ def get_ratio(self): if self.wait_for: # Add 'wait_for' event as well to make ratio more accurate. self.target_sequence.append(self.wait_for) - ratio = EventSequence.match_sequence(self.events_seen, self.target_sequence) + if self.strict: + ratio = EventSequence.match_sequence(self.events_seen, self.target_sequence) + else: + filtered_list = [e for e in self.events_seen if e in self.target_sequence] + ratio = EventSequence.match_sequence(filtered_list, self.target_sequence) if ratio < 1.0 and self.strict: return 0 return ratio diff --git a/mopidy_pandora/utils.py b/mopidy_pandora/utils.py index 98d3628..dae2a40 100644 --- a/mopidy_pandora/utils.py +++ b/mopidy_pandora/utils.py @@ -4,6 +4,62 @@ import requests +from mopidy_pandora.uri import PandoraUri + + +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 + def run_async(func): """ Function decorator intended to make "func" run in a separate thread (asynchronously). diff --git a/tests/conftest.py b/tests/conftest.py index e82675c..636d58a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -66,7 +66,7 @@ def config(): 'auto_setup': True, 'cache_time_to_live': 1800, - 'event_support_enabled': False, + 'event_support_enabled': True, 'double_click_interval': '0.5', 'on_pause_resume_click': 'thumbs_up', 'on_pause_next_click': 'thumbs_down', diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 99b7a13..4d1fc6c 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -14,10 +14,9 @@ import pykka -from mopidy_pandora import frontend +from mopidy_pandora import frontend, utils from mopidy_pandora.frontend import PandoraFrontend from mopidy_pandora.listener import PandoraFrontendListener -from mopidy_pandora.monitor import EventMonitor from tests import conftest, dummy_audio, dummy_backend from tests.dummy_audio import DummyAudio @@ -88,14 +87,15 @@ def replay_events(self, until=None): try: e = self.events.get(timeout=0.1) cls, event, kwargs = e + if event == until: + break for actor in self.actor_register: if isinstance(actor, pykka.ActorProxy): if isinstance(actor._actor, cls): actor.on_event(event, **kwargs).get() else: - actor.on_event(event, **kwargs) - if e[0] == until: - break + if isinstance(actor, cls): + actor.on_event(event, **kwargs) except Queue.Empty: # All events replayed. break @@ -153,18 +153,6 @@ def test_next_track_available_forces_stop_if_no_more_tracks(self): self.frontend.next_track_available(None).get() assert self.core.playback.get_state().get() == PlaybackState.STOPPED - def test_on_event_passes_on_calls_to_monitor(self): - config = conftest.config() - config['pandora']['event_support_enabled'] = True - - self.frontend = frontend.PandoraFrontend.start(config, self.core).proxy() - - assert self.frontend.event_monitor.core.get() - monitor_mock = mock.Mock(spec=EventMonitor) - self.frontend.event_monitor = monitor_mock - self.frontend.on_event('track_playback_started', tl_track=self.core.tracklist.get_tl_tracks().get()[0]).get() - assert monitor_mock.on_event.called - def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): func_mock = mock.PropertyMock() func_mock.__name__ = str('func_mock') @@ -283,7 +271,7 @@ def test_get_active_uri_order_of_precedence(self): kwargs = {} self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.replay_events() - assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[0].track.uri + assert utils.get_active_uri(self.core, **kwargs) == self.tl_tracks[0].track.uri # No easy way to test retrieving from history as it is not possible to set core.playback_current_tl_track # to None @@ -294,10 +282,10 @@ def test_get_active_uri_order_of_precedence(self): # assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[1].track.uri kwargs['tl_track'] = self.tl_tracks[2] - assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[2].track.uri + assert utils.get_active_uri(self.core, **kwargs) == self.tl_tracks[2].track.uri kwargs = {'track': self.tl_tracks[3].track} - assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[3].track.uri + assert utils.get_active_uri(self.core, **kwargs) == self.tl_tracks[3].track.uri def test_is_end_of_tracklist_reached(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid) @@ -361,7 +349,7 @@ def test_changing_track_station_changed(self): self.replay_events() self.core.playback.next() - self.replay_events(until='track_playback_started') + self.replay_events(until='end_of_tracklist_reached') thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 268a36b..e88dfc6 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -88,14 +88,15 @@ def replay_events(self, until=None): try: e = self.events.get(timeout=0.1) cls, event, kwargs = e + if event == until: + break for actor in self.actor_register: if isinstance(actor, pykka.ActorProxy): if isinstance(actor._actor, cls): actor.on_event(event, **kwargs).get() else: - actor.on_event(event, **kwargs) - if e[0] == until: - break + if isinstance(actor, cls): + actor.on_event(event, **kwargs) except Queue.Empty: # All events replayed. break @@ -104,7 +105,7 @@ def replay_events(self, until=None): class EventMonitorTests(BaseTest): def setUp(self): # noqa: N802 super(EventMonitorTests, self).setUp() - self.monitor = monitor.EventMonitor(conftest.config(), self.core) + self.monitor = monitor.EventMonitor.start(conftest.config(), self.core).proxy() self.actor_register.append(self.monitor) @@ -147,11 +148,13 @@ def test_detect_track_change_next_from_paused(self): with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: # Next self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() self.core.playback.seek(100) + self.replay_events() self.core.playback.pause() self.replay_events() self.core.playback.next().get() - self.replay_events(until='track_playback_paused') + self.replay_events(until='track_changed_next') thread_joiner.wait(timeout=5.0) call = mock.call(EventMonitorListener, @@ -165,7 +168,9 @@ def test_detect_track_change_no_op(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Next self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() self.core.playback.seek(100) + self.replay_events() self.core.playback.stop() self.replay_events() self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() @@ -178,10 +183,11 @@ def test_detect_track_change_previous(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Next self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() self.core.playback.seek(100) self.replay_events() self.core.playback.previous().get() - self.replay_events(until='track_playback_started') + self.replay_events(until='track_changed_previous') thread_joiner.wait(timeout=1.0) call = mock.call(EventMonitorListener, @@ -195,11 +201,13 @@ def test_detect_track_change_previous_from_paused(self): with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: # Next self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() self.core.playback.seek(100) + self.replay_events() self.core.playback.pause() self.replay_events() self.core.playback.previous().get() - self.replay_events(until='track_playback_paused') + self.replay_events(until='track_changed_previous') thread_joiner.wait(timeout=5.0) call = mock.call(EventMonitorListener, @@ -213,11 +221,13 @@ def test_events_triggered_on_next_action(self): with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: # Pause -> Next self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() self.core.playback.seek(100) + self.replay_events() self.core.playback.pause() self.replay_events() self.core.playback.next().get() - self.replay_events(until='track_changed_next') + self.replay_events(until='event_triggered') thread_joiner.wait(timeout=5.0) call = mock.call(EventMonitorListener, @@ -230,12 +240,14 @@ def test_events_triggered_on_next_action(self): def test_events_triggered_on_previous_action(self): with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: # Pause -> Previous - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.seek(100) - self.core.playback.pause() + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events() + self.core.playback.seek(100).get() + self.replay_events() + self.core.playback.pause().get() self.replay_events() self.core.playback.previous().get() - self.replay_events(until='track_changed_previous') + self.replay_events(until='event_triggered') thread_joiner.wait(timeout=5.0) call = mock.call(EventMonitorListener, @@ -249,10 +261,13 @@ def test_events_triggered_on_resume_action(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Pause -> Resume self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() self.core.playback.seek(100) + self.replay_events() self.core.playback.pause() + self.replay_events() self.core.playback.resume().get() - self.replay_events(until='track_playback_resumed') + self.replay_events(until='event_triggered') thread_joiner.wait(timeout=1.0) call = mock.call(EventMonitorListener, @@ -266,12 +281,15 @@ def test_events_triggered_on_triple_click_action(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Pause -> Resume -> Pause self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() self.core.playback.seek(100) + self.replay_events() self.core.playback.pause() + self.replay_events() self.core.playback.resume() self.replay_events() self.core.playback.pause().get() - self.replay_events(until='track_playback_resumed') + self.replay_events(until='event_triggered') thread_joiner.wait(timeout=1.0) call = mock.call(EventMonitorListener, @@ -296,13 +314,15 @@ def test_monitor_ignores_ads(self): def test_monitor_resumes_playback_after_event_trigger(self): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() self.core.playback.seek(100) + self.replay_events() self.core.playback.pause() self.replay_events() assert self.core.playback.get_state().get() == PlaybackState.PAUSED self.core.playback.next().get() - self.replay_events(until='track_changed_next') + self.replay_events() thread_joiner.wait(timeout=5.0) assert self.core.playback.get_state().get() == PlaybackState.PLAYING From fae26ac37673cdcef6185ff9dcc5180412482e1d Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 07:17:14 +0200 Subject: [PATCH 257/311] Refactor event monitor into regular frontend. --- mopidy_pandora/__init__.py | 5 +- mopidy_pandora/frontend.py | 336 +++++++++++++++++++++++++++- mopidy_pandora/monitor.py | 293 ------------------------ mopidy_pandora/utils.py | 56 ----- tests/test_frontend.py | 360 +++++++++++++++++++++++++++++- tests/test_monitor.py | 446 ------------------------------------- 6 files changed, 686 insertions(+), 810 deletions(-) delete mode 100644 mopidy_pandora/monitor.py delete mode 100644 tests/test_monitor.py diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index 5819f7f..96cac61 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -65,8 +65,7 @@ def get_config_schema(self): def setup(self, registry): from .backend import PandoraBackend - from .frontend import PandoraFrontend - from .monitor import EventMonitor + from .frontend import EventMonitorFrontend, PandoraFrontend registry.add('backend', PandoraBackend) registry.add('frontend', PandoraFrontend) - registry.add('frontend', EventMonitor) + registry.add('frontend', EventMonitorFrontend) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 0d3af82..0fcadac 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -1,20 +1,83 @@ from __future__ import absolute_import, division, print_function, unicode_literals +import Queue import logging + import threading -from mopidy import core +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 PandoraUri -from mopidy_pandora.utils import only_execute_for_pandora_uris +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, @@ -154,3 +217,270 @@ def _trim_tracklist(self, keep_only=None, maxsize=2): 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_changing', + '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_changing', + '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 >= 0.80: + 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. + self.target_sequence.append(self.wait_for) + if self.strict: + ratio = EventSequence.match_sequence(self.events_seen, self.target_sequence) + else: + filtered_list = [e for e in self.events_seen if e in self.target_sequence] + ratio = EventSequence.match_sequence(filtered_list, self.target_sequence) + if ratio < 1.0 and self.strict: + return 0 + return ratio + + def wait(self, timeout=None): + return self.monitoring_completed.wait(timeout=timeout) diff --git a/mopidy_pandora/monitor.py b/mopidy_pandora/monitor.py deleted file mode 100644 index 55b5152..0000000 --- a/mopidy_pandora/monitor.py +++ /dev/null @@ -1,293 +0,0 @@ -from __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 get_active_uri, only_execute_for_pandora_uris, run_async - -logger = logging.getLogger(__name__) - -EventMarker = namedtuple('EventMarker', 'event, uri, time') - - -@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 - - -class EventMonitor(pykka.ThreadingActor, - core.CoreListener, - audio.AudioListener, - listener.PandoraFrontendListener, - listener.PandoraBackendListener, - listener.PandoraPlaybackListener, - listener.EventMonitorListener): - - def __init__(self, config, core): - super(EventMonitor, 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_changing', - '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_changing', - '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(EventMonitor, 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 >= 0.80: - 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. - self.target_sequence.append(self.wait_for) - if self.strict: - ratio = EventSequence.match_sequence(self.events_seen, self.target_sequence) - else: - filtered_list = [e for e in self.events_seen if e in self.target_sequence] - ratio = EventSequence.match_sequence(filtered_list, self.target_sequence) - if ratio < 1.0 and self.strict: - return 0 - return ratio - - def wait(self, timeout=None): - return self.monitoring_completed.wait(timeout=timeout) diff --git a/mopidy_pandora/utils.py b/mopidy_pandora/utils.py index dae2a40..98d3628 100644 --- a/mopidy_pandora/utils.py +++ b/mopidy_pandora/utils.py @@ -4,62 +4,6 @@ import requests -from mopidy_pandora.uri import PandoraUri - - -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 - def run_async(func): """ Function decorator intended to make "func" run in a separate thread (asynchronously). diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 4d1fc6c..36c4d24 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals import Queue +import time import unittest @@ -14,9 +15,9 @@ import pykka -from mopidy_pandora import frontend, utils -from mopidy_pandora.frontend import PandoraFrontend -from mopidy_pandora.listener import PandoraFrontendListener +from mopidy_pandora import frontend +from mopidy_pandora.frontend import EventMarker, EventSequence, MatchResult, PandoraFrontend +from mopidy_pandora.listener import EventMonitorListener, PandoraBackendListener, PandoraFrontendListener from tests import conftest, dummy_audio, dummy_backend from tests.dummy_audio import DummyAudio @@ -271,7 +272,7 @@ def test_get_active_uri_order_of_precedence(self): kwargs = {} self.core.playback.play(tlid=self.tl_tracks[0].tlid) self.replay_events() - assert utils.get_active_uri(self.core, **kwargs) == self.tl_tracks[0].track.uri + assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[0].track.uri # No easy way to test retrieving from history as it is not possible to set core.playback_current_tl_track # to None @@ -282,19 +283,16 @@ def test_get_active_uri_order_of_precedence(self): # assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[1].track.uri kwargs['tl_track'] = self.tl_tracks[2] - assert utils.get_active_uri(self.core, **kwargs) == self.tl_tracks[2].track.uri + assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[2].track.uri kwargs = {'track': self.tl_tracks[3].track} - assert utils.get_active_uri(self.core, **kwargs) == self.tl_tracks[3].track.uri + assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[3].track.uri def test_is_end_of_tracklist_reached(self): self.core.playback.play(tlid=self.tl_tracks[0].tlid) assert not self.frontend.is_end_of_tracklist_reached().get() - def test_event_support_disabled_does_not_initialize_monitor(self): - assert not self.frontend.event_monitor.get() - def test_is_end_of_tracklist_reached_last_track(self): self.core.playback.play(tlid=self.tl_tracks[-1].tlid) self.replay_events() @@ -384,3 +382,347 @@ def test_track_unplayable_triggers_end_of_tracklist_event(self): assert call in self.send_mock.mock_calls assert self.core.playback.get_state().get() == PlaybackState.STOPPED + + +class EventMonitorFrontendTests(BaseTest): + def setUp(self): # noqa: N802 + super(EventMonitorFrontendTests, self).setUp() + self.monitor = frontend.EventMonitorFrontend.start(conftest.config(), self.core).proxy() + + self.actor_register.append(self.monitor) + + # Consume mode needs to be enabled to detect 'previous' track changes + self.core.tracklist.set_consume(True) + + def test_delete_station_clears_tracklist_on_finish(self): + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() + assert len(self.core.tracklist.get_tl_tracks().get()) > 0 + + listener.send(PandoraBackendListener, 'event_processed', + track_uri=self.tracks[0].uri, + pandora_event='delete_station') + self.replay_events() + + assert len(self.core.tracklist.get_tl_tracks().get()) == 0 + + def test_detect_track_change_next(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + # Next + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events() + self.core.playback.seek(100).get() + self.replay_events() + self.core.playback.next().get() + self.replay_events() + + thread_joiner.wait(timeout=1.0) + + self.replay_events() + call = mock.call(EventMonitorListener, + 'track_changed_next', + old_uri=self.tl_tracks[0].track.uri, + new_uri=self.tl_tracks[1].track.uri) + + assert call in self.send_mock.mock_calls + + def test_detect_track_change_next_from_paused(self): + with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: + # Next + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() + self.core.playback.seek(100) + self.replay_events() + self.core.playback.pause() + self.replay_events() + self.core.playback.next().get() + self.replay_events(until='track_changed_next') + + thread_joiner.wait(timeout=5.0) + call = mock.call(EventMonitorListener, + 'track_changed_next', + old_uri=self.tl_tracks[0].track.uri, + new_uri=self.tl_tracks[1].track.uri) + + assert call in self.send_mock.mock_calls + + def test_detect_track_change_no_op(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + # Next + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() + self.core.playback.seek(100) + self.replay_events() + self.core.playback.stop() + self.replay_events() + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events(until='track_playback_started') + + thread_joiner.wait(timeout=1.0) + assert self.events.empty() + + def test_detect_track_change_previous(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + # Next + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() + self.core.playback.seek(100) + self.replay_events() + self.core.playback.previous().get() + self.replay_events(until='track_changed_previous') + + thread_joiner.wait(timeout=1.0) + call = mock.call(EventMonitorListener, + 'track_changed_previous', + old_uri=self.tl_tracks[0].track.uri, + new_uri=self.tl_tracks[0].track.uri) + + assert call in self.send_mock.mock_calls + + def test_detect_track_change_previous_from_paused(self): + with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: + # Next + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() + self.core.playback.seek(100) + self.replay_events() + self.core.playback.pause() + self.replay_events() + self.core.playback.previous().get() + self.replay_events(until='track_changed_previous') + + thread_joiner.wait(timeout=5.0) + call = mock.call(EventMonitorListener, + 'track_changed_previous', + old_uri=self.tl_tracks[0].track.uri, + new_uri=self.tl_tracks[0].track.uri) + + assert call in self.send_mock.mock_calls + + def test_events_triggered_on_next_action(self): + with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: + # Pause -> Next + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() + self.core.playback.seek(100) + self.replay_events() + self.core.playback.pause() + self.replay_events() + self.core.playback.next().get() + self.replay_events(until='event_triggered') + + thread_joiner.wait(timeout=5.0) + call = mock.call(EventMonitorListener, + 'event_triggered', + track_uri=self.tl_tracks[0].track.uri, + pandora_event=conftest.config()['pandora']['on_pause_next_click']) + + assert call in self.send_mock.mock_calls + + def test_events_triggered_on_previous_action(self): + with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: + # Pause -> Previous + self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() + self.replay_events() + self.core.playback.seek(100).get() + self.replay_events() + self.core.playback.pause().get() + self.replay_events() + self.core.playback.previous().get() + self.replay_events(until='event_triggered') + + thread_joiner.wait(timeout=5.0) + call = mock.call(EventMonitorListener, + 'event_triggered', + track_uri=self.tl_tracks[0].track.uri, + pandora_event=conftest.config()['pandora']['on_pause_previous_click']) + + assert call in self.send_mock.mock_calls + + def test_events_triggered_on_resume_action(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + # Pause -> Resume + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() + self.core.playback.seek(100) + self.replay_events() + self.core.playback.pause() + self.replay_events() + self.core.playback.resume().get() + self.replay_events(until='event_triggered') + + thread_joiner.wait(timeout=1.0) + call = mock.call(EventMonitorListener, + 'event_triggered', + track_uri=self.tl_tracks[0].track.uri, + pandora_event=conftest.config()['pandora']['on_pause_resume_click']) + + assert call in self.send_mock.mock_calls + + def test_events_triggered_on_triple_click_action(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + # Pause -> Resume -> Pause + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() + self.core.playback.seek(100) + self.replay_events() + self.core.playback.pause() + self.replay_events() + self.core.playback.resume() + self.replay_events() + self.core.playback.pause().get() + self.replay_events(until='event_triggered') + + thread_joiner.wait(timeout=1.0) + call = mock.call(EventMonitorListener, + 'event_triggered', + track_uri=self.tl_tracks[0].track.uri, + pandora_event=conftest.config()['pandora']['on_pause_resume_pause_click']) + + assert call in self.send_mock.mock_calls + + def test_monitor_ignores_ads(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + self.core.playback.play(tlid=self.tl_tracks[2].tlid) + self.core.playback.seek(100) + self.core.playback.pause() + self.replay_events() + self.core.playback.resume().get() + self.replay_events(until='track_playback_resumed') + + thread_joiner.wait(timeout=1.0) + assert self.events.qsize() == 0 # Check that no events were triggered + + def test_monitor_resumes_playback_after_event_trigger(self): + with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: + self.core.playback.play(tlid=self.tl_tracks[0].tlid) + self.replay_events() + self.core.playback.seek(100) + self.replay_events() + self.core.playback.pause() + self.replay_events() + assert self.core.playback.get_state().get() == PlaybackState.PAUSED + + self.core.playback.next().get() + self.replay_events() + + thread_joiner.wait(timeout=5.0) + assert self.core.playback.get_state().get() == PlaybackState.PLAYING + + +class EventSequenceTests(unittest.TestCase): + + def setUp(self): + self.rq = Queue.PriorityQueue() + self.es = EventSequence('match_mock', ['e1', 'e2', 'e3'], self.rq, 0.1, False) + self.es_strict = EventSequence('match_mock', ['e1', 'e2', 'e3'], self.rq, 0.1, True) + self.es_wait = EventSequence('match_mock', ['e1', 'e2', 'e3'], self.rq, 0.1, False, 'w1') + + self.event_sequences = [self.es, self.es_strict, self.es_wait] + + track_mock = mock.Mock(spec=models.Track) + track_mock.uri = 'pandora:track:id_mock:token_mock' + self.tl_track_mock = mock.Mock(spec=models.TlTrack) + self.tl_track_mock.track = track_mock + + def test_events_ignored_if_time_position_is_zero(self): + for es in self.event_sequences: + es.notify('e1', tl_track=self.tl_track_mock) + for es in self.event_sequences: + assert not es.is_monitoring() + + def test_start_monitor_on_event(self): + for es in self.event_sequences: + es.notify('e1', tl_track=self.tl_track_mock, time_position=100) + for es in self.event_sequences: + assert es.is_monitoring() + + def test_start_monitor_handles_no_tl_track(self): + for es in self.event_sequences: + es.notify('e1', tl_track=self.tl_track_mock, time_position=100) + for es in self.event_sequences: + assert es.is_monitoring() + + def test_stop_monitor_adds_result_to_queue(self): + for es in self.event_sequences[0:2]: + es.notify('e1', tl_track=self.tl_track_mock, time_position=100) + es.notify('e2', time_position=100) + es.notify('e3', time_position=100) + + for es in self.event_sequences[0:2]: + es.wait(1.0) + assert not es.is_monitoring() + + assert self.rq.qsize() == 2 + + def test_stop_monitor_only_waits_for_matched_events(self): + self.es_wait.notify('e1', time_position=100) + self.es_wait.notify('e_not_in_monitored_sequence', time_position=100) + + time.sleep(0.1 * 1.1) + assert not self.es_wait.is_monitoring() + assert self.rq.qsize() == 0 + + def test_stop_monitor_waits_for_event(self): + self.es_wait.notify('e1', time_position=100) + self.es_wait.notify('e2', time_position=100) + self.es_wait.notify('e3', time_position=100) + + assert self.es_wait.is_monitoring() + assert self.rq.qsize() == 0 + + self.es_wait.notify('w1', time_position=100) + self.es_wait.wait(timeout=1.0) + + assert not self.es_wait.is_monitoring() + assert self.rq.qsize() == 1 + + def test_get_stop_monitor_ensures_that_all_events_occurred(self): + self.es.notify('e1', tl_track=self.tl_track_mock, time_position=100) + self.es.notify('e2', time_position=100) + self.es.notify('e3', time_position=100) + assert self.rq.qsize() == 0 + + self.es.wait(timeout=1.0) + self.es.events_seen = ['e1', 'e2', 'e3'] + assert self.rq.qsize() > 0 + + def test_get_stop_monitor_strict_ensures_that_events_were_seen_in_order(self): + self.es_strict.notify('e1', tl_track=self.tl_track_mock, time_position=100) + self.es_strict.notify('e3', time_position=100) + self.es_strict.notify('e2', time_position=100) + self.es_strict.wait(timeout=1.0) + assert self.rq.qsize() == 0 + + self.es_strict.notify('e1', tl_track=self.tl_track_mock, time_position=100) + self.es_strict.notify('e2', time_position=100) + self.es_strict.notify('e3', time_position=100) + self.es_strict.wait(timeout=1.0) + assert self.rq.qsize() > 0 + + def test_get_ratio_handles_repeating_events(self): + self.es.target_sequence = ['e1', 'e2', 'e3', 'e1'] + self.es.events_seen = ['e1', 'e2', 'e3', 'e1'] + assert self.es.get_ratio() > 0 + + def test_get_ratio_enforces_strict_matching(self): + self.es_strict.events_seen = ['e1', 'e2', 'e3', 'e4'] + assert self.es_strict.get_ratio() == 0 + + self.es_strict.events_seen = ['e1', 'e2', 'e3'] + assert self.es_strict.get_ratio() == 1 + + +class MatchResultTests(unittest.TestCase): + + def test_match_result_comparison(self): + + mr1 = MatchResult(EventMarker('e1', 'u1', 0), 1) + mr2 = MatchResult(EventMarker('e1', 'u1', 0), 2) + + assert mr1 < mr2 + assert mr2 > mr1 + assert mr1 != mr2 + + mr2.ratio = 1 + assert mr1 == mr2 diff --git a/tests/test_monitor.py b/tests/test_monitor.py deleted file mode 100644 index e88dfc6..0000000 --- a/tests/test_monitor.py +++ /dev/null @@ -1,446 +0,0 @@ -from __future__ import absolute_import, division, print_function, unicode_literals - -import Queue - -import time - -import unittest - -from mock import mock - -from mopidy import core, listener, models -from mopidy.audio import PlaybackState - -import pykka - -from mopidy_pandora import monitor -from mopidy_pandora.listener import EventMonitorListener, PandoraBackendListener - -from mopidy_pandora.monitor import EventMarker, EventSequence, MatchResult - -from tests import conftest, dummy_audio, dummy_backend - -from tests.dummy_audio import DummyAudio -from tests.dummy_backend import DummyBackend, DummyPandoraBackend - - -class BaseTest(unittest.TestCase): - tracks = [ - models.Track(uri='pandora:track:id_mock:token_mock1', length=40000), # Regular track - models.Track(uri='pandora:track:id_mock:token_mock2', length=40000), # Regular track - models.Track(uri='pandora:ad:id_mock:token_mock3', length=40000), # Advertisement - models.Track(uri='mock:track:id_mock:token_mock4', length=40000), # Not a pandora track - models.Track(uri='pandora:track:id_mock_other:token_mock5', length=40000), # Different station - models.Track(uri='pandora:track:id_mock:token_mock6', length=None), # No duration - ] - - uris = [ - 'pandora:track:id_mock:token_mock1', 'pandora:track:id_mock:token_mock2', - 'pandora:ad:id_mock:token_mock3', 'mock:track:id_mock:token_mock4', - 'pandora:track:id_mock_other:token_mock5', 'pandora:track:id_mock:token_mock6'] - - def setUp(self): - config = {'core': {'max_tracklist_length': 10000}} - - self.audio = dummy_audio.create_proxy(DummyAudio) - self.backend = dummy_backend.create_proxy(DummyPandoraBackend, audio=self.audio) - self.non_pandora_backend = dummy_backend.create_proxy(DummyBackend, audio=self.audio) - - self.core = core.Core.start( - config, audio=self.audio, backends=[self.backend, self.non_pandora_backend]).proxy() - - def lookup(uris): - result = {uri: [] for uri in uris} - for track in self.tracks: - if track.uri in result: - result[track.uri].append(track) - return result - - self.core.library.lookup = lookup - self.tl_tracks = self.core.tracklist.add(uris=self.uris).get() - - self.events = Queue.Queue() - - def send(cls, event, **kwargs): - self.events.put((cls, event, kwargs)) - - self.patcher = mock.patch('mopidy.listener.send') - self.send_mock = self.patcher.start() - self.send_mock.side_effect = send - - # TODO: Remove this patch once Mopidy 1.2 has been released. - try: - self.core_patcher = mock.patch('mopidy.listener.send_async') - self.core_send_mock = self.core_patcher.start() - self.core_send_mock.side_effect = send - except AttributeError: - # Mopidy > 1.1 no longer has mopidy.listener.send_async - pass - - self.actor_register = [self.backend, self.non_pandora_backend, self.core] - - def tearDown(self): - pykka.ActorRegistry.stop_all() - mock.patch.stopall() - - def replay_events(self, until=None): - while True: - try: - e = self.events.get(timeout=0.1) - cls, event, kwargs = e - if event == until: - break - for actor in self.actor_register: - if isinstance(actor, pykka.ActorProxy): - if isinstance(actor._actor, cls): - actor.on_event(event, **kwargs).get() - else: - if isinstance(actor, cls): - actor.on_event(event, **kwargs) - except Queue.Empty: - # All events replayed. - break - - -class EventMonitorTests(BaseTest): - def setUp(self): # noqa: N802 - super(EventMonitorTests, self).setUp() - self.monitor = monitor.EventMonitor.start(conftest.config(), self.core).proxy() - - self.actor_register.append(self.monitor) - - # Consume mode needs to be enabled to detect 'previous' track changes - self.core.tracklist.set_consume(True) - - def test_delete_station_clears_tracklist_on_finish(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - assert len(self.core.tracklist.get_tl_tracks().get()) > 0 - - listener.send(PandoraBackendListener, 'event_processed', - track_uri=self.tracks[0].uri, - pandora_event='delete_station') - self.replay_events() - - assert len(self.core.tracklist.get_tl_tracks().get()) == 0 - - def test_detect_track_change_next(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - # Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events() - self.core.playback.seek(100).get() - self.replay_events() - self.core.playback.next().get() - self.replay_events() - - thread_joiner.wait(timeout=1.0) - - self.replay_events() - call = mock.call(EventMonitorListener, - 'track_changed_next', - old_uri=self.tl_tracks[0].track.uri, - new_uri=self.tl_tracks[1].track.uri) - - assert call in self.send_mock.mock_calls - - def test_detect_track_change_next_from_paused(self): - with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: - # Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.pause() - self.replay_events() - self.core.playback.next().get() - self.replay_events(until='track_changed_next') - - thread_joiner.wait(timeout=5.0) - call = mock.call(EventMonitorListener, - 'track_changed_next', - old_uri=self.tl_tracks[0].track.uri, - new_uri=self.tl_tracks[1].track.uri) - - assert call in self.send_mock.mock_calls - - def test_detect_track_change_no_op(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - # Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.stop() - self.replay_events() - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events(until='track_playback_started') - - thread_joiner.wait(timeout=1.0) - assert self.events.empty() - - def test_detect_track_change_previous(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - # Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.previous().get() - self.replay_events(until='track_changed_previous') - - thread_joiner.wait(timeout=1.0) - call = mock.call(EventMonitorListener, - 'track_changed_previous', - old_uri=self.tl_tracks[0].track.uri, - new_uri=self.tl_tracks[0].track.uri) - - assert call in self.send_mock.mock_calls - - def test_detect_track_change_previous_from_paused(self): - with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: - # Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.pause() - self.replay_events() - self.core.playback.previous().get() - self.replay_events(until='track_changed_previous') - - thread_joiner.wait(timeout=5.0) - call = mock.call(EventMonitorListener, - 'track_changed_previous', - old_uri=self.tl_tracks[0].track.uri, - new_uri=self.tl_tracks[0].track.uri) - - assert call in self.send_mock.mock_calls - - def test_events_triggered_on_next_action(self): - with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: - # Pause -> Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.pause() - self.replay_events() - self.core.playback.next().get() - self.replay_events(until='event_triggered') - - thread_joiner.wait(timeout=5.0) - call = mock.call(EventMonitorListener, - 'event_triggered', - track_uri=self.tl_tracks[0].track.uri, - pandora_event=conftest.config()['pandora']['on_pause_next_click']) - - assert call in self.send_mock.mock_calls - - def test_events_triggered_on_previous_action(self): - with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: - # Pause -> Previous - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events() - self.core.playback.seek(100).get() - self.replay_events() - self.core.playback.pause().get() - self.replay_events() - self.core.playback.previous().get() - self.replay_events(until='event_triggered') - - thread_joiner.wait(timeout=5.0) - call = mock.call(EventMonitorListener, - 'event_triggered', - track_uri=self.tl_tracks[0].track.uri, - pandora_event=conftest.config()['pandora']['on_pause_previous_click']) - - assert call in self.send_mock.mock_calls - - def test_events_triggered_on_resume_action(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - # Pause -> Resume - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.pause() - self.replay_events() - self.core.playback.resume().get() - self.replay_events(until='event_triggered') - - thread_joiner.wait(timeout=1.0) - call = mock.call(EventMonitorListener, - 'event_triggered', - track_uri=self.tl_tracks[0].track.uri, - pandora_event=conftest.config()['pandora']['on_pause_resume_click']) - - assert call in self.send_mock.mock_calls - - def test_events_triggered_on_triple_click_action(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - # Pause -> Resume -> Pause - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.pause() - self.replay_events() - self.core.playback.resume() - self.replay_events() - self.core.playback.pause().get() - self.replay_events(until='event_triggered') - - thread_joiner.wait(timeout=1.0) - call = mock.call(EventMonitorListener, - 'event_triggered', - track_uri=self.tl_tracks[0].track.uri, - pandora_event=conftest.config()['pandora']['on_pause_resume_pause_click']) - - assert call in self.send_mock.mock_calls - - def test_monitor_ignores_ads(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[2].tlid) - self.core.playback.seek(100) - self.core.playback.pause() - self.replay_events() - self.core.playback.resume().get() - self.replay_events(until='track_playback_resumed') - - thread_joiner.wait(timeout=1.0) - assert self.events.qsize() == 0 # Check that no events were triggered - - def test_monitor_resumes_playback_after_event_trigger(self): - with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.pause() - self.replay_events() - assert self.core.playback.get_state().get() == PlaybackState.PAUSED - - self.core.playback.next().get() - self.replay_events() - - thread_joiner.wait(timeout=5.0) - assert self.core.playback.get_state().get() == PlaybackState.PLAYING - - -class EventSequenceTests(unittest.TestCase): - - def setUp(self): - self.rq = Queue.PriorityQueue() - self.es = EventSequence('match_mock', ['e1', 'e2', 'e3'], self.rq, 0.1, False) - self.es_strict = EventSequence('match_mock', ['e1', 'e2', 'e3'], self.rq, 0.1, True) - self.es_wait = EventSequence('match_mock', ['e1', 'e2', 'e3'], self.rq, 0.1, False, 'w1') - - self.event_sequences = [self.es, self.es_strict, self.es_wait] - - track_mock = mock.Mock(spec=models.Track) - track_mock.uri = 'pandora:track:id_mock:token_mock' - self.tl_track_mock = mock.Mock(spec=models.TlTrack) - self.tl_track_mock.track = track_mock - - def test_events_ignored_if_time_position_is_zero(self): - for es in self.event_sequences: - es.notify('e1', tl_track=self.tl_track_mock) - for es in self.event_sequences: - assert not es.is_monitoring() - - def test_start_monitor_on_event(self): - for es in self.event_sequences: - es.notify('e1', tl_track=self.tl_track_mock, time_position=100) - for es in self.event_sequences: - assert es.is_monitoring() - - def test_start_monitor_handles_no_tl_track(self): - for es in self.event_sequences: - es.notify('e1', tl_track=self.tl_track_mock, time_position=100) - for es in self.event_sequences: - assert es.is_monitoring() - - def test_stop_monitor_adds_result_to_queue(self): - for es in self.event_sequences[0:2]: - es.notify('e1', tl_track=self.tl_track_mock, time_position=100) - es.notify('e2', time_position=100) - es.notify('e3', time_position=100) - - for es in self.event_sequences[0:2]: - es.wait(1.0) - assert not es.is_monitoring() - - assert self.rq.qsize() == 2 - - def test_stop_monitor_only_waits_for_matched_events(self): - self.es_wait.notify('e1', time_position=100) - self.es_wait.notify('e_not_in_monitored_sequence', time_position=100) - - time.sleep(0.1 * 1.1) - assert not self.es_wait.is_monitoring() - assert self.rq.qsize() == 0 - - def test_stop_monitor_waits_for_event(self): - self.es_wait.notify('e1', time_position=100) - self.es_wait.notify('e2', time_position=100) - self.es_wait.notify('e3', time_position=100) - - assert self.es_wait.is_monitoring() - assert self.rq.qsize() == 0 - - self.es_wait.notify('w1', time_position=100) - self.es_wait.wait(timeout=1.0) - - assert not self.es_wait.is_monitoring() - assert self.rq.qsize() == 1 - - def test_get_stop_monitor_ensures_that_all_events_occurred(self): - self.es.notify('e1', tl_track=self.tl_track_mock, time_position=100) - self.es.notify('e2', time_position=100) - self.es.notify('e3', time_position=100) - assert self.rq.qsize() == 0 - - self.es.wait(timeout=1.0) - self.es.events_seen = ['e1', 'e2', 'e3'] - assert self.rq.qsize() > 0 - - def test_get_stop_monitor_strict_ensures_that_events_were_seen_in_order(self): - self.es_strict.notify('e1', tl_track=self.tl_track_mock, time_position=100) - self.es_strict.notify('e3', time_position=100) - self.es_strict.notify('e2', time_position=100) - self.es_strict.wait(timeout=1.0) - assert self.rq.qsize() == 0 - - self.es_strict.notify('e1', tl_track=self.tl_track_mock, time_position=100) - self.es_strict.notify('e2', time_position=100) - self.es_strict.notify('e3', time_position=100) - self.es_strict.wait(timeout=1.0) - assert self.rq.qsize() > 0 - - def test_get_ratio_handles_repeating_events(self): - self.es.target_sequence = ['e1', 'e2', 'e3', 'e1'] - self.es.events_seen = ['e1', 'e2', 'e3', 'e1'] - assert self.es.get_ratio() > 0 - - def test_get_ratio_enforces_strict_matching(self): - self.es_strict.events_seen = ['e1', 'e2', 'e3', 'e4'] - assert self.es_strict.get_ratio() == 0 - - self.es_strict.events_seen = ['e1', 'e2', 'e3'] - assert self.es_strict.get_ratio() == 1 - - -class MatchResultTests(unittest.TestCase): - - def test_match_result_comparison(self): - - mr1 = MatchResult(EventMarker('e1', 'u1', 0), 1) - mr2 = MatchResult(EventMarker('e1', 'u1', 0), 2) - - assert mr1 < mr2 - assert mr2 > mr1 - assert mr1 != mr2 - - mr2.ratio = 1 - assert mr1 == mr2 From 54bd867cecf50172be6e6ccc4ba9ca587161fad6 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 07:28:57 +0200 Subject: [PATCH 258/311] Re-align tox.ini with Mopidy's. --- tox.ini | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index f597090..2eb18ff 100644 --- a/tox.ini +++ b/tox.ini @@ -20,10 +20,8 @@ deps = responses [testenv:flake8] -skip_install = true -sitepackages = false deps = flake8 flake8-import-order pep8-naming -commands = flake8 --show-source --statistics --max-line-length 120 mopidy_pandora/ setup.py tests/ +commands = flake8 --show-source --statistics --max-line-length 120 mopidy_pandora tests From cf76f43a4ffb8198d1895eedb7e9263a21bf864d Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 07:53:08 +0200 Subject: [PATCH 259/311] Fix gobject errors on travis build. --- tests/conftest.py | 3 +++ tox.ini | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 636d58a..9c37ac6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,9 @@ import json import threading +import gobject # noqa +gobject.threads_init() # noqa + import mock from pandora import APIClient diff --git a/tox.ini b/tox.ini index 2eb18ff..f597090 100644 --- a/tox.ini +++ b/tox.ini @@ -20,8 +20,10 @@ deps = responses [testenv:flake8] +skip_install = true +sitepackages = false deps = flake8 flake8-import-order pep8-naming -commands = flake8 --show-source --statistics --max-line-length 120 mopidy_pandora tests +commands = flake8 --show-source --statistics --max-line-length 120 mopidy_pandora/ setup.py tests/ From f915dae73066289fb63beb1ea2ef89c2cbd4105f Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 08:08:43 +0200 Subject: [PATCH 260/311] Roll back unused gobject import. --- tests/conftest.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9c37ac6..636d58a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,9 +3,6 @@ import json import threading -import gobject # noqa -gobject.threads_init() # noqa - import mock from pandora import APIClient From 100fb1a136447cc285c10314da7da491cc1c9353 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 14:12:35 +0200 Subject: [PATCH 261/311] WIP: troubleshoot travis gobject import errors. --- tests/conftest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 636d58a..87e453a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,8 +13,6 @@ import requests -from mopidy_pandora import backend - MOCK_STATION_TYPE = 'station' MOCK_STATION_NAME = 'Mock Station' MOCK_STATION_ID = '0000000000000000001' @@ -77,6 +75,7 @@ def config(): def get_backend(config, simulate_request_exceptions=False): + from mopidy_pandora import backend obj = backend.PandoraBackend(config=config, audio=mock.Mock()) if simulate_request_exceptions: From 7d6fc53d2d0fa0a789e7ec8017c5cc67722e8fc9 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 14:36:13 +0200 Subject: [PATCH 262/311] WIP: troubleshoot travis gobject import errors. --- tox.ini | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tox.ini b/tox.ini index f597090..34a7653 100644 --- a/tox.ini +++ b/tox.ini @@ -4,20 +4,21 @@ envlist = py27, flake8 [testenv] sitepackages = true whitelist_externals=py.test -install_command = pip install --allow-unverified=mopidy --pre {opts} {packages} -commands = - py.test \ - --basetemp={envtmpdir} \ - --junit-xml=xunit-{envname}.xml \ - --cov=mopidy_pandora --cov-report=term-missing deps = mock - mopidy + mopidy==dev pytest pytest-capturelog pytest-cov pytest-xdist responses +install_command = pip install --allow-unverified=mopidy --pre {opts} {packages} +commands = + py.test \ + --basetemp={envtmpdir} \ + --junit-xml=xunit-{envname}.xml \ + --cov=mopidy_pandora --cov-report=term-missing \ + {posargs} [testenv:flake8] skip_install = true From 1716f79e5face28b1e7e6f9f3ac2b150e1215646 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 14:40:34 +0200 Subject: [PATCH 263/311] WIP: troubleshoot travis gobject import errors. --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 34a7653..8c74207 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,7 @@ sitepackages = true whitelist_externals=py.test deps = mock - mopidy==dev + mopidy pytest pytest-capturelog pytest-cov @@ -21,10 +21,10 @@ commands = {posargs} [testenv:flake8] -skip_install = true sitepackages = false deps = flake8 flake8-import-order pep8-naming +skip_install = true commands = flake8 --show-source --statistics --max-line-length 120 mopidy_pandora/ setup.py tests/ From 342c54be0550c277017ae3a2b09bb9375bc32fb2 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 14:44:37 +0200 Subject: [PATCH 264/311] Troubleshoot travis gobject import errors. --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index a090495..baa7318 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,6 +16,10 @@ env: - TOX_ENV=py27 - TOX_ENV=flake8 +before_install: + - "sudo apt-get update -qq" + - "sudo apt-get install -y gstreamer0.10-plugins-good python-gst0.10" + install: - "pip install tox" From c8779f641b5b24e390df1a8ec3b5335b643440c6 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 14:52:50 +0200 Subject: [PATCH 265/311] Troubleshoot travis gobject import errors. --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index baa7318..2b079f7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,5 @@ -sudo: false +sudo: required +dist: trusty language: python From 21d977ca6c5000c190aa3dada30d8003b5bccb04 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 14:56:40 +0200 Subject: [PATCH 266/311] Troubleshoot travis gobject import errors. --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 87e453a..636d58a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,8 @@ import requests +from mopidy_pandora import backend + MOCK_STATION_TYPE = 'station' MOCK_STATION_NAME = 'Mock Station' MOCK_STATION_ID = '0000000000000000001' @@ -75,7 +77,6 @@ def config(): def get_backend(config, simulate_request_exceptions=False): - from mopidy_pandora import backend obj = backend.PandoraBackend(config=config, audio=mock.Mock()) if simulate_request_exceptions: From 9adaee59fb383cadadcb98208be43ccd2e79e3d2 Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 15:09:50 +0200 Subject: [PATCH 267/311] Updated README - document defaults. --- README.rst | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 558919d..631f4f1 100644 --- a/README.rst +++ b/README.rst @@ -24,8 +24,7 @@ Mopidy-Pandora Features ======== -- Supports both Pandora One and ad-supported free accounts. -- Display album covers. +- 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. @@ -121,7 +120,7 @@ The following configuration values are available: 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. + directly from the Pandora server. Defaults to ``1800``. 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. @@ -141,7 +140,7 @@ pause/play/previous/next buttons. - ``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 include: ``thumbs_up``, ``thumbs_down``, ``sleep``, ``add_artist_bookmark``, +The full list of supported events are: ``thumbs_up``, ``thumbs_down``, ``sleep``, ``add_artist_bookmark``, ``add_song_bookmark``, and ``delete_station``. From f62f1d22f618afe1a8fe16b5ca864bbb7e09badd Mon Sep 17 00:00:00 2001 From: jcass Date: Thu, 21 Jan 2016 15:15:59 +0200 Subject: [PATCH 268/311] Update TODO comment. --- tests/test_frontend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 36c4d24..f717256 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -68,7 +68,7 @@ def send(cls, event, **kwargs): self.send_mock = self.patcher.start() self.send_mock.side_effect = send - # TODO: Remove this patch once Mopidy 1.2 has been released. + # TODO: Remove this patcher once Mopidy 1.2 has been released. try: self.core_patcher = mock.patch('mopidy.listener.send_async') self.core_send_mock = self.core_patcher.start() From 1ee2779f2f1b9a666dc25fb22212d784da77bf5a Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 23 Jan 2016 07:23:52 +0200 Subject: [PATCH 269/311] Default sort order to alphabetical and cache time to live to 24 hours. --- CHANGES.rst | 2 ++ README.rst | 6 +++--- mopidy_pandora/ext.conf | 4 ++-- tests/conftest.py | 4 ++-- tests/test_extension.py | 4 ++-- tests/test_library.py | 4 ++-- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4fbd1df..e6d2fac 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -21,6 +21,8 @@ v0.2.0 (UNRELEASED) - Now plays advertisements which should prevent non-Pandora One accounts from being locked after extended use. - Tracks are now played in ``consume`` instead of ``repeat`` mode. This is more in line with how Pandora deals with track playback. It also avoids infinite loops on unplayable tracks, which is still an issue in Mopidy 1.1.2. +- Station sort order now defaults to alphabetical. This makes it easier to find stations if the user profile contains + more than a few stations. - Added link to a short troubleshooting guide on the README page. v0.1.8 (Jan 8, 2016) diff --git a/README.rst b/README.rst index 631f4f1..f609e86 100644 --- a/README.rst +++ b/README.rst @@ -108,8 +108,8 @@ The following configuration values are available: 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 the ``date`` that the station was added. Use ``a-z`` to display the list of - stations in alphabetical order. +- ``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``, @@ -120,7 +120,7 @@ The following configuration values are available: 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 ``1800``. + 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. diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index 22d2be0..eee7c83 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -9,9 +9,9 @@ partner_device = IP01 username = password = preferred_audio_quality = highQuality -sort_order = date +sort_order = a-z auto_setup = true -cache_time_to_live = 1800 +cache_time_to_live = 86400 event_support_enabled = false double_click_interval = 2.50 diff --git a/tests/conftest.py b/tests/conftest.py index 636d58a..a71f13c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,9 +62,9 @@ def config(): 'username': 'john', 'password': 'smith', 'preferred_audio_quality': MOCK_DEFAULT_AUDIO_QUALITY, - 'sort_order': 'date', + 'sort_order': 'a-z', 'auto_setup': True, - 'cache_time_to_live': 1800, + 'cache_time_to_live': 86400, 'event_support_enabled': True, 'double_click_interval': '0.5', diff --git a/tests/test_extension.py b/tests/test_extension.py index cc1e7fc..a9387de 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -28,9 +28,9 @@ def test_get_default_config(self): assert 'username ='in config assert 'password ='in config assert 'preferred_audio_quality = highQuality'in config - assert 'sort_order = date'in config + assert 'sort_order = a-z'in config assert 'auto_setup = true'in config - assert 'cache_time_to_live = 1800'in config + assert 'cache_time_to_live = 86400'in config assert 'event_support_enabled = false'in config assert 'double_click_interval = 2.50'in config assert 'on_pause_resume_click = thumbs_up'in config diff --git a/tests/test_library.py b/tests/test_library.py index 9993b61..ec47400 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -199,11 +199,11 @@ def test_browse_directory_uri(config): assert results[2].type == models.Ref.DIRECTORY assert results[2].uri == StationUri._from_station( - Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][0])).uri + Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][1])).uri assert results[3].type == models.Ref.DIRECTORY assert results[3].uri == StationUri._from_station( - Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][1])).uri + Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][0])).uri def test_browse_directory_marks_quickmix_stations(config): From eb92935c1dcc62e0d5bd337159b2901a3283658e Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 26 Jan 2016 21:34:35 +0200 Subject: [PATCH 270/311] Reset skip limits on browse. Fixes #43. Increment pydora dependency to 1.7.0. --- CHANGES.rst | 6 ++++++ README.rst | 2 +- mopidy_pandora/library.py | 1 + mopidy_pandora/playback.py | 3 +++ setup.py | 2 +- tests/test_library.py | 9 +++++++++ 6 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index e6d2fac..c10953c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -25,6 +25,12 @@ v0.2.0 (UNRELEASED) more than a few stations. - Added link to a short troubleshooting guide on the README page. +** Fixes ** + +- Unplayable tracks are now removed from the tracklist. (Fixes: `#38 `_). +- Adds are now always assigned a unique URI. (Fixes: `#39 `_). +- Maximum skip limits are now reset whenever user browses another folder. (Fixes: `#43 `_). + v0.1.8 (Jan 8, 2016) -------------------- diff --git a/README.rst b/README.rst index f609e86..07eb0a5 100644 --- a/README.rst +++ b/README.rst @@ -55,7 +55,7 @@ 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.6.6. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. +- ``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. diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index c35ea47..54d4d44 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -36,6 +36,7 @@ def __init__(self, backend, sort_order): 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() diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index a15e388..ff7f525 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -76,6 +76,9 @@ def check_skip_limit(self): 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 diff --git a/setup.py b/setup.py index e1f95f7..2af89af 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ def run_tests(self): 'cachetools >= 1.0.0', 'Mopidy >= 1.1.2', 'Pykka >= 1.1', - 'pydora >= 1.6.5', + 'pydora >= 1.7.0', 'requests >= 2.5.0' ], tests_require=['tox'], diff --git a/tests/test_library.py b/tests/test_library.py index ec47400..b4e0a14 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -263,6 +263,15 @@ def test_browse_raises_exception_for_unsupported_uri_type(config): backend.library.browse('pandora:invalid_uri') +def test_browse_resets_skip_limits(config): + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): + backend = conftest.get_backend(config) + backend.playback._consecutive_track_skips = 5 + backend.library.browse(backend.library.root_directory.uri) + + assert backend.playback._consecutive_track_skips == 0 + + def test_browse_genre_category(config): with mock.patch.object(MopidyAPIClient, 'get_genre_stations', conftest.get_genre_stations_mock): From 711386abb42a4781a6dd96d28fc711e851e8f077 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 26 Jan 2016 21:36:37 +0200 Subject: [PATCH 271/311] Update changelog. --- CHANGES.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index c10953c..4c1126e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,8 +1,8 @@ Changelog ========= -v0.2.0 (UNRELEASED) -------------------- +v0.2.0 (Jan 26, 2016) +--------------------- **Features and improvements** @@ -25,7 +25,7 @@ v0.2.0 (UNRELEASED) more than a few stations. - Added link to a short troubleshooting guide on the README page. -** Fixes ** +**Fixes** - Unplayable tracks are now removed from the tracklist. (Fixes: `#38 `_). - Adds are now always assigned a unique URI. (Fixes: `#39 `_). From dc9da4a0840a98d4dcc36a7ac7ee5253f5bad3cf Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 30 Jan 2016 12:14:15 +0200 Subject: [PATCH 272/311] fix:Abort backend startup if login to Pandora server fails. Fixes #44. --- CHANGES.rst | 6 ++++++ mopidy_pandora/backend.py | 12 +----------- mopidy_pandora/library.py | 5 ----- tests/test_backend.py | 33 ++------------------------------- 4 files changed, 9 insertions(+), 47 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4c1126e..bd073a4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= + +v0.2.1 (UNRELEASED) +------------------- + +- Abort backend startup if login to Pandora server fails. (Fixes: `#44 `_). + v0.2.0 (Jan 26, 2016) --------------------- diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 4bcee06..995c0a5 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -8,8 +8,6 @@ import pykka -import requests - from mopidy_pandora import listener, utils from mopidy_pandora.client import MopidyAPIClient, MopidySettingsDictBuilder @@ -44,16 +42,8 @@ def __init__(self, config, audio): self.playback = PandoraPlaybackProvider(audio, self) self.uri_schemes = [PandoraUri.SCHEME] - @utils.run_async def on_start(self): - try: - self.api.login(self.config['username'], self.config['password']) - # Prefetch list of stations linked to the user's profile - self.api.get_station_list() - # Prefetch genre category list - self.api.get_genre_stations() - except requests.exceptions.RequestException: - logger.exception('Error logging in to Pandora.') + 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) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 54d4d44..31bdfc0 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -12,7 +12,6 @@ from pydora.utils import iterate_forever -from mopidy_pandora import utils from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, StationUri, TrackUri # noqa I101 logger = logging.getLogger(__name__) @@ -52,7 +51,6 @@ def browse(self, uri): return self._browse_tracks(uri) def lookup(self, uri): - pandora_uri = PandoraUri.factory(uri) if isinstance(pandora_uri, TrackUri): try: @@ -132,9 +130,6 @@ def _formatted_station_list(self, list): return list def _browse_stations(self): - # Prefetch genre category list - utils.run_async(self.backend.api.get_genre_stations)() - station_directories = [] stations = self.backend.api.get_station_list() diff --git a/tests/test_backend.py b/tests/test_backend.py index 8e5742b..314c6a2 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -11,7 +11,7 @@ from mopidy_pandora import client, library, playback from mopidy_pandora.backend import PandoraBackend from mopidy_pandora.library import PandoraLibraryProvider -from tests.conftest import get_backend, get_station_list_mock, request_exception_mock +from tests.conftest import get_backend def test_uri_schemes(config): @@ -60,40 +60,11 @@ def test_on_start_logs_in(config): login_mock = mock.Mock() backend.api.login = login_mock - t = backend.on_start() - t.join() + backend.on_start() backend.api.login.assert_called_once_with('john', 'smith') -def test_on_start_pre_fetches_lists(config): - with mock.patch.object(APIClient, 'get_station_list', get_station_list_mock): - backend = get_backend(config) - - backend.api.login = mock.Mock() - backend.api.get_genre_stations = mock.Mock() - - assert backend.api.station_list_cache.currsize == 0 - assert backend.api.genre_stations_cache.currsize == 0 - - t = backend.on_start() - t.join() - - assert backend.api.station_list_cache.currsize == 1 - assert backend.api.get_genre_stations.called - - -def test_on_start_handles_request_exception(config, caplog): - backend = get_backend(config, True) - - backend.api.login = request_exception_mock - t = backend.on_start() - t.join() - - # Check that request exceptions are caught and logged - assert 'Error logging in to Pandora' in caplog.text() - - def test_prepare_next_track_triggers_event(config): with mock.patch.object(PandoraLibraryProvider, 'get_next_pandora_track', From 6be13327dcf51db5f198bc24b3662e723fcd62d1 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 6 Feb 2016 10:53:47 +0200 Subject: [PATCH 273/311] Fix typo. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 07eb0a5..1ed5b67 100644 --- a/README.rst +++ b/README.rst @@ -147,7 +147,7 @@ The full list of supported events are: ``thumbs_up``, ``thumbs_down``, ``sleep`` Project resources ================= -- `Change log `_ +- `Changelog `_ - `Troubleshooting guide `_ - `Source code `_ - `Issue tracker `_ From a783fd4fa09423d2abfff189756202279d127daf Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 6 Feb 2016 16:50:14 +0200 Subject: [PATCH 274/311] Fix to ensure that doubleclick events are matched more accurately. --- CHANGES.rst | 8 +++++--- mopidy_pandora/frontend.py | 14 +++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index bd073a4..4af1eb1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,10 +2,12 @@ Changelog ========= -v0.2.1 (UNRELEASED) -------------------- +v0.2.1 (Feb 6, 2016) +-------------------- -- Abort backend startup if login to Pandora server fails. (Fixes: `#44 `_). +- Fix to prevent the Mopidy-Pandora backend from starting up if logging in to the Pandora server failed. + (Fixes: `#44 `_). +- Fixed an issue that would cause only the first few doubleclick events to be processed correctly. v0.2.0 (Jan 26, 2016) --------------------- diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 0fcadac..82f44d7 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -276,7 +276,6 @@ def on_start(self): self.event_sequences.append(EventSequence(self.config['on_pause_previous_click'], ['track_playback_paused', 'track_playback_ended', - 'track_changing', 'track_playback_paused'], self.sequence_match_results, wait_for='track_changed_previous', interval=interval)) @@ -284,7 +283,6 @@ def on_start(self): self.event_sequences.append(EventSequence(self.config['on_pause_next_click'], ['track_playback_paused', 'track_playback_ended', - 'track_changing', 'track_playback_paused'], self.sequence_match_results, wait_for='track_changed_next', interval=interval)) @@ -341,7 +339,7 @@ def monitor_sequences(self): match = self.sequence_match_results.get() self.sequence_match_results.task_done() - if match and match.ratio >= 0.80: + 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: @@ -472,12 +470,14 @@ def reset(self): def get_ratio(self): if self.wait_for: # Add 'wait_for' event as well to make ratio more accurate. - self.target_sequence.append(self.wait_for) + match_sequence = self.target_sequence + [self.wait_for] + else: + match_sequence = self.target_sequence if self.strict: - ratio = EventSequence.match_sequence(self.events_seen, self.target_sequence) + ratio = EventSequence.match_sequence(self.events_seen, match_sequence) else: - filtered_list = [e for e in self.events_seen if e in self.target_sequence] - ratio = EventSequence.match_sequence(filtered_list, self.target_sequence) + 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 From 534437a0d55fccae50a86a95182a0460d07c64da Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 6 Feb 2016 16:57:11 +0200 Subject: [PATCH 275/311] Increment version number. --- mopidy_pandora/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index 96cac61..bacd554 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -4,7 +4,7 @@ from mopidy import config, ext -__version__ = '0.2.0' +__version__ = '0.2.1' class Extension(ext.Extension): From 68c3fb9a48dfa6bb987fcfff3e790948ba76346c Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 26 Feb 2016 08:44:17 +0200 Subject: [PATCH 276/311] Update README. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 1ed5b67..fcf2ee4 100644 --- a/README.rst +++ b/README.rst @@ -97,7 +97,7 @@ The following configuration values are available: - ``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 `_ +- ``pandora/partner_*`` related values: The `credentials `_ to use for the Pandora API entry point. - ``pandora/username``: Your Pandora username. You *must* provide this. From 088702c4af4c6b172ad8de59f21781aa65f0b448 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 6 Feb 2016 10:53:47 +0200 Subject: [PATCH 277/311] Handle playlist items that do not have a bitrate specified. Fixes #48. --- CHANGES.rst | 8 ++++++++ README.rst | 8 +++++--- mopidy_pandora/library.py | 6 +++++- tests/test_library.py | 16 ++++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4af1eb1..d094542 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ Changelog ========= +v0.2.2 (Apr 13, 2016) +--------------------- + +- Fix an issue that would cause Mopidy-Pandora to raise an exception if a track did not have the `bitrate` field specified. + Please refer to the updated `configuration `_ options for + ``preferred_audio_quality`` for details on the effect that the chosen partner device has on stream quality options. + (Fixes: `#48 `_). + v0.2.1 (Feb 6, 2016) -------------------- diff --git a/README.rst b/README.rst index 07eb0a5..c2b8590 100644 --- a/README.rst +++ b/README.rst @@ -97,7 +97,7 @@ The following configuration values are available: - ``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 `_ +- ``pandora/partner_*`` related values: The `credentials `_ to use for the Pandora API entry point. - ``pandora/username``: Your Pandora username. You *must* provide this. @@ -106,7 +106,9 @@ The following configuration values are available: - ``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. + that Pandora supports for the chosen device will be used. Note that this setting has no effect for partner device types + that only provide one audio stream (notably credentials associated with iOS). In such instances, Mopidy-Pandora will + always revert to the default stream provided by the Pandora server. - ``pandora/sort_order``: defaults to ``a-z``. Use ``date`` to display the list of stations in the order that the stations were added. @@ -147,7 +149,7 @@ The full list of supported events are: ``thumbs_up``, ``thumbs_down``, ``sleep`` Project resources ================= -- `Change log `_ +- `Changelog `_ - `Troubleshooting guide `_ - `Source code `_ - `Issue tracker `_ diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 31bdfc0..ff72f7f 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -82,7 +82,11 @@ def lookup(self, uri): else: track_kwargs['name'] = track.song_name track_kwargs['length'] = track.track_length * 1000 - track_kwargs['bitrate'] = int(track.bitrate) + try: + track_kwargs['bitrate'] = int(track.bitrate) + except TypeError: + # Bitrate not specified for this stream, ignore. + pass artist_kwargs['name'] = track.artist_name album_kwargs['name'] = track.album_name album_kwargs['uri'] = track.album_detail_url diff --git a/tests/test_library.py b/tests/test_library.py index b4e0a14..c9767bc 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -171,6 +171,22 @@ def test_lookup_of_track_uri(config, playlist_item_mock): assert track.uri == track_uri.uri +# Regression test for https://github.com/rectalogic/mopidy-pandora/issues/48 +def test_lookup_of_track_that_does_not_specify_bitrate(config, playlist_item_mock): + backend = conftest.get_backend(config) + + playlist_item_mock.bitrate = None + track_uri = PlaylistItemUri._from_track(playlist_item_mock) + backend.library.pandora_track_cache[track_uri.uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), + playlist_item_mock) + + results = backend.library.lookup(track_uri.uri) + assert len(results) == 1 + + track = results[0] + assert track.uri == track_uri.uri + + def test_lookup_of_missing_track(config, playlist_item_mock, caplog): backend = conftest.get_backend(config) From 4b9fe68472601842eeb9300680e86ccac0891adf Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 13 Apr 2016 05:22:08 +0200 Subject: [PATCH 278/311] Increment version number. --- mopidy_pandora/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index bacd554..5e98eaf 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -4,7 +4,7 @@ from mopidy import config, ext -__version__ = '0.2.1' +__version__ = '0.2.2' class Extension(ext.Extension): From 6ab22c5ddd7fd9aa738ab97ccd46b2b89ed8d3d2 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 16 Apr 2016 07:23:37 +0200 Subject: [PATCH 279/311] Add information on OpenSSL / certifi compatibility. --- docs/troubleshooting.rst | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index aa44045..0b8dc8f 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -5,15 +5,17 @@ Troubleshooting These are the recommended steps to follow if you run into any issues using Mopidy-Pandora. -Check the logs --------------- + +1. Check the logs +----------------- Have a look at the contents of ``mopidy.log`` to see if there are any obvious issues that require attention. This could range from ``mopidy.conf`` parsing errors, or problems with the Pandora account that you are using. -Ensure that Mopidy is running ------------------------------ + +2. Ensure that Mopidy is running +-------------------------------- Make sure that Mopidy itself is working correctly and that it is accessible via the browser. Disable the Mopidy-Pandora extension by setting @@ -21,16 +23,28 @@ via the browser. Disable the Mopidy-Pandora extension by setting restart Mopidy, and confirm that the other Mopidy extensions that you have installed work as expected. -Ensure that you are connected to the internet ---------------------------------------------- + +3. Ensure that you are connected to the internet +------------------------------------------------ This sounds rather obvious but Mopidy-Pandora relies on a working internet connection to log on to the Pandora servers and retrieve station information. If you are behind a proxy, you may have to configure some of Mopidy's `proxy settings `_. -Run pydora directly -------------------- + +4. Check the installed versions of OpenSSL and certifi +------------------------------------------------------ + +There is a known problem with cross-signed certificates and versions of +OpenSSL prior to 1.0.2. If you are running Mopidy on a Raspberry Pi it is +likely that you still have an older version of OpenSSL installed. You could +try upgrading OpenSSL, or as a workaround, revert to an older version of +certifi with `pip install certifi==2015.4.28`. + + +5. Run pydora directly +---------------------- Mopidy-Pandora makes use of the pydora API, which comes bundled with its own command-line player that can be run completely independently of Mopidy. This @@ -43,8 +57,9 @@ and use ``pydora-configure`` to create the necessary configuration file in ``~/.pydora.cfg``. Once that is done running ``pydora`` from the command line will give you a quick indication of whether the issues are Mopidy-specific or not. -Try a different Pandora user account ------------------------------------- + +6. Try a different Pandora user account +--------------------------------------- It sometimes happens that Pandora will temporarily block a user account if you exceed any of the internal skip or station request limits. It may be a good From 4b9ae3877368786f16e3084b20dd17e568ee3b8c Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 16 Apr 2016 07:47:27 +0200 Subject: [PATCH 280/311] docs:Fix formatting. --- docs/troubleshooting.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 0b8dc8f..27384e3 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -36,11 +36,11 @@ If you are behind a proxy, you may have to configure some of Mopidy's 4. Check the installed versions of OpenSSL and certifi ------------------------------------------------------ -There is a known problem with cross-signed certificates and versions of -OpenSSL prior to 1.0.2. If you are running Mopidy on a Raspberry Pi it is -likely that you still have an older version of OpenSSL installed. You could -try upgrading OpenSSL, or as a workaround, revert to an older version of -certifi with `pip install certifi==2015.4.28`. +There is a `known problem `_ +with cross-signed certificates and versions of OpenSSL prior to 1.0.2. If you +are running Mopidy on a Raspberry Pi it is likely that you still have an older +version of OpenSSL installed. You could try upgrading OpenSSL, or as a +workaround, revert to an older version of certifi with ``pip install certifi==2015.4.28``. 5. Run pydora directly From 0609c9a222607481f0cbde74cbca240d03c41c44 Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 16 Apr 2016 07:59:00 +0200 Subject: [PATCH 281/311] docs:Fix formatting. --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index d094542..bbd8c5a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,7 @@ Changelog v0.2.2 (Apr 13, 2016) --------------------- -- Fix an issue that would cause Mopidy-Pandora to raise an exception if a track did not have the `bitrate` field specified. +- Fix an issue that would cause Mopidy-Pandora to raise an exception if a track did not have the ``bitrate`` field specified. Please refer to the updated `configuration `_ options for ``preferred_audio_quality`` for details on the effect that the chosen partner device has on stream quality options. (Fixes: `#48 `_). From c20c0484a5de22e7cd0e79e0c27eb716b9e53f3d Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 29 May 2016 16:16:39 +0200 Subject: [PATCH 282/311] Add support for searching. Fixes #36. --- CHANGES.rst | 5 ++++ README.rst | 3 ++- mopidy_pandora/library.py | 55 ++++++++++++++++++++++++++++++++++++--- mopidy_pandora/uri.py | 30 +++++++++++++++++++++ setup.py | 2 +- tests/conftest.py | 30 ++++++++++++++++++++- tests/test_client.py | 2 +- tests/test_library.py | 36 +++++++++++++++++++++++++ tests/test_uri.py | 48 +++++++++++++++++++++++++++++++++- 9 files changed, 202 insertions(+), 9 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index bbd8c5a..babf6b8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changelog ========= +(UNRELEASED) +------------ + +- Add support for searching. (Addresses: `#36 `_). + v0.2.2 (Apr 13, 2016) --------------------- diff --git a/README.rst b/README.rst index c2b8590..cb1b1fa 100644 --- a/README.rst +++ b/README.rst @@ -28,6 +28,7 @@ Features - Add ratings to tracks (thumbs up, thumbs down, sleep, etc.). - Bookmark songs or artists. - Browse and add genre stations. +- Search for song or artist stations. - Play QuickMix stations. - Sort stations alphabetically or by date added. - Delete stations from the user's Pandora profile. @@ -55,7 +56,7 @@ 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. +- ``pydora`` >= 1.7.2. 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. diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index ff72f7f..edfde3f 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -12,7 +12,7 @@ from pydora.utils import iterate_forever -from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, StationUri, TrackUri # noqa I101 +from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, SearchUri, StationUri, TrackUri # noqa I101 logger = logging.getLogger(__name__) @@ -52,6 +52,14 @@ def browse(self, uri): def lookup(self, uri): pandora_uri = PandoraUri.factory(uri) + if isinstance(pandora_uri, SearchUri): + # Create the station first so that it can be browsed. + station_uri = self._create_station_for_token(pandora_uri.token) + track = self._browse_tracks(station_uri.uri)[0] + + # Recursive call to look up first track in station that was searched for. + return self.lookup(track.uri) + if isinstance(pandora_uri, TrackUri): try: track = self.lookup_pandora_track(uri) @@ -157,8 +165,8 @@ 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) + def _create_station_for_token(self, token): + json_result = self.backend.api.create_station(search_token=token) new_station = Station.from_json(self.backend.api, json_result) self.refresh() @@ -178,7 +186,7 @@ def lookup_pandora_track(self, uri): def get_station_cache_item(self, station_id): if GenreStationUri.pattern.match(station_id): - pandora_uri = self._create_station_for_genre(station_id) + pandora_uri = self._create_station_for_token(station_id) station_id = pandora_uri.station_id station = self.backend.api.get_station(station_id) @@ -219,3 +227,42 @@ def refresh(self, uri=None): else: raise ValueError('Unexpected URI type to perform refresh of Pandora directory: {}.' .format(pandora_uri.uri_type)) + + def search(self, query=None, uris=None, exact=False, **kwargs): + search_text = self._formatted_search_query(query) + + if not search_text: + # No value provided for search query, abort. + logger.info('Unsupported Pandora search query: {}'.format(query)) + return [] + + search_result = self.backend.api.search(search_text) + + tracks = [] + for song in search_result.songs: + tracks.append(models.Track(uri=SearchUri(song.token).uri, + name='Pandora station for track: {}'.format(song.song_name), + artists=[models.Artist(name=song.artist)])) + + artists = [] + for artist in search_result.artists: + search_uri = SearchUri(artist.token) + if search_uri.is_artist_search: + station_name = 'Pandora station for artist: {}'.format(artist.artist) + else: + station_name = 'Pandora station for composer: {}'.format(artist.artist) + artists.append(models.Artist(uri=search_uri.uri, + name=station_name)) + + return models.SearchResult(uri='pandora:search:{}'.format(search_text), tracks=tracks, artists=artists) + + def _formatted_search_query(self, query): + search_text = [] + for (field, values) in iter(query.items()): + if not hasattr(values, '__iter__'): + values = [values] + for value in values: + if field == 'any' or field == 'artist' or field == 'track_name': + search_text.append(value) + search_text = ' '.join(search_text) + return search_text diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index aae9b75..46fc12d 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -184,3 +184,33 @@ def __repr__(self): super(AdItemUri, self).__repr__(), **self.encoded_attributes ) + + +class SearchUri(PandoraUri): + uri_type = 'search' + + def __init__(self, token): + super(SearchUri, self).__init__(self.uri_type) + + # Check that this really is a search result URI as opposed to a regular URI. + # Search result tokens always start with 'S' (song), 'R' (artist), or 'C' (composer). + assert re.match('^([S,R,C])', token) + self.token = token + + def __repr__(self): + return '{}:{token}'.format( + super(SearchUri, self).__repr__(), + **self.encoded_attributes + ) + + @property + def is_track_search(self): + return self.token.startswith('S') + + @property + def is_artist_search(self): + return self.token.startswith('R') + + @property + def is_composer_search(self): + return self.token.startswith('C') diff --git a/setup.py b/setup.py index 2af89af..aafe620 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ def run_tests(self): 'cachetools >= 1.0.0', 'Mopidy >= 1.1.2', 'Pykka >= 1.1', - 'pydora >= 1.7.0', + 'pydora >= 1.7.2', 'requests >= 2.5.0' ], tests_require=['tox'], diff --git a/tests/conftest.py b/tests/conftest.py index a71f13c..791254b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,8 @@ from pandora import APIClient -from pandora.models.pandora import AdItem, GenreStation, GenreStationList, PlaylistItem, Station, StationList +from pandora.models.pandora import AdItem, GenreStation, GenreStationList, PlaylistItem, SearchResult, Station,\ + StationList import pytest @@ -278,6 +279,28 @@ def station_list_result_mock(): return mock_result['result'] +@pytest.fixture(scope='session') +def search_result_mock(): + mock_result = {'stat': 'ok', + 'result': {'nearMatchesAvailable': True, + 'explanation': '', + 'songs': [{ + 'artistName': 'search_song_artist_mock', + 'musicToken': 'S1234567', + 'songName': MOCK_TRACK_NAME, + 'score': 100 + }], + 'artists': [{ + 'artistName': 'search_artist_artist_mock', + 'musicToken': 'R123456', + 'likelyMatch': False, + 'score': 100 + }]} + } + + return mock_result['result'] + + @pytest.fixture def get_station_list_mock(self, force_refresh=False): return StationList.from_json(get_backend(config()).api, station_list_result_mock()) @@ -298,6 +321,11 @@ def transport_call_not_implemented_mock(self, method, **data): raise TransportCallTestNotImplemented(method + '(' + json.dumps(self.remove_empty_values(data)) + ')') +@pytest.fixture +def search_mock(self, search_text): + return SearchResult.from_json(get_backend(config()).api, search_result_mock()) + + class TransportCallTestNotImplemented(Exception): pass diff --git a/tests/test_client.py b/tests/test_client.py index 1cdc965..e937f8d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -212,6 +212,6 @@ def test_create_genre_station_invalidates_cache(config): backend.api.station_list_cache[t] = mock.Mock(spec=StationList) assert t in list(backend.api.station_list_cache) - backend.library._create_station_for_genre('test_token') + backend.library._create_station_for_token('test_token') assert t not in list(backend.api.station_list_cache) assert backend.api.station_list_cache.currsize == 1 diff --git a/tests/test_library.py b/tests/test_library.py index c9767bc..0e3edce 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -329,6 +329,22 @@ def test_browse_station_uri(config, station_mock): assert len(results) == 1 +def test_formatted_search_query_concatenates_queries_into_free_text(config): + backend = conftest.get_backend(config) + + result = backend.library._formatted_search_query({ + 'any': ['any_mock'], 'artist': ['artist_mock'], 'track_name': ['track_mock'] + }) + assert 'any_mock' in result and 'artist_mock' in result and 'track_mock' in result + + +def test_formatted_search_query_ignores_unsupported_attributes(config): + backend = conftest.get_backend(config) + + result = backend.library._formatted_search_query({'album': ['album_mock']}) + assert len(result) is 0 + + def test_refresh_without_uri_refreshes_root(config): backend = conftest.get_backend(config) backend.api.get_station_list = mock.Mock() @@ -393,3 +409,23 @@ def test_refresh_station_directory_not_in_cache_handles_key_error(config): assert backend.library.pandora_station_cache.currsize == 0 assert not backend.api.get_station_list.called assert not backend.api.get_genre_stations.called + + +def test_search_returns_empty_result_for_unsupported_queries(config, caplog): + backend = conftest.get_backend(config) + assert len(backend.library.search({'album': ['album_name_mock']})) is 0 + assert 'Unsupported Pandora search query:' in caplog.text() + + +def test_search_(config): + with mock.patch.object(APIClient, 'search', conftest.search_mock): + + backend = conftest.get_backend(config) + search_result = backend.library.search({'any': 'search_mock'}) + + assert len(search_result.tracks) is 1 + assert search_result.tracks[0].uri == 'pandora:search:S1234567' + assert search_result.tracks[0].name == 'Pandora station for track: ' + conftest.MOCK_TRACK_NAME + + assert len(search_result.artists) is 1 + assert search_result.artists[0].uri == 'pandora:search:R123456' diff --git a/tests/test_uri.py b/tests/test_uri.py index 443d80a..657ad2c 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -9,7 +9,8 @@ import pytest -from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, PlaylistItemUri, StationUri, TrackUri +from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, PlaylistItemUri, SearchUri,\ + StationUri, TrackUri from . import conftest @@ -119,6 +120,51 @@ def test_pandora_parse_invalid_scheme_raises_exception(): PandoraUri()._from_uri('not_the_pandora_scheme:invalid') +def test_search_uri_parse(): + + obj = PandoraUri._from_uri('pandora:search:S1234567') + assert type(obj) is SearchUri + + assert obj.uri_type == SearchUri.uri_type + assert obj.token == 'S1234567' + + obj = PandoraUri._from_uri('pandora:search:R123456') + assert type(obj) is SearchUri + + assert obj.uri_type == SearchUri.uri_type + assert obj.token == 'R123456' + + obj = PandoraUri._from_uri('pandora:search:C12345') + assert type(obj) is SearchUri + + assert obj.uri_type == SearchUri.uri_type + assert obj.token == 'C12345' + + +def test_search_uri_is_track_search(): + obj = PandoraUri._from_uri('pandora:search:S1234567') + assert obj.is_track_search + + obj.token = 'R123456' + assert not obj.is_track_search + + +def test_search_uri_is_artist_search(): + obj = PandoraUri._from_uri('pandora:search:S1234567') + assert not obj.is_artist_search + + obj.token = 'R123456' + assert obj.is_artist_search + + +def test_search_uri_is_composer_search(): + obj = PandoraUri._from_uri('pandora:search:S1234567') + assert not obj.is_composer_search + + obj.token = 'C12345' + assert obj.is_composer_search + + def test_station_uri_from_station(station_mock): station_uri = StationUri._from_station(station_mock) From abcffac3d0cc1302a265b66293f6662f2cdd44da Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 6 Jun 2016 20:02:48 +0200 Subject: [PATCH 283/311] fix:Artist and album URI's should point back to the track URI. Fixes #51. --- CHANGES.rst | 6 ++++++ mopidy_pandora/library.py | 5 ++--- tests/test_library.py | 13 +++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index babf6b8..efe0dc1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,8 +4,14 @@ Changelog (UNRELEASED) ------------ +**Features and improvements** + - Add support for searching. (Addresses: `#36 `_). +**Fixes** + +- Album and artist URIs now point back to the Pandora track. (Fixes: `#51 `_). + v0.2.2 (Apr 13, 2016) --------------------- diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index edfde3f..ad53b47 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -85,8 +85,6 @@ def lookup(self, uri): 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 @@ -97,11 +95,12 @@ def lookup(self, uri): pass 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)) + artist_kwargs['uri'] = uri # Artist lookups should just point back to the track itself. track_kwargs['artists'] = [models.Artist(**artist_kwargs)] + album_kwargs['uri'] = uri # Album lookups should just point back to the track itself. track_kwargs['album'] = models.Album(**album_kwargs) return [models.Track(**track_kwargs)] diff --git a/tests/test_library.py b/tests/test_library.py index 0e3edce..5c8a438 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -197,6 +197,19 @@ def test_lookup_of_missing_track(config, playlist_item_mock, caplog): assert "Failed to lookup Pandora URI '{}'.".format(track_uri.uri) in caplog.text() +def test_lookup_overrides_album_and_artist_uris(config, playlist_item_mock): + backend = conftest.get_backend(config) + + track_uri = PlaylistItemUri._from_track(playlist_item_mock) + backend.library.pandora_track_cache[track_uri.uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), + playlist_item_mock) + + results = backend.library.lookup(track_uri.uri) + track = results[0] + assert next(iter(track.artists)).uri == track_uri.uri + assert track.album.uri == track_uri.uri + + def test_browse_directory_uri(config): with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): From a3166ed2c73385e80ab78b1e2795dc895350e388 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 7 Jun 2016 06:37:04 +0200 Subject: [PATCH 284/311] Make type checks support inheritance. --- mopidy_pandora/frontend.py | 2 +- mopidy_pandora/library.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 82f44d7..7251c8b 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -340,7 +340,7 @@ def monitor_sequences(self): 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: + if match.marker.uri and isinstance(PandoraUri.factory(match.marker.uri), AdItemUri): logger.info('Ignoring doubleclick event for Pandora advertisement...') else: self._trigger_event_triggered(match.marker.event, match.marker.uri) diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index edfde3f..101fa18 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -44,10 +44,10 @@ def browse(self, uri): pandora_uri = PandoraUri.factory(uri) - if type(pandora_uri) is GenreUri: + if isinstance(pandora_uri, GenreUri): return self._browse_genre_stations(uri) - if type(pandora_uri) is StationUri or type(pandora_uri) is GenreStationUri: + if isinstance(pandora_uri, StationUri): return self._browse_tracks(uri) def lookup(self, uri): @@ -75,7 +75,7 @@ def lookup(self, uri): if len(images) > 0: album_kwargs = {'images': [image.uri for image in images]} - if type(pandora_uri) is AdItemUri: + if isinstance(pandora_uri, AdItemUri): track_kwargs['name'] = 'Advertisement' if not track.title: @@ -202,7 +202,7 @@ def get_next_pandora_track(self, station_id): return None track_uri = PandoraUri.factory(track) - if type(track_uri) is AdItemUri: + if isinstance(track_uri, AdItemUri): track_name = 'Advertisement' else: track_name = track.song_name @@ -218,7 +218,7 @@ def refresh(self, uri=None): self.backend.api.get_genre_stations(force_refresh=True) else: pandora_uri = PandoraUri.factory(uri) - if type(pandora_uri) is StationUri: + if isinstance(pandora_uri, StationUri): try: self.pandora_station_cache.pop(pandora_uri.station_id) except KeyError: From e234eee56e491f2bb2faffc96c38276bb1e00c18 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 6 Jun 2016 18:52:08 +0200 Subject: [PATCH 285/311] Add support for searching genre stations. --- CHANGES.rst | 4 ++-- README.rst | 2 +- mopidy_pandora/library.py | 13 +++++++++---- mopidy_pandora/uri.py | 8 ++++++-- setup.py | 2 +- tests/conftest.py | 7 ++++++- tests/test_library.py | 10 +++++++--- tests/test_uri.py | 14 ++++++++++++++ 8 files changed, 46 insertions(+), 14 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index efe0dc1..c1a643e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,9 +6,9 @@ Changelog **Features and improvements** -- Add support for searching. (Addresses: `#36 `_). +- Add support for searching Pandora stations. (Addresses: `#36 `_). -**Fixes** +-**Fixes** - Album and artist URIs now point back to the Pandora track. (Fixes: `#51 `_). diff --git a/README.rst b/README.rst index cb1b1fa..baae27c 100644 --- a/README.rst +++ b/README.rst @@ -28,7 +28,7 @@ Features - Add ratings to tracks (thumbs up, thumbs down, sleep, etc.). - Bookmark songs or artists. - Browse and add genre stations. -- Search for song or artist stations. +- Search for song, artist, and genre stations. - Play QuickMix stations. - Sort stations alphabetically or by date added. - Delete stations from the user's Pandora profile. diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index f1617fc..7e1cb32 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -235,21 +235,26 @@ def search(self, query=None, uris=None, exact=False, **kwargs): logger.info('Unsupported Pandora search query: {}'.format(query)) return [] - search_result = self.backend.api.search(search_text) + search_result = self.backend.api.search(search_text, include_near_matches=False, include_genre_stations=True) tracks = [] + for genre in search_result.genre_stations: + tracks.append(models.Track(uri=SearchUri(genre.token).uri, + name='{} (Pandora genre)'.format(genre.station_name), + artists=[models.Artist(name=genre.station_name)])) + for song in search_result.songs: tracks.append(models.Track(uri=SearchUri(song.token).uri, - name='Pandora station for track: {}'.format(song.song_name), + name='{} (Pandora station)'.format(song.song_name), artists=[models.Artist(name=song.artist)])) artists = [] for artist in search_result.artists: search_uri = SearchUri(artist.token) if search_uri.is_artist_search: - station_name = 'Pandora station for artist: {}'.format(artist.artist) + station_name = '{} (Pandora artist)'.format(artist.artist) else: - station_name = 'Pandora station for composer: {}'.format(artist.artist) + station_name = '{} (Pandora composer)'.format(artist.artist) artists.append(models.Artist(uri=search_uri.uri, name=station_name)) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 46fc12d..71d98ff 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -193,8 +193,8 @@ def __init__(self, token): super(SearchUri, self).__init__(self.uri_type) # Check that this really is a search result URI as opposed to a regular URI. - # Search result tokens always start with 'S' (song), 'R' (artist), or 'C' (composer). - assert re.match('^([S,R,C])', token) + # Search result tokens always start with 'S' (song), 'R' (artist), 'C' (composer), or 'G' (genre station). + assert re.match('^([SRCG])', token) self.token = token def __repr__(self): @@ -214,3 +214,7 @@ def is_artist_search(self): @property def is_composer_search(self): return self.token.startswith('C') + + @property + def is_genre_search(self): + return self.token.startswith('G') diff --git a/setup.py b/setup.py index aafe620..b31a5cb 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ def run_tests(self): 'cachetools >= 1.0.0', 'Mopidy >= 1.1.2', 'Pykka >= 1.1', - 'pydora >= 1.7.2', + 'pydora >= 1.7.3', 'requests >= 2.5.0' ], tests_require=['tox'], diff --git a/tests/conftest.py b/tests/conftest.py index 791254b..51be47d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -295,6 +295,11 @@ def search_result_mock(): 'musicToken': 'R123456', 'likelyMatch': False, 'score': 100 + }], + 'genreStations': [{ + 'musicToken': 'G123', + 'score': 100, + 'stationName': 'search_genre_mock' }]} } @@ -322,7 +327,7 @@ def transport_call_not_implemented_mock(self, method, **data): @pytest.fixture -def search_mock(self, search_text): +def search_mock(self, search_text, include_near_matches=False, include_genre_stations=False): return SearchResult.from_json(get_backend(config()).api, search_result_mock()) diff --git a/tests/test_library.py b/tests/test_library.py index 5c8a438..35caaf5 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -436,9 +436,13 @@ def test_search_(config): backend = conftest.get_backend(config) search_result = backend.library.search({'any': 'search_mock'}) - assert len(search_result.tracks) is 1 - assert search_result.tracks[0].uri == 'pandora:search:S1234567' - assert search_result.tracks[0].name == 'Pandora station for track: ' + conftest.MOCK_TRACK_NAME + assert len(search_result.tracks) is 2 + assert search_result.tracks[0].uri == 'pandora:search:G123' + assert search_result.tracks[0].name == 'search_genre_mock (Pandora genre)' + + assert search_result.tracks[1].uri == 'pandora:search:S1234567' + assert search_result.tracks[1].name == conftest.MOCK_TRACK_NAME + ' (Pandora station)' assert len(search_result.artists) is 1 assert search_result.artists[0].uri == 'pandora:search:R123456' + assert search_result.artists[0].name == 'search_artist_artist_mock (Pandora artist)' diff --git a/tests/test_uri.py b/tests/test_uri.py index 657ad2c..729a4d4 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -140,6 +140,12 @@ def test_search_uri_parse(): assert obj.uri_type == SearchUri.uri_type assert obj.token == 'C12345' + obj = PandoraUri._from_uri('pandora:search:G123') + assert type(obj) is SearchUri + + assert obj.uri_type == SearchUri.uri_type + assert obj.token == 'G123' + def test_search_uri_is_track_search(): obj = PandoraUri._from_uri('pandora:search:S1234567') @@ -165,6 +171,14 @@ def test_search_uri_is_composer_search(): assert obj.is_composer_search +def test_search_uri_is_genre_search(): + obj = PandoraUri._from_uri('pandora:search:S1234567') + assert not obj.is_genre_search + + obj.token = 'G123' + assert obj.is_genre_search + + def test_station_uri_from_station(station_mock): station_uri = StationUri._from_station(station_mock) From 78f29634ef9192ed41239245e5ed2b86be47ded0 Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 5 Jul 2016 13:46:07 +0200 Subject: [PATCH 286/311] Search enhancements. --- CHANGES.rst | 4 +++- README.rst | 8 +++---- docs/troubleshooting.rst | 7 +++---- mopidy_pandora/ext.conf | 4 ++-- mopidy_pandora/library.py | 12 +++++++++-- tests/conftest.py | 31 +++++++++++++++++++-------- tests/test_extension.py | 4 ++-- tests/test_library.py | 44 ++++++++++++++++++++++++++++++++++----- 8 files changed, 85 insertions(+), 29 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index c1a643e..5119838 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,8 +7,10 @@ Changelog **Features and improvements** - Add support for searching Pandora stations. (Addresses: `#36 `_). +- Switch default partner device configuration values from ``IP01`` (iPhone) to ``android-generic``, which provides more + stream quality configuration options. --**Fixes** +**Fixes** - Album and artist URIs now point back to the Pandora track. (Fixes: `#51 `_). diff --git a/README.rst b/README.rst index baae27c..09ef8cf 100644 --- a/README.rst +++ b/README.rst @@ -56,7 +56,7 @@ 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.2. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. +- ``pydora`` >= 1.7.3. 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. @@ -85,9 +85,9 @@ configuration also requires that you provide the details of the JSON API endpoin api_host = tuner.pandora.com/services/json/ partner_encryption_key = partner_decryption_key = - partner_username = iphone + partner_username = android partner_password = - partner_device = IP01 + partner_device = android-generic username = password = @@ -99,7 +99,7 @@ The following configuration values are available: 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. + to use for the Pandora API entry point. You *must* provide these values based on your device preferences. - ``pandora/username``: Your Pandora username. You *must* provide this. diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 27384e3..d55d1a0 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -47,10 +47,9 @@ workaround, revert to an older version of certifi with ``pip install certifi==20 ---------------------- Mopidy-Pandora makes use of the pydora API, which comes bundled with its own -command-line player that can be run completely independently of Mopidy. This -is often useful for isolating issues to determine if they are Mopidy related, -or due to problems with your Pandora user account or any of a range of -technical issues in reaching and logging in to the Pandora servers. +command-line player. Running pydora completely independently of Mopidy +is often useful for isolating issues, and can be used to determine if they are +Mopidy related or not. Follow the `installation instructions `_ and use ``pydora-configure`` to create the necessary configuration file in diff --git a/mopidy_pandora/ext.conf b/mopidy_pandora/ext.conf index eee7c83..c11e207 100644 --- a/mopidy_pandora/ext.conf +++ b/mopidy_pandora/ext.conf @@ -3,9 +3,9 @@ enabled = true api_host = tuner.pandora.com/services/json/ partner_encryption_key = partner_decryption_key = -partner_username = iphone +partner_username = android partner_password = -partner_device = IP01 +partner_device = android-generic username = password = preferred_audio_quality = highQuality diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 7e1cb32..96d9030 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -2,6 +2,8 @@ import logging +import re + from collections import namedtuple from cachetools import LRUCache @@ -117,7 +119,13 @@ def get_images(self, uris): if image_uri: image_uris.update([image_uri]) except (TypeError, KeyError): - logger.exception("Failed to lookup image for Pandora URI '{}'.".format(uri)) + pandora_uri = PandoraUri.factory(uri) + if isinstance(pandora_uri, TrackUri): + # Could not find the track as expected - exception. + logger.exception("Failed to lookup image for Pandora URI '{}'.".format(uri)) + else: + # Lookup + logger.warning("No images available for Pandora URIs of type '{}'.".format(pandora_uri.uri_type)) pass result[uri] = [models.Image(uri=u) for u in image_uris] return result @@ -184,7 +192,7 @@ 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): + if re.match('^([SRCG])', station_id): pandora_uri = self._create_station_for_token(station_id) station_id = pandora_uri.station_id diff --git a/tests/conftest.py b/tests/conftest.py index 51be47d..8e7018a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,8 +7,8 @@ from pandora import APIClient -from pandora.models.pandora import AdItem, GenreStation, GenreStationList, PlaylistItem, SearchResult, Station,\ - StationList +from pandora.models.pandora import AdItem, GenreStation, GenreStationList, PlaylistItem, SearchResult, \ + SearchResultItem, Station, StationList import pytest @@ -206,7 +206,6 @@ def ad_metadata_result_mock(): @pytest.fixture(scope='session') def playlist_mock(simulate_request_exceptions=False): with mock.patch.object(APIClient, '__call__', mock.Mock()) as call_mock: - call_mock.return_value = playlist_result_mock()['result'] return get_backend(config(), simulate_request_exceptions).api.get_playlist(MOCK_STATION_TOKEN) @@ -290,12 +289,20 @@ def search_result_mock(): 'songName': MOCK_TRACK_NAME, 'score': 100 }], - 'artists': [{ - 'artistName': 'search_artist_artist_mock', - 'musicToken': 'R123456', - 'likelyMatch': False, - 'score': 100 - }], + 'artists': [ + { + 'artistName': 'search_artist_artist_mock', + 'musicToken': 'R123456', + 'likelyMatch': False, + 'score': 100 + }, + { + 'artistName': 'search_artist_composer_mock', + 'musicToken': 'C123456', + 'likelyMatch': False, + 'score': 100 + }, + ], 'genreStations': [{ 'musicToken': 'G123', 'score': 100, @@ -326,6 +333,12 @@ def transport_call_not_implemented_mock(self, method, **data): raise TransportCallTestNotImplemented(method + '(' + json.dumps(self.remove_empty_values(data)) + ')') +@pytest.fixture +def search_item_mock(): + return SearchResultItem.from_json(get_backend( + config()).api, search_result_mock()['genreStations'][0]) + + @pytest.fixture def search_mock(self, search_text, include_near_matches=False, include_genre_stations=False): return SearchResult.from_json(get_backend(config()).api, search_result_mock()) diff --git a/tests/test_extension.py b/tests/test_extension.py index a9387de..0aa2f3d 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -22,9 +22,9 @@ def test_get_default_config(self): assert 'api_host = tuner.pandora.com/services/json/'in config assert 'partner_encryption_key ='in config assert 'partner_decryption_key ='in config - assert 'partner_username ='in config + assert 'partner_username = android'in config assert 'partner_password ='in config - assert 'partner_device ='in config + assert 'partner_device = android-generic'in config assert 'username ='in config assert 'password ='in config assert 'preferred_audio_quality = highQuality'in config diff --git a/tests/test_library.py b/tests/test_library.py index 35caaf5..14ad276 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -48,6 +48,15 @@ def test_get_images_for_unknown_uri_returns_empty_list(config, caplog): assert "Failed to lookup image for Pandora URI '{}'.".format(track_uri.uri) in caplog.text() +def test_get_images_for_unsupported_uri_type_issues_warning(config, caplog): + backend = conftest.get_backend(config) + + search_uri = PandoraUri.factory('pandora:search:R12345') + results = backend.library.get_images([search_uri.uri]) + assert len(results[search_uri.uri]) == 0 + assert "No images available for Pandora URIs of type 'search'.".format(search_uri.uri) in caplog.text() + + def test_get_images_for_track_without_images(config, playlist_item_mock): backend = conftest.get_backend(config) @@ -75,7 +84,6 @@ def test_get_next_pandora_track_fetches_track(config, playlist_item_mock): station_mock = mock.Mock(spec=Station) station_mock.id = 'id_token_mock' - station_mock.id = 'id_token_mock' backend.library.pandora_station_cache[station_mock.id] = StationCacheItem(station_mock, iter([playlist_item_mock])) ref = backend.library.get_next_pandora_track('id_token_mock') @@ -89,7 +97,6 @@ def test_get_next_pandora_track_handles_no_more_tracks_available(config, caplog) station_mock = mock.Mock(spec=Station) station_mock.id = 'id_token_mock' - station_mock.id = 'id_token_mock' backend.library.pandora_station_cache[station_mock.id] = StationCacheItem(station_mock, iter([])) track = backend.library.get_next_pandora_track('id_token_mock') @@ -157,6 +164,31 @@ def test_lookup_of_ad_uri_defaults_missing_values(config, ad_item_mock): assert track.album.name == '(Company name not specified)' +def test_lookup_of_search_uri(config, playlist_item_mock): + with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): + with mock.patch.object(APIClient, 'create_station', + mock.Mock(return_value=conftest.station_result_mock()['result'])) as create_station_mock: + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): + + backend = conftest.get_backend(config) + + station_mock = mock.Mock(spec=Station) + station_mock.id = conftest.MOCK_STATION_ID + backend.library.pandora_station_cache[station_mock.id] = \ + StationCacheItem(conftest.station_result_mock()['result'], + iter([playlist_item_mock])) + + track_uri = PlaylistItemUri._from_track(playlist_item_mock) + backend.library.pandora_track_cache[track_uri.uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), + playlist_item_mock) + + results = backend.library.lookup("pandora:search:S1234567") + # Make sure a station is created for the search URI first + assert create_station_mock.called + # Check that the first track to be played is returned correctly. + assert results[0].uri == track_uri.uri + + def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) @@ -404,7 +436,6 @@ def test_refresh_station_directory(config): station_mock = mock.Mock(spec=Station) station_mock.id = 'id_token_mock' - station_mock.id = 'id_token_mock' backend.library.pandora_station_cache[station_mock.id] = StationCacheItem(station_mock, iter([])) backend.library.refresh('pandora:station:id_token_mock:id_token_mock') @@ -430,7 +461,7 @@ def test_search_returns_empty_result_for_unsupported_queries(config, caplog): assert 'Unsupported Pandora search query:' in caplog.text() -def test_search_(config): +def test_search(config): with mock.patch.object(APIClient, 'search', conftest.search_mock): backend = conftest.get_backend(config) @@ -443,6 +474,9 @@ def test_search_(config): assert search_result.tracks[1].uri == 'pandora:search:S1234567' assert search_result.tracks[1].name == conftest.MOCK_TRACK_NAME + ' (Pandora station)' - assert len(search_result.artists) is 1 + assert len(search_result.artists) is 2 assert search_result.artists[0].uri == 'pandora:search:R123456' assert search_result.artists[0].name == 'search_artist_artist_mock (Pandora artist)' + + assert search_result.artists[1].uri == 'pandora:search:C123456' + assert search_result.artists[1].name == 'search_artist_composer_mock (Pandora composer)' From bf4bc4b850353578f605bad17f2cc3a38e78b2c8 Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 8 Jul 2016 10:21:24 +0200 Subject: [PATCH 287/311] Fix flake8 build errors. --- .travis.yml | 4 +--- mopidy_pandora/library.py | 2 +- mopidy_pandora/uri.py | 2 +- tox.ini | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2b079f7..f4320ee 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,7 +19,7 @@ env: before_install: - "sudo apt-get update -qq" - - "sudo apt-get install -y gstreamer0.10-plugins-good python-gst0.10" + - "sudo apt-get install -y gir1.2-gst-plugins-base-1.0 gir1.2-gstreamer-1.0 graphviz-dev gstreamer1.0-plugins-good gstreamer1.0-plugins-bad python-gst-1.0" install: - "pip install tox" @@ -29,5 +29,3 @@ script: after_success: - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi" - - diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 96d9030..edb01d2 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -14,7 +14,7 @@ from pydora.utils import iterate_forever -from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, SearchUri, StationUri, TrackUri # noqa I101 +from mopidy_pandora.uri import AdItemUri, GenreUri, PandoraUri, SearchUri, StationUri, TrackUri # noqa I101 logger = logging.getLogger(__name__) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 71d98ff..8c1e9c8 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -18,7 +18,7 @@ def with_metaclass(meta, *bases): class _PandoraUriMeta(type): - def __init__(cls, name, bases, clsdict): # noqa N805 + 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 diff --git a/tox.ini b/tox.ini index 8c74207..53c6d9f 100644 --- a/tox.ini +++ b/tox.ini @@ -24,7 +24,7 @@ commands = sitepackages = false deps = flake8 - flake8-import-order +# flake8-import-order TODO: broken in flake8 v3.0: https://github.com/PyCQA/flake8-import-order/pull/75 pep8-naming skip_install = true commands = flake8 --show-source --statistics --max-line-length 120 mopidy_pandora/ setup.py tests/ From 92598802a2d5e5241aa7572f0f034e2ecc9e5739 Mon Sep 17 00:00:00 2001 From: jcass Date: Fri, 8 Jul 2016 10:27:36 +0200 Subject: [PATCH 288/311] Prepare release v0.3.0 --- CHANGES.rst | 4 ++-- mopidy_pandora/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5119838..9c5441b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,8 +1,8 @@ Changelog ========= -(UNRELEASED) ------------- +v0.3.0 (Jul 8, 2016) +-------------------- **Features and improvements** diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index 5e98eaf..ace8696 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -4,7 +4,7 @@ from mopidy import config, ext -__version__ = '0.2.2' +__version__ = '0.3.0' class Extension(ext.Extension): From 839d2f054c130ba9533ef0082d8690658bbf1e9f Mon Sep 17 00:00:00 2001 From: jcass Date: Sat, 5 Nov 2016 06:09:45 +0200 Subject: [PATCH 289/311] Remove defunct PyPi downloads counter. --- README.rst | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.rst b/README.rst index 09ef8cf..24966fb 100644 --- a/README.rst +++ b/README.rst @@ -6,10 +6,6 @@ Mopidy-Pandora :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 From 2f17972d841deaec715020b3b0efd5d78d0a8ec9 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 5 Feb 2017 09:16:05 +0200 Subject: [PATCH 290/311] Update documentation. --- CHANGES.rst | 8 +++++++- README.rst | 6 +++--- docs/troubleshooting.rst | 10 ++++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9c5441b..87cb126 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= +UNRELEASED +---------- + +- Update documentation to refer to new 'Pandora Plus' subscription model instead of the old 'Pandora One'. +- Update troubleshooting guide with more workarounds for cross-signed certificates using OpenSSL < 1.0.2. + v0.3.0 (Jul 8, 2016) -------------------- @@ -47,7 +53,7 @@ v0.2.0 (Jan 26, 2016) - Station lists are now cached which speeds up startup and browsing of the list of stations dramatically. Configuration parameter ``cache_time_to_live`` can be used to specify when cache items should expire and be refreshed (in seconds). - Force Mopidy to stop when skip limit is exceeded (workaround for `#1221 `_). -- Now plays advertisements which should prevent non-Pandora One accounts from being locked after extended use. +- Now plays advertisements which should prevent non-Pandora Plus accounts from being locked after extended use. - Tracks are now played in ``consume`` instead of ``repeat`` mode. This is more in line with how Pandora deals with track playback. It also avoids infinite loops on unplayable tracks, which is still an issue in Mopidy 1.1.2. - Station sort order now defaults to alphabetical. This makes it easier to find stations if the user profile contains diff --git a/README.rst b/README.rst index 24966fb..c977bdc 100644 --- a/README.rst +++ b/README.rst @@ -20,7 +20,7 @@ Mopidy-Pandora Features ======== -- Support for both Pandora One and ad-supported free accounts. +- Support for both Pandora Plus and ad-supported free accounts. - Add ratings to tracks (thumbs up, thumbs down, sleep, etc.). - Bookmark songs or artists. - Browse and add genre stations. @@ -49,7 +49,7 @@ 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 +- Requires a Pandora user account. Users with a Pandora Plus subscription will have access to the higher quality 192 Kbps audio stream. Free accounts will play advertisements. - ``pydora`` >= 1.7.3. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. @@ -92,7 +92,7 @@ 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). + the endpoints are different for Pandora Plus and free accounts (details in the link provided). - ``pandora/partner_*`` related values: The `credentials `_ to use for the Pandora API entry point. You *must* provide these values based on your device preferences. diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index d55d1a0..62ec212 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -39,8 +39,14 @@ If you are behind a proxy, you may have to configure some of Mopidy's There is a `known problem `_ with cross-signed certificates and versions of OpenSSL prior to 1.0.2. If you are running Mopidy on a Raspberry Pi it is likely that you still have an older -version of OpenSSL installed. You could try upgrading OpenSSL, or as a -workaround, revert to an older version of certifi with ``pip install certifi==2015.4.28``. +version of OpenSSL installed. `certifi/python-certifi#26 `_ lists +several workarounds. In order of preference, you could try to: +- Upgrade OpenSSL >= 1.0.2 +- Run ``python -c 'import certifi; print certifi.old_where()'``, and assign the output of this command to the + ``REQUESTS_CA_BUNDLE`` environment variable. If running Mopidy as a service, you might have to edit + ``/etc/init.d/mopidy`` so that ``start-stop-daemon`` calls a custom script that sets the variable and wraps + ``/usr/local/bin/mopidy``. +- Revert to an older version of certifi with ``pip install certifi==2015.4.28``. 5. Run pydora directly From 7da85899a6b163cb6585726c6f69965f11fb3014 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 5 Feb 2017 09:21:12 +0200 Subject: [PATCH 291/311] Fix flake8 violation. --- mopidy_pandora/frontend.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 7251c8b..2e8294a 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -233,6 +233,7 @@ def __eq__(self, other): def __lt__(self, other): return self.ratio < other.ratio + EventMarker = namedtuple('EventMarker', 'event, uri, time') From 1d74e5889b0d767f7a94d56f0ff97929c6b6ddac Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 5 Feb 2017 10:15:52 +0200 Subject: [PATCH 292/311] Fix flake8 violation. --- setup.py | 3 ++- tests/test_library.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index b31a5cb..c0413a2 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ def get_version(filename): class Tox(test): - user_options = [(b'tox-args=', b'a', "Arguments to pass to tox")] + user_options = [(b'tox-args=', b'a', 'Arguments to pass to tox')] def initialize_options(self): test.initialize_options(self) @@ -35,6 +35,7 @@ def run_tests(self): errno = tox.cmdline(args=args) sys.exit(errno) + setup( name='Mopidy-Pandora', version=get_version('mopidy_pandora/__init__.py'), diff --git a/tests/test_library.py b/tests/test_library.py index 14ad276..cdce2d0 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -182,7 +182,7 @@ def test_lookup_of_search_uri(config, playlist_item_mock): backend.library.pandora_track_cache[track_uri.uri] = TrackCacheItem(mock.Mock(spec=models.Ref.track), playlist_item_mock) - results = backend.library.lookup("pandora:search:S1234567") + results = backend.library.lookup('pandora:search:S1234567') # Make sure a station is created for the search URI first assert create_station_mock.called # Check that the first track to be played is returned correctly. From 59e2c5b0a32507a25dcab0fd2c9bdf91a08eee98 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 5 Feb 2017 10:20:15 +0200 Subject: [PATCH 293/311] Fix markdown formatting. --- docs/troubleshooting.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 62ec212..7d04292 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -41,11 +41,14 @@ with cross-signed certificates and versions of OpenSSL prior to 1.0.2. If you are running Mopidy on a Raspberry Pi it is likely that you still have an older version of OpenSSL installed. `certifi/python-certifi#26 `_ lists several workarounds. In order of preference, you could try to: + - Upgrade OpenSSL >= 1.0.2 -- Run ``python -c 'import certifi; print certifi.old_where()'``, and assign the output of this command to the - ``REQUESTS_CA_BUNDLE`` environment variable. If running Mopidy as a service, you might have to edit + +- Run ``python -c 'import certifi; print certifi.old_where()'``, and assign the output of this command to + the ``REQUESTS_CA_BUNDLE`` environment variable. If running Mopidy as a service, you might have to edit ``/etc/init.d/mopidy`` so that ``start-stop-daemon`` calls a custom script that sets the variable and wraps ``/usr/local/bin/mopidy``. + - Revert to an older version of certifi with ``pip install certifi==2015.4.28``. From 649381779f929d392bef405d7b88c28f3a7bfd15 Mon Sep 17 00:00:00 2001 From: jcass Date: Sun, 26 Feb 2017 19:42:40 +0200 Subject: [PATCH 294/311] Allow station URI's to be added to playlists and the tracklist. Addresses #58. --- CHANGES.rst | 1 + mopidy_pandora/library.py | 12 ++++++++++-- mopidy_pandora/playback.py | 10 +++++++++- tests/conftest.py | 4 +++- tests/test_library.py | 21 +++++++++++++++++++-- tests/test_playback.py | 11 +++++++++++ 6 files changed, 53 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 87cb126..4b3d7d4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,7 @@ UNRELEASED - Update documentation to refer to new 'Pandora Plus' subscription model instead of the old 'Pandora One'. - Update troubleshooting guide with more workarounds for cross-signed certificates using OpenSSL < 1.0.2. +- Allow station URI's to be added to playlists and the Mopidy tracklist. (Addresses: `#58 `_). v0.3.0 (Jul 8, 2016) -------------------- diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index edb01d2..a5bd326 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -54,6 +54,7 @@ def browse(self, uri): def lookup(self, uri): pandora_uri = PandoraUri.factory(uri) + logger.info('Looking up Pandora {} {}...'.format(pandora_uri.uri_type, pandora_uri.uri)) if isinstance(pandora_uri, SearchUri): # Create the station first so that it can be browsed. station_uri = self._create_station_for_token(pandora_uri.token) @@ -62,6 +63,9 @@ def lookup(self, uri): # Recursive call to look up first track in station that was searched for. return self.lookup(track.uri) + track_kwargs = {'uri': uri} + (album_kwargs, artist_kwargs) = {}, {} + if isinstance(pandora_uri, TrackUri): try: track = self.lookup_pandora_track(uri) @@ -69,8 +73,6 @@ def lookup(self, uri): 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] @@ -97,6 +99,12 @@ def lookup(self, uri): pass artist_kwargs['name'] = track.artist_name album_kwargs['name'] = track.album_name + elif isinstance(pandora_uri, StationUri): + station = self.backend.api.get_station(pandora_uri.station_id) + album_kwargs = {'images': [station.art_url]} + track_kwargs['name'] = station.name + artist_kwargs['name'] = 'Pandora Station' + album_kwargs['name'] = ', '.join(station.genre) else: raise ValueError('Unexpected type to perform Pandora track lookup: {}.'.format(pandora_uri.uri_type)) diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index ff7f525..8e34b6b 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -7,7 +7,7 @@ import requests from mopidy_pandora import listener - +from mopidy_pandora.uri import PandoraUri, StationUri logger = logging.getLogger(__name__) @@ -57,6 +57,14 @@ 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 + + pandora_uri = PandoraUri.factory(track.uri) + if isinstance(pandora_uri, StationUri): + # Change to first track in station playlist. + logger.warning('Cannot play Pandora stations directly. Retrieving tracks for station with ID: {}...' + .format(pandora_uri.station_id)) + self.backend.end_of_tracklist_reached(station_id=pandora_uri.station_id, auto_play=True) + return False try: self._trigger_track_changing(track) self.check_skip_limit() diff --git a/tests/conftest.py b/tests/conftest.py index 8e7018a..f1acb0d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ MOCK_STATION_TOKEN = '0000000000000000001' MOCK_STATION_DETAIL_URL = 'http://mockup.com/station/detail_url?...' MOCK_STATION_ART_URL = 'http://mockup.com/station/art_url?...' +MOCK_STATION_GENRE = 'Genre Mock' MOCK_STATION_LIST_CHECKSUM = 'aa00aa00aa00aa00aa00aa00aa00aa00' @@ -105,7 +106,8 @@ def station_result_mock(): 'stationDetailUrl': MOCK_STATION_DETAIL_URL, 'artUrl': MOCK_STATION_ART_URL, 'stationToken': MOCK_STATION_TOKEN, - 'stationName': MOCK_STATION_NAME}, + 'stationName': MOCK_STATION_NAME, + 'genre': [MOCK_STATION_GENRE]}, } return mock_result diff --git a/tests/test_library.py b/tests/test_library.py index cdce2d0..cccc6b4 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -129,8 +129,8 @@ def test_lookup_of_invalid_uri_type(config, caplog): with pytest.raises(ValueError): backend = conftest.get_backend(config) - backend.library.lookup('pandora:station:mock_id:mock_token') - assert 'Unexpected type to perform Pandora track lookup: station.' in caplog.text() + backend.library.lookup('pandora:genre:mock_name') + assert 'Unexpected type to perform Pandora track lookup: genre.' in caplog.text() def test_lookup_of_ad_uri(config, ad_item_mock): @@ -189,6 +189,23 @@ def test_lookup_of_search_uri(config, playlist_item_mock): assert results[0].uri == track_uri.uri +def test_lookup_of_station_uri(config): + with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): + with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): + backend = conftest.get_backend(config) + + station_uri = PandoraUri.factory(conftest.station_mock()) + results = backend.library.lookup(station_uri.uri) + assert len(results) == 1 + + track = results[0] + assert track.uri == station_uri.uri + assert next(iter(track.album.images)) == conftest.MOCK_STATION_ART_URL + assert track.name == conftest.MOCK_STATION_NAME + assert next(iter(track.artists)).name == 'Pandora Station' + assert track.album.name == conftest.MOCK_STATION_GENRE + + def test_lookup_of_track_uri(config, playlist_item_mock): backend = conftest.get_backend(config) diff --git a/tests/test_playback.py b/tests/test_playback.py index 2d4a4df..543c637 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -117,6 +117,17 @@ def test_change_track_fetches_next_track_if_unplayable(provider, playlist_item_m assert 'Error changing Pandora track' in caplog.text() +def test_change_track_fetches_next_track_if_station_uri(provider, caplog): + station = PandoraUri.factory(conftest.station_mock()) + + provider.backend._trigger_next_track_available = mock.PropertyMock() + + assert provider.change_track(station) is False + assert 'Cannot play Pandora stations directly. Retrieving tracks for station with ID: {}...'.format( + station.station_id) in caplog.text() + assert provider.backend._trigger_next_track_available.called + + def test_change_track_skips_if_no_track_uri(provider): track = models.Track(uri=None) From 390d1de714540e1f395e1565e3a6c09d27949fdc Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 20 Sep 2017 06:57:59 +0200 Subject: [PATCH 295/311] Prepare for release 0.4.0 --- CHANGES.rst | 4 ++-- mopidy_pandora/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4b3d7d4..893d388 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,8 +1,8 @@ Changelog ========= -UNRELEASED ----------- +v0.4.0 (Sep 20, 2017) +--------------------- - Update documentation to refer to new 'Pandora Plus' subscription model instead of the old 'Pandora One'. - Update troubleshooting guide with more workarounds for cross-signed certificates using OpenSSL < 1.0.2. diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index ace8696..e0b33f5 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -4,7 +4,7 @@ from mopidy import config, ext -__version__ = '0.3.0' +__version__ = '0.4.0' class Extension(ext.Extension): From e5d06660836fbf755703300be88f950f55f7a64a Mon Sep 17 00:00:00 2001 From: jcass77 Date: Wed, 26 Dec 2018 00:25:16 +0200 Subject: [PATCH 296/311] Fix flake8 error. --- mopidy_pandora/uri.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 8c1e9c8..3905355 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -142,7 +142,7 @@ def __repr__(self): class GenreStationUri(StationUri): uri_type = 'genre_station' - pattern = re.compile('^([G])(\d*)$') + pattern = re.compile('^([G])(\d*)$') # noqa: W605 def __init__(self, station_id, token): # Check that this really is a Genre station as opposed to a regular station. From ff9cd0c56da190ff80d2ac363615bb0cb6f236d9 Mon Sep 17 00:00:00 2001 From: jcass77 Date: Wed, 26 Dec 2018 00:26:01 +0200 Subject: [PATCH 297/311] Move development dependencies to requirements.txt file. --- dev-requirements.txt | 8 ++++++++ tox.ini | 10 ++-------- 2 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 dev-requirements.txt diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..1175803 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,8 @@ +mock +mopidy +pytest +pytest-capturelog +pytest-cov +pytest-xdist +responses +tox diff --git a/tox.ini b/tox.ini index 53c6d9f..9263944 100644 --- a/tox.ini +++ b/tox.ini @@ -5,14 +5,8 @@ envlist = py27, flake8 sitepackages = true whitelist_externals=py.test deps = - mock - mopidy - pytest - pytest-capturelog - pytest-cov - pytest-xdist - responses -install_command = pip install --allow-unverified=mopidy --pre {opts} {packages} + -rdev-requirements.txt +install_command = pip install --pre {opts} {packages} commands = py.test \ --basetemp={envtmpdir} \ From a0295f18696795e39dc7181763599d586bae1fd8 Mon Sep 17 00:00:00 2001 From: jcass77 Date: Wed, 26 Dec 2018 19:36:35 +0200 Subject: [PATCH 298/311] Switch to pytest test class convention. Add support for new cachetools `__missing__` method. --- dev-requirements.txt | 8 - mopidy_pandora/library.py | 30 +- pytest.ini | 6 + requirements-dev.txt | 5 + tests/conftest.py | 130 ++++-- tests/dummy_mopidy.py | 87 ++++ tests/test_backend.py | 7 +- tests/test_client.py | 81 ++-- tests/test_frontend.py | 807 ++++++++++++++++---------------------- tests/test_library.py | 121 +++--- tests/test_playback.py | 16 +- tests/test_uri.py | 11 +- tests/test_utils.py | 8 +- tox.ini | 9 +- 14 files changed, 685 insertions(+), 641 deletions(-) delete mode 100644 dev-requirements.txt create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 tests/dummy_mopidy.py diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index 1175803..0000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -mock -mopidy -pytest -pytest-capturelog -pytest-cov -pytest-xdist -responses -tox diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index a5bd326..7a1863b 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -33,7 +33,7 @@ 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_station_cache = StationCache(self, maxsize=5) self.pandora_track_cache = LRUCache(maxsize=10) def browse(self, uri): @@ -199,15 +199,6 @@ def _browse_genre_stations(self, uri): def lookup_pandora_track(self, uri): return self.pandora_track_cache[uri].track - def get_station_cache_item(self, station_id): - if re.match('^([SRCG])', station_id): - pandora_uri = self._create_station_for_token(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 @@ -286,3 +277,22 @@ def _formatted_search_query(self, query): search_text.append(value) search_text = ' '.join(search_text) return search_text + + +class StationCache(LRUCache): + def __init__(self, library, maxsize, getsizeof=None): + super(StationCache, self).__init__(maxsize, getsizeof=getsizeof) + self.library = library + + def __missing__(self, station_id): + if re.match('^([SRCG])', station_id): + pandora_uri = self.library._create_station_for_token(station_id) + station_id = pandora_uri.station_id + + station = self.library.backend.api.get_station(station_id) + station_iter = iterate_forever(station.get_playlist) + + item = StationCacheItem(station, station_iter) + self[station_id] = item + + return item diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..1a68fc8 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +# TODO: Remove deprecation warning suppression when PR's to upstream repositories have been merged and released. +filterwarnings = + ignore:.*playback.*:DeprecationWarning + ignore:.*playlists.*:DeprecationWarning + ignore:.*tracklist.*:PendingDeprecationWarning diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..3e88326 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +mock~=2.0 +pytest~=4.0 +pytest-cov~=2.6 +pytest-sugar~=0.9 +responses~=0.10 diff --git a/tests/conftest.py b/tests/conftest.py index f1acb0d..17bedd5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,12 @@ from __future__ import absolute_import, division, print_function, unicode_literals +import Queue import json import threading import mock +from mopidy import models +import pykka from pandora import APIClient @@ -14,7 +17,9 @@ import requests -from mopidy_pandora import backend +from mopidy_pandora import backend, frontend +from mopidy_pandora.frontend import EventSequence +from tests.dummy_mopidy import DummyMopidyInstance MOCK_STATION_TYPE = 'station' MOCK_STATION_NAME = 'Mock Station' @@ -92,13 +97,71 @@ def get_backend(config, simulate_request_exceptions=False): return obj -@pytest.fixture(scope='session') -def genre_station_mock(simulate_request_exceptions=False): - return GenreStation.from_json(get_backend(config(), simulate_request_exceptions).api, - genre_stations_result_mock()['categories'][0]['stations'][0]) +@pytest.fixture +def mopidy(config): + mopidy = DummyMopidyInstance() + mopidy.frontend = frontend.PandoraFrontend.start(config, mopidy.core).proxy() + mopidy.actor_register.append(mopidy.frontend) + yield mopidy -@pytest.fixture(scope='session') + pykka.ActorRegistry.stop_all() + mock.patch.stopall() + + +@pytest.fixture +def mopidy_with_monitor(config, mopidy): + mopidy.monitor = frontend.EventMonitorFrontend.start(config, mopidy.core).proxy() + mopidy.actor_register.append(mopidy.monitor) + + # Consume mode needs to be enabled to detect 'previous' track changes + mopidy.core.tracklist.set_consume(True) + + yield mopidy + + +@pytest.fixture +def rq(): + return Queue.PriorityQueue() + + +@pytest.fixture +def event_sequence(rq): + return EventSequence('match_mock', ['e1', 'e2', 'e3'], rq, 0.1, False) + + +@pytest.fixture +def event_sequence_strict(rq): + return EventSequence('match_mock', ['e1', 'e2', 'e3'], rq, 0.1, True) + + +@pytest.fixture +def event_sequence_wait(rq): + return EventSequence('match_mock', ['e1', 'e2', 'e3'], rq, 0.1, False, 'w1') + + +@pytest.fixture +def event_sequences(event_sequence, event_sequence_strict, event_sequence_wait): + return [event_sequence, event_sequence_strict, event_sequence_wait] + + +@pytest.fixture +def tl_track_mock(): + track_mock = mock.Mock(spec=models.Track) + track_mock.uri = 'pandora:track:id_mock:token_mock' + tl_track_mock = mock.Mock(spec=models.TlTrack) + tl_track_mock.track = track_mock + + return tl_track_mock + + +@pytest.fixture +def genre_station_mock(config, genre_stations_result_mock, simulate_request_exceptions=False): + return GenreStation.from_json(get_backend(config, simulate_request_exceptions).api, + genre_stations_result_mock['categories'][0]['stations'][0]) + + +@pytest.fixture def station_result_mock(): mock_result = {'stat': 'ok', 'result': @@ -113,15 +176,10 @@ def station_result_mock(): return mock_result -@pytest.fixture(scope='session') -def station_mock(simulate_request_exceptions=False): - return Station.from_json(get_backend(config(), simulate_request_exceptions).api, - station_result_mock()['result']) - - -@pytest.fixture(scope='session') -def get_station_mock(self, station_token): - return station_mock() +@pytest.fixture +def get_station_mock_return_value(config, station_result_mock, simulate_request_exceptions=False): + return Station.from_json(get_backend(config, simulate_request_exceptions).api, + station_result_mock['result']) @pytest.fixture(scope='session') @@ -205,33 +263,33 @@ def ad_metadata_result_mock(): return mock_result -@pytest.fixture(scope='session') -def playlist_mock(simulate_request_exceptions=False): +@pytest.fixture +def playlist_mock(config, playlist_result_mock, simulate_request_exceptions=False): with mock.patch.object(APIClient, '__call__', mock.Mock()) as call_mock: - call_mock.return_value = playlist_result_mock()['result'] - return get_backend(config(), simulate_request_exceptions).api.get_playlist(MOCK_STATION_TOKEN) + call_mock.return_value = playlist_result_mock['result'] + return get_backend(config, simulate_request_exceptions).api.get_playlist(MOCK_STATION_TOKEN) -@pytest.fixture(scope='session') -def get_playlist_mock(self, station_token): - return playlist_mock() +@pytest.fixture +def get_playlist_mock(playlist_mock): + return playlist_mock -@pytest.fixture(scope='session') -def get_station_playlist_mock(self): - return iter(get_playlist_mock(self, MOCK_STATION_TOKEN)) +@pytest.fixture +def get_station_playlist_mock(get_playlist_mock): + return iter(get_playlist_mock) @pytest.fixture -def playlist_item_mock(): +def playlist_item_mock(config, playlist_result_mock): return PlaylistItem.from_json(get_backend( - config()).api, playlist_result_mock()['result']['items'][0]) + config).api, playlist_result_mock['result']['items'][0]) @pytest.fixture -def ad_item_mock(): +def ad_item_mock(config, ad_metadata_result_mock): ad_item = AdItem.from_json(get_backend( - config()).api, ad_metadata_result_mock()['result']) + config).api, ad_metadata_result_mock['result']) ad_item.station_id = MOCK_STATION_ID ad_item.ad_token = MOCK_TRACK_AD_TOKEN return ad_item @@ -316,21 +374,19 @@ def search_result_mock(): @pytest.fixture -def get_station_list_mock(self, force_refresh=False): - return StationList.from_json(get_backend(config()).api, station_list_result_mock()) +def get_station_list_return_value_mock(config, station_list_result_mock,): + return StationList.from_json(get_backend(config).api, station_list_result_mock) @pytest.fixture -def get_genre_stations_mock(self, force_refresh=False): - return GenreStationList.from_json(get_backend(config()).api, genre_stations_result_mock()) +def get_genre_stations_return_value_mock(config, genre_stations_result_mock): + return GenreStationList.from_json(get_backend(config).api, genre_stations_result_mock) -@pytest.fixture(scope='session') def request_exception_mock(self, *args, **kwargs): raise requests.exceptions.RequestException -@pytest.fixture def transport_call_not_implemented_mock(self, method, **data): raise TransportCallTestNotImplemented(method + '(' + json.dumps(self.remove_empty_values(data)) + ')') @@ -342,8 +398,8 @@ def search_item_mock(): @pytest.fixture -def search_mock(self, search_text, include_near_matches=False, include_genre_stations=False): - return SearchResult.from_json(get_backend(config()).api, search_result_mock()) +def search_return_value_mock(config, search_result_mock): + return SearchResult.from_json(get_backend(config).api, search_result_mock) class TransportCallTestNotImplemented(Exception): diff --git a/tests/dummy_mopidy.py b/tests/dummy_mopidy.py new file mode 100644 index 0000000..7f51507 --- /dev/null +++ b/tests/dummy_mopidy.py @@ -0,0 +1,87 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +import Queue + +import mock + +from mopidy import core, models + +import pykka + +from tests import dummy_audio, dummy_backend +from tests.dummy_audio import DummyAudio +from tests.dummy_backend import DummyBackend, DummyPandoraBackend + + +class DummyMopidyInstance: + tracks = [ + models.Track(uri='pandora:track:id_mock:token_mock1', length=40000), # Regular track + models.Track(uri='pandora:track:id_mock:token_mock2', length=40000), # Regular track + models.Track(uri='pandora:ad:id_mock:token_mock3', length=40000), # Advertisement + models.Track(uri='mock:track:id_mock:token_mock4', length=40000), # Not a pandora track + models.Track(uri='pandora:track:id_mock_other:token_mock5', length=40000), # Different station + models.Track(uri='pandora:track:id_mock:token_mock6', length=None), # No duration + ] + + uris = [ + 'pandora:track:id_mock:token_mock1', 'pandora:track:id_mock:token_mock2', + 'pandora:ad:id_mock:token_mock3', 'mock:track:id_mock:token_mock4', + 'pandora:track:id_mock_other:token_mock5', 'pandora:track:id_mock:token_mock6'] + + def __init__(self): + config = {'core': {'max_tracklist_length': 10000}} + + self.audio = dummy_audio.create_proxy(DummyAudio) + self.backend = dummy_backend.create_proxy(DummyPandoraBackend, audio=self.audio) + self.non_pandora_backend = dummy_backend.create_proxy(DummyBackend, audio=self.audio) + + self.core = core.Core.start( + config, audio=self.audio, backends=[self.backend, self.non_pandora_backend]).proxy() + + def lookup(uris): + result = {uri: [] for uri in uris} + for track in self.tracks: + if track.uri in result: + result[track.uri].append(track) + return result + + self.core.library.lookup = lookup + self.tl_tracks = self.core.tracklist.add(uris=self.uris).get() + + self.events = Queue.Queue() + + def send(cls, event, **kwargs): + self.events.put((cls, event, kwargs)) + + self.patcher = mock.patch('mopidy.listener.send') + self.send_mock = self.patcher.start() + self.send_mock.side_effect = send + + # TODO: Remove this patcher once Mopidy 1.2 has been released. + try: + self.core_patcher = mock.patch('mopidy.listener.send_async') + self.core_send_mock = self.core_patcher.start() + self.core_send_mock.side_effect = send + except AttributeError: + # Mopidy > 1.1 no longer has mopidy.listener.send_async + pass + + self.actor_register = [self.backend, self.core, self.audio] + + def replay_events(self, until=None): + while True: + try: + e = self.events.get(timeout=0.1) + cls, event, kwargs = e + if event == until: + break + for actor in self.actor_register: + if isinstance(actor, pykka.ActorProxy): + if isinstance(actor._actor, cls): + actor.on_event(event, **kwargs).get() + else: + if isinstance(actor, cls): + actor.on_event(event, **kwargs) + except Queue.Empty: + # All events replayed. + break diff --git a/tests/test_backend.py b/tests/test_backend.py index 314c6a2..406125b 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals +import logging + import mock from mopidy import backend as backend_api @@ -82,6 +84,7 @@ def test_prepare_next_track_triggers_event(config): def test_process_event_calls_method(config, caplog): + caplog.set_level(logging.INFO) with mock.patch.object(PandoraLibraryProvider, 'lookup_pandora_track', mock.Mock()): with mock.patch.object(APIClient, '__call__', mock.Mock()) as mock_call: @@ -103,7 +106,7 @@ def test_process_event_calls_method(config, caplog): backend._trigger_event_processed.assert_called_with(uri_mock, event) backend._trigger_event_processed.reset_mock() - assert "Triggering event '{}'".format(event) in caplog.text() + assert "Triggering event '{}'".format(event) in caplog.text def test_process_event_handles_pandora_exception(config, caplog): @@ -119,4 +122,4 @@ def test_process_event_handles_pandora_exception(config, caplog): mock_call.assert_called_with(uri_mock) assert not backend._trigger_event_processed.called - assert 'Error calling Pandora event: thumbs_up.' in caplog.text() + assert 'Error calling Pandora event: thumbs_up.' in caplog.text diff --git a/tests/test_client.py b/tests/test_client.py index e937f8d..b69fd33 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -14,13 +14,13 @@ from . import conftest -def test_get_genre_stations(config): - with mock.patch.object(APIClient, 'get_genre_stations', conftest.get_genre_stations_mock): +def test_get_genre_stations(config, get_genre_stations_return_value_mock, genre_stations_result_mock): + with mock.patch.object(APIClient, 'get_genre_stations', return_value=get_genre_stations_return_value_mock): backend = conftest.get_backend(config) genre_stations = backend.api.get_genre_stations() - assert len(genre_stations) == len(conftest.genre_stations_result_mock()['categories']) + assert len(genre_stations) == len(genre_stations_result_mock['categories']) assert 'Category mock' in list(genre_stations) @@ -30,11 +30,11 @@ def test_get_genre_stations_handles_request_exception(config, caplog): assert backend.api.get_genre_stations() == [] # Check that request exceptions are caught and logged - assert 'Error retrieving Pandora genre stations.' in caplog.text() + assert 'Error retrieving Pandora genre stations.' in caplog.text -def test_get_genre_stations_populates_cache(config): - with mock.patch.object(APIClient, 'get_genre_stations', conftest.get_genre_stations_mock): +def test_get_genre_stations_populates_cache(config, get_genre_stations_return_value_mock): + with mock.patch.object(APIClient, 'get_genre_stations', return_value=get_genre_stations_return_value_mock): backend = conftest.get_backend(config) assert backend.api.genre_stations_cache.currsize == 0 @@ -43,8 +43,8 @@ def test_get_genre_stations_populates_cache(config): assert backend.api.genre_stations_cache.currsize == 1 -def test_get_genre_stations_changed_cached(config): - with mock.patch.object(APIClient, 'get_genre_stations', conftest.get_genre_stations_mock): +def test_get_genre_stations_changed_cached(config, get_genre_stations_return_value_mock): + with mock.patch.object(APIClient, 'get_genre_stations', return_value=get_genre_stations_return_value_mock): # Ensure that the cache is re-used between calls backend = conftest.get_backend(config) @@ -70,8 +70,8 @@ def test_get_genre_stations_changed_cached(config): APIClient, mock_cached_result['result'])) -def test_getgenre_stations_cache_disabled(config): - with mock.patch.object(APIClient, 'get_genre_stations', conftest.get_genre_stations_mock): +def test_getgenre_stations_cache_disabled(config, get_genre_stations_return_value_mock): + with mock.patch.object(APIClient, 'get_genre_stations', return_value=get_genre_stations_return_value_mock): cache_config = config cache_config['pandora']['cache_time_to_live'] = 0 backend = conftest.get_backend(cache_config) @@ -82,20 +82,20 @@ def test_getgenre_stations_cache_disabled(config): assert backend.api.genre_stations_cache.currsize == 0 -def test_get_station_list(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): +def test_get_station_list(config, get_station_list_return_value_mock, station_list_result_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): backend = conftest.get_backend(config) station_list = backend.api.get_station_list() - assert len(station_list) == len(conftest.station_list_result_mock()['stations']) + assert len(station_list) == len(station_list_result_mock['stations']) assert station_list[0].name == conftest.MOCK_STATION_NAME + ' 2' assert station_list[1].name == conftest.MOCK_STATION_NAME + ' 1' assert station_list[2].name.startswith('QuickMix') -def test_get_station_list_populates_cache(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): +def test_get_station_list_populates_cache(config, get_station_list_return_value_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): backend = conftest.get_backend(config) assert backend.api.station_list_cache.currsize == 0 @@ -104,8 +104,8 @@ def test_get_station_list_populates_cache(config): assert backend.api.station_list_cache.currsize == 1 -def test_get_station_list_changed_cached(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): +def test_get_station_list_changed_cached(config, get_station_list_return_value_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): # Ensure that the cache is re-used between calls backend = conftest.get_backend(config) @@ -113,10 +113,10 @@ def test_get_station_list_changed_cached(config): mock_cached_result = {'stat': 'ok', 'result': { 'stations': [ - {'stationId': conftest.MOCK_STATION_ID, - 'stationToken': conftest.MOCK_STATION_TOKEN, - 'stationName': conftest.MOCK_STATION_NAME - }, ], + {'stationId': conftest.MOCK_STATION_ID, + 'stationToken': conftest.MOCK_STATION_TOKEN, + 'stationName': conftest.MOCK_STATION_NAME + }, ], 'checksum': cached_checksum }} @@ -128,8 +128,8 @@ def test_get_station_list_changed_cached(config): APIClient, mock_cached_result['result'])) -def test_getstation_list_cache_disabled(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): +def test_getstation_list_cache_disabled(config, get_station_list_return_value_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): cache_config = config cache_config['pandora']['cache_time_to_live'] = 0 backend = conftest.get_backend(cache_config) @@ -140,8 +140,8 @@ def test_getstation_list_cache_disabled(config): assert backend.api.station_list_cache.currsize == 0 -def test_get_station_list_changed_refreshed(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): +def test_get_station_list_changed_refreshed(config, get_station_list_return_value_mock, station_list_result_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): # Ensure that the cache is invalidated if 'force_refresh' is True with mock.patch.object(StationList, 'has_changed', return_value=True): backend = conftest.get_backend(config) @@ -150,10 +150,10 @@ def test_get_station_list_changed_refreshed(config): mock_cached_result = {'stat': 'ok', 'result': { 'stations': [ - {'stationId': conftest.MOCK_STATION_ID, - 'stationToken': conftest.MOCK_STATION_TOKEN, - 'stationName': conftest.MOCK_STATION_NAME - }, ], + {'stationId': conftest.MOCK_STATION_ID, + 'stationToken': conftest.MOCK_STATION_TOKEN, + 'stationName': conftest.MOCK_STATION_NAME + }, ], 'checksum': cached_checksum }} @@ -164,7 +164,7 @@ def test_get_station_list_changed_refreshed(config): assert backend.api.get_station_list(force_refresh=True).checksum == conftest.MOCK_STATION_LIST_CHECKSUM assert (len(backend.api.station_list_cache.values()[0]) == - len(conftest.station_list_result_mock()['stations'])) + len(station_list_result_mock['stations'])) def test_get_station_list_handles_request_exception(config, caplog): @@ -173,11 +173,11 @@ def test_get_station_list_handles_request_exception(config, caplog): assert backend.api.get_station_list() == [] # Check that request exceptions are caught and logged - assert 'Error retrieving Pandora station list.' in caplog.text() + assert 'Error retrieving Pandora station list.' in caplog.text -def test_get_station(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): +def test_get_station(config, get_station_list_return_value_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): # Make sure we re-use the cached station list between calls with mock.patch.object(StationList, 'has_changed', return_value=False): backend = conftest.get_backend(config) @@ -191,23 +191,24 @@ def test_get_station(config): conftest.MOCK_STATION_TOKEN.replace('1', '2')).name == conftest.MOCK_STATION_NAME + ' 2' -def test_get_invalid_station(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): +def test_get_invalid_station(config, get_station_list_return_value_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): # Check that a call to the Pandora server is triggered if station is # not found in the cache with pytest.raises(conftest.TransportCallTestNotImplemented): - backend = conftest.get_backend(config) backend.api.get_station('9999999999999999999') -def test_create_genre_station_invalidates_cache(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): - with mock.patch.object(MopidyAPIClient, 'get_genre_stations', conftest.get_genre_stations_mock): +def test_create_genre_station_invalidates_cache(config, get_station_list_return_value_mock, + get_genre_stations_return_value_mock, station_result_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): + with mock.patch.object(MopidyAPIClient, 'get_genre_stations', + return_value=get_genre_stations_return_value_mock): backend = conftest.get_backend(config) - backend.api.create_station = mock.PropertyMock(return_value=conftest.station_result_mock()['result']) + backend.api.create_station = mock.PropertyMock(return_value=station_result_mock['result']) t = time.time() backend.api.station_list_cache[t] = mock.Mock(spec=StationList) assert t in list(backend.api.station_list_cache) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index f717256..679778a 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,170 +1,82 @@ from __future__ import absolute_import, division, print_function, unicode_literals -import Queue import time import unittest -from mock import mock +import mock -from mopidy import core, listener, models +from mopidy import listener, models from mopidy.audio import PlaybackState from mopidy.core import CoreListener -import pykka - from mopidy_pandora import frontend -from mopidy_pandora.frontend import EventMarker, EventSequence, MatchResult, PandoraFrontend +from mopidy_pandora.frontend import EventMarker, MatchResult, PandoraFrontend from mopidy_pandora.listener import EventMonitorListener, PandoraBackendListener, PandoraFrontendListener -from tests import conftest, dummy_audio, dummy_backend -from tests.dummy_audio import DummyAudio -from tests.dummy_backend import DummyBackend, DummyPandoraBackend +from tests import conftest class BaseTest(unittest.TestCase): - tracks = [ - models.Track(uri='pandora:track:id_mock:token_mock1', length=40000), # Regular track - models.Track(uri='pandora:track:id_mock:token_mock2', length=40000), # Regular track - models.Track(uri='pandora:ad:id_mock:token_mock3', length=40000), # Advertisement - models.Track(uri='mock:track:id_mock:token_mock4', length=40000), # Not a pandora track - models.Track(uri='pandora:track:id_mock_other:token_mock5', length=40000), # Different station - models.Track(uri='pandora:track:id_mock:token_mock6', length=None), # No duration - ] - - uris = [ - 'pandora:track:id_mock:token_mock1', 'pandora:track:id_mock:token_mock2', - 'pandora:ad:id_mock:token_mock3', 'mock:track:id_mock:token_mock4', - 'pandora:track:id_mock_other:token_mock5', 'pandora:track:id_mock:token_mock6'] - - def setUp(self): - config = {'core': {'max_tracklist_length': 10000}} - - self.audio = dummy_audio.create_proxy(DummyAudio) - self.backend = dummy_backend.create_proxy(DummyPandoraBackend, audio=self.audio) - self.non_pandora_backend = dummy_backend.create_proxy(DummyBackend, audio=self.audio) - - self.core = core.Core.start( - config, audio=self.audio, backends=[self.backend, self.non_pandora_backend]).proxy() - - def lookup(uris): - result = {uri: [] for uri in uris} - for track in self.tracks: - if track.uri in result: - result[track.uri].append(track) - return result - - self.core.library.lookup = lookup - self.tl_tracks = self.core.tracklist.add(uris=self.uris).get() - - self.events = Queue.Queue() - - def send(cls, event, **kwargs): - self.events.put((cls, event, kwargs)) - - self.patcher = mock.patch('mopidy.listener.send') - self.send_mock = self.patcher.start() - self.send_mock.side_effect = send - - # TODO: Remove this patcher once Mopidy 1.2 has been released. - try: - self.core_patcher = mock.patch('mopidy.listener.send_async') - self.core_send_mock = self.core_patcher.start() - self.core_send_mock.side_effect = send - except AttributeError: - # Mopidy > 1.1 no longer has mopidy.listener.send_async - pass - - self.actor_register = [self.backend, self.core, self.audio] - - def tearDown(self): - pykka.ActorRegistry.stop_all() - mock.patch.stopall() - - def replay_events(self, until=None): - while True: - try: - e = self.events.get(timeout=0.1) - cls, event, kwargs = e - if event == until: - break - for actor in self.actor_register: - if isinstance(actor, pykka.ActorProxy): - if isinstance(actor._actor, cls): - actor.on_event(event, **kwargs).get() - else: - if isinstance(actor, cls): - actor.on_event(event, **kwargs) - except Queue.Empty: - # All events replayed. - break - - -class FrontendTests(BaseTest): - def setUp(self): # noqa: N802 - super(FrontendTests, self).setUp() - self.frontend = frontend.PandoraFrontend.start(conftest.config(), self.core).proxy() - - self.actor_register.append(self.frontend) - - def tearDown(self): # noqa: N802 - super(FrontendTests, self).tearDown() - - def test_add_track_starts_playback(self): - assert self.core.playback.get_state().get() == PlaybackState.STOPPED - self.core.tracklist.clear() - self.frontend.add_track(self.tl_tracks[0].track, auto_play=True).get() - self.replay_events() - - assert self.core.playback.get_state().get() == PlaybackState.PLAYING - assert self.core.playback.get_current_track().get() == self.tl_tracks[0].track - - def test_add_track_trims_tracklist(self): - assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) + pass + + +class TestPandoraFrontend(object): + def test_add_track_starts_playback(self, mopidy): + assert mopidy.core.playback.get_state().get() == PlaybackState.STOPPED + mopidy.core.tracklist.clear() + mopidy.frontend.add_track(mopidy.tl_tracks[0].track, auto_play=True).get() + mopidy.replay_events() + + assert mopidy.core.playback.get_state().get() == PlaybackState.PLAYING + assert mopidy.core.playback.get_current_track().get() == mopidy.tl_tracks[0].track + + def test_add_track_trims_tracklist(self, mopidy): + assert len(mopidy.core.tracklist.get_tl_tracks().get()) == len(mopidy.tl_tracks) # Remove first track so we can add it again - self.core.tracklist.remove({'tlid': [self.tl_tracks[0].tlid]}) + mopidy.core.tracklist.remove({'tlid': [mopidy.tl_tracks[0].tlid]}) - self.frontend.add_track(self.tl_tracks[0].track).get() - tl_tracks = self.core.tracklist.get_tl_tracks().get() + mopidy.frontend.add_track(mopidy.tl_tracks[0].track).get() + tl_tracks = mopidy.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 2 - assert tl_tracks[-1].track == self.tl_tracks[0].track + assert tl_tracks[-1].track == mopidy.tl_tracks[0].track - def test_next_track_available_adds_track_to_playlist(self): - self.core.tracklist.clear() - self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri]) - tl_tracks = self.core.tracklist.get_tl_tracks().get() - self.core.playback.play(tlid=tl_tracks[0].tlid) - self.replay_events(until='track_playback_started') + def test_next_track_available_adds_track_to_playlist(self, mopidy): + mopidy.core.tracklist.clear() + mopidy.core.tracklist.add(uris=[mopidy.tl_tracks[0].track.uri]) + tl_tracks = mopidy.core.tracklist.get_tl_tracks().get() + mopidy.core.playback.play(tlid=tl_tracks[0].tlid) + mopidy.replay_events(until='track_playback_started') - self.frontend.next_track_available(self.tl_tracks[1].track, True).get() - tl_tracks = self.core.tracklist.get_tl_tracks().get() - self.replay_events() + mopidy.frontend.next_track_available(mopidy.tl_tracks[1].track, True).get() + tl_tracks = mopidy.core.tracklist.get_tl_tracks().get() + mopidy.replay_events() - assert tl_tracks[-1].track == self.tl_tracks[1].track - assert self.core.playback.get_current_track().get() == self.tl_tracks[1].track + assert tl_tracks[-1].track == mopidy.tl_tracks[1].track + assert mopidy.core.playback.get_current_track().get() == mopidy.tl_tracks[1].track - def test_next_track_available_forces_stop_if_no_more_tracks(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() + def test_next_track_available_forces_stop_if_no_more_tracks(self, mopidy): + mopidy.core.playback.play(tlid=mopidy.tl_tracks[0].tlid) + mopidy.replay_events() - assert self.core.playback.get_state().get() == PlaybackState.PLAYING - self.frontend.next_track_available(None).get() - assert self.core.playback.get_state().get() == PlaybackState.STOPPED + assert mopidy.core.playback.get_state().get() == PlaybackState.PLAYING + mopidy.frontend.next_track_available(None).get() + assert mopidy.core.playback.get_state().get() == PlaybackState.STOPPED - def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self): + def test_only_execute_for_pandora_does_not_execute_for_non_pandora_uri(self, mopidy): func_mock = mock.PropertyMock() func_mock.__name__ = str('func_mock') func_mock.return_value = True - self.core.playback.play(tlid=self.tl_tracks[3].tlid) - frontend.only_execute_for_pandora_uris(func_mock)(self) + mopidy.core.playback.play(tlid=mopidy.tl_tracks[3].tlid) + frontend.only_execute_for_pandora_uris(func_mock)(mopidy) assert not func_mock.called - def test_only_execute_for_pandora_does_not_execute_for_malformed_pandora_uri(self): + def test_only_execute_for_pandora_does_not_execute_for_malformed_pandora_uri(self, mopidy): func_mock = mock.PropertyMock() func_mock.__name__ = str('func_mock') func_mock.return_value = True @@ -173,63 +85,61 @@ def test_only_execute_for_pandora_does_not_execute_for_malformed_pandora_uri(sel track_mock = mock.Mock(spec=models.Track) track_mock.uri = 'pandora:invalid_uri' tl_track_mock.track = track_mock - frontend.only_execute_for_pandora_uris(func_mock)(self, tl_track=tl_track_mock) + frontend.only_execute_for_pandora_uris(func_mock)(mopidy, tl_track=tl_track_mock) assert not func_mock.called - def test_only_execute_for_pandora_executes_for_pandora_uri(self): + def test_only_execute_for_pandora_executes_for_pandora_uri(self, mopidy): func_mock = mock.PropertyMock() func_mock.__name__ = str('func_mock') func_mock.return_value = True - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events() - frontend.only_execute_for_pandora_uris(func_mock)(self) + mopidy.core.playback.play(tlid=mopidy.tl_tracks[0].tlid).get() + mopidy.replay_events() + frontend.only_execute_for_pandora_uris(func_mock)(mopidy) assert func_mock.called - def test_options_changed_triggers_setup(self): + def test_options_changed_triggers_setup(self, mopidy): with mock.patch.object(PandoraFrontend, 'set_options', mock.Mock()) as set_options_mock: - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.frontend.setup_required = False + mopidy.core.playback.play(tlid=mopidy.tl_tracks[0].tlid).get() + mopidy.frontend.setup_required = False listener.send(CoreListener, 'options_changed') - self.replay_events() + mopidy.replay_events() assert set_options_mock.called - def test_set_options_performs_auto_setup(self): + def test_set_options_performs_auto_setup(self, mopidy): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - assert self.frontend.setup_required.get() - self.core.tracklist.set_repeat(True) - self.core.tracklist.set_consume(False) - self.core.tracklist.set_random(True) - self.core.tracklist.set_single(True) - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events() + assert mopidy.frontend.setup_required.get() + mopidy.core.tracklist.set_repeat(True) + mopidy.core.tracklist.set_consume(False) + mopidy.core.tracklist.set_random(True) + mopidy.core.tracklist.set_single(True) + mopidy.core.playback.play(tlid=mopidy.tl_tracks[0].tlid).get() + mopidy.replay_events() thread_joiner.wait(timeout=1.0) - assert self.core.tracklist.get_repeat().get() is False - assert self.core.tracklist.get_consume().get() is True - assert self.core.tracklist.get_random().get() is False - assert self.core.tracklist.get_single().get() is False - self.replay_events() + assert mopidy.core.tracklist.get_repeat().get() is False + assert mopidy.core.tracklist.get_consume().get() is True + assert mopidy.core.tracklist.get_random().get() is False + assert mopidy.core.tracklist.get_single().get() is False + mopidy.replay_events() - assert not self.frontend.setup_required.get() + assert not mopidy.frontend.setup_required.get() - def test_set_options_skips_auto_setup_if_not_configured(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) + def test_set_options_skips_auto_setup_if_not_configured(self, config, mopidy): + mopidy.core.playback.play(tlid=mopidy.tl_tracks[0].tlid) - config = conftest.config() config['pandora']['auto_setup'] = False - self.frontend.setup_required = True + mopidy.frontend.setup_required = True - self.replay_events() - assert self.frontend.setup_required + mopidy.replay_events() + assert mopidy.frontend.setup_required - def test_set_options_triggered_on_core_events(self): + def test_set_options_triggered_on_core_events(self, mopidy): with mock.patch.object(PandoraFrontend, 'set_options', mock.Mock()) as set_options_mock: - - tl_tracks = self.core.tracklist.get_tl_tracks().get() + tl_tracks = mopidy.core.tracklist.get_tl_tracks().get() core_events = { 'track_playback_started': {'tl_track': tl_tracks[0]}, 'track_playback_ended': {'tl_track': tl_tracks[0], 'time_position': 100}, @@ -237,486 +147,463 @@ def test_set_options_triggered_on_core_events(self): 'track_playback_resumed': {'tl_track': tl_tracks[0], 'time_position': 100}, } - self.core.playback.play(tlid=self.tl_tracks[0].tlid) + mopidy.core.playback.play(tlid=mopidy.tl_tracks[0].tlid) for (event, kwargs) in core_events.items(): - self.frontend.setup_required = True + mopidy.frontend.setup_required = True listener.send(CoreListener, event, **kwargs) - self.replay_events() - self.assertEqual(set_options_mock.called, True, "Setup not done for event '{}'".format(event)) + mopidy.replay_events() + assert set_options_mock.called is True set_options_mock.reset_mock() - def test_skip_limit_exceed_stops_playback(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - assert self.core.playback.get_state().get() == PlaybackState.PLAYING + def test_skip_limit_exceed_stops_playback(self, mopidy): + mopidy.core.playback.play(tlid=mopidy.tl_tracks[0].tlid) + mopidy.replay_events() + assert mopidy.core.playback.get_state().get() == PlaybackState.PLAYING - self.frontend.skip_limit_exceeded().get() - assert self.core.playback.get_state().get() == PlaybackState.STOPPED + mopidy.frontend.skip_limit_exceeded().get() + assert mopidy.core.playback.get_state().get() == PlaybackState.STOPPED - def test_station_change_does_not_trim_currently_playing_track_from_tracklist(self): + def test_station_change_does_not_trim_currently_playing_track_from_tracklist(self, mopidy): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: with mock.patch.object(PandoraFrontend, 'is_station_changed', mock.Mock(return_value=True)): - - self.core.playback.play(tlid=self.tl_tracks[4].tlid) - self.replay_events() + mopidy.core.playback.play(tlid=mopidy.tl_tracks[4].tlid) + mopidy.replay_events() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. - tl_tracks = self.core.tracklist.get_tl_tracks().get() + tl_tracks = mopidy.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 1 - assert tl_tracks[0].track == self.tl_tracks[4].track + assert tl_tracks[0].track == mopidy.tl_tracks[4].track - def test_get_active_uri_order_of_precedence(self): + def test_get_active_uri_order_of_precedence(self, mopidy): # Should be 'track' -> 'tl_track' -> 'current_tl_track' -> 'history[0]' kwargs = {} - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[0].track.uri + mopidy.core.playback.play(tlid=mopidy.tl_tracks[0].tlid) + mopidy.replay_events() + assert frontend.get_active_uri(mopidy.core, **kwargs) == mopidy.tl_tracks[0].track.uri # No easy way to test retrieving from history as it is not possible to set core.playback_current_tl_track # to None - # self.core.playback.next() - # self.core.playback.stop() - # self.replay_events() - # assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[1].track.uri + # mopidy.core.playback.next() + # mopidy.core.playback.stop() + # mopidy.replay_events() + # assert frontend.get_active_uri(mopidy.core, **kwargs) == mopidy.tl_tracks[1].track.uri - kwargs['tl_track'] = self.tl_tracks[2] - assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[2].track.uri + kwargs['tl_track'] = mopidy.tl_tracks[2] + assert frontend.get_active_uri(mopidy.core, **kwargs) == mopidy.tl_tracks[2].track.uri - kwargs = {'track': self.tl_tracks[3].track} - assert frontend.get_active_uri(self.core, **kwargs) == self.tl_tracks[3].track.uri + kwargs = {'track': mopidy.tl_tracks[3].track} + assert frontend.get_active_uri(mopidy.core, **kwargs) == mopidy.tl_tracks[3].track.uri - def test_is_end_of_tracklist_reached(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) + def test_is_end_of_tracklist_reached(self, mopidy): + mopidy.core.playback.play(tlid=mopidy.tl_tracks[0].tlid) - assert not self.frontend.is_end_of_tracklist_reached().get() + assert not mopidy.frontend.is_end_of_tracklist_reached().get() - def test_is_end_of_tracklist_reached_last_track(self): - self.core.playback.play(tlid=self.tl_tracks[-1].tlid) - self.replay_events() + def test_is_end_of_tracklist_reached_last_track(self, mopidy): + mopidy.core.playback.play(tlid=mopidy.tl_tracks[-1].tlid) + mopidy.replay_events() - assert self.frontend.is_end_of_tracklist_reached().get() + assert mopidy.frontend.is_end_of_tracklist_reached().get() - def test_is_end_of_tracklist_reached_no_tracks(self): - self.core.tracklist.clear() + def test_is_end_of_tracklist_reached_no_tracks(self, mopidy): + mopidy.core.tracklist.clear() - assert self.frontend.is_end_of_tracklist_reached().get() + assert mopidy.frontend.is_end_of_tracklist_reached().get() - def test_is_end_of_tracklist_reached_second_last_track(self): - self.core.playback.play(tlid=self.tl_tracks[3].tlid) + def test_is_end_of_tracklist_reached_second_last_track(self, mopidy): + mopidy.core.playback.play(tlid=mopidy.tl_tracks[3].tlid) - assert not self.frontend.is_end_of_tracklist_reached(self.tl_tracks[3].track).get() + assert not mopidy.frontend.is_end_of_tracklist_reached(mopidy.tl_tracks[3].track).get() - def test_is_station_changed(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.next() - self.replay_events() + def test_is_station_changed(self, mopidy): + mopidy.core.playback.play(tlid=mopidy.tl_tracks[0].tlid) + mopidy.replay_events() + mopidy.core.playback.next() + mopidy.replay_events() # Check against track of a different station - assert self.frontend.is_station_changed(self.tl_tracks[4].track).get() + assert mopidy.frontend.is_station_changed(mopidy.tl_tracks[4].track).get() - def test_is_station_changed_no_history(self): - assert not self.frontend.is_station_changed(self.tl_tracks[0].track).get() + def test_is_station_changed_no_history(self, mopidy): + assert not mopidy.frontend.is_station_changed(mopidy.tl_tracks[0].track).get() - def test_changing_track_no_op(self): + def test_changing_track_no_op(self, mopidy): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.core.playback.next() + mopidy.core.playback.play(tlid=mopidy.tl_tracks[0].tlid) + mopidy.core.playback.next() - assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - self.replay_events() + assert len(mopidy.core.tracklist.get_tl_tracks().get()) == len(mopidy.tl_tracks) + mopidy.replay_events() thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. - assert len(self.core.tracklist.get_tl_tracks().get()) == len(self.tl_tracks) - assert self.events.qsize() == 0 + assert len(mopidy.core.tracklist.get_tl_tracks().get()) == len(mopidy.tl_tracks) + assert mopidy.events.qsize() == 0 - def test_changing_track_station_changed(self): + def test_changing_track_station_changed(self, mopidy): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.tracklist.clear() - self.core.tracklist.add(uris=[self.tl_tracks[0].track.uri, self.tl_tracks[4].track.uri]) - tl_tracks = self.core.tracklist.get_tl_tracks().get() + mopidy.core.tracklist.clear() + mopidy.core.tracklist.add(uris=[mopidy.tl_tracks[0].track.uri, mopidy.tl_tracks[4].track.uri]) + tl_tracks = mopidy.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 2 - self.core.playback.play(tlid=tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.next() + mopidy.core.playback.play(tlid=tl_tracks[0].tlid) + mopidy.replay_events() + mopidy.core.playback.seek(100) + mopidy.replay_events() + mopidy.core.playback.next() - self.replay_events(until='end_of_tracklist_reached') + mopidy.replay_events(until='end_of_tracklist_reached') thread_joiner.wait(timeout=1.0) # Wait until threads spawned by frontend have finished. - tl_tracks = self.core.tracklist.get_tl_tracks().get() + tl_tracks = mopidy.core.tracklist.get_tl_tracks().get() assert len(tl_tracks) == 1 # Tracks were trimmed from the tracklist # Only the track recently changed to is left in the tracklist - assert tl_tracks[0].track.uri == self.tl_tracks[4].track.uri + assert tl_tracks[0].track.uri == mopidy.tl_tracks[4].track.uri call = mock.call(PandoraFrontendListener, 'end_of_tracklist_reached', station_id='id_mock_other', auto_play=False) - assert call in self.send_mock.mock_calls + assert call in mopidy.send_mock.mock_calls - def test_track_unplayable_removes_tracks_from_tracklist(self): - tl_tracks = self.core.tracklist.get_tl_tracks().get() + def test_track_unplayable_removes_tracks_from_tracklist(self, mopidy): + tl_tracks = mopidy.core.tracklist.get_tl_tracks().get() unplayable_track = tl_tracks[0] - self.frontend.track_unplayable(unplayable_track.track).get() + mopidy.frontend.track_unplayable(unplayable_track.track).get() - self.assertEqual(unplayable_track in self.core.tracklist.get_tl_tracks().get(), False) + assert unplayable_track not in mopidy.core.tracklist.get_tl_tracks().get() - def test_track_unplayable_triggers_end_of_tracklist_event(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() + def test_track_unplayable_triggers_end_of_tracklist_event(self, mopidy): + mopidy.core.playback.play(tlid=mopidy.tl_tracks[0].tlid) + mopidy.replay_events() - self.frontend.track_unplayable(self.tl_tracks[-1].track).get() + mopidy.frontend.track_unplayable(mopidy.tl_tracks[-1].track).get() call = mock.call(PandoraFrontendListener, 'end_of_tracklist_reached', station_id='id_mock', auto_play=True) - assert call in self.send_mock.mock_calls + assert call in mopidy.send_mock.mock_calls - assert self.core.playback.get_state().get() == PlaybackState.STOPPED + assert mopidy.core.playback.get_state().get() == PlaybackState.STOPPED -class EventMonitorFrontendTests(BaseTest): - def setUp(self): # noqa: N802 - super(EventMonitorFrontendTests, self).setUp() - self.monitor = frontend.EventMonitorFrontend.start(conftest.config(), self.core).proxy() - - self.actor_register.append(self.monitor) - - # Consume mode needs to be enabled to detect 'previous' track changes - self.core.tracklist.set_consume(True) - - def test_delete_station_clears_tracklist_on_finish(self): - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - assert len(self.core.tracklist.get_tl_tracks().get()) > 0 +class TestEventMonitorFrontend(object): + def test_delete_station_clears_tracklist_on_finish(self, mopidy_with_monitor): + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[0].tlid) + mopidy_with_monitor.replay_events() + assert len(mopidy_with_monitor.core.tracklist.get_tl_tracks().get()) > 0 listener.send(PandoraBackendListener, 'event_processed', - track_uri=self.tracks[0].uri, + track_uri=mopidy_with_monitor.tracks[0].uri, pandora_event='delete_station') - self.replay_events() + mopidy_with_monitor.replay_events() - assert len(self.core.tracklist.get_tl_tracks().get()) == 0 + assert len(mopidy_with_monitor.core.tracklist.get_tl_tracks().get()) == 0 - def test_detect_track_change_next(self): + def test_detect_track_change_next(self, mopidy_with_monitor): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events() - self.core.playback.seek(100).get() - self.replay_events() - self.core.playback.next().get() - self.replay_events() + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[0].tlid).get() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.seek(100).get() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.next().get() + mopidy_with_monitor.replay_events() thread_joiner.wait(timeout=1.0) - self.replay_events() + mopidy_with_monitor.replay_events() call = mock.call(EventMonitorListener, 'track_changed_next', - old_uri=self.tl_tracks[0].track.uri, - new_uri=self.tl_tracks[1].track.uri) + old_uri=mopidy_with_monitor.tl_tracks[0].track.uri, + new_uri=mopidy_with_monitor.tl_tracks[1].track.uri) - assert call in self.send_mock.mock_calls + assert call in mopidy_with_monitor.send_mock.mock_calls - def test_detect_track_change_next_from_paused(self): + def test_detect_track_change_next_from_paused(self, mopidy_with_monitor): with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: # Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.pause() - self.replay_events() - self.core.playback.next().get() - self.replay_events(until='track_changed_next') + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[0].tlid) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.seek(100) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.pause() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.next().get() + mopidy_with_monitor.replay_events(until='track_changed_next') thread_joiner.wait(timeout=5.0) call = mock.call(EventMonitorListener, 'track_changed_next', - old_uri=self.tl_tracks[0].track.uri, - new_uri=self.tl_tracks[1].track.uri) + old_uri=mopidy_with_monitor.tl_tracks[0].track.uri, + new_uri=mopidy_with_monitor.tl_tracks[1].track.uri) - assert call in self.send_mock.mock_calls + assert call in mopidy_with_monitor.send_mock.mock_calls - def test_detect_track_change_no_op(self): + def test_detect_track_change_no_op(self, mopidy_with_monitor): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.stop() - self.replay_events() - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events(until='track_playback_started') + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[0].tlid) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.seek(100) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.stop() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[0].tlid).get() + mopidy_with_monitor.replay_events(until='track_playback_started') thread_joiner.wait(timeout=1.0) - assert self.events.empty() + assert mopidy_with_monitor.events.empty() - def test_detect_track_change_previous(self): + def test_detect_track_change_previous(self, mopidy_with_monitor): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.previous().get() - self.replay_events(until='track_changed_previous') + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[0].tlid) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.seek(100) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.previous().get() + mopidy_with_monitor.replay_events(until='track_changed_previous') thread_joiner.wait(timeout=1.0) call = mock.call(EventMonitorListener, 'track_changed_previous', - old_uri=self.tl_tracks[0].track.uri, - new_uri=self.tl_tracks[0].track.uri) + old_uri=mopidy_with_monitor.tl_tracks[0].track.uri, + new_uri=mopidy_with_monitor.tl_tracks[0].track.uri) - assert call in self.send_mock.mock_calls + assert call in mopidy_with_monitor.send_mock.mock_calls - def test_detect_track_change_previous_from_paused(self): + def test_detect_track_change_previous_from_paused(self, mopidy_with_monitor): with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: # Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.pause() - self.replay_events() - self.core.playback.previous().get() - self.replay_events(until='track_changed_previous') + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[0].tlid) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.seek(100) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.pause() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.previous().get() + mopidy_with_monitor.replay_events(until='track_changed_previous') thread_joiner.wait(timeout=5.0) call = mock.call(EventMonitorListener, 'track_changed_previous', - old_uri=self.tl_tracks[0].track.uri, - new_uri=self.tl_tracks[0].track.uri) + old_uri=mopidy_with_monitor.tl_tracks[0].track.uri, + new_uri=mopidy_with_monitor.tl_tracks[0].track.uri) - assert call in self.send_mock.mock_calls + assert call in mopidy_with_monitor.send_mock.mock_calls - def test_events_triggered_on_next_action(self): + def test_events_triggered_on_next_action(self, config, mopidy_with_monitor): with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: # Pause -> Next - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.pause() - self.replay_events() - self.core.playback.next().get() - self.replay_events(until='event_triggered') + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[0].tlid) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.seek(100) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.pause() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.next().get() + mopidy_with_monitor.replay_events(until='event_triggered') thread_joiner.wait(timeout=5.0) call = mock.call(EventMonitorListener, 'event_triggered', - track_uri=self.tl_tracks[0].track.uri, - pandora_event=conftest.config()['pandora']['on_pause_next_click']) + track_uri=mopidy_with_monitor.tl_tracks[0].track.uri, + pandora_event=config['pandora']['on_pause_next_click']) - assert call in self.send_mock.mock_calls + assert call in mopidy_with_monitor.send_mock.mock_calls - def test_events_triggered_on_previous_action(self): + def test_events_triggered_on_previous_action(self, config, mopidy_with_monitor): with conftest.ThreadJoiner(timeout=5.0) as thread_joiner: # Pause -> Previous - self.core.playback.play(tlid=self.tl_tracks[0].tlid).get() - self.replay_events() - self.core.playback.seek(100).get() - self.replay_events() - self.core.playback.pause().get() - self.replay_events() - self.core.playback.previous().get() - self.replay_events(until='event_triggered') + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[0].tlid).get() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.seek(100).get() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.pause().get() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.previous().get() + mopidy_with_monitor.replay_events(until='event_triggered') thread_joiner.wait(timeout=5.0) call = mock.call(EventMonitorListener, 'event_triggered', - track_uri=self.tl_tracks[0].track.uri, - pandora_event=conftest.config()['pandora']['on_pause_previous_click']) + track_uri=mopidy_with_monitor.tl_tracks[0].track.uri, + pandora_event=config['pandora']['on_pause_previous_click']) - assert call in self.send_mock.mock_calls + assert call in mopidy_with_monitor.send_mock.mock_calls - def test_events_triggered_on_resume_action(self): + def test_events_triggered_on_resume_action(self, config, mopidy_with_monitor): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Pause -> Resume - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.pause() - self.replay_events() - self.core.playback.resume().get() - self.replay_events(until='event_triggered') + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[0].tlid) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.seek(100) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.pause() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.resume().get() + mopidy_with_monitor.replay_events(until='event_triggered') thread_joiner.wait(timeout=1.0) call = mock.call(EventMonitorListener, 'event_triggered', - track_uri=self.tl_tracks[0].track.uri, - pandora_event=conftest.config()['pandora']['on_pause_resume_click']) + track_uri=mopidy_with_monitor.tl_tracks[0].track.uri, + pandora_event=config['pandora']['on_pause_resume_click']) - assert call in self.send_mock.mock_calls + assert call in mopidy_with_monitor.send_mock.mock_calls - def test_events_triggered_on_triple_click_action(self): + def test_events_triggered_on_triple_click_action(self, config, mopidy_with_monitor): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: # Pause -> Resume -> Pause - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.pause() - self.replay_events() - self.core.playback.resume() - self.replay_events() - self.core.playback.pause().get() - self.replay_events(until='event_triggered') + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[0].tlid) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.seek(100) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.pause() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.resume() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.pause().get() + mopidy_with_monitor.replay_events(until='event_triggered') thread_joiner.wait(timeout=1.0) call = mock.call(EventMonitorListener, 'event_triggered', - track_uri=self.tl_tracks[0].track.uri, - pandora_event=conftest.config()['pandora']['on_pause_resume_pause_click']) + track_uri=mopidy_with_monitor.tl_tracks[0].track.uri, + pandora_event=config['pandora']['on_pause_resume_pause_click']) - assert call in self.send_mock.mock_calls + assert call in mopidy_with_monitor.send_mock.mock_calls - def test_monitor_ignores_ads(self): + def test_monitor_ignores_ads(self, mopidy_with_monitor): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[2].tlid) - self.core.playback.seek(100) - self.core.playback.pause() - self.replay_events() - self.core.playback.resume().get() - self.replay_events(until='track_playback_resumed') + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[2].tlid) + mopidy_with_monitor.core.playback.seek(100) + mopidy_with_monitor.core.playback.pause() + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.resume().get() + mopidy_with_monitor.replay_events(until='track_playback_resumed') thread_joiner.wait(timeout=1.0) - assert self.events.qsize() == 0 # Check that no events were triggered + assert mopidy_with_monitor.events.qsize() == 0 # Check that no events were triggered - def test_monitor_resumes_playback_after_event_trigger(self): + def test_monitor_resumes_playback_after_event_trigger(self, mopidy_with_monitor): with conftest.ThreadJoiner(timeout=1.0) as thread_joiner: - self.core.playback.play(tlid=self.tl_tracks[0].tlid) - self.replay_events() - self.core.playback.seek(100) - self.replay_events() - self.core.playback.pause() - self.replay_events() - assert self.core.playback.get_state().get() == PlaybackState.PAUSED + mopidy_with_monitor.core.playback.play(tlid=mopidy_with_monitor.tl_tracks[0].tlid) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.seek(100) + mopidy_with_monitor.replay_events() + mopidy_with_monitor.core.playback.pause() + mopidy_with_monitor.replay_events() + assert mopidy_with_monitor.core.playback.get_state().get() == PlaybackState.PAUSED - self.core.playback.next().get() - self.replay_events() + mopidy_with_monitor.core.playback.next().get() + mopidy_with_monitor.replay_events() thread_joiner.wait(timeout=5.0) - assert self.core.playback.get_state().get() == PlaybackState.PLAYING - + assert mopidy_with_monitor.core.playback.get_state().get() == PlaybackState.PLAYING -class EventSequenceTests(unittest.TestCase): - def setUp(self): - self.rq = Queue.PriorityQueue() - self.es = EventSequence('match_mock', ['e1', 'e2', 'e3'], self.rq, 0.1, False) - self.es_strict = EventSequence('match_mock', ['e1', 'e2', 'e3'], self.rq, 0.1, True) - self.es_wait = EventSequence('match_mock', ['e1', 'e2', 'e3'], self.rq, 0.1, False, 'w1') +class TestEventSequence(object): - self.event_sequences = [self.es, self.es_strict, self.es_wait] - - track_mock = mock.Mock(spec=models.Track) - track_mock.uri = 'pandora:track:id_mock:token_mock' - self.tl_track_mock = mock.Mock(spec=models.TlTrack) - self.tl_track_mock.track = track_mock - - def test_events_ignored_if_time_position_is_zero(self): - for es in self.event_sequences: - es.notify('e1', tl_track=self.tl_track_mock) - for es in self.event_sequences: + def test_events_ignored_if_time_position_is_zero(self, event_sequences, tl_track_mock): + for es in event_sequences: + es.notify('e1', tl_track=tl_track_mock) + for es in event_sequences: assert not es.is_monitoring() - def test_start_monitor_on_event(self): - for es in self.event_sequences: - es.notify('e1', tl_track=self.tl_track_mock, time_position=100) - for es in self.event_sequences: + def test_start_monitor_on_event(self, event_sequences, tl_track_mock): + for es in event_sequences: + es.notify('e1', tl_track=tl_track_mock, time_position=100) + for es in event_sequences: assert es.is_monitoring() - def test_start_monitor_handles_no_tl_track(self): - for es in self.event_sequences: - es.notify('e1', tl_track=self.tl_track_mock, time_position=100) - for es in self.event_sequences: + def test_start_monitor_handles_no_tl_track(self, event_sequences, tl_track_mock): + for es in event_sequences: + es.notify('e1', tl_track=tl_track_mock, time_position=100) + for es in event_sequences: assert es.is_monitoring() - def test_stop_monitor_adds_result_to_queue(self): - for es in self.event_sequences[0:2]: - es.notify('e1', tl_track=self.tl_track_mock, time_position=100) + def test_stop_monitor_adds_result_to_queue(self, event_sequences, tl_track_mock, rq): + for es in event_sequences[0:2]: + es.notify('e1', tl_track=tl_track_mock, time_position=100) es.notify('e2', time_position=100) es.notify('e3', time_position=100) - for es in self.event_sequences[0:2]: + for es in event_sequences[0:2]: es.wait(1.0) assert not es.is_monitoring() - assert self.rq.qsize() == 2 + assert rq.qsize() == 2 - def test_stop_monitor_only_waits_for_matched_events(self): - self.es_wait.notify('e1', time_position=100) - self.es_wait.notify('e_not_in_monitored_sequence', time_position=100) + def test_stop_monitor_only_waits_for_matched_events(self, event_sequence_wait, rq): + event_sequence_wait.notify('e1', time_position=100) + event_sequence_wait.notify('e_not_in_monitored_sequence', time_position=100) time.sleep(0.1 * 1.1) - assert not self.es_wait.is_monitoring() - assert self.rq.qsize() == 0 + assert not event_sequence_wait.is_monitoring() + assert rq.qsize() == 0 - def test_stop_monitor_waits_for_event(self): - self.es_wait.notify('e1', time_position=100) - self.es_wait.notify('e2', time_position=100) - self.es_wait.notify('e3', time_position=100) + def test_stop_monitor_waits_for_event(self, event_sequence_wait, rq): + event_sequence_wait.notify('e1', time_position=100) + event_sequence_wait.notify('e2', time_position=100) + event_sequence_wait.notify('e3', time_position=100) - assert self.es_wait.is_monitoring() - assert self.rq.qsize() == 0 + assert event_sequence_wait.is_monitoring() + assert rq.qsize() == 0 - self.es_wait.notify('w1', time_position=100) - self.es_wait.wait(timeout=1.0) + event_sequence_wait.notify('w1', time_position=100) + event_sequence_wait.wait(timeout=1.0) - assert not self.es_wait.is_monitoring() - assert self.rq.qsize() == 1 + assert not event_sequence_wait.is_monitoring() + assert rq.qsize() == 1 - def test_get_stop_monitor_ensures_that_all_events_occurred(self): - self.es.notify('e1', tl_track=self.tl_track_mock, time_position=100) - self.es.notify('e2', time_position=100) - self.es.notify('e3', time_position=100) - assert self.rq.qsize() == 0 + def test_get_stop_monitor_ensures_that_all_events_occurred(self, event_sequence, rq, tl_track_mock): + event_sequence.notify('e1', tl_track=tl_track_mock, time_position=100) + event_sequence.notify('e2', time_position=100) + event_sequence.notify('e3', time_position=100) + assert rq.qsize() == 0 - self.es.wait(timeout=1.0) - self.es.events_seen = ['e1', 'e2', 'e3'] - assert self.rq.qsize() > 0 + event_sequence.wait(timeout=1.0) + event_sequence.events_seen = ['e1', 'e2', 'e3'] + assert rq.qsize() > 0 - def test_get_stop_monitor_strict_ensures_that_events_were_seen_in_order(self): - self.es_strict.notify('e1', tl_track=self.tl_track_mock, time_position=100) - self.es_strict.notify('e3', time_position=100) - self.es_strict.notify('e2', time_position=100) - self.es_strict.wait(timeout=1.0) - assert self.rq.qsize() == 0 + def test_get_stop_monitor_strict_ensures_that_events_were_seen_in_order(self, event_sequence_strict, tl_track_mock, + rq): + event_sequence_strict.notify('e1', tl_track=tl_track_mock, time_position=100) + event_sequence_strict.notify('e3', time_position=100) + event_sequence_strict.notify('e2', time_position=100) + event_sequence_strict.wait(timeout=1.0) + assert rq.qsize() == 0 - self.es_strict.notify('e1', tl_track=self.tl_track_mock, time_position=100) - self.es_strict.notify('e2', time_position=100) - self.es_strict.notify('e3', time_position=100) - self.es_strict.wait(timeout=1.0) - assert self.rq.qsize() > 0 + event_sequence_strict.notify('e1', tl_track=tl_track_mock, time_position=100) + event_sequence_strict.notify('e2', time_position=100) + event_sequence_strict.notify('e3', time_position=100) + event_sequence_strict.wait(timeout=1.0) + assert rq.qsize() > 0 - def test_get_ratio_handles_repeating_events(self): - self.es.target_sequence = ['e1', 'e2', 'e3', 'e1'] - self.es.events_seen = ['e1', 'e2', 'e3', 'e1'] - assert self.es.get_ratio() > 0 + def test_get_ratio_handles_repeating_events(self, event_sequence): + event_sequence.target_sequence = ['e1', 'e2', 'e3', 'e1'] + event_sequence.events_seen = ['e1', 'e2', 'e3', 'e1'] + assert event_sequence.get_ratio() > 0 - def test_get_ratio_enforces_strict_matching(self): - self.es_strict.events_seen = ['e1', 'e2', 'e3', 'e4'] - assert self.es_strict.get_ratio() == 0 + def test_get_ratio_enforces_strict_matching(self, event_sequence_strict): + event_sequence_strict.events_seen = ['e1', 'e2', 'e3', 'e4'] + assert event_sequence_strict.get_ratio() == 0 - self.es_strict.events_seen = ['e1', 'e2', 'e3'] - assert self.es_strict.get_ratio() == 1 + event_sequence_strict.events_seen = ['e1', 'e2', 'e3'] + assert event_sequence_strict.get_ratio() == 1 -class MatchResultTests(unittest.TestCase): +class TestMatchResult(object): def test_match_result_comparison(self): - mr1 = MatchResult(EventMarker('e1', 'u1', 0), 1) mr2 = MatchResult(EventMarker('e1', 'u1', 0), 2) diff --git a/tests/test_library.py b/tests/test_library.py index cccc6b4..91c29a4 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, division, print_function, unicode_literals +import logging import time import mock @@ -16,7 +17,7 @@ from mopidy_pandora.uri import GenreUri, PandoraUri, PlaylistItemUri, StationUri -from . import conftest +from tests import conftest def test_get_images_for_ad_without_images(config, ad_item_mock): @@ -45,7 +46,7 @@ def test_get_images_for_unknown_uri_returns_empty_list(config, caplog): track_uri = PandoraUri.factory('pandora:track:mock_id:mock_token') results = backend.library.get_images([track_uri.uri]) assert len(results[track_uri.uri]) == 0 - assert "Failed to lookup image for Pandora URI '{}'.".format(track_uri.uri) in caplog.text() + assert "Failed to lookup image for Pandora URI '{}'.".format(track_uri.uri) in caplog.text def test_get_images_for_unsupported_uri_type_issues_warning(config, caplog): @@ -54,7 +55,7 @@ def test_get_images_for_unsupported_uri_type_issues_warning(config, caplog): search_uri = PandoraUri.factory('pandora:search:R12345') results = backend.library.get_images([search_uri.uri]) assert len(results[search_uri.uri]) == 0 - assert "No images available for Pandora URIs of type 'search'.".format(search_uri.uri) in caplog.text() + assert "No images available for Pandora URIs of type 'search'.".format(search_uri.uri) in caplog.text def test_get_images_for_track_without_images(config, playlist_item_mock): @@ -101,20 +102,19 @@ def test_get_next_pandora_track_handles_no_more_tracks_available(config, caplog) track = backend.library.get_next_pandora_track('id_token_mock') assert track is None - assert 'Error retrieving next Pandora track.' in caplog.text() + assert 'Error retrieving next Pandora track.' in caplog.text -def test_get_next_pandora_track_renames_advertisements(config, station_mock): - with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): +def test_get_next_pandora_track_renames_advertisements(config, get_station_mock_return_value, playlist_mock): + with mock.patch.object(MopidyAPIClient, 'get_station', return_value=get_station_mock_return_value): with mock.patch.object(Station, 'get_playlist', mock.Mock()) as get_playlist_mock: - backend = conftest.get_backend(config) - playlist = conftest.playlist_mock() + playlist = playlist_mock playlist.pop(0) get_playlist_mock.return_value = iter(playlist) - track = backend.library.get_next_pandora_track(station_mock.id) + track = backend.library.get_next_pandora_track(get_station_mock_return_value.id) assert track.name == 'Advertisement' @@ -130,7 +130,7 @@ def test_lookup_of_invalid_uri_type(config, caplog): backend = conftest.get_backend(config) backend.library.lookup('pandora:genre:mock_name') - assert 'Unexpected type to perform Pandora track lookup: genre.' in caplog.text() + assert 'Unexpected type to perform Pandora track lookup: genre.' in caplog.text def test_lookup_of_ad_uri(config, ad_item_mock): @@ -164,18 +164,17 @@ def test_lookup_of_ad_uri_defaults_missing_values(config, ad_item_mock): assert track.album.name == '(Company name not specified)' -def test_lookup_of_search_uri(config, playlist_item_mock): - with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): +def test_lookup_of_search_uri(config, get_station_mock_return_value, playlist_item_mock, + get_station_list_return_value_mock, station_result_mock): + with mock.patch.object(MopidyAPIClient, 'get_station', return_value=get_station_mock_return_value): with mock.patch.object(APIClient, 'create_station', - mock.Mock(return_value=conftest.station_result_mock()['result'])) as create_station_mock: - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): - + mock.Mock(return_value=station_result_mock['result'])) as create_station_mock: + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): backend = conftest.get_backend(config) - station_mock = mock.Mock(spec=Station) - station_mock.id = conftest.MOCK_STATION_ID - backend.library.pandora_station_cache[station_mock.id] = \ - StationCacheItem(conftest.station_result_mock()['result'], + get_station_mock_return_value.id = conftest.MOCK_STATION_ID + backend.library.pandora_station_cache[get_station_mock_return_value.id] = \ + StationCacheItem(station_result_mock['result'], iter([playlist_item_mock])) track_uri = PlaylistItemUri._from_track(playlist_item_mock) @@ -189,12 +188,12 @@ def test_lookup_of_search_uri(config, playlist_item_mock): assert results[0].uri == track_uri.uri -def test_lookup_of_station_uri(config): - with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): +def test_lookup_of_station_uri(config, get_station_list_return_value_mock, get_station_mock_return_value): + with mock.patch.object(MopidyAPIClient, 'get_station', return_value=get_station_mock_return_value): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): backend = conftest.get_backend(config) - station_uri = PandoraUri.factory(conftest.station_mock()) + station_uri = PandoraUri.factory(get_station_mock_return_value) results = backend.library.lookup(station_uri.uri) assert len(results) == 1 @@ -243,7 +242,7 @@ def test_lookup_of_missing_track(config, playlist_item_mock, caplog): results = backend.library.lookup(track_uri.uri) assert len(results) == 0 - assert "Failed to lookup Pandora URI '{}'.".format(track_uri.uri) in caplog.text() + assert "Failed to lookup Pandora URI '{}'.".format(track_uri.uri) in caplog.text def test_lookup_overrides_album_and_artist_uris(config, playlist_item_mock): @@ -259,9 +258,8 @@ def test_lookup_overrides_album_and_artist_uris(config, playlist_item_mock): assert track.album.uri == track_uri.uri -def test_browse_directory_uri(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): - +def test_browse_directory_uri(config, get_station_list_return_value_mock, station_list_result_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): backend = conftest.get_backend(config) results = backend.library.browse(backend.library.root_directory.uri) @@ -273,22 +271,22 @@ def test_browse_directory_uri(config): assert results[1].type == models.Ref.DIRECTORY assert results[1].name.startswith('QuickMix') assert results[1].uri == StationUri._from_station( - Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][2])).uri + Station.from_json(backend.api, station_list_result_mock['stations'][2])).uri assert results[2].type == models.Ref.DIRECTORY assert results[2].uri == StationUri._from_station( - Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][1])).uri + Station.from_json(backend.api, station_list_result_mock['stations'][1])).uri assert results[3].type == models.Ref.DIRECTORY assert results[3].uri == StationUri._from_station( - Station.from_json(backend.api, conftest.station_list_result_mock()['stations'][0])).uri + Station.from_json(backend.api, station_list_result_mock['stations'][0])).uri -def test_browse_directory_marks_quickmix_stations(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): +def test_browse_directory_marks_quickmix_stations(config, get_station_list_return_value_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): quickmix_station_uri = 'pandora:track:{}:{}'.format(conftest.MOCK_STATION_ID.replace('1', '2'), - conftest.MOCK_STATION_TOKEN.replace('1', '2'),) + conftest.MOCK_STATION_TOKEN.replace('1', '2'), ) backend = conftest.get_backend(config) results = backend.library.browse(backend.library.root_directory.uri) @@ -298,9 +296,8 @@ def test_browse_directory_marks_quickmix_stations(config): assert result.name.endswith('*') -def test_browse_directory_sort_za(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): - +def test_browse_directory_sort_za(config, get_station_list_return_value_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): config['pandora']['sort_order'] = 'A-Z' backend = conftest.get_backend(config) @@ -312,9 +309,8 @@ def test_browse_directory_sort_za(config): assert results[3].name == conftest.MOCK_STATION_NAME + ' 2' + '*' -def test_browse_directory_sort_date(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): - +def test_browse_directory_sort_date(config, get_station_list_return_value_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): config['pandora']['sort_order'] = 'date' backend = conftest.get_backend(config) @@ -326,9 +322,8 @@ def test_browse_directory_sort_date(config): assert results[3].name == conftest.MOCK_STATION_NAME + ' 1' -def test_browse_genres(config): - with mock.patch.object(MopidyAPIClient, 'get_genre_stations', conftest.get_genre_stations_mock): - +def test_browse_genres(config, get_genre_stations_return_value_mock): + with mock.patch.object(MopidyAPIClient, 'get_genre_stations', return_value=get_genre_stations_return_value_mock): backend = conftest.get_backend(config) results = backend.library.browse(backend.library.genre_directory.uri) assert len(results) == 1 @@ -341,8 +336,8 @@ def test_browse_raises_exception_for_unsupported_uri_type(config): backend.library.browse('pandora:invalid_uri') -def test_browse_resets_skip_limits(config): - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): +def test_browse_resets_skip_limits(config, get_station_list_return_value_mock): + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): backend = conftest.get_backend(config) backend.playback._consecutive_track_skips = 5 backend.library.browse(backend.library.root_directory.uri) @@ -350,9 +345,8 @@ def test_browse_resets_skip_limits(config): assert backend.playback._consecutive_track_skips == 0 -def test_browse_genre_category(config): - with mock.patch.object(MopidyAPIClient, 'get_genre_stations', conftest.get_genre_stations_mock): - +def test_browse_genre_category(config, get_genre_stations_return_value_mock): + with mock.patch.object(MopidyAPIClient, 'get_genre_stations', return_value=get_genre_stations_return_value_mock): backend = conftest.get_backend(config) category_uri = 'pandora:genre:Category mock' results = backend.library.browse(category_uri) @@ -360,13 +354,15 @@ def test_browse_genre_category(config): assert results[0].name == 'Genre mock' -def test_browse_genre_station_uri(config, genre_station_mock): - with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): +def test_browse_genre_station_uri(config, get_station_mock_return_value, genre_station_mock, + get_station_list_return_value_mock, station_result_mock, + get_genre_stations_return_value_mock): + with mock.patch.object(MopidyAPIClient, 'get_station', return_value=get_station_mock_return_value): with mock.patch.object(APIClient, 'create_station', - mock.Mock(return_value=conftest.station_result_mock()['result'])) as create_station_mock: - with mock.patch.object(APIClient, 'get_station_list', conftest.get_station_list_mock): - with mock.patch.object(MopidyAPIClient, 'get_genre_stations', conftest.get_genre_stations_mock): - + mock.Mock(return_value=station_result_mock['result'])) as create_station_mock: + with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): + with mock.patch.object(MopidyAPIClient, 'get_genre_stations', + return_value=get_genre_stations_return_value_mock): backend = conftest.get_backend(config) genre_uri = GenreUri._from_station(genre_station_mock) t = time.time() @@ -379,12 +375,11 @@ def test_browse_genre_station_uri(config, genre_station_mock): assert create_station_mock.called -def test_browse_station_uri(config, station_mock): - with mock.patch.object(MopidyAPIClient, 'get_station', conftest.get_station_mock): - with mock.patch.object(Station, 'get_playlist', conftest.get_station_playlist_mock): - +def test_browse_station_uri(config, get_station_mock_return_value, get_station_playlist_mock): + with mock.patch.object(MopidyAPIClient, 'get_station', return_value=get_station_mock_return_value): + with mock.patch.object(Station, 'get_playlist', return_value=get_station_playlist_mock): backend = conftest.get_backend(config) - station_uri = StationUri._from_station(station_mock) + station_uri = StationUri._from_station(get_station_mock_return_value) results = backend.library.browse(station_uri.uri) # Station should just contain the first track to be played. @@ -473,16 +468,16 @@ def test_refresh_station_directory_not_in_cache_handles_key_error(config): def test_search_returns_empty_result_for_unsupported_queries(config, caplog): + caplog.set_level(logging.INFO) backend = conftest.get_backend(config) assert len(backend.library.search({'album': ['album_name_mock']})) is 0 - assert 'Unsupported Pandora search query:' in caplog.text() - + assert 'Unsupported Pandora search query:' in caplog.text -def test_search(config): - with mock.patch.object(APIClient, 'search', conftest.search_mock): +def test_search(config, search_return_value_mock): + with mock.patch.object(APIClient, 'search', return_value=search_return_value_mock): backend = conftest.get_backend(config) - search_result = backend.library.search({'any': 'search_mock'}) + search_result = backend.library.search({'any': 'search_return_value_mock'}) assert len(search_result.tracks) is 2 assert search_result.tracks[0].uri == 'pandora:search:G123' diff --git a/tests/test_playback.py b/tests/test_playback.py index 543c637..5713a7a 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -56,7 +56,7 @@ def test_change_track_enforces_skip_limit_if_no_track_available(provider, playli assert provider._trigger_skip_limit_exceeded.called assert 'Maximum track skip limit ({:d}) exceeded.'.format( - PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() + PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text def test_change_track_enforces_skip_limit_if_no_audio_url(provider, playlist_item_mock, caplog): @@ -79,7 +79,7 @@ def test_change_track_enforces_skip_limit_if_no_audio_url(provider, playlist_ite assert provider._trigger_skip_limit_exceeded.called assert 'Maximum track skip limit ({:d}) exceeded.'.format( - PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() + PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text def test_change_track_enforces_skip_limit_on_request_exceptions(provider, playlist_item_mock, caplog): @@ -102,7 +102,7 @@ def test_change_track_enforces_skip_limit_on_request_exceptions(provider, playli assert provider._trigger_skip_limit_exceeded.called assert 'Maximum track skip limit ({:d}) exceeded.'.format( - PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text() + PandoraPlaybackProvider.SKIP_LIMIT) in caplog.text def test_change_track_fetches_next_track_if_unplayable(provider, playlist_item_mock, caplog): @@ -114,17 +114,17 @@ def test_change_track_fetches_next_track_if_unplayable(provider, playlist_item_m assert provider.change_track(track) is False assert provider._trigger_track_unplayable.called - assert 'Error changing Pandora track' in caplog.text() + assert 'Error changing Pandora track' in caplog.text -def test_change_track_fetches_next_track_if_station_uri(provider, caplog): - station = PandoraUri.factory(conftest.station_mock()) +def test_change_track_fetches_next_track_if_station_uri(provider, get_station_mock_return_value, caplog): + station = PandoraUri.factory(get_station_mock_return_value) provider.backend._trigger_next_track_available = mock.PropertyMock() assert provider.change_track(station) is False assert 'Cannot play Pandora stations directly. Retrieving tracks for station with ID: {}...'.format( - station.station_id) in caplog.text() + station.station_id) in caplog.text assert provider.backend._trigger_next_track_available.called @@ -142,7 +142,7 @@ def test_change_track_skips_if_track_not_available_in_buffer(provider, playlist_ provider.backend.prepare_next_track = mock.PropertyMock() assert provider.change_track(track) is False - assert "Error changing Pandora track: failed to lookup '{}'.".format(track.uri) in caplog.text() + assert "Error changing Pandora track: failed to lookup '{}'.".format(track.uri) in caplog.text def test_change_track_resets_skips_on_success(provider, playlist_item_mock): diff --git a/tests/test_uri.py b/tests/test_uri.py index 729a4d4..e8f880e 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -1,12 +1,11 @@ # coding=utf-8 from __future__ import absolute_import, division, print_function, unicode_literals -from mock import mock - from mopidy import models from pandora.models.pandora import GenreStation, Station +import mock import pytest from mopidy_pandora.uri import AdItemUri, GenreStationUri, GenreUri, PandoraUri, PlaylistItemUri, SearchUri,\ @@ -179,8 +178,8 @@ def test_search_uri_is_genre_search(): assert obj.is_genre_search -def test_station_uri_from_station(station_mock): - station_uri = StationUri._from_station(station_mock) +def test_station_uri_from_station(get_station_mock_return_value): + station_uri = StationUri._from_station(get_station_mock_return_value) assert station_uri.uri == '{}:{}:{}:{}'.format(PandoraUri.SCHEME, station_uri.encode(conftest.MOCK_STATION_TYPE), @@ -194,8 +193,8 @@ def test_station_uri_from_station_unsupported_type(playlist_result_mock): PandoraUri._from_station(playlist_result_mock) -def test_station_uri_parse(station_mock): - station_uri = StationUri._from_station(station_mock) +def test_station_uri_parse(get_station_mock_return_value): + station_uri = StationUri._from_station(get_station_mock_return_value) obj = PandoraUri._from_uri(station_uri.uri) diff --git a/tests/test_utils.py b/tests/test_utils.py index 12a4446..e39e086 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -9,7 +9,7 @@ import logging -from mock import mock +import mock import requests @@ -91,16 +91,18 @@ def test_do_rpc_increments_id(): def test_run_async(caplog): + caplog.set_level(logging.INFO) t = async_func('test_1_async') t.join() - assert 'test_1_async' in caplog.text() + assert 'test_1_async' in caplog.text def test_run_async_queue(caplog): + caplog.set_level(logging.INFO) q = queue.Queue() async_func('test_2_async', queue=q) assert q.get() == 'test_value' - assert 'test_2_async' in caplog.text() + assert 'test_2_async' in caplog.text @run_async diff --git a/tox.ini b/tox.ini index 9263944..11de08b 100644 --- a/tox.ini +++ b/tox.ini @@ -3,12 +3,14 @@ envlist = py27, flake8 [testenv] sitepackages = true -whitelist_externals=py.test +whitelist_externals=pytest deps = - -rdev-requirements.txt + mopidy + -rrequirements-dev.txt + install_command = pip install --pre {opts} {packages} commands = - py.test \ + pytest \ --basetemp={envtmpdir} \ --junit-xml=xunit-{envname}.xml \ --cov=mopidy_pandora --cov-report=term-missing \ @@ -18,7 +20,6 @@ commands = sitepackages = false deps = flake8 -# flake8-import-order TODO: broken in flake8 v3.0: https://github.com/PyCQA/flake8-import-order/pull/75 pep8-naming skip_install = true commands = flake8 --show-source --statistics --max-line-length 120 mopidy_pandora/ setup.py tests/ From 78fa5ba31a84aefd8ce70e456e115104a4ec64fa Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sat, 29 Dec 2018 09:25:01 +0200 Subject: [PATCH 299/311] Update Travis config to use new xenial build environment. --- .travis.yml | 7 +++++-- requirements-dev.txt => dev-requirements.txt | 0 tox.ini | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) rename requirements-dev.txt => dev-requirements.txt (100%) diff --git a/.travis.yml b/.travis.yml index f4320ee..b2a1d0a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,13 @@ sudo: required -dist: trusty +dist: xenial language: python python: - - "2.7_with_system_site_packages" + - "2.7" + +virtualenv: + system_site_packages: true addons: apt: diff --git a/requirements-dev.txt b/dev-requirements.txt similarity index 100% rename from requirements-dev.txt rename to dev-requirements.txt diff --git a/tox.ini b/tox.ini index 11de08b..1110536 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,7 @@ sitepackages = true whitelist_externals=pytest deps = mopidy - -rrequirements-dev.txt + -rdev-requirements.txt install_command = pip install --pre {opts} {packages} commands = From 00e971b742283951fdb5b7e6c61c76770e300f70 Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sat, 29 Dec 2018 09:38:50 +0200 Subject: [PATCH 300/311] Troubleshoot Travis build. --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index b2a1d0a..074cd94 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,6 +26,7 @@ before_install: install: - "pip install tox" + - "pip install -U pyopenssl" script: - "tox -e $TOX_ENV" From d39ceab5e3ba605f6dddd92891204caa107a9dd7 Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sat, 29 Dec 2018 09:45:49 +0200 Subject: [PATCH 301/311] Troubleshoot Travis build. --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 074cd94..6cc3ab4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,6 +23,7 @@ env: before_install: - "sudo apt-get update -qq" - "sudo apt-get install -y gir1.2-gst-plugins-base-1.0 gir1.2-gstreamer-1.0 graphviz-dev gstreamer1.0-plugins-good gstreamer1.0-plugins-bad python-gst-1.0" + - "sudo apt-get --auto-remove -y remove python-openssl" install: - "pip install tox" From a77b3ed66cc276cf055e389d47698b1e40090dc7 Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sat, 29 Dec 2018 10:00:31 +0200 Subject: [PATCH 302/311] Update docs. --- CHANGES.rst | 11 +++++++++++ README.rst | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 893d388..b07bf5d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,17 @@ Changelog ========= +v0.4.1 (UNRELEASED) +------------------- + +- Update package dependencies used during development to the latest versions. +- Migrate all tests to pytest. Resolve pytest 4.0 deprecation errors. +- Switch to Xenial Travis build environment. + +**Fixes** + +- Implement ``LRUCache``s' ``__missing__``. (Fixes: `#66 `_). + v0.4.0 (Sep 20, 2017) --------------------- diff --git a/README.rst b/README.rst index c977bdc..c953ea9 100644 --- a/README.rst +++ b/README.rst @@ -52,7 +52,9 @@ Dependencies - Requires a Pandora user account. Users with a Pandora Plus subscription will have access to the higher quality 192 Kbps audio stream. Free accounts will play advertisements. -- ``pydora`` >= 1.7.3. The Python Pandora API Client. The package is available as ``pydora`` on PyPI. +- ``pydora`` >= 1.7.3. The Python Pandora API Client. **Pre-requisites**: As of 1.11.0 pydora requires `cryptography `_. + Since Mopidy is still stuck on legacy Python (2.7), there may be some native dependencies on openssl that you will + need to install beforehand. See the `cryptography installation docs `_ for details. - ``cachetools`` >= 1.0. Extensible memoizing collections and decorators. The package is available as ``cachetools`` on PyPI. From 3115674b02d0f14ad8a99608550d59e300cdba04 Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sat, 29 Dec 2018 10:15:03 +0200 Subject: [PATCH 303/311] Remove remaining unittest classes. --- tests/conftest.py | 22 +++++++++- tests/test_extension.py | 4 +- tests/test_frontend.py | 6 --- tests/test_listener.py | 92 +++++++++++++++++------------------------ 4 files changed, 60 insertions(+), 64 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 17bedd5..dae8ea4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,7 @@ import requests -from mopidy_pandora import backend, frontend +from mopidy_pandora import backend, frontend, listener from mopidy_pandora.frontend import EventSequence from tests.dummy_mopidy import DummyMopidyInstance @@ -286,6 +286,26 @@ def playlist_item_mock(config, playlist_result_mock): config).api, playlist_result_mock['result']['items'][0]) +@pytest.fixture +def event_monitor_listener(): + return listener.EventMonitorListener() + + +@pytest.fixture +def frontend_listener(): + return listener.PandoraFrontendListener() + + +@pytest.fixture +def backend_listener(): + return listener.PandoraBackendListener() + + +@pytest.fixture +def playback_listener(): + return listener.PandoraPlaybackListener() + + @pytest.fixture def ad_item_mock(config, ad_metadata_result_mock): ad_item = AdItem.from_json(get_backend( diff --git a/tests/test_extension.py b/tests/test_extension.py index 0aa2f3d..855cc55 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,7 +1,5 @@ from __future__ import absolute_import, division, print_function, unicode_literals -import unittest - import mock from mopidy_pandora import Extension @@ -10,7 +8,7 @@ from mopidy_pandora import frontend as frontend_lib -class ExtensionTests(unittest.TestCase): +class TestExtension(object): def test_get_default_config(self): ext = Extension() diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 679778a..d986cc5 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -2,8 +2,6 @@ import time -import unittest - import mock from mopidy import listener, models @@ -19,10 +17,6 @@ from tests import conftest -class BaseTest(unittest.TestCase): - pass - - class TestPandoraFrontend(object): def test_add_track_starts_playback(self, mopidy): assert mopidy.core.playback.get_state().get() == PlaybackState.STOPPED diff --git a/tests/test_listener.py b/tests/test_listener.py index f1d7982..9ce4b6b 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -1,96 +1,80 @@ from __future__ import absolute_import, unicode_literals -import unittest - import mock from mopidy import models -import mopidy_pandora.listener as listener - - -class EventMonitorListenerTests(unittest.TestCase): - def setUp(self): # noqa: N802 - self.listener = listener.EventMonitorListener() +class TestEventMonitorListener(object): - def test_on_event_forwards_to_specific_handler(self): - self.listener.event_triggered = mock.Mock() + def test_on_event_forwards_to_specific_handler(self, event_monitor_listener): + event_monitor_listener.event_triggered = mock.Mock() - self.listener.on_event('event_triggered', track_uri='pandora:track:id_mock:token_mock', + event_monitor_listener.on_event('event_triggered', track_uri='pandora:track:id_mock:token_mock', pandora_event='event_mock') - self.listener.event_triggered.assert_called_with(track_uri='pandora:track:id_mock:token_mock', + event_monitor_listener.event_triggered.assert_called_with(track_uri='pandora:track:id_mock:token_mock', pandora_event='event_mock') - def test_listener_has_default_impl_for_event_triggered(self): - self.listener.event_triggered('pandora:track:id_mock:token_mock', 'event_mock') + def test_listener_has_default_impl_for_event_triggered(self, event_monitor_listener): + event_monitor_listener.event_triggered('pandora:track:id_mock:token_mock', 'event_mock') - def test_listener_has_default_impl_for_track_changed_previous(self): - self.listener.track_changed_previous(old_uri='pandora:track:id_mock:token_mock2', + def test_listener_has_default_impl_for_track_changed_previous(self, event_monitor_listener): + event_monitor_listener.track_changed_previous(old_uri='pandora:track:id_mock:token_mock2', new_uri='pandora:track:id_mock:token_mock1') - def test_listener_has_default_impl_for_track_changed_next(self): - self.listener.track_changed_next(old_uri='pandora:track:id_mock:token_mock1', + def test_listener_has_default_impl_for_track_changed_next(self, event_monitor_listener): + event_monitor_listener.track_changed_next(old_uri='pandora:track:id_mock:token_mock1', new_uri='pandora:track:id_mock:token_mock2') -class PandoraFrontendListenerTests(unittest.TestCase): +class TestPandoraFrontendListener(object): - def setUp(self): # noqa: N802 - self.listener = listener.PandoraFrontendListener() + def test_on_event_forwards_to_specific_handler(self, frontend_listener): + frontend_listener.end_of_tracklist_reached = mock.Mock() - def test_on_event_forwards_to_specific_handler(self): - self.listener.end_of_tracklist_reached = mock.Mock() - - self.listener.on_event( + frontend_listener.on_event( 'end_of_tracklist_reached', station_id='id_mock', auto_play=False) - self.listener.end_of_tracklist_reached.assert_called_with(station_id='id_mock', auto_play=False) - - def test_listener_has_default_impl_for_end_of_tracklist_reached(self): - self.listener.end_of_tracklist_reached(station_id='id_mock', auto_play=False) + frontend_listener.end_of_tracklist_reached.assert_called_with(station_id='id_mock', auto_play=False) + def test_listener_has_default_impl_for_end_of_tracklist_reached(self, frontend_listener): + frontend_listener.end_of_tracklist_reached(station_id='id_mock', auto_play=False) -class PandoraBackendListenerTests(unittest.TestCase): - def setUp(self): # noqa: N802 - self.listener = listener.PandoraBackendListener() +class TestPandoraBackendListener(object): - def test_on_event_forwards_to_specific_handler(self): - self.listener.next_track_available = mock.Mock() + def test_on_event_forwards_to_specific_handler(self, backend_listener): + backend_listener.next_track_available = mock.Mock() - self.listener.on_event( + backend_listener.on_event( 'next_track_available', track=models.Ref(name='name_mock'), auto_play=False) - self.listener.next_track_available.assert_called_with(track=models.Ref(name='name_mock'), auto_play=False) + backend_listener.next_track_available.assert_called_with(track=models.Ref(name='name_mock'), auto_play=False) - def test_listener_has_default_impl_for_next_track_available(self): - self.listener.next_track_available(track=models.Ref(name='name_mock'), auto_play=False) + def test_listener_has_default_impl_for_next_track_available(self, backend_listener): + backend_listener.next_track_available(track=models.Ref(name='name_mock'), auto_play=False) - def test_listener_has_default_impl_for_event_processed(self): - self.listener.event_processed(track_uri='pandora:track:id_mock:token_mock', + def test_listener_has_default_impl_for_event_processed(self, backend_listener): + backend_listener.event_processed(track_uri='pandora:track:id_mock:token_mock', pandora_event='event_mock') -class PandoraPlaybackListenerTests(unittest.TestCase): - - def setUp(self): # noqa: N802 - self.listener = listener.PandoraPlaybackListener() +class TestPandoraPlaybackListener(object): - def test_on_event_forwards_to_specific_handler(self): - self.listener.track_changing = mock.Mock() + def test_on_event_forwards_to_specific_handler(self, playback_listener): + playback_listener.track_changing = mock.Mock() - self.listener.on_event( + playback_listener.on_event( 'track_changing', track=models.Ref(name='name_mock')) - self.listener.track_changing.assert_called_with(track=models.Ref(name='name_mock')) + playback_listener.track_changing.assert_called_with(track=models.Ref(name='name_mock')) - def test_listener_has_default_impl_for_track_changing(self): - self.listener.track_changing(track=models.Ref(name='name_mock')) + def test_listener_has_default_impl_for_track_changing(self, playback_listener): + playback_listener.track_changing(track=models.Ref(name='name_mock')) - def test_listener_has_default_impl_for_track_unplayable(self): - self.listener.track_unplayable(track=models.Ref(name='name_mock')) + def test_listener_has_default_impl_for_track_unplayable(self, playback_listener): + playback_listener.track_unplayable(track=models.Ref(name='name_mock')) - def test_listener_has_default_impl_for_skip_limit_exceeded(self): - self.listener.skip_limit_exceeded() + def test_listener_has_default_impl_for_skip_limit_exceeded(self, playback_listener): + playback_listener.skip_limit_exceeded() From 350aea83e36aab8ae8770c007235653e8a6f82ae Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sat, 29 Dec 2018 10:21:53 +0200 Subject: [PATCH 304/311] Fix pep8 violations. --- tests/test_listener.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_listener.py b/tests/test_listener.py index 9ce4b6b..9015584 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -11,21 +11,21 @@ def test_on_event_forwards_to_specific_handler(self, event_monitor_listener): event_monitor_listener.event_triggered = mock.Mock() event_monitor_listener.on_event('event_triggered', track_uri='pandora:track:id_mock:token_mock', - pandora_event='event_mock') + pandora_event='event_mock') event_monitor_listener.event_triggered.assert_called_with(track_uri='pandora:track:id_mock:token_mock', - pandora_event='event_mock') + pandora_event='event_mock') def test_listener_has_default_impl_for_event_triggered(self, event_monitor_listener): event_monitor_listener.event_triggered('pandora:track:id_mock:token_mock', 'event_mock') def test_listener_has_default_impl_for_track_changed_previous(self, event_monitor_listener): event_monitor_listener.track_changed_previous(old_uri='pandora:track:id_mock:token_mock2', - new_uri='pandora:track:id_mock:token_mock1') + new_uri='pandora:track:id_mock:token_mock1') def test_listener_has_default_impl_for_track_changed_next(self, event_monitor_listener): event_monitor_listener.track_changed_next(old_uri='pandora:track:id_mock:token_mock1', - new_uri='pandora:track:id_mock:token_mock2') + new_uri='pandora:track:id_mock:token_mock2') class TestPandoraFrontendListener(object): @@ -57,7 +57,7 @@ def test_listener_has_default_impl_for_next_track_available(self, backend_listen def test_listener_has_default_impl_for_event_processed(self, backend_listener): backend_listener.event_processed(track_uri='pandora:track:id_mock:token_mock', - pandora_event='event_mock') + pandora_event='event_mock') class TestPandoraPlaybackListener(object): From 4860c77ad0bd0385a1f02dbce38a7792603b78a6 Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sat, 29 Dec 2018 12:14:58 +0200 Subject: [PATCH 305/311] Align with pydora Station API changes introduced in 1.12.0. Fixes #65. --- CHANGES.rst | 3 ++- mopidy_pandora/library.py | 5 +---- setup.py | 2 +- tests/test_client.py | 7 +++++-- tests/test_library.py | 5 ++++- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index b07bf5d..f29dada 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -10,7 +10,8 @@ v0.4.1 (UNRELEASED) **Fixes** -- Implement ``LRUCache``s' ``__missing__``. (Fixes: `#66 `_). +- Use the updated Station API introduced in pydora 1.12.0. (Fixes: `#65 `_). +- Implement ``LRUCache``'s ``__missing__``. (Fixes: `#66 `_). v0.4.0 (Sep 20, 2017) --------------------- diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 7a1863b..601ca40 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -10,8 +10,6 @@ from mopidy import backend, models -from pandora.models.pandora import Station - from pydora.utils import iterate_forever from mopidy_pandora.uri import AdItemUri, GenreUri, PandoraUri, SearchUri, StationUri, TrackUri # noqa I101 @@ -181,8 +179,7 @@ def _browse_tracks(self, uri): return [self.get_next_pandora_track(pandora_uri.station_id)] def _create_station_for_token(self, token): - json_result = self.backend.api.create_station(search_token=token) - new_station = Station.from_json(self.backend.api, json_result) + new_station = self.backend.api.create_station(search_token=token) self.refresh() return PandoraUri.factory(new_station) diff --git a/setup.py b/setup.py index c0413a2..08f3f13 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,7 @@ def run_tests(self): 'cachetools >= 1.0.0', 'Mopidy >= 1.1.2', 'Pykka >= 1.1', - 'pydora >= 1.7.3', + 'pydora >= 1.12.0', 'requests >= 2.5.0' ], tests_require=['tox'], diff --git a/tests/test_client.py b/tests/test_client.py index b69fd33..8c283dc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,7 +5,7 @@ import mock from pandora import APIClient -from pandora.models.pandora import GenreStationList, StationList +from pandora.models.pandora import GenreStationList, StationList, Station import pytest @@ -208,7 +208,10 @@ def test_create_genre_station_invalidates_cache(config, get_station_list_return_ return_value=get_genre_stations_return_value_mock): backend = conftest.get_backend(config) - backend.api.create_station = mock.PropertyMock(return_value=station_result_mock['result']) + backend.api.create_station = mock.PropertyMock(return_value=Station.from_json( + mock.MagicMock(MopidyAPIClient), + station_result_mock['result']) + ) t = time.time() backend.api.station_list_cache[t] = mock.Mock(spec=StationList) assert t in list(backend.api.station_list_cache) diff --git a/tests/test_library.py b/tests/test_library.py index 91c29a4..3419f85 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -168,7 +168,10 @@ def test_lookup_of_search_uri(config, get_station_mock_return_value, playlist_it get_station_list_return_value_mock, station_result_mock): with mock.patch.object(MopidyAPIClient, 'get_station', return_value=get_station_mock_return_value): with mock.patch.object(APIClient, 'create_station', - mock.Mock(return_value=station_result_mock['result'])) as create_station_mock: + mock.Mock(return_value=Station.from_json( + mock.MagicMock(MopidyAPIClient), + station_result_mock['result'] + ))) as create_station_mock: with mock.patch.object(APIClient, 'get_station_list', return_value=get_station_list_return_value_mock): backend = conftest.get_backend(config) From 5fcbcf5788539de00442d1790e0c35a12aac844d Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sat, 29 Dec 2018 12:24:45 +0200 Subject: [PATCH 306/311] Reformat code using Black. --- mopidy_pandora/__init__.py | 118 ++++++++++-------- mopidy_pandora/backend.py | 71 +++++++---- mopidy_pandora/client.py | 62 ++++++---- mopidy_pandora/frontend.py | 243 ++++++++++++++++++++++++------------- mopidy_pandora/library.py | 179 +++++++++++++++++---------- mopidy_pandora/playback.py | 36 ++++-- mopidy_pandora/uri.py | 90 +++++++------- mopidy_pandora/utils.py | 34 +++--- 8 files changed, 518 insertions(+), 315 deletions(-) diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index e0b33f5..d567a9d 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -4,68 +4,90 @@ from mopidy import config, ext -__version__ = '0.4.0' +__version__ = "0.4.0" class Extension(ext.Extension): - dist_name = 'Mopidy-Pandora' - ext_name = 'pandora' + 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') + 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']) + 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) + + registry.add("backend", PandoraBackend) + registry.add("frontend", PandoraFrontend) + registry.add("frontend", EventMonitorFrontend) diff --git a/mopidy_pandora/backend.py b/mopidy_pandora/backend.py index 995c0a5..4cb90a1 100644 --- a/mopidy_pandora/backend.py +++ b/mopidy_pandora/backend.py @@ -19,37 +19,47 @@ logger = logging.getLogger(__name__) -class PandoraBackend(pykka.ThreadingActor, backend.Backend, core.CoreListener, listener.PandoraFrontendListener, - listener.EventMonitorListener): - +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'] + 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') + "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.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']) + 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) + 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) @@ -57,17 +67,24 @@ def event_triggered(self, 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)) + 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)) + 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)) + logger.exception("Error calling Pandora event: {}.".format(pandora_event)) return False def thumbs_up(self, track_uri): @@ -92,7 +109,11 @@ def delete_station(self, track_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) + 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) + listener.PandoraBackendListener.send( + "event_processed", track_uri=track_uri, pandora_event=pandora_event + ) diff --git a/mopidy_pandora/client.py b/mopidy_pandora/client.py index d2e6e10..5147e83 100644 --- a/mopidy_pandora/client.py +++ b/mopidy_pandora/client.py @@ -6,7 +6,12 @@ from cachetools import TTLCache import pandora -from pandora.clientbuilder import APITransport, DEFAULT_API_HOST, Encryptor, SettingsDictBuilder +from pandora.clientbuilder import ( + APITransport, + DEFAULT_API_HOST, + Encryptor, + SettingsDictBuilder, +) import requests @@ -15,22 +20,23 @@ class MopidySettingsDictBuilder(SettingsDictBuilder): - def build_from_settings_dict(self, settings): - enc = Encryptor(settings['DECRYPTION_KEY'], - settings['ENCRYPTION_KEY']) + enc = Encryptor(settings["DECRYPTION_KEY"], settings["ENCRYPTION_KEY"]) - trans = APITransport(enc, - settings.get('API_HOST', DEFAULT_API_HOST), - settings.get('PROXY', None)) + 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) + 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) + return self.client_class( + settings["CACHE_TTL"], + trans, + settings["PARTNER_USER"], + settings["PARTNER_PASSWORD"], + settings["DEVICE"], + quality, + ) class MopidyAPIClient(pandora.APIClient): @@ -39,11 +45,19 @@ class MopidyAPIClient(pandora.APIClient): 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): + 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) + 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) @@ -51,14 +65,15 @@ def __init__(self, cache_ttl, transport, partner_user, partner_password, device, 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())): + 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.') + logger.exception("Error retrieving Pandora station list.") station_list = [] try: @@ -77,14 +92,15 @@ def get_station(self, 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())): + 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.') + logger.exception("Error retrieving Pandora genre stations.") return genre_stations try: diff --git a/mopidy_pandora/frontend.py b/mopidy_pandora/frontend.py index 2e8294a..d76431b 100644 --- a/mopidy_pandora/frontend.py +++ b/mopidy_pandora/frontend.py @@ -64,11 +64,11 @@ def get_active_uri(core, *args, **kwargs): :return: the URI of the active Mopidy track, if it could be determined, or None otherwise. """ uri = None - track = kwargs.get('track', 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()) + tl_track = kwargs.get("tl_track", core.playback.get_current_tl_track().get()) if tl_track: uri = tl_track.track.uri if not uri: @@ -78,17 +78,18 @@ def get_active_uri(core, *args, **kwargs): return uri -class PandoraFrontend(pykka.ThreadingActor, - core.CoreListener, - listener.PandoraBackendListener, - listener.PandoraPlaybackListener, - listener.EventMonitorListener): - +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.config = config["pandora"] + self.auto_setup = self.config.get("auto_setup") self.setup_required = True self.core = core @@ -146,7 +147,7 @@ def is_end_of_tracklist_reached(self, track=None): if length <= 1: return True if track: - tl_track = self.core.tracklist.filter({'uri': [track.uri]}).get()[0] + 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() @@ -155,8 +156,13 @@ def is_end_of_tracklist_reached(self, track=None): 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: + 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 @@ -171,22 +177,24 @@ def update_tracklist(self, 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) + 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._trigger_end_of_tracklist_reached( + PandoraUri.factory(track).station_id, auto_play=True + ) - self.core.tracklist.remove({'uri': [track.uri]}) + 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.') + logger.warning("No more Pandora tracks available to play.") self.core.playback.stop() def skip_limit_exceeded(self): @@ -205,18 +213,24 @@ def _trim_tracklist(self, keep_only=None, maxsize=2): 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}) + 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)]} + { + "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) + listener.PandoraFrontendListener.send( + "end_of_tracklist_reached", station_id=station_id, auto_play=auto_play + ) @total_ordering @@ -234,17 +248,18 @@ def __lt__(self, other): return self.ratio < other.ratio -EventMarker = namedtuple('EventMarker', 'event, uri, time') +EventMarker = namedtuple("EventMarker", "event, uri, time") -class EventMonitorFrontend(pykka.ThreadingActor, - core.CoreListener, - audio.AudioListener, - listener.PandoraFrontendListener, - listener.PandoraBackendListener, - listener.PandoraPlaybackListener, - listener.EventMonitorListener): - +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 @@ -253,40 +268,65 @@ def __init__(self, config, core): self._track_changed_marker = None self._monitor_lock = threading.Lock() - self.config = config['pandora'] - self.is_active = self.config['event_support_enabled'] + 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']) + 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.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) @@ -301,7 +341,9 @@ def on_event(self, 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.notify_all( + event, uri=get_active_uri(self.core, event, **kwargs), **kwargs + ) self.monitor_sequences() else: self._monitor_lock.release() @@ -315,17 +357,24 @@ def notify_all(self, event, **kwargs): 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)) + 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) + 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._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 @@ -341,8 +390,10 @@ def monitor_sequences(self): self.sequence_match_results.task_done() if match and match.ratio == 1.0: - if match.marker.uri and isinstance(PandoraUri.factory(match.marker.uri), AdItemUri): - logger.info('Ignoring doubleclick event for Pandora advertisement...') + if match.marker.uri and isinstance( + PandoraUri.factory(match.marker.uri), AdItemUri + ): + logger.info("Ignoring doubleclick event for Pandora advertisement...") else: self._trigger_event_triggered(match.marker.event, match.marker.uri) # Resume playback... @@ -352,7 +403,7 @@ def monitor_sequences(self): self._monitor_lock.release() def event_processed(self, track_uri, pandora_event): - if pandora_event == 'delete_station': + if pandora_event == "delete_station": self.core.tracklist.clear() def _get_track_change_direction(self, track_marker): @@ -363,30 +414,42 @@ def _get_track_change_direction(self, track_marker): 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: + 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' + return "track_changed_previous" else: # Switched to another track, user clicked 'next'. - return 'track_changed_next' + return "track_changed_next" def _trigger_event_triggered(self, event, uri): - (listener.EventMonitorListener.send('event_triggered', - track_uri=uri, - pandora_event=event)) + ( + 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)) + ( + 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): + 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 @@ -407,7 +470,7 @@ def __init__(self, on_match_event, target_sequence, result_queue, interval=1.0, @classmethod def match_sequence(cls, a, b): - sm = SequenceMatcher(a=' '.join(a), b=' '.join(b)) + sm = SequenceMatcher(a=" ".join(a), b=" ".join(b)) return sm.ratio() def notify(self, event, **kwargs): @@ -417,11 +480,11 @@ def notify(self, event, **kwargs): self.wait_for_event.set() elif self.target_sequence[0] == event: - if kwargs.get('time_position', 0) == 0: + 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.start_monitor(kwargs.get("uri", None)) self.events_seen.append(event) def is_monitoring(self): @@ -431,7 +494,9 @@ 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 = threading.Timer( + self.interval, self.stop_monitor, args=(self.interval,) + ) self._timer.daemon = True self._timer.start() @@ -452,8 +517,12 @@ def stop_monitor(self, timeout): 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() + EventMarker( + self.on_match_event, + self.target_uri, + int(time.time() * 1000), + ), + self.get_ratio(), ) ) finally: diff --git a/mopidy_pandora/library.py b/mopidy_pandora/library.py index 601ca40..5ac8a84 100644 --- a/mopidy_pandora/library.py +++ b/mopidy_pandora/library.py @@ -12,20 +12,31 @@ from pydora.utils import iterate_forever -from mopidy_pandora.uri import AdItemUri, GenreUri, PandoraUri, SearchUri, StationUri, TrackUri # noqa I101 +from mopidy_pandora.uri import ( + AdItemUri, + GenreUri, + PandoraUri, + SearchUri, + StationUri, + TrackUri, +) # noqa I101 logger = logging.getLogger(__name__) -StationCacheItem = namedtuple('StationCacheItem', 'station, iter') -TrackCacheItem = namedtuple('TrackCacheItem', 'ref, track') +StationCacheItem = namedtuple("StationCacheItem", "station, iter") +TrackCacheItem = namedtuple("TrackCacheItem", "ref, track") class PandoraLibraryProvider(backend.LibraryProvider): - ROOT_DIR_NAME = 'Pandora' - GENRE_DIR_NAME = 'Browse Genres' + 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) + 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) @@ -52,7 +63,9 @@ def browse(self, uri): def lookup(self, uri): pandora_uri = PandoraUri.factory(uri) - logger.info('Looking up Pandora {} {}...'.format(pandora_uri.uri_type, pandora_uri.uri)) + logger.info( + "Looking up Pandora {} {}...".format(pandora_uri.uri_type, pandora_uri.uri) + ) if isinstance(pandora_uri, SearchUri): # Create the station first so that it can be browsed. station_uri = self._create_station_for_token(pandora_uri.token) @@ -61,7 +74,7 @@ def lookup(self, uri): # Recursive call to look up first track in station that was searched for. return self.lookup(track.uri) - track_kwargs = {'uri': uri} + track_kwargs = {"uri": uri} (album_kwargs, artist_kwargs) = {}, {} if isinstance(pandora_uri, TrackUri): @@ -75,41 +88,49 @@ def lookup(self, uri): # 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]} + album_kwargs = {"images": [image.uri for image in images]} if isinstance(pandora_uri, AdItemUri): - track_kwargs['name'] = 'Advertisement' + track_kwargs["name"] = "Advertisement" if not track.title: - track.title = '(Title not specified)' - artist_kwargs['name'] = 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 + track.company_name = "(Company name not specified)" + album_kwargs["name"] = track.company_name else: - track_kwargs['name'] = track.song_name - track_kwargs['length'] = track.track_length * 1000 + track_kwargs["name"] = track.song_name + track_kwargs["length"] = track.track_length * 1000 try: - track_kwargs['bitrate'] = int(track.bitrate) + track_kwargs["bitrate"] = int(track.bitrate) except TypeError: # Bitrate not specified for this stream, ignore. pass - artist_kwargs['name'] = track.artist_name - album_kwargs['name'] = track.album_name + artist_kwargs["name"] = track.artist_name + album_kwargs["name"] = track.album_name elif isinstance(pandora_uri, StationUri): station = self.backend.api.get_station(pandora_uri.station_id) - album_kwargs = {'images': [station.art_url]} - track_kwargs['name'] = station.name - artist_kwargs['name'] = 'Pandora Station' - album_kwargs['name'] = ', '.join(station.genre) + album_kwargs = {"images": [station.art_url]} + track_kwargs["name"] = station.name + artist_kwargs["name"] = "Pandora Station" + album_kwargs["name"] = ", ".join(station.genre) else: - raise ValueError('Unexpected type to perform Pandora track lookup: {}.'.format(pandora_uri.uri_type)) - - artist_kwargs['uri'] = uri # Artist lookups should just point back to the track itself. - track_kwargs['artists'] = [models.Artist(**artist_kwargs)] - album_kwargs['uri'] = uri # Album lookups should just point back to the track itself. - track_kwargs['album'] = models.Album(**album_kwargs) + raise ValueError( + "Unexpected type to perform Pandora track lookup: {}.".format( + pandora_uri.uri_type + ) + ) + + artist_kwargs[ + "uri" + ] = uri # Artist lookups should just point back to the track itself. + track_kwargs["artists"] = [models.Artist(**artist_kwargs)] + album_kwargs[ + "uri" + ] = uri # Album lookups should just point back to the track itself. + track_kwargs["album"] = models.Album(**album_kwargs) return [models.Track(**track_kwargs)] def get_images(self, uris): @@ -128,10 +149,16 @@ def get_images(self, uris): pandora_uri = PandoraUri.factory(uri) if isinstance(pandora_uri, TrackUri): # Could not find the track as expected - exception. - logger.exception("Failed to lookup image for Pandora URI '{}'.".format(uri)) + logger.exception( + "Failed to lookup image for Pandora URI '{}'.".format(uri) + ) else: # Lookup - logger.warning("No images available for Pandora URIs of type '{}'.".format(pandora_uri.uri_type)) + logger.warning( + "No images available for Pandora URIs of type '{}'.".format( + pandora_uri.uri_type + ) + ) pass result[uri] = [models.Image(uri=u) for u in image_uris] return result @@ -141,16 +168,16 @@ def _formatted_station_list(self, list): 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 *)' + 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 += '*' + if not station.name.endswith("*"): + station.name += "*" return list @@ -159,7 +186,7 @@ def _browse_stations(self): stations = self.backend.api.get_station_list() if stations: - if self.sort_order == 'a-z': + if self.sort_order == "a-z": stations.sort(key=lambda x: x.name, reverse=False) for station in self._formatted_station_list(stations): @@ -168,7 +195,10 @@ def _browse_stations(self): # 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)) + models.Ref.directory( + name=station.name, uri=PandoraUri.factory(station).uri + ) + ) station_directories.insert(0, self.genre_directory) @@ -185,13 +215,18 @@ def _create_station_for_token(self, token): 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())] + 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]] + 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 @@ -201,12 +236,12 @@ def get_next_pandora_track(self, station_id): station_iter = self.pandora_station_cache[station_id].iter track = next(station_iter) except Exception: - logger.exception('Error retrieving next Pandora track.') + logger.exception("Error retrieving next Pandora track.") return None track_uri = PandoraUri.factory(track) if isinstance(track_uri, AdItemUri): - track_name = 'Advertisement' + track_name = "Advertisement" else: track_name = track.song_name @@ -228,61 +263,75 @@ def refresh(self, uri=None): # Item not in cache, ignore pass else: - raise ValueError('Unexpected URI type to perform refresh of Pandora directory: {}.' - .format(pandora_uri.uri_type)) + raise ValueError( + "Unexpected URI type to perform refresh of Pandora directory: {}.".format( + pandora_uri.uri_type + ) + ) def search(self, query=None, uris=None, exact=False, **kwargs): search_text = self._formatted_search_query(query) if not search_text: # No value provided for search query, abort. - logger.info('Unsupported Pandora search query: {}'.format(query)) + logger.info("Unsupported Pandora search query: {}".format(query)) return [] - search_result = self.backend.api.search(search_text, include_near_matches=False, include_genre_stations=True) + search_result = self.backend.api.search( + search_text, include_near_matches=False, include_genre_stations=True + ) tracks = [] for genre in search_result.genre_stations: - tracks.append(models.Track(uri=SearchUri(genre.token).uri, - name='{} (Pandora genre)'.format(genre.station_name), - artists=[models.Artist(name=genre.station_name)])) + tracks.append( + models.Track( + uri=SearchUri(genre.token).uri, + name="{} (Pandora genre)".format(genre.station_name), + artists=[models.Artist(name=genre.station_name)], + ) + ) for song in search_result.songs: - tracks.append(models.Track(uri=SearchUri(song.token).uri, - name='{} (Pandora station)'.format(song.song_name), - artists=[models.Artist(name=song.artist)])) + tracks.append( + models.Track( + uri=SearchUri(song.token).uri, + name="{} (Pandora station)".format(song.song_name), + artists=[models.Artist(name=song.artist)], + ) + ) artists = [] for artist in search_result.artists: search_uri = SearchUri(artist.token) if search_uri.is_artist_search: - station_name = '{} (Pandora artist)'.format(artist.artist) + station_name = "{} (Pandora artist)".format(artist.artist) else: - station_name = '{} (Pandora composer)'.format(artist.artist) - artists.append(models.Artist(uri=search_uri.uri, - name=station_name)) + station_name = "{} (Pandora composer)".format(artist.artist) + artists.append(models.Artist(uri=search_uri.uri, name=station_name)) - return models.SearchResult(uri='pandora:search:{}'.format(search_text), tracks=tracks, artists=artists) + return models.SearchResult( + uri="pandora:search:{}".format(search_text), tracks=tracks, artists=artists + ) def _formatted_search_query(self, query): search_text = [] for (field, values) in iter(query.items()): - if not hasattr(values, '__iter__'): + if not hasattr(values, "__iter__"): values = [values] for value in values: - if field == 'any' or field == 'artist' or field == 'track_name': + if field == "any" or field == "artist" or field == "track_name": search_text.append(value) - search_text = ' '.join(search_text) + search_text = " ".join(search_text) return search_text class StationCache(LRUCache): - def __init__(self, library, maxsize, getsizeof=None): + def __init__(self, library, maxsize, getsizeof=None): super(StationCache, self).__init__(maxsize, getsizeof=getsizeof) self.library = library def __missing__(self, station_id): - if re.match('^([SRCG])', station_id): + if re.match("^([SRCG])", station_id): pandora_uri = self.library._create_station_for_token(station_id) station_id = pandora_uri.station_id diff --git a/mopidy_pandora/playback.py b/mopidy_pandora/playback.py index 8e34b6b..7a9fd5b 100644 --- a/mopidy_pandora/playback.py +++ b/mopidy_pandora/playback.py @@ -44,26 +44,35 @@ def change_pandora_track(self, track): # Success, reset track skip counter. self._consecutive_track_skips = 0 else: - raise Unplayable("Track with URI '{}' is not playable.".format(track.uri)) + 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)) + 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)) + logger.warning( + "No URI for Pandora track '{}'. Track cannot be played.".format(track) + ) return False pandora_uri = PandoraUri.factory(track.uri) if isinstance(pandora_uri, StationUri): # Change to first track in station playlist. - logger.warning('Cannot play Pandora stations directly. Retrieving tracks for station with ID: {}...' - .format(pandora_uri.station_id)) - self.backend.end_of_tracklist_reached(station_id=pandora_uri.station_id, auto_play=True) + logger.warning( + "Cannot play Pandora stations directly. Retrieving tracks for station with ID: {}...".format( + pandora_uri.station_id + ) + ) + self.backend.end_of_tracklist_reached( + station_id=pandora_uri.station_id, auto_play=True + ) return False try: self._trigger_track_changing(track) @@ -72,7 +81,9 @@ def change_track(self, track): return super(PandoraPlaybackProvider, self).change_track(track) except KeyError: - logger.exception("Error changing Pandora track: failed to lookup '{}'.".format(track.uri)) + logger.exception( + "Error changing Pandora track: failed to lookup '{}'.".format(track.uri) + ) return False except (MaxSkipLimitExceeded, Unplayable) as e: logger.warning(e) @@ -81,8 +92,9 @@ def change_track(self, track): 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))) + raise MaxSkipLimitExceeded( + ("Maximum track skip limit ({:d}) exceeded.".format(self.SKIP_LIMIT)) + ) def reset_skip_limits(self): self._consecutive_track_skips = 0 @@ -91,13 +103,13 @@ 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) + listener.PandoraPlaybackListener.send("track_changing", track=track) def _trigger_track_unplayable(self, track): - listener.PandoraPlaybackListener.send('track_unplayable', track=track) + listener.PandoraPlaybackListener.send("track_unplayable", track=track) def _trigger_skip_limit_exceeded(self): - listener.PandoraPlaybackListener.send('skip_limit_exceeded') + listener.PandoraPlaybackListener.send("skip_limit_exceeded") class MaxSkipLimitExceeded(Exception): diff --git a/mopidy_pandora/uri.py b/mopidy_pandora/uri.py index 3905355..2927e26 100644 --- a/mopidy_pandora/uri.py +++ b/mopidy_pandora/uri.py @@ -14,25 +14,25 @@ def with_metaclass(meta, *bases): - return meta(str('NewBase'), 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'): + if hasattr(cls, "uri_type"): cls.TYPES[cls.uri_type] = cls class PandoraUri(with_metaclass(_PandoraUriMeta, object)): TYPES = {} - SCHEME = 'pandora' + SCHEME = "pandora" def __init__(self, uri_type=None): self.uri_type = uri_type def __repr__(self): - return '{}:{uri_type}'.format(self.SCHEME, **self.__dict__) + return "{}:{uri_type}".format(self.SCHEME, **self.__dict__) @property def encoded_attributes(self): @@ -49,9 +49,9 @@ def uri(self): @classmethod def encode(cls, value): if value is None: - value = '' + value = "" if isinstance(value, compat.text_type): - value = value.encode('utf-8') + value = value.encode("utf-8") return value @classmethod @@ -72,13 +72,15 @@ def factory(cls, obj): # One of the playlist item (track) types return PandoraUri._from_track(obj) else: - raise NotImplementedError("Unsupported URI object type '{}'".format(type(obj))) + 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(':')] + 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)) + raise NotImplementedError("Not a Pandora URI: {}".format(uri)) uri_cls = cls.TYPES.get(parts[1]) if uri_cls: return uri_cls(*parts[2:]) @@ -88,11 +90,16 @@ def _from_uri(cls, 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: + 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)) + raise NotImplementedError( + "Unsupported station item type '{}'".format(station) + ) @classmethod def _from_track(cls, track): @@ -101,32 +108,38 @@ def _from_track(cls, track): elif isinstance(track, AdItem): return AdItemUri(track.station_id, track.ad_token) else: - raise NotImplementedError("Unsupported playlist item type '{}'".format(track)) + 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) + 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' + 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 + return "{}:{category_name}".format( + super(GenreUri, self).__repr__(), **self.encoded_attributes ) class StationUri(PandoraUri): - uri_type = 'station' + uri_type = "station" def __init__(self, station_id, token): super(StationUri, self).__init__(self.uri_type) @@ -134,15 +147,14 @@ def __init__(self, station_id, token): self.token = token def __repr__(self): - return '{}:{station_id}:{token}'.format( - super(StationUri, self).__repr__(), - **self.encoded_attributes + return "{}:{station_id}:{token}".format( + super(StationUri, self).__repr__(), **self.encoded_attributes ) class GenreStationUri(StationUri): - uri_type = 'genre_station' - pattern = re.compile('^([G])(\d*)$') # noqa: W605 + uri_type = "genre_station" + pattern = re.compile("^([G])(\d*)$") # noqa: W605 def __init__(self, station_id, token): # Check that this really is a Genre station as opposed to a regular station. @@ -154,25 +166,23 @@ def __init__(self, station_id, token): class TrackUri(PandoraUri): - uri_type = 'track' + 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 + return "{}:{station_id}:{token}".format( + super(PlaylistItemUri, self).__repr__(), **self.encoded_attributes ) class AdItemUri(TrackUri): - uri_type = 'ad' + uri_type = "ad" def __init__(self, station_id, ad_token): super(AdItemUri, self).__init__(self.uri_type) @@ -180,41 +190,39 @@ def __init__(self, station_id, ad_token): self.ad_token = ad_token def __repr__(self): - return '{}:{station_id}:{ad_token}'.format( - super(AdItemUri, self).__repr__(), - **self.encoded_attributes + return "{}:{station_id}:{ad_token}".format( + super(AdItemUri, self).__repr__(), **self.encoded_attributes ) class SearchUri(PandoraUri): - uri_type = 'search' + uri_type = "search" def __init__(self, token): super(SearchUri, self).__init__(self.uri_type) # Check that this really is a search result URI as opposed to a regular URI. # Search result tokens always start with 'S' (song), 'R' (artist), 'C' (composer), or 'G' (genre station). - assert re.match('^([SRCG])', token) + assert re.match("^([SRCG])", token) self.token = token def __repr__(self): - return '{}:{token}'.format( - super(SearchUri, self).__repr__(), - **self.encoded_attributes + return "{}:{token}".format( + super(SearchUri, self).__repr__(), **self.encoded_attributes ) @property def is_track_search(self): - return self.token.startswith('S') + return self.token.startswith("S") @property def is_artist_search(self): - return self.token.startswith('R') + return self.token.startswith("R") @property def is_composer_search(self): - return self.token.startswith('C') + return self.token.startswith("C") @property def is_genre_search(self): - return self.token.startswith('G') + return self.token.startswith("G") diff --git a/mopidy_pandora/utils.py b/mopidy_pandora/utils.py index 98d3628..e0332c0 100644 --- a/mopidy_pandora/utils.py +++ b/mopidy_pandora/utils.py @@ -25,7 +25,7 @@ def async_func(*args, **kwargs): """ t = Thread(target=func, args=args, kwargs=kwargs) - queue = kwargs.get('queue', None) + queue = kwargs.get("queue", None) if queue is not None: t.result_queue = queue @@ -36,23 +36,23 @@ def async_func(*args, **kwargs): def format_proxy(proxy_config): - if not proxy_config.get('hostname'): + if not proxy_config.get("hostname"): return None - port = proxy_config.get('port') + port = proxy_config.get("port") if not port or port < 0: port = 80 - template = '{hostname}:{port}' + template = "{hostname}:{port}" - return template.format(hostname=proxy_config['hostname'], port=port) + return template.format(hostname=proxy_config["hostname"], port=port) class RPCClient(object): - hostname = '127.0.0.1' - port = '6680' + hostname = "127.0.0.1" + port = "6680" - url = 'http://' + str(hostname) + ':' + str(port) + '/mopidy/rpc' + url = "http://" + str(hostname) + ":" + str(port) + "/mopidy/rpc" id = 0 @classmethod @@ -70,12 +70,18 @@ def _do_rpc(cls, method, params=None, queue=None): :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} + 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) + 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']) + queue.put(json_data["result"]) From 2c5848ee0f97e82ab2f2b4c9d54d97d6c6a6d0c0 Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sat, 29 Dec 2018 12:25:27 +0200 Subject: [PATCH 307/311] Prepare release 0.4.1 --- CHANGES.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index f29dada..42b4fa5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,8 +1,8 @@ Changelog ========= -v0.4.1 (UNRELEASED) -------------------- +v0.4.1 (Dec 29, 2018) +--------------------- - Update package dependencies used during development to the latest versions. - Migrate all tests to pytest. Resolve pytest 4.0 deprecation errors. From 3ed52c04aaca2ea5e1d91f7a9eb394329469d8fc Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sat, 29 Dec 2018 12:31:23 +0200 Subject: [PATCH 308/311] Increment version number. --- mopidy_pandora/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index d567a9d..8e050c7 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -4,7 +4,7 @@ from mopidy import config, ext -__version__ = "0.4.0" +__version__ = "0.4.1" class Extension(ext.Extension): From f582df20c3a19e475c1e74f6d759e7a83dc26352 Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sat, 29 Dec 2018 12:52:43 +0200 Subject: [PATCH 309/311] Fix version parsing regex. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 08f3f13..899c7e9 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ def get_version(filename): with open(filename) as fh: - metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fh.read())) + metadata = dict(re.findall("__([a-z]+)__ = \"([^\"]+)\"", fh.read())) return metadata['version'] From 9f24ee6b89eb1c3c4f4816c19aa8eb46b6174ec3 Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sun, 21 Apr 2019 15:43:51 +0200 Subject: [PATCH 310/311] Prepare release v0.4.2 --- CHANGES.rst | 6 ++++++ README.rst | 2 +- setup.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 42b4fa5..d5b7243 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= +v0.4.2 (Apr 21, 2019) +--------------------- + +- Pin pydora dependency to pydora>=1.13,<2. (Resolves: `#70 `_). + + v0.4.1 (Dec 29, 2018) --------------------- diff --git a/README.rst b/README.rst index c953ea9..1ee5bf6 100644 --- a/README.rst +++ b/README.rst @@ -52,7 +52,7 @@ Dependencies - Requires a Pandora user account. Users with a Pandora Plus subscription will have access to the higher quality 192 Kbps audio stream. Free accounts will play advertisements. -- ``pydora`` >= 1.7.3. The Python Pandora API Client. **Pre-requisites**: As of 1.11.0 pydora requires `cryptography `_. +- ``pydora`` >= 1.13,<2. The Python Pandora API Client. **Pre-requisites**: As of 1.11.0 pydora requires `cryptography `_. Since Mopidy is still stuck on legacy Python (2.7), there may be some native dependencies on openssl that you will need to install beforehand. See the `cryptography installation docs `_ for details. diff --git a/setup.py b/setup.py index 899c7e9..a9e07dd 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,7 @@ def run_tests(self): 'cachetools >= 1.0.0', 'Mopidy >= 1.1.2', 'Pykka >= 1.1', - 'pydora >= 1.12.0', + 'pydora >= 1.13,<2', 'requests >= 2.5.0' ], tests_require=['tox'], From 050e7ed240e311a6d65d7d9124f062a7b225e104 Mon Sep 17 00:00:00 2001 From: jcass77 Date: Sun, 21 Apr 2019 15:44:28 +0200 Subject: [PATCH 311/311] Prepare release v0.4.2 --- mopidy_pandora/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy_pandora/__init__.py b/mopidy_pandora/__init__.py index 8e050c7..6858da2 100644 --- a/mopidy_pandora/__init__.py +++ b/mopidy_pandora/__init__.py @@ -4,7 +4,7 @@ from mopidy import config, ext -__version__ = "0.4.1" +__version__ = "0.4.2" class Extension(ext.Extension):