mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-04 04:23:02 +00:00
663 lines
22 KiB
Python
663 lines
22 KiB
Python
"""
|
|
Spotify Metadata Client for SwingMusic
|
|
Handles fetching metadata from Spotify for catalog browsing and downloads
|
|
|
|
UPDATED: Now uses Spotify Web Player API (NO ACCOUNT REQUIRED)
|
|
Based on SpotiFLAC approach - reverse-engineered Web Player authentication
|
|
|
|
This replaces the deprecated Spotify Web API which now requires Premium subscription.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from swingmusic.logger import log as logger
|
|
|
|
# Import the new Web Player client (no account required)
|
|
from swingmusic.services.spotify_web_player_client import (
|
|
SpotifyWebPlayerClient,
|
|
get_spotify_web_player_client,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SpotifyTrack:
|
|
"""Spotify track metadata"""
|
|
|
|
id: str
|
|
name: str
|
|
artists: list[dict[str, Any]]
|
|
album: dict[str, Any]
|
|
duration_ms: int
|
|
popularity: int
|
|
preview_url: str | None
|
|
explicit: bool
|
|
external_urls: dict[str, str]
|
|
track_number: int
|
|
disc_number: int
|
|
available_markets: list[str]
|
|
|
|
|
|
@dataclass
|
|
class SpotifyAlbum:
|
|
"""Spotify album metadata"""
|
|
|
|
id: str
|
|
name: str
|
|
artists: list[dict[str, Any]]
|
|
release_date: str
|
|
total_tracks: int
|
|
popularity: int
|
|
images: list[dict[str, str]]
|
|
external_urls: dict[str, str]
|
|
available_markets: list[str]
|
|
album_type: str # album, single, compilation
|
|
tracks: list[dict[str, Any]] = field(default_factory=list) # Track list
|
|
|
|
|
|
@dataclass
|
|
class SpotifyArtist:
|
|
"""Spotify artist metadata"""
|
|
|
|
id: str
|
|
name: str
|
|
popularity: int
|
|
followers: dict[str, int]
|
|
genres: list[str]
|
|
images: list[dict[str, str]]
|
|
external_urls: dict[str, str]
|
|
|
|
|
|
@dataclass
|
|
class SpotifyPlaylist:
|
|
"""Spotify playlist metadata"""
|
|
|
|
id: str
|
|
name: str
|
|
description: str | None
|
|
owner: dict[str, Any]
|
|
public: bool
|
|
collaborative: bool
|
|
tracks: dict[str, Any] # Contains href, total, limit
|
|
images: list[dict[str, str]]
|
|
external_urls: dict[str, str]
|
|
|
|
|
|
class SpotifyMetadataClient:
|
|
"""
|
|
Client for accessing Spotify metadata - NO ACCOUNT REQUIRED
|
|
|
|
Uses the Spotify Web Player API (reverse-engineered) which doesn't require
|
|
any authentication or Premium subscription. This is the same approach used
|
|
by SpotiFLAC and other open-source tools.
|
|
|
|
The old Spotify Web API is deprecated as it now requires Premium subscription.
|
|
"""
|
|
|
|
def __init__(self):
|
|
# Use the new Web Player client (no account required)
|
|
self._web_player_client: SpotifyWebPlayerClient | None = None
|
|
|
|
# Legacy API support (deprecated, requires Premium)
|
|
self.client_id = os.getenv("SPOTIFY_CLIENT_ID", "")
|
|
self.client_secret = os.getenv("SPOTIFY_CLIENT_SECRET", "")
|
|
self.access_token = None
|
|
self.token_expires_at = 0
|
|
self.base_url = "https://api.spotify.com/v1"
|
|
self.rate_limit_remaining = 0
|
|
self.rate_limit_reset = 0
|
|
|
|
# Always use Web Player client (no account needed)
|
|
self.use_demo_mode = False
|
|
self._use_web_player = True
|
|
|
|
# Use local logger if global logger is not available
|
|
local_logger = logger or logging.getLogger(__name__)
|
|
local_logger.info(
|
|
"SpotifyMetadataClient initialized with Web Player API (no account required)"
|
|
)
|
|
|
|
def _get_web_player_client(self) -> SpotifyWebPlayerClient:
|
|
"""Get or create the Web Player client"""
|
|
if self._web_player_client is None:
|
|
self._web_player_client = get_spotify_web_player_client()
|
|
return self._web_player_client
|
|
|
|
def _get_access_token(self) -> str | None:
|
|
"""Get access token - now using Web Player client (no account required)"""
|
|
# Web Player client handles its own authentication
|
|
# This method is kept for backward compatibility
|
|
return "web_player_token"
|
|
|
|
def _make_request(
|
|
self, endpoint: str, params: dict[str, Any] = None
|
|
) -> dict[str, Any] | None:
|
|
"""
|
|
Make request to Spotify - now using Web Player client (no account required)
|
|
|
|
This method is kept for backward compatibility but routes through
|
|
the Web Player client which doesn't require any authentication.
|
|
"""
|
|
# Parse endpoint to determine what to fetch
|
|
endpoint = endpoint.lstrip("/")
|
|
|
|
client = self._get_web_player_client()
|
|
|
|
# Handle track endpoints
|
|
if endpoint.startswith("tracks/"):
|
|
track_id = endpoint.split("/")[1]
|
|
track = client.get_track(track_id)
|
|
if track:
|
|
return self._track_to_dict(track)
|
|
return None
|
|
|
|
# Handle album endpoints
|
|
if endpoint.startswith("albums/"):
|
|
parts = endpoint.split("/")
|
|
album_id = parts[1]
|
|
if len(parts) > 2 and parts[2] == "tracks":
|
|
# Album tracks request
|
|
album = client.get_album(album_id)
|
|
if album:
|
|
return {"items": [self._track_to_dict(t) for t in album.tracks]}
|
|
else:
|
|
album = client.get_album(album_id)
|
|
if album:
|
|
return self._album_to_dict(album)
|
|
return None
|
|
|
|
# Handle artist endpoints
|
|
if endpoint.startswith("artists/"):
|
|
parts = endpoint.split("/")
|
|
artist_id = parts[1]
|
|
if len(parts) > 2:
|
|
sub_endpoint = parts[2]
|
|
endpoint_map = {
|
|
"albums": {"items": []},
|
|
"top-tracks": {"tracks": []},
|
|
"related-artists": {"artists": []},
|
|
}
|
|
return endpoint_map.get(sub_endpoint)
|
|
else:
|
|
artist = client.get_artist(artist_id)
|
|
if artist:
|
|
return self._artist_to_dict(artist)
|
|
return None
|
|
|
|
# Handle playlist endpoints
|
|
if endpoint.startswith("playlists/"):
|
|
parts = endpoint.split("/")
|
|
playlist_id = parts[1]
|
|
if len(parts) > 2 and parts[2] == "tracks":
|
|
playlist = client.get_playlist(playlist_id)
|
|
if playlist:
|
|
return {
|
|
"items": [
|
|
{"track": self._track_to_dict(t)} for t in playlist.tracks
|
|
]
|
|
}
|
|
else:
|
|
playlist = client.get_playlist(playlist_id)
|
|
if playlist:
|
|
return self._playlist_to_dict(playlist)
|
|
return None
|
|
|
|
# Handle search
|
|
if endpoint == "search":
|
|
query = params.get("q", "") if params else ""
|
|
search_type = params.get("type", "track") if params else "track"
|
|
# Search would need additional implementation
|
|
logger.info(f"Search for '{query}' type={search_type}")
|
|
return {
|
|
"tracks": {"items": []},
|
|
"albums": {"items": []},
|
|
"artists": {"items": []},
|
|
}
|
|
|
|
logger.warning(f"Unknown endpoint: {endpoint}")
|
|
return None
|
|
|
|
def _track_to_dict(self, track) -> dict:
|
|
"""Convert SpotifyTrack to dict format expected by legacy code"""
|
|
return {
|
|
"id": track.id,
|
|
"name": track.name,
|
|
"artists": track.artists,
|
|
"album": track.album,
|
|
"duration_ms": track.duration_ms,
|
|
"popularity": track.popularity,
|
|
"preview_url": track.preview_url,
|
|
"explicit": track.explicit,
|
|
"external_urls": track.external_urls,
|
|
"track_number": track.track_number,
|
|
"disc_number": track.disc_number,
|
|
"available_markets": [],
|
|
}
|
|
|
|
def _album_to_dict(self, album) -> dict:
|
|
"""Convert SpotifyAlbum to dict format"""
|
|
return {
|
|
"id": album.id,
|
|
"name": album.name,
|
|
"artists": album.artists,
|
|
"release_date": str(album.release_date),
|
|
"total_tracks": album.total_tracks,
|
|
"popularity": 0,
|
|
"images": album.images,
|
|
"external_urls": album.external_urls,
|
|
"available_markets": [],
|
|
"album_type": album.album_type,
|
|
"tracks": {"items": [self._track_to_dict(t) for t in album.tracks]},
|
|
}
|
|
|
|
def _artist_to_dict(self, artist) -> dict:
|
|
"""Convert SpotifyArtist to dict format"""
|
|
return {
|
|
"id": artist.id,
|
|
"name": artist.name,
|
|
"popularity": artist.popularity,
|
|
"followers": {"total": artist.followers},
|
|
"genres": artist.genres,
|
|
"images": artist.images,
|
|
"external_urls": artist.external_urls,
|
|
}
|
|
|
|
def _playlist_to_dict(self, playlist) -> dict:
|
|
"""Convert SpotifyPlaylist to dict format"""
|
|
return {
|
|
"id": playlist.id,
|
|
"name": playlist.name,
|
|
"description": playlist.description,
|
|
"owner": playlist.owner,
|
|
"public": False,
|
|
"collaborative": False,
|
|
"tracks": {"total": playlist.total_tracks},
|
|
"images": playlist.images,
|
|
"external_urls": playlist.external_urls,
|
|
}
|
|
|
|
def _demo_response(
|
|
self, endpoint: str, params: dict[str, Any] = None
|
|
) -> dict[str, Any] | None:
|
|
"""DEPRECATED: Demo responses are no longer used - Web Player client provides real data"""
|
|
logger.warning(f"Demo mode called but deprecated - endpoint: {endpoint}")
|
|
return None
|
|
|
|
def get_track(self, track_id: str) -> SpotifyTrack | None:
|
|
"""Get track by ID"""
|
|
data = self._make_request(f"tracks/{track_id}")
|
|
if not data:
|
|
return None
|
|
|
|
return SpotifyTrack(
|
|
id=data["id"],
|
|
name=data["name"],
|
|
artists=data["artists"],
|
|
album=data["album"],
|
|
duration_ms=data["duration_ms"],
|
|
popularity=data["popularity"],
|
|
preview_url=data.get("preview_url"),
|
|
explicit=data["explicit"],
|
|
external_urls=data["external_urls"],
|
|
track_number=data["track_number"],
|
|
disc_number=data.get("disc_number", 1),
|
|
available_markets=data.get("available_markets", []),
|
|
)
|
|
|
|
def get_album(self, album_id: str) -> SpotifyAlbum | None:
|
|
"""Get album by ID"""
|
|
data = self._make_request(f"albums/{album_id}")
|
|
if not data:
|
|
return None
|
|
|
|
return SpotifyAlbum(
|
|
id=data["id"],
|
|
name=data["name"],
|
|
artists=data["artists"],
|
|
release_date=data["release_date"],
|
|
total_tracks=data["total_tracks"],
|
|
popularity=data.get("popularity", 0),
|
|
images=data["images"],
|
|
external_urls=data["external_urls"],
|
|
available_markets=data.get("available_markets", []),
|
|
album_type=data["album_type"],
|
|
)
|
|
|
|
def get_album_tracks(
|
|
self, album_id: str, limit: int = 50, offset: int = 0
|
|
) -> list[SpotifyTrack]:
|
|
"""Get tracks from album"""
|
|
data = self._make_request(
|
|
f"albums/{album_id}/tracks", {"limit": limit, "offset": offset}
|
|
)
|
|
|
|
if not data or "items" not in data:
|
|
return []
|
|
|
|
tracks = []
|
|
for item in data["items"]:
|
|
# Get full track details for each track
|
|
track = self.get_track(item["id"])
|
|
if track:
|
|
tracks.append(track)
|
|
|
|
return tracks
|
|
|
|
def get_artist(self, artist_id: str) -> SpotifyArtist | None:
|
|
"""Get artist by ID"""
|
|
data = self._make_request(f"artists/{artist_id}")
|
|
if not data:
|
|
return None
|
|
|
|
return SpotifyArtist(
|
|
id=data["id"],
|
|
name=data["name"],
|
|
popularity=data["popularity"],
|
|
followers=data["followers"],
|
|
genres=data["genres"],
|
|
images=data["images"],
|
|
external_urls=data["external_urls"],
|
|
)
|
|
|
|
def get_artist_albums(
|
|
self,
|
|
artist_id: str,
|
|
limit: int = 20,
|
|
include_groups: str = "album,single",
|
|
offset: int = 0,
|
|
) -> list[SpotifyAlbum]:
|
|
"""Get artist albums"""
|
|
albums = []
|
|
page_offset = max(0, int(offset))
|
|
remaining = max(1, int(limit))
|
|
|
|
# Spotify API page size upper bound.
|
|
while remaining > 0:
|
|
page_size = min(50, remaining)
|
|
data = self._make_request(
|
|
f"artists/{artist_id}/albums",
|
|
{
|
|
"limit": page_size,
|
|
"offset": page_offset,
|
|
"include_groups": include_groups,
|
|
},
|
|
)
|
|
|
|
if not data or "items" not in data:
|
|
break
|
|
|
|
items = data["items"]
|
|
if not items:
|
|
break
|
|
|
|
for item in items:
|
|
album = SpotifyAlbum(
|
|
id=item["id"],
|
|
name=item["name"],
|
|
artists=item["artists"],
|
|
release_date=item["release_date"],
|
|
total_tracks=item["total_tracks"],
|
|
popularity=item.get("popularity", 0),
|
|
images=item["images"],
|
|
external_urls=item["external_urls"],
|
|
available_markets=item.get("available_markets", []),
|
|
album_type=item["album_type"],
|
|
)
|
|
albums.append(album)
|
|
|
|
fetched = len(items)
|
|
remaining -= fetched
|
|
page_offset += fetched
|
|
|
|
# Last page reached.
|
|
if fetched < page_size:
|
|
break
|
|
|
|
return albums
|
|
|
|
def get_artist_top_tracks(
|
|
self, artist_id: str, market: str = "US"
|
|
) -> list[SpotifyTrack]:
|
|
"""Get artist's top tracks"""
|
|
data = self._make_request(f"artists/{artist_id}/top-tracks", {"market": market})
|
|
|
|
if not data or "tracks" not in data:
|
|
return []
|
|
|
|
tracks = []
|
|
for item in data["tracks"]:
|
|
track = SpotifyTrack(
|
|
id=item["id"],
|
|
name=item["name"],
|
|
artists=item["artists"],
|
|
album=item["album"],
|
|
duration_ms=item["duration_ms"],
|
|
popularity=item["popularity"],
|
|
preview_url=item.get("preview_url"),
|
|
explicit=item["explicit"],
|
|
external_urls=item["external_urls"],
|
|
track_number=item.get("track_number", 1),
|
|
disc_number=item.get("disc_number", 1),
|
|
available_markets=item.get("available_markets", []),
|
|
)
|
|
tracks.append(track)
|
|
|
|
return tracks
|
|
|
|
def get_related_artists(self, artist_id: str) -> list[SpotifyArtist]:
|
|
"""Get related artists"""
|
|
data = self._make_request(f"artists/{artist_id}/related-artists")
|
|
|
|
if not data or "artists" not in data:
|
|
return []
|
|
|
|
artists = []
|
|
for item in data["artists"]:
|
|
artist = SpotifyArtist(
|
|
id=item["id"],
|
|
name=item["name"],
|
|
popularity=item["popularity"],
|
|
followers=item["followers"],
|
|
genres=item["genres"],
|
|
images=item["images"],
|
|
external_urls=item["external_urls"],
|
|
)
|
|
artists.append(artist)
|
|
|
|
return artists
|
|
|
|
def get_playlist(self, playlist_id: str) -> SpotifyPlaylist | None:
|
|
"""Get playlist by ID"""
|
|
data = self._make_request(f"playlists/{playlist_id}")
|
|
if not data:
|
|
return None
|
|
|
|
return SpotifyPlaylist(
|
|
id=data["id"],
|
|
name=data["name"],
|
|
description=data.get("description"),
|
|
owner=data.get("owner", {}),
|
|
public=bool(data.get("public", False)),
|
|
collaborative=bool(data.get("collaborative", False)),
|
|
tracks=data.get("tracks", {}),
|
|
images=data.get("images", []),
|
|
external_urls=data.get("external_urls", {}),
|
|
)
|
|
|
|
def get_playlist_tracks(
|
|
self,
|
|
playlist_id: str,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
market: str = "US",
|
|
) -> list[SpotifyTrack]:
|
|
"""Get playlist tracks"""
|
|
tracks: list[SpotifyTrack] = []
|
|
page_offset = max(0, int(offset))
|
|
remaining = max(1, int(limit))
|
|
|
|
while remaining > 0:
|
|
page_size = min(100, remaining)
|
|
data = self._make_request(
|
|
f"playlists/{playlist_id}/tracks",
|
|
{
|
|
"limit": page_size,
|
|
"offset": page_offset,
|
|
"market": market,
|
|
},
|
|
)
|
|
|
|
if not data or "items" not in data:
|
|
break
|
|
|
|
items = data["items"]
|
|
if not items:
|
|
break
|
|
|
|
for item in items:
|
|
track_data = item.get("track") if isinstance(item, dict) else None
|
|
if not isinstance(track_data, dict):
|
|
continue
|
|
|
|
track_id = track_data.get("id")
|
|
if not track_id:
|
|
continue
|
|
|
|
track = SpotifyTrack(
|
|
id=track_id,
|
|
name=track_data.get("name", ""),
|
|
artists=track_data.get("artists", []),
|
|
album=track_data.get("album", {}),
|
|
duration_ms=int(track_data.get("duration_ms") or 0),
|
|
popularity=int(track_data.get("popularity") or 0),
|
|
preview_url=track_data.get("preview_url"),
|
|
explicit=bool(track_data.get("explicit", False)),
|
|
external_urls=track_data.get("external_urls", {}),
|
|
track_number=int(track_data.get("track_number") or 0),
|
|
disc_number=int(track_data.get("disc_number") or 1),
|
|
available_markets=track_data.get("available_markets", []),
|
|
)
|
|
tracks.append(track)
|
|
|
|
fetched = len(items)
|
|
remaining -= fetched
|
|
page_offset += fetched
|
|
|
|
if fetched < page_size:
|
|
break
|
|
|
|
return tracks
|
|
|
|
def search(
|
|
self,
|
|
query: str,
|
|
search_type: str = "track",
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
market: str = "US",
|
|
) -> dict[str, list]:
|
|
"""Search for content"""
|
|
types = (
|
|
search_type
|
|
if search_type in ["track", "album", "artist", "playlist"]
|
|
else "track"
|
|
)
|
|
|
|
data = self._make_request(
|
|
"search",
|
|
{
|
|
"q": query,
|
|
"type": types,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"market": market,
|
|
},
|
|
)
|
|
|
|
if not data:
|
|
return {"tracks": [], "albums": [], "artists": [], "playlists": []}
|
|
|
|
result = {"tracks": [], "albums": [], "artists": [], "playlists": []}
|
|
|
|
# Process tracks
|
|
if "tracks" in data and "items" in data["tracks"]:
|
|
for item in data["tracks"]["items"]:
|
|
track = SpotifyTrack(
|
|
id=item["id"],
|
|
name=item["name"],
|
|
artists=item["artists"],
|
|
album=item["album"],
|
|
duration_ms=item["duration_ms"],
|
|
popularity=item["popularity"],
|
|
preview_url=item.get("preview_url"),
|
|
explicit=item["explicit"],
|
|
external_urls=item["external_urls"],
|
|
track_number=item.get("track_number", 1),
|
|
disc_number=item.get("disc_number", 1),
|
|
available_markets=item.get("available_markets", []),
|
|
)
|
|
result["tracks"].append(track)
|
|
|
|
# Process albums
|
|
if "albums" in data and "items" in data["albums"]:
|
|
for item in data["albums"]["items"]:
|
|
album = SpotifyAlbum(
|
|
id=item["id"],
|
|
name=item["name"],
|
|
artists=item["artists"],
|
|
release_date=item["release_date"],
|
|
total_tracks=item["total_tracks"],
|
|
popularity=item.get("popularity", 0),
|
|
images=item["images"],
|
|
external_urls=item["external_urls"],
|
|
available_markets=item.get("available_markets", []),
|
|
album_type=item["album_type"],
|
|
)
|
|
result["albums"].append(album)
|
|
|
|
# Process artists
|
|
if "artists" in data and "items" in data["artists"]:
|
|
for item in data["artists"]["items"]:
|
|
artist = SpotifyArtist(
|
|
id=item["id"],
|
|
name=item["name"],
|
|
popularity=item["popularity"],
|
|
followers=item["followers"],
|
|
genres=item["genres"],
|
|
images=item["images"],
|
|
external_urls=item["external_urls"],
|
|
)
|
|
result["artists"].append(artist)
|
|
|
|
# Process playlists
|
|
if "playlists" in data and "items" in data["playlists"]:
|
|
for item in data["playlists"]["items"]:
|
|
playlist = SpotifyPlaylist(
|
|
id=item["id"],
|
|
name=item["name"],
|
|
description=item.get("description"),
|
|
owner=item["owner"],
|
|
public=item.get("public", False),
|
|
collaborative=item.get("collaborative", False),
|
|
tracks=item["tracks"],
|
|
images=item.get("images", []),
|
|
external_urls=item["external_urls"],
|
|
)
|
|
result["playlists"].append(playlist)
|
|
|
|
return result
|
|
|
|
|
|
# Global instance - lazy initialization
|
|
spotify_metadata_client = None
|
|
|
|
|
|
def get_spotify_metadata_client():
|
|
"""Get or create the Spotify metadata client instance"""
|
|
global spotify_metadata_client
|
|
if spotify_metadata_client is None:
|
|
spotify_metadata_client = SpotifyMetadataClient()
|
|
return spotify_metadata_client
|