Add manual search to the failed-track candidates modal

When an auto-download fails or returns "not found" with leftover
candidates, the user can already click the status cell to open a
modal showing those candidates and pick a different one. This adds
a manual search bar to that modal — type any query, hit search,
get a fresh round of results without having to bail out and start
over from the main search page.

Solves the case where the auto-query was bad (featured artist not
in title, parentheticals like "(Remastered 2019)" tripping the
matcher, slight artist-name variants, transliteration) but the
file genuinely exists on the source.

Frontend (downloads.js)

- Added a manual-search section above the existing auto-candidates
  table inside the candidates modal.
- Source picker is smart per download mode:
  - Single-source mode (soulseek-only / youtube-only / etc) shows
    a "Searching X" label, no dropdown.
  - Hybrid mode shows a dropdown with "All sources" default + every
    configured source. Picking "All" runs parallel searches across
    them and tags each result row with its source badge.
  - Only configured sources show up; unconfigured are hidden.
- Validation: button disabled until query length >= 2, "Type at
  least 2 characters" hint until threshold crosses.
- Loading state on search button while the request is in flight.
- Manual results render in a separate table above the existing
  auto-candidates table, using the same row template (file /
  quality / size / duration / user / ⬇ button) so the renderer
  helper is shared.
- Click ⬇ reuses the existing `downloadCandidate(taskId, candidate,
  trackName)` flow — same retry path, same AcoustID verification
  when the file lands, no shortcut around the safety net.
- Re-running the search with a different query replaces the
  previous manual results.

Backend (web_server.py)

- Extended `GET /api/downloads/task/<id>/candidates` response with:
  - `download_mode` (e.g. 'hybrid', 'soulseek')
  - `available_sources` (list of configured source IDs + labels)
  - `source` field on each candidate (purely additive — frontend
    auto-renderer ignores it on legacy code paths, manual-search
    renderer uses it for the badge)
- Added `POST /api/downloads/task/<id>/manual-search`:
  - Body: `{ query, source: 'all' | <source_id> }`
  - Validates query length (>=2 trimmed) → 400
  - Validates source against the configured-sources gate → 400
    (rejects unconfigured sources even when explicitly named)
  - For 'all': parallel `ThreadPoolExecutor` dispatch across every
    configured download source, merged results
  - For specific source: just that source
  - Returns same shape as `/candidates` so the frontend renderer
    is reused
- New module-level helpers: `_STREAMING_SOURCE_NAMES`,
  `_infer_candidate_source`, `_serialize_candidate`,
  `_list_available_download_sources`. The existing `/candidates`
  endpoint also goes through `_serialize_candidate` so the source
  badge is consistent across both flows.

Behavior preserved

- Existing modal layout / candidates table / ⬇ button are
  byte-identical when the user doesn't use manual search.
- `downloadCandidate()` JS function untouched.
- `/candidates` and `/download-candidate` endpoints
  backwards-compatible — only NEW fields added, nothing changed
  or removed.

Tests

`tests/test_manual_search_endpoint.py` — 10 tests:

- `test_manual_search_validates_query_length`
- `test_manual_search_validates_source` (whitelist gate)
- `test_manual_search_handles_task_not_found` (404)
- `test_manual_search_dispatches_to_configured_source_only`
- `test_manual_search_all_dispatches_parallel`
- `test_manual_search_skips_unconfigured_sources`
- `test_manual_search_rejects_unconfigured_source_explicitly`
- `test_manual_search_returns_same_shape_as_candidates`
- `test_manual_search_single_source_mode_lists_source` (verifies
  `available_sources` reflects the active mode)
