soulsync/core/spotify_public_api.py
BoulderBadgeDad 06f11dc95a Full public playlists via optional SpotipyFree (no creds), MIT-clean
The in-house anonymous-token path is blocked by Spotify (429 without the web
player's rotating client-auth). Switch the full-fetch to SpotipyFree — the
maintained no-creds spotipy drop-in spotDL uses, which tracks that machinery.

- core/spotify_public_api.fetch_public_playlist_full now uses a SpotipyFree
  client (playlist + playlist_items + next), normalising the spotipy-shaped
  items to the embed scraper's shape. Injectable client_factory keeps it
  unit-testable without the library or network. Dropped the dead in-house
  token/pagination code.
- Licensing: SpotipyFree is GPL-3.0, so it is NOT bundled/required (SoulSync is
  MIT). Optional, user-installed: the import is soft, and on ImportError (or any
  failure) fetch_spotify_public falls back to the embed scraper (~100). So the
  shipped project stays cleanly MIT and the link path never regresses.
- requirements.txt: documents it as a commented optional extra
  (pip install SpotipyFree) with the GPL/MIT rationale.
- 9 tests: normalisation, pagination past 100, library-missing -> raises (->
  fallback), and the embed-fallback orchestration.

Needs a live click-through with SpotipyFree installed to confirm the exact
class/method names match (SpotipyFree.Spotify / playlist / playlist_items).
2026-06-02 22:43:34 -07:00

116 lines
4.2 KiB
Python

"""Full public-playlist fetch for the 'Spotify link' path, via the OPTIONAL
SpotipyFree library (no Spotify credentials needed).
Why a library: the embed scraper caps at ~100 tracks, and getting the full list
with no login means talking to Spotify's private API the way the web player
does — including client-auth headers Spotify rotates constantly. Rather than
chase those ourselves (we tried; Spotify 429s the bare token), we lean on
SpotipyFree — the maintained no-creds ``spotipy`` drop-in that spotDL uses,
which tracks those rotating bits for us.
Licensing: SpotipyFree is GPL-3.0, so it is NOT bundled or required by SoulSync
(MIT). It's an OPTIONAL install — if the user has run ``pip install spotipyFree``
this lights up; otherwise the import fails, this raises, and the caller
(``spotify_public_scraper.fetch_spotify_public``) falls back to the embed
scraper (today's ≤100). So SoulSync ships zero GPL code and stays cleanly MIT.
``client_factory`` is injectable so the orchestration is unit-testable without
the library or the network.
"""
from __future__ import annotations
import hashlib
import logging
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger('soulsync.spotify_public')
_MAX_TRACKS = 10000 # safety cap
def normalize_api_track(item: Any, index: int) -> Optional[Dict[str, Any]]:
"""Convert a spotipy-shape playlist item to the embed scraper's track shape.
Returns None for items without a usable track id (local files, removed
tracks, podcast episodes) so the caller can skip them.
"""
track = (item or {}).get('track') or {}
track_id = track.get('id')
if not track_id:
return None
artists = [{'name': a.get('name', '')} for a in (track.get('artists') or []) if a.get('name')]
return {
'id': track_id,
'name': track.get('name', 'Unknown Track'),
'artists': artists or [{'name': 'Unknown Artist'}],
'duration_ms': track.get('duration_ms', 0),
'is_explicit': bool(track.get('explicit', False)),
'track_number': index + 1,
}
def _default_client():
"""Create a no-credentials SpotipyFree client.
Raises ImportError when the optional GPL-3.0 library isn't installed — the
caller treats that like any other failure and falls back to the embed
scraper.
"""
from SpotipyFree import Spotify # optional, user-installed (GPL-3.0)
return Spotify()
def fetch_public_playlist_full(
spotify_id: str,
*,
client_factory: Optional[Callable[[], Any]] = None,
) -> Dict[str, Any]:
"""Pull a public playlist's FULL track list with no credentials.
Uses a SpotipyFree client (spotipy-compatible: ``playlist`` for metadata,
``playlist_items`` + ``next`` for paginated tracks). Returns the embed
scraper's shape. Raises on any failure (incl. the library not being
installed) so the caller can fall back to the embed scraper.
"""
client = (client_factory or _default_client)()
meta: Dict[str, Any] = {}
try:
meta = client.playlist(spotify_id) or {}
except Exception as e: # metadata is nice-to-have; tracks are the point
logger.debug("playlist metadata fetch failed (%s); continuing", e)
name = meta.get('name', 'Unknown')
subtitle = (meta.get('owner') or {}).get('display_name', '')
tracks: List[Dict[str, Any]] = []
results = client.playlist_items(spotify_id)
while results:
for item in results.get('items', []):
t = normalize_api_track(item, len(tracks))
if t:
tracks.append(t)
if len(tracks) >= _MAX_TRACKS:
break
if results.get('next'):
results = client.next(results)
else:
break
if not tracks:
raise RuntimeError('SpotipyFree returned no usable tracks')
logger.info("SpotipyFree full fetch: %s (%d tracks)", name, len(tracks))
source_url = f'https://open.spotify.com/playlist/{spotify_id}'
return {
'id': spotify_id,
'type': 'playlist',
'name': name,
'subtitle': subtitle,
'tracks': tracks,
'url': source_url,
'url_hash': hashlib.md5(source_url.encode()).hexdigest()[:12],
}
__all__ = ['normalize_api_track', 'fetch_public_playlist_full']