diff --git a/core/search/basic.py b/core/search/basic.py index 7c70eb29..1fce2567 100644 --- a/core/search/basic.py +++ b/core/search/basic.py @@ -1,28 +1,59 @@ -"""Basic Soulseek file search — flat list of file results sorted by quality. +"""Basic download-source 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. +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 +from typing import Callable, Optional logger = logging.getLogger(__name__) -def run_basic_soulseek_search( +def run_basic_search( query: str, download_orchestrator, run_async: Callable, + *, + source: Optional[str] = None, ) -> list[dict]: - """Search Soulseek for `query`, normalize albums + tracks to one sorted list. + """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 (caller logs). + 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). """ - tracks, albums = run_async(download_orchestrator.search(query)) + 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: @@ -42,3 +73,7 @@ def run_basic_soulseek_search( 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 diff --git a/tests/search/test_search_basic.py b/tests/search/test_search_basic.py index 0e1d9d79..bc0def37 100644 --- a/tests/search/test_search_basic.py +++ b/tests/search/test_search_basic.py @@ -96,3 +96,123 @@ def test_missing_quality_score_treated_as_zero(): 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' + + +# ── Source-targeted search (new in basic-search redesign) ────────────── + + +class _FakeOrchestratorMulti: + """Orchestrator stand-in with per-source clients. + + ``search()`` is the default orchestrator path (single-source mode or + first hybrid source). ``client(name)`` returns the per-source client + so a source-targeted basic search can call ``.search()`` directly on + the specific source rather than going through the chain. + """ + + def __init__(self, default_results, per_source_results, fail_unknown=False): + self._default = default_results + self._sources = per_source_results + self._fail_unknown = fail_unknown + self.default_search_calls = 0 + self.per_source_calls = {} + + async def search(self, query, timeout=None, progress_callback=None, exclude_sources=None): + self.default_search_calls += 1 + return self._default + + def client(self, name): + if name not in self._sources: + if self._fail_unknown: + return None + return None + plugin = _FakeSoulseek(tracks=self._sources[name][0], albums=self._sources[name][1]) + # Record which sources got asked for. + self.per_source_calls.setdefault(name, 0) + self.per_source_calls[name] += 1 + return plugin + + +def test_source_param_routes_to_specific_client(): + """``source='tidal'`` calls the Tidal client directly, NOT the + orchestrator's chain. Ensures the per-source basic search bypasses + the hybrid-first selection so users can target any active source.""" + tidal_track = _SearchTrack('From Tidal', 0.9, username='tidal') + soul_track = _SearchTrack('From Soulseek', 0.5, username='peer') + + orch = _FakeOrchestratorMulti( + default_results=([soul_track], []), + per_source_results={ + 'tidal': ([tidal_track], []), + 'soulseek': ([soul_track], []), + }, + ) + result = basic.run_basic_search('q', orch, _sync_run_async, source='tidal') + + # Tidal result returned, NOT soulseek result. + assert len(result) == 1 + assert result[0]['name'] == 'From Tidal' + # Orchestrator default chain NOT consulted. + assert orch.default_search_calls == 0 + # Tidal client was called exactly once. + assert orch.per_source_calls.get('tidal') == 1 + + +def test_no_source_param_falls_through_to_orchestrator_default(): + """When ``source`` is omitted, behaviour is identical to pre-redesign: + orchestrator.search() is called and routes to the configured source + (single-source mode) or first hybrid source. Preserves the existing + contract for callers that don't opt in to per-source targeting.""" + track = _SearchTrack('Default', 0.7) + orch = _FakeOrchestratorMulti( + default_results=([track], []), + per_source_results={'tidal': ([], [])}, + ) + result = basic.run_basic_search('q', orch, _sync_run_async) + + assert result[0]['name'] == 'Default' + assert orch.default_search_calls == 1 + assert orch.per_source_calls == {} + + +def test_unknown_source_falls_back_to_orchestrator(): + """Bogus source name (e.g. user-edited config with a typo) falls + through to the orchestrator default rather than returning an empty + list silently. Logged as a warning so misconfiguration is visible.""" + track = _SearchTrack('Default', 0.7) + orch = _FakeOrchestratorMulti( + default_results=([track], []), + per_source_results={'tidal': ([], [])}, + ) + result = basic.run_basic_search('q', orch, _sync_run_async, source='nonexistent') + + assert result[0]['name'] == 'Default' + assert orch.default_search_calls == 1 + + +def test_backwards_compat_alias_still_works(): + """``run_basic_soulseek_search`` is the legacy name; any external + caller that hasn't been updated must keep working. Aliased to + ``run_basic_search`` so both call the same code path.""" + track = _SearchTrack('Compat', 0.5) + client = _FakeSoulseek(tracks=[track]) + result = basic.run_basic_soulseek_search('q', client, _sync_run_async) + assert result[0]['name'] == 'Compat' + assert basic.run_basic_soulseek_search is basic.run_basic_search + + +def test_source_targeted_search_serialises_albums_with_tracks(): + """Source-targeted path goes through the same normaliser as the + default path, so albums returned via a specific source still get + their tracks serialised + ``result_type='album'`` tagged.""" + inner = _SearchTrack('inner', 0.5, filename='in.mp3') + album = _SearchAlbum('TidalAlbum', 0.9, tracks=[inner], username='tidal') + orch = _FakeOrchestratorMulti( + default_results=([], []), + per_source_results={'tidal': ([], [album])}, + ) + + result = basic.run_basic_search('q', orch, _sync_run_async, source='tidal') + + assert result[0]['result_type'] == 'album' + assert result[0]['tracks'][0]['name'] == 'inner' diff --git a/web_server.py b/web_server.py index 450c0ee6..51c4e844 100644 --- a/web_server.py +++ b/web_server.py @@ -5536,19 +5536,50 @@ def _build_search_deps(): ) +@app.route('/api/search/sources', methods=['GET']) +def search_sources(): + """Return the list of active download sources available for basic search. + + In single-source mode returns that one source. In hybrid mode returns + every source in the configured chain so the frontend can render a + source-picker chip row and let the user search specific sources. + + Response shape: ``{"mode": "hybrid"|, "sources": [{"name": str, + "display_name": str}]}`` + """ + if not download_orchestrator: + return jsonify({"mode": "soulseek", "sources": [{"name": "soulseek", "display_name": "Soulseek"}]}) + mode = download_orchestrator.mode + if mode == 'hybrid': + chain = download_orchestrator._resolve_source_chain() + sources = [ + {"name": s, "display_name": download_orchestrator.registry.display_name(s)} + for s in chain + ] + else: + sources = [{"name": mode, "display_name": download_orchestrator.registry.display_name(mode)}] + return jsonify({"mode": mode, "sources": sources}) + + @app.route('/api/search', methods=['POST']) def search_music(): - """Basic Soulseek file search.""" + """Basic download-source file search. + + Accepts an optional ``source`` body param to target a specific source + in hybrid mode (e.g. ``"soulseek"``, ``"tidal"``). When omitted, uses + the active source (single-source mode) or the first hybrid source. + """ 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}'") + requested_source = (data.get('source') or '').strip().lower() or None + logger.info(f"Web UI Search initiated for: '{query}'" + (f" (source={requested_source})" if requested_source else "")) add_activity_item("", "Search Started", f"'{query}'", "Now") try: - results = _search_basic.run_basic_soulseek_search(query, download_orchestrator, run_async) + results = _search_basic.run_basic_search(query, download_orchestrator, run_async, source=requested_source) add_activity_item("", "Search Complete", f"'{query}' - {len(results)} results", "Now") return jsonify({"results": results}) except Exception as e: diff --git a/webui/index.html b/webui/index.html index 845a8a03..c01545e8 100644 --- a/webui/index.html +++ b/webui/index.html @@ -10,6 +10,7 @@ + {{ vite_assets('head')|safe }} @@ -2088,103 +2089,71 @@ results are cached per (query, source) pair. -->
- +
- -
- - - + + +
+ + + - - - - - -
+ +
-

