39 KiB
Metadata Fallback Implementation Guide
This document details two alternative approaches for fetching music metadata without requiring users to provide Spotify API credentials or complete OAuth authentication.
Table of Contents
- Overview
- Current Implementation
- Option A: SpotiFLAC-Style Anonymous Spotify Access
- Option B: iTunes Search API
- Implementation Strategy
- Comparison Matrix
- Recommended Approach
Overview
The Problem
Currently, SoulSync requires users to:
- Register at Spotify Developer Dashboard
- Create an application to get
client_idandclient_secret - Enter credentials in settings
- Complete OAuth flow to authenticate
This creates friction for users who just want search/metadata functionality without syncing playlists.
The Goal
Implement a fallback system that allows search and metadata operations to work immediately without any user-provided credentials, while still supporting full Spotify OAuth for users who want playlist sync features.
Priority Order
1. Spotify OAuth (full features - playlists, library, search, metadata)
2. Spotify Client Credentials (search, metadata - requires app credentials)
3. Anonymous Spotify Access (search, metadata - no credentials needed)
4. iTunes Search API (search, metadata - no credentials needed, different data source)
Current Implementation
File: core/spotify_client.py
The current SpotifyClient class uses SpotifyOAuth exclusively:
auth_manager = SpotifyOAuth(
client_id=config['client_id'],
client_secret=config['client_secret'],
redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"),
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email",
cache_path='config/.spotify_cache'
)
self.sp = spotipy.Spotify(auth_manager=auth_manager)
Limitations:
- Requires valid
client_idandclient_secret - Requires user to complete OAuth flow
- All methods check
is_authenticated()before making API calls
Option A: SpotiFLAC-Style Anonymous Spotify Access
How It Works
SpotiFLAC reverse-engineered Spotify's web player authentication to obtain anonymous access tokens without any developer credentials.
Technical Details
1. TOTP-Based Token Generation
Spotify's web player uses a Time-based One-Time Password (TOTP) mechanism for anonymous session tokens.
Source: https://github.com/afkarxyz/SpotiFLAC/blob/main/backend/spotfetch.go
// Hardcoded TOTP secrets (XOR-encoded for obfuscation)
// These have been updated multiple times (v59, v60, v61) as Spotify patches them
var totpSecretV61 = []byte{...} // Current working version
// Generate TOTP code
func generateTOTP(secret []byte) string {
// 1. XOR transform the secret with calculated byte
// 2. Convert to hex, then base32 encode
// 3. Generate standard TOTP code
}
2. Token Acquisition Flow
Step 1: Generate TOTP Code
|
v
Step 2: GET https://open.spotify.com/api/token?totp={code}
|
v
Step 3: Response contains:
- accessToken (for API calls)
- clientId (Spotify's internal client ID)
- sp_t cookie (device ID)
|
v
Step 4: POST https://clienttoken.spotify.com/v1/clienttoken
Body: device info, client version
|
v
Step 5: Response contains:
- granted_token.token (client token for API calls)
3. API Endpoints Used
SpotiFLAC uses Spotify's internal GraphQL API, not the public REST API:
Base URL: https://api-partner.spotify.com/pathfinder/v2/query
Headers:
- Authorization: Bearer {accessToken}
- Client-Token: {clientToken}
- User-Agent: {randomized browser UA}
GraphQL Queries use persisted query hashes:
{
"operationName": "searchTracks",
"variables": {"searchTerm": "radiohead", "limit": 20},
"extensions": {
"persistedQuery": {
"sha256Hash": "612585ae06ba435ad26369870deaae23b5c8800a256cd8a57e08eddc25a37294"
}
}
}
Python Implementation
import time
import hmac
import struct
import hashlib
import base64
import requests
from typing import Optional, Dict, Any, Tuple
class SpotifyAnonymousClient:
"""
Anonymous Spotify client using reverse-engineered web player authentication.
WARNING: This is unofficial and may break at any time.
Spotify actively patches these methods.
"""
TOKEN_URL = "https://open.spotify.com/api/token"
CLIENT_TOKEN_URL = "https://clienttoken.spotify.com/v1/clienttoken"
API_BASE = "https://api-partner.spotify.com/pathfinder/v2/query"
# TOTP secret (XOR-encoded) - Version 61
# This will need updating when Spotify patches it
TOTP_SECRET_V61 = bytes([
# ... byte array from SpotiFLAC source
# Omitted here - copy from actual SpotiFLAC source
])
def __init__(self):
self.access_token: Optional[str] = None
self.client_token: Optional[str] = None
self.client_id: Optional[str] = None
self.token_expiry: float = 0
self.session = requests.Session()
self._setup_session()
def _setup_session(self):
"""Configure session with browser-like headers"""
self.session.headers.update({
'User-Agent': self._random_user_agent(),
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
'Origin': 'https://open.spotify.com',
'Referer': 'https://open.spotify.com/',
})
def _random_user_agent(self) -> str:
"""Generate randomized browser User-Agent"""
import random
chrome_versions = ['120.0.0.0', '121.0.0.0', '122.0.0.0', '123.0.0.0']
version = random.choice(chrome_versions)
return f'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{version} Safari/537.36'
def _decode_totp_secret(self) -> bytes:
"""Decode the XOR-obfuscated TOTP secret"""
secret = bytearray(self.TOTP_SECRET_V61)
# XOR transformation (from SpotiFLAC)
xor_byte = (secret[0] ^ 0x47) & 0xFF
for i in range(len(secret)):
secret[i] ^= xor_byte
return bytes(secret)
def _generate_totp(self) -> str:
"""Generate TOTP code for Spotify authentication"""
secret = self._decode_totp_secret()
# Convert to base32 for TOTP
secret_hex = secret.hex()
secret_b32 = base64.b32encode(bytes.fromhex(secret_hex)).decode()
# Standard TOTP generation (30-second window)
counter = int(time.time()) // 30
counter_bytes = struct.pack('>Q', counter)
hmac_hash = hmac.new(
base64.b32decode(secret_b32),
counter_bytes,
hashlib.sha1
).digest()
offset = hmac_hash[-1] & 0x0F
code = struct.unpack('>I', hmac_hash[offset:offset + 4])[0]
code = (code & 0x7FFFFFFF) % 1000000
return str(code).zfill(6)
def _fetch_access_token(self) -> bool:
"""Fetch anonymous access token from Spotify"""
try:
totp_code = self._generate_totp()
response = self.session.get(
self.TOKEN_URL,
params={'totp': totp_code}
)
if response.status_code != 200:
return False
data = response.json()
self.access_token = data.get('accessToken')
self.client_id = data.get('clientId')
# Token typically valid for 1 hour
self.token_expiry = time.time() + 3600
return self.access_token is not None
except Exception as e:
print(f"Failed to fetch access token: {e}")
return False
def _fetch_client_token(self) -> bool:
"""Fetch client token required for API calls"""
if not self.access_token:
return False
try:
# Get Spotify web player version from homepage
homepage = self.session.get('https://open.spotify.com/')
# Extract version from HTML (simplified)
client_version = "1.2.48.255" # Fallback version
payload = {
"client_data": {
"client_version": client_version,
"client_id": self.client_id,
"js_sdk_data": {
"device_brand": "unknown",
"device_model": "unknown",
"os": "windows",
"os_version": "NT 10.0"
}
}
}
response = self.session.post(
self.CLIENT_TOKEN_URL,
json=payload,
headers={'Authorization': f'Bearer {self.access_token}'}
)
if response.status_code != 200:
return False
data = response.json()
self.client_token = data.get('granted_token', {}).get('token')
return self.client_token is not None
except Exception as e:
print(f"Failed to fetch client token: {e}")
return False
def ensure_authenticated(self) -> bool:
"""Ensure we have valid tokens"""
if self.access_token and time.time() < self.token_expiry:
return True
if not self._fetch_access_token():
return False
if not self._fetch_client_token():
return False
return True
def _graphql_request(self, operation: str, variables: Dict, query_hash: str) -> Optional[Dict]:
"""Make a GraphQL request to Spotify's internal API"""
if not self.ensure_authenticated():
return None
try:
payload = {
"operationName": operation,
"variables": variables,
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": query_hash
}
}
}
response = self.session.post(
self.API_BASE,
json=payload,
headers={
'Authorization': f'Bearer {self.access_token}',
'Client-Token': self.client_token,
'Content-Type': 'application/json'
}
)
if response.status_code != 200:
return None
return response.json()
except Exception as e:
print(f"GraphQL request failed: {e}")
return None
# === Public API Methods ===
def search_tracks(self, query: str, limit: int = 20) -> list:
"""Search for tracks"""
# Query hash for searchTracks operation
SEARCH_HASH = "612585ae06ba435ad26369870deaae23b5c8800a256cd8a57e08eddc25a37294"
result = self._graphql_request(
"searchTracks",
{"searchTerm": query, "limit": limit, "offset": 0},
SEARCH_HASH
)
if not result:
return []
# Parse and return tracks
# Structure depends on actual GraphQL response
tracks = []
# ... parse result['data']['searchV2']['tracksV2']['items']
return tracks
def search_artists(self, query: str, limit: int = 20) -> list:
"""Search for artists"""
SEARCH_HASH = "..." # Different hash for artist search
# Similar implementation
pass
def search_albums(self, query: str, limit: int = 20) -> list:
"""Search for albums"""
SEARCH_HASH = "..." # Different hash for album search
# Similar implementation
pass
def get_track(self, track_id: str) -> Optional[Dict]:
"""Get track details by ID"""
TRACK_HASH = "..."
# Similar implementation
pass
def get_album(self, album_id: str) -> Optional[Dict]:
"""Get album details by ID"""
ALBUM_HASH = "..."
# Similar implementation
pass
def get_artist(self, artist_id: str) -> Optional[Dict]:
"""Get artist details by ID"""
ARTIST_HASH = "..."
# Similar implementation
pass
Required GraphQL Query Hashes
These hashes correspond to Spotify's internal persisted queries. They may change when Spotify updates their web player.
| Operation | Hash | Notes |
|---|---|---|
| searchTracks | 612585ae... |
Search for tracks |
| searchArtists | ... |
Search for artists |
| searchAlbums | ... |
Search for albums |
| getTrack | ... |
Get track by ID |
| getAlbum | ... |
Get album by ID |
| getArtist | ... |
Get artist by ID |
| getAlbumTracks | ... |
Get album's track listing |
| getArtistDiscography | ... |
Get artist's albums |
Note: Extract current hashes from SpotiFLAC source or by inspecting Spotify web player network requests.
Risks and Considerations
| Risk | Impact | Mitigation |
|---|---|---|
| TOTP secret changes | Auth breaks completely | Monitor SpotiFLAC updates, implement version detection |
| Query hashes change | Specific operations fail | Keep hashes configurable, monitor for changes |
| Rate limiting | 403 errors | Implement backoff, respect implicit limits |
| IP bans | Service unavailable | Unlikely for normal usage, but possible |
| Legal/TOS | Account/service issues | This violates Spotify TOS |
Maintenance Requirements
- Monitor SpotiFLAC repository for TOTP secret updates
- Track Spotify web player updates that may change query hashes
- Implement version detection to automatically try multiple TOTP versions
- Add fallback to iTunes API when Spotify anonymous access fails
Option B: iTunes Search API
How It Works
Apple provides a free, public API for searching the iTunes/Apple Music catalog. No authentication required.
API Documentation
Official Docs: https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/
Endpoints
Search Endpoint
GET https://itunes.apple.com/search
Parameters:
| Parameter | Required | Description | Example |
|---|---|---|---|
term |
Yes | URL-encoded search text | jack+johnson |
country |
Yes | ISO 2-letter country code | US |
media |
No | Media type | music |
entity |
No | Result type | song, album, musicArtist |
limit |
No | Max results (1-200) | 25 |
lang |
No | Language | en_us |
Entity Types for Music:
musicArtist- Artists onlysong- Tracks/songs onlyalbum- Albums onlymusicTrack- Same as songmix- MixesmusicVideo- Music videos
Lookup Endpoint
GET https://itunes.apple.com/lookup
Parameters:
| Parameter | Description | Example |
|---|---|---|
id |
iTunes ID | 909253 |
upc |
Album UPC code | 720642462928 |
amgArtistId |
AMG artist ID | 468749 |
entity |
Include related entities | song (for album tracks) |
Response Format
{
"resultCount": 1,
"results": [
{
"wrapperType": "track",
"kind": "song",
"artistId": 909253,
"collectionId": 1440857781,
"trackId": 1440857786,
"artistName": "Jack Johnson",
"collectionName": "In Between Dreams (Bonus Track Version)",
"trackName": "Better Together",
"collectionCensoredName": "In Between Dreams (Bonus Track Version)",
"trackCensoredName": "Better Together",
"artistViewUrl": "https://music.apple.com/us/artist/jack-johnson/909253",
"collectionViewUrl": "https://music.apple.com/us/album/better-together/1440857781?i=1440857786",
"trackViewUrl": "https://music.apple.com/us/album/better-together/1440857781?i=1440857786",
"previewUrl": "https://audio-ssl.itunes.apple.com/...",
"artworkUrl30": "https://is1-ssl.mzstatic.com/.../30x30bb.jpg",
"artworkUrl60": "https://is1-ssl.mzstatic.com/.../60x60bb.jpg",
"artworkUrl100": "https://is1-ssl.mzstatic.com/.../100x100bb.jpg",
"releaseDate": "2005-03-01T08:00:00Z",
"collectionExplicitness": "notExplicit",
"trackExplicitness": "notExplicit",
"discCount": 1,
"discNumber": 1,
"trackCount": 16,
"trackNumber": 1,
"trackTimeMillis": 207679,
"country": "USA",
"currency": "USD",
"primaryGenreName": "Rock",
"isStreamable": true
}
]
}
Python Implementation
import requests
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
from urllib.parse import quote_plus
import time
@dataclass
class iTunesTrack:
id: str
name: str
artists: List[str]
album: str
duration_ms: int
track_number: int
disc_number: int
release_date: str
preview_url: Optional[str] = None
image_url: Optional[str] = None
genre: Optional[str] = None
explicit: bool = False
isrc: Optional[str] = None # iTunes doesn't provide ISRC
@classmethod
def from_itunes_result(cls, data: Dict[str, Any]) -> 'iTunesTrack':
# Get highest quality artwork (replace 100x100 with larger size)
artwork_url = data.get('artworkUrl100', '')
if artwork_url:
artwork_url = artwork_url.replace('100x100bb', '600x600bb')
return cls(
id=str(data.get('trackId', '')),
name=data.get('trackName', ''),
artists=[data.get('artistName', '')],
album=data.get('collectionName', ''),
duration_ms=data.get('trackTimeMillis', 0),
track_number=data.get('trackNumber', 0),
disc_number=data.get('discNumber', 1),
release_date=data.get('releaseDate', ''),
preview_url=data.get('previewUrl'),
image_url=artwork_url,
genre=data.get('primaryGenreName'),
explicit=data.get('trackExplicitness') == 'explicit'
)
@dataclass
class iTunesArtist:
id: str
name: str
genre: Optional[str] = None
image_url: Optional[str] = None # iTunes artist search doesn't return images
@classmethod
def from_itunes_result(cls, data: Dict[str, Any]) -> 'iTunesArtist':
return cls(
id=str(data.get('artistId', '')),
name=data.get('artistName', ''),
genre=data.get('primaryGenreName')
)
@dataclass
class iTunesAlbum:
id: str
name: str
artists: List[str]
release_date: str
total_tracks: int
genre: Optional[str] = None
image_url: Optional[str] = None
explicit: bool = False
@classmethod
def from_itunes_result(cls, data: Dict[str, Any]) -> 'iTunesAlbum':
artwork_url = data.get('artworkUrl100', '')
if artwork_url:
artwork_url = artwork_url.replace('100x100bb', '600x600bb')
return cls(
id=str(data.get('collectionId', '')),
name=data.get('collectionName', ''),
artists=[data.get('artistName', '')],
release_date=data.get('releaseDate', ''),
total_tracks=data.get('trackCount', 0),
genre=data.get('primaryGenreName'),
image_url=artwork_url,
explicit=data.get('collectionExplicitness') == 'explicit'
)
class iTunesClient:
"""
iTunes Search API client for music metadata.
Free, no authentication required.
Rate limit: ~20 calls/minute on /search, /lookup appears unlimited.
"""
SEARCH_URL = "https://itunes.apple.com/search"
LOOKUP_URL = "https://itunes.apple.com/lookup"
# Rate limiting
MIN_SEARCH_INTERVAL = 3.0 # 20 calls/min = 1 call per 3 seconds
def __init__(self, country: str = "US"):
self.country = country
self.session = requests.Session()
self._last_search_time = 0
def _rate_limit_search(self):
"""Enforce rate limiting for search endpoint"""
elapsed = time.time() - self._last_search_time
if elapsed < self.MIN_SEARCH_INTERVAL:
time.sleep(self.MIN_SEARCH_INTERVAL - elapsed)
self._last_search_time = time.time()
def _search(self, term: str, entity: str, limit: int = 25) -> List[Dict]:
"""Generic search method"""
self._rate_limit_search()
try:
response = self.session.get(
self.SEARCH_URL,
params={
'term': term,
'country': self.country,
'media': 'music',
'entity': entity,
'limit': min(limit, 200)
},
timeout=10
)
if response.status_code == 403:
# Rate limited
time.sleep(60) # Wait a minute
return []
if response.status_code != 200:
return []
data = response.json()
return data.get('results', [])
except Exception as e:
print(f"iTunes search error: {e}")
return []
def _lookup(self, **params) -> List[Dict]:
"""Generic lookup method (not rate limited)"""
try:
params['country'] = self.country
response = self.session.get(
self.LOOKUP_URL,
params=params,
timeout=10
)
if response.status_code != 200:
return []
data = response.json()
return data.get('results', [])
except Exception as e:
print(f"iTunes lookup error: {e}")
return []
# === Public API Methods ===
def search_tracks(self, query: str, limit: int = 25) -> List[iTunesTrack]:
"""Search for tracks/songs"""
results = self._search(query, 'song', limit)
return [
iTunesTrack.from_itunes_result(r)
for r in results
if r.get('wrapperType') == 'track'
]
def search_artists(self, query: str, limit: int = 25) -> List[iTunesArtist]:
"""Search for artists"""
results = self._search(query, 'musicArtist', limit)
return [
iTunesArtist.from_itunes_result(r)
for r in results
if r.get('wrapperType') == 'artist'
]
def search_albums(self, query: str, limit: int = 25) -> List[iTunesAlbum]:
"""Search for albums"""
results = self._search(query, 'album', limit)
return [
iTunesAlbum.from_itunes_result(r)
for r in results
if r.get('wrapperType') == 'collection'
]
def get_track(self, track_id: str) -> Optional[iTunesTrack]:
"""Get track by iTunes ID"""
results = self._lookup(id=track_id)
for r in results:
if r.get('wrapperType') == 'track':
return iTunesTrack.from_itunes_result(r)
return None
def get_album(self, album_id: str) -> Optional[iTunesAlbum]:
"""Get album by iTunes ID"""
results = self._lookup(id=album_id)
for r in results:
if r.get('wrapperType') == 'collection':
return iTunesAlbum.from_itunes_result(r)
return None
def get_album_tracks(self, album_id: str) -> List[iTunesTrack]:
"""Get all tracks for an album"""
results = self._lookup(id=album_id, entity='song')
tracks = []
for r in results:
if r.get('wrapperType') == 'track':
tracks.append(iTunesTrack.from_itunes_result(r))
# Sort by disc and track number
tracks.sort(key=lambda t: (t.disc_number, t.track_number))
return tracks
def get_artist(self, artist_id: str) -> Optional[iTunesArtist]:
"""Get artist by iTunes ID"""
results = self._lookup(id=artist_id)
for r in results:
if r.get('wrapperType') == 'artist':
return iTunesArtist.from_itunes_result(r)
return None
def get_artist_albums(self, artist_id: str, limit: int = 50) -> List[iTunesAlbum]:
"""Get all albums by an artist"""
results = self._lookup(id=artist_id, entity='album')
albums = []
for r in results:
if r.get('wrapperType') == 'collection':
albums.append(iTunesAlbum.from_itunes_result(r))
return albums[:limit]
def lookup_by_upc(self, upc: str) -> Optional[iTunesAlbum]:
"""Look up album by UPC barcode"""
results = self._lookup(upc=upc)
for r in results:
if r.get('wrapperType') == 'collection':
return iTunesAlbum.from_itunes_result(r)
return None
Rate Limiting Details
| Endpoint | Rate Limit | Behavior |
|---|---|---|
/search |
~20 calls/minute | Returns 403 Forbidden when exceeded |
/lookup |
Appears unlimited | No observed throttling |
Best Practices:
- Cache search results aggressively
- Use
/lookupfor subsequent detail fetches (not rate limited) - Implement exponential backoff on 403 errors
- Consider using iTunes IDs for cross-referencing after initial search
Metadata Comparison: iTunes vs Spotify
| Field | iTunes | Spotify |
|---|---|---|
| Track name | ✅ | ✅ |
| Artist name | ✅ | ✅ |
| Album name | ✅ | ✅ |
| Duration | ✅ | ✅ |
| Track/disc number | ✅ | ✅ |
| Release date | ✅ | ✅ |
| Artwork | ✅ (up to 600x600) | ✅ (up to 640x640) |
| Preview URL | ✅ | ✅ |
| Genre | ✅ (primary only) | ✅ (multiple) |
| Explicit flag | ✅ | ✅ |
| ISRC | ❌ | ✅ |
| Popularity | ❌ | ✅ |
| Audio features | ❌ | ✅ |
| Artist followers | ❌ | ✅ |
| Artist genres | ❌ | ✅ (multiple) |
Implementation Strategy
Unified Metadata Client
Create a unified client that abstracts the underlying data source:
from abc import ABC, abstractmethod
from typing import Optional, List
from dataclasses import dataclass
from enum import Enum
class MetadataSource(Enum):
SPOTIFY_OAUTH = "spotify_oauth"
SPOTIFY_CLIENT_CREDENTIALS = "spotify_client_credentials"
SPOTIFY_ANONYMOUS = "spotify_anonymous"
ITUNES = "itunes"
@dataclass
class UnifiedTrack:
"""Unified track representation across all sources"""
id: str
source: MetadataSource
name: str
artists: List[str]
album: str
duration_ms: int
track_number: Optional[int] = None
disc_number: Optional[int] = None
release_date: Optional[str] = None
preview_url: Optional[str] = None
image_url: Optional[str] = None
genre: Optional[str] = None
explicit: bool = False
popularity: Optional[int] = None # Spotify only
isrc: Optional[str] = None # Spotify only
# Original source IDs for cross-referencing
spotify_id: Optional[str] = None
itunes_id: Optional[str] = None
@dataclass
class UnifiedArtist:
"""Unified artist representation"""
id: str
source: MetadataSource
name: str
genres: List[str] = None
image_url: Optional[str] = None
popularity: Optional[int] = None # Spotify only
followers: Optional[int] = None # Spotify only
spotify_id: Optional[str] = None
itunes_id: Optional[str] = None
@dataclass
class UnifiedAlbum:
"""Unified album representation"""
id: str
source: MetadataSource
name: str
artists: List[str]
release_date: Optional[str] = None
total_tracks: Optional[int] = None
image_url: Optional[str] = None
genre: Optional[str] = None
explicit: bool = False
spotify_id: Optional[str] = None
itunes_id: Optional[str] = None
class MetadataClient:
"""
Unified metadata client with automatic fallback.
Priority order:
1. Spotify OAuth (if authenticated)
2. Spotify Client Credentials (if credentials configured)
3. Spotify Anonymous (if enabled)
4. iTunes Search API (always available)
"""
def __init__(self):
self.spotify_client: Optional[SpotifyClient] = None
self.spotify_anon_client: Optional[SpotifyAnonymousClient] = None
self.itunes_client: Optional[iTunesClient] = None
self._active_source: Optional[MetadataSource] = None
self._initialize_clients()
def _initialize_clients(self):
"""Initialize available clients in priority order"""
# Try Spotify OAuth first
try:
from core.spotify_client import SpotifyClient
self.spotify_client = SpotifyClient()
if self.spotify_client.is_authenticated():
self._active_source = MetadataSource.SPOTIFY_OAUTH
return
except Exception:
pass
# Try Spotify Anonymous
try:
self.spotify_anon_client = SpotifyAnonymousClient()
if self.spotify_anon_client.ensure_authenticated():
self._active_source = MetadataSource.SPOTIFY_ANONYMOUS
return
except Exception:
pass
# Fallback to iTunes (always available)
self.itunes_client = iTunesClient()
self._active_source = MetadataSource.ITUNES
@property
def active_source(self) -> MetadataSource:
return self._active_source
def search_tracks(self, query: str, limit: int = 20) -> List[UnifiedTrack]:
"""Search for tracks using best available source"""
# Try Spotify OAuth
if self.spotify_client and self.spotify_client.is_authenticated():
try:
tracks = self.spotify_client.search_tracks(query, limit)
return [self._spotify_track_to_unified(t) for t in tracks]
except Exception:
pass
# Try Spotify Anonymous
if self.spotify_anon_client:
try:
tracks = self.spotify_anon_client.search_tracks(query, limit)
if tracks:
return [self._spotify_anon_track_to_unified(t) for t in tracks]
except Exception:
pass
# Fallback to iTunes
if self.itunes_client:
tracks = self.itunes_client.search_tracks(query, limit)
return [self._itunes_track_to_unified(t) for t in tracks]
return []
def search_artists(self, query: str, limit: int = 20) -> List[UnifiedArtist]:
"""Search for artists using best available source"""
# Similar implementation with fallback chain
pass
def search_albums(self, query: str, limit: int = 20) -> List[UnifiedAlbum]:
"""Search for albums using best available source"""
# Similar implementation with fallback chain
pass
# === Conversion Methods ===
def _spotify_track_to_unified(self, track) -> UnifiedTrack:
"""Convert Spotify Track to UnifiedTrack"""
return UnifiedTrack(
id=track.id,
source=MetadataSource.SPOTIFY_OAUTH,
name=track.name,
artists=track.artists,
album=track.album,
duration_ms=track.duration_ms,
preview_url=track.preview_url,
image_url=track.image_url,
popularity=track.popularity,
spotify_id=track.id
)
def _itunes_track_to_unified(self, track: iTunesTrack) -> UnifiedTrack:
"""Convert iTunes Track to UnifiedTrack"""
return UnifiedTrack(
id=f"itunes:{track.id}",
source=MetadataSource.ITUNES,
name=track.name,
artists=track.artists,
album=track.album,
duration_ms=track.duration_ms,
track_number=track.track_number,
disc_number=track.disc_number,
release_date=track.release_date,
preview_url=track.preview_url,
image_url=track.image_url,
genre=track.genre,
explicit=track.explicit,
itunes_id=track.id
)
Integration with Existing SpotifyClient
Modify core/spotify_client.py to support the fallback chain:
class SpotifyClient:
def __init__(self):
self.sp: Optional[spotipy.Spotify] = None
self.sp_anon: Optional[SpotifyAnonymousClient] = None
self.itunes: Optional[iTunesClient] = None
self.user_id: Optional[str] = None
self._auth_mode: str = "none" # "oauth", "client_credentials", "anonymous", "itunes", "none"
self._setup_client()
def _setup_client(self):
config = config_manager.get_spotify_config()
client_id = config.get('client_id', '')
client_secret = config.get('client_secret', '')
# Check if credentials are placeholder values
has_valid_credentials = (
client_id and
client_secret and
client_id != 'SpotifyClientID' and
client_secret != 'SpotifyClientSecret'
)
if has_valid_credentials:
# Try OAuth first
try:
auth_manager = SpotifyOAuth(
client_id=client_id,
client_secret=client_secret,
redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"),
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email",
cache_path='config/.spotify_cache'
)
self.sp = spotipy.Spotify(auth_manager=auth_manager)
# Check if we have a valid token
if self._has_valid_oauth_token():
self._auth_mode = "oauth"
logger.info("Spotify client initialized with OAuth")
return
except Exception as e:
logger.warning(f"OAuth setup failed: {e}")
# Fallback to Client Credentials (no user data, but search works)
try:
auth_manager = SpotifyClientCredentials(
client_id=client_id,
client_secret=client_secret
)
self.sp = spotipy.Spotify(auth_manager=auth_manager)
self._auth_mode = "client_credentials"
logger.info("Spotify client initialized with Client Credentials")
return
except Exception as e:
logger.warning(f"Client Credentials setup failed: {e}")
# No valid credentials - try anonymous access
try:
self.sp_anon = SpotifyAnonymousClient()
if self.sp_anon.ensure_authenticated():
self._auth_mode = "anonymous"
logger.info("Spotify client initialized with anonymous access")
return
except Exception as e:
logger.warning(f"Anonymous Spotify access failed: {e}")
# Final fallback - iTunes
try:
self.itunes = iTunesClient()
self._auth_mode = "itunes"
logger.info("Using iTunes as metadata fallback")
except Exception as e:
logger.error(f"All metadata sources failed: {e}")
self._auth_mode = "none"
def _has_valid_oauth_token(self) -> bool:
"""Check if we have a valid OAuth token (not just credentials)"""
try:
self.sp.current_user()
return True
except:
return False
@property
def auth_mode(self) -> str:
"""Return current authentication mode"""
return self._auth_mode
def is_authenticated(self) -> bool:
"""Check if any authentication method is working"""
return self._auth_mode != "none"
def has_user_access(self) -> bool:
"""Check if we have access to user data (playlists, library)"""
return self._auth_mode == "oauth"
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
"""Search tracks using available source"""
if self._auth_mode in ("oauth", "client_credentials") and self.sp:
# Use official Spotify API
try:
results = self.sp.search(q=query, type='track', limit=limit)
return [Track.from_spotify_track(t) for t in results['tracks']['items']]
except Exception as e:
logger.error(f"Spotify search failed: {e}")
if self._auth_mode == "anonymous" and self.sp_anon:
# Use anonymous Spotify access
try:
return self.sp_anon.search_tracks(query, limit)
except Exception as e:
logger.error(f"Anonymous Spotify search failed: {e}")
if self.itunes:
# Fallback to iTunes
try:
itunes_tracks = self.itunes.search_tracks(query, limit)
return [self._itunes_to_track(t) for t in itunes_tracks]
except Exception as e:
logger.error(f"iTunes search failed: {e}")
return []
def _itunes_to_track(self, itunes_track: iTunesTrack) -> Track:
"""Convert iTunes track to Spotify-compatible Track dataclass"""
return Track(
id=f"itunes:{itunes_track.id}",
name=itunes_track.name,
artists=itunes_track.artists,
album=itunes_track.album,
duration_ms=itunes_track.duration_ms,
popularity=0, # iTunes doesn't have popularity
preview_url=itunes_track.preview_url,
image_url=itunes_track.image_url
)
Comparison Matrix
| Feature | Spotify OAuth | Spotify Client Creds | Spotify Anonymous | iTunes |
|---|---|---|---|---|
| Setup Required | Developer account + User OAuth | Developer account | None | None |
| User Playlists | ✅ | ❌ | ❌ | ❌ |
| User Library | ✅ | ❌ | ❌ | ❌ |
| Search | ✅ | ✅ | ✅ | ✅ |
| Track Metadata | ✅ Full | ✅ Full | ✅ Full | ✅ Basic |
| Audio Features | ✅ | ✅ | ❌ | ❌ |
| Artist Genres | ✅ | ✅ | ✅ | ❌ |
| Popularity | ✅ | ✅ | ✅ | ❌ |
| ISRC Codes | ✅ | ✅ | ✅ | ❌ |
| Stability | Stable | Stable | Unstable | Stable |
| TOS Compliant | ✅ | ✅ | ❌ | ✅ |
| Rate Limits | 180 req/min | 180 req/min | Unknown | ~20 search/min |
Recommended Approach
For SoulSync
- Primary: Keep Spotify OAuth for users who want full features (playlist sync)
- Secondary: Add Spotify Client Credentials fallback for search/metadata when OAuth not completed
- Tertiary: Implement iTunes fallback for when no Spotify credentials configured
- Optional: Add anonymous Spotify as experimental option (with clear warnings about instability)
Implementation Priority
Phase 1: Add Spotify Client Credentials fallback
- Simple change, stable, TOS compliant
- Search/metadata works after entering app credentials
Phase 2: Add iTunes fallback
- Works with zero configuration
- Good enough for basic search/matching
Phase 3 (Optional): Add anonymous Spotify
- Experimental/advanced feature
- Requires ongoing maintenance
- Add toggle in settings with warning
File Changes Required
core/spotify_client.py- Add fallback logiccore/itunes_client.py- New file for iTunes APIconfig/settings.py- Add iTunes/anonymous settingsweb_server.py- Update status endpoints to show active sourcetemplates/settings.html- UI for fallback options