Routes moved to thin parse-args/jsonify handlers; logic now lives in six focused modules under core/search/. 720 lines deleted from web_server.py; 109 added back as wrappers; ~700 lines of new core code plus ~700 lines of tests. Module split: - core/search/cache.py — TTL+LRU cache for enhanced-search responses, keyed by (query, active_server, fallback_source, hydrabase_active, source_tag) so config changes don't poison stale entries. - core/search/sources.py — per-kind metadata search (artists/albums/ tracks) and the multi-kind ThreadPoolExecutor that fans them out. - core/search/library_check.py — library + wishlist presence check with Plex thumb URL resolution; profile-aware wishlist with legacy fallback for older DBs missing the profile_id column. - core/search/stream.py — single-track preview search; effective stream mode resolution, query-variant generation, retry walk, matching engine integration. - core/search/basic.py — flat Soulseek file search, quality-sorted. - core/search/orchestrator.py — main enhanced-search dispatch (short-query fast path, single-source bypass, hydrabase-primary fan out, alternate source list builder), NDJSON streaming generator for /source/<src>, and the SearchDeps dataclass that bundles the cross-cutting deps. Routes pass clients (spotify, hydrabase, hydrabase_worker, soulseek) and helpers (config_manager, fix_artist_image_url, _is_hydrabase_active, _get_metadata_fallback_*, _run_background_ comparison, run_async, dev_mode_enabled_provider) into core/search via a SearchDeps bundle built per-request. fix_artist_image_url stays in web_server.py because it touches 31 other call sites. Behavior preserved 1:1: - Same response shapes (db_artists, spotify_artists, spotify_albums, spotify_tracks, primary_source, metadata_source, alternate_sources, source_available) - Same NDJSON line ordering (artists/albums/tracks as they finish, plus done marker) - Same per-kind exception swallowing - Same hydrabase-worker mirror on dev mode - Same cache key shape (5-tuple) and TTL/LRU semantics - Same stream-track effective-mode resolution including the Soulseek-coerce-to-YouTube edge case - Same library-check Plex thumb URL rewriting and wishlist fallback for older DBs Tests: 94 new (cache TTL/LRU/key, sources happy/partial/all-fail, library presence with library + wishlist + thumbs, stream effective mode + query gen + retry, orchestrator client resolution + short query + single source + fan-out alternates + hydrabase primary + NDJSON drain). Full suite: 788 passing (was 694). Ruff clean.
108 lines
3.3 KiB
Python
108 lines
3.3 KiB
Python
"""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()
|