Files
Tomas Dvorak 6e8fedf534 first commit
2026-04-13 17:46:58 +02:00

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