mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-04 20:43:04 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,662 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user