Merge pull request #725 from Nezreka/feat/basic-search-redesign

Basic search: visual overhaul + per-source picker in hybrid mode
This commit is contained in:
BoulderBadgeDad 2026-05-28 10:44:43 -07:00 committed by GitHub
commit 1b5056ef2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1029 additions and 104 deletions

View file

@ -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

View file

@ -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'

View file

@ -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"|<source>, "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:

View file

@ -10,6 +10,7 @@
<meta name="theme-color" content="#1db954">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='pwa-icon-192.png', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='basic-search-v2.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}">
{{ vite_assets('head')|safe }}
@ -2088,103 +2089,71 @@
results are cached per (query, source) pair. -->
<div id="enh-source-row" class="enh-source-row" role="tablist" aria-label="Search source"></div>
<!-- Basic Search Section (Current) -->
<!-- Basic Search Section -->
<div id="basic-search-section" class="search-section">
<!-- Search Bar: Replicates create_elegant_search_bar() -->
<div class="search-bar-container">
<input type="text" id="downloads-search-input"
placeholder="Search for music... (e.g., 'Virtual Mage', 'Queen Bohemian Rhapsody')">
<button id="downloads-cancel-btn" class="hidden">✕ Cancel</button>
<button id="downloads-search-btn">🔍 Search</button>
<!-- Source picker: chip row, one chip per active download source.
Populated by downloads.js:initBasicSearchSources().
Hidden until sources load; in single-source mode shows one
non-interactive chip so the user knows what they're searching. -->
<div class="bs-source-row" id="bs-source-row" aria-label="Search source" role="tablist"></div>
<!-- Search bar -->
<div class="bs-search-bar">
<div class="bs-search-input-wrap">
<svg class="bs-search-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="9" r="6"/><path d="M15 15l3 3"/></svg>
<input type="text" id="downloads-search-input"
placeholder="Search artists, albums, tracks…"
autocomplete="off" spellcheck="false">
<button id="downloads-cancel-btn" class="bs-cancel-btn hidden" aria-label="Cancel"></button>
</div>
<button id="downloads-search-btn" class="bs-search-btn">Search</button>
</div>
<div id="filters-container" class="filters-container hidden">
<div class="filter-toggle-header">
<button id="filter-toggle-btn" class="filter-toggle-btn">⏷ Filters</button>
</div>
<div id="filter-content" class="filter-content hidden">
<!-- Filter by Type -->
<div class="filter-group">
<label class="filter-label">Type:</label>
<button class="filter-btn active" data-filter-type="type"
data-value="all">All</button>
<button class="filter-btn" data-filter-type="type"
data-value="album">Albums</button>
<button class="filter-btn" data-filter-type="type"
data-value="track">Singles</button>
</div>
<!-- Filter by Format -->
<div class="filter-group">
<label class="filter-label">Format:</label>
<button class="filter-btn active" data-filter-type="format"
data-value="all">All</button>
<button class="filter-btn" data-filter-type="format"
data-value="flac">FLAC</button>
<button class="filter-btn" data-filter-type="format"
data-value="mp3">MP3</button>
<!-- Added missing format buttons -->
<button class="filter-btn" data-filter-type="format"
data-value="ogg">OGG</button>
<button class="filter-btn" data-filter-type="format"
data-value="aac">AAC</button>
<button class="filter-btn" data-filter-type="format"
data-value="wma">WMA</button>
</div>
<!-- Sort Controls -->
<div class="filter-group">
<label class="filter-label">Sort by:</label>
<button id="sort-order-btn" class="filter-btn sort-order-btn"
data-order="desc">↓</button>
<!-- Added all sort options from the GUI -->
<button class="filter-btn active" data-filter-type="sort"
data-value="relevance">Relevance</button>
<button class="filter-btn" data-filter-type="sort"
data-value="quality_score">Quality</button>
<button class="filter-btn" data-filter-type="sort"
data-value="size">Size</button>
<button class="filter-btn" data-filter-type="sort"
data-value="title">Name</button>
<button class="filter-btn" data-filter-type="sort"
data-value="username">Uploader</button>
<button class="filter-btn" data-filter-type="sort"
data-value="bitrate">Bitrate</button>
<button class="filter-btn" data-filter-type="sort"
data-value="duration">Duration</button>
<button class="filter-btn" data-filter-type="sort"
data-value="availability">Available</button>
<button class="filter-btn" data-filter-type="sort"
data-value="upload_speed">Speed</button>
</div>
</div>
</div>
<!-- Search Status Bar -->
<div class="search-status-container">
<!-- Status / loading bar -->
<div class="bs-status-bar">
<div class="spinner-animation hidden"></div>
<p id="search-status-text">Ready to search • Enter artist, song, or album name</p>
<span id="search-status-text" class="bs-status-text">Enter an artist, album, or track name to search</span>
<div class="dots-animation hidden"></div>
</div>
<!-- Search Results Area: Replicates the QScrollArea -->
<div class="search-results-container">
<div class="search-results-header">
<h3>Search Results</h3>
<!-- Filters: always-visible compact pill row -->
<div id="filters-container" class="bs-filters hidden">
<div class="bs-filter-group">
<span class="bs-filter-label">Type</span>
<button class="filter-btn bs-filter-pill active" data-filter-type="type" data-value="all">All</button>
<button class="filter-btn bs-filter-pill" data-filter-type="type" data-value="album">Albums</button>
<button class="filter-btn bs-filter-pill" data-filter-type="type" data-value="track">Tracks</button>
</div>
<div class="search-results-scroll-area" id="search-results-area">
<!--
The placeholder search results have been removed.
This area will now be populated by JavaScript based on API responses.
-->
<div class="search-results-placeholder">
<p>Your search results will appear here.</p>
</div>
<div class="bs-filter-group">
<span class="bs-filter-label">Format</span>
<button class="filter-btn bs-filter-pill active" data-filter-type="format" data-value="all">All</button>
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="flac">FLAC</button>
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="mp3">MP3</button>
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="ogg">OGG</button>
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="aac">AAC</button>
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="wma">WMA</button>
</div>
<div class="bs-filter-group">
<span class="bs-filter-label">Sort</span>
<button id="sort-order-btn" class="filter-btn bs-filter-pill sort-order-btn" data-order="desc"></button>
<button class="filter-btn bs-filter-pill active" data-filter-type="sort" data-value="relevance">Relevance</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="quality_score">Quality</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="size">Size</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="title">Name</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="username">Uploader</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="bitrate">Bitrate</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="duration">Duration</button>
</div>
</div>
<!-- Results area -->
<div class="bs-results-wrap" id="search-results-area">
<div class="search-results-placeholder">
<p>Enter a search term to get started.</p>
</div>
</div>
</div>
<!-- End Basic Search Section -->

View file

@ -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;
}
}

View file

@ -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 = `<span class="bs-source-label">${_escBsHtml(src.display_name)}</span>`;
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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');

View file

@ -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': [

View file

@ -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: