Routes moved to thin parse-args/jsonify handlers; logic now lives in six focused modules under core/search/. 720 lines deleted from web_server.py; 109 added back as wrappers; ~700 lines of new core code plus ~700 lines of tests. Module split: - core/search/cache.py — TTL+LRU cache for enhanced-search responses, keyed by (query, active_server, fallback_source, hydrabase_active, source_tag) so config changes don't poison stale entries. - core/search/sources.py — per-kind metadata search (artists/albums/ tracks) and the multi-kind ThreadPoolExecutor that fans them out. - core/search/library_check.py — library + wishlist presence check with Plex thumb URL resolution; profile-aware wishlist with legacy fallback for older DBs missing the profile_id column. - core/search/stream.py — single-track preview search; effective stream mode resolution, query-variant generation, retry walk, matching engine integration. - core/search/basic.py — flat Soulseek file search, quality-sorted. - core/search/orchestrator.py — main enhanced-search dispatch (short-query fast path, single-source bypass, hydrabase-primary fan out, alternate source list builder), NDJSON streaming generator for /source/<src>, and the SearchDeps dataclass that bundles the cross-cutting deps. Routes pass clients (spotify, hydrabase, hydrabase_worker, soulseek) and helpers (config_manager, fix_artist_image_url, _is_hydrabase_active, _get_metadata_fallback_*, _run_background_ comparison, run_async, dev_mode_enabled_provider) into core/search via a SearchDeps bundle built per-request. fix_artist_image_url stays in web_server.py because it touches 31 other call sites. Behavior preserved 1:1: - Same response shapes (db_artists, spotify_artists, spotify_albums, spotify_tracks, primary_source, metadata_source, alternate_sources, source_available) - Same NDJSON line ordering (artists/albums/tracks as they finish, plus done marker) - Same per-kind exception swallowing - Same hydrabase-worker mirror on dev mode - Same cache key shape (5-tuple) and TTL/LRU semantics - Same stream-track effective-mode resolution including the Soulseek-coerce-to-YouTube edge case - Same library-check Plex thumb URL rewriting and wishlist fallback for older DBs Tests: 94 new (cache TTL/LRU/key, sources happy/partial/all-fail, library presence with library + wishlist + thumbs, stream effective mode + query gen + retry, orchestrator client resolution + short query + single source + fan-out alternates + hydrabase primary + NDJSON drain). Full suite: 788 passing (was 694). Ruff clean.
160 lines
6 KiB
Python
160 lines
6 KiB
Python
"""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': getattr(soulseek_client, 'youtube', None),
|
|
'tidal': getattr(soulseek_client, 'tidal', None),
|
|
'qobuz': getattr(soulseek_client, 'qobuz', None),
|
|
'hifi': getattr(soulseek_client, 'hifi', None),
|
|
'deezer_dl': getattr(soulseek_client, 'deezer_dl', None),
|
|
'lidarr': getattr(soulseek_client, 'lidarr', None),
|
|
}
|
|
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
|