diff --git a/tests/test_manual_search_endpoint.py b/tests/test_manual_search_endpoint.py new file mode 100644 index 00000000..f89e61ad --- /dev/null +++ b/tests/test_manual_search_endpoint.py @@ -0,0 +1,458 @@ +"""Tests for the /api/downloads/task//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 diff --git a/web_server.py b/web_server.py index 5c76527f..f327aa7f 100644 --- a/web_server.py +++ b/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//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//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.""" diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 8db1bc0e..91e2b879 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -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 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 + ? `${c.quality.toUpperCase()}` + : ''; + const sourceBadge = (showSourceBadge && c.source) + ? `${escapeHtml(c.source)} ` + : ''; + return ` + ${index + 1} + ${sourceBadge}${escapeHtml(shortFile)} + ${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''} + ${_candidatesFmtSize(c.size)} + ${_candidatesFmtDur(c.duration)} + ${escapeHtml(c.username || '-')} + + `; +} + 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 = ` No candidates were found during search.`; } 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 - ? `${c.quality.toUpperCase()}` - : ''; - tableRows += ` - ${i + 1} - ${escapeHtml(shortFile)} - ${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''} - ${fmtSize(c.size)} - ${fmtDur(c.duration)} - ${escapeHtml(c.username || '-')} - - `; + tableRows += _renderCandidateRow(c, i, 'candidates-row-auto', isHybrid); }); } + // ----- Manual search bar ----- + let sourceControl; + if (isHybrid && availableSources.length > 0) { + const optionsHtml = [''] + .concat(availableSources.map(s => + `` + )) + .join(''); + sourceControl = ``; + } 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 = `Searching ${escapeHtml(label)}`; + } + + const manualSearchHtml = ` + `; + overlay = document.createElement('div'); overlay.id = 'candidates-modal-overlay'; overlay.className = 'candidates-modal-overlay'; @@ -2962,14 +3016,17 @@ function _renderCandidatesModal(data) {
${errorMsg ? `
${escapeHtml(errorMsg)}
` : ''} -
${candidates.length} candidate${candidates.length !== 1 ? 's' : ''} found${candidates.length > 0 ? ' but none passed filters' : ''}
-
- - - - - ${tableRows} -
#FileQualitySizeDurationUser
+ ${manualSearchHtml} +
+
${candidates.length} candidate${candidates.length !== 1 ? 's' : ''} found${candidates.length > 0 ? ' but none passed filters' : ''}
+
+ + + + + ${tableRows} +
#FileQualitySizeDurationUser
+
`; @@ -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 = '
Searching...
'; + + 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 = `
${escapeHtml(payload.error || 'Search failed')}
`; + 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 = '
Search request failed
'; + 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 = `
No manual search results for "${escapeHtml(query)}"
`; + 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 = ` +
${results.length} result${results.length !== 1 ? 's' : ''}
+
+ + + + + ${rows} +
#FileQualitySizeDurationUser
+
`; + + 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) { diff --git a/webui/static/helper.js b/webui/static/helper.js index b9f978fb..2c4ea506 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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//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' }, diff --git a/webui/static/style.css b/webui/static/style.css index b49adeab..631046a9 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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 ===================================== */