Skip to content

Commit 6dd39cf

Browse files
committed
Force 10 second cache expiry for everything.
Workaround for Spotify's use of max-age=0 for the me/playlists endpoint. 1. ~7.7 seconds INFO 2019-12-03 23:47:16,030 Logged into Spotify Web API as kingosticks4 INFO 2019-12-03 23:47:23,659 Refreshed 68 playlists INFO 2019-12-03 23:47:23,659 Refresh Playlists took 7628ms INFO 2019-12-03 23:47:23,659 Starting Mopidy core 2. real 0m0.057s INFO 2019-12-03 23:47:26,070 playlists.as_list() took 1ms INFO 2019-12-03 23:47:26,073 playlists.as_list() took 1ms 2. real 0m0.223s INFO 2019-12-03 23:47:27,668 playlists.as_list() took 212ms 3. real 0m0.837s 3. real 0m0.470s
1 parent ec9d914 commit 6dd39cf

File tree

5 files changed

+259
-78
lines changed

5 files changed

+259
-78
lines changed

mopidy_spotify/playlists.py

+11-14
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,7 @@ def as_list(self):
2525
return list(self._get_flattened_playlist_refs())
2626

2727
def _get_flattened_playlist_refs(self):
28-
if self._backend._web_client is None:
29-
return
30-
31-
if self._backend._web_client.user_id is None:
28+
if not self._backend._web_client.logged_in:
3229
return
3330

3431
web_client = self._backend._web_client
@@ -41,9 +38,6 @@ def _get_flattened_playlist_refs(self):
4138

4239
def get_items(self, uri):
4340
with utils.time_logger(f"playlist.get_items({uri})", logging.INFO):
44-
if not self._loaded:
45-
return []
46-
4741
return self._get_playlist(uri, as_items=True)
4842

4943
def lookup(self, uri):
@@ -60,14 +54,17 @@ def _get_playlist(self, uri, as_items=False):
6054
)
6155

6256
def refresh(self):
57+
if not self._backend._web_client.logged_in:
58+
return
59+
6360
with utils.time_logger("Refresh Playlists", logging.INFO):
6461
_sp_links.clear()
65-
with self._backend._web_client.refresh_playlists():
66-
count = 0
67-
for playlist_ref in self._get_flattened_playlist_refs():
68-
self._get_playlist(playlist_ref.uri)
69-
count = count + 1
70-
logger.info(f"Refreshed {count} playlists")
62+
self._backend._web_client.clear_cache()
63+
count = 0
64+
for playlist_ref in self._get_flattened_playlist_refs():
65+
self._get_playlist(playlist_ref.uri)
66+
count = count + 1
67+
logger.info(f"Refreshed {count} playlists")
7168

7269
self._loaded = True
7370

@@ -82,7 +79,7 @@ def save(self, playlist):
8279

8380

8481
def playlist_lookup(session, web_client, uri, bitrate, as_items=False):
85-
if web_client is None or web_client.user_id is None:
82+
if web_client is None or not web_client.logged_in:
8683
return
8784

8885
logger.debug(f'Fetching Spotify playlist "{uri}"')

mopidy_spotify/web.py

+42-22
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import re
77
import time
88
import urllib.parse
9-
from contextlib import contextmanager
109
from datetime import datetime
1110

1211
import requests
@@ -74,9 +73,10 @@ def get(self, path, cache=None, *args, **kwargs):
7473

7574
_trace(f"Get '{path}'")
7675

76+
ignore_expiry = kwargs.pop("ignore_expiry", False)
7777
if cache is not None and path in cache:
7878
cached_result = cache.get(path)
79-
if not cached_result.expired:
79+
if cached_result.still_valid(ignore_expiry):
8080
return cached_result
8181
kwargs.setdefault("headers", {}).update(cached_result.etag_headers)
8282

@@ -260,6 +260,7 @@ def _parse_retry_after(self, response):
260260

261261
class WebResponse(dict):
262262
def __init__(self, url, data, expires=0.0, etag=None, status_code=400):
263+
self._from_cache = False
263264
self.url = url
264265
self._expires = expires
265266
self._etag = etag
@@ -315,13 +316,24 @@ def _parse_etag(response):
315316
if etag and len(etag.groups()) == 2:
316317
return etag.groups()[1]
317318

318-
@property
319-
def expired(self):
320-
status_str = {True: "expired", False: "fresh"}
321-
result = self._expires < time.time()
322-
_trace(f"Cached data {status_str[result]} for {self}")
319+
def still_valid(self, ignore_expiry=False):
320+
if ignore_expiry:
321+
result = True
322+
status = "forced"
323+
elif self._expires >= time.time():
324+
result = True
325+
status = "fresh"
326+
else:
327+
result = False
328+
status = "expired"
329+
self._from_cache = result
330+
_trace("Cached data %s for %s", status, self)
323331
return result
324332

