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:
parent
4277648734
commit
996575fab3
5 changed files with 1009 additions and 55 deletions
458
tests/test_manual_search_endpoint.py
Normal file
458
tests/test_manual_search_endpoint.py
Normal 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
|
||||
240
web_server.py
240
web_server.py
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
===================================== */
|
||||
|
|
|
|||
Loading…
Reference in a new issue