Merge pull request #391 from Nezreka/refactor/lift-search-to-core
Refactor/lift search to core
This commit is contained in:
commit
1f2c7ebbd3
17 changed files with 2514 additions and 710 deletions
8
core/search/__init__.py
Normal file
8
core/search/__init__.py
Normal 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
44
core/search/basic.py
Normal 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
108
core/search/cache.py
Normal 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()
|
||||
158
core/search/library_check.py
Normal file
158
core/search/library_check.py
Normal 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
347
core/search/orchestrator.py
Normal 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
113
core/search/sources.py
Normal 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
160
core/search/stream.py
Normal 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
0
tests/search/__init__.py
Normal file
98
tests/search/test_search_basic.py
Normal file
98
tests/search/test_search_basic.py
Normal 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'
|
||||
133
tests/search/test_search_cache.py
Normal file
133
tests/search/test_search_cache.py
Normal 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
|
||||
282
tests/search/test_search_library_check.py
Normal file
282
tests/search/test_search_library_check.py
Normal 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
|
||||
502
tests/search/test_search_orchestrator.py
Normal file
502
tests/search/test_search_orchestrator.py
Normal 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'}
|
||||
174
tests/search/test_search_sources.py
Normal file
174
tests/search/test_search_sources.py
Normal 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}
|
||||
260
tests/search/test_search_stream.py
Normal file
260
tests/search/test_search_stream.py
Normal 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
|
||||
831
web_server.py
831
web_server.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue