Merge pull request #391 from Nezreka/refactor/lift-search-to-core

Refactor/lift search to core
This commit is contained in:
BoulderBadgeDad 2026-04-27 16:59:34 -07:00 committed by GitHub
commit 1f2c7ebbd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2514 additions and 710 deletions

8
core/search/__init__.py Normal file
View file

@ -0,0 +1,8 @@
"""Search API helpers package.
Lifted from web_server.py /api/search and /api/enhanced-search/* routes.
Each module exposes pure-ish functions that take dependencies (database,
clients, config_manager, matching_engine) as arguments. Route handlers in
web_server.py stay thin they parse requests, call into these helpers,
and return jsonify / streaming responses.
"""

44
core/search/basic.py Normal file
View file

@ -0,0 +1,44 @@
"""Basic Soulseek file search — flat list of file results sorted by quality.
Used by the Soulseek source icon in the unified search UI and by direct
/api/search calls. Synchronous wrapper around the async soulseek client.
"""
from __future__ import annotations
import logging
from typing import Callable
logger = logging.getLogger(__name__)
def run_basic_soulseek_search(
query: str,
soulseek_client,
run_async: Callable,
) -> list[dict]:
"""Search Soulseek for `query`, normalize albums + tracks to one sorted list.
Returns dicts with `result_type` set to "album" or "track" and sorted by
`quality_score` descending. Empty list on any failure (caller logs).
"""
tracks, albums = run_async(soulseek_client.search(query))
processed_albums = []
for album in albums:
album_dict = album.__dict__.copy()
album_dict['tracks'] = [track.__dict__ for track in album.tracks]
album_dict['result_type'] = 'album'
processed_albums.append(album_dict)
processed_tracks = []
for track in tracks:
track_dict = track.__dict__.copy()
track_dict['result_type'] = 'track'
processed_tracks.append(track_dict)
return sorted(
processed_albums + processed_tracks,
key=lambda x: x.get('quality_score', 0),
reverse=True,
)

108
core/search/cache.py Normal file
View file

@ -0,0 +1,108 @@
"""TTL'd in-memory cache for enhanced-search responses.
The cache key blends the normalized query with the active media server,
configured fallback metadata source, hydrabase-active flag, and the
explicit single-source request (if any). This prevents responses from
colliding when a user changes settings or switches single-source mode.
"""
from __future__ import annotations
import collections
import threading
import time
from typing import Any, Callable, Optional, Tuple
CacheKey = Tuple[str, str, str, bool, str]
CACHE_TTL_SECONDS = 600
CACHE_MAX_ENTRIES = 100
class EnhancedSearchCache:
"""Thread-safe LRU+TTL cache for enhanced-search response payloads.
A single shared instance lives in this module (`_cache`). The module-level
helpers (`get_cache_key`, `get_cached_response`, `set_cached_response`)
operate on it.
"""
def __init__(self, ttl: float = CACHE_TTL_SECONDS, max_entries: int = CACHE_MAX_ENTRIES):
self._ttl = ttl
self._max_entries = max_entries
self._store: "collections.OrderedDict[CacheKey, dict]" = collections.OrderedDict()
self._lock = threading.Lock()
def get(self, key: CacheKey) -> Optional[dict]:
now = time.time()
with self._lock:
entry = self._store.get(key)
if not entry:
return None
if now - entry['timestamp'] < self._ttl:
self._store.move_to_end(key)
return entry['data']
self._store.pop(key, None)
return None
def set(self, key: CacheKey, data: dict) -> None:
with self._lock:
self._store[key] = {'timestamp': time.time(), 'data': data}
self._store.move_to_end(key)
while len(self._store) > self._max_entries:
self._store.popitem(last=False)
def clear(self) -> None:
with self._lock:
self._store.clear()
_cache = EnhancedSearchCache()
def get_cache_key(
query: str,
requested_source: Optional[str],
*,
active_server_provider: Callable[[], str],
fallback_source_provider: Callable[[], str],
hydrabase_active_provider: Callable[[], bool],
) -> CacheKey:
"""Build a cache key for an enhanced-search query.
Each provider arg is a zero-arg callable so the cache key reflects the
LIVE config state at lookup time, not the state at app startup. Each
provider is wrapped in try/except: failures resolve to a sentinel value
so a misconfigured client never breaks search.
"""
normalized_query = (query or '').strip().lower()
try:
active_server = active_server_provider()
except Exception:
active_server = 'unknown'
try:
fallback_source = fallback_source_provider()
except Exception:
fallback_source = 'unknown'
try:
hydrabase_active = hydrabase_active_provider()
except Exception:
hydrabase_active = False
source_tag = (requested_source or '').strip().lower() or 'auto'
return (normalized_query, active_server, fallback_source, hydrabase_active, source_tag)
def get_cached_response(key: CacheKey) -> Optional[dict]:
return _cache.get(key)
def set_cached_response(key: CacheKey, data: Any) -> None:
_cache.set(key, data)
def clear_cache() -> None:
_cache.clear()

View file

