soulsync/core/search/sources.py
Broque Thomas fd7b56e58c Lift /api/search and /api/enhanced-search/* into core/search/
Routes moved to thin parse-args/jsonify handlers; logic now lives in
six focused modules under core/search/. 720 lines deleted from
web_server.py; 109 added back as wrappers; ~700 lines of new core code
plus ~700 lines of tests.

Module split:
- core/search/cache.py — TTL+LRU cache for enhanced-search responses,
  keyed by (query, active_server, fallback_source, hydrabase_active,
  source_tag) so config changes don't poison stale entries.
- core/search/sources.py — per-kind metadata search (artists/albums/
  tracks) and the multi-kind ThreadPoolExecutor that fans them out.
- core/search/library_check.py — library + wishlist presence check
  with Plex thumb URL resolution; profile-aware wishlist with legacy
  fallback for older DBs missing the profile_id column.
- core/search/stream.py — single-track preview search; effective stream
  mode resolution, query-variant generation, retry walk, matching
  engine integration.
- core/search/basic.py — flat Soulseek file search, quality-sorted.
- core/search/orchestrator.py — main enhanced-search dispatch
  (short-query fast path, single-source bypass, hydrabase-primary fan
  out, alternate source list builder), NDJSON streaming generator
  for /source/<src>, and the SearchDeps dataclass that bundles the
  cross-cutting deps.

Routes pass clients (spotify, hydrabase, hydrabase_worker, soulseek)
and helpers (config_manager, fix_artist_image_url,
_is_hydrabase_active, _get_metadata_fallback_*, _run_background_
comparison, run_async, dev_mode_enabled_provider) into core/search via
a SearchDeps bundle built per-request. fix_artist_image_url stays in
web_server.py because it touches 31 other call sites.

Behavior preserved 1:1:
- Same response shapes (db_artists, spotify_artists, spotify_albums,
  spotify_tracks, primary_source, metadata_source, alternate_sources,
  source_available)
- Same NDJSON line ordering (artists/albums/tracks as they finish, plus
  done marker)
- Same per-kind exception swallowing
- Same hydrabase-worker mirror on dev mode
- Same cache key shape (5-tuple) and TTL/LRU semantics
- Same stream-track effective-mode resolution including the
  Soulseek-coerce-to-YouTube edge case
- Same library-check Plex thumb URL rewriting and wishlist fallback
  for older DBs

Tests: 94 new (cache TTL/LRU/key, sources happy/partial/all-fail,
library presence with library + wishlist + thumbs, stream effective
mode + query gen + retry, orchestrator client resolution + short
query + single source + fan-out alternates + hydrabase primary +
NDJSON drain). Full suite: 788 passing (was 694).

Ruff clean.
2026-04-27 15:07:11 -07:00

115 lines
4.4 KiB
Python

"""Per-source metadata search.
Two public functions:
- `search_kind(client, query, kind, source_name=None)` — search a single
result type (artists | albums | tracks) on one client and normalize the
result to a list of plain dicts.
- `search_source(query, client, source_name=None)` — fan three
search_kind calls out across a thread pool and return the merged dict.
Both swallow per-kind exceptions — search reliability matters more than
strict error propagation, and the route layer cannot do anything useful
with a single-kind failure.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Optional
logger = logging.getLogger(__name__)
EMPTY_SOURCE = {"artists": [], "albums": [], "tracks": [], "available": False}
def search_kind(client, query: str, kind: str, source_name: Optional[str] = None) -> list:
"""Search one result type from a metadata source and normalize it."""
source_label = source_name or type(client).__name__
if kind == "artists":
artists = []
try:
artist_objs = client.search_artists(query, limit=10)
for artist in artist_objs:
artists.append({
"id": artist.id,
"name": artist.name,
"image_url": artist.image_url,
"external_urls": artist.external_urls or {},
})
except Exception as e:
logger.debug(f"Artist search failed for {source_label}: {e}")
return artists
if kind == "albums":
albums = []
try:
album_objs = client.search_albums(query, limit=10)
for album in album_objs:
artist_name = ', '.join(album.artists) if album.artists else 'Unknown Artist'
albums.append({
"id": album.id,
"name": album.name,
"artist": artist_name,
"image_url": album.image_url,
"release_date": album.release_date,
"total_tracks": album.total_tracks,
"album_type": album.album_type,
"external_urls": album.external_urls or {},
})
except Exception as e:
logger.warning(f"Album search failed for {source_label}: {e}", exc_info=True)
return albums
if kind == "tracks":
tracks = []
try:
track_objs = client.search_tracks(query, limit=10)
for track in track_objs:
artist_name = ', '.join(track.artists) if track.artists else 'Unknown Artist'
tracks.append({
"id": track.id,
"name": track.name,
"artist": artist_name,
"album": track.album,
"duration_ms": track.duration_ms,
"image_url": track.image_url,
"release_date": track.release_date,
"external_urls": track.external_urls or {},
})
except Exception as e:
logger.warning(f"Track search failed for {source_label}: {e}", exc_info=True)
return tracks
raise ValueError(f"Unknown metadata search kind: {kind}")
def search_source(query: str, client, source_name: Optional[str] = None) -> dict:
"""Run all three search-kinds against a single client in parallel."""
results: dict[str, Any] = {"artists": [], "albums": [], "tracks": []}
with ThreadPoolExecutor(max_workers=3) as executor:
futures = {
executor.submit(search_kind, client, query, "artists", source_name): "artists",
executor.submit(search_kind, client, query, "albums", source_name): "albums",
executor.submit(search_kind, client, query, "tracks", source_name): "tracks",
}
for future in as_completed(futures):
kind = futures[future]
try:
results[kind] = future.result()
except Exception as e:
logger.warning(
f"{kind.title()} search failed for {source_name or type(client).__name__}: {e}",
exc_info=True,
)
results[kind] = []
return {
"artists": results["artists"],
"albums": results["albums"],
"tracks": results["tracks"],
"available": True,
}