Files
SpotifyRecAlg/swingmusic/services/music_catalog.py
T
Tomas Dvorak 6e8fedf534 first commit
2026-04-13 17:46:58 +02:00

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()