soulsync/core/search/basic.py
Broque Thomas 7145368d42 Basic search: visual overhaul + per-source picker in hybrid mode
Two things in this commit. Functional download / matched-download
behaviour is untouched — same JS handlers, same routes for the
download actions, same album-expand interaction.

VISUAL REDESIGN
- Glass search-bar card with accent radial wash + focus ring + pill
  primary search button
- Source chip row above the search bar (see below)
- Always-visible compact filter pill row (Type / Format / Sort) —
  pills carry both ``bs-filter-pill`` (new visual) and ``filter-btn``
  (legacy class for ``resetFilters`` + ``applyFiltersAndSort`` in
  wishlist-tools.js to keep working)
- Accent-tinted status pill matching the dashboard / auto-sync look
- Album result cards: glass card with accent left-edge stripe,
  52px brand-tinted cover icon, chevron expand indicator, pill
  action buttons (Download / Matched Album), accent glow on hover
- Track result cards: glass row with accent stripe, 44px icon,
  pill action buttons (Stream / Download / Matched Download)
- Multi-disc separators inside expanded album track lists styled
  with the accent treatment
- Responsive: action button columns stack vertically below 900px

New CSS lives in a self-contained ``webui/static/basic-search-v2.css``
sheet linked from index.html. Selectors are scoped to
``#basic-search-section`` for any class that already exists in
style.css (``.album-result-card``, ``.album-icon``, ``.track-*``,
etc.); the new ``bs-*`` prefixed classes for the search bar /
filters / source row / status are unscoped because they only exist
in the new markup. ``!important`` is used on the card-level rules
to defeat the original unscoped ``.album-result-card`` etc. rules
in style.css that would otherwise leak heavyweight padding /
box-shadow / 56px icon styles into the new design.

Also removed ``overflow: hidden`` from the original
``.album-result-card`` and ``.track-result-card`` rules in style.css
— those two classes only render in ``downloads.js`` basic search
results (verified via grep, two render sites only), so the
removal can't impact any other UI.

SOURCE PICKER (hybrid mode)
- New ``GET /api/search/sources`` endpoint returns the list of
  active sources from the orchestrator's chain (or the single
  active source in single-source mode).
- Frontend renders a chip row above the search bar. Click a chip
  to target that source for the next search; the chip's brand
  accent fills.
- In single-source mode the lone chip is rendered as a dashed-
  border label so the user always knows what they're searching
  but can't accidentally try to switch to sources that aren't
  configured.
- ``/api/search`` accepts an optional ``source`` body param. When
  set, ``core/search/basic.py:run_basic_search`` resolves the
  client directly via ``orchestrator.client(source)`` and calls
  its ``.search()`` instead of going through the hybrid chain.
- Backwards compatible: omitting ``source`` falls through to the
  original ``orchestrator.search()`` call exactly as before.
  Unknown source names also fall back to the default — typo
  protection.

TESTS (5 new + 6 pre-existing = 11 total in test_search_basic.py)
- source param routes to specific client, NOT orchestrator chain
- no source param preserves original orchestrator-default behaviour
- unknown source name falls back to orchestrator default
- ``run_basic_soulseek_search`` backwards-compat alias preserved
- source-targeted path serialises albums + tracks correctly

101 search-suite tests pass.
2026-05-28 10:22:07 -07:00

79 lines
2.8 KiB
Python

"""Basic download-source file search — flat list of file results sorted by quality.
Used by the basic search UI on the Search page and by ``/api/search``.
``run_basic_search`` replaced ``run_basic_soulseek_search`` so the caller
can target any active download source (not just slskd). The old name is
kept as a thin alias for backwards compat with any callers outside this
module.
"""
from __future__ import annotations
import logging
from typing import Callable, Optional
logger = logging.getLogger(__name__)
def run_basic_search(
query: str,
download_orchestrator,
run_async: Callable,
*,
source: Optional[str] = None,
) -> list[dict]:
"""Search ``source`` (or the active/first hybrid source) for ``query``.
Returns dicts with ``result_type`` set to ``"album"`` or ``"track"``
and sorted by ``quality_score`` descending. Empty list on any failure.
Parameters
----------
source:
Optional source name to override the orchestrator's default selection.
Must be a canonical name from ``DownloadPluginRegistry`` (e.g.
``"soulseek"``, ``"tidal"``, ``"qobuz"``). When ``None``, behaviour
is unchanged from before: orchestrator.search() picks the active
source (single mode) or the first in chain (hybrid).
"""
if source and download_orchestrator:
# Target a specific source: resolve the client and call search()
# directly instead of going through the orchestrator chain.
try:
client = download_orchestrator.client(source)
except Exception as exc:
logger.warning("basic search: could not resolve client for %r: %s", source, exc)
client = None
if client is None:
logger.warning("basic search: no client for source %r — falling back to orchestrator", source)
tracks, albums = run_async(download_orchestrator.search(query))
else:
logger.info("basic search: targeting %r for %r", source, query)
tracks, albums = run_async(client.search(query))
else:
tracks, albums = run_async(download_orchestrator.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,
)
# Backwards-compat alias for any callers that haven't been updated yet.
run_basic_soulseek_search = run_basic_search