From fd7b56e58c1ecd10796e4a5707823c1072176ab1 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:07:11 -0700 Subject: [PATCH 1/4] Lift /api/search and /api/enhanced-search/* into core/search/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/, 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. --- core/search/__init__.py | 8 + core/search/basic.py | 44 ++ core/search/cache.py | 108 +++ core/search/library_check.py | 168 +++++ core/search/orchestrator.py | 358 ++++++++++ core/search/sources.py | 115 +++ core/search/stream.py | 160 +++++ tests/search/__init__.py | 0 tests/search/test_search_basic.py | 98 +++ tests/search/test_search_cache.py | 121 ++++ tests/search/test_search_library_check.py | 282 ++++++++ tests/search/test_search_orchestrator.py | 508 +++++++++++++ tests/search/test_search_sources.py | 174 +++++ tests/search/test_search_stream.py | 260 +++++++ web_server.py | 826 +++------------------- webui/static/helper.js | 1 + 16 files changed, 2512 insertions(+), 719 deletions(-) create mode 100644 core/search/__init__.py create mode 100644 core/search/basic.py create mode 100644 core/search/cache.py create mode 100644 core/search/library_check.py create mode 100644 core/search/orchestrator.py create mode 100644 core/search/sources.py create mode 100644 core/search/stream.py create mode 100644 tests/search/__init__.py create mode 100644 tests/search/test_search_basic.py create mode 100644 tests/search/test_search_cache.py create mode 100644 tests/search/test_search_library_check.py create mode 100644 tests/search/test_search_orchestrator.py create mode 100644 tests/search/test_search_sources.py create mode 100644 tests/search/test_search_stream.py diff --git a/core/search/__init__.py b/core/search/__init__.py new file mode 100644 index 00000000..45e97085 --- /dev/null +++ b/core/search/__init__.py @@ -0,0 +1,8 @@ +"""Search API helpers package. + +Lifted from web_server.py /api/search and /api/enhanced-search/* routes. +Each module exposes pure-ish functions that take dependencies (database, +clients, config_manager, matching_engine) as arguments. Route handlers in +web_server.py stay thin — they parse requests, call into these helpers, +and return jsonify / streaming responses. +""" diff --git a/core/search/basic.py b/core/search/basic.py new file mode 100644 index 00000000..75ddd575 --- /dev/null +++ b/core/search/basic.py @@ -0,0 +1,44 @@ +"""Basic Soulseek file search — flat list of file results sorted by quality. + +Used by the Soulseek source icon in the unified search UI and by direct +/api/search calls. Synchronous wrapper around the async soulseek client. +""" + +from __future__ import annotations + +import logging +from typing import Callable + +logger = logging.getLogger(__name__) + + +def run_basic_soulseek_search( + query: str, + soulseek_client, + run_async: Callable, +) -> list[dict]: + """Search Soulseek for `query`, normalize albums + tracks to one sorted list. + + Returns dicts with `result_type` set to "album" or "track" and sorted by + `quality_score` descending. Empty list on any failure (caller logs). + """ + tracks, albums = run_async(soulseek_client.search(query)) + + processed_albums = [] + for album in albums: + album_dict = album.__dict__.copy() + album_dict['tracks'] = [track.__dict__ for track in album.tracks] + album_dict['result_type'] = 'album' + processed_albums.append(album_dict) + + processed_tracks = [] + for track in tracks: + track_dict = track.__dict__.copy() + track_dict['result_type'] = 'track' + processed_tracks.append(track_dict) + + return sorted( + processed_albums + processed_tracks, + key=lambda x: x.get('quality_score', 0), + reverse=True, + ) diff --git a/core/search/cache.py b/core/search/cache.py new file mode 100644 index 00000000..4692f3dd --- /dev/null +++ b/core/search/cache.py @@ -0,0 +1,108 @@ +"""TTL'd in-memory cache for enhanced-search responses. + +The cache key blends the normalized query with the active media server, +configured fallback metadata source, hydrabase-active flag, and the +explicit single-source request (if any). This prevents responses from +colliding when a user changes settings or switches single-source mode. +""" + +from __future__ import annotations + +import collections +import threading +import time +from typing import Any, Callable, Optional, Tuple + +CacheKey = Tuple[str, str, str, bool, str] + +CACHE_TTL_SECONDS = 600 +CACHE_MAX_ENTRIES = 100 + + +class EnhancedSearchCache: + """Thread-safe LRU+TTL cache for enhanced-search response payloads. + + A single shared instance lives in this module (`_cache`). The module-level + helpers (`get_cache_key`, `get_cached_response`, `set_cached_response`) + operate on it. + """ + + def __init__(self, ttl: float = CACHE_TTL_SECONDS, max_entries: int = CACHE_MAX_ENTRIES): + self._ttl = ttl + self._max_entries = max_entries + self._store: "collections.OrderedDict[CacheKey, dict]" = collections.OrderedDict() + self._lock = threading.Lock() + + def get(self, key: CacheKey) -> Optional[dict]: + now = time.time() + with self._lock: + entry = self._store.get(key) + if not entry: + return None + if now - entry['timestamp'] < self._ttl: + self._store.move_to_end(key) + return entry['data'] + self._store.pop(key, None) + return None + + def set(self, key: CacheKey, data: dict) -> None: + with self._lock: + self._store[key] = {'timestamp': time.time(), 'data': data} + self._store.move_to_end(key) + while len(self._store) > self._max_entries: + self._store.popitem(last=False) + + def clear(self) -> None: + with self._lock: + self._store.clear() + + +_cache = EnhancedSearchCache() + + +def get_cache_key( + query: str, + requested_source: Optional[str], + *, + active_server_provider: Callable[[], str], + fallback_source_provider: Callable[[], str], + hydrabase_active_provider: Callable[[], bool], +) -> CacheKey: + """Build a cache key for an enhanced-search query. + + Each provider arg is a zero-arg callable so the cache key reflects the + LIVE config state at lookup time, not the state at app startup. Each + provider is wrapped in try/except: failures resolve to a sentinel value + so a misconfigured client never breaks search. + """ + normalized_query = (query or '').strip().lower() + + try: + active_server = active_server_provider() or 'unknown' + except Exception: + active_server = 'unknown' + + try: + fallback_source = fallback_source_provider() or 'unknown' + except Exception: + fallback_source = 'unknown' + + try: + hydrabase_active = bool(hydrabase_active_provider()) + except Exception: + hydrabase_active = False + + source_tag = (requested_source or '').strip().lower() or 'auto' + return (normalized_query, active_server, fallback_source, hydrabase_active, source_tag) + + +def get_cached_response(key: CacheKey) -> Optional[dict]: + return _cache.get(key) + + +def set_cached_response(key: CacheKey, data: Any) -> None: + _cache.set(key, data) + + +def clear_cache() -> None: + _cache.clear() diff --git a/core/search/library_check.py b/core/search/library_check.py new file mode 100644 index 00000000..479a23d1 --- /dev/null +++ b/core/search/library_check.py @@ -0,0 +1,168 @@ +"""Batch library presence check for search results. + +Given a list of `albums` and `tracks` from a metadata search, return per-row +booleans (and matched-row metadata for tracks) indicating whether each +result is already in the user's library or wishlist. Plex relative-path +thumb URLs are rewritten to absolute URLs with token. + +Called async from the frontend after the main search renders, so the user +sees results immediately and "in library" badges fade in once the check +completes. +""" + +from __future__ import annotations + +import json +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +def _resolve_plex_thumb(thumb: str, plex_base: str, plex_token: str) -> str: + """Rewrite a Plex relative thumb path to an absolute URL with token.""" + if not thumb or thumb.startswith('http') or not plex_base or not thumb.startswith('/'): + return thumb + if plex_token: + return f"{plex_base}{thumb}?X-Plex-Token={plex_token}" + return f"{plex_base}{thumb}" + + +def _resolve_plex_credentials(plex_client, config_manager) -> tuple[str, str]: + """Pull (base_url, token) for the active Plex server. + + Prefers the live `plex_client.server` attrs; falls back to config_manager + if the live client isn't connected yet. + """ + base, token = '', '' + if plex_client and getattr(plex_client, 'server', None) is not None: + base = getattr(plex_client.server, '_baseurl', '') or '' + token = getattr(plex_client.server, '_token', '') or '' + if not base: + try: + cfg = config_manager.get_plex_config() + base = (cfg.get('base_url', '') or '').rstrip('/') + token = token or cfg.get('token', '') + except Exception: + pass + return base, token + + +def _load_wishlist_keys(cursor, profile_id: int) -> set[str]: + """Build a set of `name|||artist` keys from the wishlist for fast lookup. + + Try the profile-aware schema first; fall back to the legacy schema if + profile_id column is missing (older DBs). Errors at any level are + swallowed — wishlist annotation is best-effort. + """ + keys: set[str] = set() + + def _absorb(rows): + for wr in rows: + try: + wd = json.loads(wr[0]) if isinstance(wr[0], str) else {} + wname = (wd.get('name') or '').lower() + wartists = wd.get('artists', []) + if wartists: + first = wartists[0] + wa = first.get('name', '') if isinstance(first, dict) else str(first) + else: + wa = '' + if wname: + keys.add(wname + '|||' + wa.lower().strip()) + except Exception: + pass + + try: + cursor.execute("SELECT spotify_data FROM wishlist_tracks WHERE profile_id = ?", (profile_id,)) + _absorb(cursor.fetchall()) + return keys + except Exception: + pass + + try: + cursor.execute("SELECT spotify_data FROM wishlist_tracks") + _absorb(cursor.fetchall()) + except Exception: + pass + return keys + + +def check_library_presence( + database, + plex_client, + config_manager, + profile_id: int, + albums: list[dict], + tracks: list[dict], +) -> dict: + """Return `{albums: [bool], tracks: [{...}]}` for the given search results. + + - `albums` returns one bool per input row. + - `tracks` returns one dict per input row. Matched rows get the full + track metadata + resolved thumb URL; unmatched rows get + `{in_library: False, in_wishlist: bool}`. + """ + conn = database._get_connection() + try: + cursor = conn.cursor() + + cursor.execute( + "SELECT LOWER(al.title) || '|||' || LOWER(ar.name) " + "FROM albums al JOIN artists ar ON ar.id = al.artist_id" + ) + owned_albums = {r[0] for r in cursor.fetchall()} + + cursor.execute( + """ + SELECT LOWER(t.title) || '|||' || LOWER(a.name), t.id, t.file_path, + t.title, a.name, al.title, al.thumb_url + FROM tracks t + JOIN artists a ON a.id = t.artist_id + JOIN albums al ON al.id = t.album_id + """ + ) + owned_tracks: dict[str, dict] = {} + for r in cursor.fetchall(): + if r[0] not in owned_tracks: # keep first match only + owned_tracks[r[0]] = { + 'track_id': r[1], + 'file_path': r[2], + 'title': r[3], + 'artist_name': r[4], + 'album_title': r[5], + 'album_thumb_url': r[6], + } + + wishlist_keys = _load_wishlist_keys(cursor, profile_id) + + album_results: list[bool] = [] + for a in albums: + key = ( + (a.get('name') or '').lower() + + '|||' + + (a.get('artist') or '').split(',')[0].strip().lower() + ) + album_results.append(key in owned_albums) + + plex_base, plex_token = _resolve_plex_credentials(plex_client, config_manager) + + track_results: list[dict] = [] + for t in tracks: + key = ( + (t.get('name') or '').lower() + + '|||' + + (t.get('artist') or '').split(',')[0].strip().lower() + ) + in_wishlist = key in wishlist_keys + match = owned_tracks.get(key) + if match: + thumb = match.get('album_thumb_url') or '' + match['album_thumb_url'] = _resolve_plex_thumb(thumb, plex_base, plex_token) + track_results.append({'in_library': True, 'in_wishlist': in_wishlist, **match}) + else: + track_results.append({'in_library': False, 'in_wishlist': in_wishlist}) + finally: + conn.close() + + return {'albums': album_results, 'tracks': track_results} diff --git a/core/search/orchestrator.py b/core/search/orchestrator.py new file mode 100644 index 00000000..ec833692 --- /dev/null +++ b/core/search/orchestrator.py @@ -0,0 +1,358 @@ +"""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/` → `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/ +# --------------------------------------------------------------------------- + +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' diff --git a/core/search/sources.py b/core/search/sources.py new file mode 100644 index 00000000..ac6c7735 --- /dev/null +++ b/core/search/sources.py @@ -0,0 +1,115 @@ +"""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, + } diff --git a/core/search/stream.py b/core/search/stream.py new file mode 100644 index 00000000..1b5710e7 --- /dev/null +++ b/core/search/stream.py @@ -0,0 +1,160 @@ +"""Single-track stream search — finds the best Soulseek result for a track +play preview. + +Builds a small ordered list of search query variants (artist+title, +artist+cleaned title; or title-only when the stream source is Soulseek +itself) and walks them until one returns a usable match through the +matching engine. + +Stream source resolution: +- If `download_source.stream_source` is "youtube" (default), use the + YouTube downloader for previews — instant, no auth pressure on the + download stack. +- If it's "active", mirror the user's download mode (tidal / qobuz / + hifi / deezer_dl / lidarr) — but coerce Soulseek to YouTube because + Soulseek is too slow for streaming previews. +""" + +from __future__ import annotations + +import logging +import re +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + + +def _resolve_effective_stream_mode(config_manager) -> str: + """Pick the streaming source based on settings.""" + stream_source = config_manager.get('download_source.stream_source', 'youtube') + download_mode = config_manager.get('download_source.mode', 'hybrid') + + if stream_source == 'youtube': + return 'youtube' + + hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) + hybrid_first = hybrid_order[0] if hybrid_order else config_manager.get('download_source.hybrid_primary', 'hifi') + + if download_mode == 'soulseek' or (download_mode == 'hybrid' and hybrid_first == 'soulseek'): + logger.info("Stream source is 'active' but primary is Soulseek — falling back to YouTube") + return 'youtube' + if download_mode == 'hybrid': + return hybrid_first + return download_mode + + +def _build_stream_queries(track_name: str, artist_name: str, effective_mode: str) -> list[str]: + """Build an ordered, deduped list of search queries to try.""" + queries: list[str] = [] + + is_streaming_source = effective_mode in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr') + + if is_streaming_source: + if artist_name and track_name: + queries.append(f"{artist_name} {track_name}".strip()) + + cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip() + cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip() + if cleaned_name and cleaned_name.lower() != track_name.lower(): + queries.append(f"{artist_name} {cleaned_name}".strip()) + else: + if track_name.strip(): + queries.append(track_name.strip()) + cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip() + cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip() + if cleaned_name and cleaned_name.lower() != track_name.lower(): + queries.append(cleaned_name.strip()) + + seen: set[str] = set() + deduped: list[str] = [] + for q in queries: + if q and q.lower() not in seen: + deduped.append(q) + seen.add(q.lower()) + return deduped + + +def _result_to_dict(best_result) -> dict: + return { + "username": best_result.username, + "filename": best_result.filename, + "size": best_result.size, + "bitrate": best_result.bitrate, + "duration": best_result.duration, + "quality": best_result.quality, + "free_upload_slots": best_result.free_upload_slots, + "upload_speed": best_result.upload_speed, + "queue_length": best_result.queue_length, + "result_type": "track", + } + + +def stream_search_track( + *, + track_name: str, + artist_name: str, + album_name: Optional[str], + duration_ms: int, + config_manager, + soulseek_client, + matching_engine, + run_async: Callable, +) -> Optional[dict]: + """Find the best Soulseek/stream-source result for a single track. + + Returns the matched result dict on success, or `None` if no query + variant produced a usable match. The route layer turns `None` into a + 404 response. + """ + temp_track = type('TempTrack', (), { + 'name': track_name, + 'artists': [artist_name], + 'album': album_name if album_name else None, + 'duration_ms': duration_ms, + })() + + effective_mode = _resolve_effective_stream_mode(config_manager) + logger.info(f"Stream source effective mode: {effective_mode}") + + queries = _build_stream_queries(track_name, artist_name, effective_mode) + + stream_clients = { + 'youtube': getattr(soulseek_client, 'youtube', None), + 'tidal': getattr(soulseek_client, 'tidal', None), + 'qobuz': getattr(soulseek_client, 'qobuz', None), + 'hifi': getattr(soulseek_client, 'hifi', None), + 'deezer_dl': getattr(soulseek_client, 'deezer_dl', None), + 'lidarr': getattr(soulseek_client, 'lidarr', None), + } + stream_client = stream_clients.get(effective_mode) + use_direct_client = stream_client is not None + + max_peer_queue = config_manager.get('soulseek.max_peer_queue', 0) or 0 + + for query_index, query in enumerate(queries): + logger.info(f"Stream query {query_index + 1}/{len(queries)}: '{query}'") + try: + if use_direct_client: + tracks_result, _ = run_async(stream_client.search(query, timeout=15)) + else: + tracks_result, _ = run_async(soulseek_client.search(query, timeout=15)) + + if not tracks_result: + logger.info(f"No results for query '{query}', trying next...") + continue + + best_matches = matching_engine.find_best_slskd_matches_enhanced( + temp_track, tracks_result, max_peer_queue=max_peer_queue + ) + if best_matches: + best = best_matches[0] + logger.info(f"Stream match for '{query}': {best.filename} ({best.quality})") + return _result_to_dict(best) + + logger.info(f"No suitable matches for query '{query}', trying next...") + except Exception as e: + logger.warning(f"Stream search failed for query '{query}': {e}") + continue + + logger.warning(f"No stream match found after {len(queries)} queries") + return None diff --git a/tests/search/__init__.py b/tests/search/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/search/test_search_basic.py b/tests/search/test_search_basic.py new file mode 100644 index 00000000..0e1d9d79 --- /dev/null +++ b/tests/search/test_search_basic.py @@ -0,0 +1,98 @@ +"""Tests for core/search/basic.py — basic Soulseek file search.""" + +from __future__ import annotations + +from core.search import basic + + +class _SearchTrack: + def __init__(self, name, quality_score, **extra): + self.__dict__['name'] = name + self.__dict__['quality_score'] = quality_score + self.__dict__.update(extra) + + +class _SearchAlbum: + def __init__(self, name, quality_score, tracks=None, **extra): + self.__dict__['name'] = name + self.__dict__['quality_score'] = quality_score + self.__dict__['tracks'] = tracks or [] + self.__dict__.update(extra) + + +class _FakeSoulseek: + def __init__(self, tracks=None, albums=None): + self._tracks = tracks or [] + self._albums = albums or [] + + async def search(self, query): + return self._tracks, self._albums + + +def _run_async(coro): + """Test-friendly run_async — drains a coroutine synchronously.""" + import asyncio + return asyncio.get_event_loop().run_until_complete(coro) if not asyncio.get_event_loop().is_running() else None + + +def _sync_run_async(coro): + """Threadless awaitable runner using a fresh loop.""" + import asyncio + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def test_returns_empty_for_no_results(): + client = _FakeSoulseek(tracks=[], albums=[]) + result = basic.run_basic_soulseek_search('q', client, _sync_run_async) + assert result == [] + + +def test_tracks_are_tagged_with_result_type(): + track = _SearchTrack('T1', 0.5, username='u', filename='f.mp3', size=1, bitrate=320) + client = _FakeSoulseek(tracks=[track]) + result = basic.run_basic_soulseek_search('q', client, _sync_run_async) + assert result[0]['result_type'] == 'track' + assert result[0]['name'] == 'T1' + + +def test_albums_get_tracks_serialized_and_tagged(): + inner_track = _SearchTrack('inner', 0.5, filename='in.mp3') + album = _SearchAlbum('A1', 0.9, tracks=[inner_track], username='u') + client = _FakeSoulseek(albums=[album]) + result = basic.run_basic_soulseek_search('q', client, _sync_run_async) + assert result[0]['result_type'] == 'album' + assert result[0]['name'] == 'A1' + assert isinstance(result[0]['tracks'], list) + assert result[0]['tracks'][0]['name'] == 'inner' + + +def test_results_sorted_by_quality_score_desc(): + low = _SearchTrack('low', 0.1) + high = _SearchTrack('high', 0.9) + mid = _SearchTrack('mid', 0.5) + client = _FakeSoulseek(tracks=[low, mid, high]) + result = basic.run_basic_soulseek_search('q', client, _sync_run_async) + assert [r['name'] for r in result] == ['high', 'mid', 'low'] + + +def test_albums_and_tracks_intermingled_by_quality(): + track = _SearchTrack('mid_t', 0.5) + album = _SearchAlbum('top_a', 0.9, tracks=[]) + client = _FakeSoulseek(tracks=[track], albums=[album]) + result = basic.run_basic_soulseek_search('q', client, _sync_run_async) + assert result[0]['name'] == 'top_a' + assert result[1]['name'] == 'mid_t' + + +def test_missing_quality_score_treated_as_zero(): + no_score = _SearchTrack('n', None) + no_score.__dict__.pop('quality_score', None) + has_score = _SearchTrack('h', 0.5) + client = _FakeSoulseek(tracks=[no_score, has_score]) + result = basic.run_basic_soulseek_search('q', client, _sync_run_async) + # has_score (0.5) ranks above no_score (treated as 0) + assert result[0]['name'] == 'h' diff --git a/tests/search/test_search_cache.py b/tests/search/test_search_cache.py new file mode 100644 index 00000000..15ae6517 --- /dev/null +++ b/tests/search/test_search_cache.py @@ -0,0 +1,121 @@ +"""Tests for core/search/cache.py — TTL+LRU cache for enhanced-search responses.""" + +from __future__ import annotations + +import time + +import pytest + +from core.search import cache as search_cache + + +@pytest.fixture +def fresh_cache(): + """Each test gets a clean instance — module-level _cache is shared otherwise.""" + return search_cache.EnhancedSearchCache(ttl=10, max_entries=3) + + +def test_set_and_get_round_trip(fresh_cache): + key = ('q', 'plex', 'spotify', False, 'auto') + fresh_cache.set(key, {'result': 'data'}) + assert fresh_cache.get(key) == {'result': 'data'} + + +def test_get_returns_none_for_missing_key(fresh_cache): + assert fresh_cache.get(('absent', 'plex', 'spotify', False, 'auto')) is None + + +def test_ttl_expiration_evicts_entry(): + c = search_cache.EnhancedSearchCache(ttl=0.05, max_entries=10) + key = ('q', 'plex', 'spotify', False, 'auto') + c.set(key, {'x': 1}) + assert c.get(key) == {'x': 1} + time.sleep(0.1) + assert c.get(key) is None + + +def test_max_entries_evicts_lru(fresh_cache): + # max_entries = 3 + for i in range(3): + fresh_cache.set((f"q{i}", 'plex', 'spotify', False, 'auto'), {'i': i}) + + fresh_cache.set(('q3', 'plex', 'spotify', False, 'auto'), {'i': 3}) + # q0 should be evicted (oldest) + assert fresh_cache.get(('q0', 'plex', 'spotify', False, 'auto')) is None + assert fresh_cache.get(('q3', 'plex', 'spotify', False, 'auto')) == {'i': 3} + + +def test_get_promotes_lru(fresh_cache): + fresh_cache.set(('q0', 'plex', 'spotify', False, 'auto'), {'i': 0}) + fresh_cache.set(('q1', 'plex', 'spotify', False, 'auto'), {'i': 1}) + fresh_cache.set(('q2', 'plex', 'spotify', False, 'auto'), {'i': 2}) + fresh_cache.get(('q0', 'plex', 'spotify', False, 'auto')) # touch q0 + fresh_cache.set(('q3', 'plex', 'spotify', False, 'auto'), {'i': 3}) + # q1 should be evicted, not q0 (which was just touched) + assert fresh_cache.get(('q1', 'plex', 'spotify', False, 'auto')) is None + assert fresh_cache.get(('q0', 'plex', 'spotify', False, 'auto')) == {'i': 0} + + +def test_clear_empties_cache(fresh_cache): + fresh_cache.set(('q', 'plex', 'spotify', False, 'auto'), {}) + fresh_cache.clear() + assert fresh_cache.get(('q', 'plex', 'spotify', False, 'auto')) is None + + +# --------------------------------------------------------------------------- +# Key builder +# --------------------------------------------------------------------------- + +def _providers(server='plex', source='spotify', hb=False): + return { + 'active_server_provider': lambda: server, + 'fallback_source_provider': lambda: source, + 'hydrabase_active_provider': lambda: hb, + } + + +def test_key_normalizes_query(): + key = search_cache.get_cache_key(" Pink FLOYD ", None, **_providers()) + assert key[0] == "pink floyd" + + +def test_key_includes_active_server_and_fallback(): + key = search_cache.get_cache_key('q', None, **_providers(server='jellyfin', source='deezer')) + assert key[1] == 'jellyfin' + assert key[2] == 'deezer' + + +def test_key_includes_hydrabase_flag(): + k1 = search_cache.get_cache_key('q', None, **_providers(hb=False)) + k2 = search_cache.get_cache_key('q', None, **_providers(hb=True)) + assert k1 != k2 + + +def test_key_includes_source_tag(): + k_auto = search_cache.get_cache_key('q', None, **_providers()) + k_explicit = search_cache.get_cache_key('q', 'spotify', **_providers()) + assert k_auto != k_explicit + assert k_auto[4] == 'auto' + assert k_explicit[4] == 'spotify' + + +def test_key_provider_failure_falls_back_to_unknown(): + def boom(): + raise RuntimeError("config dead") + + key = search_cache.get_cache_key('q', None, + active_server_provider=boom, + fallback_source_provider=lambda: 'spotify', + hydrabase_active_provider=lambda: False) + assert key[1] == 'unknown' + + +def test_key_hydrabase_provider_failure_falls_back_to_false(): + def boom(): + raise RuntimeError("hydrabase init failed") + + key = search_cache.get_cache_key('q', None, + active_server_provider=lambda: 'plex', + fallback_source_provider=lambda: 'spotify', + hydrabase_active_provider=boom) + assert key[3] is False diff --git a/tests/search/test_search_library_check.py b/tests/search/test_search_library_check.py new file mode 100644 index 00000000..f9fff1d7 --- /dev/null +++ b/tests/search/test_search_library_check.py @@ -0,0 +1,282 @@ +"""Tests for core/search/library_check.py — library/wishlist presence + thumb resolution.""" + +from __future__ import annotations + +import json + +import pytest + +from core.search import library_check +from database.music_database import MusicDatabase + + +@pytest.fixture +def db(tmp_path): + return MusicDatabase(str(tmp_path / "music.db")) + + +# --------------------------------------------------------------------------- +# Fakes for plex / config_manager +# --------------------------------------------------------------------------- + +class _FakePlexServer: + def __init__(self, base, token): + self._baseurl = base + self._token = token + + +class _FakePlexClient: + def __init__(self, base='https://plex.local:32400', token='abc123'): + self.server = _FakePlexServer(base, token) + + +class _NoServerPlexClient: + """Plex client that hasn't connected yet.""" + server = None + + +class _FakeConfigManager: + def __init__(self, plex_cfg=None): + self._plex_cfg = plex_cfg or {} + + def get_plex_config(self): + return dict(self._plex_cfg) + + def get(self, key, default=None): + return default + + +# --------------------------------------------------------------------------- +# DB seed helpers +# --------------------------------------------------------------------------- + +_id_counter = {'n': 0} + + +def _next_id(prefix): + _id_counter['n'] += 1 + return f"{prefix}-{_id_counter['n']}" + + +def _seed_artist(db, name): + aid = _next_id('art') + conn = db._get_connection() + try: + c = conn.cursor() + c.execute("INSERT INTO artists (id, name) VALUES (?, ?)", (aid, name)) + conn.commit() + return aid + finally: + conn.close() + + +def _seed_album(db, artist_id, title, thumb=None): + alb = _next_id('alb') + conn = db._get_connection() + try: + c = conn.cursor() + c.execute( + "INSERT INTO albums (id, artist_id, title, thumb_url) VALUES (?, ?, ?, ?)", + (alb, artist_id, title, thumb), + ) + conn.commit() + return alb + finally: + conn.close() + + +def _seed_track(db, album_id, artist_id, title, file_path=None): + tid = _next_id('trk') + conn = db._get_connection() + try: + c = conn.cursor() + c.execute( + "INSERT INTO tracks (id, album_id, artist_id, title, file_path) VALUES (?, ?, ?, ?, ?)", + (tid, album_id, artist_id, title, file_path), + ) + conn.commit() + return tid + finally: + conn.close() + + +def _seed_wishlist(db, profile_id, name, artist_name): + spotify_data = {'name': name, 'artists': [{'name': artist_name}]} + conn = db._get_connection() + try: + c = conn.cursor() + c.execute("PRAGMA table_info(wishlist_tracks)") + cols = [r[1] for r in c.fetchall()] + if 'profile_id' in cols: + c.execute( + "INSERT INTO wishlist_tracks (spotify_track_id, spotify_data, profile_id) VALUES (?, ?, ?)", + (f"sp-{name}-{artist_name}", json.dumps(spotify_data), profile_id), + ) + else: + c.execute( + "INSERT INTO wishlist_tracks (spotify_track_id, spotify_data) VALUES (?, ?)", + (f"sp-{name}-{artist_name}", json.dumps(spotify_data)), + ) + conn.commit() + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Plex thumb resolution +# --------------------------------------------------------------------------- + +def test_resolve_plex_thumb_already_absolute_passes_through(): + assert library_check._resolve_plex_thumb('http://x/y.jpg', 'https://plex', 'tok') == 'http://x/y.jpg' + + +def test_resolve_plex_thumb_relative_gets_base_and_token(): + out = library_check._resolve_plex_thumb('/library/x.jpg', 'https://plex.local:32400', 'tok123') + assert out == 'https://plex.local:32400/library/x.jpg?X-Plex-Token=tok123' + + +def test_resolve_plex_thumb_no_token_omits_query_string(): + out = library_check._resolve_plex_thumb('/library/x.jpg', 'https://plex.local:32400', '') + assert out == 'https://plex.local:32400/library/x.jpg' + + +def test_resolve_plex_thumb_no_base_passes_through(): + assert library_check._resolve_plex_thumb('/library/x.jpg', '', 'tok') == '/library/x.jpg' + + +def test_resolve_plex_thumb_empty_passes_through(): + assert library_check._resolve_plex_thumb('', 'https://plex', 'tok') == '' + + +def test_resolve_plex_credentials_uses_live_client_first(): + cfg = _FakeConfigManager({'base_url': 'https://wrong', 'token': 'wrongtok'}) + base, token = library_check._resolve_plex_credentials(_FakePlexClient(), cfg) + assert base == 'https://plex.local:32400' + assert token == 'abc123' + + +def test_resolve_plex_credentials_falls_back_to_config(): + cfg = _FakeConfigManager({'base_url': 'https://configured/', 'token': 'cfgtok'}) + base, token = library_check._resolve_plex_credentials(_NoServerPlexClient(), cfg) + assert base == 'https://configured' + assert token == 'cfgtok' + + +def test_resolve_plex_credentials_handles_no_config(): + cfg = _FakeConfigManager({}) + base, token = library_check._resolve_plex_credentials(_NoServerPlexClient(), cfg) + assert base == '' + assert token == '' + + +# --------------------------------------------------------------------------- +# check_library_presence — albums +# --------------------------------------------------------------------------- + +def test_album_in_library_returns_true(db): + aid = _seed_artist(db, 'Pink Floyd') + _seed_album(db, aid, 'DSOTM') + cfg = _FakeConfigManager({}) + result = library_check.check_library_presence( + db, _NoServerPlexClient(), cfg, profile_id=1, + albums=[{'name': 'DSOTM', 'artist': 'Pink Floyd'}], + tracks=[], + ) + assert result['albums'] == [True] + + +def test_album_not_in_library_returns_false(db): + cfg = _FakeConfigManager({}) + result = library_check.check_library_presence( + db, _NoServerPlexClient(), cfg, profile_id=1, + albums=[{'name': 'Phantom', 'artist': 'Nobody'}], + tracks=[], + ) + assert result['albums'] == [False] + + +def test_album_lookup_uses_first_artist_in_csv(db): + aid = _seed_artist(db, 'Pink Floyd') + _seed_album(db, aid, 'DSOTM') + cfg = _FakeConfigManager({}) + result = library_check.check_library_presence( + db, _NoServerPlexClient(), cfg, profile_id=1, + albums=[{'name': 'DSOTM', 'artist': 'Pink Floyd, Roger Waters'}], + tracks=[], + ) + assert result['albums'] == [True] + + +# --------------------------------------------------------------------------- +# check_library_presence — tracks +# --------------------------------------------------------------------------- + +def test_track_in_library_returns_full_match_metadata(db): + aid = _seed_artist(db, 'Pink Floyd') + alb = _seed_album(db, aid, 'DSOTM', thumb='/library/dsotm.jpg') + tid = _seed_track(db, alb, aid, 'Money', file_path='/m/money.flac') + cfg = _FakeConfigManager({}) + result = library_check.check_library_presence( + db, _FakePlexClient(), cfg, profile_id=1, + albums=[], + tracks=[{'name': 'Money', 'artist': 'Pink Floyd'}], + ) + track = result['tracks'][0] + assert track['in_library'] is True + assert track['track_id'] == tid + assert track['file_path'] == '/m/money.flac' + assert track['title'] == 'Money' + assert track['artist_name'] == 'Pink Floyd' + assert track['album_title'] == 'DSOTM' + assert 'X-Plex-Token=abc123' in track['album_thumb_url'] + assert track['album_thumb_url'].startswith('https://plex.local:32400') + + +def test_track_not_in_library_returns_minimal_shape(db): + cfg = _FakeConfigManager({}) + result = library_check.check_library_presence( + db, _NoServerPlexClient(), cfg, profile_id=1, + albums=[], + tracks=[{'name': 'Phantom', 'artist': 'Nobody'}], + ) + assert result['tracks'] == [{'in_library': False, 'in_wishlist': False}] + + +def test_track_in_wishlist_returns_in_wishlist_true(db): + _seed_wishlist(db, profile_id=1, name='HUMBLE.', artist_name='Kendrick Lamar') + cfg = _FakeConfigManager({}) + result = library_check.check_library_presence( + db, _NoServerPlexClient(), cfg, profile_id=1, + albums=[], + tracks=[{'name': 'HUMBLE.', 'artist': 'Kendrick Lamar'}], + ) + assert result['tracks'][0] == {'in_library': False, 'in_wishlist': True} + + +def test_track_in_library_and_wishlist_both_set(db): + aid = _seed_artist(db, 'Kendrick Lamar') + alb = _seed_album(db, aid, 'DAMN.') + _seed_track(db, alb, aid, 'HUMBLE.') + _seed_wishlist(db, profile_id=1, name='HUMBLE.', artist_name='Kendrick Lamar') + + cfg = _FakeConfigManager({}) + result = library_check.check_library_presence( + db, _NoServerPlexClient(), cfg, profile_id=1, + albums=[], + tracks=[{'name': 'HUMBLE.', 'artist': 'Kendrick Lamar'}], + ) + assert result['tracks'][0]['in_library'] is True + assert result['tracks'][0]['in_wishlist'] is True + + +def test_track_artist_csv_uses_first_only(db): + aid = _seed_artist(db, 'Kendrick Lamar') + alb = _seed_album(db, aid, 'DAMN.') + _seed_track(db, alb, aid, 'HUMBLE.', file_path='/x.flac') + cfg = _FakeConfigManager({}) + result = library_check.check_library_presence( + db, _NoServerPlexClient(), cfg, profile_id=1, + albums=[], + tracks=[{'name': 'HUMBLE.', 'artist': 'Kendrick Lamar, J. Cole'}], + ) + assert result['tracks'][0]['in_library'] is True diff --git a/tests/search/test_search_orchestrator.py b/tests/search/test_search_orchestrator.py new file mode 100644 index 00000000..be0b0be0 --- /dev/null +++ b/tests/search/test_search_orchestrator.py @@ -0,0 +1,508 @@ +"""Tests for core/search/orchestrator.py — main enhanced-search dispatch + streaming.""" + +from __future__ import annotations + +import json + +import pytest + +from core.search import orchestrator + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + +class _Artist: + def __init__(self, id_, name, image_url=None, external_urls=None, thumb_url=None): + self.id = id_ + self.name = name + self.image_url = image_url + self.external_urls = external_urls + self.thumb_url = thumb_url + + +class _Album: + def __init__(self, id_, name, artists=None, image_url=None, release_date=None, + total_tracks=10, album_type='album', external_urls=None): + self.id = id_ + self.name = name + self.artists = artists or [] + self.image_url = image_url + self.release_date = release_date + self.total_tracks = total_tracks + self.album_type = album_type + self.external_urls = external_urls + + +class _Track: + def __init__(self, id_, name, artists=None, album=None, duration_ms=180000, + image_url=None, release_date=None, external_urls=None): + self.id = id_ + self.name = name + self.artists = artists or [] + self.album = album + self.duration_ms = duration_ms + self.image_url = image_url + self.release_date = release_date + self.external_urls = external_urls + + +class _Client: + def __init__(self, *, name='fake', artists=None, albums=None, tracks=None, + fail_search=False, authed=True, connected=True): + self.name = name + self._artists = artists or [] + self._albums = albums or [] + self._tracks = tracks or [] + self._fail = fail_search + self._authed = authed + self._connected = connected + + def search_artists(self, q, limit=10): + if self._fail: + raise RuntimeError("client search boom") + return self._artists + + def search_albums(self, q, limit=10): + if self._fail: + raise RuntimeError("client search boom") + return self._albums + + def search_tracks(self, q, limit=10): + if self._fail: + raise RuntimeError("client search boom") + return self._tracks + + def is_spotify_authenticated(self): + return self._authed + + def is_connected(self): + return self._connected + + +class _DB: + def __init__(self, artists=None): + self._artists = artists or [] + + def search_artists(self, q, limit=5, server_source=None): + return self._artists + + +class _Cfg: + def __init__(self, values=None): + self._v = values or {} + + def get(self, k, default=None): + return self._v.get(k, default) + + def get_active_media_server(self): + return self._v.get('__active_server', 'plex') + + +class _Worker: + def __init__(self): + self.enqueued = [] + + def enqueue(self, query, kind): + self.enqueued.append((query, kind)) + + +def _sync_run_async(coro): + """Run a coroutine synchronously on a fresh loop.""" + import asyncio + import inspect + if not inspect.iscoroutine(coro): + return coro + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _build_deps(**overrides): + """Default deps for an enhanced-search call. Override with kwargs.""" + base = dict( + database=_DB(), + config_manager=_Cfg({'discogs.token': ''}), + spotify_client=None, + hydrabase_client=None, + hydrabase_worker=None, + soulseek_client=None, + fix_artist_image_url=lambda u: f'FIXED::{u}' if u else None, + is_hydrabase_active=lambda: False, + get_metadata_fallback_source=lambda: 'spotify', + get_metadata_fallback_client=lambda: _Client(name='fallback'), + get_itunes_client=lambda: _Client(name='itunes'), + get_deezer_client=lambda: _Client(name='deezer'), + get_discogs_client=lambda token=None: _Client(name='discogs'), + run_background_comparison=lambda *a, **k: None, + run_async=_sync_run_async, + dev_mode_enabled_provider=lambda: False, + ) + base.update(overrides) + return orchestrator.SearchDeps(**base) + + +# --------------------------------------------------------------------------- +# resolve_client +# --------------------------------------------------------------------------- + +def test_resolve_spotify_authed_returns_client(): + deps = _build_deps(spotify_client=_Client(authed=True)) + client, ok = orchestrator.resolve_client('spotify', deps) + assert client is deps.spotify_client + assert ok is True + + +def test_resolve_spotify_unauthed_returns_none(): + deps = _build_deps(spotify_client=_Client(authed=False)) + client, ok = orchestrator.resolve_client('spotify', deps) + assert client is None + assert ok is False + + +def test_resolve_spotify_missing_returns_none(): + deps = _build_deps(spotify_client=None) + client, ok = orchestrator.resolve_client('spotify', deps) + assert client is None + assert ok is False + + +def test_resolve_itunes_always_returns_client(): + deps = _build_deps() + client, ok = orchestrator.resolve_client('itunes', deps) + assert client.name == 'itunes' + assert ok is True + + +def test_resolve_deezer_always_returns_client(): + deps = _build_deps() + client, ok = orchestrator.resolve_client('deezer', deps) + assert client.name == 'deezer' + assert ok is True + + +def test_resolve_discogs_with_token_returns_client(): + deps = _build_deps(config_manager=_Cfg({'discogs.token': 'tok'})) + client, ok = orchestrator.resolve_client('discogs', deps) + assert client.name == 'discogs' + assert ok is True + + +def test_resolve_discogs_without_token_returns_none(): + deps = _build_deps(config_manager=_Cfg({'discogs.token': ''})) + client, ok = orchestrator.resolve_client('discogs', deps) + assert client is None + assert ok is False + + +def test_resolve_hydrabase_connected_returns_client(): + deps = _build_deps(hydrabase_client=_Client(connected=True)) + client, ok = orchestrator.resolve_client('hydrabase', deps) + assert client is deps.hydrabase_client + assert ok is True + + +def test_resolve_hydrabase_disconnected_returns_none(): + deps = _build_deps(hydrabase_client=_Client(connected=False)) + client, ok = orchestrator.resolve_client('hydrabase', deps) + assert client is None + assert ok is False + + +def test_resolve_unknown_source_returns_none(): + deps = _build_deps() + client, ok = orchestrator.resolve_client('garbage', deps) + assert client is None + assert ok is False + + +# --------------------------------------------------------------------------- +# run_enhanced_search — short query path +# --------------------------------------------------------------------------- + +def test_short_query_skips_remote_search(): + db_artist = _Artist('a1', 'Aretha', thumb_url='http://x/a.jpg') + deps = _build_deps(database=_DB(artists=[db_artist])) + + result = orchestrator.run_enhanced_search('aa', '', deps) + assert result['db_artists'][0]['name'] == 'Aretha' + assert result['spotify_artists'] == [] + assert result['spotify_albums'] == [] + assert result['spotify_tracks'] == [] + assert result['primary_source'] == 'spotify' + assert result['alternate_sources'] == [] + + +def test_short_query_with_explicit_source_uses_that_source_label(): + deps = _build_deps() + result = orchestrator.run_enhanced_search('aa', 'deezer', deps) + assert result['primary_source'] == 'deezer' + assert result['metadata_source'] == 'deezer' + + +# --------------------------------------------------------------------------- +# run_enhanced_search — single source +# --------------------------------------------------------------------------- + +def test_single_source_runs_only_that_source(): + spot = _Client(authed=True, artists=[_Artist('s1', 'Spot Artist')]) + deps = _build_deps(spotify_client=spot) + result = orchestrator.run_enhanced_search('pink floyd', 'spotify', deps) + + assert result['primary_source'] == 'spotify' + assert result['metadata_source'] == 'spotify' + assert result['source_available'] is True + assert result['spotify_artists'][0]['name'] == 'Spot Artist' + assert result['alternate_sources'] == [] + + +def test_single_source_unavailable_returns_empty_with_source_available_false(): + deps = _build_deps(spotify_client=None) + result = orchestrator.run_enhanced_search('pink floyd', 'spotify', deps) + assert result['source_available'] is False + assert result['spotify_artists'] == [] + assert result['primary_source'] == 'spotify' + + +def test_single_source_search_failure_returns_empty(): + spot = _Client(authed=True, fail_search=True) + deps = _build_deps(spotify_client=spot) + result = orchestrator.run_enhanced_search('q', 'spotify', deps) + # search_source still returns a wrapper because per-kind exceptions are + # swallowed inside it, so we get [] for each kind, source_available=True + assert result['spotify_artists'] == [] + assert result['spotify_albums'] == [] + assert result['spotify_tracks'] == [] + + +# --------------------------------------------------------------------------- +# run_enhanced_search — fan-out +# --------------------------------------------------------------------------- + +def test_fanout_uses_fallback_client_as_primary(): + fb_client = _Client(artists=[_Artist('f1', 'Fallback Artist')]) + deps = _build_deps( + get_metadata_fallback_source=lambda: 'deezer', + get_metadata_fallback_client=lambda: fb_client, + ) + result = orchestrator.run_enhanced_search('pink floyd', '', deps) + + assert result['primary_source'] == 'deezer' + assert result['spotify_artists'][0]['name'] == 'Fallback Artist' + + +def test_fanout_lists_alternate_sources_excluding_primary(): + deps = _build_deps( + get_metadata_fallback_source=lambda: 'deezer', + spotify_client=_Client(authed=True), + ) + result = orchestrator.run_enhanced_search('pink floyd', '', deps) + alts = result['alternate_sources'] + assert 'deezer' not in alts # primary excluded + assert 'itunes' in alts + assert 'spotify' in alts + assert 'youtube_videos' in alts + assert 'musicbrainz' in alts + + +def test_fanout_omits_spotify_alternate_when_unauthed(): + deps = _build_deps( + get_metadata_fallback_source=lambda: 'deezer', + spotify_client=_Client(authed=False), + ) + result = orchestrator.run_enhanced_search('pink floyd', '', deps) + assert 'spotify' not in result['alternate_sources'] + + +def test_fanout_omits_discogs_alternate_when_no_token(): + deps = _build_deps( + get_metadata_fallback_source=lambda: 'deezer', + config_manager=_Cfg({'discogs.token': ''}), + ) + result = orchestrator.run_enhanced_search('pink floyd', '', deps) + assert 'discogs' not in result['alternate_sources'] + + +def test_fanout_includes_discogs_alternate_when_token_set(): + deps = _build_deps( + get_metadata_fallback_source=lambda: 'deezer', + config_manager=_Cfg({'discogs.token': 'abc'}), + ) + result = orchestrator.run_enhanced_search('pink floyd', '', deps) + assert 'discogs' in result['alternate_sources'] + + +def test_fanout_omits_hydrabase_alternate_when_disconnected(): + deps = _build_deps( + get_metadata_fallback_source=lambda: 'deezer', + hydrabase_client=None, + ) + result = orchestrator.run_enhanced_search('pink floyd', '', deps) + assert 'hydrabase' not in result['alternate_sources'] + + +def test_fanout_hydrabase_primary_runs_hydrabase_first(): + hydra = _Client(connected=True, artists=[_Artist('h1', 'Hydra Artist')]) + deps = _build_deps( + is_hydrabase_active=lambda: True, + hydrabase_client=hydra, + ) + result = orchestrator.run_enhanced_search('pink floyd', '', deps) + assert result['primary_source'] == 'hydrabase' + assert result['spotify_artists'][0]['name'] == 'Hydra Artist' + + +def test_fanout_hydrabase_failure_falls_through_to_spotify_default(): + hydra_fail = _Client(connected=True, fail_search=True) + deps = _build_deps( + is_hydrabase_active=lambda: True, + hydrabase_client=hydra_fail, + get_metadata_fallback_source=lambda: 'spotify', + get_metadata_fallback_client=lambda: _Client(name='spotify-fb'), + ) + # Should not raise + result = orchestrator.run_enhanced_search('q', '', deps) + # search_source still returns a wrapper because per-kind exceptions are + # swallowed inside it — so primary_results.tracks is []. Code keeps + # primary_source='hydrabase' because search_source returned a value. + assert result is not None + + +def test_fanout_hydrabase_worker_enqueued_when_dev_mode_enabled(): + worker = _Worker() + deps = _build_deps( + hydrabase_worker=worker, + dev_mode_enabled_provider=lambda: True, + get_metadata_fallback_source=lambda: 'deezer', + ) + orchestrator.run_enhanced_search('pink floyd', '', deps) + enqueued_kinds = {kind for _q, kind in worker.enqueued} + assert enqueued_kinds == {'tracks', 'albums', 'artists'} + + +def test_fanout_hydrabase_worker_skipped_in_prod_mode(): + worker = _Worker() + deps = _build_deps( + hydrabase_worker=worker, + dev_mode_enabled_provider=lambda: False, + get_metadata_fallback_source=lambda: 'deezer', + ) + orchestrator.run_enhanced_search('pink floyd', '', deps) + assert worker.enqueued == [] + + +def test_fanout_db_artists_get_image_url_fixed(): + db_artist = _Artist('a1', 'Aretha', thumb_url='/library/a.jpg') + deps = _build_deps(database=_DB(artists=[db_artist])) + result = orchestrator.run_enhanced_search('pink floyd', '', deps) + assert result['db_artists'][0]['image_url'] == 'FIXED::/library/a.jpg' + + +# --------------------------------------------------------------------------- +# empty_response +# --------------------------------------------------------------------------- + +def test_empty_response_keys(): + r = orchestrator.empty_response() + for k in ('db_artists', 'spotify_artists', 'spotify_albums', 'spotify_tracks', + 'sources', 'primary_source', 'metadata_source'): + assert k in r + assert r['primary_source'] == 'spotify' + + +# --------------------------------------------------------------------------- +# stream_source_search — NDJSON streaming +# --------------------------------------------------------------------------- + +def _drain(generator): + """Drain an NDJSON generator into a list of parsed JSON dicts.""" + out = [] + for line in generator: + out.append(json.loads(line.rstrip('\n'))) + return out + + +def test_stream_source_empty_query_yields_done_only(): + deps = _build_deps() + out = _drain(orchestrator.stream_source_search('spotify', '', deps)) + assert out == [{'type': 'done'}] + + +def test_stream_source_unknown_client_yields_done_only(): + deps = _build_deps(spotify_client=None) + out = _drain(orchestrator.stream_source_search('spotify', 'q', deps)) + assert out == [{'type': 'done'}] + + +def test_stream_metadata_source_yields_three_kinds_plus_done(): + spot = _Client( + authed=True, + artists=[_Artist('a', 'A')], + albums=[_Album('b', 'B')], + tracks=[_Track('c', 'C')], + ) + deps = _build_deps(spotify_client=spot) + + out = _drain(orchestrator.stream_source_search('spotify', 'q', deps)) + types = [m['type'] for m in out] + assert 'artists' in types + assert 'albums' in types + assert 'tracks' in types + assert types[-1] == 'done' + + +class _FakeYouTubeVideo: + def __init__(self, vid): + self.video_id = vid + self.title = f"Title {vid}" + self.channel = "Chan" + self.duration = 100 + self.thumbnail = f"thumb-{vid}.jpg" + self.url = f"https://yt/{vid}" + self.view_count = 1000 + self.upload_date = "20260101" + + +class _FakeYouTube: + def __init__(self, results=None): + self._results = results or [] + + async def search_videos(self, q, max_results=20): + return self._results + + +class _FakeSoulseekWithYT: + def __init__(self, youtube): + self.youtube = youtube + + +def test_stream_youtube_videos_yields_videos_chunk_and_done(): + yt = _FakeYouTube(results=[_FakeYouTubeVideo('vid1'), _FakeYouTubeVideo('vid2')]) + deps = _build_deps(soulseek_client=_FakeSoulseekWithYT(yt)) + + out = _drain(orchestrator.stream_source_search('youtube_videos', 'q', deps)) + assert out[0]['type'] == 'videos' + assert len(out[0]['data']) == 2 + assert out[0]['data'][0]['video_id'] == 'vid1' + assert out[-1]['type'] == 'done' + + +def test_stream_youtube_videos_no_youtube_yields_empty_videos(): + deps = _build_deps(soulseek_client=None) + out = _drain(orchestrator.stream_source_search('youtube_videos', 'q', deps)) + assert out[0] == {'type': 'videos', 'data': []} + assert out[-1] == {'type': 'done'} + + +def test_stream_youtube_videos_search_failure_yields_empty_videos(): + class _BadYT: + async def search_videos(self, q, max_results=20): + raise RuntimeError("yt-dlp boom") + + deps = _build_deps(soulseek_client=_FakeSoulseekWithYT(_BadYT())) + out = _drain(orchestrator.stream_source_search('youtube_videos', 'q', deps)) + assert out[0] == {'type': 'videos', 'data': []} + assert out[-1] == {'type': 'done'} diff --git a/tests/search/test_search_sources.py b/tests/search/test_search_sources.py new file mode 100644 index 00000000..48a93620 --- /dev/null +++ b/tests/search/test_search_sources.py @@ -0,0 +1,174 @@ +"""Tests for core/search/sources.py — per-source-kind + multi-kind executor.""" + +from __future__ import annotations + +from core.search import sources + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + +class _Artist: + def __init__(self, id_, name, image_url=None, external_urls=None): + self.id = id_ + self.name = name + self.image_url = image_url + self.external_urls = external_urls + + +class _Album: + def __init__(self, id_, name, artists=None, image_url=None, release_date=None, + total_tracks=10, album_type='album', external_urls=None): + self.id = id_ + self.name = name + self.artists = artists or [] + self.image_url = image_url + self.release_date = release_date + self.total_tracks = total_tracks + self.album_type = album_type + self.external_urls = external_urls + + +class _Track: + def __init__(self, id_, name, artists=None, album=None, duration_ms=180000, + image_url=None, release_date=None, external_urls=None): + self.id = id_ + self.name = name + self.artists = artists or [] + self.album = album + self.duration_ms = duration_ms + self.image_url = image_url + self.release_date = release_date + self.external_urls = external_urls + + +class _Client: + def __init__(self, artists=None, albums=None, tracks=None, fail=None): + self._artists = artists or [] + self._albums = albums or [] + self._tracks = tracks or [] + self._fail = fail or set() + + def search_artists(self, q, limit=10): + if 'artists' in self._fail: + raise RuntimeError("artists boom") + return self._artists + + def search_albums(self, q, limit=10): + if 'albums' in self._fail: + raise RuntimeError("albums boom") + return self._albums + + def search_tracks(self, q, limit=10): + if 'tracks' in self._fail: + raise RuntimeError("tracks boom") + return self._tracks + + +# --------------------------------------------------------------------------- +# search_kind +# --------------------------------------------------------------------------- + +def test_search_kind_artists_returns_normalized_dicts(): + client = _Client(artists=[_Artist('id1', 'Pink Floyd', 'thumb.jpg', {'spotify': 'url'})]) + result = sources.search_kind(client, 'pink', 'artists', 'spotify') + assert result == [{ + 'id': 'id1', + 'name': 'Pink Floyd', + 'image_url': 'thumb.jpg', + 'external_urls': {'spotify': 'url'}, + }] + + +def test_search_kind_artists_handles_none_external_urls(): + client = _Client(artists=[_Artist('id1', 'X', None, None)]) + result = sources.search_kind(client, 'x', 'artists') + assert result[0]['external_urls'] == {} + + +def test_search_kind_albums_joins_multiple_artists(): + client = _Client(albums=[_Album('a1', 'DSOTM', artists=['Pink Floyd', 'Roger'])]) + result = sources.search_kind(client, 'd', 'albums') + assert result[0]['artist'] == 'Pink Floyd, Roger' + + +def test_search_kind_albums_handles_no_artists(): + client = _Client(albums=[_Album('a1', 'Mystery', artists=[])]) + result = sources.search_kind(client, 'm', 'albums') + assert result[0]['artist'] == 'Unknown Artist' + + +def test_search_kind_tracks_returns_full_shape(): + client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM', + duration_ms=383000, image_url='m.jpg', + release_date='1973-03-01', external_urls={'a': 'b'})]) + result = sources.search_kind(client, 'money', 'tracks') + assert result == [{ + 'id': 't1', + 'name': 'Money', + 'artist': 'Pink Floyd', + 'album': 'DSOTM', + 'duration_ms': 383000, + 'image_url': 'm.jpg', + 'release_date': '1973-03-01', + 'external_urls': {'a': 'b'}, + }] + + +def test_search_kind_swallows_artist_errors(): + client = _Client(fail={'artists'}) + assert sources.search_kind(client, 'q', 'artists') == [] + + +def test_search_kind_swallows_album_errors(): + client = _Client(fail={'albums'}) + assert sources.search_kind(client, 'q', 'albums') == [] + + +def test_search_kind_swallows_track_errors(): + client = _Client(fail={'tracks'}) + assert sources.search_kind(client, 'q', 'tracks') == [] + + +def test_search_kind_unknown_kind_raises(): + import pytest + with pytest.raises(ValueError): + sources.search_kind(_Client(), 'q', 'movies') + + +# --------------------------------------------------------------------------- +# search_source — multi-kind executor +# --------------------------------------------------------------------------- + +def test_search_source_returns_all_three_kinds(): + client = _Client( + artists=[_Artist('a', 'A')], + albums=[_Album('b', 'B', artists=['A'])], + tracks=[_Track('c', 'C', artists=['A'], album='B')], + ) + result = sources.search_source('q', client, 'spotify') + assert result['available'] is True + assert len(result['artists']) == 1 + assert len(result['albums']) == 1 + assert len(result['tracks']) == 1 + + +def test_search_source_partial_failure_does_not_break_others(): + client = _Client( + artists=[_Artist('a', 'A')], + albums=[_Album('b', 'B')], + tracks=[_Track('c', 'C')], + fail={'albums'}, + ) + result = sources.search_source('q', client, 'spotify') + assert result['available'] is True + assert result['artists'] != [] + assert result['albums'] == [] + assert result['tracks'] != [] + + +def test_search_source_all_fail_returns_empty_lists(): + client = _Client(fail={'artists', 'albums', 'tracks'}) + result = sources.search_source('q', client, 'spotify') + assert result == {'artists': [], 'albums': [], 'tracks': [], 'available': True} diff --git a/tests/search/test_search_stream.py b/tests/search/test_search_stream.py new file mode 100644 index 00000000..fc12f724 --- /dev/null +++ b/tests/search/test_search_stream.py @@ -0,0 +1,260 @@ +"""Tests for core/search/stream.py — single-track stream search.""" + +from __future__ import annotations + +from core.search import stream + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + +class _FakeConfig: + def __init__(self, values): + self._v = values + + def get(self, key, default=None): + return self._v.get(key, default) + + +class _FakeMatchResult: + def __init__(self, filename='match.flac', quality='Lossless'): + self.username = 'u' + self.filename = filename + self.size = 100 + self.bitrate = 1411 + self.duration = 180 + self.quality = quality + self.free_upload_slots = 1 + self.upload_speed = 1000 + self.queue_length = 0 + + +class _FakeMatchingEngine: + def __init__(self, match_for_query=None): + self._match = match_for_query + + def find_best_slskd_matches_enhanced(self, temp, results, max_peer_queue=0): + if self._match is None: + return [] + return self._match + + +class _FakeStreamClient: + def __init__(self, results_per_query=None): + # Map query -> ([results], []) + self._results = results_per_query or {} + self.calls = [] + + async def search(self, query, timeout=15): + self.calls.append(query) + return self._results.get(query, ([], [])) + + +class _FakeSoulseek: + def __init__(self, youtube=None, tidal=None, qobuz=None, hifi=None, deezer_dl=None, lidarr=None, results_per_query=None): + self.youtube = youtube + self.tidal = tidal + self.qobuz = qobuz + self.hifi = hifi + self.deezer_dl = deezer_dl + self.lidarr = lidarr + self._results = results_per_query or {} + self.search_calls = [] + + async def search(self, query, timeout=15): + self.search_calls.append(query) + return self._results.get(query, ([], [])) + + +def _run_async(coro): + import asyncio + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +# --------------------------------------------------------------------------- +# _resolve_effective_stream_mode +# --------------------------------------------------------------------------- + +def test_stream_source_youtube_returns_youtube(): + cfg = _FakeConfig({'download_source.stream_source': 'youtube'}) + assert stream._resolve_effective_stream_mode(cfg) == 'youtube' + + +def test_stream_source_active_with_hybrid_first_returns_first(): + cfg = _FakeConfig({ + 'download_source.stream_source': 'active', + 'download_source.mode': 'hybrid', + 'download_source.hybrid_order': ['hifi', 'youtube', 'soulseek'], + }) + assert stream._resolve_effective_stream_mode(cfg) == 'hifi' + + +def test_stream_source_active_with_soulseek_primary_falls_back_to_youtube(): + cfg = _FakeConfig({ + 'download_source.stream_source': 'active', + 'download_source.mode': 'soulseek', + }) + assert stream._resolve_effective_stream_mode(cfg) == 'youtube' + + +def test_stream_source_active_with_hybrid_soulseek_first_falls_back_to_youtube(): + cfg = _FakeConfig({ + 'download_source.stream_source': 'active', + 'download_source.mode': 'hybrid', + 'download_source.hybrid_order': ['soulseek', 'youtube'], + }) + assert stream._resolve_effective_stream_mode(cfg) == 'youtube' + + +def test_stream_source_active_non_hybrid_uses_mode_directly(): + cfg = _FakeConfig({ + 'download_source.stream_source': 'active', + 'download_source.mode': 'tidal', + }) + assert stream._resolve_effective_stream_mode(cfg) == 'tidal' + + +# --------------------------------------------------------------------------- +# _build_stream_queries +# --------------------------------------------------------------------------- + +def test_build_queries_streaming_mode_includes_artist(): + qs = stream._build_stream_queries('Money', 'Pink Floyd', 'youtube') + assert qs[0] == 'Pink Floyd Money' + + +def test_build_queries_streaming_mode_adds_cleaned_variant(): + qs = stream._build_stream_queries('Money (Remastered)', 'Pink Floyd', 'youtube') + assert qs == ['Pink Floyd Money (Remastered)', 'Pink Floyd Money'] + + +def test_build_queries_soulseek_mode_strips_artist(): + qs = stream._build_stream_queries('Money', 'Pink Floyd', 'soulseek') + assert qs == ['Money'] + + +def test_build_queries_soulseek_mode_adds_cleaned_variant(): + qs = stream._build_stream_queries('Money [Live]', 'Pink Floyd', 'soulseek') + assert qs == ['Money [Live]', 'Money'] + + +def test_build_queries_dedupes_case_insensitive(): + qs = stream._build_stream_queries('Money', 'Pink Floyd', 'soulseek') + # Cleaned == original → dedup → only one entry + assert qs == ['Money'] + + +# --------------------------------------------------------------------------- +# stream_search_track +# --------------------------------------------------------------------------- + +def test_stream_finds_match_on_first_query(): + youtube = _FakeStreamClient(results_per_query={ + 'Pink Floyd Money': ([object()], []), + }) + soul = _FakeSoulseek(youtube=youtube) + cfg = _FakeConfig({'download_source.stream_source': 'youtube'}) + engine = _FakeMatchingEngine(match_for_query=[_FakeMatchResult()]) + + result = stream.stream_search_track( + track_name='Money', artist_name='Pink Floyd', album_name=None, + duration_ms=180000, + config_manager=cfg, soulseek_client=soul, matching_engine=engine, + run_async=_run_async, + ) + assert result is not None + assert result['filename'] == 'match.flac' + assert result['quality'] == 'Lossless' + assert result['result_type'] == 'track' + + +def test_stream_walks_to_second_query_on_no_match(): + youtube = _FakeStreamClient(results_per_query={ + 'Pink Floyd Money (Remastered)': ([], []), # no results + 'Pink Floyd Money': ([object()], []), + }) + soul = _FakeSoulseek(youtube=youtube) + cfg = _FakeConfig({'download_source.stream_source': 'youtube'}) + engine = _FakeMatchingEngine(match_for_query=[_FakeMatchResult()]) + + result = stream.stream_search_track( + track_name='Money (Remastered)', artist_name='Pink Floyd', album_name=None, + duration_ms=180000, + config_manager=cfg, soulseek_client=soul, matching_engine=engine, + run_async=_run_async, + ) + assert result is not None + # 2 queries tried + assert len(youtube.calls) == 2 + + +def test_stream_returns_none_when_no_matches(): + youtube = _FakeStreamClient(results_per_query={ + 'Pink Floyd Money': ([object()], []), + }) + soul = _FakeSoulseek(youtube=youtube) + cfg = _FakeConfig({'download_source.stream_source': 'youtube'}) + engine = _FakeMatchingEngine(match_for_query=None) + + result = stream.stream_search_track( + track_name='Money', artist_name='Pink Floyd', album_name=None, + duration_ms=180000, + config_manager=cfg, soulseek_client=soul, matching_engine=engine, + run_async=_run_async, + ) + assert result is None + + +def test_stream_falls_back_to_default_soulseek_when_no_direct_client(): + # Effective mode = 'youtube' (coerced from soulseek), but soul.youtube is None + # → code falls through to soulseek_client.search directly. Streaming-mode + # query gen → "Pink Floyd Money". + soul = _FakeSoulseek(results_per_query={'Pink Floyd Money': ([object()], [])}) + cfg = _FakeConfig({ + 'download_source.stream_source': 'active', + 'download_source.mode': 'soulseek', + }) + engine = _FakeMatchingEngine(match_for_query=[_FakeMatchResult()]) + + result = stream.stream_search_track( + track_name='Money', artist_name='Pink Floyd', album_name=None, + duration_ms=180000, + config_manager=cfg, soulseek_client=soul, matching_engine=engine, + run_async=_run_async, + ) + assert result is not None + assert 'Pink Floyd Money' in soul.search_calls + + +def test_stream_continues_past_per_query_exception(): + class _BoomFirst(_FakeStreamClient): + def __init__(self): + super().__init__() + self._n = 0 + + async def search(self, query, timeout=15): + self.calls.append(query) + self._n += 1 + if self._n == 1: + raise RuntimeError("boom") + return ([object()], []) + + youtube = _BoomFirst() + soul = _FakeSoulseek(youtube=youtube) + cfg = _FakeConfig({'download_source.stream_source': 'youtube'}) + engine = _FakeMatchingEngine(match_for_query=[_FakeMatchResult()]) + + result = stream.stream_search_track( + track_name='Money (Live)', artist_name='Pink Floyd', album_name=None, + duration_ms=180000, + config_manager=cfg, soulseek_client=soul, matching_engine=engine, + run_async=_run_async, + ) + # First query raised, second succeeded + assert result is not None + assert len(youtube.calls) == 2 diff --git a/web_server.py b/web_server.py index d119a05c..e8c4fba5 100644 --- a/web_server.py +++ b/web_server.py @@ -2671,102 +2671,24 @@ def add_cache_headers(response, cache_duration=300): return response -_enhanced_search_cache = collections.OrderedDict() -_enhanced_search_cache_lock = threading.Lock() -_ENHANCED_SEARCH_CACHE_TTL = 600 -_ENHANCED_SEARCH_CACHE_MAX_ENTRIES = 100 +# Enhanced-search cache + helpers live in core/search/cache.py. +# Re-exported here so the existing call sites in this module still resolve. +from core.search.cache import ( + get_cache_key as _get_enhanced_search_cache_key_impl, + get_cached_response as _get_cached_enhanced_search_response, + set_cached_response as _set_cached_enhanced_search_response, +) +from core.search.orchestrator import VALID_SOURCES as ENHANCED_SEARCH_VALID_SOURCES def _get_enhanced_search_cache_key(query, requested_source=None): - """Build a cache key that follows the current metadata/search configuration. - - When an explicit `requested_source` is provided (single-source search), it is - included in the key so that results for different sources don't collide.""" - normalized_query = (query or '').strip().lower() - - try: - active_server = config_manager.get_active_media_server() - except Exception: - active_server = 'unknown' - - try: - fallback_source = _get_metadata_fallback_source() - except Exception: - fallback_source = 'unknown' - - try: - hydrabase_active = _is_hydrabase_active() - except Exception: - hydrabase_active = False - - source_tag = (requested_source or '').strip().lower() or 'auto' - return (normalized_query, active_server, fallback_source, hydrabase_active, source_tag) - - -ENHANCED_SEARCH_VALID_SOURCES = ( - 'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz', -) - - -def _resolve_enhanced_search_client(source_name): - """Return (client, is_available) for a single requested metadata source. - - Mirrors the client-resolution logic used by the /source/ endpoint so - that the `source` param on the main endpoint behaves consistently.""" - if source_name == 'spotify': - if spotify_client and spotify_client.is_spotify_authenticated(): - return spotify_client, True - return None, False - if source_name == 'itunes': - return _get_itunes_client(), True - if source_name == 'deezer': - return _get_deezer_client(), True - if source_name == 'discogs': - token = config_manager.get('discogs.token', '') - if not token: - return None, False - return _get_discogs_client(token), True - if source_name == 'hydrabase': - if hydrabase_client and hydrabase_client.is_connected(): - return 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 _get_cached_enhanced_search_response(cache_key): - """Return a cached enhanced-search response if it is still fresh.""" - now = time.time() - with _enhanced_search_cache_lock: - entry = _enhanced_search_cache.get(cache_key) - if not entry: - return None - - if now - entry['timestamp'] < _ENHANCED_SEARCH_CACHE_TTL: - _enhanced_search_cache.move_to_end(cache_key) - return entry['data'] - - _enhanced_search_cache.pop(cache_key, None) - return None - - -def _set_cached_enhanced_search_response(cache_key, response_data): - """Store an enhanced-search response in the short-lived in-memory cache.""" - with _enhanced_search_cache_lock: - _enhanced_search_cache[cache_key] = { - 'timestamp': time.time(), - 'data': response_data, - } - _enhanced_search_cache.move_to_end(cache_key) - - while len(_enhanced_search_cache) > _ENHANCED_SEARCH_CACHE_MAX_ENTRIES: - _enhanced_search_cache.popitem(last=False) + """Thin wrapper that wires live config providers into the cache-key builder.""" + return _get_enhanced_search_cache_key_impl( + query, requested_source, + active_server_provider=config_manager.get_active_media_server, + fallback_source_provider=_get_metadata_fallback_source, + hydrabase_active_provider=_is_hydrabase_active, + ) # --- Background Download Monitoring (GUI Parity) --- class WebUIDownloadMonitor: @@ -9038,64 +8960,65 @@ def start_sync(): # Placeholder: simulates starting a sync return jsonify({"success": True, "message": "Sync process started."}) +# Search route bodies live in core/search/* — these routes are thin handlers. +from core.search import basic as _search_basic +from core.search import library_check as _search_library_check +from core.search import orchestrator as _search_orchestrator +from core.search import stream as _search_stream + + +def _build_search_deps(): + """Build the SearchDeps bundle from this module's globals on each request. + + Constructed per-request so config / client state is always live. + """ + return _search_orchestrator.SearchDeps( + database=get_database(), + config_manager=config_manager, + spotify_client=spotify_client, + hydrabase_client=hydrabase_client, + hydrabase_worker=hydrabase_worker, + soulseek_client=soulseek_client, + fix_artist_image_url=fix_artist_image_url, + is_hydrabase_active=_is_hydrabase_active, + get_metadata_fallback_source=_get_metadata_fallback_source, + get_metadata_fallback_client=_get_metadata_fallback_client, + get_itunes_client=_get_itunes_client, + get_deezer_client=_get_deezer_client, + get_discogs_client=_get_discogs_client, + run_background_comparison=_run_background_comparison, + run_async=run_async, + dev_mode_enabled_provider=lambda: dev_mode_enabled, + ) + + @app.route('/api/search', methods=['POST']) def search_music(): - """Real search using soulseek_client""" + """Basic Soulseek file search.""" data = request.get_json() query = data.get('query') if not query: return jsonify({"error": "No search query provided."}), 400 logger.info(f"Web UI Search initiated for: '{query}'") - - # Add activity for search start add_activity_item("", "Search Started", f"'{query}'", "Now") - + try: - tracks, albums = run_async(soulseek_client.search(query)) - - # Convert to dictionaries for JSON response - processed_albums = [] - for album in albums: - album_dict = album.__dict__.copy() - album_dict["tracks"] = [track.__dict__ for track in album.tracks] - album_dict["result_type"] = "album" - processed_albums.append(album_dict) - - processed_tracks = [] - for track in tracks: - track_dict = track.__dict__.copy() - track_dict["result_type"] = "track" - processed_tracks.append(track_dict) - - # Sort by quality score - all_results = sorted(processed_albums + processed_tracks, key=lambda x: x.get('quality_score', 0), reverse=True) - - # Add activity for search completion - total_results = len(all_results) - add_activity_item("", "Search Complete", f"'{query}' - {total_results} results", "Now") - - return jsonify({"results": all_results}) - + results = _search_basic.run_basic_soulseek_search(query, soulseek_client, run_async) + add_activity_item("", "Search Complete", f"'{query}' - {len(results)} results", "Now") + return jsonify({"results": results}) except Exception as e: logger.error(f"Search error: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/enhanced-search', methods=['POST']) def enhanced_search(): - """ - Unified search across metadata sources and local database for enhanced search mode. - Returns categorized results: DB artists, source artists, albums, and tracks. + """Unified metadata search across configured sources + local DB artists. - Fires parallel queries against all available sources (Spotify, iTunes, Deezer) - and returns results keyed by source, plus backward-compatible top-level keys - mapped from the primary source. - - Optional JSON body param `source` (one of: auto, spotify, itunes, deezer, - discogs, hydrabase, musicbrainz). When set to a specific source, the endpoint - bypasses the primary-source fan-out and returns just that source's results - (plus the usual db_artists). `auto` and omitted behave identically — current - multi-source fan-out. + Optional `source` body param ("spotify"|"itunes"|"deezer"|"discogs"| + "hydrabase"|"musicbrainz"|"auto"|"") forces a single-source search and + bypasses the fan-out. Otherwise picks a primary source per the user's + configuration and lists alternates for the frontend to fetch async. """ data = request.get_json() query = data.get('query', '').strip() @@ -9105,285 +9028,36 @@ def enhanced_search(): if requested_source and requested_source not in ENHANCED_SEARCH_VALID_SOURCES: return jsonify({"error": f"Unknown source: {requested_source}"}), 400 - cache_key = _get_enhanced_search_cache_key(query, requested_source) - - empty_source = {"artists": [], "albums": [], "tracks": [], "available": False} - if not query: - return jsonify({ - "db_artists": [], - "spotify_artists": [], - "spotify_albums": [], - "spotify_tracks": [], - "sources": {}, - "primary_source": "spotify", - "metadata_source": "spotify" - }) + return jsonify(_search_orchestrator.empty_response()) - cached_response = _get_cached_enhanced_search_response(cache_key) - if cached_response is not None: + cache_key = _get_enhanced_search_cache_key(query, requested_source) + cached = _get_cached_enhanced_search_response(cache_key) + if cached is not None: logger.info(f"Enhanced search cache hit for: '{query}'") - return jsonify(cached_response) + return jsonify(cached) - logger.info( - f"Enhanced search initiated for: '{query}' " - f"(source={requested_source or 'auto'})" - ) + logger.info(f"Enhanced search initiated for: '{query}' (source={requested_source or 'auto'})") try: - # Search local database for artists (always) - database = get_database() - active_server = config_manager.get_active_media_server() - db_artists_objs = database.search_artists(query, limit=5, server_source=active_server) - - db_artists = [] - for artist in db_artists_objs: - image_url = None - if hasattr(artist, 'thumb_url') and artist.thumb_url: - image_url = fix_artist_image_url(artist.thumb_url) - db_artists.append({ - "id": artist.id, - "name": artist.name, - "image_url": image_url - }) - - # Very short queries are usually broad enough that remote metadata searches - # just add latency without improving the result quality much. Keep them local. - if len(query) < 3: - short_source = requested_source or _get_metadata_fallback_source() - response_data = { - "db_artists": db_artists, - "spotify_artists": [], - "spotify_albums": [], - "spotify_tracks": [], - "metadata_source": short_source, - "primary_source": short_source, - "alternate_sources": [], - "sources": {}, - } - _set_cached_enhanced_search_response(cache_key, response_data) - return jsonify(response_data) - - # Explicit single-source search — bypass primary-source fan-out entirely. - if requested_source: - client, available = _resolve_enhanced_search_client(requested_source) - if not client: - response_data = { - "db_artists": db_artists, - "spotify_artists": [], - "spotify_albums": [], - "spotify_tracks": [], - "metadata_source": requested_source, - "primary_source": requested_source, - "alternate_sources": [], - "source_available": False, - } - _set_cached_enhanced_search_response(cache_key, response_data) - return jsonify(response_data) - - try: - source_results = _enhanced_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" - ) - - response_data = { - "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, - } - _set_cached_enhanced_search_response(cache_key, response_data) - return jsonify(response_data) - - # ── Determine primary source and search it synchronously ── - primary_source = "spotify" - primary_results = empty_source - - if _is_hydrabase_active(): - primary_source = "hydrabase" - try: - primary_results = _enhanced_search_source(query, hydrabase_client) - # Fire off background comparison - _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 = empty_source - - if primary_source != "hydrabase": - # Mirror to Hydrabase worker (fire-and-forget) - if hydrabase_worker and dev_mode_enabled: - hydrabase_worker.enqueue(query, 'tracks') - hydrabase_worker.enqueue(query, 'albums') - hydrabase_worker.enqueue(query, 'artists') - - # Search using the user's configured primary metadata source - fb_source = _get_metadata_fallback_source() - try: - primary_results = _enhanced_search_source(query, _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 source failed and it wasn't Spotify, try Spotify as fallback - if primary_results is empty_source and fb_source != 'spotify': - if spotify_client and spotify_client.is_spotify_authenticated(): - try: - primary_results = _enhanced_search_source(query, spotify_client, "spotify") - primary_source = "spotify" - except Exception as e: - logger.debug(f"Spotify fallback search failed: {e}") - - # Determine which alternate sources are available (for frontend to fetch async) - spotify_available = bool(spotify_client and spotify_client.is_spotify_authenticated()) - hydrabase_available = bool(hydrabase_client and hydrabase_client.is_connected()) - discogs_available = bool(config_manager.get('discogs.token', '')) - alternate_sources = [] - if primary_source != 'spotify' and spotify_available: - alternate_sources.append('spotify') - if primary_source != 'itunes': - alternate_sources.append('itunes') - if primary_source != 'deezer': - alternate_sources.append('deezer') - if primary_source != 'discogs' and discogs_available: - alternate_sources.append('discogs') - if primary_source != 'hydrabase' and hydrabase_available: - alternate_sources.append('hydrabase') - # YouTube music videos always available (uses yt-dlp, no auth needed) - alternate_sources.append('youtube_videos') - # MusicBrainz always available (public API, no auth) - alternate_sources.append('musicbrainz') - - logger.info(f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, " - f"{len(primary_results['artists'])} artists, {len(primary_results['albums'])} albums, " - f"{len(primary_results['tracks'])} tracks | " - f"Alt sources available: {alternate_sources}") - - response_data = { - # Backward compat — same shape as before - "db_artists": db_artists, - "spotify_artists": primary_results["artists"], - "spotify_albums": primary_results["albums"], - "spotify_tracks": primary_results["tracks"], - "metadata_source": primary_source, - # New multi-source data - "primary_source": primary_source, - "alternate_sources": alternate_sources, - } + deps = _build_search_deps() + response_data = _search_orchestrator.run_enhanced_search(query, requested_source, deps) _set_cached_enhanced_search_response(cache_key, response_data) return jsonify(response_data) - except Exception as e: logger.error(f"Enhanced search error: {e}") return jsonify({"error": str(e)}), 500 -def _search_metadata_source_kind(client, query, kind, source_name=None): - """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 _enhanced_search_source(query, client, source_name=None): - """Search a single metadata source and return normalized results dict.""" - results = {"artists": [], "albums": [], "tracks": []} - with ThreadPoolExecutor(max_workers=3) as executor: - futures = { - executor.submit(_search_metadata_source_kind, client, query, "artists", source_name): "artists", - executor.submit(_search_metadata_source_kind, client, query, "albums", source_name): "albums", - executor.submit(_search_metadata_source_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} - - @app.route('/api/enhanced-search/source/', methods=['POST']) def enhanced_search_source(source_name): - """Fetch search results from a specific alternate metadata source. + """Streaming NDJSON search for one alternate metadata source. - Streams NDJSON — one line per search type (artists, albums, tracks) as each completes. - This prevents slow sources (iTunes with 3s rate limit) from blocking the UI. - Falls back to single JSON response if streaming not supported. + One line per search-kind (artists, albums, tracks) as it completes, + plus a final `{"type":"done"}` marker. `youtube_videos` yields a single + `videos` chunk via yt-dlp instead. """ - if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos', 'musicbrainz'): + if source_name not in _search_orchestrator.VALID_STREAM_SOURCES: return jsonify({"error": f"Unknown source: {source_name}"}), 400 data = request.get_json() @@ -9391,202 +9065,39 @@ def enhanced_search_source(source_name): if not query: return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) - # YouTube music videos — separate flow from metadata sources - if source_name == 'youtube_videos': - if not soulseek_client or not hasattr(soulseek_client, 'youtube') or not soulseek_client.youtube: - return jsonify({"videos": [], "available": False}) - try: - def generate_videos(): - try: - # Search YouTube via yt-dlp - video_query = f"{query} official music video" - results = run_async(soulseek_client.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" - return app.response_class(generate_videos(), mimetype='application/x-ndjson') - except Exception as e: - return jsonify({"error": str(e)}), 500 - - try: - client = None - if source_name == 'spotify': - if spotify_client and spotify_client.is_spotify_authenticated(): - client = spotify_client - else: - return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) - elif source_name == 'itunes': - client = _get_itunes_client() - elif source_name == 'deezer': - client = _get_deezer_client() - elif source_name == 'discogs': - token = config_manager.get('discogs.token', '') - if token: - client = _get_discogs_client(token) - else: - return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) - elif source_name == 'hydrabase': - if hydrabase_client and hydrabase_client.is_connected(): - client = hydrabase_client - else: - return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) - elif source_name == 'musicbrainz': - try: - from core.musicbrainz_search import MusicBrainzSearchClient - client = MusicBrainzSearchClient() - except Exception as e: - logger.warning(f"MusicBrainz search client init failed: {e}") - return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) - - def generate(): - # Stream each search type as it completes - with ThreadPoolExecutor(max_workers=3) as executor: - futures = { - executor.submit(_search_metadata_source_kind, client, query, "artists", source_name): "artists", - executor.submit(_search_metadata_source_kind, client, query, "albums", source_name): "albums", - executor.submit(_search_metadata_source_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" - - return app.response_class(generate(), mimetype='application/x-ndjson') - except Exception as e: - logger.error(f"Enhanced search source ({source_name}) error: {e}") - return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) + deps = _build_search_deps() + return app.response_class( + _search_orchestrator.stream_source_search(source_name, query, deps), + mimetype='application/x-ndjson', + ) @app.route('/api/enhanced-search/library-check', methods=['POST']) def enhanced_search_library_check(): - """Batch check which albums/tracks from search results exist in the library. - Called async after search results render — doesn't block the search.""" + """Batch-check which albums/tracks from search results are already in the library. + + Called async after search renders so badges fade in without blocking results. + """ try: data = request.get_json() or {} - albums = data.get('albums', []) - tracks = data.get('tracks', []) - - database = get_database() - conn = database._get_connection() - try: - cursor = conn.cursor() - - # Build lookup sets from library — two fast queries - cursor.execute("SELECT LOWER(al.title) || '|||' || LOWER(ar.name) FROM albums al JOIN artists ar ON ar.id = al.artist_id") - owned_albums = {r[0] for r in cursor.fetchall()} - - cursor.execute(""" - SELECT LOWER(t.title) || '|||' || LOWER(a.name), t.id, t.file_path, t.title, a.name, al.title, al.thumb_url - FROM tracks t JOIN artists a ON a.id = t.artist_id JOIN albums al ON al.id = t.album_id - """) - owned_tracks = {} - for r in cursor.fetchall(): - if r[0] not in owned_tracks: # Keep first match only - owned_tracks[r[0]] = {'track_id': r[1], 'file_path': r[2], 'title': r[3], 'artist_name': r[4], 'album_title': r[5], 'album_thumb_url': r[6]} - - # Build wishlist lookup set — track name|||artist - wishlist_keys = set() - try: - profile_id = get_current_profile_id() - cursor.execute("SELECT spotify_data FROM wishlist_tracks WHERE profile_id = ?", (profile_id,)) - for wr in cursor.fetchall(): - try: - wd = json.loads(wr[0]) if isinstance(wr[0], str) else {} - wname = (wd.get('name') or '').lower() - wartists = wd.get('artists', []) - if wartists: - wa = wartists[0].get('name', '') if isinstance(wartists[0], dict) else str(wartists[0]) - else: - wa = '' - if wname: - wishlist_keys.add(wname + '|||' + wa.lower().strip()) - except Exception: - pass - except Exception: - # profile_id column may not exist on older DBs — try without it - try: - cursor.execute("SELECT spotify_data FROM wishlist_tracks") - for wr in cursor.fetchall(): - try: - wd = json.loads(wr[0]) if isinstance(wr[0], str) else {} - wname = (wd.get('name') or '').lower() - wartists = wd.get('artists', []) - if wartists: - wa = wartists[0].get('name', '') if isinstance(wartists[0], dict) else str(wartists[0]) - else: - wa = '' - if wname: - wishlist_keys.add(wname + '|||' + wa.lower().strip()) - except Exception: - pass - except Exception: - pass - - # O(1) lookups per item - album_results = [] - for a in albums: - key = (a.get('name', '').lower() + '|||' + a.get('artist', '').split(',')[0].strip().lower()) - album_results.append(key in owned_albums) - - # Resolve Plex thumb URLs (relative paths need base URL + token) - _plex_base = '' - _plex_token = '' - if plex_client and plex_client.server: - _plex_base = getattr(plex_client.server, '_baseurl', '') or '' - _plex_token = getattr(plex_client.server, '_token', '') or '' - if not _plex_base: - _pc = config_manager.get_plex_config() - _plex_base = (_pc.get('base_url', '') or '').rstrip('/') - _plex_token = _plex_token or _pc.get('token', '') - - track_results = [] - for t in tracks: - key = (t.get('name', '').lower() + '|||' + t.get('artist', '').split(',')[0].strip().lower()) - in_wishlist = key in wishlist_keys - match = owned_tracks.get(key) - if match: - # Resolve thumb URL - thumb = match.get('album_thumb_url') or '' - if thumb and not thumb.startswith('http') and _plex_base and thumb.startswith('/'): - thumb = f"{_plex_base}{thumb}?X-Plex-Token={_plex_token}" if _plex_token else f"{_plex_base}{thumb}" - match['album_thumb_url'] = thumb - track_results.append({'in_library': True, 'in_wishlist': in_wishlist, **match}) - else: - track_results.append({'in_library': False, 'in_wishlist': in_wishlist}) - finally: - conn.close() - - return jsonify({'albums': album_results, 'tracks': track_results}) + result = _search_library_check.check_library_presence( + database=get_database(), + plex_client=plex_client, + config_manager=config_manager, + profile_id=get_current_profile_id(), + albums=data.get('albums', []), + tracks=data.get('tracks', []), + ) + return jsonify(result) except Exception as e: logger.debug(f"Library check error: {e}") return jsonify({'albums': [], 'tracks': []}) @app.route('/api/enhanced-search/stream-track', methods=['POST']) def stream_enhanced_search_track(): - """ - Quick slskd search for a single track to stream from enhanced search. - Uses multi-query retry strategy to work around Soulseek keyword filtering. - Returns the best matching result from Soulseek. + """Single-track preview search — finds the best Soulseek/stream-source match. + + Uses a multi-query retry strategy to work around Soulseek keyword filtering. """ data = request.get_json() track_name = data.get('track_name', '').strip() @@ -9600,145 +9111,22 @@ def stream_enhanced_search_track(): logger.info(f"Enhanced search stream request: '{track_name}' by '{artist_name}'") try: - # Create a temporary SpotifyTrack-like object for the matching engine - temp_track = type('TempTrack', (), { - 'name': track_name, - 'artists': [artist_name], - 'album': album_name if album_name else None, - 'duration_ms': duration_ms - })() - - # Determine effective stream source - # stream_source: "youtube" (default, instant) or "active" (use download source) - stream_source = config_manager.get('download_source.stream_source', 'youtube') - download_mode = config_manager.get('download_source.mode', 'hybrid') - - # Resolve the effective mode for streaming - if stream_source == 'youtube': - effective_mode = 'youtube' - else: - # "active" mode — use download source, but fall back to YouTube if Soulseek - _hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) - _hybrid_first = _hybrid_order[0] if _hybrid_order else config_manager.get('download_source.hybrid_primary', 'hifi') - if download_mode == 'soulseek' or (download_mode == 'hybrid' and _hybrid_first == 'soulseek'): - effective_mode = 'youtube' # Soulseek is too slow for streaming preview - logger.info("Stream source is 'active' but primary is Soulseek — falling back to YouTube") - elif download_mode == 'hybrid': - effective_mode = _hybrid_first - else: - effective_mode = download_mode - - logger.info(f"Stream source: {stream_source} → effective: {effective_mode}") - - # Generate search queries based on effective stream mode - search_queries = [] - import re - - if effective_mode in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'): - # Streaming sources: Include artist for better context - if artist_name and track_name: - search_queries.append(f"{artist_name} {track_name}".strip()) - - # Fallback: Artist + Cleaned track (remove parentheses/brackets) - cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip() - cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip() - if cleaned_name and cleaned_name.lower() != track_name.lower(): - search_queries.append(f"{artist_name} {cleaned_name}".strip()) - - logger.info(f"{effective_mode.title()} stream: Searching with artist + track name: {search_queries}") - else: - # Soulseek mode: Track name only to avoid keyword filtering - if track_name.strip(): - search_queries.append(track_name.strip()) - - cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip() - cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip() - - if cleaned_name and cleaned_name.lower() != track_name.lower(): - search_queries.append(cleaned_name.strip()) - - logger.info(f"Soulseek mode: Searching by track name only (will match with artist): {search_queries}") - - # Remove duplicates while preserving order - unique_queries = [] - seen = set() - for query in search_queries: - if query and query.lower() not in seen: - unique_queries.append(query) - seen.add(query.lower()) - - search_queries = unique_queries - - # Select the search client based on effective stream mode - _stream_clients = { - 'youtube': soulseek_client.youtube, - 'tidal': soulseek_client.tidal, - 'qobuz': soulseek_client.qobuz, - 'hifi': soulseek_client.hifi, - 'deezer_dl': soulseek_client.deezer_dl, - 'lidarr': soulseek_client.lidarr, - } - stream_client = _stream_clients.get(effective_mode) - use_direct_client = stream_client is not None - - # Try queries sequentially until we find a good match - for query_index, query in enumerate(search_queries): - logger.info(f"Query {query_index + 1}/{len(search_queries)}: '{query}'") - - try: - # Search using the stream source client (not the download source) - if use_direct_client: - tracks_result, _ = run_async(stream_client.search(query, timeout=15)) - else: - tracks_result, _ = run_async(soulseek_client.search(query, timeout=15)) - - if tracks_result: - logger.info(f"Found {len(tracks_result)} results for query: '{query}'") - - # Use matching engine to find best match - _max_q = config_manager.get('soulseek.max_peer_queue', 0) or 0 - best_matches = matching_engine.find_best_slskd_matches_enhanced(temp_track, tracks_result, max_peer_queue=_max_q) - - if best_matches: - # Get the first (best) result - best_result = best_matches[0] - - # Convert to dictionary for JSON response (same format as basic search) - result_dict = { - "username": best_result.username, - "filename": best_result.filename, - "size": best_result.size, - "bitrate": best_result.bitrate, - "duration": best_result.duration, - "quality": best_result.quality, - "free_upload_slots": best_result.free_upload_slots, - "upload_speed": best_result.upload_speed, - "queue_length": best_result.queue_length, - "result_type": "track" - } - - logger.info(f"Returning best match from query '{query}': {best_result.filename} ({best_result.quality})") - - return jsonify({ - "success": True, - "result": result_dict - }) - else: - logger.info(f"No suitable matches for query '{query}', trying next query...") - else: - logger.info(f"No results for query '{query}', trying next query...") - - except Exception as search_error: - logger.warning(f"Error searching with query '{query}': {search_error}") - continue - - # If we get here, none of the queries found a suitable match - logger.warning(f"No suitable matches found after trying {len(search_queries)} queries") - return jsonify({ - "success": False, - "error": "No suitable track found after trying multiple search strategies" - }), 404 - + result = _search_stream.stream_search_track( + track_name=track_name, + artist_name=artist_name, + album_name=album_name, + duration_ms=duration_ms, + config_manager=config_manager, + soulseek_client=soulseek_client, + matching_engine=matching_engine, + run_async=run_async, + ) + if result is None: + return jsonify({ + "success": False, + "error": "No suitable track found after trying multiple search strategies", + }), 404 + return jsonify({"success": True, "result": result}) except Exception as e: logger.error(f"Error streaming enhanced search track: {e}", exc_info=True) return jsonify({"error": str(e)}), 500 diff --git a/webui/static/helper.js b/webui/static/helper.js index d69563b8..23a882b0 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3450,6 +3450,7 @@ const WHATS_NEW = { { title: 'Browser Caching for Static Assets + Discover Pages', desc: 'static assets (js/css/icons) now get a 1-year browser cache instead of revalidating on every page load. safe because the existing ?v=static_v cache-bust query changes every server restart, so deploys still ship live. discover pages (hero, similar artists, recent releases, deep cuts, etc.) now cache 5 minutes browser-side so toggling between sections doesn\'t re-fetch everything. faster repeat loads, fewer round-trips.', page: 'discover' }, { title: 'Service Worker for Cover Art + Installable PWA', desc: 'cover art used to re-fetch from the cdn on every library / discover page visit. now a service worker caches images locally — second visit serves art instantly from disk, no network hit. also added a pwa manifest so soulsync can be installed to home screen / desktop as a standalone app (chrome / edge / safari → install soulsync). cache versioned so future strategy changes invalidate cleanly.' }, { title: 'Stats Endpoints Lifted to core/stats', desc: 'internal — moved /api/stats/* and /api/listening-stats/* logic out of web_server.py into core/stats/queries.py with full test coverage. no behavior change. step toward breaking up the web_server.py monolith.' }, + { title: 'Search Endpoints Lifted to core/search', desc: 'internal — moved /api/search and /api/enhanced-search/* logic into core/search/ (cache, sources, library_check, stream, basic, orchestrator). 612 fewer lines in web_server.py, 94 new tests. no behavior change.' }, ], '2.4.0': [ // --- April 26, 2026 — Search & Artists unification + reorganize queue --- From 47b4663091f60fa509e8cbc7078014c9c1ca35c9 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:19:52 -0700 Subject: [PATCH 2/4] Search cache: preserve falsy provider returns to match original behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line-by-line review of the search lift caught one drift: cache.get_cache_key was coercing falsy provider returns ('', None, 0) to 'unknown' / False. Original web_server.py only fell back to those sentinels on exception, not on falsy success values. Real-world impact: low — get_active_media_server() and get_primary_source() return non-empty strings in practice. But cache keys are tuples with no schema enforcement, so any drift here can silently fragment the cache. Restored 1:1 parity with original semantics. Added test covering the falsy-success path so this can't drift again. 789 tests pass, ruff clean. --- core/search/cache.py | 6 +++--- tests/search/test_search_cache.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/core/search/cache.py b/core/search/cache.py index 4692f3dd..e988c04d 100644 --- a/core/search/cache.py +++ b/core/search/cache.py @@ -78,17 +78,17 @@ def get_cache_key( normalized_query = (query or '').strip().lower() try: - active_server = active_server_provider() or 'unknown' + active_server = active_server_provider() except Exception: active_server = 'unknown' try: - fallback_source = fallback_source_provider() or 'unknown' + fallback_source = fallback_source_provider() except Exception: fallback_source = 'unknown' try: - hydrabase_active = bool(hydrabase_active_provider()) + hydrabase_active = hydrabase_active_provider() except Exception: hydrabase_active = False diff --git a/tests/search/test_search_cache.py b/tests/search/test_search_cache.py index 15ae6517..eb4a67d8 100644 --- a/tests/search/test_search_cache.py +++ b/tests/search/test_search_cache.py @@ -119,3 +119,15 @@ def test_key_hydrabase_provider_failure_falls_back_to_false(): fallback_source_provider=lambda: 'spotify', hydrabase_active_provider=boom) assert key[3] is False + + +def test_key_preserves_falsy_provider_returns(): + """Original behavior: if provider returns None / '' on success, store it + as-is. Don't coerce to 'unknown' — that's reserved for exceptions.""" + key = search_cache.get_cache_key('q', None, + active_server_provider=lambda: None, + fallback_source_provider=lambda: '', + hydrabase_active_provider=lambda: 0) + assert key[1] is None + assert key[2] == '' + assert key[3] == 0 From b94cbd7dd7f95b5a986305584c82559fdcccd231 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:32:48 -0700 Subject: [PATCH 3/4] Search lift: pre-merge parity polish for cin's review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three drifts caught in line-by-line review against the pre-lift web_server.py. All addressed for strict 1:1 behavior parity. 1. /api/enhanced-search/source/ now returns plain JSON `{"artists":[],"albums":[],"tracks":[],"available":false}` (or `{"videos":[],"available":false}` for youtube_videos) when the source's client isn't available, matching the original endpoint contract. Previously streamed an NDJSON `{"type":"done"}` line instead. Restructured by splitting the orchestrator into resolve+stream helpers: - `resolve_client(source_name, deps)` — already existed, used for /api/enhanced-search single-source mode - `resolve_youtube_videos_client(deps)` — new, returns the soulseek_client.youtube subclient or None - `stream_metadata_source(source_name, query, client)` — pure NDJSON generator, caller resolves client first - `stream_youtube_videos(query, youtube_client, run_async)` — same shape for the yt-dlp path The route now decides plain-JSON-vs-stream based on resolution result, mirroring the original control flow exactly. 2. core/search/library_check.py — reverted the defensive `(x or '')` and `getattr(plex_client, 'server', None) is not None` patterns to original byte-for-byte (`x.get('name', '')`, `plex_client.server`, no try/except around `get_plex_config`). Lift PR shouldn't change crash semantics; if the original raises on malformed input, mine should too. Pre-existing edge cases get their own follow-up PR. 3. core/search/stream.py — same revert: `soulseek_client.youtube` instead of `getattr(..., 'youtube', None)` etc. Also removed the module-level `EMPTY_SOURCE` from sources.py and moved its (per-call) duplicate into _fan_out_response as a local — the original used a per-request local dict and the identity-check behavior depends on that. Module-level was a footgun for future mutations. 789 tests still pass (95 search), ruff clean. --- core/search/library_check.py | 26 ++++------- core/search/orchestrator.py | 59 ++++++++++-------------- core/search/sources.py | 2 - core/search/stream.py | 12 ++--- tests/search/test_search_orchestrator.py | 50 +++++++++----------- web_server.py | 35 ++++++++++++-- 6 files changed, 91 insertions(+), 93 deletions(-) diff --git a/core/search/library_check.py b/core/search/library_check.py index 479a23d1..89e89119 100644 --- a/core/search/library_check.py +++ b/core/search/library_check.py @@ -32,19 +32,17 @@ def _resolve_plex_credentials(plex_client, config_manager) -> tuple[str, str]: """Pull (base_url, token) for the active Plex server. Prefers the live `plex_client.server` attrs; falls back to config_manager - if the live client isn't connected yet. + if the live client isn't connected yet. Mirrors original web_server.py + inline logic byte-for-byte. """ base, token = '', '' - if plex_client and getattr(plex_client, 'server', None) is not None: + if plex_client and plex_client.server: base = getattr(plex_client.server, '_baseurl', '') or '' token = getattr(plex_client.server, '_token', '') or '' if not base: - try: - cfg = config_manager.get_plex_config() - base = (cfg.get('base_url', '') or '').rstrip('/') - token = token or cfg.get('token', '') - except Exception: - pass + cfg = config_manager.get_plex_config() + base = (cfg.get('base_url', '') or '').rstrip('/') + token = token or cfg.get('token', '') return base, token @@ -138,22 +136,14 @@ def check_library_presence( album_results: list[bool] = [] for a in albums: - key = ( - (a.get('name') or '').lower() - + '|||' - + (a.get('artist') or '').split(',')[0].strip().lower() - ) + key = (a.get('name', '').lower() + '|||' + a.get('artist', '').split(',')[0].strip().lower()) album_results.append(key in owned_albums) plex_base, plex_token = _resolve_plex_credentials(plex_client, config_manager) track_results: list[dict] = [] for t in tracks: - key = ( - (t.get('name') or '').lower() - + '|||' - + (t.get('artist') or '').split(',')[0].strip().lower() - ) + key = (t.get('name', '').lower() + '|||' + t.get('artist', '').split(',')[0].strip().lower()) in_wishlist = key in wishlist_keys match = owned_tracks.get(key) if match: diff --git a/core/search/orchestrator.py b/core/search/orchestrator.py index ec833692..1c2cb4c1 100644 --- a/core/search/orchestrator.py +++ b/core/search/orchestrator.py @@ -190,8 +190,13 @@ def _alternate_sources(primary_source: str, deps: SearchDeps) -> list[str]: def _fan_out_response(query: str, db_artists: list[dict], deps: SearchDeps) -> dict: """Default flow: pick a primary source, run it, list alternates.""" + # Per-request empty marker — used for identity check at the spotify-fallback + # gate below. Local (not module-level) so a future caller can't accidentally + # mutate it across requests. + empty_source = {"artists": [], "albums": [], "tracks": [], "available": False} + primary_source = 'spotify' - primary_results = sources.EMPTY_SOURCE + primary_results = empty_source if deps.is_hydrabase_active(): primary_source = 'hydrabase' @@ -205,7 +210,7 @@ def _fan_out_response(query: str, db_artists: list[dict], deps: SearchDeps) -> d except Exception as e: logger.error(f"Hydrabase search failed: {e}") primary_source = 'spotify' - primary_results = sources.EMPTY_SOURCE + primary_results = empty_source if primary_source != 'hydrabase': if deps.hydrabase_worker and deps.dev_mode_enabled_provider(): @@ -220,7 +225,7 @@ def _fan_out_response(query: str, db_artists: list[dict], deps: SearchDeps) -> d 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 primary_results is 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') @@ -283,40 +288,21 @@ def run_enhanced_search(query: str, requested_source: str, deps: SearchDeps) -> # NDJSON streaming for /api/enhanced-search/source/ # --------------------------------------------------------------------------- -def stream_source_search(source_name: str, query: str, deps: SearchDeps) -> Iterator[str]: - """Yield NDJSON lines for a single-source streaming search. +def resolve_youtube_videos_client(deps: SearchDeps): + """Return the soulseek_client.youtube subclient or None when unavailable.""" + if not deps.soulseek_client: + return None + return getattr(deps.soulseek_client, 'youtube', None) - 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. + +def stream_youtube_videos(query: str, youtube_client, run_async: Callable) -> Iterator[str]: + """yt-dlp video search generator — yields one videos chunk + done marker. + + Caller is responsible for verifying youtube_client is not None. """ - 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)) + results = run_async(youtube_client.search_videos(video_query, max_results=20)) videos = [] for v in (results or []): videos.append({ @@ -336,8 +322,11 @@ def _stream_youtube_videos(query: str, deps: SearchDeps) -> Iterator[str]: 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.""" +def stream_metadata_source(source_name: str, query: str, client) -> Iterator[str]: + """Fan three search-kinds out and yield each as it lands. + + Caller is responsible for resolving and validating the client. + """ from concurrent.futures import ThreadPoolExecutor, as_completed with ThreadPoolExecutor(max_workers=3) as executor: diff --git a/core/search/sources.py b/core/search/sources.py index ac6c7735..e66b9eda 100644 --- a/core/search/sources.py +++ b/core/search/sources.py @@ -22,8 +22,6 @@ 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.""" diff --git a/core/search/stream.py b/core/search/stream.py index 1b5710e7..cae298f3 100644 --- a/core/search/stream.py +++ b/core/search/stream.py @@ -119,12 +119,12 @@ def stream_search_track( queries = _build_stream_queries(track_name, artist_name, effective_mode) stream_clients = { - 'youtube': getattr(soulseek_client, 'youtube', None), - 'tidal': getattr(soulseek_client, 'tidal', None), - 'qobuz': getattr(soulseek_client, 'qobuz', None), - 'hifi': getattr(soulseek_client, 'hifi', None), - 'deezer_dl': getattr(soulseek_client, 'deezer_dl', None), - 'lidarr': getattr(soulseek_client, 'lidarr', None), + 'youtube': soulseek_client.youtube, + 'tidal': soulseek_client.tidal, + 'qobuz': soulseek_client.qobuz, + 'hifi': soulseek_client.hifi, + 'deezer_dl': soulseek_client.deezer_dl, + 'lidarr': soulseek_client.lidarr, } stream_client = stream_clients.get(effective_mode) use_direct_client = stream_client is not None diff --git a/tests/search/test_search_orchestrator.py b/tests/search/test_search_orchestrator.py index be0b0be0..25082404 100644 --- a/tests/search/test_search_orchestrator.py +++ b/tests/search/test_search_orchestrator.py @@ -414,7 +414,7 @@ def test_empty_response_keys(): # --------------------------------------------------------------------------- -# stream_source_search — NDJSON streaming +# Streaming generators + youtube_videos client resolution # --------------------------------------------------------------------------- def _drain(generator): @@ -425,18 +425,6 @@ def _drain(generator): return out -def test_stream_source_empty_query_yields_done_only(): - deps = _build_deps() - out = _drain(orchestrator.stream_source_search('spotify', '', deps)) - assert out == [{'type': 'done'}] - - -def test_stream_source_unknown_client_yields_done_only(): - deps = _build_deps(spotify_client=None) - out = _drain(orchestrator.stream_source_search('spotify', 'q', deps)) - assert out == [{'type': 'done'}] - - def test_stream_metadata_source_yields_three_kinds_plus_done(): spot = _Client( authed=True, @@ -444,9 +432,7 @@ def test_stream_metadata_source_yields_three_kinds_plus_done(): albums=[_Album('b', 'B')], tracks=[_Track('c', 'C')], ) - deps = _build_deps(spotify_client=spot) - - out = _drain(orchestrator.stream_source_search('spotify', 'q', deps)) + out = _drain(orchestrator.stream_metadata_source('spotify', 'q', spot)) types = [m['type'] for m in out] assert 'artists' in types assert 'albums' in types @@ -479,30 +465,38 @@ class _FakeSoulseekWithYT: self.youtube = youtube +def test_resolve_youtube_videos_returns_subclient(): + yt = _FakeYouTube() + deps = _build_deps(soulseek_client=_FakeSoulseekWithYT(yt)) + assert orchestrator.resolve_youtube_videos_client(deps) is yt + + +def test_resolve_youtube_videos_no_soulseek_returns_none(): + deps = _build_deps(soulseek_client=None) + assert orchestrator.resolve_youtube_videos_client(deps) is None + + +def test_resolve_youtube_videos_no_youtube_attr_returns_none(): + class _NoYT: + pass + deps = _build_deps(soulseek_client=_NoYT()) + assert orchestrator.resolve_youtube_videos_client(deps) is None + + def test_stream_youtube_videos_yields_videos_chunk_and_done(): yt = _FakeYouTube(results=[_FakeYouTubeVideo('vid1'), _FakeYouTubeVideo('vid2')]) - deps = _build_deps(soulseek_client=_FakeSoulseekWithYT(yt)) - - out = _drain(orchestrator.stream_source_search('youtube_videos', 'q', deps)) + out = _drain(orchestrator.stream_youtube_videos('q', yt, _sync_run_async)) assert out[0]['type'] == 'videos' assert len(out[0]['data']) == 2 assert out[0]['data'][0]['video_id'] == 'vid1' assert out[-1]['type'] == 'done' -def test_stream_youtube_videos_no_youtube_yields_empty_videos(): - deps = _build_deps(soulseek_client=None) - out = _drain(orchestrator.stream_source_search('youtube_videos', 'q', deps)) - assert out[0] == {'type': 'videos', 'data': []} - assert out[-1] == {'type': 'done'} - - def test_stream_youtube_videos_search_failure_yields_empty_videos(): class _BadYT: async def search_videos(self, q, max_results=20): raise RuntimeError("yt-dlp boom") - deps = _build_deps(soulseek_client=_FakeSoulseekWithYT(_BadYT())) - out = _drain(orchestrator.stream_source_search('youtube_videos', 'q', deps)) + out = _drain(orchestrator.stream_youtube_videos('q', _BadYT(), _sync_run_async)) assert out[0] == {'type': 'videos', 'data': []} assert out[-1] == {'type': 'done'} diff --git a/web_server.py b/web_server.py index e8c4fba5..a3fea269 100644 --- a/web_server.py +++ b/web_server.py @@ -9056,6 +9056,12 @@ def enhanced_search_source(source_name): One line per search-kind (artists, albums, tracks) as it completes, plus a final `{"type":"done"}` marker. `youtube_videos` yields a single `videos` chunk via yt-dlp instead. + + When the requested source's client isn't available (Spotify unauthed, + Discogs missing token, Hydrabase disconnected, MusicBrainz import + failure, soulseek_client.youtube missing), returns plain JSON + `{"artists":[],"albums":[],"tracks":[],"available":false}` to match + the original endpoint contract. """ if source_name not in _search_orchestrator.VALID_STREAM_SOURCES: return jsonify({"error": f"Unknown source: {source_name}"}), 400 @@ -9066,10 +9072,31 @@ def enhanced_search_source(source_name): return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) deps = _build_search_deps() - return app.response_class( - _search_orchestrator.stream_source_search(source_name, query, deps), - mimetype='application/x-ndjson', - ) + + if source_name == 'youtube_videos': + youtube_client = _search_orchestrator.resolve_youtube_videos_client(deps) + if youtube_client is None: + return jsonify({"videos": [], "available": False}) + try: + return app.response_class( + _search_orchestrator.stream_youtube_videos(query, youtube_client, run_async), + mimetype='application/x-ndjson', + ) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + try: + client, _available = _search_orchestrator.resolve_client(source_name, deps) + if client is None: + return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) + + return app.response_class( + _search_orchestrator.stream_metadata_source(source_name, query, client), + mimetype='application/x-ndjson', + ) + except Exception as e: + logger.error(f"Enhanced search source ({source_name}) error: {e}") + return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) @app.route('/api/enhanced-search/library-check', methods=['POST']) From e309370862e904a12e8f2a30193eb92eb62bbf7d Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:03:56 -0700 Subject: [PATCH 4/4] Source picker: rename Soulseek icon to "Basic Search" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That source icon hits /api/search — raw slskd file results, the same flow the UI historically labelled "Basic Search" before the source-icon row replaced the dropdown. Reverting the label avoids implying it returns Soulseek-flavoured metadata results in the same shape as the other source icons. Backend route + endpoint name unchanged; this is display-only. --- webui/static/shared-helpers.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 348944ea..26b1c060 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -72,8 +72,9 @@ const SOURCE_LABELS = { tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube', }, soulseek: { - // No canonical brand logo available — stick with a basic music glyph. - text: 'Soulseek', icon: '🎼', + // Routes through /api/search (raw slskd file results) — historically + // called "Basic Search" in the UI before the source picker landed. + text: 'Basic Search', icon: '🎼', tabClass: 'enh-tab-soulseek', badgeClass: 'enh-badge-soulseek', }, };