You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/Support/METADATA-FALLBACK-IMPLEMENT...

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

  1. Overview
  2. Current Implementation
  3. Option A: SpotiFLAC-Style Anonymous Spotify Access
  4. Option B: iTunes Search API
  5. Implementation Strategy
  6. Comparison Matrix
  7. Recommended Approach

Overview

The Problem

Currently, SoulSync requires users to:

  1. Register at Spotify Developer Dashboard
  2. Create an application to get client_id and client_secret
  3. Enter credentials in settings
  4. 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_id and client_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

  1. Monitor SpotiFLAC repository for TOTP secret updates
  2. Track Spotify web player updates that may change query hashes
  3. Implement version detection to automatically try multiple TOTP versions
  4. 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 only
  • song - Tracks/songs only
  • album - Albums only
  • musicTrack - Same as song
  • mix - Mixes
  • musicVideo - 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:

  1. Cache search results aggressively
  2. Use /lookup for subsequent detail fetches (not rate limited)
  3. Implement exponential backoff on 403 errors
  4. 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

For SoulSync

  1. Primary: Keep Spotify OAuth for users who want full features (playlist sync)
  2. Secondary: Add Spotify Client Credentials fallback for search/metadata when OAuth not completed
  3. Tertiary: Implement iTunes fallback for when no Spotify credentials configured
  4. 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

  1. core/spotify_client.py - Add fallback logic
  2. core/itunes_client.py - New file for iTunes API
  3. config/settings.py - Add iTunes/anonymous settings
  4. web_server.py - Update status endpoints to show active source
  5. templates/settings.html - UI for fallback options

References