- `test_manual_search_isolates_per_source_exceptions` (one source
  throwing doesn't kill the merged result)

2242/2242 full suite green (was 2232 + 10 new). Ruff clean.
JS parses clean.
This commit is contained in:
Broque Thomas 2026-05-08 09:50:17 -07:00
parent 4277648734
commit 996575fab3
5 changed files with 1009 additions and 55 deletions

View file

@ -0,0 +1,458 @@
"""Tests for the /api/downloads/task/<id>/manual-search endpoint.
The candidates modal lets the user click an auto-found candidate to retry
a failed download. Manual search adds a second avenue type a query, hit
search, get fresh results from the configured download source(s) without
having to leave the modal.
These tests cover the new endpoint's validation + dispatch behavior:
- Query length / source whitelist validation
- Hybrid mode 'all' fans out across every configured source in parallel
- Per-source request hits only the named source
- Unconfigured sources in hybrid mode are silently skipped
- Task lookup gates the endpoint with a 404 when the task isn't known
The endpoint constructs candidate JSON via the same helpers the
``/candidates`` endpoint uses, so the response shape carries the same
fields (track_info + candidates) plus a ``source`` tag on each candidate
so the manual-search frontend can show a per-row source badge in 'all'
mode.
The existing ``/download-candidate`` retry path is unchanged manual-
search results POST through the same endpoint so AcoustID + post-download
safety nets stay in the loop.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Fixture — Flask test client + mocked plugin registry.
# ---------------------------------------------------------------------------
def _async_return(value):
"""Return a coroutine that resolves to ``value`` — passed to plugins'
.search() so run_async() can drive it like a real awaitable."""
async def _coro():
return value
return _coro()
def _fake_track_result(filename: str, source: str, username_override: str = None):
"""Build a TrackResult-shaped MagicMock that the serializer can read."""
mock = MagicMock()
mock.filename = filename
mock.username = username_override or source # streaming sources stamp the source name
mock.size = 1024 * 1024 * 4
mock.bitrate = 320
mock.duration = 200_000
mock.quality = 'mp3'
mock.free_upload_slots = 1
mock.upload_speed = 100_000
mock.queue_length = 0
mock.artist = 'Test Artist'
mock.title = 'Test Title'
mock.album = 'Test Album'
# `hasattr(c, '__dict__')` is what _serialize_candidate uses to detect
# dataclass-shaped inputs. MagicMock has __dict__, so this works.
return mock
def _make_plugin(search_results=None, configured=True):
"""Stand-in for a download-source plugin. Records calls to .search()
so tests can assert which sources got dispatched."""
plugin = MagicMock()
plugin.is_configured = MagicMock(return_value=configured)
# Each call returns a fresh coroutine — async functions can't be
# awaited twice, so the side_effect rebuilds the awaitable each time.
plugin.search = MagicMock(
side_effect=lambda *args, **kwargs: _async_return((search_results or [], []))
)
return plugin
@pytest.fixture
def manual_search_client(monkeypatch):
"""Flask test client with a fully mocked download_orchestrator + a
seeded download_tasks entry. Each test reaches into the plugin mocks
via the returned ``ctx`` dict to assert dispatch behavior."""
# Avoid the real activity-feed side effects.
with patch("web_server.add_activity_item"):
# Mock external service singletons so import doesn't try to spin up
# real clients / hit real APIs at module-load time.
with patch("web_server.SpotifyClient"):
with patch("core.tidal_client.TidalClient"):
from web_server import app as flask_app
import web_server
flask_app.config['TESTING'] = True
# Build a fresh registry-like object with deterministic plugins
# — bypasses the eight real clients the orchestrator instantiates.
plugins = {
'soulseek': _make_plugin(),
'youtube': _make_plugin(),
'tidal': _make_plugin(configured=False), # not configured
'qobuz': _make_plugin(),
'hifi': _make_plugin(),
'deezer': _make_plugin(),
}
class _FakeSpec:
def __init__(self, name):
self.name = name
self.display_name = name.title()
self.aliases = ()
class _FakeRegistry:
def __init__(self, plugins_dict):
self._plugins = plugins_dict
def get(self, name):
return self._plugins.get(name)
def get_spec(self, name):
return _FakeSpec(name) if name in self._plugins else None
def names(self):
return list(self._plugins.keys())
def all_plugins(self):
return list(self._plugins.items())
fake_orch = MagicMock()
fake_orch.registry = _FakeRegistry(plugins)
fake_orch.client = MagicMock(side_effect=lambda name: plugins.get(name))
monkeypatch.setattr(web_server, 'download_orchestrator', fake_orch)
# run_async drives the awaitable each plugin.search() returns —
# the real one schedules onto the asyncio loop. The default
# implementation in utils.async_helpers handles this fine,
# but force a deterministic synchronous version so test
# ordering is predictable.
def _sync_run_async(coro):
import asyncio
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
monkeypatch.setattr(web_server, 'run_async', _sync_run_async)
# Seed download_tasks so the endpoint finds a real task.
from core.runtime_state import download_tasks, tasks_lock
with tasks_lock:
download_tasks.clear()
download_tasks['task-abc'] = {
'status': 'failed',
'track_info': {
'name': 'Test Track',
'artists': [{'name': 'Test Artist'}],
'duration_ms': 200_000,
},
'cached_candidates': [],
}
# Default config: hybrid mode with all six in hybrid_order.
# Individual tests override this.
from config.settings import config_manager
original_get = config_manager.get
def _fake_config_get(key, default=None):
if key == 'download_source.mode':
return 'hybrid'
if key == 'download_source.hybrid_order':
return ['soulseek', 'youtube', 'tidal', 'qobuz', 'hifi', 'deezer']
return original_get(key, default)
monkeypatch.setattr(config_manager, 'get', _fake_config_get)
ctx = {
'plugins': plugins,
'config_get_setter': lambda fn: monkeypatch.setattr(config_manager, 'get', fn),
}
yield flask_app.test_client(), ctx
with tasks_lock:
download_tasks.clear()
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def test_manual_search_validates_query_length(manual_search_client):
"""Empty / 1-char query returns 400 — frontend hint says ≥2 chars."""
client, _ctx = manual_search_client
for bad_query in ['', ' ', 'a', ' a ']:
resp = client.post(
'/api/downloads/task/task-abc/manual-search',
json={'query': bad_query, 'source': 'all'},
)
assert resp.status_code == 400, f"query={bad_query!r} should 400"
body = resp.get_json()
assert 'error' in body
def test_manual_search_validates_source(manual_search_client):
"""Source must be 'all' or one of the configured source ids — anything
else returns 400. Prevents users from triggering searches against a
source the user explicitly disabled."""
client, _ctx = manual_search_client
resp = client.post(
'/api/downloads/task/task-abc/manual-search',
json={'query': 'drake feelings', 'source': 'made_up_source'},
)
assert resp.status_code == 400
assert 'error' in resp.get_json()
def test_manual_search_handles_task_not_found(manual_search_client):
"""Unknown task_id returns 404 — same gate as the existing /candidates
and /download-candidate endpoints."""
client, _ctx = manual_search_client
resp = client.post(
'/api/downloads/task/no-such-task/manual-search',
json={'query': 'drake feelings', 'source': 'all'},
)
assert resp.status_code == 404
assert 'error' in resp.get_json()
# ---------------------------------------------------------------------------
# Dispatch behavior — single source vs. parallel "all"
# ---------------------------------------------------------------------------
def test_manual_search_dispatches_to_configured_source_only(manual_search_client):
"""In hybrid mode, source='youtube' should hit only the youtube plugin's
search not soulseek, hifi, etc. The candidates endpoint already
validates source against the configured list, so unconfigured plugins
aren't reachable here."""
client, ctx = manual_search_client
plugins = ctx['plugins']
# Configure youtube to return one result so we can verify the response.
plugins['youtube'] = _make_plugin(
search_results=[_fake_track_result('youtube_song.mp3', 'youtube')]
)
# Re-wire the orchestrator's client() so it returns the new mock.
import web_server
web_server.download_orchestrator.registry._plugins['youtube'] = plugins['youtube']
resp = client.post(
'/api/downloads/task/task-abc/manual-search',
json={'query': 'drake feelings', 'source': 'youtube'},
)
assert resp.status_code == 200
body = resp.get_json()
assert len(body['candidates']) == 1
# Only youtube should have been searched.
assert plugins['youtube'].search.call_count == 1
assert plugins['soulseek'].search.call_count == 0
assert plugins['hifi'].search.call_count == 0
assert plugins['qobuz'].search.call_count == 0
def test_manual_search_all_dispatches_parallel(manual_search_client):
"""source='all' with hybrid mode → searches every CONFIGURED source.
Tidal is unconfigured (is_configured()=False) so it's filtered out
upstream by _list_available_download_sources 5 of 6 sources hit."""
client, ctx = manual_search_client
plugins = ctx['plugins']
# Make each configured source return one distinct result.
import web_server
for src_name in ('soulseek', 'youtube', 'qobuz', 'hifi', 'deezer'):
new_plugin = _make_plugin(
search_results=[_fake_track_result(f'{src_name}_song.mp3', src_name)]
)
plugins[src_name] = new_plugin
web_server.download_orchestrator.registry._plugins[src_name] = new_plugin
resp = client.post(
'/api/downloads/task/task-abc/manual-search',
json={'query': 'drake feelings', 'source': 'all'},
)
assert resp.status_code == 200
body = resp.get_json()
# 5 configured sources × 1 result each
assert len(body['candidates']) == 5
# Each configured source got searched once.
for src_name in ('soulseek', 'youtube', 'qobuz', 'hifi', 'deezer'):
assert plugins[src_name].search.call_count == 1, (
f"{src_name} should have been searched in 'all' mode"
)
# Tidal is unconfigured → not in available_sources → not searched.
assert plugins['tidal'].search.call_count == 0
def test_manual_search_skips_unconfigured_sources(manual_search_client):
"""Sources whose is_configured() returns False are excluded from the
'all' dispatch list. This is the same gate hybrid-mode fallback uses
for actual downloads keeps manual search consistent with the rest
of the orchestrator."""
client, ctx = manual_search_client
plugins = ctx['plugins']
resp = client.post(
'/api/downloads/task/task-abc/manual-search',
json={'query': 'something', 'source': 'all'},
)
assert resp.status_code == 200
body = resp.get_json()
# Tidal is unconfigured — not in available_sources
available_ids = {s['id'] for s in body['available_sources']}
assert 'tidal' not in available_ids
assert {'soulseek', 'youtube', 'qobuz', 'hifi', 'deezer'} <= available_ids
# And tidal.search was NEVER called.
assert plugins['tidal'].search.call_count == 0
def test_manual_search_rejects_unconfigured_source_explicitly(manual_search_client):
"""User can't bypass the 'all' filter by naming an unconfigured source
directly endpoint validates `source` against the live configured-
sources list."""
client, _ctx = manual_search_client
resp = client.post(
'/api/downloads/task/task-abc/manual-search',
json={'query': 'something', 'source': 'tidal'},
)
# Tidal is in the registry but is_configured()=False, so it's not in
# available_sources, so the endpoint should reject the request.
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# Response shape
# ---------------------------------------------------------------------------
def test_manual_search_returns_same_shape_as_candidates(manual_search_client):
"""Response includes track_info + candidates array; each candidate
carries a source field so the frontend can show per-row badges in
'all' mode. Frontend renderer reuses the same row template for both
auto-candidates and manual results."""
client, ctx = manual_search_client
plugins = ctx['plugins']
import web_server
plugins['youtube'] = _make_plugin(
search_results=[_fake_track_result('youtube_song.mp3', 'youtube')]
)
web_server.download_orchestrator.registry._plugins['youtube'] = plugins['youtube']
resp = client.post(
'/api/downloads/task/task-abc/manual-search',
json={'query': 'drake feelings', 'source': 'youtube'},
)
assert resp.status_code == 200
body = resp.get_json()
# Top-level shape mirrors /candidates
assert body['task_id'] == 'task-abc'
assert 'track_info' in body
assert body['track_info']['name'] == 'Test Track'
assert 'candidates' in body
assert 'candidate_count' in body
assert body['candidate_count'] == len(body['candidates'])
assert body['download_mode'] == 'hybrid'
assert isinstance(body['available_sources'], list)
# Echoed query lets frontend show "No results for X" with the same casing
# the user typed.
assert body['query'] == 'drake feelings'
# Each candidate carries source for the frontend badge.
for candidate in body['candidates']:
assert 'source' in candidate
assert candidate['source'] == 'youtube'
# And the standard candidate fields are present (same shape as
# /candidates serialization).
for field in ('username', 'filename', 'size', 'quality',
'duration', 'bitrate', 'queue_length',
'free_upload_slots'):
assert field in candidate, f"missing {field}"
def test_manual_search_single_source_mode_only_offers_one_source(monkeypatch, manual_search_client):
"""When download_source.mode is a single source (not hybrid), the
available_sources list should contain just that one source. Frontend
swaps the dropdown for a static label in this case."""
client, _ctx = manual_search_client
# Override config to single-source mode (soulseek only).
from config.settings import config_manager
monkeypatch.setattr(
config_manager, 'get',
lambda key, default=None: (
'soulseek' if key == 'download_source.mode'
else (['soulseek', 'youtube'] if key == 'download_source.hybrid_order' else default)
),
)
resp = client.post(
'/api/downloads/task/task-abc/manual-search',
json={'query': 'something', 'source': 'soulseek'},
)
assert resp.status_code == 200
body = resp.get_json()
assert body['download_mode'] == 'soulseek'
available_ids = [s['id'] for s in body['available_sources']]
assert available_ids == ['soulseek']
def test_manual_search_handles_plugin_exception_gracefully(manual_search_client):
"""If one source's .search() raises, the endpoint logs + skips it
instead of failing the whole 'all' request. Other sources' results
still come through."""
client, ctx = manual_search_client
plugins = ctx['plugins']
import web_server
# Soulseek raises; youtube returns one result.
flaky_plugin = MagicMock()
flaky_plugin.is_configured = MagicMock(return_value=True)
def _raise(*args, **kwargs):
async def _coro():
raise RuntimeError("network blip")
return _coro()
flaky_plugin.search = MagicMock(side_effect=_raise)
plugins['soulseek'] = flaky_plugin
web_server.download_orchestrator.registry._plugins['soulseek'] = flaky_plugin
plugins['youtube'] = _make_plugin(
search_results=[_fake_track_result('youtube_song.mp3', 'youtube')]
)
web_server.download_orchestrator.registry._plugins['youtube'] = plugins['youtube']
resp = client.post(
'/api/downloads/task/task-abc/manual-search',
json={'query': 'something', 'source': 'all'},
)
assert resp.status_code == 200
body = resp.get_json()
# Failed plugin contributed 0; youtube's 1 result still comes through.
yt_results = [c for c in body['candidates'] if c.get('source') == 'youtube']
assert len(yt_results) == 1

View file

@ -7742,6 +7742,109 @@ def clear_finished_downloads():
logger.error(f"Error clearing finished downloads: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# Streaming sources where the candidate's `username` field IS the source name
# (Soulseek uses a real peer username; everything else stamps the source string).
_STREAMING_SOURCE_NAMES = frozenset((
'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud'
))
def _infer_candidate_source(username: str) -> str:
"""Infer which download source a candidate came from based on its
`username` field. Streaming sources stamp their canonical name there;
everything else is Soulseek."""
if not username:
return 'soulseek'
return username if username in _STREAMING_SOURCE_NAMES else 'soulseek'
def _serialize_candidate(c, source_override: str = None) -> dict:
"""Convert a TrackResult (or dict) into the JSON shape the candidates
modal expects. ``source_override`` lets manual-search callers stamp
the source explicitly when the dispatcher knows it; otherwise we
infer from the username."""
if hasattr(c, '__dict__'):
username = getattr(c, 'username', '')
return {
'username': username,
'filename': getattr(c, 'filename', ''),
'size': getattr(c, 'size', 0),
'bitrate': getattr(c, 'bitrate', None),
'duration': getattr(c, 'duration', None),
'quality': getattr(c, 'quality', ''),
'free_upload_slots': getattr(c, 'free_upload_slots', 0),
'upload_speed': getattr(c, 'upload_speed', 0),
'queue_length': getattr(c, 'queue_length', 0),
'artist': getattr(c, 'artist', None),
'title': getattr(c, 'title', None),
'album': getattr(c, 'album', None),
'source': source_override or _infer_candidate_source(username),
}
if isinstance(c, dict):
out = dict(c)
out.setdefault('source', source_override or _infer_candidate_source(out.get('username', '')))
return out
return {}
def _list_available_download_sources() -> tuple:
"""Return ``(download_mode, available_sources)`` for the current
download configuration. ``download_mode`` is the value of
``download_source.mode`` (one of 'soulseek'/'youtube'/.../'hybrid').
``available_sources`` is a list of ``{id, label}`` dicts the
sources the manual-search dropdown should offer.
In single-source mode: returns just that one source if it's
initialized + configured (the user picked it, so we expose it
even if is_configured() doesn't fully approve — they may still
want to retry).
In hybrid mode: filters ``hybrid_order`` down to sources that are
BOTH initialized and ``is_configured()`` same gate hybrid-mode
fallback already uses.
"""
if not download_orchestrator:
return 'soulseek', []
download_mode = config_manager.get('download_source.mode', 'soulseek')
sources = []
def _make_entry(name: str) -> dict:
spec = download_orchestrator.registry.get_spec(name) if hasattr(download_orchestrator, 'registry') else None
return {
'id': name,
'label': spec.display_name if spec else name.title(),
}
if download_mode == 'hybrid':
hybrid_order = config_manager.get('download_source.hybrid_order',
['hifi', 'youtube', 'soulseek']) or []
seen = set()
for raw_name in hybrid_order:
spec = download_orchestrator.registry.get_spec(raw_name) if hasattr(download_orchestrator, 'registry') else None
canonical = spec.name if spec else raw_name
if canonical in seen:
continue
client = download_orchestrator.client(canonical)
if not client:
continue
try:
if not client.is_configured():
continue
except Exception:
continue
seen.add(canonical)
sources.append(_make_entry(canonical))
else:
# Single-source mode — just expose the configured mode (the user
# picked it, so they expect manual search to hit that source).
client = download_orchestrator.client(download_mode)
if client:
sources.append(_make_entry(download_mode))
return download_mode, sources
@app.route('/api/downloads/task/<task_id>/candidates', methods=['GET'])
def get_task_candidates(task_id):
"""Returns the cached search candidates for a download task so the UI can show what was found."""
@ -7755,25 +7858,10 @@ def get_task_candidates(task_id):
track_info = task.get('track_info', {})
error_message = task.get('error_message', '')
serialized = []
for c in candidates:
if hasattr(c, '__dict__'):
serialized.append({
'username': getattr(c, 'username', ''),
'filename': getattr(c, 'filename', ''),
'size': getattr(c, 'size', 0),
'bitrate': getattr(c, 'bitrate', None),
'duration': getattr(c, 'duration', None),
'quality': getattr(c, 'quality', ''),
'free_upload_slots': getattr(c, 'free_upload_slots', 0),
'upload_speed': getattr(c, 'upload_speed', 0),
'queue_length': getattr(c, 'queue_length', 0),
'artist': getattr(c, 'artist', None),
'title': getattr(c, 'title', None),
'album': getattr(c, 'album', None),
})
elif isinstance(c, dict):
serialized.append(c)
serialized = [_serialize_candidate(c) for c in candidates if c is not None]
serialized = [s for s in serialized if s]
download_mode, available_sources = _list_available_download_sources()
return jsonify({
"task_id": task_id,
@ -7784,6 +7872,8 @@ def get_task_candidates(task_id):
"error_message": error_message,
"candidates": serialized,
"candidate_count": len(serialized),
"download_mode": download_mode,
"available_sources": available_sources,
})
except Exception as e:
logger.error(f"[Candidates] Error fetching candidates for task {task_id}: {e}")
@ -7903,6 +7993,118 @@ def download_selected_candidate(task_id):
traceback.print_exc()
return jsonify({"error": str(e)}), 500
@app.route('/api/downloads/task/<task_id>/manual-search', methods=['POST'])
def manual_search_for_task(task_id):
"""Run a user-driven search against one (or all) configured download sources
and return candidate results in the same shape as ``/candidates``. The
candidates modal lets the user pick one to retry the download that retry
still goes through the existing ``/download-candidate`` endpoint, so all
AcoustID + post-download safety nets stay in the loop.
"""
try:
data = request.get_json(silent=True) or {}
raw_query = data.get('query', '')
query = raw_query.strip() if isinstance(raw_query, str) else ''
source = data.get('source', 'all')
if len(query) < 2:
return jsonify({"error": "Query must be at least 2 characters"}), 400
# Validate task exists
with tasks_lock:
task = download_tasks.get(task_id)
if not task:
return jsonify({"error": "Task not found"}), 404
track_info = dict(task.get('track_info', {}))
# Validate source against the live configured-sources list (so users
# can't bypass hybrid_order and trigger a search against a source the
# user disabled / hasn't configured).
download_mode, available_sources = _list_available_download_sources()
valid_source_ids = {s['id'] for s in available_sources}
if source != 'all':
if source not in valid_source_ids:
return jsonify({
"error": f"Source '{source}' is not configured or available"
}), 400
sources_to_query = [source]
else:
# 'all' is only meaningful in hybrid mode, but we accept it in
# single-source mode too (degenerates to the one configured source).
sources_to_query = list(valid_source_ids)
if not sources_to_query:
return jsonify({
"task_id": task_id,
"track_info": {
"name": track_info.get('name', 'Unknown'),
"artist": _get_track_artist_name(track_info) if isinstance(track_info, dict) else 'Unknown',
},
"candidates": [],
"candidate_count": 0,
"download_mode": download_mode,
"available_sources": available_sources,
})
# Dispatch parallel searches. Each plugin's `.search(query)` is a
# coroutine, so we wrap it via run_async and submit to a small thread
# pool — same pattern the rest of the app uses to fan out across
# sources without blocking the request thread.
from concurrent.futures import ThreadPoolExecutor, as_completed
def _search_one(src_name: str):
client = download_orchestrator.client(src_name) if download_orchestrator else None
if not client:
return src_name, []
try:
result = run_async(client.search(query))
if isinstance(result, tuple):
tracks = result[0] if result else []
else:
tracks = result or []
return src_name, tracks
except Exception as exc:
logger.warning(f"[Manual Search] {src_name} search failed for query '{query}': {exc}")
return src_name, []
merged_candidates = []
max_workers = min(8, max(1, len(sources_to_query)))
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='manual-search') as executor:
futures = [executor.submit(_search_one, name) for name in sources_to_query]
for future in as_completed(futures):
src_name, tracks = future.result()
for t in tracks:
serialized = _serialize_candidate(t, source_override=src_name)
if serialized:
merged_candidates.append(serialized)
logger.info(
f"[Manual Search] task={task_id} query='{query}' source={source} "
f"sources_queried={sources_to_query} results={len(merged_candidates)}"
)
return jsonify({
"task_id": task_id,
"track_info": {
"name": track_info.get('name', 'Unknown'),
"artist": _get_track_artist_name(track_info) if isinstance(track_info, dict) else 'Unknown',
},
"candidates": merged_candidates,
"candidate_count": len(merged_candidates),
"download_mode": download_mode,
"available_sources": available_sources,
"query": query,
})
except Exception as e:
logger.error(f"[Manual Search] {e}")
import traceback
traceback.print_exc()
return jsonify({"error": str(e)}), 500
@app.route('/api/quarantine/clear', methods=['POST'])
def clear_quarantine():
"""Delete all files and folders inside the ss_quarantine directory."""

View file

@ -2903,6 +2903,45 @@ async function showCandidatesModal(taskId) {
}
}
// Format helpers used by both auto-candidates and manual-search rendering.
function _candidatesFmtSize(bytes) {
if (!bytes) return '-';
const units = ['B', 'KB', 'MB', 'GB'];
let s = bytes, u = 0;
while (s >= 1024 && u < units.length - 1) { s /= 1024; u++; }
return `${s.toFixed(1)} ${units[u]}`;
}
function _candidatesFmtDur(ms) {
if (!ms) return '-';
const sec = Math.floor(ms / 1000);
return `${Math.floor(sec / 60)}:${(sec % 60).toString().padStart(2, '0')}`;
}
// Build a single <tr> for the candidates table. ``rowClass`` lets the
// manual-search renderer distinguish its rows from the auto-candidates
// rows (different click binding scope). ``showSourceBadge`` adds a small
// per-row source pill — used in hybrid "All sources" mode where the user
// otherwise can't tell which source a row came from.
function _renderCandidateRow(c, index, rowClass, showSourceBadge) {
const shortFile = c.filename ? c.filename.split(/[/\\]/).pop() : '-';
const qBadge = c.quality
? `<span class="candidates-quality-badge candidates-quality-${c.quality.toLowerCase()}">${c.quality.toUpperCase()}</span>`
: '';
const sourceBadge = (showSourceBadge && c.source)
? `<span class="candidates-source-badge" title="Source">${escapeHtml(c.source)}</span> `
: '';
return `<tr class="${rowClass}">
<td class="candidates-col-index">${index + 1}</td>
<td class="candidates-col-file" title="${escapeHtml(c.filename || '')}">${sourceBadge}${escapeHtml(shortFile)}</td>
<td class="candidates-col-quality">${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''}</td>
<td class="candidates-col-size">${_candidatesFmtSize(c.size)}</td>
<td class="candidates-col-duration">${_candidatesFmtDur(c.duration)}</td>
<td class="candidates-col-user" title="Queue: ${c.queue_length || 0} | Slots: ${c.free_upload_slots || 0}">${escapeHtml(c.username || '-')}</td>
<td class="candidates-col-action"><button class="candidates-download-btn" data-index="${index}" title="Download this file"></button></td>
</tr>`;
}
function _renderCandidatesModal(data) {
let overlay = document.getElementById('candidates-modal-overlay');
if (overlay) overlay.remove();
@ -2911,42 +2950,57 @@ function _renderCandidatesModal(data) {
const trackArtist = data.track_info?.artist || 'Unknown Artist';
const candidates = data.candidates || [];
const errorMsg = data.error_message || '';
const fmtSize = (bytes) => {
if (!bytes) return '-';
const units = ['B', 'KB', 'MB', 'GB'];
let s = bytes, u = 0;
while (s >= 1024 && u < units.length - 1) { s /= 1024; u++; }
return `${s.toFixed(1)} ${units[u]}`;
};
const fmtDur = (ms) => {
if (!ms) return '-';
const sec = Math.floor(ms / 1000);
return `${Math.floor(sec / 60)}:${(sec % 60).toString().padStart(2, '0')}`;
};
const downloadMode = data.download_mode || 'soulseek';
const availableSources = Array.isArray(data.available_sources) ? data.available_sources : [];
// Hybrid mode shows the dropdown; everything else implies a single source.
const isHybrid = downloadMode === 'hybrid';
let tableRows = '';
if (candidates.length === 0) {
tableRows = `<tr><td colspan="7" style="text-align:center; color: rgba(255,255,255,0.5); padding: 30px;">
No candidates were found during search.</td></tr>`;
} else {
// Auto-candidates only show source badges in hybrid mode (where the
// user can't infer source from the dropdown).
candidates.forEach((c, i) => {
const shortFile = c.filename ? c.filename.split(/[/\\]/).pop() : '-';
const qBadge = c.quality
? `<span class="candidates-quality-badge candidates-quality-${c.quality.toLowerCase()}">${c.quality.toUpperCase()}</span>`
: '';
tableRows += `<tr>
<td class="candidates-col-index">${i + 1}</td>
<td class="candidates-col-file" title="${escapeHtml(c.filename || '')}">${escapeHtml(shortFile)}</td>
<td class="candidates-col-quality">${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''}</td>
<td class="candidates-col-size">${fmtSize(c.size)}</td>
<td class="candidates-col-duration">${fmtDur(c.duration)}</td>
<td class="candidates-col-user" title="Queue: ${c.queue_length || 0} | Slots: ${c.free_upload_slots || 0}">${escapeHtml(c.username || '-')}</td>
<td class="candidates-col-action"><button class="candidates-download-btn" data-index="${i}" title="Download this file"></button></td>
</tr>`;
tableRows += _renderCandidateRow(c, i, 'candidates-row-auto', isHybrid);
});
}
// ----- Manual search bar -----
let sourceControl;
if (isHybrid && availableSources.length > 0) {
const optionsHtml = ['<option value="all">All sources</option>']
.concat(availableSources.map(s =>
`<option value="${escapeHtml(s.id)}">${escapeHtml(s.label)}</option>`
))
.join('');
sourceControl = `<select class="candidates-manual-source" id="candidates-manual-source">${optionsHtml}</select>`;
} else {
// Single-source mode — render a small static label, not a dropdown.
const onlySrc = availableSources[0];
const label = onlySrc ? onlySrc.label : (downloadMode || 'configured source');
sourceControl = `<span class="candidates-manual-source-label">Searching ${escapeHtml(label)}</span>`;
}
const manualSearchHtml = `
<div class="candidates-manual-search">
<div class="candidates-manual-search-header">Manual search</div>
<div class="candidates-manual-search-controls">
<input type="text"
class="candidates-manual-search-input"
id="candidates-manual-search-input"
placeholder="Search for a different track..."
maxlength="200" />
${sourceControl}
<button class="candidates-manual-search-btn"
id="candidates-manual-search-btn"
disabled>Search</button>
</div>
<div class="candidates-manual-search-hint" id="candidates-manual-search-hint">Type at least 2 characters</div>
<div class="candidates-manual-search-results" id="candidates-manual-search-results"></div>
</div>`;
overlay = document.createElement('div');
overlay.id = 'candidates-modal-overlay';
overlay.className = 'candidates-modal-overlay';
@ -2962,14 +3016,17 @@ function _renderCandidatesModal(data) {
</div>
<div class="candidates-modal-body">
${errorMsg ? `<div class="candidates-error-summary">${escapeHtml(errorMsg)}</div>` : ''}
<div class="candidates-count">${candidates.length} candidate${candidates.length !== 1 ? 's' : ''} found${candidates.length > 0 ? ' but none passed filters' : ''}</div>
<div class="candidates-table-wrapper">
<table class="candidates-table">
<thead><tr>
<th>#</th><th>File</th><th>Quality</th><th>Size</th><th>Duration</th><th>User</th><th></th>
</tr></thead>
<tbody>${tableRows}</tbody>
</table>
${manualSearchHtml}
<div class="candidates-auto-section">
<div class="candidates-count">${candidates.length} candidate${candidates.length !== 1 ? 's' : ''} found${candidates.length > 0 ? ' but none passed filters' : ''}</div>
<div class="candidates-table-wrapper">
<table class="candidates-table">
<thead><tr>
<th>#</th><th>File</th><th>Quality</th><th>Size</th><th>Duration</th><th>User</th><th></th>
</tr></thead>
<tbody>${tableRows}</tbody>
</table>
</div>
</div>
</div>
</div>`;
@ -2977,14 +3034,125 @@ function _renderCandidatesModal(data) {
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('visible'));
// Bind download buttons
overlay.querySelectorAll('.candidates-download-btn').forEach(btn => {
// Bind auto-candidate download buttons (existing behavior, scoped to
// .candidates-row-auto so manual-search rows don't double-trigger).
overlay.querySelectorAll('.candidates-row-auto .candidates-download-btn').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index);
const c = candidates[idx];
if (c) downloadCandidate(data.task_id, c, trackName);
});
});
// Wire manual search controls.
_wireManualSearch(overlay, data.task_id, trackName, isHybrid);
}
// Manual-search wiring — input/button/dropdown. Kept separate from
// _renderCandidatesModal so the existing render path stays readable and
// any future refactor can lift this into its own module.
function _wireManualSearch(overlay, taskId, trackName, isHybrid) {
const input = overlay.querySelector('#candidates-manual-search-input');
const button = overlay.querySelector('#candidates-manual-search-btn');
const hint = overlay.querySelector('#candidates-manual-search-hint');
const resultsContainer = overlay.querySelector('#candidates-manual-search-results');
const sourceSelect = overlay.querySelector('#candidates-manual-source');
if (!input || !button || !resultsContainer) return;
let currentResults = [];
let inFlight = false;
const updateButtonState = () => {
const q = (input.value || '').trim();
const tooShort = q.length < 2;
button.disabled = tooShort || inFlight;
if (hint) {
if (tooShort) {
hint.textContent = 'Type at least 2 characters';
hint.style.display = '';
} else {
hint.style.display = 'none';
}
}
};
const runSearch = async () => {
const q = (input.value || '').trim();
if (q.length < 2 || inFlight) return;
const source = sourceSelect ? sourceSelect.value : 'all';
inFlight = true;
button.disabled = true;
const originalLabel = button.textContent;
button.textContent = 'Searching...';
resultsContainer.innerHTML = '<div class="candidates-manual-search-loading">Searching...</div>';
try {
const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/manual-search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, source: source })
});
const payload = await resp.json().catch(() => ({}));
if (!resp.ok) {
resultsContainer.innerHTML = `<div class="candidates-manual-search-error">${escapeHtml(payload.error || 'Search failed')}</div>`;
currentResults = [];
return;
}
currentResults = Array.isArray(payload.candidates) ? payload.candidates : [];
_renderManualSearchResults(resultsContainer, currentResults, q, isHybrid, taskId, trackName);
} catch (err) {
console.error('Manual search failed:', err);
resultsContainer.innerHTML = '<div class="candidates-manual-search-error">Search request failed</div>';
currentResults = [];
} finally {
inFlight = false;
button.textContent = originalLabel;
updateButtonState();
}
};
input.addEventListener('input', updateButtonState);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !button.disabled) {
e.preventDefault();
runSearch();
}
});
button.addEventListener('click', runSearch);
updateButtonState();
}
function _renderManualSearchResults(container, results, query, isHybrid, taskId, trackName) {
if (!results || results.length === 0) {
container.innerHTML = `<div class="candidates-manual-search-empty">No manual search results for "${escapeHtml(query)}"</div>`;
return;
}
let rows = '';
results.forEach((c, i) => {
// Always show the source badge on manual-search rows when there's
// more than one possible source — covers hybrid "All" + per-source
// selections so the user always sees provenance.
rows += _renderCandidateRow(c, i, 'candidates-row-manual', isHybrid);
});
container.innerHTML = `
<div class="candidates-manual-search-count">${results.length} result${results.length !== 1 ? 's' : ''}</div>
<div class="candidates-table-wrapper">
<table class="candidates-table">
<thead><tr>
<th>#</th><th>File</th><th>Quality</th><th>Size</th><th>Duration</th><th>User</th><th></th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
container.querySelectorAll('.candidates-row-manual .candidates-download-btn').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index);
const c = results[idx];
if (c) downloadCandidate(taskId, c, trackName);
});
});
}
async function downloadCandidate(taskId, candidate, trackName) {

View file

@ -3413,6 +3413,11 @@ function closeHelperSearch() {
// projects that span multiple commits before shipping. Strip the flag at
// release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = {
'2.4.4': [
// --- post-2.4.3 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
{ date: 'Unreleased — 2.4.4 dev cycle' },
{ title: 'Manual Search In The Failed-Track Candidates Modal', desc: 'when a download fails or returns "not found" the user can already click the status cell to open a modal showing whatever search candidates the auto-search left over and pick a different one. that modal now ALSO has a manual search bar. type any query, hit search, get a fresh round of results from the download sources without having to start the whole download flow over from the search page. solves the case where the auto-query was bad (featured artist not in title, parentheticals like "(remastered 2019)" tripping the matcher, slight artist-name variants) but the file genuinely exists on the source. source picker is smart per download mode: single-source mode (soulseek-only / youtube-only / etc) shows a "searching X" label, no dropdown; hybrid mode shows a dropdown with "all sources" default plus every configured source — picking "all" runs parallel searches across all of them and tags each result row with its source badge. only configured sources show up; unconfigured ones are hidden. clicking a result reuses the existing retry-download flow → same path, same acoustid verification on the file when it lands, no shortcut around the safety net. additive in the truest sense: the existing modal layout / candidates table / download buttons are byte-identical when the user doesn\'t use manual search. backend extends the candidates endpoint with `download_mode` + `available_sources` + a `source` field per candidate (purely additive — old fields untouched), and adds a new `POST /api/downloads/task/<id>/manual-search` that mirrors the candidates response shape so the frontend renderer is reused. 10 new tests pin endpoint validation (query length / source whitelist / task 404), single-source dispatch, parallel "all" dispatch, unconfigured-source skip + reject, response shape, and exception isolation per source.', page: 'downloads' },
],
'2.4.3': [
// --- May 8, 2026 — patch release ---
{ date: 'May 8, 2026 — 2.4.3 release' },

View file

@ -40845,6 +40845,127 @@ div.artist-hero-badge {
border-color: rgb(var(--accent-rgb));
}
/* Manual search section inside the candidates modal sits above the
auto-candidates table; visually separated by a subtle border + spacing. */
.candidates-manual-search {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 18px;
}
.candidates-manual-search-header {
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.candidates-manual-search-controls {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.candidates-manual-search-input {
flex: 1;
min-width: 200px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
padding: 8px 12px;
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
outline: none;
transition: border-color 0.15s ease;
}
.candidates-manual-search-input:focus {
border-color: rgba(var(--accent-rgb), 0.6);
}
.candidates-manual-source {
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
padding: 8px 10px;
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
cursor: pointer;
outline: none;
}
.candidates-manual-source:focus {
border-color: rgba(var(--accent-rgb), 0.6);
}
.candidates-manual-source-label {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
padding: 0 8px;
white-space: nowrap;
}
.candidates-manual-search-btn {
background: rgba(var(--accent-rgb), 0.2);
border: 1px solid rgba(var(--accent-rgb), 0.4);
color: rgb(var(--accent-rgb));
border-radius: 6px;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.candidates-manual-search-btn:hover:not(:disabled) {
background: rgba(var(--accent-rgb), 0.35);
border-color: rgb(var(--accent-rgb));
}
.candidates-manual-search-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.candidates-manual-search-hint {
color: rgba(255, 255, 255, 0.4);
font-size: 11px;
margin-top: 6px;
font-style: italic;
}
.candidates-manual-search-results {
margin-top: 12px;
}
.candidates-manual-search-results:empty {
display: none;
}
.candidates-manual-search-loading,
.candidates-manual-search-empty,
.candidates-manual-search-error {
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
padding: 14px;
text-align: center;
background: rgba(0, 0, 0, 0.15);
border-radius: 6px;
}
.candidates-manual-search-error {
color: rgba(255, 100, 100, 0.85);
}
.candidates-manual-search-count {
color: rgba(255, 255, 255, 0.55);
font-size: 12px;
margin-bottom: 8px;
}
.candidates-source-badge {
display: inline-block;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.7);
border-radius: 4px;
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
margin-right: 4px;
vertical-align: middle;
}
/* =====================================
HYDRABASE PAGE
===================================== */