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.
98 lines
3.3 KiB
Python
98 lines
3.3 KiB
Python
"""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'
|