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.
168 lines
5.8 KiB
Python
168 lines
5.8 KiB
Python
"""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}
|