333+
@property
334+
def status_unchanged(self):
335+
return self._from_cache or 304 == self._status_code
336+
325337
@property
326338
def status_ok(self):
327339
return self._status_code >= 200 and self._status_code < 400
@@ -334,6 +346,7 @@ def etag_headers(self):
334346
return {}
335347

336348
def updated(self, response):
349+
self._from_cache = False
337350
if self._etag is None:
338351
return False
339352
elif self.url != response.url:
@@ -349,6 +362,7 @@ def updated(self, response):
349362
_trace(f"ETag match for {self} {response}")
350363
self._expires = response._expires
351364
self._etag = response._etag
365+
self._status_code = response._status_code
352366
return True
353367

354368
def __str__(self):
@@ -358,6 +372,10 @@ def __str__(self):
358372
f"[ETag: {self._etag}]"
359373
)
360374

375+
def increase_expiry(self, delta_seconds):
376+
if self.status_ok and not self._from_cache:
377+
self._expires += delta_seconds
378+
361379

362380
class SpotifyOAuthClient(OAuthClient):
363381

@@ -368,6 +386,7 @@ class SpotifyOAuthClient(OAuthClient):
368386
PLAYLIST_FIELDS = (
369387
f"name,owner.id,type,uri,snapshot_id,tracks({TRACK_FIELDS}),"
370388
)
389+
DEFAULT_EXTRA_EXPIRY = 10
371390

372391
def __init__(self, client_id, client_secret, proxy_config):
373392
super(SpotifyOAuthClient, self).__init__(
@@ -379,11 +398,17 @@ def __init__(self, client_id, client_secret, proxy_config):
379398
)
380399
self.user_id = None
381400
self._cache = {}
401+
self._extra_expiry = self.DEFAULT_EXTRA_EXPIRY
402+
403+
def get_one(self, path, *args, **kwargs):
404+
logger.debug(f'Fetching page "{path}"')
405+
result = self.get(path, cache=self._cache, *args, **kwargs)
406+
result.increase_expiry(self._extra_expiry)
407+
return result
382408

383409
def get_all(self, path, *args, **kwargs):
384410
while path is not None:
385-
logger.debug(f'Fetching page "{path}"')
386-
result = self.get(path, *args, **kwargs)
411+
result = self.get_one(path, *args, **kwargs)
387412
path = result.get("next")
388413
yield result
389414

@@ -396,11 +421,13 @@ def login(self):
396421
logger.info(f"Logged into Spotify Web API as {self.user_id}")
397422
return True
398423

424+
@property
425+
def logged_in(self):
426+
return self.user_id is not None
427+
399428
def get_user_playlists(self):
400429
with utils.time_logger("get_user_playlists"):
401-
pages = self.get_all(
402-
"me/playlists", cache=self._cache, params={"limit": 50}
403-
)
430+
pages = self.get_all("me/playlists", params={"limit": 50})
404431
for page in pages:
405432
for playlist in page.get("items", []):
406433
yield playlist
@@ -416,17 +443,16 @@ def get_playlist(self, uri):
416443
logger.error(exc)
417444
return {}
418445

