soulsync/tests/test_spotify_public_api.py
BoulderBadgeDad dd7f048386 Full public playlist fetch for the 'Spotify link' path (no creds), embed fallback
The no-auth 'add by link' path scrapes Spotify's embed widget, which only ever
contains ~100 tracks and can't paginate — so big public playlists got
truncated. This adds an in-house anonymous fetch that pulls the FULL list:

- core/spotify_public_api.py: reads the anonymous web-player accessToken Spotify
  already embeds in its own open.spotify.com page HTML (no app credentials, and
  no rotating TOTP secret for us to maintain), then paginates
  /v1/playlists/{id}/tracks 100 at a time until the whole playlist is pulled.
  Returns the embed scraper's exact shape. Pure helpers + injected http_get so
  it's unit-testable without the network.
- core/spotify_public_scraper.fetch_spotify_public(): tries the full fetch for
  playlists; on ANY failure (or for albums) falls back to scrape_spotify_embed.
  Worst case == today's behaviour, so the link path can't regress.
- web_server: the link-tab endpoint and the authed flow's last-resort scrape
  now both go through fetch_spotify_public.

Scoped entirely to the spotify_public_* (no-auth) path — the authenticated
playlist sync is untouched. 11 tests (token extraction, normalisation,
pagination past 100, and the embed-fallback orchestration).

Caveat: rides Spotify's undocumented page-embedded token — expected to break
when they change their page; it degrades to the embed fallback, never crashes.
Needs a live click-through to confirm the token path works end to end (can't
hit Spotify from the test env).
2026-06-02 21:27:06 -07:00

144 lines
6 KiB
Python

"""Anonymous full-playlist fetch for the public 'Spotify link' path.
Covers the testable seams (token extraction, track normalisation, paginated
fetch via an injected http_get) and — most importantly — the embed fallback
orchestration, so a broken anonymous path can never make the link worse than
today.
"""
from __future__ import annotations
import pytest
import core.spotify_public_api as papi
import core.spotify_public_scraper as scraper
# --------------------------------------------------------------------------
# Pure helpers
# --------------------------------------------------------------------------
def test_extract_access_token_found():
html = 'window.foo={"accessToken":"BQ_abcdefghijklmnopqrstuvwxyz","other":1}'
assert papi.extract_access_token(html) == 'BQ_abcdefghijklmnopqrstuvwxyz'
def test_extract_access_token_absent_or_short():
assert papi.extract_access_token('<html>no token here</html>') is None
assert papi.extract_access_token('') is None
assert papi.extract_access_token('{"accessToken":"short"}') is None # too short to be real
def test_normalize_api_track_shape():
item = {'track': {'id': 't1', 'name': 'Song', 'artists': [{'name': 'A'}, {'name': 'B'}],
'duration_ms': 1000, 'explicit': True}}
t = papi.normalize_api_track(item, 4)
assert t == {'id': 't1', 'name': 'Song', 'artists': [{'name': 'A'}, {'name': 'B'}],
'duration_ms': 1000, 'is_explicit': True, 'track_number': 5}
def test_normalize_api_track_skips_unusable():
assert papi.normalize_api_track({'track': {'id': None}}, 0) is None # local file / removed
assert papi.normalize_api_track({}, 0) is None
# missing artists -> Unknown Artist fallback
t = papi.normalize_api_track({'track': {'id': 'x', 'name': 'N'}}, 0)
assert t['artists'] == [{'name': 'Unknown Artist'}]
# --------------------------------------------------------------------------
# Paginated fetch with injected HTTP (no network)
# --------------------------------------------------------------------------
class _Resp:
def __init__(self, *, text='', json_data=None, status=200):
self.text, self._json, self.status_code = text, json_data, status
def raise_for_status(self):
if self.status_code >= 400:
import requests
raise requests.HTTPError(str(self.status_code))
def json(self):
return self._json
def _make_items(start, count):
return [{'track': {'id': f't{start + i}', 'name': f'Song {start + i}',
'artists': [{'name': 'Artist'}], 'duration_ms': 1000, 'explicit': False}}
for i in range(count)]
def _fake_http(*, total, token='BQ_aaaaaaaaaaaaaaaaaaaaaaaa', no_token=False):
"""Build an http_get that serves the page, playlist meta, and track pages."""
def http(url, headers=None, params=None, timeout=None):
if 'open.spotify.com/playlist/' in url:
return _Resp(text='' if no_token else f'x={{"accessToken":"{token}"}}')
if url.endswith('/tracks'):
offset = params['offset']
remaining = max(0, total - offset)
return _Resp(json_data={'items': _make_items(offset, min(100, remaining))})
# playlist meta
return _Resp(json_data={'name': 'My Playlist', 'owner': {'display_name': 'Owner'},
'tracks': {'total': total}})
return http
def test_full_fetch_paginates_past_100():
result = papi.fetch_public_playlist_full('pl1', http_get=_fake_http(total=250))
assert result['name'] == 'My Playlist'
assert result['subtitle'] == 'Owner'
assert len(result['tracks']) == 250 # 3 pages (100+100+50), not capped at 100
assert result['tracks'][0]['track_number'] == 1
assert result['tracks'][-1]['id'] == 't249'
assert result['type'] == 'playlist' and result['id'] == 'pl1'
def test_full_fetch_single_page():
result = papi.fetch_public_playlist_full('pl1', http_get=_fake_http(total=30))
assert len(result['tracks']) == 30
def test_full_fetch_raises_without_token():
with pytest.raises(Exception):
papi.fetch_public_playlist_full('pl1', http_get=_fake_http(total=10, no_token=True))
def test_full_fetch_raises_when_no_tracks():
with pytest.raises(Exception):
papi.fetch_public_playlist_full('pl1', http_get=_fake_http(total=0))
# --------------------------------------------------------------------------
# Fallback orchestration (the safety net)
# --------------------------------------------------------------------------
def test_fetch_public_uses_full_when_it_succeeds(monkeypatch):
calls = {'embed': 0}
monkeypatch.setattr(papi, 'fetch_public_playlist_full',
lambda pid, **kw: {'name': 'Full', 'tracks': [{'id': 'a'}] * 200})
monkeypatch.setattr(scraper, 'scrape_spotify_embed',
lambda *a, **k: calls.__setitem__('embed', calls['embed'] + 1) or {'tracks': []})
out = scraper.fetch_spotify_public('playlist', 'pl1')
assert len(out['tracks']) == 200
assert calls['embed'] == 0 # full path won — embed never called
def test_fetch_public_falls_back_to_embed_on_failure(monkeypatch):
def boom(pid, **kw):
raise RuntimeError('spotify changed their page')
monkeypatch.setattr(papi, 'fetch_public_playlist_full', boom)
monkeypatch.setattr(scraper, 'scrape_spotify_embed',
lambda *a, **k: {'name': 'Embed', 'tracks': [{'id': 'e'}]})
out = scraper.fetch_spotify_public('playlist', 'pl1')
assert out['name'] == 'Embed' # gracefully fell back
def test_fetch_public_album_uses_embed_directly(monkeypatch):
full_called = {'n': 0}
monkeypatch.setattr(papi, 'fetch_public_playlist_full',
lambda pid, **kw: full_called.__setitem__('n', 1) or {})
monkeypatch.setattr(scraper, 'scrape_spotify_embed',
lambda *a, **k: {'name': 'Album', 'tracks': [{'id': 'x'}]})
out = scraper.fetch_spotify_public('album', 'al1')
assert out['name'] == 'Album'
assert full_called['n'] == 0 # albums don't attempt the playlist full-fetch