soulsync/core/search/library_check.py
Broque Thomas fd7b56e58c Lift /api/search and /api/enhanced-search/* into core/search/
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.
2026-04-27 15:07:11 -07:00

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}