soulsync/core/search/library_check.py
Broque Thomas b94cbd7dd7 Search lift: pre-merge parity polish for cin's review
Three drifts caught in line-by-line review against the pre-lift
web_server.py. All addressed for strict 1:1 behavior parity.

1. /api/enhanced-search/source/<src> now returns plain JSON
   `{"artists":[],"albums":[],"tracks":[],"available":false}` (or
   `{"videos":[],"available":false}` for youtube_videos) when the
   source's client isn't available, matching the original endpoint
   contract. Previously streamed an NDJSON `{"type":"done"}` line
   instead.

   Restructured by splitting the orchestrator into resolve+stream
   helpers:
   - `resolve_client(source_name, deps)` — already existed, used
     for /api/enhanced-search single-source mode
   - `resolve_youtube_videos_client(deps)` — new, returns the
     soulseek_client.youtube subclient or None
   - `stream_metadata_source(source_name, query, client)` — pure
     NDJSON generator, caller resolves client first
   - `stream_youtube_videos(query, youtube_client, run_async)` —
     same shape for the yt-dlp path

   The route now decides plain-JSON-vs-stream based on resolution
   result, mirroring the original control flow exactly.

2. core/search/library_check.py — reverted the defensive `(x or '')`
   and `getattr(plex_client, 'server', None) is not None` patterns
   to original byte-for-byte (`x.get('name', '')`,
   `plex_client.server`, no try/except around `get_plex_config`).
   Lift PR shouldn't change crash semantics; if the original raises
   on malformed input, mine should too. Pre-existing edge cases get
   their own follow-up PR.

3. core/search/stream.py — same revert: `soulseek_client.youtube`
   instead of `getattr(..., 'youtube', None)` etc.

Also removed the module-level `EMPTY_SOURCE` from sources.py and
moved its (per-call) duplicate into _fan_out_response as a local —
the original used a per-request local dict and the identity-check
behavior depends on that. Module-level was a footgun for future
mutations.

789 tests still pass (95 search), ruff clean.
2026-04-27 15:32:48 -07:00

158 lines
5.6 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. Mirrors original web_server.py
inline logic byte-for-byte.
"""
base, token = '', ''
if plex_client and plex_client.server:
base = getattr(plex_client.server, '_baseurl', '') or ''
token = getattr(plex_client.server, '_token', '') or ''
if not base:
cfg = config_manager.get_plex_config()
base = (cfg.get('base_url', '') or '').rstrip('/')
token = token or cfg.get('token', '')
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', '').lower() + '|||' + a.get('artist', '').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', '').lower() + '|||' + t.get('artist', '').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}