soulsync/api/search.py
Broque Thomas 46ac46134b Add Deezer as configurable free metadata fallback source alongside iTunes
Users can now choose between iTunes/Apple Music and Deezer as their free
metadata source in Settings. Spotify always takes priority when authenticated;
the fallback handles all lookups when it's not.

Core changes:
- DeezerClient: full metadata interface (search, albums, artists, tracks)
  matching iTunesClient's API surface with identical dataclass return types
- SpotifyClient: configurable _fallback property switches between iTunes/Deezer
  based on live config reads (no restart needed)
- MetadataService, web_server, watchlist_scanner, api/search, repair_worker,
  seasonal_discovery, personalized_playlists: all direct iTunesClient imports
  replaced with fallback-aware helpers

Database:
- deezer_artist_id on watchlist_artists and similar_artists tables
- deezer_track_id/album_id/artist_id on discovery_pool and discovery_cache
- Full CRUD for Deezer IDs: add, read, update, backfill, metadata enrichment
- Watchlist duplicate detection by artist name prevents re-adding across sources
- SimilarArtist dataclass and all query/insert methods handle Deezer columns

Bug fixes found during review:
- Similar artist backfill was writing Deezer IDs into iTunes columns
- Discover hero was storing resolved Deezer IDs in wrong column
- Status cache not invalidating on settings save (source name lag)
- Watchlist add allowing duplicates when switching metadata sources
2026-03-16 13:12:12 -07:00

173 lines
6.2 KiB
Python

"""
Search endpoints — search external sources (Spotify, iTunes, Hydrabase).
"""
from flask import request, current_app
from .auth import require_api_key
from .helpers import api_success, api_error
def register_routes(bp):
@bp.route("/search/tracks", methods=["POST"])
@require_api_key
def search_tracks():
"""Search for tracks across music sources.
Body: {"query": "...", "source": "spotify"|"itunes"|"auto", "limit": 20}
"""
body = request.get_json(silent=True) or {}
query = body.get("query", "").strip()
source = body.get("source", "auto")
limit = min(50, max(1, int(body.get("limit", 20))))
if not query:
return api_error("BAD_REQUEST", "Missing 'query' in request body.", 400)
try:
ctx = current_app.soulsync
tracks = []
# Hydrabase first when active
hydrabase = ctx.get("hydrabase_client")
if source == "auto" and hydrabase:
try:
from web_server import _is_hydrabase_active
if _is_hydrabase_active():
hydra_results = hydrabase.search_tracks(query, limit=limit)
if hydra_results:
tracks = [_serialize_track(t) for t in hydra_results]
return api_success({"tracks": tracks, "source": "hydrabase"})
except Exception:
pass
spotify = ctx.get("spotify_client")
if source in ("spotify", "auto") and spotify and spotify.is_authenticated():
results = spotify.search_tracks(query, limit=limit)
if results:
tracks = [_serialize_track(t) for t in results]
return api_success({"tracks": tracks, "source": "spotify"})
if source in ("itunes", "deezer", "auto"):
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source
fallback = _create_fallback_client()
fallback_source = _get_configured_fallback_source()
results = fallback.search_tracks(query, limit=limit)
if results:
tracks = [_serialize_track(t) for t in results]
return api_success({"tracks": tracks, "source": fallback_source})
return api_success({"tracks": [], "source": source})
except Exception as e:
return api_error("SEARCH_ERROR", str(e), 500)
@bp.route("/search/albums", methods=["POST"])
@require_api_key
def search_albums():
"""Search for albums.
Body: {"query": "...", "limit": 20}
"""
body = request.get_json(silent=True) or {}
query = body.get("query", "").strip()
limit = min(50, max(1, int(body.get("limit", 20))))
if not query:
return api_error("BAD_REQUEST", "Missing 'query' in request body.", 400)
try:
ctx = current_app.soulsync
spotify = ctx.get("spotify_client")
if spotify and spotify.is_authenticated():
results = spotify.search_albums(query, limit=limit)
if results:
return api_success({
"albums": [_serialize_album(a) for a in results],
"source": "spotify",
})
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source
fallback = _create_fallback_client()
fallback_source = _get_configured_fallback_source()
results = fallback.search_albums(query, limit=limit)
return api_success({
"albums": [_serialize_album(a) for a in results] if results else [],
"source": fallback_source,
})
except Exception as e:
return api_error("SEARCH_ERROR", str(e), 500)
@bp.route("/search/artists", methods=["POST"])
@require_api_key
def search_artists():
"""Search for artists.
Body: {"query": "...", "limit": 20}
"""
body = request.get_json(silent=True) or {}
query = body.get("query", "").strip()
limit = min(50, max(1, int(body.get("limit", 20))))
if not query:
return api_error("BAD_REQUEST", "Missing 'query' in request body.", 400)
try:
ctx = current_app.soulsync
spotify = ctx.get("spotify_client")
if spotify and spotify.is_authenticated():
results = spotify.search_artists(query, limit=limit)
if results:
return api_success({
"artists": [_serialize_artist(a) for a in results],
"source": "spotify",
})
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source
fallback = _create_fallback_client()
fallback_source = _get_configured_fallback_source()
results = fallback.search_artists(query, limit=limit)
return api_success({
"artists": [_serialize_artist(a) for a in results] if results else [],
"source": fallback_source,
})
except Exception as e:
return api_error("SEARCH_ERROR", str(e), 500)
# ---- serialization (from core dataclasses) ----
def _serialize_track(t):
return {
"id": t.id,
"name": t.name,
"artists": t.artists,
"album": t.album,
"duration_ms": t.duration_ms,
"popularity": t.popularity,
"preview_url": t.preview_url,
"image_url": t.image_url,
"release_date": t.release_date,
}
def _serialize_album(a):
return {
"id": a.id,
"name": a.name,
"artists": a.artists,
"release_date": a.release_date,
"total_tracks": a.total_tracks,
"album_type": a.album_type,
"image_url": a.image_url,
}
def _serialize_artist(a):
return {
"id": a.id,
"name": a.name,
"popularity": a.popularity,
"genres": a.genres,
"followers": a.followers,
"image_url": a.image_url,
}