@ -0,0 +1,158 @@
"""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}

347
core/search/orchestrator.py Normal file
View file

@ -0,0 +1,347 @@
"""Enhanced-search orchestration.
Two routes funnel through here:
- `/api/enhanced-search` `run_enhanced_search`
- Always returns library DB matches
- Single-source mode (request body has `source: "spotify"` etc) skips fan-out
- Default mode resolves a primary source, runs it synchronously, and
returns the list of alternate sources for the frontend to fetch async
- `/api/enhanced-search/source/<src>` `stream_source_search` (generator)
- NDJSON: yields one line per kind (artists / albums / tracks) as each
finishes, plus a final `{"type":"done"}` line
- Has its own special-case for `youtube_videos` which uses yt-dlp
The route layer wraps the generator in a Flask `Response(...,
mimetype='application/x-ndjson')`. Everything else is plain Python.
Cross-cutting deps are passed in as a `SearchDeps` dataclass to keep the
function signatures readable. Each field is a live reference (not a
snapshot) so callers see config changes without restart.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Any, Callable, Iterator, Optional
from . import sources
logger = logging.getLogger(__name__)
VALID_SOURCES = (
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz',
)
VALID_STREAM_SOURCES = VALID_SOURCES + ('youtube_videos',)
@dataclass
class SearchDeps:
"""Bundle of cross-cutting deps used by the orchestrator.
All fields are lazily evaluated where possible (live providers, not
cached values) so settings changes take effect without restart.
"""
database: Any
config_manager: Any
spotify_client: Any
hydrabase_client: Any
hydrabase_worker: Any
soulseek_client: Any
fix_artist_image_url: Callable[[Optional[str]], Optional[str]]
is_hydrabase_active: Callable[[], bool]
get_metadata_fallback_source: Callable[[], str]
get_metadata_fallback_client: Callable[[], Any]
get_itunes_client: Callable[[], Any]
get_deezer_client: Callable[[], Any]
get_discogs_client: Callable[[Optional[str]], Any]
run_background_comparison: Callable[..., None]
run_async: Callable
dev_mode_enabled_provider: Callable[[], bool]
def resolve_client(source_name: str, deps: SearchDeps) -> tuple[Any, bool]:
"""Return (client, is_available) for an explicit metadata source request."""
if source_name == 'spotify':
if deps.spotify_client and deps.spotify_client.is_spotify_authenticated():
return deps.spotify_client, True
return None, False
if source_name == 'itunes':
return deps.get_itunes_client(), True
if source_name == 'deezer':
return deps.get_deezer_client(), True
if source_name == 'discogs':
token = deps.config_manager.get('discogs.token', '')
if not token:
return None, False
return deps.get_discogs_client(token), True
if source_name == 'hydrabase':
if deps.hydrabase_client and deps.hydrabase_client.is_connected():
return deps.hydrabase_client, True
return None, False
if source_name == 'musicbrainz':
try:
from core.musicbrainz_search import MusicBrainzSearchClient
return MusicBrainzSearchClient(), True
except Exception as e:
logger.warning(f"MusicBrainz search client init failed: {e}")
return None, False
return None, False
def _build_db_artists(query: str, deps: SearchDeps) -> list[dict]:
active_server = deps.config_manager.get_active_media_server()
artist_objs = deps.database.search_artists(query, limit=5, server_source=active_server)
out: list[dict] = []
for artist in artist_objs:
image_url = None
if hasattr(artist, 'thumb_url') and artist.thumb_url:
image_url = deps.fix_artist_image_url(artist.thumb_url)
out.append({
'id': artist.id,
'name': artist.name,
'image_url': image_url,
})
return out
def _short_query_response(db_artists: list[dict], requested_source: str, deps: SearchDeps) -> dict:
"""Skip the remote search for queries shorter than 3 chars."""
short_source = requested_source or deps.get_metadata_fallback_source()
return {
'db_artists': db_artists,
'spotify_artists': [],
'spotify_albums': [],
'spotify_tracks': [],
'metadata_source': short_source,
'primary_source': short_source,
'alternate_sources': [],
'sources': {},
}
def _single_source_response(
query: str,
db_artists: list[dict],
requested_source: str,
deps: SearchDeps,
) -> dict:
"""Run a single-source search — bypasses the fan-out."""
client, available = resolve_client(requested_source, deps)
if not client:
return {
'db_artists': db_artists,
'spotify_artists': [],
'spotify_albums': [],
'spotify_tracks': [],
'metadata_source': requested_source,
'primary_source': requested_source,
'alternate_sources': [],
'source_available': False,
}
try:
source_results = sources.search_source(query, client, requested_source)
except Exception as e:
logger.warning(f"Single-source search ({requested_source}) failed: {e}")
source_results = {'artists': [], 'albums': [], 'tracks': [], 'available': False}
logger.info(
f"Enhanced search [source={requested_source}] results: "
f"{len(db_artists)} DB, {len(source_results['artists'])} artists, "
f"{len(source_results['albums'])} albums, {len(source_results['tracks'])} tracks"
)
return {
'db_artists': db_artists,
'spotify_artists': source_results['artists'],
'spotify_albums': source_results['albums'],
'spotify_tracks': source_results['tracks'],
'metadata_source': requested_source,
'primary_source': requested_source,
'alternate_sources': [],
'source_available': True,
}
def _alternate_sources(primary_source: str, deps: SearchDeps) -> list[str]:
"""Build the list of alternate sources the frontend should fetch async."""
spotify_available = bool(deps.spotify_client and deps.spotify_client.is_spotify_authenticated())
hydrabase_available = bool(deps.hydrabase_client and deps.hydrabase_client.is_connected())
discogs_available = bool(deps.config_manager.get('discogs.token', ''))
alts: list[str] = []
if primary_source != 'spotify' and spotify_available:
alts.append('spotify')
if primary_source != 'itunes':
alts.append('itunes')
if primary_source != 'deezer':
alts.append('deezer')
if primary_source != 'discogs' and discogs_available:
alts.append('discogs')
if primary_source != 'hydrabase' and hydrabase_available:
alts.append('hydrabase')
alts.append('youtube_videos') # always available (yt-dlp, no auth)
alts.append('musicbrainz') # always available (public API)
return alts
def _fan_out_response(query: str, db_artists: list[dict], deps: SearchDeps) -> dict:
"""Default flow: pick a primary source, run it, list alternates."""
# Per-request empty marker — used for identity check at the spotify-fallback
# gate below. Local (not module-level) so a future caller can't accidentally
# mutate it across requests.
empty_source = {"artists": [], "albums": [], "tracks": [], "available": False}
primary_source = 'spotify'
primary_results = empty_source
if deps.is_hydrabase_active():
primary_source = 'hydrabase'
try:
primary_results = sources.search_source(query, deps.hydrabase_client)
deps.run_background_comparison(query, hydrabase_counts={
'tracks': len(primary_results['tracks']),
'artists': len(primary_results['artists']),
'albums': len(primary_results['albums']),
})
except Exception as e:
logger.error(f"Hydrabase search failed: {e}")
primary_source = 'spotify'
primary_results = empty_source
if primary_source != 'hydrabase':
if deps.hydrabase_worker and deps.dev_mode_enabled_provider():
deps.hydrabase_worker.enqueue(query, 'tracks')
deps.hydrabase_worker.enqueue(query, 'albums')
deps.hydrabase_worker.enqueue(query, 'artists')
fb_source = deps.get_metadata_fallback_source()
try:
primary_results = sources.search_source(query, deps.get_metadata_fallback_client(), fb_source)
primary_source = fb_source
except Exception as e:
logger.debug(f"Primary source ({fb_source}) search failed: {e}")
if primary_results is empty_source and fb_source != 'spotify':
if deps.spotify_client and deps.spotify_client.is_spotify_authenticated():
try:
primary_results = sources.search_source(query, deps.spotify_client, 'spotify')
primary_source = 'spotify'
except Exception as e:
logger.debug(f"Spotify fallback search failed: {e}")
alternate_sources = _alternate_sources(primary_source, deps)
logger.info(
f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, "
f"{len(primary_results['artists'])} artists, "
f"{len(primary_results['albums'])} albums, "
f"{len(primary_results['tracks'])} tracks | "
f"Alt sources available: {alternate_sources}"
)
return {
'db_artists': db_artists,
'spotify_artists': primary_results['artists'],
'spotify_albums': primary_results['albums'],
'spotify_tracks': primary_results['tracks'],
'metadata_source': primary_source,
'primary_source': primary_source,
'alternate_sources': alternate_sources,
}
def empty_response() -> dict:
"""Response shape for an empty query — preserves the legacy spotify-default keys."""
return {
'db_artists': [],
'spotify_artists': [],
'spotify_albums': [],
'spotify_tracks': [],
'sources': {},
'primary_source': 'spotify',
'metadata_source': 'spotify',
}
def run_enhanced_search(query: str, requested_source: str, deps: SearchDeps) -> dict:
"""Main flow: build db_artists, then dispatch to the right strategy.
Caller is responsible for cache lookup / store and request shape; this
function returns a plain dict.
"""
db_artists = _build_db_artists(query, deps)
if len(query) < 3:
return _short_query_response(db_artists, requested_source, deps)
if requested_source:
return _single_source_response(query, db_artists, requested_source, deps)
return _fan_out_response(query, db_artists, deps)
# ---------------------------------------------------------------------------
# NDJSON streaming for /api/enhanced-search/source/<src>
# ---------------------------------------------------------------------------
def resolve_youtube_videos_client(deps: SearchDeps):
"""Return the soulseek_client.youtube subclient or None when unavailable."""
if not deps.soulseek_client:
return None
return getattr(deps.soulseek_client, 'youtube', None)
def stream_youtube_videos(query: str, youtube_client, run_async: Callable) -> Iterator[str]:
"""yt-dlp video search generator — yields one videos chunk + done marker.
Caller is responsible for verifying youtube_client is not None.
"""
try:
video_query = f"{query} official music video"
results = run_async(youtube_client.search_videos(video_query, max_results=20))
videos = []
for v in (results or []):
videos.append({
'video_id': v.video_id,
'title': v.title,
'channel': v.channel,
'duration': v.duration,
'thumbnail': v.thumbnail,
'url': v.url,
'view_count': v.view_count,
'upload_date': v.upload_date,
})
yield json.dumps({'type': 'videos', 'data': videos}) + '\n'
except Exception as e:
logger.error(f"YouTube music video search failed: {e}")
yield json.dumps({'type': 'videos', 'data': []}) + '\n'
yield json.dumps({'type': 'done'}) + '\n'
def stream_metadata_source(source_name: str, query: str, client) -> Iterator[str]:
"""Fan three search-kinds out and yield each as it lands.
Caller is responsible for resolving and validating the client.
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=3) as executor:
futures = {
executor.submit(sources.search_kind, client, query, 'artists', source_name): 'artists',
executor.submit(sources.search_kind, client, query, 'albums', source_name): 'albums',
executor.submit(sources.search_kind, client, query, 'tracks', source_name): 'tracks',
}
for future in as_completed(futures):
kind = futures[future]
try:
payload = future.result()
except Exception as e:
logger.warning(f"{kind.title()} search failed for {source_name}: {e}", exc_info=True)
payload = []
yield json.dumps({'type': kind, 'data': payload}) + '\n'
yield json.dumps({'type': 'done'}) + '\n'

113
core/search/sources.py Normal file
View file

@ -0,0 +1,113 @@
"""Per-source metadata search.
Two public functions:
- `search_kind(client, query, kind, source_name=None)` search a single
result type (artists | albums | tracks) on one client and normalize the
result to a list of plain dicts.
- `search_source(query, client, source_name=None)` fan three
search_kind calls out across a thread pool and return the merged dict.
Both swallow per-kind exceptions search reliability matters more than
strict error propagation, and the route layer cannot do anything useful
with a single-kind failure.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Optional
logger = logging.getLogger(__name__)
def search_kind(client, query: str, kind: str, source_name: Optional[str] = None) -> list:
"""Search one result type from a metadata source and normalize it."""
source_label = source_name or type(client).__name__
if kind == "artists":
artists = []
try:
artist_objs = client.search_artists(query, limit=10)
for artist in artist_objs:
artists.append({
"id": artist.id,
"name": artist.name,
"image_url": artist.image_url,
"external_urls": artist.external_urls or {},
})
except Exception as e:
logger.debug(f"Artist search failed for {source_label}: {e}")
return artists
if kind == "albums":
albums = []
try:
album_objs = client.search_albums(query, limit=10)
for album in album_objs:
artist_name = ', '.join(album.artists) if album.artists else 'Unknown Artist'
albums.append({
"id": album.id,
"name": album.name,
"artist": artist_name,
"image_url": album.image_url,
"release_date": album.release_date,
"total_tracks": album.total_tracks,
"album_type": album.album_type,
"external_urls": album.external_urls or {},
})
except Exception as e:
logger.warning(f"Album search failed for {source_label}: {e}", exc_info=True)
return albums
if kind == "tracks":
tracks = []
try:
track_objs = client.search_tracks(query, limit=10)
for track in track_objs:
artist_name = ', '.join(track.artists) if track.artists else 'Unknown Artist'
tracks.append({
"id": track.id,
"name": track.name,
"artist": artist_name,
"album": track.album,
"duration_ms": track.duration_ms,
"image_url": track.image_url,
"release_date": track.release_date,
"external_urls": track.external_urls or {},
})
except Exception as e:
logger.warning(f"Track search failed for {source_label}: {e}", exc_info=True)
return tracks
raise ValueError(f"Unknown metadata search kind: {kind}")
def search_source(query: str, client, source_name: Optional[str] = None) -> dict:
"""Run all three search-kinds against a single client in parallel."""
results: dict[str, Any] = {"artists": [], "albums": [], "tracks": []}
with ThreadPoolExecutor(max_workers=3) as executor:
futures = {
executor.submit(search_kind, client, query, "artists", source_name): "artists",
executor.submit(search_kind, client, query, "albums", source_name): "albums",
executor.submit(search_kind, client, query, "tracks", source_name): "tracks",
}
for future in as_completed(futures):
kind = futures[future]
try:
results[kind] = future.result()
except Exception as e:
logger.warning(
f"{kind.title()} search failed for {source_name or type(client).__name__}: {e}",
exc_info=True,
)
results[kind] = []
return {
"artists": results["artists"],
"albums": results["albums"],
"tracks": results["tracks"],
"available": True,
}

160
core/search/stream.py Normal file
View file

@ -0,0 +1,160 @@
"""Single-track stream search — finds the best Soulseek result for a track
play preview.
Builds a small ordered list of search query variants (artist+title,
artist+cleaned title; or title-only when the stream source is Soulseek
itself) and walks them until one returns a usable match through the
matching engine.
Stream source resolution:
- If `download_source.stream_source` is "youtube" (default), use the
YouTube downloader for previews instant, no auth pressure on the
download stack.
- If it's "active", mirror the user's download mode (tidal / qobuz /
hifi / deezer_dl / lidarr) but coerce Soulseek to YouTube because
Soulseek is too slow for streaming previews.
"""
from __future__ import annotations
import logging
import re
from typing import Callable, Optional
logger = logging.getLogger(__name__)
def _resolve_effective_stream_mode(config_manager) -> str:
"""Pick the streaming source based on settings."""
stream_source = config_manager.get('download_source.stream_source', 'youtube')
download_mode = config_manager.get('download_source.mode', 'hybrid')
if stream_source == 'youtube':
return 'youtube'
hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
hybrid_first = hybrid_order[0] if hybrid_order else config_manager.get('download_source.hybrid_primary', 'hifi')
if download_mode == 'soulseek' or (download_mode == 'hybrid' and hybrid_first == 'soulseek'):
logger.info("Stream source is 'active' but primary is Soulseek — falling back to YouTube")
return 'youtube'
if download_mode == 'hybrid':
return hybrid_first
return download_mode
def _build_stream_queries(track_name: str, artist_name: str, effective_mode: str) -> list[str]:
"""Build an ordered, deduped list of search queries to try."""
queries: list[str] = []
is_streaming_source = effective_mode in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr')
if is_streaming_source:
if artist_name and track_name:
queries.append(f"{artist_name} {track_name}".strip())
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
if cleaned_name and cleaned_name.lower() != track_name.lower():
queries.append(f"{artist_name} {cleaned_name}".strip())
else:
if track_name.strip():
queries.append(track_name.strip())
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
if cleaned_name and cleaned_name.lower() != track_name.lower():
queries.append(cleaned_name.strip())
seen: set[str] = set()
deduped: list[str] = []
for q in queries:
if q and q.lower() not in seen:
deduped.append(q)
seen.add(q.lower())
return deduped
def _result_to_dict(best_result) -> dict:
return {
"username": best_result.username,
"filename": best_result.filename,
"size": best_result.size,
"bitrate": best_result.bitrate,
"duration": best_result.duration,
"quality": best_result.quality,
"free_upload_slots": best_result.free_upload_slots,
"upload_speed": best_result.upload_speed,
"queue_length": best_result.queue_length,
"result_type": "track",
}
def stream_search_track(
*,
track_name: str,
artist_name: str,
album_name: Optional[str],
duration_ms: int,
config_manager,
soulseek_client,
matching_engine,
run_async: Callable,
) -> Optional[dict]:
"""Find the best Soulseek/stream-source result for a single track.
Returns the matched result dict on success, or `None` if no query
variant produced a usable match. The route layer turns `None` into a
404 response.
"""
temp_track = type('TempTrack', (), {
'name': track_name,
'artists': [artist_name],
'album': album_name if album_name else None,
'duration_ms': duration_ms,
})()
effective_mode = _resolve_effective_stream_mode(config_manager)
logger.info(f"Stream source effective mode: {effective_mode}")
queries = _build_stream_queries(track_name, artist_name, effective_mode)
stream_clients = {
'youtube': soulseek_client.youtube,
'tidal': soulseek_client.tidal,
'qobuz': soulseek_client.qobuz,
'hifi': soulseek_client.hifi,
'deezer_dl': soulseek_client.deezer_dl,
'lidarr': soulseek_client.lidarr,
}
stream_client = stream_clients.get(effective_mode)
use_direct_client = stream_client is not None
max_peer_queue = config_manager.get('soulseek.max_peer_queue', 0) or 0
for query_index, query in enumerate(queries):
logger.info(f"Stream query {query_index + 1}/{len(queries)}: '{query}'")
try:
if use_direct_client:
tracks_result, _ = run_async(stream_client.search(query, timeout=15))
else:
tracks_result, _ = run_async(soulseek_client.search(query, timeout=15))
if not tracks_result:
logger.info(f"No results for query '{query}', trying next...")
continue
best_matches = matching_engine.find_best_slskd_matches_enhanced(
temp_track, tracks_result, max_peer_queue=max_peer_queue
)
if best_matches:
best = best_matches[0]
logger.info(f"Stream match for '{query}': {best.filename} ({best.quality})")
return _result_to_dict(best)
logger.info(f"No suitable matches for query '{query}', trying next...")
except Exception as e:
logger.warning(f"Stream search failed for query '{query}': {e}")
continue
logger.warning(f"No stream match found after {len(queries)} queries")
return None

