soulsync/tests/test_manual_search_endpoint.py
Broque Thomas e20994e1c7 Manual picks: stream results, don't auto-retry, fix stuck-at-0%
Three follow-on fixes to the manual-search candidates modal once people
started actually using it:

1. NDJSON streaming. Manual search waited for every source to return
   before showing anything. Now streams one event per source as each
   completes — header line, source_results per source, done terminator.
   Frontend appends rows incrementally via response.body.getReader().

2. Manual picks no longer auto-retry on failure. New _user_manual_pick
   flag set on the task in /download-candidate. Both monitor retry
   paths (not-in-live-transfers stuck + Errored state) bail on the
   flag. Surfaces the failure to the user instead of silently picking
   a different candidate via fresh search.

3. Non-Soulseek manual picks (youtube/tidal/qobuz/hifi/deezer/
   soundcloud/lidarr) no longer stuck at "downloading 0%" forever. The
   live_transfers IF branch now marks manual-pick tasks failed
   directly when the engine reports Errored, instead of deferring to
   the monitor (which bails on manual picks). Engine fallback in else
   branch covers the rare race where the orchestrator's pre-populated
   transfer lookup is missing the entry.

Plus a deadlock fix discovered along the way: the new failure path
synchronously called on_download_completed while holding tasks_lock,
which itself re-acquires the same Lock — non-reentrant
threading.Lock self-deadlocked the polling thread. While wedged, every
other endpoint that needed the lock (including /candidates → other
failed rows couldn't open modals) hung waiting. Moved completion
callbacks onto a daemon thread so the lock releases first.

Plus failed/not_found/cancelled rows are now ALWAYS clickable (not
just when the auto-search cached candidates) — the modal carries the
manual search bar, which is the user's recourse for empty results.

Plus manual download worker now runs on a dedicated thread instead of
competing with the batch's 3-worker missing_download_executor pool —
saturated batches no longer queue manual picks indefinitely.

All scoped to manual picks via the _user_manual_pick flag — auto
attempt flow byte-identical to before. Engine fallback gated on the
flag too so auto attempts in the else branch keep the original
do-nothing behavior (safety valve handles the stuck-forever case).

Also dropped _handle_failed_download from web_server.py — defined
but had no callers (dead code).

17 new unit tests pin the gate behavior:
- engine fallback: Errored/Cancelled/Succeeded/InProgress transitions,
  manual-pick gate, terminal-state skip, soulseek skip, missing
  download_id skip, engine returning None, orchestrator exception
- monitor: manual-pick skips not-in-live-transfers retry + Errored
  retry
- IF-branch end-to-end: Errored marks failed, "Completed, Errored"
  hits failure branch, auto attempts defer to monitor

Manual-search endpoint tests rewritten for NDJSON: 11 cases (validation,
single-source dispatch, parallel "all" dispatch, one-event-per-source
streaming shape, unconfigured-source skip + reject, header metadata,
per-source exception isolation).

Full suite 2259 passed, 1 skipped.
2026-05-08 15:12:58 -07:00

498 lines
19 KiB
Python

"""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.
The endpoint streams results as NDJSON — one JSON object per line — so
the modal can render rows from each source as that source's search
completes, instead of blocking the whole UI on the slowest source. The
``_consume_ndjson`` helper below replays the stream as a list of message
dicts so test assertions stay readable.
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
- Per-source exceptions emit ``source_error`` events but don't fail the
overall stream
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
import json
from unittest.mock import MagicMock, patch
import pytest
def _consume_ndjson(resp) -> list:
"""Parse a Flask test-client streaming response into a list of
NDJSON message dicts. The endpoint emits one JSON object per line,
each terminated by ``\\n``.
"""
raw = resp.get_data(as_text=True)
msgs = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
msgs.append(json.loads(line))
return msgs
def _flatten_candidates(msgs: list) -> list:
"""Pull all candidate dicts out of every ``source_results`` message —
same flat list the old single-shot response used to return."""
out = []
for m in msgs:
if m.get('type') == 'source_results':
out.extend(m.get('candidates', []))
return out
# ---------------------------------------------------------------------------
# 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."""
client, ctx = manual_search_client
plugins = ctx['plugins']
plugins['youtube'] = _make_plugin(
search_results=[_fake_track_result('youtube_song.mp3', 'youtube')]
)
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
msgs = _consume_ndjson(resp)
candidates = _flatten_candidates(msgs)
assert len(candidates) == 1
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 so it's filtered out — 5 of 6 sources hit."""
client, ctx = manual_search_client
plugins = ctx['plugins']
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
msgs = _consume_ndjson(resp)
candidates = _flatten_candidates(msgs)
assert len(candidates) == 5
for src_name in ('soulseek', 'youtube', 'qobuz', 'hifi', 'deezer'):
assert plugins[src_name].search.call_count == 1
assert plugins['tidal'].search.call_count == 0
def test_manual_search_streams_one_event_per_source(manual_search_client):
"""source='all' must emit one ``source_results`` event per configured
source — not a single batched event. That's what lets the frontend
render rows as they arrive instead of waiting for the slowest source."""
client, ctx = manual_search_client
plugins = ctx['plugins']
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'},
)
msgs = _consume_ndjson(resp)
source_events = [m for m in msgs if m.get('type') == 'source_results']
seen_sources = {m['source'] for m in source_events}
assert seen_sources == {'soulseek', 'youtube', 'qobuz', 'hifi', 'deezer'}
# One header + one per source + one done terminator
assert msgs[0]['type'] == 'header'
assert msgs[-1]['type'] == 'done'
assert msgs[-1]['total'] == 5
def test_manual_search_skips_unconfigured_sources(manual_search_client):
"""Sources whose is_configured() returns False are excluded from the
'all' dispatch list."""
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
msgs = _consume_ndjson(resp)
header = msgs[0]
assert header['type'] == 'header'
available_ids = {s['id'] for s in header['available_sources']}
assert 'tidal' not in available_ids
assert {'soulseek', 'youtube', 'qobuz', 'hifi', 'deezer'} <= available_ids
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."""
client, _ctx = manual_search_client
resp = client.post(
'/api/downloads/task/task-abc/manual-search',
json={'query': 'something', 'source': 'tidal'},
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# Response shape
# ---------------------------------------------------------------------------
def test_manual_search_header_carries_track_and_source_metadata(manual_search_client):
"""The first NDJSON line is a ``header`` event carrying track_info,
download_mode, available_sources, and the echoed query — everything
the frontend needs to render the modal shell before any results
arrive."""
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
msgs = _consume_ndjson(resp)
header = msgs[0]
assert header['type'] == 'header'
assert header['task_id'] == 'task-abc'
assert header['track_info']['name'] == 'Test Track'
assert header['download_mode'] == 'hybrid'
assert isinstance(header['available_sources'], list)
assert header['query'] == 'drake feelings'
assert header['sources_queried'] == ['youtube']
candidates = _flatten_candidates(msgs)
assert len(candidates) == 1
for candidate in candidates:
assert candidate['source'] == 'youtube'
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, available_sources
contains just that one entry. Frontend swaps the dropdown for a static
label in this case."""
client, _ctx = manual_search_client
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
msgs = _consume_ndjson(resp)
header = msgs[0]
assert header['download_mode'] == 'soulseek'
available_ids = [s['id'] for s in header['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 emits a
``source_error`` event for it but other sources' results still come
through. The whole stream doesn't fail."""
client, ctx = manual_search_client
plugins = ctx['plugins']
import web_server
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
msgs = _consume_ndjson(resp)
error_events = [m for m in msgs if m.get('type') == 'source_error']
assert any(m['source'] == 'soulseek' for m in error_events)
assert any('network blip' in m.get('error', '') for m in error_events)
candidates = _flatten_candidates(msgs)
yt_results = [c for c in candidates if c.get('source') == 'youtube']
assert len(yt_results) == 1