419-
playlist = self.get(
446+
playlist = self.get_one(
420447
f"playlists/{parsed.id}",
421-
cache=self._cache,
422448
params={"fields": self.PLAYLIST_FIELDS, "market": "from_token"},
423449
)
424450

425451
tracks_path = playlist.get("tracks", {}).get("next")
426452
track_pages = self.get_all(
427453
tracks_path,
428-
cache=self._cache,
429454
params={"fields": self.TRACK_FIELDS, "market": "from_token"},
455+
ignore_expiry=playlist.status_unchanged,
430456
)
431457

432458
more_tracks = []
@@ -440,14 +466,8 @@ def get_playlist(self, uri):
440466

441467
return playlist
442468

443-
@contextmanager
444-
def refresh_playlists(self, extra_expiry=None):
469+
def clear_cache(self, extra_expiry=None):
445470
self._cache.clear()
446-
old_extra_expiry = self._extra_expiry
447-
if extra_expiry is not None:
448-
self._extra_expiry = extra_expiry
449-
yield
450-
self._extra_expiry = old_extra_expiry
451471

452472

453473
WebLink = collections.namedtuple("WebLink", ["uri", "type", "id", "owner"])

tests/test_backend.py

+1
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ def test_on_start_refreshes_playlists(spotify_mock, web_mock, config, caplog):
186186
client_mock = web_mock.SpotifyOAuthClient.return_value
187187
client_mock.get_user_playlists.assert_called_once()
188188
assert "Refreshed 0 playlists" in caplog.text
189+
assert backend.playlists._loaded
189190

190191

191192
def test_on_start_doesnt_refresh_playlists_if_not_allowed(

tests/test_playlists.py

+48-15
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def test_is_a_playlists_provider(provider):
5555

5656

5757
def test_as_list_when_not_logged_in(web_client_mock, provider):
58-
web_client_mock.user_id = None
58+
web_client_mock.logged_in = False
5959

6060
result = provider.as_list()
6161

@@ -70,15 +70,15 @@ def test_as_list_when_offline(web_client_mock, provider):
7070
assert len(result) == 0
7171

7272

73-
def test_as_list_blocked_when_not_loaded(provider):
73+
def test_as_list_when_not_loaded(provider):
7474
provider._loaded = False
7575

7676
result = provider.as_list()
7777

7878
assert len(result) == 0
7979

8080

81-
def test_as_list_when_playlist_wont_translate(provider, caplog):
81+
def test_as_list_when_playlist_wont_translate(provider):
8282
result = provider.as_list()
8383

8484
assert len(result) == 2
@@ -104,20 +104,34 @@ def test_get_items_when_playlist_without_tracks(provider):
104104

105105
assert len(result) == 0
106106

107-
assert result == []
108107

108+
def test_get_items_when_not_logged_in(web_client_mock, provider):
109+
web_client_mock.logged_in = False
109110

110-
def test_get_items_blocked_when_not_loaded(provider):
111+
assert provider.get_items("spotify:user:alice:playlist:foo") is None
112+
113+
114+
def test_get_items_when_offline(web_client_mock, provider, caplog):
115+
web_client_mock.get_playlist.side_effect = None
116+
web_client_mock.get_playlist.return_value = {}
117+
118+
assert provider.get_items("spotify:user:alice:playlist:foo") is None
119+
assert (
120+
"Failed to lookup Spotify playlist URI "
121+
"spotify:user:alice:playlist:foo" in caplog.text
122+
)
123+
124+
125+
def test_get_items_when_not_loaded(provider):
111126
provider._loaded = False
112127

113128
result = provider.get_items("spotify:user:alice:playlist:foo")
114129

115-
assert len(result) == 0
116-
117-
assert result == []
130+
assert len(result) == 1
131+
assert result[0] == Ref.track(uri="spotify:track:abc", name="ABC 123")
118132

119133

120-
def test_get_items_when_playlist_wont_translate(provider, caplog):
134+
def test_get_items_when_playlist_wont_translate(provider):
121135
assert provider.get_items("spotify:user:alice:playlist:malformed") is None
122136

123137

@@ -141,7 +155,18 @@ def test_refresh_loads_all_playlists(provider, web_client_mock):
141155
web_client_mock.get_playlist.assert_has_calls(expected_calls)
142156

143157

144-
def test_refresh_when_not_loaded(provider, web_client_mock):
158+
def test_refresh_when_not_logged_in(provider, web_client_mock):
159+
provider._loaded = False
160+
web_client_mock.logged_in = False
161+
162+
provider.refresh()
163+
164+
web_client_mock.get_user_playlists.assert_not_called()
165+
web_client_mock.get_playlist.assert_not_called()
166+
assert not provider._loaded
167+
168+
169+
def test_refresh_sets_loaded(provider, web_client_mock):
145170
provider._loaded = False
146171

147172
provider.refresh()
@@ -157,13 +182,13 @@ def test_refresh_counts_playlists(provider, caplog):
157182
assert "Refreshed 2 playlists" in caplog.text
158183

159184

160-
def test_refresh_clears_link_cache(provider):
161-
playlists._sp_links = {"bar": "foobar", "bar2": "foofoo"}
185+
def test_refresh_clears_caches(provider, web_client_mock):
186+
playlists._sp_links = {"bar": "foobar"}
162187

163188
provider.refresh()
164189

165-
assert len(playlists._sp_links) == 1
166-
assert list(playlists._sp_links.keys()) == ["spotify:track:abc"]
190+
assert "bar" not in playlists._sp_links
191+
web_client_mock.clear_cache.assert_called_once()
167192

168193

169194
def test_lookup(provider):
@@ -174,6 +199,14 @@ def test_lookup(provider):
174199
assert playlist.tracks[0].bitrate == 160
175200

176201

202+
def test_lookup_when_not_logged_in(web_client_mock, provider):
203+
web_client_mock.logged_in = False
204+
205+
playlist = provider.lookup("spotify:user:alice:playlist:foo")
206+
207+
assert playlist is None
208+
209+
177210
def test_lookup_when_not_loaded(provider):
178211
provider._loaded = False
179212

@@ -218,7 +251,7 @@ def test_playlist_lookup_stores_track_link(
218251
)
219252

220253
session_mock.get_link.assert_called_once_with("spotify:track:abc")
221-
assert len(playlists._sp_links) == 1
254+
assert {"spotify:track:abc": sp_track_mock.link} == playlists._sp_links
222255

223256

224257
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)