0
tests/search/__init__.py Normal file
View file

View file

@ -0,0 +1,98 @@
"""Tests for core/search/basic.py — basic Soulseek file search."""
from __future__ import annotations
from core.search import basic
class _SearchTrack:
def __init__(self, name, quality_score, **extra):
self.__dict__['name'] = name
self.__dict__['quality_score'] = quality_score
self.__dict__.update(extra)
class _SearchAlbum:
def __init__(self, name, quality_score, tracks=None, **extra):
self.__dict__['name'] = name
self.__dict__['quality_score'] = quality_score
self.__dict__['tracks'] = tracks or []
self.__dict__.update(extra)
class _FakeSoulseek:
def __init__(self, tracks=None, albums=None):
self._tracks = tracks or []
self._albums = albums or []
async def search(self, query):
return self._tracks, self._albums
def _run_async(coro):
"""Test-friendly run_async — drains a coroutine synchronously."""
import asyncio
return asyncio.get_event_loop().run_until_complete(coro) if not asyncio.get_event_loop().is_running() else None
def _sync_run_async(coro):
"""Threadless awaitable runner using a fresh loop."""
import asyncio
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def test_returns_empty_for_no_results():
client = _FakeSoulseek(tracks=[], albums=[])
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
assert result == []
def test_tracks_are_tagged_with_result_type():
track = _SearchTrack('T1', 0.5, username='u', filename='f.mp3', size=1, bitrate=320)
client = _FakeSoulseek(tracks=[track])
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
assert result[0]['result_type'] == 'track'
assert result[0]['name'] == 'T1'
def test_albums_get_tracks_serialized_and_tagged():
inner_track = _SearchTrack('inner', 0.5, filename='in.mp3')
album = _SearchAlbum('A1', 0.9, tracks=[inner_track], username='u')
client = _FakeSoulseek(albums=[album])
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
assert result[0]['result_type'] == 'album'
assert result[0]['name'] == 'A1'
assert isinstance(result[0]['tracks'], list)
assert result[0]['tracks'][0]['name'] == 'inner'
def test_results_sorted_by_quality_score_desc():
low = _SearchTrack('low', 0.1)
high = _SearchTrack('high', 0.9)
mid = _SearchTrack('mid', 0.5)
client = _FakeSoulseek(tracks=[low, mid, high])
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
assert [r['name'] for r in result] == ['high', 'mid', 'low']
def test_albums_and_tracks_intermingled_by_quality():
track = _SearchTrack('mid_t', 0.5)
album = _SearchAlbum('top_a', 0.9, tracks=[])
client = _FakeSoulseek(tracks=[track], albums=[album])
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
assert result[0]['name'] == 'top_a'
assert result[1]['name'] == 'mid_t'
def test_missing_quality_score_treated_as_zero():
no_score = _SearchTrack('n', None)
no_score.__dict__.pop('quality_score', None)
has_score = _SearchTrack('h', 0.5)
client = _FakeSoulseek(tracks=[no_score, has_score])
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
# has_score (0.5) ranks above no_score (treated as 0)
assert result[0]['name'] == 'h'

View file

@ -0,0 +1,133 @@
"""Tests for core/search/cache.py — TTL+LRU cache for enhanced-search responses."""
from __future__ import annotations
import time
import pytest
from core.search import cache as search_cache
@pytest.fixture
def fresh_cache():
"""Each test gets a clean instance — module-level _cache is shared otherwise."""
return search_cache.EnhancedSearchCache(ttl=10, max_entries=3)
def test_set_and_get_round_trip(fresh_cache):
key = ('q', 'plex', 'spotify', False, 'auto')
fresh_cache.set(key, {'result': 'data'})
assert fresh_cache.get(key) == {'result': 'data'}
def test_get_returns_none_for_missing_key(fresh_cache):
assert fresh_cache.get(('absent', 'plex', 'spotify', False, 'auto')) is None
def test_ttl_expiration_evicts_entry():
c = search_cache.EnhancedSearchCache(ttl=0.05, max_entries=10)
key = ('q', 'plex', 'spotify', False, 'auto')
c.set(key, {'x': 1})
assert c.get(key) == {'x': 1}
time.sleep(0.1)
assert c.get(key) is None
def test_max_entries_evicts_lru(fresh_cache):
# max_entries = 3
for i in range(3):
fresh_cache.set((f"q{i}", 'plex', 'spotify', False, 'auto'), {'i': i})
fresh_cache.set(('q3', 'plex', 'spotify', False, 'auto'), {'i': 3})
# q0 should be evicted (oldest)
assert fresh_cache.get(('q0', 'plex', 'spotify', False, 'auto')) is None
assert fresh_cache.get(('q3', 'plex', 'spotify', False, 'auto')) == {'i': 3}
def test_get_promotes_lru(fresh_cache):
fresh_cache.set(('q0', 'plex', 'spotify', False, 'auto'), {'i': 0})
fresh_cache.set(('q1', 'plex', 'spotify', False, 'auto'), {'i': 1})
fresh_cache.set(('q2', 'plex', 'spotify', False, 'auto'), {'i': 2})
fresh_cache.get(('q0', 'plex', 'spotify', False, 'auto')) # touch q0
fresh_cache.set(('q3', 'plex', 'spotify', False, 'auto'), {'i': 3})
# q1 should be evicted, not q0 (which was just touched)
assert fresh_cache.get(('q1', 'plex', 'spotify', False, 'auto')) is None
assert fresh_cache.get(('q0', 'plex', 'spotify', False, 'auto')) == {'i': 0}
def test_clear_empties_cache(fresh_cache):
fresh_cache.set(('q', 'plex', 'spotify', False, 'auto'), {})
fresh_cache.clear()
assert fresh_cache.get(('q', 'plex', 'spotify', False, 'auto')) is None
# ---------------------------------------------------------------------------
# Key builder
# ---------------------------------------------------------------------------
def _providers(server='plex', source='spotify', hb=False):
return {
'active_server_provider': lambda: server,
'fallback_source_provider': lambda: source,
'hydrabase_active_provider': lambda: hb,
}
def test_key_normalizes_query():
key = search_cache.get_cache_key(" Pink FLOYD ", None, **_providers())
assert key[0] == "pink floyd"
def test_key_includes_active_server_and_fallback():
key = search_cache.get_cache_key('q', None, **_providers(server='jellyfin', source='deezer'))
assert key[1] == 'jellyfin'
assert key[2] == 'deezer'
def test_key_includes_hydrabase_flag():
k1 = search_cache.get_cache_key('q', None, **_providers(hb=False))
k2 = search_cache.get_cache_key('q', None, **_providers(hb=True))
assert k1 != k2
def test_key_includes_source_tag():
k_auto = search_cache.get_cache_key('q', None, **_providers())
k_explicit = search_cache.get_cache_key('q', 'spotify', **_providers())
assert k_auto != k_explicit
assert k_auto[4] == 'auto'
assert k_explicit[4] == 'spotify'
def test_key_provider_failure_falls_back_to_unknown():
def boom():
raise RuntimeError("config dead")
key = search_cache.get_cache_key('q', None,
active_server_provider=boom,
fallback_source_provider=lambda: 'spotify',
hydrabase_active_provider=lambda: False)
assert key[1] == 'unknown'
def test_key_hydrabase_provider_failure_falls_back_to_false():
def boom():
raise RuntimeError("hydrabase init failed")
key = search_cache.get_cache_key('q', None,
active_server_provider=lambda: 'plex',
fallback_source_provider=lambda: 'spotify',
hydrabase_active_provider=boom)
assert key[3] is False
def test_key_preserves_falsy_provider_returns():
"""Original behavior: if provider returns None / '' on success, store it
as-is. Don't coerce to 'unknown' — that's reserved for exceptions."""
key = search_cache.get_cache_key('q', None,
active_server_provider=lambda: None,
fallback_source_provider=lambda: '',
hydrabase_active_provider=lambda: 0)
assert key[1] is None
assert key[2] == ''
assert key[3] == 0

View file

