mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-03 20:13:03 +00:00
1118 lines
40 KiB
Python
1118 lines
40 KiB
Python
"""
|
|
Music Catalog Service for SwingMusic
|
|
Provides Spotify-like browsing of global music catalog with download capabilities
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from contextlib import contextmanager
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime, timedelta
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
from swingmusic import logger
|
|
from swingmusic.settings import Paths
|
|
|
|
|
|
@contextmanager
|
|
def get_db_connection():
|
|
conn = sqlite3.connect(Paths().app_db_path)
|
|
try:
|
|
yield conn
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
class CatalogItemType(Enum):
|
|
TRACK = "track"
|
|
ALBUM = "album"
|
|
ARTIST = "artist"
|
|
PLAYLIST = "playlist"
|
|
|
|
|
|
@dataclass
|
|
class CatalogItem:
|
|
"""Represents an item in the global music catalog"""
|
|
|
|
spotify_id: str
|
|
item_type: CatalogItemType
|
|
title: str
|
|
artist: str
|
|
album: str | None = None
|
|
duration_ms: int | None = None
|
|
popularity: int | None = None
|
|
preview_url: str | None = None
|
|
image_url: str | None = None
|
|
release_date: str | None = None
|
|
explicit: bool = False
|
|
data: dict[str, Any] | None = None
|
|
cached_at: datetime | None = None
|
|
expires_at: datetime | None = None
|
|
|
|
|
|
@dataclass
|
|
class ArtistInfo:
|
|
"""Extended artist information with top tracks"""
|
|
|
|
spotify_id: str
|
|
name: str
|
|
image_url: str | None = None
|
|
followers: int | None = None
|
|
popularity: int | None = None
|
|
genres: list[str] | None = None
|
|
top_tracks: list[CatalogItem] | None = None
|
|
albums: list[CatalogItem] | None = None
|
|
related_artists: list[dict] | None = None
|
|
|
|
|
|
@dataclass
|
|
class SearchResult:
|
|
"""Global search result across all content types"""
|
|
|
|
tracks: list[CatalogItem]
|
|
albums: list[CatalogItem]
|
|
artists: list[CatalogItem]
|
|
playlists: list[CatalogItem]
|
|
total: int
|
|
query: str
|
|
|
|
|
|
class MusicCatalogService:
|
|
"""Service for managing global music catalog with caching"""
|
|
|
|
def __init__(self):
|
|
self.cache_ttl = 3600 # 1 hour default cache TTL
|
|
self.max_top_tracks = 15
|
|
# Discography should be effectively complete; configurable for resource control.
|
|
self.max_albums_per_artist = max(
|
|
20, min(int(os.getenv("SWINGMUSIC_MAX_ARTIST_DISCOGRAPHY", "200")), 500)
|
|
)
|
|
self.session = None
|
|
|
|
async def _get_session(self):
|
|
"""Get or create aiohttp session"""
|
|
if self.session is None:
|
|
self.session = aiohttp.ClientSession()
|
|
return self.session
|
|
|
|
async def close(self):
|
|
"""Close aiohttp session"""
|
|
if self.session:
|
|
await self.session.close()
|
|
|
|
def _get_spotify_client(self):
|
|
"""Get Spotify metadata client"""
|
|
try:
|
|
from swingmusic.services.spotify_metadata_client import (
|
|
get_spotify_metadata_client,
|
|
)
|
|
|
|
return get_spotify_metadata_client()
|
|
except ImportError:
|
|
logger.warning("Spotify metadata client not available for catalog service")
|
|
return None
|
|
|
|
@staticmethod
|
|
def _serialize_catalog_item(item: CatalogItem) -> dict[str, Any]:
|
|
payload = asdict(item)
|
|
item_type = payload.get("item_type")
|
|
if isinstance(item_type, Enum):
|
|
payload["item_type"] = item_type.value
|
|
return payload
|
|
|
|
@staticmethod
|
|
def _deserialize_catalog_item(payload: dict[str, Any]) -> CatalogItem:
|
|
item_type = payload.get("item_type")
|
|
if isinstance(item_type, str):
|
|
try:
|
|
payload = {**payload, "item_type": CatalogItemType(item_type)}
|
|
except ValueError:
|
|
payload = {**payload, "item_type": CatalogItemType.TRACK}
|
|
return CatalogItem(**payload)
|
|
|
|
async def get_artist_top_tracks(
|
|
self, artist_id: str, limit: int = 15
|
|
) -> list[CatalogItem]:
|
|
"""
|
|
Get artist's most popular tracks
|
|
|
|
Args:
|
|
artist_id: Spotify artist ID
|
|
limit: Maximum number of tracks to return
|
|
|
|
Returns:
|
|
List of popular tracks
|
|
"""
|
|
try:
|
|
# Check cache first
|
|
cached_tracks = await self._get_cached_artist_top_tracks(artist_id, limit)
|
|
if cached_tracks:
|
|
return cached_tracks
|
|
|
|
# Fetch from Spotify API
|
|
spotify_client = self._get_spotify_client()
|
|
if not spotify_client:
|
|
return []
|
|
|
|
# This would integrate with the existing Spotify metadata client
|
|
# For now, return empty list - integration point
|
|
tracks_data = await self._fetch_artist_top_tracks_from_spotify(
|
|
artist_id, limit
|
|
)
|
|
|
|
# Cache the results
|
|
await self._cache_artist_top_tracks(artist_id, tracks_data)
|
|
|
|
return tracks_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting artist top tracks: {e}")
|
|
return []
|
|
|
|
async def get_artist_discography(self, artist_id: str) -> list[CatalogItem]:
|
|
"""
|
|
Get complete artist discography with albums
|
|
|
|
Args:
|
|
artist_id: Spotify artist ID
|
|
|
|
Returns:
|
|
List of artist albums
|
|
"""
|
|
try:
|
|
# Check cache first
|
|
cached_albums = await self._get_cached_artist_albums(artist_id)
|
|
if cached_albums:
|
|
return cached_albums
|
|
|
|
# Fetch from Spotify API
|
|
spotify_client = self._get_spotify_client()
|
|
if not spotify_client:
|
|
return []
|
|
|
|
albums_data = await self._fetch_artist_albums_from_spotify(artist_id)
|
|
|
|
# Cache the results
|
|
await self._cache_artist_albums(artist_id, albums_data)
|
|
|
|
return albums_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting artist discography: {e}")
|
|
return []
|
|
|
|
async def get_album_details(self, album_id: str) -> CatalogItem | None:
|
|
"""
|
|
Get full album information with tracklist
|
|
|
|
Args:
|
|
album_id: Spotify album ID
|
|
|
|
Returns:
|
|
Album details with tracklist
|
|
"""
|
|
try:
|
|
# Check cache first
|
|
cached_album = await self._get_cached_album(album_id)
|
|
if cached_album:
|
|
return cached_album
|
|
|
|
# Fetch from Spotify API
|
|
spotify_client = self._get_spotify_client()
|
|
if not spotify_client:
|
|
return None
|
|
|
|
album_data = await self._fetch_album_details_from_spotify(album_id)
|
|
|
|
# Cache the result
|
|
await self._cache_album(album_id, album_data)
|
|
|
|
return album_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting album details: {e}")
|
|
return None
|
|
|
|
async def get_playlist_details(
|
|
self, playlist_id: str, limit: int = 200
|
|
) -> CatalogItem | None:
|
|
"""
|
|
Get full playlist information with tracklist
|
|
|
|
Args:
|
|
playlist_id: Spotify playlist ID
|
|
limit: Maximum number of tracks to include
|
|
|
|
Returns:
|
|
Playlist details with tracks
|
|
"""
|
|
try:
|
|
spotify_client = self._get_spotify_client()
|
|
if not spotify_client:
|
|
return None
|
|
|
|
playlist = spotify_client.get_playlist(playlist_id)
|
|
if not playlist:
|
|
return None
|
|
|
|
tracks = spotify_client.get_playlist_tracks(
|
|
playlist_id,
|
|
limit=min(max(int(limit), 1), 300),
|
|
)
|
|
|
|
track_items: list[dict[str, Any]] = []
|
|
for track in tracks:
|
|
track_items.append(
|
|
{
|
|
"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,
|
|
}
|
|
)
|
|
|
|
owner = playlist.owner or {}
|
|
owner_name = (
|
|
owner.get("display_name", "") if isinstance(owner, dict) else ""
|
|
)
|
|
|
|
return CatalogItem(
|
|
spotify_id=playlist.id,
|
|
item_type=CatalogItemType.PLAYLIST,
|
|
title=playlist.name,
|
|
artist=owner_name,
|
|
popularity=0,
|
|
image_url=playlist.images[0]["url"] if playlist.images else None,
|
|
explicit=False,
|
|
data={
|
|
"description": playlist.description,
|
|
"owner": owner,
|
|
"public": playlist.public,
|
|
"collaborative": playlist.collaborative,
|
|
"tracks_total": (playlist.tracks or {}).get("total"),
|
|
"external_urls": playlist.external_urls,
|
|
"tracks": track_items,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error getting playlist details: {e}")
|
|
return None
|
|
|
|
async def search_global_catalog(
|
|
self, query: str, item_type: str = "all", limit: int = 20
|
|
) -> SearchResult:
|
|
"""
|
|
Search across all music types in global catalog
|
|
|
|
Args:
|
|
query: Search query
|
|
item_type: Type of content to search (all, tracks, albums, artists, playlists)
|
|
limit: Maximum results per type
|
|
|
|
Returns:
|
|
Search results across specified types
|
|
"""
|
|
try:
|
|
# Check cache first
|
|
cache_key = f"search:{query}:{item_type}:{limit}"
|
|
cached_result = await self._get_cached_search(cache_key)
|
|
if cached_result:
|
|
return cached_result
|
|
|
|
# Search different types based on request
|
|
tracks = []
|
|
albums = []
|
|
artists = []
|
|
playlists = []
|
|
|
|
spotify_client = self._get_spotify_client()
|
|
if spotify_client:
|
|
if item_type in ["all", "tracks"]:
|
|
tracks = await self._search_tracks(query, limit)
|
|
if item_type in ["all", "albums"]:
|
|
albums = await self._search_albums(query, limit)
|
|
if item_type in ["all", "artists"]:
|
|
artists = await self._search_artists(query, limit)
|
|
if item_type in ["all", "playlists"]:
|
|
playlists = await self._search_playlists(query, limit)
|
|
|
|
result = SearchResult(
|
|
tracks=tracks,
|
|
albums=albums,
|
|
artists=artists,
|
|
playlists=playlists,
|
|
total=len(tracks) + len(albums) + len(artists) + len(playlists),
|
|
query=query,
|
|
)
|
|
|
|
# Cache the search result
|
|
await self._cache_search(cache_key, result)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching global catalog: {e}")
|
|
return SearchResult([], [], [], [], 0, query)
|
|
|
|
async def get_artist_info(self, artist_id: str) -> ArtistInfo | None:
|
|
"""
|
|
Get comprehensive artist information including top tracks and albums
|
|
|
|
Args:
|
|
artist_id: Spotify artist ID
|
|
|
|
Returns:
|
|
Complete artist information
|
|
"""
|
|
try:
|
|
# Check cache first
|
|
cached_info = await self._get_cached_artist_info(artist_id)
|
|
if cached_info:
|
|
return cached_info
|
|
|
|
# Fetch all artist data concurrently
|
|
top_tracks_task = self.get_artist_top_tracks(artist_id, self.max_top_tracks)
|
|
albums_task = self.get_artist_discography(artist_id)
|
|
basic_info_task = self._get_artist_basic_info(artist_id)
|
|
|
|
top_tracks, albums, basic_info = await asyncio.gather(
|
|
top_tracks_task, albums_task, basic_info_task, return_exceptions=True
|
|
)
|
|
|
|
if isinstance(basic_info, Exception):
|
|
logger.error(f"Error getting basic artist info: {basic_info}")
|
|
return None
|
|
|
|
artist_info = ArtistInfo(
|
|
spotify_id=artist_id,
|
|
name=basic_info.get("name", ""),
|
|
image_url=basic_info.get("image_url"),
|
|
followers=basic_info.get("followers"),
|
|
popularity=basic_info.get("popularity"),
|
|
genres=basic_info.get("genres", []),
|
|
top_tracks=top_tracks if not isinstance(top_tracks, Exception) else [],
|
|
albums=albums if not isinstance(albums, Exception) else [],
|
|
related_artists=basic_info.get("related_artists", []),
|
|
)
|
|
|
|
# Cache the complete artist info
|
|
await self._cache_artist_info(artist_id, artist_info)
|
|
|
|
return artist_info
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting artist info: {e}")
|
|
return None
|
|
|
|
# Cache management methods
|
|
async def _get_cached_artist_top_tracks(
|
|
self, artist_id: str, limit: int
|
|
) -> list[CatalogItem] | None:
|
|
"""Get cached top tracks for artist"""
|
|
try:
|
|
with get_db_connection() as conn:
|
|
query = """
|
|
SELECT data FROM global_catalog_cache
|
|
WHERE spotify_id = ? AND item_type = 'artist_top_tracks'
|
|
AND expires_at > datetime('now')
|
|
ORDER BY cached_at DESC LIMIT 1
|
|
"""
|
|
cursor = conn.execute(query, (artist_id,))
|
|
row = cursor.fetchone()
|
|
|
|
if row:
|
|
data = json.loads(row[0])
|
|
return [
|
|
self._deserialize_catalog_item(item)
|
|
for item in data.get("tracks", [])[:limit]
|
|
]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting cached artist top tracks: {e}")
|
|
|
|
return None
|
|
|
|
async def _cache_artist_top_tracks(self, artist_id: str, tracks: list[CatalogItem]):
|
|
"""Cache artist top tracks"""
|
|
try:
|
|
expires_at = datetime.now() + timedelta(seconds=self.cache_ttl)
|
|
|
|
with get_db_connection() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO global_catalog_cache
|
|
(spotify_id, item_type, title, artist, explicit, data, cached_at, expires_at)
|
|
VALUES (?, 'artist_top_tracks', ?, ?, 0, ?, datetime('now'), ?)
|
|
""",
|
|
(
|
|
artist_id,
|
|
f"Top tracks for {artist_id}",
|
|
"",
|
|
json.dumps(
|
|
{
|
|
"tracks": [
|
|
self._serialize_catalog_item(track)
|
|
for track in tracks
|
|
]
|
|
}
|
|
),
|
|
expires_at.isoformat(),
|
|
),
|
|
)
|
|
conn.commit()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error caching artist top tracks: {e}")
|
|
|
|
async def _get_cached_artist_albums(
|
|
self, artist_id: str
|
|
) -> list[CatalogItem] | None:
|
|
"""Get cached albums for artist"""
|
|
try:
|
|
with get_db_connection() as conn:
|
|
query = """
|
|
SELECT data FROM global_catalog_cache
|
|
WHERE spotify_id = ? AND item_type = 'artist_albums'
|
|
AND expires_at > datetime('now')
|
|
ORDER BY cached_at DESC LIMIT 1
|
|
"""
|
|
cursor = conn.execute(query, (artist_id,))
|
|
row = cursor.fetchone()
|
|
|
|
if row:
|
|
data = json.loads(row[0])
|
|
return [
|
|
self._deserialize_catalog_item(item)
|
|
for item in data.get("albums", [])
|
|
]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting cached artist albums: {e}")
|
|
|
|
return None
|
|
|
|
async def _cache_artist_albums(self, artist_id: str, albums: list[CatalogItem]):
|
|
"""Cache artist albums"""
|
|
try:
|
|
expires_at = datetime.now() + timedelta(seconds=self.cache_ttl)
|
|
|
|
with get_db_connection() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO global_catalog_cache
|
|
(spotify_id, item_type, title, artist, explicit, data, cached_at, expires_at)
|
|
VALUES (?, 'artist_albums', ?, ?, 0, ?, datetime('now'), ?)
|
|
""",
|
|
(
|
|
artist_id,
|
|
f"Albums for {artist_id}",
|
|
"",
|
|
json.dumps(
|
|
{
|
|
"albums": [
|
|
self._serialize_catalog_item(album)
|
|
for album in albums
|
|
]
|
|
}
|
|
),
|
|
expires_at.isoformat(),
|
|
),
|
|
)
|
|
conn.commit()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error caching artist albums: {e}")
|
|
|
|
async def _get_cached_album(self, album_id: str) -> CatalogItem | None:
|
|
"""Get cached album details"""
|
|
try:
|
|
with get_db_connection() as conn:
|
|
query = """
|
|
SELECT * FROM global_catalog_cache
|
|
WHERE spotify_id = ? AND item_type = 'album'
|
|
AND expires_at > datetime('now')
|
|
ORDER BY cached_at DESC LIMIT 1
|
|
"""
|
|
cursor = conn.execute(query, (album_id,))
|
|
row = cursor.fetchone()
|
|
|
|
if row:
|
|
return CatalogItem(
|
|
spotify_id=row[1],
|
|
item_type=CatalogItemType(row[2]),
|
|
title=row[3],
|
|
artist=row[4],
|
|
album=row[5],
|
|
duration_ms=row[6],
|
|
popularity=row[7],
|
|
preview_url=row[8],
|
|
image_url=row[9],
|
|
release_date=row[10],
|
|
explicit=bool(row[11]),
|
|
data=json.loads(row[12]) if row[12] else None,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting cached album: {e}")
|
|
|
|
return None
|
|
|
|
async def _cache_album(self, album_id: str, album: CatalogItem):
|
|
"""Cache album details"""
|
|
try:
|
|
expires_at = datetime.now() + timedelta(seconds=self.cache_ttl)
|
|
|
|
with get_db_connection() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO global_catalog_cache
|
|
(spotify_id, item_type, title, artist, album, duration_ms,
|
|
popularity, preview_url, image_url, release_date, explicit, data, cached_at, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)
|
|
""",
|
|
(
|
|
album.spotify_id,
|
|
album.item_type.value,
|
|
album.title,
|
|
album.artist,
|
|
album.album,
|
|
album.duration_ms,
|
|
album.popularity,
|
|
album.preview_url,
|
|
album.image_url,
|
|
album.release_date,
|
|
album.explicit,
|
|
json.dumps(album.data) if album.data else None,
|
|
expires_at.isoformat(),
|
|
),
|
|
)
|
|
conn.commit()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error caching album: {e}")
|
|
|
|
async def _get_cached_search(self, cache_key: str) -> SearchResult | None:
|
|
"""Get cached search results"""
|
|
try:
|
|
with get_db_connection() as conn:
|
|
query = """
|
|
SELECT data FROM global_catalog_cache
|
|
WHERE spotify_id = ? AND item_type = 'search'
|
|
AND expires_at > datetime('now')
|
|
ORDER BY cached_at DESC LIMIT 1
|
|
"""
|
|
cursor = conn.execute(query, (cache_key,))
|
|
row = cursor.fetchone()
|
|
|
|
if row:
|
|
data = json.loads(row[0])
|
|
return SearchResult(
|
|
tracks=[
|
|
self._deserialize_catalog_item(item)
|
|
for item in data.get("tracks", [])
|
|
],
|
|
albums=[
|
|
self._deserialize_catalog_item(item)
|
|
for item in data.get("albums", [])
|
|
],
|
|
artists=[
|
|
self._deserialize_catalog_item(item)
|
|
for item in data.get("artists", [])
|
|
],
|
|
playlists=[
|
|
self._deserialize_catalog_item(item)
|
|
for item in data.get("playlists", [])
|
|
],
|
|
total=data.get("total", 0),
|
|
query=data.get("query", ""),
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting cached search: {e}")
|
|
|
|
return None
|
|
|
|
async def _cache_search(self, cache_key: str, result: SearchResult):
|
|
"""Cache search results"""
|
|
try:
|
|
expires_at = datetime.now() + timedelta(
|
|
seconds=self.cache_ttl // 2
|
|
) # Shorter cache for searches
|
|
|
|
with get_db_connection() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO global_catalog_cache
|
|
(spotify_id, item_type, title, artist, explicit, data, cached_at, expires_at)
|
|
VALUES (?, 'search', ?, ?, 0, ?, datetime('now'), ?)
|
|
""",
|
|
(
|
|
cache_key,
|
|
f"Search: {result.query}",
|
|
"",
|
|
json.dumps(
|
|
{
|
|
"tracks": [
|
|
self._serialize_catalog_item(track)
|
|
for track in result.tracks
|
|
],
|
|
"albums": [
|
|
self._serialize_catalog_item(album)
|
|
for album in result.albums
|
|
],
|
|
"artists": [
|
|
self._serialize_catalog_item(artist)
|
|
for artist in result.artists
|
|
],
|
|
"playlists": [
|
|
self._serialize_catalog_item(playlist)
|
|
for playlist in result.playlists
|
|
],
|
|
"total": result.total,
|
|
"query": result.query,
|
|
}
|
|
),
|
|
expires_at.isoformat(),
|
|
),
|
|
)
|
|
conn.commit()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error caching search: {e}")
|
|
|
|
async def _get_cached_artist_info(self, artist_id: str) -> ArtistInfo | None:
|
|
"""Get cached complete artist info"""
|
|
try:
|
|
with get_db_connection() as conn:
|
|
query = """
|
|
SELECT data FROM global_catalog_cache
|
|
WHERE spotify_id = ? AND item_type = 'artist_info'
|
|
AND expires_at > datetime('now')
|
|
ORDER BY cached_at DESC LIMIT 1
|
|
"""
|
|
cursor = conn.execute(query, (artist_id,))
|
|
row = cursor.fetchone()
|
|
|
|
if row:
|
|
data = json.loads(row[0])
|
|
return ArtistInfo(
|
|
spotify_id=data["spotify_id"],
|
|
name=data["name"],
|
|
image_url=data.get("image_url"),
|
|
followers=data.get("followers"),
|
|
popularity=data.get("popularity"),
|
|
genres=data.get("genres", []),
|
|
top_tracks=[
|
|
self._deserialize_catalog_item(item)
|
|
for item in data.get("top_tracks", [])
|
|
],
|
|
albums=[
|
|
self._deserialize_catalog_item(item)
|
|
for item in data.get("albums", [])
|
|
],
|
|
related_artists=data.get("related_artists", []),
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting cached artist info: {e}")
|
|
|
|
return None
|
|
|
|
async def _cache_artist_info(self, artist_id: str, artist_info: ArtistInfo):
|
|
"""Cache complete artist info"""
|
|
try:
|
|
expires_at = datetime.now() + timedelta(seconds=self.cache_ttl)
|
|
|
|
with get_db_connection() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO global_catalog_cache
|
|
(spotify_id, item_type, title, artist, explicit, data, cached_at, expires_at)
|
|
VALUES (?, 'artist_info', ?, ?, 0, ?, datetime('now'), ?)
|
|
""",
|
|
(
|
|
artist_id,
|
|
f"Artist info: {artist_info.name}",
|
|
"",
|
|
json.dumps(
|
|
{
|
|
"spotify_id": artist_info.spotify_id,
|
|
"name": artist_info.name,
|
|
"image_url": artist_info.image_url,
|
|
"followers": artist_info.followers,
|
|
"popularity": artist_info.popularity,
|
|
"genres": artist_info.genres,
|
|
"top_tracks": [
|
|
self._serialize_catalog_item(track)
|
|
for track in artist_info.top_tracks or []
|
|
],
|
|
"albums": [
|
|
self._serialize_catalog_item(album)
|
|
for album in artist_info.albums or []
|
|
],
|
|
"related_artists": artist_info.related_artists or [],
|
|
}
|
|
),
|
|
expires_at.isoformat(),
|
|
),
|
|
)
|
|
conn.commit()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error caching artist info: {e}")
|
|
|
|
# Spotify API integration methods
|
|
async def _fetch_artist_top_tracks_from_spotify(
|
|
self, artist_id: str, limit: int
|
|
) -> list[CatalogItem]:
|
|
"""Fetch artist top tracks from Spotify API"""
|
|
try:
|
|
spotify_client = self._get_spotify_client()
|
|
if not spotify_client:
|
|
return []
|
|
|
|
tracks = spotify_client.get_artist_top_tracks(artist_id, market="US")
|
|
|
|
catalog_items = []
|
|
for track in tracks[:limit]:
|
|
catalog_item = CatalogItem(
|
|
spotify_id=track.id,
|
|
item_type=CatalogItemType.TRACK,
|
|
title=track.name,
|
|
artist=", ".join([artist["name"] for artist in track.artists]),
|
|
album=track.album["name"] if track.album else None,
|
|
duration_ms=track.duration_ms,
|
|
popularity=track.popularity,
|
|
preview_url=track.preview_url,
|
|
image_url=track.album["images"][0]["url"]
|
|
if track.album and track.album.get("images")
|
|
else None,
|
|
explicit=track.explicit,
|
|
data={
|
|
"artists": track.artists,
|
|
"album": track.album,
|
|
"external_urls": track.external_urls,
|
|
"track_number": track.track_number,
|
|
"disc_number": track.disc_number,
|
|
"available_markets": track.available_markets,
|
|
},
|
|
)
|
|
catalog_items.append(catalog_item)
|
|
|
|
return catalog_items
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching artist top tracks from Spotify: {e}")
|
|
return []
|
|
|
|
async def _fetch_artist_albums_from_spotify(
|
|
self, artist_id: str
|
|
) -> list[CatalogItem]:
|
|
"""Fetch artist albums from Spotify API"""
|
|
try:
|
|
spotify_client = self._get_spotify_client()
|
|
if not spotify_client:
|
|
return []
|
|
|
|
albums = spotify_client.get_artist_albums(
|
|
artist_id, limit=self.max_albums_per_artist
|
|
)
|
|
|
|
catalog_items = []
|
|
for album in albums:
|
|
catalog_item = CatalogItem(
|
|
spotify_id=album.id,
|
|
item_type=CatalogItemType.ALBUM,
|
|
title=album.name,
|
|
artist=", ".join([artist["name"] for artist in album.artists]),
|
|
album=album.name,
|
|
popularity=album.popularity,
|
|
image_url=album.images[0]["url"] if album.images else None,
|
|
release_date=album.release_date,
|
|
explicit=False, # Albums don't have explicit flag in API
|
|
data={
|
|
"artists": album.artists,
|
|
"total_tracks": album.total_tracks,
|
|
"external_urls": album.external_urls,
|
|
"available_markets": album.available_markets,
|
|
"album_type": album.album_type,
|
|
},
|
|
)
|
|
catalog_items.append(catalog_item)
|
|
|
|
return catalog_items
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching artist albums from Spotify: {e}")
|
|
return []
|
|
|
|
async def _fetch_album_details_from_spotify(
|
|
self, album_id: str
|
|
) -> CatalogItem | None:
|
|
"""Fetch album details from Spotify API"""
|
|
try:
|
|
spotify_client = self._get_spotify_client()
|
|
if not spotify_client:
|
|
return None
|
|
|
|
album = spotify_client.get_album(album_id)
|
|
if not album:
|
|
return None
|
|
|
|
# Get album tracks
|
|
tracks = spotify_client.get_album_tracks(album_id)
|
|
|
|
catalog_item = CatalogItem(
|
|
spotify_id=album.id,
|
|
item_type=CatalogItemType.ALBUM,
|
|
title=album.name,
|
|
artist=", ".join([artist["name"] for artist in album.artists]),
|
|
album=album.name,
|
|
popularity=album.popularity,
|
|
image_url=album.images[0]["url"] if album.images else None,
|
|
release_date=album.release_date,
|
|
explicit=False,
|
|
data={
|
|
"artists": album.artists,
|
|
"total_tracks": album.total_tracks,
|
|
"external_urls": album.external_urls,
|
|
"available_markets": album.available_markets,
|
|
"album_type": album.album_type,
|
|
"tracks": [
|
|
{
|
|
"id": track.id,
|
|
"name": track.name,
|
|
"artists": track.artists,
|
|
"duration_ms": track.duration_ms,
|
|
"track_number": track.track_number,
|
|
"disc_number": track.disc_number,
|
|
"explicit": track.explicit,
|
|
"preview_url": track.preview_url,
|
|
}
|
|
for track in tracks
|
|
],
|
|
},
|
|
)
|
|
|
|
return catalog_item
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching album details from Spotify: {e}")
|
|
return None
|
|
|
|
async def _search_tracks(self, query: str, limit: int) -> list[CatalogItem]:
|
|
"""Search tracks in Spotify catalog"""
|
|
try:
|
|
spotify_client = self._get_spotify_client()
|
|
if not spotify_client:
|
|
return []
|
|
|
|
search_results = spotify_client.search(query, "track", limit=limit)
|
|
|
|
catalog_items = []
|
|
for track in search_results.get("tracks", []):
|
|
catalog_item = CatalogItem(
|
|
spotify_id=track.id,
|
|
item_type=CatalogItemType.TRACK,
|
|
title=track.name,
|
|
artist=", ".join([artist["name"] for artist in track.artists]),
|
|
album=track.album["name"] if track.album else None,
|
|
duration_ms=track.duration_ms,
|
|
popularity=track.popularity,
|
|
preview_url=track.preview_url,
|
|
image_url=track.album["images"][0]["url"]
|
|
if track.album and track.album.get("images")
|
|
else None,
|
|
explicit=track.explicit,
|
|
data={
|
|
"artists": track.artists,
|
|
"album": track.album,
|
|
"external_urls": track.external_urls,
|
|
"track_number": track.track_number,
|
|
"disc_number": track.disc_number,
|
|
"available_markets": track.available_markets,
|
|
},
|
|
)
|
|
catalog_items.append(catalog_item)
|
|
|
|
return catalog_items
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching tracks: {e}")
|
|
return []
|
|
|
|
async def _search_albums(self, query: str, limit: int) -> list[CatalogItem]:
|
|
"""Search albums in Spotify catalog"""
|
|
try:
|
|
spotify_client = self._get_spotify_client()
|
|
if not spotify_client:
|
|
return []
|
|
|
|
search_results = spotify_client.search(query, "album", limit=limit)
|
|
|
|
catalog_items = []
|
|
for album in search_results.get("albums", []):
|
|
catalog_item = CatalogItem(
|
|
spotify_id=album.id,
|
|
item_type=CatalogItemType.ALBUM,
|
|
title=album.name,
|
|
artist=", ".join([artist["name"] for artist in album.artists]),
|
|
album=album.name,
|
|
popularity=album.popularity,
|
|
image_url=album.images[0]["url"] if album.images else None,
|
|
release_date=album.release_date,
|
|
explicit=False,
|
|
data={
|
|
"artists": album.artists,
|
|
"total_tracks": album.total_tracks,
|
|
"external_urls": album.external_urls,
|
|
"available_markets": album.available_markets,
|
|
"album_type": album.album_type,
|
|
},
|
|
)
|
|
catalog_items.append(catalog_item)
|
|
|
|
return catalog_items
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching albums: {e}")
|
|
return []
|
|
|
|
async def _search_artists(self, query: str, limit: int) -> list[CatalogItem]:
|
|
"""Search artists in Spotify catalog"""
|
|
try:
|
|
spotify_client = self._get_spotify_client()
|
|
if not spotify_client:
|
|
return []
|
|
|
|
search_results = spotify_client.search(query, "artist", limit=limit)
|
|
|
|
catalog_items = []
|
|
for artist in search_results.get("artists", []):
|
|
catalog_item = CatalogItem(
|
|
spotify_id=artist.id,
|
|
item_type=CatalogItemType.ARTIST,
|
|
title=artist.name,
|
|
artist=artist.name,
|
|
popularity=artist.popularity,
|
|
image_url=artist.images[0]["url"] if artist.images else None,
|
|
explicit=False,
|
|
data={
|
|
"followers": artist.followers,
|
|
"genres": artist.genres,
|
|
"external_urls": artist.external_urls,
|
|
},
|
|
)
|
|
catalog_items.append(catalog_item)
|
|
|
|
return catalog_items
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching artists: {e}")
|
|
return []
|
|
|
|
async def _search_playlists(self, query: str, limit: int) -> list[CatalogItem]:
|
|
"""Search playlists in Spotify catalog"""
|
|
try:
|
|
spotify_client = self._get_spotify_client()
|
|
if not spotify_client:
|
|
return []
|
|
|
|
search_results = spotify_client.search(query, "playlist", limit=limit)
|
|
|
|
catalog_items = []
|
|
for playlist in search_results.get("playlists", []):
|
|
catalog_item = CatalogItem(
|
|
spotify_id=playlist.id,
|
|
item_type=CatalogItemType.PLAYLIST,
|
|
title=playlist.name,
|
|
artist=playlist.owner["display_name"] if playlist.owner else "",
|
|
popularity=0, # Playlists don't have popularity
|
|
image_url=playlist.images[0]["url"] if playlist.images else None,
|
|
explicit=False,
|
|
data={
|
|
"description": playlist.description,
|
|
"owner": playlist.owner,
|
|
"public": playlist.public,
|
|
"collaborative": playlist.collaborative,
|
|
"tracks": playlist.tracks,
|
|
"external_urls": playlist.external_urls,
|
|
},
|
|
)
|
|
catalog_items.append(catalog_item)
|
|
|
|
return catalog_items
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching playlists: {e}")
|
|
return []
|
|
|
|
async def _get_artist_basic_info(self, artist_id: str) -> dict[str, Any] | None:
|
|
"""Get basic artist information from Spotify API"""
|
|
try:
|
|
spotify_client = self._get_spotify_client()
|
|
if not spotify_client:
|
|
return None
|
|
|
|
artist = spotify_client.get_artist(artist_id)
|
|
if not artist:
|
|
return None
|
|
|
|
# Get related artists
|
|
related_artists = spotify_client.get_related_artists(artist_id)
|
|
|
|
return {
|
|
"name": artist.name,
|
|
"image_url": artist.images[0]["url"] if artist.images else None,
|
|
"followers": artist.followers.get("total", 0)
|
|
if artist.followers
|
|
else 0,
|
|
"popularity": artist.popularity,
|
|
"genres": artist.genres,
|
|
"related_artists": [
|
|
{
|
|
"id": related.id,
|
|
"name": related.name,
|
|
"popularity": related.popularity,
|
|
"image_url": related.images[0]["url"]
|
|
if related.images
|
|
else None,
|
|
}
|
|
for related in related_artists[:10] # Limit to 10 related artists
|
|
],
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting basic artist info: {e}")
|
|
return None
|
|
|
|
def cleanup_expired_cache(self):
|
|
"""Clean up expired cache entries"""
|
|
try:
|
|
with get_db_connection() as conn:
|
|
conn.execute("""
|
|
DELETE FROM global_catalog_cache
|
|
WHERE expires_at < datetime('now')
|
|
""")
|
|
conn.commit()
|
|
logger.info("Cleaned up expired catalog cache entries")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error cleaning up expired cache: {e}")
|
|
|
|
|
|
# Global instance
|
|
music_catalog_service = MusicCatalogService()
|