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.
282 lines
9.3 KiB
Python
282 lines
9.3 KiB
Python
"""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
|