@ -0,0 +1,282 @@
"""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

View file

@ -0,0 +1,502 @@
"""Tests for core/search/orchestrator.py — main enhanced-search dispatch + streaming."""
from __future__ import annotations
import json
import pytest
from core.search import orchestrator
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _Artist:
def __init__(self, id_, name, image_url=None, external_urls=None, thumb_url=None):
self.id = id_
self.name = name
self.image_url = image_url
self.external_urls = external_urls
self.thumb_url = thumb_url
class _Album:
def __init__(self, id_, name, artists=None, image_url=None, release_date=None,
total_tracks=10, album_type='album', external_urls=None):
self.id = id_
self.name = name
self.artists = artists or []
self.image_url = image_url
self.release_date = release_date
self.total_tracks = total_tracks
self.album_type = album_type
self.external_urls = external_urls
class _Track:
def __init__(self, id_, name, artists=None, album=None, duration_ms=180000,
image_url=None, release_date=None, external_urls=None):
self.id = id_
self.name = name
self.artists = artists or []
self.album = album
self.duration_ms = duration_ms
self.image_url = image_url
self.release_date = release_date
self.external_urls = external_urls
class _Client:
def __init__(self, *, name='fake', artists=None, albums=None, tracks=None,
fail_search=False, authed=True, connected=True):
self.name = name
self._artists = artists or []
self._albums = albums or []
self._tracks = tracks or []
self._fail = fail_search
self._authed = authed
self._connected = connected
def search_artists(self, q, limit=10):
if self._fail:
raise RuntimeError("client search boom")
return self._artists
def search_albums(self, q, limit=10):
if self._fail:
raise RuntimeError("client search boom")
return self._albums
def search_tracks(self, q, limit=10):
if self._fail:
raise RuntimeError("client search boom")
return self._tracks
def is_spotify_authenticated(self):
return self._authed
def is_connected(self):
return self._connected
class _DB:
def __init__(self, artists=None):
self._artists = artists or []
def search_artists(self, q, limit=5, server_source=None):
return self._artists
class _Cfg:
def __init__(self, values=None):
self._v = values or {}
def get(self, k, default=None):
return self._v.get(k, default)
def get_active_media_server(self):
return self._v.get('__active_server', 'plex')
class _Worker:
def __init__(self):
self.enqueued = []
def enqueue(self, query, kind):
self.enqueued.append((query, kind))
def _sync_run_async(coro):
"""Run a coroutine synchronously on a fresh loop."""
import asyncio
import inspect
if not inspect.iscoroutine(coro):
return coro
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def _build_deps(**overrides):
"""Default deps for an enhanced-search call. Override with kwargs."""
base = dict(
database=_DB(),
config_manager=_Cfg({'discogs.token': ''}),
spotify_client=None,
hydrabase_client=None,
hydrabase_worker=None,
soulseek_client=None,
fix_artist_image_url=lambda u: f'FIXED::{u}' if u else None,
is_hydrabase_active=lambda: False,
get_metadata_fallback_source=lambda: 'spotify',
get_metadata_fallback_client=lambda: _Client(name='fallback'),
get_itunes_client=lambda: _Client(name='itunes'),
get_deezer_client=lambda: _Client(name='deezer'),
get_discogs_client=lambda token=None: _Client(name='discogs'),
run_background_comparison=lambda *a, **k: None,
run_async=_sync_run_async,
dev_mode_enabled_provider=lambda: False,
)
base.update(overrides)
return orchestrator.SearchDeps(**base)
# ---------------------------------------------------------------------------
# resolve_client
# ---------------------------------------------------------------------------
def test_resolve_spotify_authed_returns_client():
deps = _build_deps(spotify_client=_Client(authed=True))
client, ok = orchestrator.resolve_client('spotify', deps)
assert client is deps.spotify_client
assert ok is True
def test_resolve_spotify_unauthed_returns_none():
deps = _build_deps(spotify_client=_Client(authed=False))
client, ok = orchestrator.resolve_client('spotify', deps)
assert client is None
assert ok is False
def test_resolve_spotify_missing_returns_none():
deps = _build_deps(spotify_client=None)
client, ok = orchestrator.resolve_client('spotify', deps)
assert client is None
assert ok is False
def test_resolve_itunes_always_returns_client():
deps = _build_deps()
client, ok = orchestrator.resolve_client('itunes', deps)
assert client.name == 'itunes'
assert ok is True
def test_resolve_deezer_always_returns_client():
deps = _build_deps()
client, ok = orchestrator.resolve_client('deezer', deps)
assert client.name == 'deezer'
assert ok is True
def test_resolve_discogs_with_token_returns_client():
deps = _build_deps(config_manager=_Cfg({'discogs.token': 'tok'}))
client, ok = orchestrator.resolve_client('discogs', deps)
assert client.name == 'discogs'
assert ok is True
def test_resolve_discogs_without_token_returns_none():
deps = _build_deps(config_manager=_Cfg({'discogs.token': ''}))
client, ok = orchestrator.resolve_client('discogs', deps)
assert client is None
assert ok is False
def test_resolve_hydrabase_connected_returns_client():
deps = _build_deps(hydrabase_client=_Client(connected=True))
client, ok = orchestrator.resolve_client('hydrabase', deps)
assert client is deps.hydrabase_client
assert ok is True
def test_resolve_hydrabase_disconnected_returns_none():
deps = _build_deps(hydrabase_client=_Client(connected=False))
client, ok = orchestrator.resolve_client('hydrabase', deps)
assert client is None
assert ok is False
def test_resolve_unknown_source_returns_none():
deps = _build_deps()
client, ok = orchestrator.resolve_client('garbage', deps)
assert client is None
assert ok is False
# ---------------------------------------------------------------------------
# run_enhanced_search — short query path
# ---------------------------------------------------------------------------
def test_short_query_skips_remote_search():
db_artist = _Artist('a1', 'Aretha', thumb_url='http://x/a.jpg')
deps = _build_deps(database=_DB(artists=[db_artist]))
result = orchestrator.run_enhanced_search('aa', '', deps)
assert result['db_artists'][0]['name'] == 'Aretha'
assert result['spotify_artists'] == []
assert result['spotify_albums'] == []
assert result['spotify_tracks'] == []
assert result['primary_source'] == 'spotify'
assert result['alternate_sources'] == []
def test_short_query_with_explicit_source_uses_that_source_label():
deps = _build_deps()
result = orchestrator.run_enhanced_search('aa', 'deezer', deps)
assert result['primary_source'] == 'deezer'
assert result['metadata_source'] == 'deezer'
# ---------------------------------------------------------------------------
# run_enhanced_search — single source
# ---------------------------------------------------------------------------
def test_single_source_runs_only_that_source():
spot = _Client(authed=True, artists=[_Artist('s1', 'Spot Artist')])
deps = _build_deps(spotify_client=spot)
result = orchestrator.run_enhanced_search('pink floyd', 'spotify', deps)
assert result['primary_source'] == 'spotify'
assert result['metadata_source'] == 'spotify'
assert result['source_available'] is True
assert result['spotify_artists'][0]['name'] == 'Spot Artist'
assert result['alternate_sources'] == []
def test_single_source_unavailable_returns_empty_with_source_available_false():
deps = _build_deps(spotify_client=None)
result = orchestrator.run_enhanced_search('pink floyd', 'spotify', deps)
assert result['source_available'] is False
assert result['spotify_artists'] == []
assert result['primary_source'] == 'spotify'
def test_single_source_search_failure_returns_empty():
spot = _Client(authed=True, fail_search=True)
deps = _build_deps(spotify_client=spot)
result = orchestrator.run_enhanced_search('q', 'spotify', deps)
# search_source still returns a wrapper because per-kind exceptions are
# swallowed inside it, so we get [] for each kind, source_available=True
assert result['spotify_artists'] == []
assert result['spotify_albums'] == []
assert result['spotify_tracks'] == []
# ---------------------------------------------------------------------------
# run_enhanced_search — fan-out
# ---------------------------------------------------------------------------
def test_fanout_uses_fallback_client_as_primary():
fb_client = _Client(artists=[_Artist('f1', 'Fallback Artist')])
deps = _build_deps(
get_metadata_fallback_source=lambda: 'deezer',
get_metadata_fallback_client=lambda: fb_client,
)
result = orchestrator.run_enhanced_search('pink floyd', '', deps)
assert result['primary_source'] == 'deezer'
assert result['spotify_artists'][0]['name'] == 'Fallback Artist'
def test_fanout_lists_alternate_sources_excluding_primary():
deps = _build_deps(
get_metadata_fallback_source=lambda: 'deezer',
spotify_client=_Client(authed=True),
)
result = orchestrator.run_enhanced_search('pink floyd', '', deps)
alts = result['alternate_sources']
assert 'deezer' not in alts # primary excluded
assert 'itunes' in alts
assert 'spotify' in alts
assert 'youtube_videos' in alts
assert 'musicbrainz' in alts
def test_fanout_omits_spotify_alternate_when_unauthed():
deps = _build_deps(
get_metadata_fallback_source=lambda: 'deezer',
spotify_client=_Client(authed=False),
)
result = orchestrator.run_enhanced_search('pink floyd', '', deps)
assert 'spotify' not in result['alternate_sources']
def test_fanout_omits_discogs_alternate_when_no_token():
deps = _build_deps(
get_metadata_fallback_source=lambda: 'deezer',
config_manager=_Cfg({'discogs.token': ''}),
)
result = orchestrator.run_enhanced_search('pink floyd', '', deps)
assert 'discogs' not in result['alternate_sources']
def test_fanout_includes_discogs_alternate_when_token_set():
deps = _build_deps(
get_metadata_fallback_source=lambda: 'deezer',
config_manager=_Cfg({'discogs.token': 'abc'}),
)
result = orchestrator.run_enhanced_search('pink floyd', '', deps)
assert 'discogs' in result['alternate_sources']
def test_fanout_omits_hydrabase_alternate_when_disconnected():
deps = _build_deps(
get_metadata_fallback_source=lambda: 'deezer',
hydrabase_client=None,
)
result = orchestrator.run_enhanced_search('pink floyd', '', deps)
assert 'hydrabase' not in result['alternate_sources']
def test_fanout_hydrabase_primary_runs_hydrabase_first():
hydra = _Client(connected=True, artists=[_Artist('h1', 'Hydra Artist')])
deps = _build_deps(
is_hydrabase_active=lambda: True,
hydrabase_client=hydra,
)
result = orchestrator.run_enhanced_search('pink floyd', '', deps)
assert result['primary_source'] == 'hydrabase'
assert result['spotify_artists'][0]['name'] == 'Hydra Artist'
def test_fanout_hydrabase_failure_falls_through_to_spotify_default():
hydra_fail = _Client(connected=True, fail_search=True)
deps = _build_deps(
is_hydrabase_active=lambda: True,
hydrabase_client=hydra_fail,
get_metadata_fallback_source=lambda: 'spotify',
get_metadata_fallback_client=lambda: _Client(name='spotify-fb'),
)
# Should not raise
result = orchestrator.run_enhanced_search('q', '', deps)
# search_source still returns a wrapper because per-kind exceptions are
# swallowed inside it — so primary_results.tracks is []. Code keeps
# primary_source='hydrabase' because search_source returned a value.
assert result is not None
def test_fanout_hydrabase_worker_enqueued_when_dev_mode_enabled():
worker = _Worker()
deps = _build_deps(
hydrabase_worker=worker,
dev_mode_enabled_provider=lambda: True,
get_metadata_fallback_source=lambda: 'deezer',
)
orchestrator.run_enhanced_search('pink floyd', '', deps)
enqueued_kinds = {kind for _q, kind in worker.enqueued}
assert enqueued_kinds == {'tracks', 'albums', 'artists'}
def test_fanout_hydrabase_worker_skipped_in_prod_mode():
worker = _Worker()
deps = _build_deps(
hydrabase_worker=worker,
dev_mode_enabled_provider=lambda: False,
get_metadata_fallback_source=lambda: 'deezer',
)
orchestrator.run_enhanced_search('pink floyd', '', deps)
assert worker.enqueued == []
def test_fanout_db_artists_get_image_url_fixed():
db_artist = _Artist('a1', 'Aretha', thumb_url='/library/a.jpg')
deps = _build_deps(database=_DB(artists=[db_artist]))
result = orchestrator.run_enhanced_search('pink floyd', '', deps)
assert result['db_artists'][0]['image_url'] == 'FIXED::/library/a.jpg'
# ---------------------------------------------------------------------------
# empty_response
# ---------------------------------------------------------------------------
def test_empty_response_keys():
r = orchestrator.empty_response()
for k in ('db_artists', 'spotify_artists', 'spotify_albums', 'spotify_tracks',
'sources', 'primary_source', 'metadata_source'):
assert k in r
assert r['primary_source'] == 'spotify'
# ---------------------------------------------------------------------------
# Streaming generators + youtube_videos client resolution
# ---------------------------------------------------------------------------
def _drain(generator):
"""Drain an NDJSON generator into a list of parsed JSON dicts."""
out = []
for line in generator:
out.append(json.loads(line.rstrip('\n')))
return out
def test_stream_metadata_source_yields_three_kinds_plus_done():
spot = _Client(
authed=True,
artists=[_Artist('a', 'A')],
albums=[_Album('b', 'B')],
tracks=[_Track('c', 'C')],
)
out = _drain(orchestrator.stream_metadata_source('spotify', 'q', spot))
types = [m['type'] for m in out]
assert 'artists' in types
assert 'albums' in types
assert 'tracks' in types
assert types[-1] == 'done'
class _FakeYouTubeVideo:
def __init__(self, vid):
self.video_id = vid
self.title = f"Title {vid}"
self.channel = "Chan"
self.duration = 100
self.thumbnail = f"thumb-{vid}.jpg"
self.url = f"https://yt/{vid}"
self.view_count = 1000
self.upload_date = "20260101"
class _FakeYouTube:
def __init__(self, results=None):
self._results = results or []
async def search_videos(self, q, max_results=20):
return self._results
class _FakeSoulseekWithYT:
def __init__(self, youtube):
self.youtube = youtube
def test_resolve_youtube_videos_returns_subclient():
yt = _FakeYouTube()
deps = _build_deps(soulseek_client=_FakeSoulseekWithYT(yt))
assert orchestrator.resolve_youtube_videos_client(deps) is yt
def test_resolve_youtube_videos_no_soulseek_returns_none():
deps = _build_deps(soulseek_client=None)
assert orchestrator.resolve_youtube_videos_client(deps) is None
def test_resolve_youtube_videos_no_youtube_attr_returns_none():
class _NoYT:
pass
deps = _build_deps(soulseek_client=_NoYT())
assert orchestrator.resolve_youtube_videos_client(deps) is None
def test_stream_youtube_videos_yields_videos_chunk_and_done():
yt = _FakeYouTube(results=[_FakeYouTubeVideo('vid1'), _FakeYouTubeVideo('vid2')])
out = _drain(orchestrator.stream_youtube_videos('q', yt, _sync_run_async))
assert out[0]['type'] == 'videos'
assert len(out[0]['data']) == 2
assert out[0]['data'][0]['video_id'] == 'vid1'
assert out[-1]['type'] == 'done'
def test_stream_youtube_videos_search_failure_yields_empty_videos():
class _BadYT:
async def search_videos(self, q, max_results=20):
raise RuntimeError("yt-dlp boom")
out = _drain(orchestrator.stream_youtube_videos('q', _BadYT(), _sync_run_async))
assert out[0] == {'type': 'videos', 'data': []}
assert out[-1] == {'type': 'done'}

View file

@ -0,0 +1,174 @@
"""Tests for core/search/sources.py — per-source-kind + multi-kind executor."""
from __future__ import annotations
from core.search import sources
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _Artist:
def __init__(self, id_, name, image_url=None, external_urls=None):
self.id = id_
self.name = name
self.image_url = image_url
self.external_urls = external_urls
class _Album:
def __init__(self, id_, name, artists=None, image_url=None, release_date=None,
total_tracks=10, album_type='album', external_urls=None):
self.id = id_
self.name = name
self.artists = artists or []
self.image_url = image_url
self.release_date = release_date
self.total_tracks = total_tracks
self.album_type = album_type
self.external_urls = external_urls
class _Track:
def __init__(self, id_, name, artists=None, album=None, duration_ms=180000,
image_url=None, release_date=None, external_urls=None):
self.id = id_
self.name = name
self.artists = artists or []
self.album = album
self.duration_ms = duration_ms
self.image_url = image_url
self.release_date = release_date
self.external_urls = external_urls
class _Client:
def __init__(self, artists=None, albums=None, tracks=None, fail=None):
self._artists = artists or []
self._albums = albums or []
self._tracks = tracks or []
self._fail = fail or set()
def search_artists(self, q, limit=10):
if 'artists' in self._fail:
raise RuntimeError("artists boom")
return self._artists
def search_albums(self, q, limit=10):
if 'albums' in self._fail:
raise RuntimeError("albums boom")
return self._albums
def search_tracks(self, q, limit=10):
if 'tracks' in self._fail:
raise RuntimeError("tracks boom")
return self._tracks
# ---------------------------------------------------------------------------
# search_kind
# ---------------------------------------------------------------------------
def test_search_kind_artists_returns_normalized_dicts():
client = _Client(artists=[_Artist('id1', 'Pink Floyd', 'thumb.jpg', {'spotify': 'url'})])
result = sources.search_kind(client, 'pink', 'artists', 'spotify')
assert result == [{
'id': 'id1',
'name': 'Pink Floyd',
'image_url': 'thumb.jpg',
'external_urls': {'spotify': 'url'},
}]
def test_search_kind_artists_handles_none_external_urls():
client = _Client(artists=[_Artist('id1', 'X', None, None)])
result = sources.search_kind(client, 'x', 'artists')
assert result[0]['external_urls'] == {}
def test_search_kind_albums_joins_multiple_artists():
client = _Client(albums=[_Album('a1', 'DSOTM', artists=['Pink Floyd', 'Roger'])])
result = sources.search_kind(client, 'd', 'albums')
assert result[0]['artist'] == 'Pink Floyd, Roger'
def test_search_kind_albums_handles_no_artists():
client = _Client(albums=[_Album('a1', 'Mystery', artists=[])])
result = sources.search_kind(client, 'm', 'albums')
assert result[0]['artist'] == 'Unknown Artist'
def test_search_kind_tracks_returns_full_shape():
client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM',
duration_ms=383000, image_url='m.jpg',
release_date='1973-03-01', external_urls={'a': 'b'})])
result = sources.search_kind(client, 'money', 'tracks')
assert result == [{
'id': 't1',
'name': 'Money',
'artist': 'Pink Floyd',
'album': 'DSOTM',
'duration_ms': 383000,
'image_url': 'm.jpg',
'release_date': '1973-03-01',
'external_urls': {'a': 'b'},
}]
def test_search_kind_swallows_artist_errors():
client = _Client(fail={'artists'})
assert sources.search_kind(client, 'q', 'artists') == []
def test_search_kind_swallows_album_errors():
client = _Client(fail={'albums'})
assert sources.search_kind(client, 'q', 'albums') == []
def test_search_kind_swallows_track_errors():
client = _Client(fail={'tracks'})
assert sources.search_kind(client, 'q', 'tracks') == []
def test_search_kind_unknown_kind_raises():
import pytest
with pytest.raises(ValueError):
sources.search_kind(_Client(), 'q', 'movies')
# ---------------------------------------------------------------------------
# search_source — multi-kind executor
# ---------------------------------------------------------------------------
def test_search_source_returns_all_three_kinds():
client = _Client(
artists=[_Artist('a', 'A')],
albums=[_Album('b', 'B', artists=['A'])],
tracks=[_Track('c', 'C', artists=['A'], album='B')],
)
result = sources.search_source('q', client, 'spotify')
assert result['available'] is True
assert len(result['artists']) == 1
assert len(result['albums']) == 1
assert len(result['tracks']) == 1
def test_search_source_partial_failure_does_not_break_others():
client = _Client(
artists=[_Artist('a', 'A')],
albums=[_Album('b', 'B')],
tracks=[_Track('c', 'C')],
fail={'albums'},
)
result = sources.search_source('q', client, 'spotify')
assert result['available'] is True
assert result['artists'] != []
assert result['albums'] == []
assert result['tracks'] != []
def test_search_source_all_fail_returns_empty_lists():
client = _Client(fail={'artists', 'albums', 'tracks'})
result = sources.search_source('q', client, 'spotify')
assert result == {'artists': [], 'albums': [], 'tracks': [], 'available': True}

View file

@ -0,0 +1,260 @@
"""Tests for core/search/stream.py — single-track stream search."""
from __future__ import annotations
from core.search import stream
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeConfig:
def __init__(self, values):
self._v = values
def get(self, key, default=None):
return self._v.get(key, default)
class _FakeMatchResult:
def __init__(self, filename='match.flac', quality='Lossless'):
self.username = 'u'
self.filename = filename
self.size = 100
self.bitrate = 1411
self.duration = 180
self.quality = quality
self.free_upload_slots = 1
self.upload_speed = 1000
self.queue_length = 0
class _FakeMatchingEngine:
def __init__(self, match_for_query=None):
self._match = match_for_query
def find_best_slskd_matches_enhanced(self, temp, results, max_peer_queue=0):
if self._match is None:
return []
return self._match
class _FakeStreamClient:
def __init__(self, results_per_query=None):
# Map query -> ([results], [])
self._results = results_per_query or {}
self.calls = []
async def search(self, query, timeout=15):
self.calls.append(query)
return self._results.get(query, ([], []))
class _FakeSoulseek:
def __init__(self, youtube=None, tidal=None, qobuz=None, hifi=None, deezer_dl=None, lidarr=None, results_per_query=None):
self.youtube = youtube
self.tidal = tidal
self.qobuz = qobuz
self.hifi = hifi
self.deezer_dl = deezer_dl
self.lidarr = lidarr
self._results = results_per_query or {}
self.search_calls = []
async def search(self, query, timeout=15):
self.search_calls.append(query)
return self._results.get(query, ([], []))
def _run_async(coro):
import asyncio
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
# ---------------------------------------------------------------------------
# _resolve_effective_stream_mode
# ---------------------------------------------------------------------------
def test_stream_source_youtube_returns_youtube():
cfg = _FakeConfig({'download_source.stream_source': 'youtube'})
assert stream._resolve_effective_stream_mode(cfg) == 'youtube'
def test_stream_source_active_with_hybrid_first_returns_first():
cfg = _FakeConfig({
'download_source.stream_source': 'active',
'download_source.mode': 'hybrid',
'download_source.hybrid_order': ['hifi', 'youtube', 'soulseek'],
})
assert stream._resolve_effective_stream_mode(cfg) == 'hifi'
def test_stream_source_active_with_soulseek_primary_falls_back_to_youtube():
cfg = _FakeConfig({
'download_source.stream_source': 'active',
'download_source.mode': 'soulseek',
})
assert stream._resolve_effective_stream_mode(cfg) == 'youtube'
def test_stream_source_active_with_hybrid_soulseek_first_falls_back_to_youtube():
cfg = _FakeConfig({
'download_source.stream_source': 'active',
'download_source.mode': 'hybrid',
'download_source.hybrid_order': ['soulseek', 'youtube'],
})
assert stream._resolve_effective_stream_mode(cfg) == 'youtube'
def test_stream_source_active_non_hybrid_uses_mode_directly():
cfg = _FakeConfig({
'download_source.stream_source': 'active',
'download_source.mode': 'tidal',
})
assert stream._resolve_effective_stream_mode(cfg) == 'tidal'
# ---------------------------------------------------------------------------
# _build_stream_queries
# ---------------------------------------------------------------------------
def test_build_queries_streaming_mode_includes_artist():
qs = stream._build_stream_queries('Money', 'Pink Floyd', 'youtube')
assert qs[0] == 'Pink Floyd Money'
def test_build_queries_streaming_mode_adds_cleaned_variant():
qs = stream._build_stream_queries('Money (Remastered)', 'Pink Floyd', 'youtube')
assert qs == ['Pink Floyd Money (Remastered)', 'Pink Floyd Money']
def test_build_queries_soulseek_mode_strips_artist():
qs = stream._build_stream_queries('Money', 'Pink Floyd', 'soulseek')
assert qs == ['Money']
def test_build_queries_soulseek_mode_adds_cleaned_variant():
qs = stream._build_stream_queries('Money [Live]', 'Pink Floyd', 'soulseek')
assert qs == ['Money [Live]', 'Money']
def test_build_queries_dedupes_case_insensitive():
qs = stream._build_stream_queries('Money', 'Pink Floyd', 'soulseek')
# Cleaned == original → dedup → only one entry
assert qs == ['Money']
# ---------------------------------------------------------------------------
# stream_search_track
# ---------------------------------------------------------------------------
def test_stream_finds_match_on_first_query():
youtube = _FakeStreamClient(results_per_query={
'Pink Floyd Money': ([object()], []),
})
soul = _FakeSoulseek(youtube=youtube)
cfg = _FakeConfig({'download_source.stream_source': 'youtube'})
engine = _FakeMatchingEngine(match_for_query=[_FakeMatchResult()])
result = stream.stream_search_track(
track_name='Money', artist_name='Pink Floyd', album_name=None,
duration_ms=180000,
config_manager=cfg, soulseek_client=soul, matching_engine=engine,
run_async=_run_async,
)
assert result is not None
assert result['filename'] == 'match.flac'
assert result['quality'] == 'Lossless'
assert result['result_type'] == 'track'
def test_stream_walks_to_second_query_on_no_match():
youtube = _FakeStreamClient(results_per_query={
'Pink Floyd Money (Remastered)': ([], []), # no results
'Pink Floyd Money': ([object()], []),
})
soul = _FakeSoulseek(youtube=youtube)
cfg = _FakeConfig({'download_source.stream_source': 'youtube'})
engine = _FakeMatchingEngine(match_for_query=[_FakeMatchResult()])
result = stream.stream_search_track(
track_name='Money (Remastered)', artist_name='Pink Floyd', album_name=None,
duration_ms=180000,
config_manager=cfg, soulseek_client=soul, matching_engine=engine,
run_async=_run_async,
)
assert result is not None
# 2 queries tried
assert len(youtube.calls) == 2
def test_stream_returns_none_when_no_matches():
youtube = _FakeStreamClient(results_per_query={
'Pink Floyd Money': ([object()], []),
})
soul = _FakeSoulseek(youtube=youtube)
cfg = _FakeConfig({'download_source.stream_source': 'youtube'})
engine = _FakeMatchingEngine(match_for_query=None)
result = stream.stream_search_track(
track_name='Money', artist_name='Pink Floyd', album_name=None,
duration_ms=180000,
config_manager=cfg, soulseek_client=soul, matching_engine=engine,
run_async=_run_async,
)
assert result is None
def test_stream_falls_back_to_default_soulseek_when_no_direct_client():
# Effective mode = 'youtube' (coerced from soulseek), but soul.youtube is None
# → code falls through to soulseek_client.search directly. Streaming-mode
# query gen → "Pink Floyd Money".
soul = _FakeSoulseek(results_per_query={'Pink Floyd Money': ([object()], [])})
cfg = _FakeConfig({
'download_source.stream_source': 'active',
'download_source.mode': 'soulseek',
})
engine = _FakeMatchingEngine(match_for_query=[_FakeMatchResult()])
result = stream.stream_search_track(
track_name='Money', artist_name='Pink Floyd', album_name=None,
duration_ms=180000,
config_manager=cfg, soulseek_client=soul, matching_engine=engine,
run_async=_run_async,
)
assert result is not None
assert 'Pink Floyd Money' in soul.search_calls
def test_stream_continues_past_per_query_exception():
class _BoomFirst(_FakeStreamClient):
def __init__(self):
super().__init__()
self._n = 0
async def search(self, query, timeout=15):
self.calls.append(query)
self._n += 1
if self._n == 1:
raise RuntimeError("boom")
return ([object()], [])
youtube = _BoomFirst()
soul = _FakeSoulseek(youtube=youtube)
cfg = _FakeConfig({'download_source.stream_source': 'youtube'})
engine = _FakeMatchingEngine(match_for_query=[_FakeMatchResult()])
result = stream.stream_search_track(
track_name='Money (Live)', artist_name='Pink Floyd', album_name=None,
duration_ms=180000,
config_manager=cfg, soulseek_client=soul, matching_engine=engine,
run_async=_run_async,
)
# First query raised, second succeeded
assert result is not None
assert len(youtube.calls) == 2

View file

@ -2671,102 +2671,24 @@ def add_cache_headers(response, cache_duration=300):
return response
_enhanced_search_cache = collections.OrderedDict()
_enhanced_search_cache_lock = threading.Lock()
_ENHANCED_SEARCH_CACHE_TTL = 600
_ENHANCED_SEARCH_CACHE_MAX_ENTRIES = 100
# Enhanced-search cache + helpers live in core/search/cache.py.
# Re-exported here so the existing call sites in this module still resolve.
from core.search.cache import (
get_cache_key as _get_enhanced_search_cache_key_impl,
get_cached_response as _get_cached_enhanced_search_response,
set_cached_response as _set_cached_enhanced_search_response,
)
from core.search.orchestrator import VALID_SOURCES as ENHANCED_SEARCH_VALID_SOURCES
def _get_enhanced_search_cache_key(query, requested_source=None):
"""Build a cache key that follows the current metadata/search configuration.
When an explicit `requested_source` is provided (single-source search), it is
included in the key so that results for different sources don't collide."""
normalized_query = (query or '').strip().lower()
try:
active_server = config_manager.get_active_media_server()
except Exception:
active_server = 'unknown'
try:
fallback_source = _get_metadata_fallback_source()
except Exception:
fallback_source = 'unknown'
try:
hydrabase_active = _is_hydrabase_active()
except Exception:
hydrabase_active = False
source_tag = (requested_source or '').strip().lower() or 'auto'
return (normalized_query, active_server, fallback_source, hydrabase_active, source_tag)
ENHANCED_SEARCH_VALID_SOURCES = (
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz',
)
def _resolve_enhanced_search_client(source_name):
"""Return (client, is_available) for a single requested metadata source.
Mirrors the client-resolution logic used by the /source/<source> endpoint so
that the `source` param on the main endpoint behaves consistently."""
if source_name == 'spotify':
if spotify_client and spotify_client.is_spotify_authenticated():
return spotify_client, True
return None, False
if source_name == 'itunes':
return _get_itunes_client(), True
if source_name == 'deezer':
return _get_deezer_client(), True
if source_name == 'discogs':
token = config_manager.get('discogs.token', '')
if not token:
return None, False
return _get_discogs_client(token), True
if source_name == 'hydrabase':
if hydrabase_client and hydrabase_client.is_connected():
return hydrabase_client, True
return None, False
if source_name == 'musicbrainz':
try:
from core.musicbrainz_search import MusicBrainzSearchClient
return MusicBrainzSearchClient(), True
except Exception as e:
logger.warning(f"MusicBrainz search client init failed: {e}")
return None, False
return None, False
def _get_cached_enhanced_search_response(cache_key):
"""Return a cached enhanced-search response if it is still fresh."""
now = time.time()
with _enhanced_search_cache_lock:
entry = _enhanced_search_cache.get(cache_key)
if not entry:
return None
if now - entry['timestamp'] < _ENHANCED_SEARCH_CACHE_TTL:
_enhanced_search_cache.move_to_end(cache_key)
return entry['data']
_enhanced_search_cache.pop(cache_key, None)
return None
def _set_cached_enhanced_search_response(cache_key, response_data):
"""Store an enhanced-search response in the short-lived in-memory cache."""
with _enhanced_search_cache_lock:
_enhanced_search_cache[cache_key] = {
'timestamp': time.time(),
'data': response_data,
}
_enhanced_search_cache.move_to_end(cache_key)
while len(_enhanced_search_cache) > _ENHANCED_SEARCH_CACHE_MAX_ENTRIES:
_enhanced_search_cache.popitem(last=False)
"""Thin wrapper that wires live config providers into the cache-key builder."""
return _get_enhanced_search_cache_key_impl(
query, requested_source,
active_server_provider=config_manager.get_active_media_server,
fallback_source_provider=_get_metadata_fallback_source,
hydrabase_active_provider=_is_hydrabase_active,
)
# --- Background Download Monitoring (GUI Parity) ---
class WebUIDownloadMonitor:
@ -9038,64 +8960,65 @@ def start_sync():
# Placeholder: simulates starting a sync
return jsonify({"success": True, "message": "Sync process started."})
# Search route bodies live in core/search/* — these routes are thin handlers.
from core.search import basic as _search_basic
from core.search import library_check as _search_library_check
from core.search import orchestrator as _search_orchestrator
from core.search import stream as _search_stream
def _build_search_deps():
"""Build the SearchDeps bundle from this module's globals on each request.
Constructed per-request so config / client state is always live.
"""
return _search_orchestrator.SearchDeps(
database=get_database(),
config_manager=config_manager,
spotify_client=spotify_client,
hydrabase_client=hydrabase_client,
hydrabase_worker=hydrabase_worker,
soulseek_client=soulseek_client,
fix_artist_image_url=fix_artist_image_url,
is_hydrabase_active=_is_hydrabase_active,
get_metadata_fallback_source=_get_metadata_fallback_source,
get_metadata_fallback_client=_get_metadata_fallback_client,
get_itunes_client=_get_itunes_client,
get_deezer_client=_get_deezer_client,
get_discogs_client=_get_discogs_client,
run_background_comparison=_run_background_comparison,
run_async=run_async,
dev_mode_enabled_provider=lambda: dev_mode_enabled,
)
@app.route('/api/search', methods=['POST'])
def search_music():
"""Real search using soulseek_client"""
"""Basic Soulseek file search."""
data = request.get_json()
query = data.get('query')
if not query:
return jsonify({"error": "No search query provided."}), 400
logger.info(f"Web UI Search initiated for: '{query}'")
# Add activity for search start
add_activity_item("", "Search Started", f"'{query}'", "Now")
try:
tracks, albums = run_async(soulseek_client.search(query))
# Convert to dictionaries for JSON response
processed_albums = []
for album in albums:
album_dict = album.__dict__.copy()
album_dict["tracks"] = [track.__dict__ for track in album.tracks]
album_dict["result_type"] = "album"
processed_albums.append(album_dict)
processed_tracks = []
for track in tracks:
track_dict = track.__dict__.copy()
track_dict["result_type"] = "track"
processed_tracks.append(track_dict)
# Sort by quality score
all_results = sorted(processed_albums + processed_tracks, key=lambda x: x.get('quality_score', 0), reverse=True)
# Add activity for search completion
total_results = len(all_results)
add_activity_item("", "Search Complete", f"'{query}' - {total_results} results", "Now")
return jsonify({"results": all_results})
results = _search_basic.run_basic_soulseek_search(query, soulseek_client, run_async)
add_activity_item("", "Search Complete", f"'{query}' - {len(results)} results", "Now")
return jsonify({"results": results})
except Exception as e:
logger.error(f"Search error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/enhanced-search', methods=['POST'])
def enhanced_search():
"""
Unified search across metadata sources and local database for enhanced search mode.
Returns categorized results: DB artists, source artists, albums, and tracks.
"""Unified metadata search across configured sources + local DB artists.
Fires parallel queries against all available sources (Spotify, iTunes, Deezer)
and returns results keyed by source, plus backward-compatible top-level keys
mapped from the primary source.
Optional JSON body param `source` (one of: auto, spotify, itunes, deezer,
discogs, hydrabase, musicbrainz). When set to a specific source, the endpoint
bypasses the primary-source fan-out and returns just that source's results
(plus the usual db_artists). `auto` and omitted behave identically current
multi-source fan-out.
Optional `source` body param ("spotify"|"itunes"|"deezer"|"discogs"|
"hydrabase"|"musicbrainz"|"auto"|"") forces a single-source search and
bypasses the fan-out. Otherwise picks a primary source per the user's
configuration and lists alternates for the frontend to fetch async.
"""
data = request.get_json()
query = data.get('query', '').strip()
@ -9105,285 +9028,42 @@ def enhanced_search():
if requested_source and requested_source not in ENHANCED_SEARCH_VALID_SOURCES:
return jsonify({"error": f"Unknown source: {requested_source}"}), 400
cache_key = _get_enhanced_search_cache_key(query, requested_source)
empty_source = {"artists": [], "albums": [], "tracks": [], "available": False}
if not query:
return jsonify({
"db_artists": [],
"spotify_artists": [],
"spotify_albums": [],
"spotify_tracks": [],
"sources": {},
"primary_source": "spotify",
"metadata_source": "spotify"
})
return jsonify(_search_orchestrator.empty_response())
cached_response = _get_cached_enhanced_search_response(cache_key)
if cached_response is not None:
cache_key = _get_enhanced_search_cache_key(query, requested_source)
cached = _get_cached_enhanced_search_response(cache_key)
if cached is not None:
logger.info(f"Enhanced search cache hit for: '{query}'")
return jsonify(cached_response)
return jsonify(cached)
logger.info(
f"Enhanced search initiated for: '{query}' "
f"(source={requested_source or 'auto'})"
)
logger.info(f"Enhanced search initiated for: '{query}' (source={requested_source or 'auto'})")
try:
# Search local database for artists (always)
database = get_database()
active_server = config_manager.get_active_media_server()
db_artists_objs = database.search_artists(query, limit=5, server_source=active_server)
db_artists = []
for artist in db_artists_objs:
image_url = None
if hasattr(artist, 'thumb_url') and artist.thumb_url:
image_url = fix_artist_image_url(artist.thumb_url)
db_artists.append({
"id": artist.id,
"name": artist.name,
"image_url": image_url
})
# Very short queries are usually broad enough that remote metadata searches
# just add latency without improving the result quality much. Keep them local.
if len(query) < 3:
short_source = requested_source or _get_metadata_fallback_source()
response_data = {
"db_artists": db_artists,
"spotify_artists": [],
"spotify_albums": [],
"spotify_tracks": [],
"metadata_source": short_source,
"primary_source": short_source,
"alternate_sources": [],
"sources": {},
}
_set_cached_enhanced_search_response(cache_key, response_data)
return jsonify(response_data)
# Explicit single-source search — bypass primary-source fan-out entirely.
if requested_source:
client, available = _resolve_enhanced_search_client(requested_source)
if not client:
response_data = {
"db_artists": db_artists,
"spotify_artists": [],
"spotify_albums": [],
"spotify_tracks": [],
"metadata_source": requested_source,
"primary_source": requested_source,
"alternate_sources": [],
"source_available": False,
}
_set_cached_enhanced_search_response(cache_key, response_data)
return jsonify(response_data)
try:
source_results = _enhanced_search_source(query, client, requested_source)
except Exception as e:
logger.warning(f"Single-source search ({requested_source}) failed: {e}")
source_results = {"artists": [], "albums": [], "tracks": [], "available": False}
logger.info(
f"Enhanced search [source={requested_source}] results: "
f"{len(db_artists)} DB, {len(source_results['artists'])} artists, "
f"{len(source_results['albums'])} albums, {len(source_results['tracks'])} tracks"
)
response_data = {
"db_artists": db_artists,
"spotify_artists": source_results["artists"],
"spotify_albums": source_results["albums"],
"spotify_tracks": source_results["tracks"],
"metadata_source": requested_source,
"primary_source": requested_source,
"alternate_sources": [],
"source_available": True,
}
_set_cached_enhanced_search_response(cache_key, response_data)
return jsonify(response_data)
# ── Determine primary source and search it synchronously ──
primary_source = "spotify"
primary_results = empty_source
if _is_hydrabase_active():
primary_source = "hydrabase"
try:
primary_results = _enhanced_search_source(query, hydrabase_client)
# Fire off background comparison
_run_background_comparison(query, hydrabase_counts={
'tracks': len(primary_results['tracks']),
'artists': len(primary_results['artists']),
'albums': len(primary_results['albums'])
})
except Exception as e:
logger.error(f"Hydrabase search failed: {e}")
primary_source = "spotify"
primary_results = empty_source
if primary_source != "hydrabase":
# Mirror to Hydrabase worker (fire-and-forget)
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'tracks')
hydrabase_worker.enqueue(query, 'albums')
hydrabase_worker.enqueue(query, 'artists')
# Search using the user's configured primary metadata source
fb_source = _get_metadata_fallback_source()
try:
primary_results = _enhanced_search_source(query, _get_metadata_fallback_client(), fb_source)
primary_source = fb_source
except Exception as e:
logger.debug(f"Primary source ({fb_source}) search failed: {e}")
# If primary source failed and it wasn't Spotify, try Spotify as fallback
if primary_results is empty_source and fb_source != 'spotify':
if spotify_client and spotify_client.is_spotify_authenticated():
try:
primary_results = _enhanced_search_source(query, spotify_client, "spotify")
primary_source = "spotify"
except Exception as e:
logger.debug(f"Spotify fallback search failed: {e}")
# Determine which alternate sources are available (for frontend to fetch async)
spotify_available = bool(spotify_client and spotify_client.is_spotify_authenticated())
hydrabase_available = bool(hydrabase_client and hydrabase_client.is_connected())
discogs_available = bool(config_manager.get('discogs.token', ''))
alternate_sources = []
if primary_source != 'spotify' and spotify_available:
alternate_sources.append('spotify')
if primary_source != 'itunes':
alternate_sources.append('itunes')
if primary_source != 'deezer':
alternate_sources.append('deezer')
if primary_source != 'discogs' and discogs_available:
alternate_sources.append('discogs')
if primary_source != 'hydrabase' and hydrabase_available:
alternate_sources.append('hydrabase')
# YouTube music videos always available (uses yt-dlp, no auth needed)
alternate_sources.append('youtube_videos')
# MusicBrainz always available (public API, no auth)
alternate_sources.append('musicbrainz')
logger.info(f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, "
f"{len(primary_results['artists'])} artists, {len(primary_results['albums'])} albums, "
f"{len(primary_results['tracks'])} tracks | "
f"Alt sources available: {alternate_sources}")
response_data = {
# Backward compat — same shape as before
"db_artists": db_artists,
"spotify_artists": primary_results["artists"],
"spotify_albums": primary_results["albums"],
"spotify_tracks": primary_results["tracks"],
"metadata_source": primary_source,
# New multi-source data
"primary_source": primary_source,
"alternate_sources": alternate_sources,
}
deps = _build_search_deps()
response_data = _search_orchestrator.run_enhanced_search(query, requested_source, deps)
_set_cached_enhanced_search_response(cache_key, response_data)
return jsonify(response_data)
except Exception as e:
logger.error(f"Enhanced search error: {e}")
return jsonify({"error": str(e)}), 500
def _search_metadata_source_kind(client, query, kind, source_name=None):
"""Search one result type from a metadata source and normalize it."""
source_label = source_name or type(client).__name__
if kind == "artists":
artists = []
try:
artist_objs = client.search_artists(query, limit=10)
for artist in artist_objs:
artists.append({
"id": artist.id,
"name": artist.name,
"image_url": artist.image_url,
"external_urls": artist.external_urls or {},
})
except Exception as e:
logger.debug(f"Artist search failed for {source_label}: {e}")
return artists
if kind == "albums":
albums = []
try:
album_objs = client.search_albums(query, limit=10)
for album in album_objs:
artist_name = ', '.join(album.artists) if album.artists else 'Unknown Artist'
albums.append({
"id": album.id,
"name": album.name,
"artist": artist_name,
"image_url": album.image_url,
"release_date": album.release_date,
"total_tracks": album.total_tracks,
"album_type": album.album_type,
"external_urls": album.external_urls or {},
})
except Exception as e:
logger.warning(f"Album search failed for {source_label}: {e}", exc_info=True)
return albums
if kind == "tracks":
tracks = []
try:
track_objs = client.search_tracks(query, limit=10)
for track in track_objs:
artist_name = ', '.join(track.artists) if track.artists else 'Unknown Artist'
tracks.append({
"id": track.id,
"name": track.name,
"artist": artist_name,
"album": track.album,
"duration_ms": track.duration_ms,
"image_url": track.image_url,
"release_date": track.release_date,
"external_urls": track.external_urls or {},
})
except Exception as e:
logger.warning(f"Track search failed for {source_label}: {e}", exc_info=True)
return tracks
raise ValueError(f"Unknown metadata search kind: {kind}")
def _enhanced_search_source(query, client, source_name=None):
"""Search a single metadata source and return normalized results dict."""
results = {"artists": [], "albums": [], "tracks": []}
with ThreadPoolExecutor(max_workers=3) as executor:
futures = {
executor.submit(_search_metadata_source_kind, client, query, "artists", source_name): "artists",
executor.submit(_search_metadata_source_kind, client, query, "albums", source_name): "albums",
executor.submit(_search_metadata_source_kind, client, query, "tracks", source_name): "tracks",
}
for future in as_completed(futures):
kind = futures[future]
try:
results[kind] = future.result()
except Exception as e:
logger.warning(f"{kind.title()} search failed for {source_name or type(client).__name__}: {e}", exc_info=True)
results[kind] = []
return {"artists": results["artists"], "albums": results["albums"], "tracks": results["tracks"], "available": True}
@app.route('/api/enhanced-search/source/<source_name>', methods=['POST'])
def enhanced_search_source(source_name):
"""Fetch search results from a specific alternate metadata source.
"""Streaming NDJSON search for one alternate metadata source.
Streams NDJSON one line per search type (artists, albums, tracks) as each completes.
This prevents slow sources (iTunes with 3s rate limit) from blocking the UI.
Falls back to single JSON response if streaming not supported.
One line per search-kind (artists, albums, tracks) as it completes,
plus a final `{"type":"done"}` marker. `youtube_videos` yields a single
`videos` chunk via yt-dlp instead.
When the requested source's client isn't available (Spotify unauthed,
Discogs missing token, Hydrabase disconnected, MusicBrainz import
failure, soulseek_client.youtube missing), returns plain JSON
`{"artists":[],"albums":[],"tracks":[],"available":false}` to match
the original endpoint contract.
"""
if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos', 'musicbrainz'):
if source_name not in _search_orchestrator.VALID_STREAM_SOURCES:
return jsonify({"error": f"Unknown source: {source_name}"}), 400
data = request.get_json()
@ -9391,87 +9071,29 @@ def enhanced_search_source(source_name):
if not query:
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
# YouTube music videos — separate flow from metadata sources
deps = _build_search_deps()
if source_name == 'youtube_videos':
if not soulseek_client or not hasattr(soulseek_client, 'youtube') or not soulseek_client.youtube:
youtube_client = _search_orchestrator.resolve_youtube_videos_client(deps)
if youtube_client is None:
return jsonify({"videos": [], "available": False})
try:
def generate_videos():
try:
# Search YouTube via yt-dlp
video_query = f"{query} official music video"
results = run_async(soulseek_client.youtube.search_videos(video_query, max_results=20))
videos = []
for v in (results or []):
videos.append({
'video_id': v.video_id,
'title': v.title,
'channel': v.channel,
'duration': v.duration,
'thumbnail': v.thumbnail,
'url': v.url,
'view_count': v.view_count,
'upload_date': v.upload_date,
})
yield json.dumps({"type": "videos", "data": videos}) + "\n"
except Exception as e:
logger.error(f"YouTube music video search failed: {e}")
yield json.dumps({"type": "videos", "data": []}) + "\n"
yield json.dumps({"type": "done"}) + "\n"
return app.response_class(generate_videos(), mimetype='application/x-ndjson')
return app.response_class(
_search_orchestrator.stream_youtube_videos(query, youtube_client, run_async),
mimetype='application/x-ndjson',
)
except Exception as e:
return jsonify({"error": str(e)}), 500
try:
client = None
if source_name == 'spotify':
if spotify_client and spotify_client.is_spotify_authenticated():
client = spotify_client
else:
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
elif source_name == 'itunes':
client = _get_itunes_client()
elif source_name == 'deezer':
client = _get_deezer_client()
elif source_name == 'discogs':
token = config_manager.get('discogs.token', '')
if token:
client = _get_discogs_client(token)
else:
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
elif source_name == 'hydrabase':
if hydrabase_client and hydrabase_client.is_connected():
client = hydrabase_client
else:
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
elif source_name == 'musicbrainz':
try:
from core.musicbrainz_search import MusicBrainzSearchClient
client = MusicBrainzSearchClient()
except Exception as e:
logger.warning(f"MusicBrainz search client init failed: {e}")
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
client, _available = _search_orchestrator.resolve_client(source_name, deps)
if client is None:
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
def generate():
# Stream each search type as it completes
with ThreadPoolExecutor(max_workers=3) as executor:
futures = {
executor.submit(_search_metadata_source_kind, client, query, "artists", source_name): "artists",
executor.submit(_search_metadata_source_kind, client, query, "albums", source_name): "albums",
executor.submit(_search_metadata_source_kind, client, query, "tracks", source_name): "tracks",
}
for future in as_completed(futures):
kind = futures[future]
try:
payload = future.result()
except Exception as e:
logger.warning(f"{kind.title()} search failed for {source_name}: {e}", exc_info=True)
payload = []
yield json.dumps({"type": kind, "data": payload}) + "\n"
yield json.dumps({"type": "done"}) + "\n"
return app.response_class(generate(), mimetype='application/x-ndjson')
return app.response_class(
_search_orchestrator.stream_metadata_source(source_name, query, client),
mimetype='application/x-ndjson',
)
except Exception as e:
logger.error(f"Enhanced search source ({source_name}) error: {e}")
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
@ -9479,114 +9101,30 @@ def enhanced_search_source(source_name):
@app.route('/api/enhanced-search/library-check', methods=['POST'])
def enhanced_search_library_check():
"""Batch check which albums/tracks from search results exist in the library.
Called async after search results render doesn't block the search."""
"""Batch-check which albums/tracks from search results are already in the library.
Called async after search renders so badges fade in without blocking results.
"""
try:
data = request.get_json() or {}
albums = data.get('albums', [])
tracks = data.get('tracks', [])
database = get_database()
conn = database._get_connection()
try:
cursor = conn.cursor()
# Build lookup sets from library — two fast queries
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 = {}
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]}
# Build wishlist lookup set — track name|||artist
wishlist_keys = set()
try:
profile_id = get_current_profile_id()
cursor.execute("SELECT spotify_data FROM wishlist_tracks WHERE profile_id = ?", (profile_id,))
for wr in cursor.fetchall():
try:
wd = json.loads(wr[0]) if isinstance(wr[0], str) else {}
wname = (wd.get('name') or '').lower()
wartists = wd.get('artists', [])
if wartists:
wa = wartists[0].get('name', '') if isinstance(wartists[0], dict) else str(wartists[0])
else:
wa = ''
if wname:
wishlist_keys.add(wname + '|||' + wa.lower().strip())
except Exception:
pass
except Exception:
# profile_id column may not exist on older DBs — try without it
try:
cursor.execute("SELECT spotify_data FROM wishlist_tracks")
for wr in cursor.fetchall():
try:
wd = json.loads(wr[0]) if isinstance(wr[0], str) else {}
wname = (wd.get('name') or '').lower()
wartists = wd.get('artists', [])
if wartists:
wa = wartists[0].get('name', '') if isinstance(wartists[0], dict) else str(wartists[0])
else:
wa = ''
if wname:
wishlist_keys.add(wname + '|||' + wa.lower().strip())
except Exception:
pass
except Exception:
pass
# O(1) lookups per item
album_results = []
for a in albums:
key = (a.get('name', '').lower() + '|||' + a.get('artist', '').split(',')[0].strip().lower())
album_results.append(key in owned_albums)
# Resolve Plex thumb URLs (relative paths need base URL + token)
_plex_base = ''
_plex_token = ''
if plex_client and plex_client.server:
_plex_base = getattr(plex_client.server, '_baseurl', '') or ''
_plex_token = getattr(plex_client.server, '_token', '') or ''
if not _plex_base:
_pc = config_manager.get_plex_config()
_plex_base = (_pc.get('base_url', '') or '').rstrip('/')
_plex_token = _plex_token or _pc.get('token', '')
track_results = []
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:
# Resolve thumb URL
thumb = match.get('album_thumb_url') or ''
if thumb and not thumb.startswith('http') and _plex_base and thumb.startswith('/'):
thumb = f"{_plex_base}{thumb}?X-Plex-Token={_plex_token}" if _plex_token else f"{_plex_base}{thumb}"
match['album_thumb_url'] = thumb
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 jsonify({'albums': album_results, 'tracks': track_results})
result = _search_library_check.check_library_presence(
database=get_database(),
plex_client=plex_client,
config_manager=config_manager,
profile_id=get_current_profile_id(),
albums=data.get('albums', []),
tracks=data.get('tracks', []),
)
return jsonify(result)
except Exception as e:
logger.debug(f"Library check error: {e}")
return jsonify({'albums': [], 'tracks': []})
@app.route('/api/enhanced-search/stream-track', methods=['POST'])
def stream_enhanced_search_track():
"""
Quick slskd search for a single track to stream from enhanced search.
Uses multi-query retry strategy to work around Soulseek keyword filtering.
Returns the best matching result from Soulseek.
"""Single-track preview search — finds the best Soulseek/stream-source match.
Uses a multi-query retry strategy to work around Soulseek keyword filtering.
"""
data = request.get_json()
track_name = data.get('track_name', '').strip()
@ -9600,145 +9138,22 @@ def stream_enhanced_search_track():
logger.info(f"Enhanced search stream request: '{track_name}' by '{artist_name}'")
try:
# Create a temporary SpotifyTrack-like object for the matching engine
temp_track = type('TempTrack', (), {
'name': track_name,
'artists': [artist_name],
'album': album_name if album_name else None,
'duration_ms': duration_ms
})()
# Determine effective stream source
# stream_source: "youtube" (default, instant) or "active" (use download source)
stream_source = config_manager.get('download_source.stream_source', 'youtube')
download_mode = config_manager.get('download_source.mode', 'hybrid')
# Resolve the effective mode for streaming
if stream_source == 'youtube':
effective_mode = 'youtube'
else:
# "active" mode — use download source, but fall back to YouTube if Soulseek
_hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
_hybrid_first = _hybrid_order[0] if _hybrid_order else config_manager.get('download_source.hybrid_primary', 'hifi')
if download_mode == 'soulseek' or (download_mode == 'hybrid' and _hybrid_first == 'soulseek'):
effective_mode = 'youtube' # Soulseek is too slow for streaming preview
logger.info("Stream source is 'active' but primary is Soulseek — falling back to YouTube")
elif download_mode == 'hybrid':
effective_mode = _hybrid_first
else:
effective_mode = download_mode
logger.info(f"Stream source: {stream_source} → effective: {effective_mode}")
# Generate search queries based on effective stream mode
search_queries = []
import re
if effective_mode in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'):
# Streaming sources: Include artist for better context
if artist_name and track_name:
search_queries.append(f"{artist_name} {track_name}".strip())
# Fallback: Artist + Cleaned track (remove parentheses/brackets)
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
if cleaned_name and cleaned_name.lower() != track_name.lower():
search_queries.append(f"{artist_name} {cleaned_name}".strip())
logger.info(f"{effective_mode.title()} stream: Searching with artist + track name: {search_queries}")
else:
# Soulseek mode: Track name only to avoid keyword filtering
if track_name.strip():
search_queries.append(track_name.strip())
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
if cleaned_name and cleaned_name.lower() != track_name.lower():
search_queries.append(cleaned_name.strip())
logger.info(f"Soulseek mode: Searching by track name only (will match with artist): {search_queries}")
# Remove duplicates while preserving order
unique_queries = []
seen = set()
for query in search_queries:
if query and query.lower() not in seen:
unique_queries.append(query)
seen.add(query.lower())
search_queries = unique_queries
# Select the search client based on effective stream mode
_stream_clients = {
'youtube': soulseek_client.youtube,
'tidal': soulseek_client.tidal,
'qobuz': soulseek_client.qobuz,
'hifi': soulseek_client.hifi,
'deezer_dl': soulseek_client.deezer_dl,
'lidarr': soulseek_client.lidarr,
}
stream_client = _stream_clients.get(effective_mode)
use_direct_client = stream_client is not None
# Try queries sequentially until we find a good match
for query_index, query in enumerate(search_queries):
logger.info(f"Query {query_index + 1}/{len(search_queries)}: '{query}'")
try:
# Search using the stream source client (not the download source)
if use_direct_client:
tracks_result, _ = run_async(stream_client.search(query, timeout=15))
else:
tracks_result, _ = run_async(soulseek_client.search(query, timeout=15))
if tracks_result:
logger.info(f"Found {len(tracks_result)} results for query: '{query}'")
# Use matching engine to find best match
_max_q = config_manager.get('soulseek.max_peer_queue', 0) or 0
best_matches = matching_engine.find_best_slskd_matches_enhanced(temp_track, tracks_result, max_peer_queue=_max_q)
if best_matches:
# Get the first (best) result
best_result = best_matches[0]
# Convert to dictionary for JSON response (same format as basic search)
result_dict = {
"username": best_result.username,
"filename": best_result.filename,
"size": best_result.size,
"bitrate": best_result.bitrate,
"duration": best_result.duration,
"quality": best_result.quality,
"free_upload_slots": best_result.free_upload_slots,
"upload_speed": best_result.upload_speed,
"queue_length": best_result.queue_length,
"result_type": "track"
}
logger.info(f"Returning best match from query '{query}': {best_result.filename} ({best_result.quality})")
return jsonify({
"success": True,
"result": result_dict
})
else:
logger.info(f"No suitable matches for query '{query}', trying next query...")
else:
logger.info(f"No results for query '{query}', trying next query...")
except Exception as search_error:
logger.warning(f"Error searching with query '{query}': {search_error}")
continue
# If we get here, none of the queries found a suitable match
logger.warning(f"No suitable matches found after trying {len(search_queries)} queries")
return jsonify({
"success": False,
"error": "No suitable track found after trying multiple search strategies"
}), 404
result = _search_stream.stream_search_track(
track_name=track_name,
artist_name=artist_name,
album_name=album_name,
duration_ms=duration_ms,
config_manager=config_manager,
soulseek_client=soulseek_client,
matching_engine=matching_engine,
run_async=run_async,
)
if result is None:
return jsonify({
"success": False,
"error": "No suitable track found after trying multiple search strategies",
}), 404
return jsonify({"success": True, "result": result})
except Exception as e:
logger.error(f"Error streaming enhanced search track: {e}", exc_info=True)
return jsonify({"error": str(e)}), 500

View file

@ -3450,6 +3450,7 @@ const WHATS_NEW = {
{ title: 'Browser Caching for Static Assets + Discover Pages', desc: 'static assets (js/css/icons) now get a 1-year browser cache instead of revalidating on every page load. safe because the existing ?v=static_v cache-bust query changes every server restart, so deploys still ship live. discover pages (hero, similar artists, recent releases, deep cuts, etc.) now cache 5 minutes browser-side so toggling between sections doesn\'t re-fetch everything. faster repeat loads, fewer round-trips.', page: 'discover' },
{ title: 'Service Worker for Cover Art + Installable PWA', desc: 'cover art used to re-fetch from the cdn on every library / discover page visit. now a service worker caches images locally — second visit serves art instantly from disk, no network hit. also added a pwa manifest so soulsync can be installed to home screen / desktop as a standalone app (chrome / edge / safari → install soulsync). cache versioned so future strategy changes invalidate cleanly.' },
{ title: 'Stats Endpoints Lifted to core/stats', desc: 'internal — moved /api/stats/* and /api/listening-stats/* logic out of web_server.py into core/stats/queries.py with full test coverage. no behavior change. step toward breaking up the web_server.py monolith.' },
{ title: 'Search Endpoints Lifted to core/search', desc: 'internal — moved /api/search and /api/enhanced-search/* logic into core/search/ (cache, sources, library_check, stream, basic, orchestrator). 612 fewer lines in web_server.py, 94 new tests. no behavior change.' },
],
'2.4.0': [
// --- April 26, 2026 — Search & Artists unification + reorganize queue ---

View file

@ -72,8 +72,9 @@ const SOURCE_LABELS = {
tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube',
},
soulseek: {
// No canonical brand logo available — stick with a basic music glyph.
text: 'Soulseek', icon: '🎼',
// Routes through /api/search (raw slskd file results) — historically
// called "Basic Search" in the UI before the source picker landed.
text: 'Basic Search', icon: '🎼',
tabClass: 'enh-tab-soulseek', badgeClass: 'enh-badge-soulseek',
},
};