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.
158 lines
5.6 KiB
Python
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}
|