soulsync/core/search/orchestrator.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

358 lines
14 KiB
Python

"""Enhanced-search orchestration.
Two routes funnel through here:
- `/api/enhanced-search` → `run_enhanced_search`
- Always returns library DB matches
- Single-source mode (request body has `source: "spotify"` etc) skips fan-out
- Default mode resolves a primary source, runs it synchronously, and
returns the list of alternate sources for the frontend to fetch async
- `/api/enhanced-search/source/<src>` → `stream_source_search` (generator)
- NDJSON: yields one line per kind (artists / albums / tracks) as each
finishes, plus a final `{"type":"done"}` line
- Has its own special-case for `youtube_videos` which uses yt-dlp
The route layer wraps the generator in a Flask `Response(...,
mimetype='application/x-ndjson')`. Everything else is plain Python.
Cross-cutting deps are passed in as a `SearchDeps` dataclass to keep the
function signatures readable. Each field is a live reference (not a
snapshot) so callers see config changes without restart.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Any, Callable, Iterator, Optional
from . import sources
logger = logging.getLogger(__name__)
VALID_SOURCES = (
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz',
)
VALID_STREAM_SOURCES = VALID_SOURCES + ('youtube_videos',)
@dataclass
class SearchDeps:
"""Bundle of cross-cutting deps used by the orchestrator.
All fields are lazily evaluated where possible (live providers, not
cached values) so settings changes take effect without restart.
"""
database: Any
config_manager: Any
spotify_client: Any
hydrabase_client: Any
hydrabase_worker: Any
soulseek_client: Any
fix_artist_image_url: Callable[[Optional[str]], Optional[str]]
is_hydrabase_active: Callable[[], bool]
get_metadata_fallback_source: Callable[[], str]
get_metadata_fallback_client: Callable[[], Any]
get_itunes_client: Callable[[], Any]
get_deezer_client: Callable[[], Any]
get_discogs_client: Callable[[Optional[str]], Any]
run_background_comparison: Callable[..., None]
run_async: Callable
dev_mode_enabled_provider: Callable[[], bool]
def resolve_client(source_name: str, deps: SearchDeps) -> tuple[Any, bool]:
"""Return (client, is_available) for an explicit metadata source request."""
if source_name == 'spotify':
if deps.spotify_client and deps.spotify_client.is_spotify_authenticated():
return deps.spotify_client, True
return None, False
if source_name == 'itunes':
return deps.get_itunes_client(), True
if source_name == 'deezer':
return deps.get_deezer_client(), True
if source_name == 'discogs':
token = deps.config_manager.get('discogs.token', '')
if not token:
return None, False
return deps.get_discogs_client(token), True
if source_name == 'hydrabase':
if deps.hydrabase_client and deps.hydrabase_client.is_connected():
return deps.hydrabase_client, True
return None, False
if source_name == 'musicbrainz':
try:
from core.musicbrainz_search import MusicBrainzSearchClient
return MusicBrainzSearchClient(), True
except Exception as e:
logger.warning(f"MusicBrainz search client init failed: {e}")
return None, False
return None, False
def _build_db_artists(query: str, deps: SearchDeps) -> list[dict]:
active_server = deps.config_manager.get_active_media_server()
artist_objs = deps.database.search_artists(query, limit=5, server_source=active_server)
out: list[dict] = []
for artist in artist_objs:
image_url = None
if hasattr(artist, 'thumb_url') and artist.thumb_url:
image_url = deps.fix_artist_image_url(artist.thumb_url)
out.append({
'id': artist.id,
'name': artist.name,
'image_url': image_url,
})
return out
def _short_query_response(db_artists: list[dict], requested_source: str, deps: SearchDeps) -> dict:
"""Skip the remote search for queries shorter than 3 chars."""
short_source = requested_source or deps.get_metadata_fallback_source()
return {
'db_artists': db_artists,
'spotify_artists': [],
'spotify_albums': [],
'spotify_tracks': [],
'metadata_source': short_source,
'primary_source': short_source,
'alternate_sources': [],
'sources': {},
}
def _single_source_response(
query: str,
db_artists: list[dict],
requested_source: str,
deps: SearchDeps,
) -> dict:
"""Run a single-source search — bypasses the fan-out."""
client, available = resolve_client(requested_source, deps)
if not client:
return {
'db_artists': db_artists,
'spotify_artists': [],
'spotify_albums': [],
'spotify_tracks': [],
'metadata_source': requested_source,
'primary_source': requested_source,
'alternate_sources': [],
'source_available': False,
}
try:
source_results = sources.search_source(query, client, requested_source)
except Exception as e:
logger.warning(f"Single-source search ({requested_source}) failed: {e}")
source_results = {'artists': [], 'albums': [], 'tracks': [], 'available': False}
logger.info(
f"Enhanced search [source={requested_source}] results: "
f"{len(db_artists)} DB, {len(source_results['artists'])} artists, "
f"{len(source_results['albums'])} albums, {len(source_results['tracks'])} tracks"
)
return {
'db_artists': db_artists,
'spotify_artists': source_results['artists'],
'spotify_albums': source_results['albums'],
'spotify_tracks': source_results['tracks'],
'metadata_source': requested_source,
'primary_source': requested_source,
'alternate_sources': [],
'source_available': True,
}
def _alternate_sources(primary_source: str, deps: SearchDeps) -> list[str]:
"""Build the list of alternate sources the frontend should fetch async."""
spotify_available = bool(deps.spotify_client and deps.spotify_client.is_spotify_authenticated())
hydrabase_available = bool(deps.hydrabase_client and deps.hydrabase_client.is_connected())
discogs_available = bool(deps.config_manager.get('discogs.token', ''))
alts: list[str] = []
if primary_source != 'spotify' and spotify_available:
alts.append('spotify')
if primary_source != 'itunes':
alts.append('itunes')
if primary_source != 'deezer':
alts.append('deezer')
if primary_source != 'discogs' and discogs_available:
alts.append('discogs')
if primary_source != 'hydrabase' and hydrabase_available:
alts.append('hydrabase')
alts.append('youtube_videos') # always available (yt-dlp, no auth)
alts.append('musicbrainz') # always available (public API)
return alts
def _fan_out_response(query: str, db_artists: list[dict], deps: SearchDeps) -> dict:
"""Default flow: pick a primary source, run it, list alternates."""
primary_source = 'spotify'
primary_results = sources.EMPTY_SOURCE
if deps.is_hydrabase_active():
primary_source = 'hydrabase'
try:
primary_results = sources.search_source(query, deps.hydrabase_client)
deps.run_background_comparison(query, hydrabase_counts={
'tracks': len(primary_results['tracks']),
'artists': len(primary_results['artists']),
'albums': len(primary_results['albums']),
})
except Exception as e:
logger.error(f"Hydrabase search failed: {e}")
primary_source = 'spotify'
primary_results = sources.EMPTY_SOURCE
if primary_source != 'hydrabase':
if deps.hydrabase_worker and deps.dev_mode_enabled_provider():
deps.hydrabase_worker.enqueue(query, 'tracks')
deps.hydrabase_worker.enqueue(query, 'albums')
deps.hydrabase_worker.enqueue(query, 'artists')
fb_source = deps.get_metadata_fallback_source()
try:
primary_results = sources.search_source(query, deps.get_metadata_fallback_client(), fb_source)
primary_source = fb_source
except Exception as e:
logger.debug(f"Primary source ({fb_source}) search failed: {e}")
if primary_results is sources.EMPTY_SOURCE and fb_source != 'spotify':
if deps.spotify_client and deps.spotify_client.is_spotify_authenticated():
try:
primary_results = sources.search_source(query, deps.spotify_client, 'spotify')
primary_source = 'spotify'
except Exception as e:
logger.debug(f"Spotify fallback search failed: {e}")
alternate_sources = _alternate_sources(primary_source, deps)
logger.info(
f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, "
f"{len(primary_results['artists'])} artists, "
f"{len(primary_results['albums'])} albums, "
f"{len(primary_results['tracks'])} tracks | "
f"Alt sources available: {alternate_sources}"
)
return {
'db_artists': db_artists,
'spotify_artists': primary_results['artists'],
'spotify_albums': primary_results['albums'],
'spotify_tracks': primary_results['tracks'],
'metadata_source': primary_source,
'primary_source': primary_source,
'alternate_sources': alternate_sources,
}
def empty_response() -> dict:
"""Response shape for an empty query — preserves the legacy spotify-default keys."""
return {
'db_artists': [],
'spotify_artists': [],
'spotify_albums': [],
'spotify_tracks': [],
'sources': {},
'primary_source': 'spotify',
'metadata_source': 'spotify',
}
def run_enhanced_search(query: str, requested_source: str, deps: SearchDeps) -> dict:
"""Main flow: build db_artists, then dispatch to the right strategy.
Caller is responsible for cache lookup / store and request shape; this
function returns a plain dict.
"""
db_artists = _build_db_artists(query, deps)
if len(query) < 3:
return _short_query_response(db_artists, requested_source, deps)
if requested_source:
return _single_source_response(query, db_artists, requested_source, deps)
return _fan_out_response(query, db_artists, deps)
# ---------------------------------------------------------------------------
# NDJSON streaming for /api/enhanced-search/source/<src>
# ---------------------------------------------------------------------------
def stream_source_search(source_name: str, query: str, deps: SearchDeps) -> Iterator[str]:
"""Yield NDJSON lines for a single-source streaming search.
One line per kind (artists / albums / tracks) as each finishes; final
`{"type":"done"}` line. For `youtube_videos`, yields a single
`{"type":"videos", "data": [...]}` line plus the done marker.
"""
if not query:
yield json.dumps({'type': 'done'}) + '\n'
return
if source_name == 'youtube_videos':
yield from _stream_youtube_videos(query, deps)
return
client, _available = resolve_client(source_name, deps)
if client is None:
yield json.dumps({'type': 'done'}) + '\n'
return
yield from _stream_metadata_source(source_name, query, client)
def _stream_youtube_videos(query: str, deps: SearchDeps) -> Iterator[str]:
"""yt-dlp video search — yields a single videos chunk + done marker."""
youtube = getattr(deps.soulseek_client, 'youtube', None) if deps.soulseek_client else None
if not youtube:
yield json.dumps({'type': 'videos', 'data': []}) + '\n'
yield json.dumps({'type': 'done'}) + '\n'
return
try:
video_query = f"{query} official music video"
results = deps.run_async(youtube.search_videos(video_query, max_results=20))
videos = []
for v in (results or []):
videos.append({
'video_id': v.video_id,
'title': v.title,
'channel': v.channel,
'duration': v.duration,
'thumbnail': v.thumbnail,
'url': v.url,
'view_count': v.view_count,
'upload_date': v.upload_date,
})
yield json.dumps({'type': 'videos', 'data': videos}) + '\n'
except Exception as e:
logger.error(f"YouTube music video search failed: {e}")
yield json.dumps({'type': 'videos', 'data': []}) + '\n'
yield json.dumps({'type': 'done'}) + '\n'
def _stream_metadata_source(source_name: str, query: str, client) -> Iterator[str]:
"""Fan three search-kinds out and yield each as it lands."""
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=3) as executor:
futures = {
executor.submit(sources.search_kind, client, query, 'artists', source_name): 'artists',
executor.submit(sources.search_kind, client, query, 'albums', source_name): 'albums',
executor.submit(sources.search_kind, client, query, 'tracks', source_name): 'tracks',
}
for future in as_completed(futures):
kind = futures[future]
try:
payload = future.result()
except Exception as e:
logger.warning(f"{kind.title()} search failed for {source_name}: {e}", exc_info=True)
payload = []
yield json.dumps({'type': kind, 'data': payload}) + '\n'
yield json.dumps({'type': 'done'}) + '\n'