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

1078 lines
37 KiB
Python

"""Music Catalog API with per-user availability and recommendation blocks."""
from __future__ import annotations
import asyncio
import random
from dataclasses import asdict, is_dataclass
from typing import Any
import requests
from flask import Blueprint, jsonify, request
from flask_jwt_extended import get_jwt_identity
from sqlalchemy import and_, func, select
from swingmusic import logger
from swingmusic.config import UserConfig
from swingmusic.db.engine import DbEngine
from swingmusic.db.spotify import UserCatalogPreferencesTable
from swingmusic.db.userdata import ScrobbleTable
from swingmusic.services.library_projection import get_track_availability_map
from swingmusic.services.music_catalog import music_catalog_service
from swingmusic.services.user_library_scope import get_available_trackhashes
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.auth import get_current_userid
from swingmusic.utils.hashing import create_hash
music_catalog_bp = Blueprint("music_catalog", __name__, url_prefix="/api/catalog")
def _run_async(coro):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def _current_userid() -> int:
try:
identity = get_jwt_identity()
if isinstance(identity, dict) and identity.get("id") is not None:
return int(identity["id"])
except Exception:
pass
return get_current_userid()
def _item_to_dict(item: Any) -> dict[str, Any]:
if item is None:
return {}
if is_dataclass(item):
payload = asdict(item)
elif hasattr(item, "__dict__"):
payload = dict(item.__dict__)
else:
payload = dict(item)
item_type = payload.get("item_type")
if hasattr(item_type, "value"):
payload["item_type"] = item_type.value
return payload
def _spotify_url(item_type: str, spotify_id: str | None) -> str | None:
if not spotify_id:
return None
type_map = {
"track": "track",
"album": "album",
"artist": "artist",
"playlist": "playlist",
}
normalized = type_map.get(item_type, item_type)
return f"https://open.spotify.com/{normalized}/{spotify_id}"
def _navigation_payload(item: dict[str, Any]) -> dict[str, Any]:
item_type = str(item.get("item_type") or "").lower()
spotify_id = item.get("spotify_id")
data = item.get("data") or {}
payload: dict[str, Any] = {
"item_type": item_type or "unknown",
"spotify_id": spotify_id,
"spotify_url": _spotify_url(item_type or "track", spotify_id),
}
if item_type == "artist":
payload["target"] = {"route": "global-artist", "params": {"id": spotify_id}}
return payload
if item_type == "album":
payload["target"] = {"route": "global-album", "params": {"id": spotify_id}}
return payload
if item_type == "playlist":
payload["target"] = {"route": "global-playlist", "params": {"id": spotify_id}}
return payload
# Track navigation: prefer album page, fallback to artist.
album_data = data.get("album") if isinstance(data, dict) else None
artists_data = data.get("artists") if isinstance(data, dict) else None
album_id = album_data.get("id") if isinstance(album_data, dict) else None
artist_id = None
if isinstance(artists_data, list) and artists_data:
first_artist = artists_data[0]
if isinstance(first_artist, dict):
artist_id = first_artist.get("id")
if album_id:
payload["target"] = {"route": "global-album", "params": {"id": album_id}}
elif artist_id:
payload["target"] = {"route": "global-artist", "params": {"id": artist_id}}
else:
payload["target"] = {"route": "spotify-track", "params": {"id": spotify_id}}
if artist_id:
payload["artist_target"] = {
"route": "global-artist",
"params": {"id": artist_id},
}
if album_id:
payload["album_target"] = {"route": "global-album", "params": {"id": album_id}}
return payload
def _decorate_navigation(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
for item in items:
item["navigation"] = _navigation_payload(item)
return items
def _get_lastfm_seed_artist_names(limit: int = 12) -> list[str]:
api_key = UserConfig().lastfmApiKey
if not api_key:
return []
try:
response = requests.get(
"https://ws.audioscrobbler.com/2.0/",
params={
"method": "chart.gettopartists",
"api_key": api_key,
"format": "json",
"limit": limit,
},
timeout=8,
)
if not response.ok:
return []
payload = response.json()
artists = payload.get("artists", {}).get("artist", [])
names = [
artist.get("name", "").strip() for artist in artists if artist.get("name")
]
return [name for name in names if name]
except Exception:
return []
def _build_local_fallback_recommendations(
limit: int = 18, userid: int | None = None
) -> list[dict[str, Any]]:
userid = userid or get_current_userid()
available = get_available_trackhashes(userid)
if not available:
return []
seen = set()
items: list[dict[str, Any]] = []
for track in TrackStore.get_flat_list():
if track.trackhash not in available:
continue
for artist in track.artists:
name = artist.get("name")
artisthash = artist.get("artisthash")
if not name or not artisthash or artisthash in seen:
continue
seen.add(artisthash)
items.append(
{
"spotify_id": f"local-{artisthash}",
"title": name,
"name": name,
"source": "local",
"navigation": {
"item_type": "local_artist",
"target": {
"route": "ArtistView",
"params": {"hash": artisthash},
},
},
}
)
if len(items) >= limit:
return items
return items
def _trackhash_from_catalog_track(track: dict[str, Any]) -> str | None:
title = (track.get("title") or "").strip()
artist = (track.get("artist") or "").strip()
album = (track.get("album") or "").strip()
if not title or not artist:
return None
return create_hash(title, album, artist)
def _normalize_catalog_text(value: str | None) -> str:
return " ".join((value or "").strip().lower().split())
def _build_local_album_availability(
artist_name: str | None, userid: int
) -> dict[str, int]:
available = get_available_trackhashes(userid)
if not available:
return {}
target_artist = _normalize_catalog_text(artist_name)
album_counts: dict[str, int] = {}
for track in TrackStore.get_flat_list():
if track.trackhash not in available:
continue
if target_artist:
artist_names = [
_normalize_catalog_text(a.get("name"))
for a in (track.artists or [])
if a.get("name")
]
if target_artist not in artist_names:
continue
album_key = _normalize_catalog_text(track.album)
if not album_key:
continue
album_counts[album_key] = album_counts.get(album_key, 0) + 1
return album_counts
def _album_availability_payload(
album_title: str | None, local_counts: dict[str, int]
) -> dict[str, Any]:
album_key = _normalize_catalog_text(album_title)
available_count = int(local_counts.get(album_key, 0)) if album_key else 0
if available_count > 0:
return {
"state": "available",
"available_tracks": available_count,
"download_action": {
"type": "download",
"label": "Download album",
"enabled": True,
},
}
return {
"state": "missing",
"available_tracks": 0,
"download_action": {
"type": "download",
"label": "Download album",
"enabled": True,
},
}
def _stable_track_signal(trackhash: str | None) -> float:
value = trackhash or ""
if not value:
return 0.0
try:
sample = int(value[:8], 16)
except ValueError:
sample = sum(ord(ch) for ch in value)
return (sample % 1000) / 1000.0
def _get_user_track_signals(
trackhashes: set[str], userid: int
) -> dict[str, dict[str, float]]:
if not trackhashes:
return {}
with DbEngine.manager() as conn:
result = conn.execute(
select(
ScrobbleTable.trackhash,
func.count(ScrobbleTable.id).label("plays"),
func.max(ScrobbleTable.timestamp).label("last_played"),
)
.where(
and_(
ScrobbleTable.userid == userid,
ScrobbleTable.trackhash.in_(trackhashes),
)
)
.group_by(ScrobbleTable.trackhash)
)
rows = result.fetchall()
signals: dict[str, dict[str, float]] = {}
for row in rows:
signals[row.trackhash] = {
"plays": float(row.plays or 0.0),
"last_played": float(row.last_played or 0.0),
}
return signals
def _rank_catalog_tracks_for_user(
tracks: list[dict[str, Any]], userid: int
) -> list[dict[str, Any]]:
if not tracks:
return []
trackhashes: set[str] = set()
for track in tracks:
trackhash = track.get("trackhash") or _trackhash_from_catalog_track(track)
if trackhash:
track["trackhash"] = trackhash
trackhashes.add(trackhash)
signals = _get_user_track_signals(trackhashes, userid)
max_plays = max((signal["plays"] for signal in signals.values()), default=0.0)
last_played_values = [
signal["last_played"]
for signal in signals.values()
if signal["last_played"] > 0
]
min_last_played = min(last_played_values) if last_played_values else 0.0
max_last_played = max(last_played_values) if last_played_values else 0.0
max_popularity = max(
(float(track.get("popularity") or 0.0) for track in tracks), default=0.0
)
def _score(track: dict[str, Any]) -> float:
trackhash = track.get("trackhash")
signal = signals.get(trackhash or "", {"plays": 0.0, "last_played": 0.0})
popularity = float(track.get("popularity") or 0.0)
popularity_norm = (
(popularity / max_popularity)
if max_popularity > 0
else _stable_track_signal(trackhash)
)
if max_plays <= 0:
return (0.75 * popularity_norm) + (0.25 * _stable_track_signal(trackhash))
plays_norm = signal["plays"] / max_plays if max_plays > 0 else 0.0
if max_last_played > min_last_played and signal["last_played"] > 0:
recency_norm = (signal["last_played"] - min_last_played) / (
max_last_played - min_last_played
)
elif signal["last_played"] > 0:
recency_norm = 1.0
else:
recency_norm = 0.0
return (0.55 * plays_norm) + (0.25 * recency_norm) + (0.20 * popularity_norm)
ranked = sorted(
tracks,
key=lambda track: (_score(track), float(track.get("popularity") or 0.0)),
reverse=True,
)
return ranked
def _decorate_tracks_with_availability(
tracks: list[dict[str, Any]], userid: int
) -> list[dict[str, Any]]:
hashes: list[str] = []
hash_by_index: dict[int, str] = {}
for index, track in enumerate(tracks):
trackhash = _trackhash_from_catalog_track(track)
if not trackhash:
continue
hashes.append(trackhash)
hash_by_index[index] = trackhash
track["trackhash"] = trackhash
availability_map = get_track_availability_map(hashes, userid=userid)
signals = _get_user_track_signals(set(hashes), userid)
for index, trackhash in hash_by_index.items():
availability = availability_map.get(trackhash) or {}
signal = signals.get(trackhash) or {}
tracks[index]["availability"] = availability
tracks[index]["import_available"] = bool(availability.get("import_available"))
tracks[index]["import_action"] = availability.get("import_action")
tracks[index]["download_action"] = availability.get("download_action")
tracks[index]["quality_badge"] = availability.get("quality_badge")
tracks[index]["is_available"] = availability.get("state") == "available"
tracks[index]["play_count"] = int(signal.get("plays") or 0)
tracks[index]["last_played"] = int(signal.get("last_played") or 0)
tracks[index]["navigation"] = _navigation_payload(tracks[index])
return tracks
def _build_artist_radio(related_artists: list[dict[str, Any]]) -> list[dict[str, Any]]:
candidates: list[dict[str, Any]] = []
for related in related_artists[:6]:
related_id = related.get("id")
if not related_id:
continue
try:
tracks = _run_async(
music_catalog_service.get_artist_top_tracks(related_id, 4)
)
except Exception as error:
logger.warning(
"Failed to build radio for related artist %s: %s", related_id, error
)
continue
candidates.extend([_item_to_dict(track) for track in tracks])
deduped: list[dict[str, Any]] = []
seen = set()
for track in candidates:
track_id = track.get("spotify_id")
if not track_id or track_id in seen:
continue
seen.add(track_id)
deduped.append(track)
return deduped[:50]
def _build_this_is(top_tracks: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen = set()
ordered: list[dict[str, Any]] = []
for track in top_tracks:
key = track.get("spotify_id") or track.get("trackhash")
if not key or key in seen:
continue
seen.add(key)
ordered.append(track)
return ordered[:40]
@music_catalog_bp.route("/artist/<artist_id>/top-tracks", methods=["GET"])
def get_artist_top_tracks(artist_id: str):
try:
# Product contract: artist pages always expose top 15 tracks.
limit = min(max(request.args.get("limit", 15, type=int), 1), 15)
userid = _current_userid()
tracks = _run_async(
music_catalog_service.get_artist_top_tracks(artist_id, limit)
)
payload = [_item_to_dict(track) for track in tracks]
payload = _decorate_tracks_with_availability(payload, userid)
return jsonify({"tracks": payload, "total": len(payload)})
except Exception as error:
logger.error("Error getting artist top tracks: %s", error)
return jsonify({"error": "Failed to get artist top tracks"}), 500
@music_catalog_bp.route("/artist/<artist_id>/albums", methods=["GET"])
def get_artist_discography(artist_id: str):
try:
userid = _current_userid()
albums = _run_async(music_catalog_service.get_artist_discography(artist_id))
payload = [_item_to_dict(album) for album in albums]
payload = _decorate_navigation(payload)
local_album_counts: dict[str, int] = {}
sections = {
"albums": [],
"singles": [],
"compilations": [],
"other": [],
}
if payload:
first_artist = (payload[0].get("artist") or "").strip()
local_album_counts = _build_local_album_availability(first_artist, userid)
for album in payload:
album_type = (album.get("data") or {}).get("album_type", "album")
lowered = str(album_type).lower()
if lowered == "album":
sections["albums"].append(album)
elif lowered in {"single", "ep"}:
sections["singles"].append(album)
elif lowered == "compilation":
sections["compilations"].append(album)
else:
sections["other"].append(album)
album["availability"] = _album_availability_payload(
album.get("title"), local_album_counts
)
return jsonify(
{
"albums": payload,
"sections": sections,
"total": len(payload),
"userid": userid,
}
)
except Exception as error:
logger.error("Error getting artist discography: %s", error)
return jsonify({"error": "Failed to get artist discography"}), 500
@music_catalog_bp.route("/artist/<artist_id>", methods=["GET"])
def get_artist_info(artist_id: str):
try:
userid = _current_userid()
artist_info = _run_async(music_catalog_service.get_artist_info(artist_id))
if not artist_info:
return jsonify({"error": "Artist not found"}), 404
top_tracks = [
_item_to_dict(track) for track in (artist_info.top_tracks or [])[:15]
]
top_tracks = _decorate_tracks_with_availability(top_tracks, userid)
top_tracks = _rank_catalog_tracks_for_user(top_tracks, userid)
albums = [_item_to_dict(album) for album in (artist_info.albums or [])]
albums = _decorate_navigation(albums)
local_album_counts = _build_local_album_availability(artist_info.name, userid)
related = artist_info.related_artists or []
for related_artist in related:
related_artist["navigation"] = {
"item_type": "artist",
"target": {
"route": "global-artist",
"params": {"id": related_artist.get("id")},
},
"spotify_url": _spotify_url("artist", related_artist.get("id")),
}
this_is_tracks = _decorate_tracks_with_availability(
_build_this_is(top_tracks), userid
)
this_is_tracks = _rank_catalog_tracks_for_user(this_is_tracks, userid)
radio_tracks = _decorate_tracks_with_availability(
_build_artist_radio(related), userid
)
radio_tracks = _rank_catalog_tracks_for_user(radio_tracks, userid)
sections = {
"albums": [],
"singles": [],
"compilations": [],
"other": [],
}
for album in albums:
album["availability"] = _album_availability_payload(
album.get("title"), local_album_counts
)
album_type = (album.get("data") or {}).get("album_type", "album")
lowered = str(album_type).lower()
if lowered == "album":
sections["albums"].append(album)
elif lowered in {"single", "ep"}:
sections["singles"].append(album)
elif lowered == "compilation":
sections["compilations"].append(album)
else:
sections["other"].append(album)
return jsonify(
{
"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 or [],
"top_tracks": top_tracks,
"albums": albums,
"discography_sections": sections,
"related_artists": related,
"this_is_artist": this_is_tracks,
"artist_radio": radio_tracks,
}
)
except Exception as error:
logger.error("Error getting artist info: %s", error)
return jsonify({"error": "Failed to get artist info"}), 500
@music_catalog_bp.route("/album/<album_id>", methods=["GET"])
def get_album_details(album_id: str):
try:
userid = _current_userid()
album = _run_async(music_catalog_service.get_album_details(album_id))
if not album:
return jsonify({"error": "Album not found"}), 404
payload = _item_to_dict(album)
payload["navigation"] = _navigation_payload(payload)
tracks = (payload.get("data") or {}).get("tracks") or []
normalized_tracks = []
for track in tracks:
artists = track.get("artists") or []
if artists and isinstance(artists[0], dict):
artist_name = ", ".join(
a.get("name", "") for a in artists if a.get("name")
)
else:
artist_name = payload.get("artist") or ""
normalized_tracks.append(
{
"spotify_id": track.get("id"),
"item_type": "track",
"title": track.get("name"),
"artist": artist_name,
"album": payload.get("title"),
"duration_ms": track.get("duration_ms"),
"explicit": bool(track.get("explicit")),
"preview_url": track.get("preview_url"),
"track_number": track.get("track_number"),
"disc_number": track.get("disc_number"),
}
)
normalized_tracks = _decorate_tracks_with_availability(
normalized_tracks, userid
)
payload["tracks"] = normalized_tracks
return jsonify(payload)
except Exception as error:
logger.error("Error getting album details: %s", error)
return jsonify({"error": "Failed to get album details"}), 500
@music_catalog_bp.route("/playlist/<playlist_id>", methods=["GET"])
def get_playlist_details(playlist_id: str):
try:
userid = _current_userid()
limit = min(max(request.args.get("limit", 200, type=int), 1), 300)
playlist = _run_async(
music_catalog_service.get_playlist_details(playlist_id, limit)
)
if not playlist:
return jsonify({"error": "Playlist not found"}), 404
payload = _item_to_dict(playlist)
payload["navigation"] = _navigation_payload(payload)
data = payload.get("data") or {}
tracks = data.get("tracks") or []
normalized_tracks = []
for track in tracks:
artists = track.get("artists") or []
artist_name = ", ".join(
artist.get("name", "")
for artist in artists
if isinstance(artist, dict) and artist.get("name")
)
album = track.get("album") or {}
album_name = album.get("name") if isinstance(album, dict) else None
normalized_tracks.append(
{
"spotify_id": track.get("id"),
"item_type": "track",
"title": track.get("name"),
"artist": artist_name,
"album": album_name,
"duration_ms": track.get("duration_ms"),
"explicit": bool(track.get("explicit")),
"preview_url": track.get("preview_url"),
"track_number": track.get("track_number"),
"disc_number": track.get("disc_number"),
"popularity": track.get("popularity"),
}
)
normalized_tracks = _decorate_tracks_with_availability(
normalized_tracks, userid
)
payload["tracks"] = normalized_tracks
payload["owner"] = data.get("owner")
payload["description"] = data.get("description")
payload["tracks_total"] = data.get("tracks_total")
payload["public"] = bool(data.get("public"))
payload["collaborative"] = bool(data.get("collaborative"))
return jsonify(payload)
except Exception as error:
logger.error("Error getting playlist details: %s", error)
return jsonify({"error": "Failed to get playlist details"}), 500
@music_catalog_bp.route("/search", methods=["POST"])
def search_catalog():
try:
data = request.get_json() or {}
query = (data.get("query") or "").strip()
if not query:
return jsonify({"error": "Search query is required"}), 400
search_type = data.get("type", "all")
limit = min(max(int(data.get("limit", 20)), 1), 50)
offset = max(int(data.get("offset", 0)), 0)
userid = _current_userid()
# Spotify search APIs are page-based. We over-fetch then slice so
# clients can request deterministic offset-based pages.
fetch_limit = min(max(offset + limit, limit), 120)
result = _run_async(
music_catalog_service.search_global_catalog(query, search_type, fetch_limit)
)
tracks_all = _decorate_tracks_with_availability(
[_item_to_dict(track) for track in result.tracks], userid
)
albums_all = _decorate_navigation(
[_item_to_dict(album) for album in result.albums]
)
artists_all = _decorate_navigation(
[_item_to_dict(artist) for artist in result.artists]
)
playlists_all = _decorate_navigation(
[_item_to_dict(playlist) for playlist in result.playlists]
)
def _paginate(
items: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], int, bool]:
total_items = len(items)
page = items[offset : offset + limit]
has_more = total_items > offset + limit
return page, total_items, has_more
tracks, tracks_total, tracks_has_more = _paginate(tracks_all)
albums, albums_total, albums_has_more = _paginate(albums_all)
artists, artists_total, artists_has_more = _paginate(artists_all)
playlists, playlists_total, playlists_has_more = _paginate(playlists_all)
has_more_payload: bool | dict[str, bool]
item_total: int | None = None
if search_type == "tracks":
has_more_payload = tracks_has_more
item_total = tracks_total
elif search_type == "albums":
has_more_payload = albums_has_more
item_total = albums_total
elif search_type == "artists":
has_more_payload = artists_has_more
item_total = artists_total
elif search_type == "playlists":
has_more_payload = playlists_has_more
item_total = playlists_total
else:
has_more_payload = {
"tracks": tracks_has_more,
"albums": albums_has_more,
"artists": artists_has_more,
"playlists": playlists_has_more,
}
return jsonify(
{
"tracks": tracks,
"albums": albums,
"artists": artists,
"playlists": playlists,
"total": result.total,
"query": result.query,
"type": search_type,
"offset": offset,
"limit": limit,
"has_more": has_more_payload,
"totals": {
"tracks": tracks_total,
"albums": albums_total,
"artists": artists_total,
"playlists": playlists_total,
},
"item_total": item_total,
}
)
except Exception as error:
logger.error("Error searching catalog: %s", error)
return jsonify({"error": "Failed to search catalog"}), 500
@music_catalog_bp.route("/trending", methods=["GET"])
def get_trending_content():
try:
content_type = request.args.get("type", "tracks")
limit = min(max(request.args.get("limit", 20, type=int), 1), 50)
userid = _current_userid()
valid_types = {"tracks", "albums", "artists"}
if content_type not in valid_types:
return jsonify(
{"error": f"Invalid type. Must be one of: {sorted(valid_types)}"}
), 400
trending_queries = {
"tracks": "popular songs",
"albums": "popular albums",
"artists": "popular artists",
}
query = trending_queries.get(content_type, "popular")
service_type_map = {
"tracks": "tracks",
"albums": "albums",
"artists": "artists",
}
result = _run_async(
music_catalog_service.search_global_catalog(
query,
service_type_map.get(content_type, "tracks"),
limit,
)
)
response = {
"type": content_type,
"query": query,
}
if content_type == "tracks":
tracks = _decorate_tracks_with_availability(
[_item_to_dict(track) for track in result.tracks], userid
)
response["tracks"] = tracks
response["total"] = len(tracks)
elif content_type == "albums":
albums = _decorate_navigation(
[_item_to_dict(album) for album in result.albums]
)
response["albums"] = albums
response["total"] = len(albums)
else:
artists = _decorate_navigation(
[_item_to_dict(artist) for artist in result.artists]
)
response["artists"] = artists
response["total"] = len(artists)
return jsonify(response)
except Exception as error:
logger.error("Error getting trending content: %s", error)
return jsonify({"error": "Failed to get trending content"}), 500
@music_catalog_bp.route("/recommendations", methods=["POST"])
def get_recommendations():
try:
data = request.get_json() or {}
seed_artists = data.get("seed_artists", [])
seed_tracks = data.get("seed_tracks", [])
seed_genres = data.get("seed_genres", [])
limit = min(max(int(data.get("limit", 20)), 1), 50)
userid = _current_userid()
if not any([seed_artists, seed_tracks, seed_genres]):
# Cold-start recommendation fallback.
trend = _run_async(
music_catalog_service.search_global_catalog(
"popular artists", "artists", 6
)
)
seed_artists = [artist.spotify_id for artist in trend.artists[:5]]
recommendations = []
for artist_id in seed_artists[:6]:
tracks = _run_async(
music_catalog_service.get_artist_top_tracks(artist_id, 6)
)
recommendations.extend([_item_to_dict(track) for track in tracks])
seen = set()
unique_recommendations: list[dict[str, Any]] = []
for track in recommendations:
track_id = track.get("spotify_id")
if not track_id or track_id in seen:
continue
seen.add(track_id)
unique_recommendations.append(track)
if len(unique_recommendations) >= limit:
break
unique_recommendations = _decorate_tracks_with_availability(
unique_recommendations, userid
)
return jsonify(
{
"tracks": unique_recommendations,
"total": len(unique_recommendations),
"seeds": {
"artists": seed_artists,
"tracks": seed_tracks,
"genres": seed_genres,
},
}
)
except Exception as error:
logger.error("Error getting recommendations: %s", error)
return jsonify({"error": "Failed to get recommendations"}), 500
@music_catalog_bp.route("/home/recommendations", methods=["GET"])
def get_home_recommendations():
"""
Default dashboard recommendations for cold-start users.
"""
try:
userid = _current_userid()
recent_scrobbles = list(ScrobbleTable.get_all(0, 60, userid=userid))
strategy = "spotify_lastfm_cold_start"
artist_candidates = []
if recent_scrobbles:
strategy = "listening_blend"
local_artist_names: list[str] = []
for scrobble in recent_scrobbles:
entry = TrackStore.trackhashmap.get(scrobble.trackhash)
if not entry:
continue
for artist in entry.tracks[0].artists:
name = artist.get("name")
if name and name not in local_artist_names:
local_artist_names.append(name)
for name in local_artist_names[:6]:
result = _run_async(
music_catalog_service.search_global_catalog(name, "artists", 8)
)
artist_candidates.extend(
[_item_to_dict(artist) for artist in result.artists]
)
if not artist_candidates:
# Cold-start: Last.fm chart seeds first, then Spotify query fallback.
lastfm_names = _get_lastfm_seed_artist_names(limit=10)
if lastfm_names:
strategy = "lastfm_seeded"
for name in lastfm_names[:8]:
result = _run_async(
music_catalog_service.search_global_catalog(name, "artists", 8)
)
artist_candidates.extend(
[_item_to_dict(artist) for artist in result.artists]
)
if not artist_candidates:
strategy = "spotify_seeded"
seed_queries = [
"jazz",
"indie",
"electronic",
"hip hop",
"rock",
"soul",
"classical",
"pop",
]
random.Random(userid).shuffle(seed_queries)
for query in seed_queries[:4]:
result = _run_async(
music_catalog_service.search_global_catalog(query, "artists", 10)
)
artist_candidates.extend(
[_item_to_dict(artist) for artist in result.artists]
)
unique = []
seen = set()
for artist in artist_candidates:
artist_id = artist.get("spotify_id")
if not artist_id or artist_id in seen:
continue
seen.add(artist_id)
unique.append(artist)
random.Random(userid + len(unique)).shuffle(unique)
selected = _decorate_navigation(unique[:18])
if not selected:
strategy = "local_library_fallback"
selected = _build_local_fallback_recommendations(limit=18, userid=userid)
return jsonify(
{
"strategy": strategy,
"artists": selected,
"total": len(selected),
}
)
except Exception as error:
logger.error("Error getting home recommendations: %s", error)
return jsonify({"error": "Failed to get home recommendations"}), 500
@music_catalog_bp.route("/preferences/<int:user_id>", methods=["GET", "POST"])
def user_catalog_preferences(user_id: int):
try:
userid = _current_userid()
if userid != user_id:
return jsonify({"error": "Forbidden"}), 403
if request.method == "GET":
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
return jsonify(
{
"user_id": user_id,
"max_search_results": user_prefs.max_search_results,
"max_top_tracks": user_prefs.max_top_tracks,
"max_albums_per_artist": user_prefs.max_albums_per_artist,
"max_trending_results": user_prefs.max_trending_results,
"max_recommendations": user_prefs.max_recommendations,
"show_explicit": user_prefs.show_explicit,
"preferred_markets": user_prefs.preferred_markets or [],
}
)
data = request.get_json() or {}
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
if "max_search_results" in data:
user_prefs.max_search_results = min(int(data["max_search_results"]), 100)
if "max_top_tracks" in data:
user_prefs.max_top_tracks = min(int(data["max_top_tracks"]), 50)
if "max_albums_per_artist" in data:
user_prefs.max_albums_per_artist = min(
int(data["max_albums_per_artist"]), 100
)
if "max_trending_results" in data:
user_prefs.max_trending_results = min(
int(data["max_trending_results"]), 100
)
if "max_recommendations" in data:
user_prefs.max_recommendations = min(int(data["max_recommendations"]), 100)
if "show_explicit" in data:
user_prefs.show_explicit = bool(data["show_explicit"])
if "preferred_markets" in data:
user_prefs.preferred_markets = data["preferred_markets"]
user_prefs.save()
return jsonify({"message": "Preferences updated successfully"})
except Exception as error:
logger.error("Error handling catalog preferences: %s", error)
return jsonify({"error": "Failed to handle preferences"}), 500