SpotipyFree has no album-name search (search_albums returned []), and SpotifyClient.search_albums had no Free branch at all — so when Spotify Free was the active source (no-auth / rate-limit ban / spent budget), album matching couldn't use Spotify and dropped straight to the iTunes/Deezer fallback. The enrichment worker's per-album matching was effectively blind on Free. Fix — work the gap using ONLY the free source: - spotify_free_metadata: new search_albums_via_artist(artist, album) resolves the best-matching artist, scans their discography (get_artist_albums_list — which Free DOES support), and ranks by album-name similarity. Pure rank_albums_by_name helper is unit-testable; the network method composes it. - SpotifyClient.search_albums: add a Free branch mirroring search_artists/tracks (official → Free-if-active → iTunes/Deezer), plus optional artist/album params so the Free path has the names it needs. Bare-query callers skip it unchanged. - spotify_worker: _process_album_individual passes artist+album so the bridge can match albums on Free. Tests: rank ordering + limit, via-artist picks the right artist and ranks, empty on missing artist/album/no-match, and SpotifyClient.search_albums returns Free albums (not the iTunes fallback) when free is active. 210 Spotify tests pass.
286 lines
12 KiB
Python
286 lines
12 KiB
Python
"""PROTOTYPE — no-credentials Spotify metadata via SpotipyFree / spotapi.
|
|
|
|
Goal: stand in as a READ-ONLY Spotify metadata source when the user has no
|
|
Spotify auth (or is rate-limited), mapping SpotipyFree's outputs onto the same
|
|
Spotify-compatible shapes the rest of SoulSync already consumes
|
|
(see core/spotify_client.py + core/search/sources.py).
|
|
|
|
Unofficial / web-player scraping — best-effort, fragile, and NOT a substitute
|
|
for the user-account features (those need a real login).
|
|
|
|
Capabilities (verified live, 2026-06):
|
|
search_tracks ✅ SpotipyFree.search (already official-shaped)
|
|
search_artists ✅ spotapi.Public().artist_search (normalized here)
|
|
search_albums ❌ no album-name search exists upstream → returns []
|
|
get_album ✅ SpotipyFree.album (already official-shaped)
|
|
get_artist_albums_list ✅ SpotipyFree.artist_albums (already official-shaped)
|
|
get_track_details ✅ SpotipyFree.track (already official-shaped)
|
|
get_artist ✅ SpotipyFree.artist (RAW GraphQL → normalized here)
|
|
get_artist_top_tracks ❌ unavailable
|
|
audio_features ❌ unavailable (Spotify deprecated it anyway)
|
|
|
|
This module is import-safe even when SpotipyFree isn't installed — it
|
|
soft-imports inside the client factory.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import logging
|
|
from difflib import SequenceMatcher
|
|
from typing import Any, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _norm_name(s: str) -> str:
|
|
"""Lowercase + collapse whitespace for loose name comparison."""
|
|
return ' '.join((s or '').lower().split())
|
|
|
|
|
|
def rank_albums_by_name(albums: list[dict], album_name: str, limit: int = 10) -> list[dict]:
|
|
"""Rank Spotify-shaped album dicts by name similarity to ``album_name`` (best
|
|
first) and return up to ``limit``. Pure (no network) so it's unit-testable.
|
|
|
|
Used to turn an artist's whole discography into the most-relevant album
|
|
candidates, since SpotipyFree has no album-name search of its own."""
|
|
target = _norm_name(album_name)
|
|
scored = [
|
|
(SequenceMatcher(None, _norm_name((alb or {}).get('name', '')), target).ratio(), alb)
|
|
for alb in (albums or [])
|
|
]
|
|
scored.sort(key=lambda t: t[0], reverse=True)
|
|
return [alb for _score, alb in scored[:limit]]
|
|
|
|
_installed_cache: Optional[bool] = None
|
|
|
|
|
|
def spotify_free_installed() -> bool:
|
|
"""Cheap, cached check: is the optional SpotipyFree package importable?
|
|
|
|
Used by the availability gates — never constructs a client or hits the
|
|
network. Absence just means we degrade to the iTunes/Deezer fallback.
|
|
"""
|
|
global _installed_cache
|
|
if _installed_cache is None:
|
|
_installed_cache = importlib.util.find_spec('SpotipyFree') is not None
|
|
return _installed_cache
|
|
|
|
|
|
def should_use_free_fallback(authenticated: bool, rate_limited: bool,
|
|
budget_exhausted: bool = False) -> bool:
|
|
"""The per-request gate: the no-creds SpotipyFree source may serve a request
|
|
ONLY when official Spotify can't — i.e. the user has no Spotify auth, we're
|
|
currently rate-limited, OR the worker's self-imposed real-API daily budget
|
|
is spent. The daily budget is a real-API ban protection; once it's hit, the
|
|
free source (which isn't subject to it) is the natural place to keep going,
|
|
so a Spotify-Free user is never paused by the budget. When authed AND
|
|
healthy AND under budget, the official path returns before any fallback.
|
|
"""
|
|
return (not authenticated) or rate_limited or budget_exhausted
|
|
|
|
|
|
def should_offer_spotify_metadata(authenticated: bool, free_available: bool) -> bool:
|
|
"""The availability gate: SoulSync can serve *some* Spotify metadata when
|
|
either real auth is present, or the no-creds fallback is available. The
|
|
upstream gates (search resolve, enrichment worker, watchlist) use this so
|
|
the fallback is actually reachable — without changing what
|
|
``is_spotify_authenticated()`` means anywhere.
|
|
"""
|
|
return authenticated or free_available
|
|
|
|
|
|
def should_block_rate_limited_resume(rate_limited: bool, metadata_available: bool) -> bool:
|
|
"""Whether to refuse resuming the Spotify enrichment worker.
|
|
|
|
The worker's own loop bridges to the no-creds free source during a ban
|
|
(its rate-limit guard checks ``is_spotify_metadata_available()``). The
|
|
resume button must mirror that: block ONLY when rate-limited AND nothing
|
|
can serve (plain auth, no free) — otherwise resuming just sleeps. When the
|
|
free fallback is available, ``metadata_available`` is True during a ban
|
|
(``is_spotify_authenticated()`` returns False while banned), so resume is
|
|
allowed and the worker bridges via free.
|
|
"""
|
|
return rate_limited and not metadata_available
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Normalizers (pure — unit-testable against captured fixtures)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def normalize_artist(raw: dict) -> dict:
|
|
"""Map a raw SpotipyFree/spotapi artist object (from ``artist()`` or an
|
|
``artist_search`` item's ``data``) onto the Spotify-compatible artist dict
|
|
SoulSync expects: ``{id, name, images, genres, followers, external_urls}``.
|
|
|
|
Artist-search items carry no usable image (only color swatches), so
|
|
``images`` may be empty there — SoulSync lazy-loads artist art separately.
|
|
Genres aren't provided by the web player at all.
|
|
"""
|
|
raw = raw or {}
|
|
profile = raw.get('profile') or {}
|
|
uri = raw.get('uri') or ''
|
|
artist_id = raw.get('id') or (uri.split(':')[-1] if uri else '')
|
|
name = profile.get('name') or raw.get('name') or ''
|
|
|
|
images = []
|
|
avatar = (raw.get('visuals') or {}).get('avatarImage') or {}
|
|
for src in (avatar.get('sources') or []):
|
|
if src.get('url'):
|
|
images.append({
|
|
'url': src['url'],
|
|
'height': src.get('height'),
|
|
'width': src.get('width'),
|
|
})
|
|
|
|
followers = (raw.get('stats') or {}).get('followers')
|
|
|
|
return {
|
|
'id': str(artist_id),
|
|
'name': name,
|
|
'images': images,
|
|
'genres': [], # web player doesn't expose genres
|
|
'followers': {'total': followers or 0},
|
|
'external_urls': (
|
|
{'spotify': f'https://open.spotify.com/artist/{artist_id}'}
|
|
if artist_id else {}
|
|
),
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Client
|
|
# --------------------------------------------------------------------------
|
|
|
|
class SpotifyFreeMetadataClient:
|
|
"""Read-only Spotify metadata via SpotipyFree, normalized to SoulSync's
|
|
Spotify-compatible shapes. Methods mirror the metadata-source interface."""
|
|
|
|
def __init__(self):
|
|
self._sf = None # SpotipyFree.Spotify instance
|
|
self._public = None # spotapi.Public() for artist_search
|
|
|
|
# -- lazy clients (soft import; absence is not fatal) ------------------
|
|
def _sf_client(self):
|
|
if self._sf is None:
|
|
from SpotipyFree import Spotify # optional, user-installed
|
|
self._sf = Spotify()
|
|
return self._sf
|
|
|
|
def _public_client(self):
|
|
if self._public is None:
|
|
import spotapi
|
|
self._public = spotapi.Public()
|
|
return self._public
|
|
|
|
def is_available(self) -> bool:
|
|
try:
|
|
self._sf_client()
|
|
return True
|
|
except Exception as e:
|
|
logger.debug(f"SpotipyFree unavailable: {e}")
|
|
return False
|
|
|
|
# -- search -----------------------------------------------------------
|
|
def search_tracks(self, query: str, limit: int = 10) -> list[dict]:
|
|
try:
|
|
res = self._sf_client().search(query, limit=limit) or {}
|
|
items = ((res.get('tracks') or {}).get('items')) or []
|
|
return items[:limit]
|
|
except Exception as e:
|
|
logger.debug(f"SpotipyFree search_tracks failed: {e}")
|
|
return []
|
|
|
|
def search_artists(self, query: str, limit: int = 10) -> list[dict]:
|
|
try:
|
|
pages = self._public_client().artist_search(query)
|
|
first = next(iter(pages), [])
|
|
out = []
|
|
for item in first[:limit]:
|
|
data = item.get('data') if isinstance(item, dict) else None
|
|
if data:
|
|
out.append(normalize_artist(data))
|
|
return out
|
|
except Exception as e:
|
|
logger.debug(f"SpotipyFree search_artists failed: {e}")
|
|
return []
|
|
|
|
def search_albums(self, query: str, limit: int = 10) -> list[dict]:
|
|
# No album-name search exists in SpotipyFree/spotapi. Albums are only
|
|
# reachable by id or via an artist's discography — see
|
|
# search_albums_via_artist() for the artist-scoped workaround.
|
|
return []
|
|
|
|
def search_albums_via_artist(self, artist_name: str, album_name: str,
|
|
limit: int = 10) -> list[dict]:
|
|
"""Find an album by name using ONLY the free source: SpotipyFree has no
|
|
album-name search, so resolve the artist (best name match) and scan their
|
|
discography for the album. Returns Spotify-shaped album dicts ranked by
|
|
similarity to ``album_name`` (best first); the caller applies its own
|
|
match threshold. Empty when artist/album is missing or no artist matches.
|
|
|
|
This is what lets the budget/rate-limit bridge match albums on Spotify
|
|
Free instead of being unable to (the gap that previously dropped album
|
|
enrichment straight through to the iTunes/Deezer fallback)."""
|
|
if not (artist_name and album_name):
|
|
return []
|
|
try:
|
|
artists = self.search_artists(artist_name, limit=5)
|
|
except Exception as e:
|
|
logger.debug(f"SpotipyFree search_albums_via_artist artist lookup failed: {e}")
|
|
return []
|
|
if not artists:
|
|
return []
|
|
target_artist = _norm_name(artist_name)
|
|
best = max(
|
|
artists,
|
|
key=lambda a: SequenceMatcher(
|
|
None, _norm_name(a.get('name', '')), target_artist).ratio(),
|
|
)
|
|
artist_id = best.get('id')
|
|
if not artist_id:
|
|
return []
|
|
try:
|
|
albums = self.get_artist_albums_list(artist_id, limit=50)
|
|
except Exception as e:
|
|
logger.debug(f"SpotipyFree search_albums_via_artist discography failed: {e}")
|
|
return []
|
|
return rank_albums_by_name(albums, album_name, limit)
|
|
|
|
# -- entity lookups ---------------------------------------------------
|
|
def get_album(self, album_id: str, include_tracks: bool = True) -> Optional[dict]:
|
|
try:
|
|
return self._sf_client().album(album_id)
|
|
except Exception as e:
|
|
logger.debug(f"SpotipyFree get_album({album_id}) failed: {e}")
|
|
return None
|
|
|
|
def get_track_details(self, track_id: str) -> Optional[dict]:
|
|
try:
|
|
return self._sf_client().track(track_id)
|
|
except Exception as e:
|
|
logger.debug(f"SpotipyFree get_track_details({track_id}) failed: {e}")
|
|
return None
|
|
|
|
def get_album_tracks(self, album_id: str) -> Optional[dict]:
|
|
try:
|
|
return self._sf_client().album_tracks(album_id)
|
|
except Exception as e:
|
|
logger.debug(f"SpotipyFree get_album_tracks({album_id}) failed: {e}")
|
|
return None
|
|
|
|
def get_artist(self, artist_id: str) -> Optional[dict]:
|
|
try:
|
|
raw = self._sf_client().artist(artist_id)
|
|
return normalize_artist(raw) if raw else None
|
|
except Exception as e:
|
|
logger.debug(f"SpotipyFree get_artist({artist_id}) failed: {e}")
|
|
return None
|
|
|
|
def get_artist_albums_list(self, artist_id: str, limit: int = 50) -> list[dict]:
|
|
try:
|
|
res = self._sf_client().artist_albums(artist_id) or {}
|
|
return (res.get('items') or [])[:limit]
|
|
except Exception as e:
|
|
logger.debug(f"SpotipyFree get_artist_albums_list({artist_id}) failed: {e}")
|
|
return []
|