Ready to search • Enter artist, song, or album name

+ Enter an artist, album, or track name to search
- -
-
-

Search Results

+ + diff --git a/webui/static/basic-search-v2.css b/webui/static/basic-search-v2.css new file mode 100644 index 00000000..e028d845 --- /dev/null +++ b/webui/static/basic-search-v2.css @@ -0,0 +1,694 @@ +/* ===================================================================== + * Basic Search — visual redesign (functional behaviour untouched) + * + * Self-contained sheet appended via index.html link. Targets the new + * markup (``bs-*`` prefixed classes). Existing + * ``.search-bar-container`` / ``.filter-btn`` / ``.search-results-container`` + * rules earlier in style.css still apply elsewhere (other search UIs reuse + * them); they no longer match the new basic-search markup so this redesign + * is fully scoped. + * ===================================================================== */ + + +/* — Source chip row above the search bar. One chip per active hybrid + * source; in single-source mode the only chip is rendered with the + * ``.single`` modifier so it reads as a label rather than a control. */ +.bs-source-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; + align-items: center; + min-height: 32px; +} + +.bs-source-row:empty { + display: none; +} + +.bs-source-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + background: + linear-gradient(160deg, + rgba(255, 255, 255, 0.035) 0%, + rgba(0, 0, 0, 0.2) 100%); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 999px; + color: rgba(255, 255, 255, 0.55); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.bs-source-chip:hover:not(.active):not(.single):not(:disabled) { + border-color: rgba(var(--accent-rgb), 0.35); + background: + linear-gradient(160deg, + rgba(var(--accent-rgb), 0.08) 0%, + rgba(0, 0, 0, 0.2) 100%); + color: #fff; + transform: translateY(-1px); +} + +.bs-source-chip.active { + background: + linear-gradient(135deg, + color-mix(in srgb, rgb(var(--accent-rgb)) 95%, white 5%), + color-mix(in srgb, rgb(var(--accent-rgb)) 78%, black 22%)); + border-color: rgba(var(--accent-rgb), 0.55); + color: #fff; + box-shadow: + 0 4px 14px color-mix(in srgb, rgb(var(--accent-rgb)) 25%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +.bs-source-chip.single { + cursor: default; + border-style: dashed; + border-color: rgba(var(--accent-rgb), 0.35); + background: transparent; + color: rgb(var(--accent-light-rgb)); +} + + +/* — Search bar: glass card matching the dashboard / auto-sync vibe. */ +.bs-search-bar { + display: flex; + gap: 12px; + align-items: center; + padding: 10px 12px; + background: + radial-gradient(ellipse at 0% 0%, + color-mix(in srgb, rgb(var(--accent-rgb)) 6%, transparent) 0%, + transparent 60%), + linear-gradient(160deg, + rgba(22, 25, 36, 0.7) 0%, + rgba(14, 16, 24, 0.85) 100%); + border: 1px solid rgba(var(--accent-rgb), 0.22); + border-radius: 16px; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 6px 20px rgba(0, 0, 0, 0.25); + margin-bottom: 12px; +} + +.bs-search-input-wrap { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; + background: rgba(0, 0, 0, 0.32); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + height: 42px; + transition: border-color 0.2s, background 0.2s, box-shadow 0.2s; +} + +.bs-search-input-wrap:focus-within { + border-color: rgba(var(--accent-rgb), 0.45); + background: rgba(0, 0, 0, 0.4); + box-shadow: 0 0 0 3px color-mix(in srgb, rgb(var(--accent-rgb)) 12%, transparent); +} + +.bs-search-icon { + width: 16px; + height: 16px; + color: rgba(255, 255, 255, 0.4); + flex-shrink: 0; +} + +.bs-search-input-wrap:focus-within .bs-search-icon { + color: rgb(var(--accent-light-rgb)); +} + +#basic-search-section #downloads-search-input { + flex: 1; + border: none; + background: transparent; + color: #fff; + font-size: 14px; + font-weight: 500; + outline: none; + padding: 0; + height: 100%; +} + +#basic-search-section #downloads-search-input::placeholder { + color: rgba(255, 255, 255, 0.32); +} + +.bs-cancel-btn { + width: 24px; + height: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.5); + border-radius: 50%; + cursor: pointer; + font-size: 13px; + line-height: 1; + transition: all 0.2s; + flex-shrink: 0; + padding: 0; +} + +.bs-cancel-btn:hover { + background: rgba(239, 68, 68, 0.18); + border-color: rgba(239, 68, 68, 0.45); + color: #fff; +} + +.bs-search-btn { + padding: 0 22px; + height: 42px; + border: 1px solid rgba(var(--accent-rgb), 0.55); + border-radius: 12px; + background: + linear-gradient(135deg, + color-mix(in srgb, rgb(var(--accent-rgb)) 95%, white 5%), + color-mix(in srgb, rgb(var(--accent-rgb)) 78%, black 22%)); + color: #fff; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 4px 14px color-mix(in srgb, rgb(var(--accent-rgb)) 28%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.2); +} + +.bs-search-btn:hover { + transform: translateY(-1px); + box-shadow: + 0 6px 18px color-mix(in srgb, rgb(var(--accent-rgb)) 40%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.25); +} + +.bs-search-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + + +/* — Status bar: thin accent-tinted pill matching the dashboard vibe. */ +.bs-status-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + background: + linear-gradient(180deg, + color-mix(in srgb, rgb(var(--accent-rgb)) 4%, transparent), + transparent); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 10px; + margin-bottom: 12px; +} + +.bs-status-text { + color: rgba(255, 255, 255, 0.55); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + margin: 0; + flex: 1; +} + + +/* — Filters: compact pill row, always visible after first search. */ +.bs-filters { + display: flex; + flex-wrap: wrap; + gap: 16px 24px; + padding: 12px 14px; + background: + linear-gradient(160deg, + rgba(255, 255, 255, 0.025) 0%, + rgba(0, 0, 0, 0.15) 100%); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + margin-bottom: 12px; +} + +.bs-filter-group { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.bs-filter-label { + color: rgba(255, 255, 255, 0.4); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; + margin-right: 4px; +} + +.bs-filter-pill { + padding: 4px 12px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 99px; + color: rgba(255, 255, 255, 0.55); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + cursor: pointer; + transition: all 0.18s ease; +} + +.bs-filter-pill:hover:not(.active) { + border-color: rgba(var(--accent-rgb), 0.35); + color: #fff; +} + +.bs-filter-pill.active { + background: + linear-gradient(135deg, + color-mix(in srgb, rgb(var(--accent-rgb)) 80%, white 20%), + rgb(var(--accent-rgb))); + border-color: rgba(var(--accent-rgb), 0.55); + color: #fff; + box-shadow: + 0 2px 8px color-mix(in srgb, rgb(var(--accent-rgb)) 25%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + + +/* — Results area: glass surface for cards. */ +.bs-results-wrap { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 4px 2px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.bs-results-wrap .search-results-placeholder { + text-align: center; + padding: 60px 30px; + color: rgba(255, 255, 255, 0.35); + font-size: 13px; +} + + +/* — Album result card: glass + accent left-edge stripe + cover icon. + * ``!important`` is used liberally below because the original + * ``.album-result-card`` / ``.album-icon`` / etc. rules in style.css + * (~7247 onwards) are unscoped and apply heavyweight styles (24px + * padding, 56px icon, 24px border-radius, big box-shadows) that + * collide with this redesign. Scoping with ``#basic-search-section`` + * wins on specificity for some properties but the original + * ``box-shadow`` and ``padding`` rules need explicit override to + * defeat the cascade interaction with hover / pseudo states. */ +#basic-search-section .album-result-card { + background: + linear-gradient(160deg, + rgba(255, 255, 255, 0.04) 0%, + rgba(0, 0, 0, 0.2) 100%) !important; + border: 1px solid rgba(255, 255, 255, 0.06) !important; + border-radius: 14px !important; + position: relative; + transition: border-color 0.2s, transform 0.2s !important; + margin: 0 !important; + padding: 0 !important; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.03), + 0 2px 10px rgba(0, 0, 0, 0.2) !important; + transform: none !important; +} + +#basic-search-section .album-result-card::before { + content: ''; + position: absolute; + top: 14px; + bottom: 14px; + left: 0; + width: 3px; + background: linear-gradient(180deg, + rgb(var(--accent-light-rgb)), + rgb(var(--accent-rgb))); + border-radius: 0 3px 3px 0; + box-shadow: 0 0 12px color-mix(in srgb, rgb(var(--accent-rgb)) 50%, transparent); + z-index: 2; +} + +#basic-search-section .album-result-card:hover { + border-color: rgba(var(--accent-rgb), 0.35) !important; + background: + linear-gradient(160deg, + rgba(var(--accent-rgb), 0.06) 0%, + rgba(0, 0, 0, 0.2) 100%) !important; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 6px 20px rgba(0, 0, 0, 0.3) !important; + transform: translateY(-1px) !important; +} + +#basic-search-section .album-card-header { + display: flex !important; + align-items: center !important; + gap: 16px !important; + padding: 18px 22px !important; + cursor: pointer; +} + +#basic-search-section .album-expand-indicator { + color: rgba(255, 255, 255, 0.4); + font-size: 12px; + transition: transform 0.25s ease; + flex-shrink: 0; + width: 16px; + text-align: center; +} + +#basic-search-section .album-result-card.expanded .album-expand-indicator { + transform: rotate(90deg); +} + +#basic-search-section .album-icon { + width: 52px !important; + height: 52px !important; + min-width: 52px; + border-radius: 10px !important; + background: + linear-gradient(135deg, + color-mix(in srgb, rgb(var(--accent-rgb)) 35%, transparent), + rgba(0, 0, 0, 0.4)) !important; + border: 1px solid rgba(var(--accent-rgb), 0.3) !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-size: 26px !important; + flex-shrink: 0; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08) !important; + color: rgba(var(--accent-rgb), 1.0); +} + +#basic-search-section .album-info { + flex: 1 1 auto !important; + min-width: 0; + display: block; +} + +#basic-search-section .album-title { + color: rgba(255, 255, 255, 0.95) !important; + font-size: 15px !important; + font-weight: 700 !important; + letter-spacing: -0.005em; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0 0 4px 0 !important; +} + +#basic-search-section .album-artist { + color: rgba(255, 255, 255, 0.65) !important; + font-size: 13px !important; + font-weight: 500; + line-height: 1.3; + margin: 0 0 6px 0 !important; +} + +#basic-search-section .album-details { + color: rgb(var(--accent-light-rgb)) !important; + font-size: 11px !important; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + line-height: 1.3; + margin: 0 0 3px 0 !important; +} + +#basic-search-section .album-uploader { + color: rgba(255, 255, 255, 0.4) !important; + font-size: 11px !important; + line-height: 1.3; + margin: 0 !important; +} + +#basic-search-section .album-actions { + display: flex !important; + flex-direction: column !important; + gap: 6px !important; + flex-shrink: 0; +} + + +/* — Track result card: glass row, similar height to album cards. */ +#basic-search-section .track-result-card { + display: flex !important; + align-items: center !important; + gap: 16px !important; + padding: 16px 22px !important; + background: + linear-gradient(160deg, + rgba(255, 255, 255, 0.03) 0%, + rgba(0, 0, 0, 0.18) 100%) !important; + border: 1px solid rgba(255, 255, 255, 0.05) !important; + border-radius: 14px !important; + position: relative; + transition: border-color 0.2s, transform 0.2s, background 0.2s !important; + margin: 0 !important; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.03), + 0 2px 10px rgba(0, 0, 0, 0.2) !important; + transform: none !important; +} + +#basic-search-section .track-result-card::before { + content: ''; + position: absolute; + top: 14px; + bottom: 14px; + left: 0; + width: 2px; + background: linear-gradient(180deg, + rgba(var(--accent-rgb), 0.7), + rgba(var(--accent-rgb), 0.3)); + border-radius: 0 2px 2px 0; + z-index: 2; +} + +#basic-search-section .track-result-card:hover { + border-color: rgba(var(--accent-rgb), 0.3) !important; + background: + linear-gradient(160deg, + rgba(var(--accent-rgb), 0.06) 0%, + rgba(0, 0, 0, 0.18) 100%) !important; + transform: translateY(-1px) !important; +} + +#basic-search-section .track-icon { + width: 44px !important; + height: 44px !important; + min-width: 44px; + border-radius: 10px !important; + background: rgba(255, 255, 255, 0.04) !important; + border: 1px solid rgba(255, 255, 255, 0.08) !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-size: 22px !important; + flex-shrink: 0; +} + +#basic-search-section .track-info { + flex: 1 1 auto !important; + min-width: 0; +} + +#basic-search-section .track-title { + color: rgba(255, 255, 255, 0.95) !important; + font-size: 14px !important; + font-weight: 600 !important; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0 0 3px 0 !important; +} + +#basic-search-section .track-artist { + color: rgba(255, 255, 255, 0.6) !important; + font-size: 12px !important; + line-height: 1.3; + margin: 0 0 5px 0 !important; +} + +#basic-search-section .track-details { + color: rgba(255, 255, 255, 0.5) !important; + font-size: 11px !important; + line-height: 1.3; + margin: 0 0 3px 0 !important; +} + +#basic-search-section .track-uploader { + color: rgba(255, 255, 255, 0.38) !important; + font-size: 11px !important; + line-height: 1.3; + margin: 0 !important; +} + +#basic-search-section .track-actions { + display: flex !important; + gap: 6px !important; + flex-shrink: 0; +} + + +/* — Action buttons inside album / track cards: pill primary + ghost. */ +#basic-search-section .album-download-btn, +#basic-search-section .album-matched-btn, +#basic-search-section .track-download-btn, +#basic-search-section .track-matched-btn, +#basic-search-section .track-stream-btn { + padding: 6px 12px; + border-radius: 99px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + cursor: pointer; + transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; +} + +#basic-search-section .album-download-btn, +#basic-search-section .track-download-btn { + background: + linear-gradient(135deg, + color-mix(in srgb, rgb(var(--accent-rgb)) 92%, white 8%), + rgb(var(--accent-rgb))); + border: 1px solid rgba(var(--accent-rgb), 0.5); + color: #fff; + box-shadow: 0 2px 8px color-mix(in srgb, rgb(var(--accent-rgb)) 22%, transparent); +} + +#basic-search-section .album-download-btn:hover, +#basic-search-section .track-download-btn:hover { + transform: translateY(-1px); + box-shadow: 0 5px 14px color-mix(in srgb, rgb(var(--accent-rgb)) 35%, transparent); +} + +#basic-search-section .album-matched-btn, +#basic-search-section .track-matched-btn { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(var(--accent-rgb), 0.3); + color: rgb(var(--accent-light-rgb)); +} + +#basic-search-section .album-matched-btn:hover, +#basic-search-section .track-matched-btn:hover { + background: color-mix(in srgb, rgb(var(--accent-rgb)) 12%, transparent); + border-color: rgba(var(--accent-rgb), 0.5); + color: #fff; +} + +#basic-search-section .track-stream-btn { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.65); +} + +#basic-search-section .track-stream-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.18); + color: #fff; +} + + +/* — Expanded album track list */ +#basic-search-section .album-track-list { + background: rgba(0, 0, 0, 0.18); + border-top: 1px solid rgba(255, 255, 255, 0.04); + padding: 4px 0; +} + +#basic-search-section .track-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 18px 10px 50px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + transition: background 0.15s; +} + +#basic-search-section .track-item:last-child { + border-bottom: none; +} + +#basic-search-section .track-item:hover { + background: rgba(255, 255, 255, 0.02); +} + +#basic-search-section .track-item-info { + flex: 1; + min-width: 0; +} + +#basic-search-section .track-item-title { + color: rgba(255, 255, 255, 0.88); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#basic-search-section .track-item-details { + color: rgba(255, 255, 255, 0.4); + font-size: 10px; + margin-top: 2px; +} + +#basic-search-section .track-item-actions { + display: flex; + gap: 5px; + flex-shrink: 0; +} + +#basic-search-section .track-item-actions .track-stream-btn, +#basic-search-section .track-item-actions .track-download-btn, +#basic-search-section .track-item-actions .track-matched-btn { + padding: 4px 10px; + font-size: 9px; +} + +#basic-search-section .disc-separator { + padding: 8px 18px 6px 50px !important; + font-weight: 700 !important; + font-size: 10px !important; + color: rgb(var(--accent-light-rgb)) !important; + border-bottom: 1px solid rgba(var(--accent-rgb), 0.2) !important; + letter-spacing: 0.12em; + text-transform: uppercase; + background: none !important; + margin: 0 !important; +} + + +/* — Responsive: collapse action button row on narrow viewports. */ +@media (max-width: 900px) { + #basic-search-section .album-actions, + #basic-search-section .track-actions { + flex-direction: column; + } + .bs-filters { + gap: 10px 16px; + } +} diff --git a/webui/static/downloads.js b/webui/static/downloads.js index c44cce45..714ae934 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -4772,7 +4772,69 @@ function updateModalSyncProgress(playlistId, progress) { } -// Raw Soulseek file search (used by the 'Soulseek (raw files)' source picker option). +// ── Basic-search source picker ────────────────────────────────────────────── +// Tracks which download source the user has selected in the chip row. Null +// means "use the orchestrator's default" (same as pre-redesign behaviour). +let _bsActiveSource = null; + +async function initBasicSearchSources() { + const row = document.getElementById('bs-source-row'); + if (!row) return; + try { + const resp = await fetch('/api/search/sources'); + if (!resp.ok) return; + const { mode, sources } = await resp.json(); + if (!sources || !sources.length) return; + + row.innerHTML = ''; + const isSingle = mode !== 'hybrid' || sources.length < 2; + + sources.forEach((src, i) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'bs-source-chip' + (i === 0 ? ' active' : ''); + btn.dataset.source = src.name; + btn.setAttribute('role', 'tab'); + btn.setAttribute('aria-selected', i === 0 ? 'true' : 'false'); + btn.setAttribute('title', src.display_name); + btn.innerHTML = `${_escBsHtml(src.display_name)}`; + + if (isSingle) { + // Non-interactive: single source, just a label. + btn.classList.add('single'); + btn.disabled = true; + } else { + btn.addEventListener('click', () => { + row.querySelectorAll('.bs-source-chip').forEach(c => { + c.classList.remove('active'); + c.setAttribute('aria-selected', 'false'); + }); + btn.classList.add('active'); + btn.setAttribute('aria-selected', 'true'); + _bsActiveSource = src.name; + // Re-run last search with new source if results already showing. + const query = document.getElementById('downloads-search-input')?.value?.trim(); + if (query && window.currentSearchResults?.length) { + performDownloadsSearch(); + } + }); + } + row.appendChild(btn); + }); + + // Default active source = first in chain. + _bsActiveSource = isSingle ? null : sources[0]?.name ?? null; + } catch (_err) { + // Non-fatal — search still works without the picker. + } +} + +function _escBsHtml(str) { + return String(str || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} + + +// Raw download-source file search (basic search tab). async function performDownloadsSearch() { const query = document.getElementById('downloads-search-input').value.trim(); if (!query) { @@ -4802,10 +4864,15 @@ async function performDownloadsSearch() { displayDownloadsResults([]); // Clear previous results // --- 2. Perform the Fetch Request --- + // Source param routes the search to a specific download source in + // hybrid mode. Omitted in single-source mode (backend falls through + // to orchestrator.search() which targets the configured source). + const body = { query }; + if (_bsActiveSource) body.source = _bsActiveSource; const response = await fetch('/api/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), + body: JSON.stringify(body), signal: searchAbortController.signal // Link fetch to the AbortController }); @@ -4825,7 +4892,8 @@ async function performDownloadsSearch() { statusText.textContent = `No results found for '${query}'`; showToast('No results found', 'error'); } else { - document.getElementById('filters-container').classList.remove('hidden'); + const filtersEl = document.getElementById('filters-container'); + if (filtersEl) filtersEl.classList.remove('hidden'); // Count albums and singles like the GUI app let totalAlbums = 0; @@ -5658,6 +5726,15 @@ let _gsController = null; else { _doInit(); setTimeout(_gsUpdateVisibility, 500); } })(); +// Init basic-search source chip row once the DOM is ready. +(function _bsInit() { + const run = () => { + if (typeof initBasicSearchSources === 'function') initBasicSearchSources(); + }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); + else run(); +})(); + function _gsUpdateVisibility() { const bar = document.getElementById('gsearch-bar'); const aura = document.getElementById('gsearch-aura'); diff --git a/webui/static/helper.js b/webui/static/helper.js index d03f4014..2d82537a 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3415,6 +3415,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.6.5': [ { unreleased: true }, + { title: 'Basic search: visual overhaul + per-source picker in hybrid mode', desc: 'the basic search tab on the Search page was carrying its original visual treatment from before the rest of the app moved toward the glassy / accent-radial aesthetic, and it always targeted the first source in the hybrid chain with no way to pick a different one. Two things in this commit: (1) full visual redesign — glass search-bar card with accent radial wash + focus ring, pill primary search button, always-visible compact filter pill row (Type / Format / Sort), accent-tinted status pill, album result cards with accent left-edge stripe + cover icon + chevron expand + pill action buttons, track result cards as slim glass rows, multi-disc separators in album track lists, responsive button-stack on narrow viewports. Self-contained sheet at ``webui/static/basic-search-v2.css`` so revert is just dropping the link tag. (2) source picker — small chip row above the search bar lists every active source in the hybrid chain. Click a chip to target that specific source for the next search. In single-source mode the chip is rendered as a dashed-border label so the user always knows what they\'re searching. New ``GET /api/search/sources`` endpoint returns the active source list; ``/api/search`` accepts an optional ``source`` body param to target that specific source via its client. Backwards compatible — omitting ``source`` uses the orchestrator default exactly as before. Pinned with 5 new unit tests (source routing, fallback on unknown source, no-source default, alias preservation, album serialisation through the source-targeted path) on top of the existing 6 tests.', page: 'search' }, { title: 'Fix: duplicate tracks in albums with Japanese / CJK titles (#722)', desc: 'Japanese OST downloads via Apple Music + Tidal produced duplicate library entries — the same audio file landed under multiple track positions in the album. Root cause: ``MusicMatchingEngine.normalize_string`` correctly skipped unidecode for CJK text (kanji→pinyin would have been gibberish) but then ran ``re.sub(r"[^a-z0-9\\s$]", "", text)`` which stripped EVERY CJK character. Every Japanese title normalised to ``""`` and ``similarity_score`` short-circuited to 0.000 on the empty-string guard. The matcher fell back to duration+artist alone, so multiple iTunes tracks (different songs with same artist + similar duration) mapped to the same Tidal candidate. User got the same audio downloaded N times under different track positions. Fix: when CJK is detected, the alphanumeric-strip step preserves CJK Unified Ideographs, Hiragana, Katakana, Hangul, and Halfwidth/Fullwidth ranges, so CJK titles produce a comparable normalised form. Two different Japanese tracks now score appropriately low; two identical Japanese tracks now score 1.0. Latin-only normalisation is completely unchanged. 16 new unit tests pin every CJK family + every regression-prone Latin baseline. Closes #722.', page: 'downloads' }, ], '2.6.4': [ diff --git a/webui/static/style.css b/webui/static/style.css index fbadf333..78bb629d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -7155,7 +7155,6 @@ body.helper-mode-active #dashboard-activity-feed:hover { align-items: center; gap: 18px; position: relative; - overflow: hidden; /* Neumorphic depth shadows - elevated card effect */ box-shadow: @@ -7255,7 +7254,6 @@ body.helper-mode-active #dashboard-activity-feed:hover { margin: 12px 8px; padding: 24px; position: relative; - overflow: hidden; /* Neumorphic depth shadows - elevated card effect */ box-shadow: