Route primary Soulseek album downloads through the album-bundle staging flow, reusing preflight-selected folders when available. In hybrid mode, only the first configured source can claim whole-album bundle behavior so later Soulseek fallback keeps the existing per-track/source-reuse path. Allow Soulseek album bundles to stage completed tracks when some same-source transfers fail or time out, and keep partial bundles from blocking per-track fallback. Add coverage for dispatcher gating, master flow ordering, task-worker staged-miss behavior, and Soulseek bundle polling.
830 lines
33 KiB
Python
830 lines
33 KiB
Python
"""Phase A pinning tests for SoulseekClient's download lifecycle.
|
|
|
|
These tests pin the OBSERVABLE BEHAVIOR of `SoulseekClient.download` /
|
|
`get_all_downloads` / `cancel_download` so the upcoming download
|
|
engine refactor (which lifts shared state + thread workers + search
|
|
retry into a central engine) can't drift the per-source contract.
|
|
|
|
The contract these tests pin is what the engine will call into via
|
|
`plugin.download_raw(target_id)` / `plugin.cancel_raw(target_id)`
|
|
after the refactor lands. If a future commit breaks any of these
|
|
expectations, the diff fails fast — long before a real download
|
|
attempt against a live slskd would have surfaced the bug.
|
|
|
|
NOTE: Soulseek is structurally different from the streaming sources.
|
|
It has NO local thread worker — slskd manages downloads server-side
|
|
and the client just polls for state. So Soulseek skips most of the
|
|
engine refactor's thread-extraction work; what stays critical is
|
|
the slskd HTTP API contract (endpoints, payload shape, id
|
|
extraction). That's what these tests pin.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from core.soulseek_client import SoulseekClient
|
|
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
|
|
|
|
|
def _run_async(coro):
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration / lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_is_configured_returns_false_when_no_base_url():
|
|
"""Pinning: an unconfigured client (no slskd URL set) reports
|
|
is_configured() == False. The orchestrator's hybrid fallback +
|
|
every consumer that gates on is_configured() depends on this."""
|
|
client = SoulseekClient.__new__(SoulseekClient)
|
|
client.base_url = None
|
|
client.api_key = None
|
|
assert client.is_configured() is False
|
|
|
|
|
|
def test_is_configured_returns_true_when_base_url_set():
|
|
"""Pinning: configured client (slskd URL present) reports True."""
|
|
client = SoulseekClient.__new__(SoulseekClient)
|
|
client.base_url = 'http://localhost:5030'
|
|
client.api_key = 'test-key'
|
|
assert client.is_configured() is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# download()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def configured_client():
|
|
"""A SoulseekClient with the slskd URL set but no real network. Tests
|
|
individually patch `_make_request` to return whatever shape they
|
|
want to exercise."""
|
|
client = SoulseekClient.__new__(SoulseekClient)
|
|
client.base_url = 'http://localhost:5030'
|
|
client.api_key = 'test-key'
|
|
client.download_path = Path('./test_downloads')
|
|
return client
|
|
|
|
|
|
def test_download_returns_none_when_not_configured():
|
|
"""Pinning: an unconfigured client refuses downloads — returns
|
|
None silently rather than raising. Used as the soft-fail signal
|
|
by the orchestrator's per-source fallback chain."""
|
|
client = SoulseekClient.__new__(SoulseekClient)
|
|
client.base_url = None
|
|
result = _run_async(client.download('user', 'song.flac', 1024))
|
|
assert result is None
|
|
|
|
|
|
def test_download_hits_transfers_downloads_username_endpoint(configured_client):
|
|
"""Pinning: the primary download endpoint is
|
|
`transfers/downloads/<username>` POST. This shape was chosen to
|
|
match slskd's web-interface API exactly. Changing it breaks
|
|
every download against current slskd builds."""
|
|
captured = []
|
|
|
|
async def fake_request(method, endpoint, json=None, **kwargs):
|
|
captured.append((method, endpoint, json))
|
|
return {'id': 'dl-id-from-slskd'}
|
|
|
|
with patch.object(configured_client, '_make_request', side_effect=fake_request):
|
|
result = _run_async(configured_client.download('user', 'song.flac', 1024))
|
|
|
|
assert result == 'dl-id-from-slskd'
|
|
method, endpoint, payload = captured[0]
|
|
assert method == 'POST'
|
|
assert endpoint == 'transfers/downloads/user'
|
|
# Payload is the slskd web-interface array format.
|
|
assert isinstance(payload, list)
|
|
assert payload[0]['filename'] == 'song.flac'
|
|
assert payload[0]['size'] == 1024
|
|
|
|
|
|
def test_download_extracts_id_from_dict_response(configured_client):
|
|
"""Pinning: when slskd returns `{id: ...}`, that's the
|
|
download_id the orchestrator uses to track the download."""
|
|
with patch.object(configured_client, '_make_request',
|
|
AsyncMock(return_value={'id': 'abc123'})):
|
|
result = _run_async(configured_client.download('user', 'song.flac', 1024))
|
|
assert result == 'abc123'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# album bundle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _track(username='peer', filename='Artist/Album/01 - Song.flac', title='Song', number=1, size=10):
|
|
return TrackResult(
|
|
username=username,
|
|
filename=filename,
|
|
size=size,
|
|
bitrate=None,
|
|
duration=180000,
|
|
quality='flac',
|
|
free_upload_slots=1,
|
|
upload_speed=1_000_000,
|
|
queue_length=0,
|
|
artist='Artist',
|
|
title=title,
|
|
album='Album',
|
|
track_number=number,
|
|
)
|
|
|
|
|
|
def test_album_bundle_stages_one_selected_soulseek_folder(configured_client, tmp_path):
|
|
configured_client.download_path = tmp_path
|
|
local_file = tmp_path / '01 - Song.flac'
|
|
local_file.write_bytes(b'audio')
|
|
track = _track(filename='Artist/Album/01 - Song.flac')
|
|
album = AlbumResult(
|
|
username='peer',
|
|
album_path='Artist/Album',
|
|
album_title='Album',
|
|
artist='Artist',
|
|
track_count=1,
|
|
total_size=10,
|
|
tracks=[track],
|
|
dominant_quality='flac',
|
|
free_upload_slots=1,
|
|
upload_speed=1_000_000,
|
|
queue_length=0,
|
|
)
|
|
events = []
|
|
|
|
with patch.object(configured_client, 'search', AsyncMock(return_value=([], [album]))), \
|
|
patch.object(configured_client, 'browse_user_directory', AsyncMock(return_value=[
|
|
{'filename': '01 - Song.flac', 'size': 10}
|
|
])), \
|
|
patch.object(configured_client, 'filter_results_by_quality_preference', side_effect=lambda tracks: tracks), \
|
|
patch.object(configured_client, 'download', AsyncMock(return_value='dl-1')) as download_mock, \
|
|
patch.object(configured_client, 'get_all_downloads', AsyncMock(return_value=[
|
|
DownloadStatus(
|
|
id='dl-1',
|
|
username='peer',
|
|
filename='Artist/Album/01 - Song.flac',
|
|
state='Completed, Succeeded',
|
|
progress=100,
|
|
size=10,
|
|
transferred=10,
|
|
speed=0,
|
|
)
|
|
])), \
|
|
patch('core.soulseek_client.get_poll_timeout', return_value=1), \
|
|
patch('core.soulseek_client.get_poll_interval', return_value=0.01):
|
|
outcome = configured_client.download_album_to_staging(
|
|
'Album',
|
|
'Artist',
|
|
str(tmp_path / 'staging'),
|
|
events.append,
|
|
)
|
|
|
|
assert outcome['success'] is True
|
|
assert outcome['fallback'] is False
|
|
assert len(outcome['files']) == 1
|
|
assert Path(outcome['files'][0]).read_bytes() == b'audio'
|
|
download_mock.assert_awaited_once_with(
|
|
'peer',
|
|
'Artist/Album/01 - Song.flac',
|
|
10,
|
|
)
|
|
assert events[-1]['state'] == 'staged'
|
|
|
|
|
|
def test_album_bundle_stages_completed_files_when_same_source_partially_times_out(configured_client, tmp_path):
|
|
configured_client.download_path = tmp_path
|
|
(tmp_path / '01 - Ready.flac').write_bytes(b'audio')
|
|
ready = _track(filename='Artist/Album/01 - Ready.flac', title='Ready', number=1)
|
|
timed_out = _track(filename='Artist/Album/02 - Waiting.flac', title='Waiting', number=2)
|
|
events = []
|
|
|
|
with patch.object(configured_client, 'download', AsyncMock(side_effect=['dl-1', 'dl-2'])), \
|
|
patch.object(configured_client, 'filter_results_by_quality_preference', side_effect=lambda tracks: tracks), \
|
|
patch.object(configured_client, 'get_all_downloads', AsyncMock(return_value=[
|
|
DownloadStatus(
|
|
id='dl-1',
|
|
username='peer',
|
|
filename='Artist/Album/01 - Ready.flac',
|
|
state='Completed, Succeeded',
|
|
progress=100,
|
|
size=10,
|
|
transferred=10,
|
|
speed=0,
|
|
),
|
|
DownloadStatus(
|
|
id='dl-2',
|
|
username='peer',
|
|
filename='Artist/Album/02 - Waiting.flac',
|
|
state='TimedOut',
|
|
progress=0,
|
|
size=10,
|
|
transferred=0,
|
|
speed=0,
|
|
),
|
|
])), \
|
|
patch('core.soulseek_client.get_poll_timeout', return_value=1), \
|
|
patch('core.soulseek_client.get_poll_interval', return_value=0.01):
|
|
outcome = configured_client.download_album_to_staging(
|
|
'Album',
|
|
'Artist',
|
|
str(tmp_path / 'staging'),
|
|
events.append,
|
|
preferred_source={'username': 'peer', 'folder_path': 'Artist/Album'},
|
|
preferred_tracks=[ready, timed_out],
|
|
)
|
|
|
|
assert outcome['success'] is True
|
|
assert outcome['fallback'] is False
|
|
assert len(outcome['files']) == 1
|
|
assert Path(outcome['files'][0]).name == '01 - Ready.flac'
|
|
assert any(event.get('failed') == 1 for event in events)
|
|
|
|
|
|
def test_album_bundle_falls_back_when_no_album_folder(configured_client, tmp_path):
|
|
configured_client.download_path = tmp_path
|
|
with patch.object(configured_client, 'search', AsyncMock(return_value=([], []))), \
|
|
patch.object(configured_client, 'download', AsyncMock(return_value='dl-1')) as dl:
|
|
outcome = configured_client.download_album_to_staging(
|
|
'Missing Album',
|
|
'Artist',
|
|
str(tmp_path / 'staging'),
|
|
)
|
|
|
|
assert outcome['success'] is False
|
|
assert outcome['fallback'] is True
|
|
assert 'No complete Soulseek album folders' in outcome['error']
|
|
dl.assert_not_awaited()
|
|
|
|
|
|
def test_download_extracts_id_from_list_response(configured_client):
|
|
"""Pinning: slskd sometimes returns a list of file objects.
|
|
The first item's id is the download_id."""
|
|
with patch.object(configured_client, '_make_request',
|
|
AsyncMock(return_value=[{'id': 'list-id'}, {'id': 'second'}])):
|
|
result = _run_async(configured_client.download('user', 'song.flac', 1024))
|
|
assert result == 'list-id'
|
|
|
|
|
|
def test_download_falls_back_to_filename_when_no_id_in_response(configured_client):
|
|
"""Pinning: defensive — older slskd builds returned 201 Created
|
|
with no id field. The client uses the filename as the download
|
|
identifier in that case so downstream tracking still works."""
|
|
with patch.object(configured_client, '_make_request',
|
|
AsyncMock(return_value={'status': 'queued'})):
|
|
result = _run_async(configured_client.download('user', 'song.flac', 1024))
|
|
assert result == 'song.flac'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_all_downloads()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_all_downloads_returns_empty_when_not_configured():
|
|
client = SoulseekClient.__new__(SoulseekClient)
|
|
client.base_url = None
|
|
result = _run_async(client.get_all_downloads())
|
|
assert result == []
|
|
|
|
|
|
def test_get_all_downloads_parses_nested_user_directory_files_response(configured_client):
|
|
"""Pinning: slskd's `transfers/downloads` returns
|
|
`[{username, directories: [{files: [...]}]}]`. The client
|
|
flattens that into a list of DownloadStatus objects, one per
|
|
file. Engine refactor's state aggregation depends on this shape."""
|
|
fake_response = [
|
|
{
|
|
'username': 'peer1',
|
|
'directories': [{
|
|
'files': [
|
|
{'id': 'f1', 'filename': 'a.flac', 'state': 'InProgress',
|
|
'size': 100, 'bytesTransferred': 50, 'averageSpeed': 1024},
|
|
{'id': 'f2', 'filename': 'b.flac', 'state': 'Completed, Succeeded',
|
|
'size': 200, 'bytesTransferred': 200, 'averageSpeed': 2048},
|
|
],
|
|
}],
|
|
},
|
|
]
|
|
|
|
with patch.object(configured_client, '_make_request',
|
|
AsyncMock(return_value=fake_response)):
|
|
result = _run_async(configured_client.get_all_downloads())
|
|
|
|
assert len(result) == 2
|
|
assert result[0].id == 'f1'
|
|
assert result[0].username == 'peer1'
|
|
assert result[0].state == 'InProgress'
|
|
assert result[1].id == 'f2'
|
|
# Pinning: 'Completed' state forces progress=100 regardless of source data.
|
|
assert result[1].progress == 100.0
|
|
|
|
|
|
def test_get_all_downloads_endpoint_is_transfers_downloads(configured_client):
|
|
"""Pinning: the listing endpoint is `transfers/downloads` (no
|
|
username). The 404'd `users/.../downloads` variant was tried
|
|
once and removed — keep it gone."""
|
|
captured = []
|
|
|
|
async def fake_request(method, endpoint, **kwargs):
|
|
captured.append((method, endpoint))
|
|
return []
|
|
|
|
with patch.object(configured_client, '_make_request', side_effect=fake_request):
|
|
_run_async(configured_client.get_all_downloads())
|
|
|
|
assert captured == [('GET', 'transfers/downloads')]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cancel_download()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cancel_download_returns_false_when_not_configured():
|
|
client = SoulseekClient.__new__(SoulseekClient)
|
|
client.base_url = None
|
|
result = _run_async(client.cancel_download('dl-id', 'user', remove=False))
|
|
assert result is False
|
|
|
|
|
|
def test_cancel_download_looks_up_username_when_not_provided(configured_client):
|
|
"""Pinning: orchestrator may call cancel_download without a
|
|
username hint. The client falls back to scanning all downloads
|
|
to find which peer owns it. Engine refactor must preserve this
|
|
so existing API endpoints that don't pass username keep working."""
|
|
fake_listing = [
|
|
{
|
|
'username': 'peer-owner',
|
|
'directories': [{
|
|
'files': [{'id': 'target-dl', 'filename': 'x.flac',
|
|
'state': 'InProgress', 'size': 0,
|
|
'bytesTransferred': 0, 'averageSpeed': 0}],
|
|
}],
|
|
},
|
|
]
|
|
|
|
captured_endpoints = []
|
|
|
|
async def fake_request(method, endpoint, **kwargs):
|
|
captured_endpoints.append((method, endpoint))
|
|
if method == 'GET' and endpoint == 'transfers/downloads':
|
|
return fake_listing
|
|
# The DELETE for cancel — return success
|
|
return True
|
|
|
|
with patch.object(configured_client, '_make_request', side_effect=fake_request):
|
|
_run_async(configured_client.cancel_download('target-dl', None, remove=False))
|
|
|
|
# The lookup hit get_all_downloads first, then the DELETE used the discovered username.
|
|
assert ('GET', 'transfers/downloads') in captured_endpoints
|
|
delete_calls = [(m, e) for m, e in captured_endpoints if m == 'DELETE']
|
|
assert delete_calls, "Expected at least one DELETE after username lookup"
|
|
# The cancel endpoint URL contains the discovered username.
|
|
assert any('peer-owner' in e for _, e in delete_calls)
|
|
|
|
|
|
def test_cancel_download_returns_false_when_username_lookup_fails(configured_client):
|
|
"""Pinning: if the download_id isn't in the active list, return
|
|
False rather than raising. Orchestrator treats False as "couldn't
|
|
cancel" and continues; an exception would propagate to the user."""
|
|
with patch.object(configured_client, '_make_request',
|
|
AsyncMock(return_value=[])):
|
|
result = _run_async(configured_client.cancel_download('missing-id', None))
|
|
assert result is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTTP timeout config (issue #499 — prevent worker thread deadlock)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_default_timeout_constant_has_bounded_values():
|
|
"""Pin issue #499 fix: the module-level timeout config is defined
|
|
with a hard ceiling so an unresponsive slskd can't wedge the
|
|
download worker thread permanently. Any future change that drops
|
|
or unbounds the timeout would re-introduce the
|
|
'downloads stop after 2-3 hours' deadlock."""
|
|
from core.soulseek_client import _SLSKD_DEFAULT_TIMEOUT
|
|
import aiohttp
|
|
|
|
assert isinstance(_SLSKD_DEFAULT_TIMEOUT, aiohttp.ClientTimeout)
|
|
# Total timeout must be set and bounded — prevents infinite hang.
|
|
assert _SLSKD_DEFAULT_TIMEOUT.total is not None
|
|
assert _SLSKD_DEFAULT_TIMEOUT.total > 0
|
|
assert _SLSKD_DEFAULT_TIMEOUT.total <= 300, (
|
|
f"Total timeout {_SLSKD_DEFAULT_TIMEOUT.total}s exceeds 5min "
|
|
"ceiling — slskd metadata calls should never legitimately take this long"
|
|
)
|
|
# Connect timeout bounded — TCP connect to slskd should be fast.
|
|
assert _SLSKD_DEFAULT_TIMEOUT.connect is not None
|
|
assert _SLSKD_DEFAULT_TIMEOUT.connect <= 60
|
|
|
|
|
|
def test_make_request_returns_none_on_timeout(configured_client):
|
|
"""Pin: when the slskd HTTP call times out (asyncio.TimeoutError),
|
|
``_make_request`` returns None rather than raising. The download
|
|
worker thread unblocks; the caller treats None as a normal failure
|
|
and the batch's stuck-detection later marks the task not_found.
|
|
Pre-fix this raised → propagated up the call stack → eventually
|
|
the worker thread died but only after wedging the executor pool."""
|
|
|
|
async def _raise_timeout(*args, **kwargs):
|
|
raise asyncio.TimeoutError("simulated slskd hang")
|
|
|
|
# Patch aiohttp.ClientSession to return a session whose request()
|
|
# context manager raises TimeoutError on entry.
|
|
class _StubSession:
|
|
async def __aenter__(self):
|
|
return self
|
|
async def __aexit__(self, *args):
|
|
return None
|
|
|
|
def request(self, *args, **kwargs):
|
|
class _Cm:
|
|
async def __aenter__(self_inner):
|
|
raise asyncio.TimeoutError("simulated slskd hang")
|
|
async def __aexit__(self_inner, *args):
|
|
return None
|
|
return _Cm()
|
|
|
|
async def close(self):
|
|
return None
|
|
|
|
with patch('aiohttp.ClientSession', return_value=_StubSession()):
|
|
result = _run_async(configured_client._make_request('GET', 'transfers/downloads'))
|
|
|
|
assert result is None, "Timeout must return None, not raise"
|
|
|
|
|
|
def test_make_direct_request_returns_none_on_timeout(configured_client):
|
|
"""Same pin for ``_make_direct_request`` (the non-/api/v0/ helper).
|
|
Both code paths are used by different slskd endpoints — neither
|
|
can be allowed to wedge."""
|
|
|
|
class _StubSession:
|
|
async def __aenter__(self):
|
|
return self
|
|
async def __aexit__(self, *args):
|
|
return None
|
|
|
|
def request(self, *args, **kwargs):
|
|
class _Cm:
|
|
async def __aenter__(self_inner):
|
|
raise asyncio.TimeoutError("simulated slskd hang")
|
|
async def __aexit__(self_inner, *args):
|
|
return None
|
|
return _Cm()
|
|
|
|
async def close(self):
|
|
return None
|
|
|
|
with patch('aiohttp.ClientSession', return_value=_StubSession()):
|
|
result = _run_async(configured_client._make_direct_request('GET', 'health'))
|
|
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Issue #649 — connection-error log spam suppression
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_unreachable_session(error_message: str = 'Cannot connect to host'):
|
|
"""Stub aiohttp session whose request() raises ClientConnectorError."""
|
|
import aiohttp
|
|
from unittest.mock import MagicMock
|
|
|
|
class _Cm:
|
|
async def __aenter__(self_inner):
|
|
# ClientConnectorError needs a connection_key + OSError. The
|
|
# exact values don't matter for the test — we just need an
|
|
# instance of the right class so the except-branch fires.
|
|
os_err = OSError(-2, 'Name or service not known')
|
|
raise aiohttp.ClientConnectorError(MagicMock(), os_err)
|
|
async def __aexit__(self_inner, *args):
|
|
return None
|
|
|
|
class _StubSession:
|
|
async def __aenter__(self):
|
|
return self
|
|
async def __aexit__(self, *args):
|
|
return None
|
|
def request(self, *args, **kwargs):
|
|
return _Cm()
|
|
async def close(self):
|
|
return None
|
|
|
|
return _StubSession
|
|
|
|
|
|
def test_unreachable_slskd_returns_none_not_raises(configured_client):
|
|
"""Pin: ClientConnectorError must not propagate. Caller treats None
|
|
as a normal failure (same as a 5xx) — every consumer that gates on
|
|
`if response is None` keeps working when slskd is unreachable."""
|
|
StubSession = _build_unreachable_session()
|
|
with patch('aiohttp.ClientSession', return_value=StubSession()):
|
|
result = _run_async(configured_client._make_request('GET', 'transfers/downloads'))
|
|
assert result is None
|
|
|
|
|
|
def test_unreachable_slskd_logs_warning_once_then_debug(configured_client, caplog):
|
|
"""Issue #649: status polling at /api/downloads/status fans out to
|
|
every plugin including soulseek even when the user has soulseek
|
|
toggled out, so each frontend poll produced an ERROR log line. Pin
|
|
that the FIRST unreachable response emits one WARNING with
|
|
actionable context, and subsequent repeats demote to DEBUG so the
|
|
log isn't spammed for the lifetime of every non-soulseek download."""
|
|
import logging
|
|
configured_client._last_unreachable_logged = False
|
|
StubSession = _build_unreachable_session()
|
|
|
|
with patch('aiohttp.ClientSession', return_value=StubSession()):
|
|
with caplog.at_level(logging.DEBUG, logger='soulseek_client'):
|
|
# Three repeated polls — first must warn, rest must stay quiet.
|
|
_run_async(configured_client._make_request('GET', 'transfers/downloads'))
|
|
_run_async(configured_client._make_request('GET', 'transfers/downloads'))
|
|
_run_async(configured_client._make_request('GET', 'transfers/downloads'))
|
|
|
|
warning_records = [r for r in caplog.records if r.levelno == logging.WARNING
|
|
and 'slskd unreachable' in r.message]
|
|
error_records = [r for r in caplog.records if r.levelno == logging.ERROR
|
|
and 'Error making API request' in r.message]
|
|
assert len(warning_records) == 1, \
|
|
f"Expected exactly 1 WARNING (one-time slskd-unreachable notice), got {len(warning_records)}"
|
|
assert len(error_records) == 0, \
|
|
"Connection errors must not log at ERROR — that's the spam pattern #649 reported"
|
|
assert configured_client._last_unreachable_logged is True
|
|
|
|
|
|
def test_unreachable_flag_resets_on_successful_response(configured_client, caplog):
|
|
"""When slskd comes back up after a stretch of being down, a fresh
|
|
WARNING should fire if it goes down again later — the suppression is
|
|
per-outage, not per-process-lifetime. The flag resets on any
|
|
successful (200/201/204) response."""
|
|
import logging
|
|
configured_client._last_unreachable_logged = True # Simulate prior outage already warned
|
|
|
|
# Simulate a 200 response — must reset the suppression flag.
|
|
class _OkCm:
|
|
async def __aenter__(self_inner):
|
|
class _Resp:
|
|
status = 200
|
|
reason = 'OK'
|
|
async def text(self_resp):
|
|
return '{"ok": true}'
|
|
async def json(self_resp):
|
|
return {'ok': True}
|
|
return _Resp()
|
|
async def __aexit__(self_inner, *args):
|
|
return None
|
|
|
|
class _OkSession:
|
|
async def __aenter__(self):
|
|
return self
|
|
async def __aexit__(self, *args):
|
|
return None
|
|
def request(self, *args, **kwargs):
|
|
return _OkCm()
|
|
async def close(self):
|
|
return None
|
|
|
|
with patch('aiohttp.ClientSession', return_value=_OkSession()):
|
|
_run_async(configured_client._make_request('GET', 'server/state'))
|
|
|
|
assert configured_client._last_unreachable_logged is False, \
|
|
"Successful response must reset the suppression flag so a future outage warns again"
|
|
|
|
|
|
def test_make_direct_request_also_suppresses_unreachable_spam(configured_client, caplog):
|
|
"""`_make_direct_request` shares the same base_url and same outage
|
|
mode, so it gets the same WARNING-once + DEBUG-after treatment."""
|
|
import logging
|
|
configured_client._last_unreachable_logged = False
|
|
StubSession = _build_unreachable_session()
|
|
|
|
with patch('aiohttp.ClientSession', return_value=StubSession()):
|
|
with caplog.at_level(logging.DEBUG, logger='soulseek_client'):
|
|
_run_async(configured_client._make_direct_request('GET', 'health'))
|
|
_run_async(configured_client._make_direct_request('GET', 'health'))
|
|
|
|
warning_records = [r for r in caplog.records if r.levelno == logging.WARNING
|
|
and 'slskd unreachable' in r.message]
|
|
error_records = [r for r in caplog.records if r.levelno == logging.ERROR
|
|
and 'Error making direct API request' in r.message]
|
|
assert len(warning_records) == 1
|
|
assert len(error_records) == 0
|
|
|
|
|
|
def test_non_connection_exception_still_logs_error(configured_client, caplog):
|
|
"""Guard: only ClientConnectorError gets the suppression treatment.
|
|
Any other exception (programming bug, unexpected aiohttp behaviour,
|
|
etc.) must still surface at ERROR so we don't accidentally hide
|
|
real problems behind the noise reduction."""
|
|
import logging
|
|
|
|
class _BoomCm:
|
|
async def __aenter__(self_inner):
|
|
raise ValueError("not a connection error — should still log ERROR")
|
|
async def __aexit__(self_inner, *args):
|
|
return None
|
|
|
|
class _BoomSession:
|
|
async def __aenter__(self):
|
|
return self
|
|
async def __aexit__(self, *args):
|
|
return None
|
|
def request(self, *args, **kwargs):
|
|
return _BoomCm()
|
|
async def close(self):
|
|
return None
|
|
|
|
with patch('aiohttp.ClientSession', return_value=_BoomSession()):
|
|
with caplog.at_level(logging.DEBUG, logger='soulseek_client'):
|
|
result = _run_async(configured_client._make_request('GET', 'transfers/downloads'))
|
|
|
|
assert result is None # Still returns None — non-raising contract preserved
|
|
error_records = [r for r in caplog.records if r.levelno == logging.ERROR
|
|
and 'Error making API request' in r.message]
|
|
assert len(error_records) == 1, "Non-connection exceptions must still log ERROR"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Issue #652 — quarantined-source dedup in the candidate filter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _mk_track_result(username='peer', filename='song.flac', quality='flac',
|
|
bitrate=1411, size=10_000_000, duration=180_000):
|
|
"""Build a minimal TrackResult for the candidate filter tests."""
|
|
from core.download_plugins.types import TrackResult
|
|
return TrackResult(
|
|
username=username,
|
|
filename=filename,
|
|
size=size,
|
|
bitrate=bitrate,
|
|
duration=duration,
|
|
quality=quality,
|
|
free_upload_slots=1,
|
|
upload_speed=1_000_000,
|
|
queue_length=0,
|
|
)
|
|
|
|
|
|
def test_drop_quarantined_sources_keeps_clean_candidates(configured_client, tmp_path, monkeypatch):
|
|
"""When no candidate matches a quarantined `(username, filename)`,
|
|
every result passes through. Filter is a no-op for clean searches."""
|
|
quarantine_dir = tmp_path / 'ss_quarantine'
|
|
quarantine_dir.mkdir()
|
|
|
|
# Patch config_manager to point at our temp download path.
|
|
import core.soulseek_client as sc
|
|
monkeypatch.setattr(sc.config_manager, 'get',
|
|
lambda key, default=None: str(tmp_path) if key == 'soulseek.download_path' else default)
|
|
|
|
results = [
|
|
_mk_track_result(username='goodpeer1', filename='a.flac'),
|
|
_mk_track_result(username='goodpeer2', filename='b.flac'),
|
|
]
|
|
|
|
kept = configured_client._drop_quarantined_sources(results)
|
|
|
|
assert len(kept) == 2
|
|
assert {r.username for r in kept} == {'goodpeer1', 'goodpeer2'}
|
|
|
|
|
|
def test_drop_quarantined_sources_drops_known_bad(configured_client, tmp_path, monkeypatch):
|
|
"""Issue #652 core contract: a candidate whose `(username, filename)`
|
|
matches a quarantined entry is dropped before the quality picker
|
|
ranks it. Stops the loop where the same source kept winning the
|
|
quality picker and re-downloading itself."""
|
|
import json as _json
|
|
quarantine_dir = tmp_path / 'ss_quarantine'
|
|
quarantine_dir.mkdir()
|
|
|
|
# Write a sidecar matching the bad source.
|
|
sidecar = {
|
|
"original_filename": "bad.flac",
|
|
"quarantine_reason": "AcoustID mismatch",
|
|
"context": {
|
|
"original_search_result": {
|
|
"username": "badpeer", "filename": "albums/bad.flac",
|
|
},
|
|
},
|
|
}
|
|
(quarantine_dir / "20260518_120000.json").write_text(_json.dumps(sidecar))
|
|
|
|
import core.soulseek_client as sc
|
|
monkeypatch.setattr(sc.config_manager, 'get',
|
|
lambda key, default=None: str(tmp_path) if key == 'soulseek.download_path' else default)
|
|
|
|
results = [
|
|
_mk_track_result(username='badpeer', filename='albums/bad.flac'),
|
|
_mk_track_result(username='goodpeer', filename='albums/good.flac'),
|
|
]
|
|
|
|
kept = configured_client._drop_quarantined_sources(results)
|
|
|
|
assert len(kept) == 1
|
|
assert kept[0].username == 'goodpeer'
|
|
|
|
|
|
def test_drop_quarantined_sources_returns_input_when_quarantine_missing(configured_client, tmp_path, monkeypatch):
|
|
"""No quarantine directory yet (fresh install / never used) —
|
|
helper returns an empty set; filter returns the input unchanged.
|
|
Defaults to today's behaviour for users with no quarantine history."""
|
|
import core.soulseek_client as sc
|
|
monkeypatch.setattr(sc.config_manager, 'get',
|
|
lambda key, default=None: str(tmp_path) if key == 'soulseek.download_path' else default)
|
|
|
|
results = [_mk_track_result(username='peer', filename='song.flac')]
|
|
|
|
kept = configured_client._drop_quarantined_sources(results)
|
|
|
|
assert kept == results
|
|
|
|
|
|
def test_drop_quarantined_sources_swallows_filesystem_errors(configured_client, monkeypatch):
|
|
"""If something goes wrong loading the quarantine keys (permissions,
|
|
OS quirk, etc.), the filter must NOT break the download pipeline.
|
|
Returns input unchanged so legitimate downloads keep working —
|
|
same defensive contract as the existing 401/connection handlers."""
|
|
import core.soulseek_client as sc
|
|
|
|
def _broken_get(key, default=None):
|
|
raise RuntimeError("config explosion")
|
|
|
|
monkeypatch.setattr(sc.config_manager, 'get', _broken_get)
|
|
|
|
results = [_mk_track_result(username='peer', filename='song.flac')]
|
|
|
|
kept = configured_client._drop_quarantined_sources(results)
|
|
|
|
assert kept == results
|
|
|
|
|
|
def test_filter_results_by_quality_runs_quarantine_dedup_first(configured_client, tmp_path, monkeypatch):
|
|
"""Integration pin: `filter_results_by_quality_preference` calls
|
|
the quarantine dedup BEFORE the quality picker. If a candidate is
|
|
on the quarantine record, it can't win the picker by virtue of
|
|
superior bitrate — that's how the #652 loop manifested."""
|
|
import json as _json
|
|
quarantine_dir = tmp_path / 'ss_quarantine'
|
|
quarantine_dir.mkdir()
|
|
|
|
sidecar = {
|
|
"context": {
|
|
"original_search_result": {
|
|
"username": "badpeer", "filename": "high_bitrate_bad.flac",
|
|
},
|
|
},
|
|
}
|
|
(quarantine_dir / "20260518_120000.json").write_text(_json.dumps(sidecar))
|
|
|
|
import core.soulseek_client as sc
|
|
monkeypatch.setattr(sc.config_manager, 'get',
|
|
lambda key, default=None: str(tmp_path) if key == 'soulseek.download_path' else default)
|
|
|
|
# Mock the DB call inside filter_results_by_quality_preference so the
|
|
# test doesn't need a real DB. Quality profile permits FLAC.
|
|
class _FakeDB:
|
|
def get_quality_profile(self):
|
|
return {
|
|
'preset': 'flac',
|
|
'qualities': {
|
|
'flac': {'enabled': True, 'min_kbps': 800, 'max_kbps': 99999},
|
|
'mp3_320': {'enabled': False, 'min_kbps': 0, 'max_kbps': 0},
|
|
'mp3_256': {'enabled': False, 'min_kbps': 0, 'max_kbps': 0},
|
|
'mp3_192': {'enabled': False, 'min_kbps': 0, 'max_kbps': 0},
|
|
},
|
|
'priority': ['flac'],
|
|
}
|
|
|
|
import database.music_database as md
|
|
monkeypatch.setattr(md, 'MusicDatabase', lambda: _FakeDB())
|
|
|
|
results = [
|
|
# The "bad" source has the BEST quality on paper — pre-fix would win the picker.
|
|
_mk_track_result(username='badpeer', filename='high_bitrate_bad.flac',
|
|
quality='flac', bitrate=1411, size=20_000_000, duration=180_000),
|
|
_mk_track_result(username='goodpeer', filename='good.flac',
|
|
quality='flac', bitrate=1411, size=20_000_000, duration=180_000),
|
|
]
|
|
|
|
kept = configured_client.filter_results_by_quality_preference(results)
|
|
|
|
usernames = {r.username for r in kept}
|
|
assert 'badpeer' not in usernames, "Quarantined source must be filtered before the quality picker"
|
|
assert 'goodpeer' in usernames
|