soulsync/tests/downloads/test_downloads_status.py
BoulderBadgeDad 56bdcee434 Fix: rejected slskd download hung the task at 'downloading' forever (#836)
A wishlist track (or tracks in an album) that slskd accepted then REJECTED would
sit at "DOWNLOADING... 0%" indefinitely, spam an ERROR every status poll
("…Completed, Rejected - letting monitor handle retry"), and — for albums —
block the whole batch from ever completing.

Root defect: the status formatter's non-manual error branch keeps the task
'downloading' and trusts the retry monitor to resolve it, with NO backstop. When
the monitor can't make progress (a rejected transfer with no other source), the
task is stuck forever.

Backstop: measure how long the ERROR state has persisted (keyed off the task's
last status transition, so a slow-but-healthy transfer is never failed, and each
monitor-retry episode gets a fresh window). Once it exceeds the monitor's retry
window (60s, vs the monitor's ~15s) with no resolution, mark the task failed and
fire the worker-freeing completion callback so the batch can finish. Also log the
error ONCE per episode instead of every 2s poll. The healthy path is untouched —
a working retry transitions the task before the grace, so this never fires.
Manual picks still fail immediately (unchanged).

Tests: rejected-within-grace stays downloading; rejected-beyond-grace fails +
schedules completion; manual pick fails immediately. 45 status tests pass.
2026-06-10 18:16:22 -07:00

826 lines
30 KiB
Python

"""Tests for core/downloads/status.py — batch + unified status helpers."""
from __future__ import annotations
import pytest
from core.downloads import status as st
from core.runtime_state import (
download_batches,
download_tasks,
)
@pytest.fixture(autouse=True)
def reset_state():
download_tasks.clear()
download_batches.clear()
yield
download_tasks.clear()
download_batches.clear()
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeConfig:
def __init__(self, values=None):
self._v = values or {}
def get(self, key, default=None):
return self._v.get(key, default)
def _build_deps(
*,
config=None,
docker_resolve=None,
find_completed=None,
make_key=None,
submit_pp=None,
cached_transfers=None,
download_orchestrator=None,
run_async=None,
persistent_history=None,
):
submitted = []
def _default_submit(task_id, batch_id):
submitted.append((task_id, batch_id))
deps = st.StatusDeps(
config_manager=config or _FakeConfig({'soulseek.download_timeout': 600}),
docker_resolve_path=docker_resolve or (lambda p: p),
find_completed_file=find_completed or (lambda *a, **kw: (None, None)),
make_context_key=make_key or (lambda u, f: f"{u}::{f}"),
submit_post_processing=submit_pp or _default_submit,
get_cached_transfer_data=cached_transfers or (lambda: {}),
download_orchestrator=download_orchestrator,
run_async=run_async,
get_persistent_download_history=persistent_history,
)
return deps, submitted
# ---------------------------------------------------------------------------
# build_batch_status_data — phase routing
# ---------------------------------------------------------------------------
def test_unknown_phase_includes_basic_fields_only():
deps, _ = _build_deps()
batch = {'phase': 'unknown', 'playlist_id': 'pl1', 'playlist_name': 'My PL'}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert out['phase'] == 'unknown'
assert out['playlist_id'] == 'pl1'
assert out['playlist_name'] == 'My PL'
assert 'tasks' not in out
assert 'analysis_progress' not in out
def test_analysis_phase_includes_analysis_progress_and_results():
deps, _ = _build_deps()
batch = {
'phase': 'analysis',
'analysis_total': 10,
'analysis_processed': 4,
'analysis_results': [{'track_index': 0, 'found': True}],
}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert out['analysis_progress'] == {'total': 10, 'processed': 4}
assert out['analysis_results'] == [{'track_index': 0, 'found': True}]
def test_queued_phase_surfaces_analysis_progress_for_ui_count():
"""A batch in ``queued`` state hasn't been picked up by the
executor yet, so analysis_processed is 0. The UI still needs
``analysis_total`` so it can render "Queued — N tracks" instead
of showing an empty card. Pre-fix the queued phase fell through
to the default branch and the UI lost the track count entirely."""
deps, _ = _build_deps()
batch = {
'phase': 'queued',
'analysis_total': 17,
'analysis_processed': 0,
'analysis_results': [],
}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert out['phase'] == 'queued'
assert out['analysis_progress'] == {'total': 17, 'processed': 0}
assert out['analysis_results'] == []
def test_album_downloading_phase_exposes_bundle_progress_without_task_safety_valve():
deps, _ = _build_deps(config=_FakeConfig({'soulseek.download_timeout': 1}))
download_tasks['t1'] = {
'track_index': 0,
'status': 'queued',
'track_info': {},
'status_change_time': 0,
}
batch = {
'phase': 'album_downloading',
'queue': ['t1'],
'album_bundle_state': 'downloading',
'album_bundle_source': 'torrent',
'album_bundle_release': 'GNX [FLAC]',
'album_bundle_progress': 0.42,
'album_bundle_speed': 2048,
'album_bundle_size': 4096,
}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert out['album_bundle'] == {
'state': 'downloading',
'source': 'torrent',
'release': 'GNX [FLAC]',
'progress': 0.42,
'progress_percent': 42,
'speed': 2048,
'size': 4096,
}
assert 'tasks' not in out
assert download_tasks['t1']['status'] == 'queued'
def test_complete_phase_includes_wishlist_summary_when_present():
deps, _ = _build_deps()
batch = {
'phase': 'complete',
'queue': [],
'wishlist_summary': {'added': 3, 'failed': 0},
}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert out['wishlist_summary'] == {'added': 3, 'failed': 0}
def test_complete_phase_omits_wishlist_summary_when_missing():
deps, _ = _build_deps()
batch = {'phase': 'complete', 'queue': []}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert 'wishlist_summary' not in out
# ---------------------------------------------------------------------------
# Task formatting
# ---------------------------------------------------------------------------
def test_downloading_phase_includes_active_count_and_max_concurrent():
deps, _ = _build_deps()
batch = {'phase': 'downloading', 'queue': [], 'active_count': 2, 'max_concurrent': 5}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert out['active_count'] == 2
assert out['max_concurrent'] == 5
def test_tasks_sorted_by_track_index():
deps, _ = _build_deps()
download_tasks['t1'] = {'track_index': 2, 'status': 'queued', 'track_info': {}}
download_tasks['t2'] = {'track_index': 0, 'status': 'queued', 'track_info': {}}
download_tasks['t3'] = {'track_index': 1, 'status': 'queued', 'track_info': {}}
batch = {'phase': 'downloading', 'queue': ['t1', 't2', 't3']}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert [t['track_index'] for t in out['tasks']] == [0, 1, 2]
def test_missing_task_in_queue_is_skipped():
deps, _ = _build_deps()
download_tasks['t1'] = {'track_index': 0, 'status': 'queued', 'track_info': {}}
batch = {'phase': 'downloading', 'queue': ['t1', 'absent']}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert len(out['tasks']) == 1
def test_task_status_includes_v2_state_fields():
deps, _ = _build_deps()
download_tasks['t1'] = {
'track_index': 0, 'status': 'cancelling', 'track_info': {},
'cancel_requested': True, 'cancel_timestamp': 12345,
'ui_state': 'cancelling', 'playlist_id': 'pl1',
'error_message': 'oh no', 'cached_candidates': [{'x': 1}],
'quarantine_entry_id': '20260514_120000_song',
}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, {}, deps)
t = out['tasks'][0]
assert t['cancel_requested'] is True
assert t['cancel_timestamp'] == 12345
assert t['ui_state'] == 'cancelling'
assert t['playlist_id'] == 'pl1'
assert t['error_message'] == 'oh no'
assert t['quarantine_entry_id'] == '20260514_120000_song'
assert t['has_candidates'] is True
# ---------------------------------------------------------------------------
# Live transfer state mapping
# ---------------------------------------------------------------------------
def test_live_state_cancelled_maps_to_cancelled():
deps, _ = _build_deps()
download_tasks['t1'] = {
'track_index': 0, 'status': 'downloading', 'track_info': {},
'filename': 'song.flac', 'username': 'u1',
}
live = {'u1::song.flac': {'state': 'Cancelled', 'percentComplete': 50}}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, live, deps)
assert out['tasks'][0]['status'] == 'cancelled'
assert download_tasks['t1']['status'] == 'cancelled' # mutates source state
def test_live_state_succeeded_with_full_bytes_marks_post_processing_and_submits():
deps, submitted = _build_deps()
download_tasks['t1'] = {
'track_index': 0, 'status': 'downloading', 'track_info': {},
'filename': 'song.flac', 'username': 'u1',
}
live = {'u1::song.flac': {
'state': 'Succeeded', 'size': 100, 'bytesTransferred': 100, 'percentComplete': 100,
}}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, live, deps)
assert out['tasks'][0]['status'] == 'post_processing'
assert download_tasks['t1']['status'] == 'post_processing'
assert submitted == [('t1', 'b1')]
def test_live_state_succeeded_with_byte_mismatch_keeps_status():
deps, submitted = _build_deps()
download_tasks['t1'] = {
'track_index': 0, 'status': 'downloading', 'track_info': {},
'filename': 'song.flac', 'username': 'u1',
}
live = {'u1::song.flac': {
'state': 'Succeeded', 'size': 100, 'bytesTransferred': 50,
}}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, live, deps)
assert out['tasks'][0]['status'] == 'downloading' # not promoted to post_processing
assert submitted == [] # not submitted
def test_live_state_inprogress_maps_to_downloading():
deps, _ = _build_deps()
download_tasks['t1'] = {
'track_index': 0, 'status': 'queued', 'track_info': {},
'filename': 'song.flac', 'username': 'u1',
}
live = {'u1::song.flac': {'state': 'InProgress', 'percentComplete': 42}}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, live, deps)
assert out['tasks'][0]['status'] == 'downloading'
assert out['tasks'][0]['progress'] == 42
def test_live_state_errored_keeps_active_status_for_monitor():
deps, _ = _build_deps()
download_tasks['t1'] = {
'track_index': 0, 'status': 'downloading', 'track_info': {},
'filename': 'song.flac', 'username': 'u1',
}
live = {'u1::song.flac': {'state': 'Errored'}}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, live, deps)
# Keeps current status so monitor handles retry
assert out['tasks'][0]['status'] == 'downloading'
assert download_tasks['t1']['status'] == 'downloading' # not marked failed
def test_terminal_status_not_overridden_by_live_state():
deps, _ = _build_deps()
download_tasks['t1'] = {
'track_index': 0, 'status': 'completed', 'track_info': {},
'filename': 'song.flac', 'username': 'u1',
}
live = {'u1::song.flac': {'state': 'InProgress', 'percentComplete': 50}}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, live, deps)
assert out['tasks'][0]['status'] == 'completed'
assert out['tasks'][0]['progress'] == 100
def test_post_processing_status_progress_is_95():
deps, _ = _build_deps()
download_tasks['t1'] = {
'track_index': 0, 'status': 'post_processing', 'track_info': {},
'filename': 'song.flac', 'username': 'u1',
}
live = {} # no live entry
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, live, deps)
assert out['tasks'][0]['progress'] == 95
def test_auto_torrent_without_live_entry_uses_engine_success_fallback():
class _Record:
state = 'Completed, Succeeded'
progress = 100
class _Orchestrator:
def get_download_status(self, download_id):
assert download_id == 'dl1'
return _Record()
deps, submitted = _build_deps(
download_orchestrator=_Orchestrator(),
run_async=lambda value: value,
)
download_tasks['t1'] = {
'track_index': 0,
'status': 'downloading',
'track_info': {},
'filename': 'song.flac',
'username': 'torrent',
'download_id': 'dl1',
}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert out['tasks'][0]['status'] == 'post_processing'
assert download_tasks['t1']['status'] == 'post_processing'
assert submitted == [('t1', 'b1')]
def test_auto_torrent_prefers_engine_success_over_live_entry():
class _Record:
state = 'Completed, Succeeded'
progress = 100
class _Orchestrator:
def get_download_status(self, download_id):
assert download_id == 'dl1'
return _Record()
deps, submitted = _build_deps(
download_orchestrator=_Orchestrator(),
run_async=lambda value: value,
)
download_tasks['t1'] = {
'track_index': 0,
'status': 'downloading',
'track_info': {},
'filename': 'song.flac',
'username': 'torrent',
'download_id': 'dl1',
}
live = {'torrent::song.flac': {
'state': 'InProgress, Downloading',
'percentComplete': 100,
}}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, live, deps)
assert out['tasks'][0]['status'] == 'post_processing'
assert download_tasks['t1']['status'] == 'post_processing'
assert submitted == [('t1', 'b1')]
def test_auto_torrent_engine_failure_does_not_bypass_monitor_retry():
class _Record:
state = 'Completed, Errored'
progress = 100
error = 'client failed'
class _Orchestrator:
def get_download_status(self, download_id):
assert download_id == 'dl1'
return _Record()
deps, submitted = _build_deps(
download_orchestrator=_Orchestrator(),
run_async=lambda value: value,
)
download_tasks['t1'] = {
'track_index': 0,
'status': 'downloading',
'track_info': {},
'filename': 'song.flac',
'username': 'torrent',
'download_id': 'dl1',
}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert out['tasks'][0]['status'] == 'downloading'
assert download_tasks['t1']['status'] == 'downloading'
assert submitted == []
def test_auto_torrent_engine_success_recovers_premature_failed_task():
class _Record:
state = 'Completed, Succeeded'
progress = 100
class _Orchestrator:
def get_download_status(self, download_id):
assert download_id == 'dl1'
return _Record()
deps, submitted = _build_deps(
download_orchestrator=_Orchestrator(),
run_async=lambda value: value,
)
download_tasks['t1'] = {
'track_index': 0,
'status': 'failed',
'track_info': {},
'filename': 'release-url||Release',
'username': 'torrent',
'download_id': 'dl1',
'error_message': 'premature failure',
}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert out['tasks'][0]['status'] == 'post_processing'
assert download_tasks['t1']['status'] == 'post_processing'
assert submitted == [('t1', 'b1')]
# ---------------------------------------------------------------------------
# Safety valve (stuck task handling)
# ---------------------------------------------------------------------------
def test_safety_valve_stuck_searching_marks_not_found():
deps, _ = _build_deps(config=_FakeConfig({'soulseek.download_timeout': 1}))
download_tasks['t1'] = {
'track_index': 0, 'status': 'searching', 'track_info': {},
'status_change_time': 0, # ancient
}
batch = {'phase': 'downloading', 'queue': ['t1']}
st.build_batch_status_data('b1', batch, {}, deps)
assert download_tasks['t1']['status'] == 'not_found'
assert 'Search stuck' in download_tasks['t1']['error_message']
def test_safety_valve_stuck_downloading_with_recovered_file_routes_to_post_processing():
deps, submitted = _build_deps(
config=_FakeConfig({'soulseek.download_timeout': 1, 'soulseek.download_path': '/d', 'soulseek.transfer_path': '/t'}),
find_completed=lambda *a, **kw: ('/found.flac', 'transfer'),
)
download_tasks['t1'] = {
'track_index': 0, 'status': 'downloading', 'track_info': {},
'filename': 'song.flac',
'status_change_time': 0,
}
batch = {'phase': 'downloading', 'queue': ['t1']}
st.build_batch_status_data('b1', batch, {}, deps)
assert download_tasks['t1']['status'] == 'post_processing'
assert submitted == [('t1', 'b1')]
def test_safety_valve_stuck_downloading_no_file_marks_failed():
deps, _ = _build_deps(config=_FakeConfig({'soulseek.download_timeout': 1}))
download_tasks['t1'] = {
'track_index': 0, 'status': 'downloading', 'track_info': {},
'filename': 'song.flac',
'status_change_time': 0,
}
batch = {'phase': 'downloading', 'queue': ['t1']}
st.build_batch_status_data('b1', batch, {}, deps)
assert download_tasks['t1']['status'] == 'failed'
assert 'Task stuck' in download_tasks['t1']['error_message']
# ---------------------------------------------------------------------------
# build_single_batch_status (route helper)
# ---------------------------------------------------------------------------
def test_single_batch_status_404_for_unknown_batch():
deps, _ = _build_deps()
body, status = st.build_single_batch_status('absent', deps)
assert status == 404
assert body['error'] == 'Batch not found'
def test_single_batch_status_returns_payload_for_known_batch():
deps, _ = _build_deps()
download_batches['b1'] = {'phase': 'unknown', 'playlist_id': 'pl1'}
body, status = st.build_single_batch_status('b1', deps)
assert status == 200
assert body['phase'] == 'unknown'
assert body['playlist_id'] == 'pl1'
# ---------------------------------------------------------------------------
# build_batched_status (route helper)
# ---------------------------------------------------------------------------
def test_batched_status_filters_to_requested_ids():
deps, _ = _build_deps()
download_batches['b1'] = {'phase': 'unknown'}
download_batches['b2'] = {'phase': 'unknown'}
download_batches['b3'] = {'phase': 'unknown'}
out = st.build_batched_status(['b1', 'b3'], deps)
assert set(out['batches'].keys()) == {'b1', 'b3'}
def test_batched_status_no_filter_returns_all_batches():
deps, _ = _build_deps()
download_batches['b1'] = {'phase': 'unknown'}
download_batches['b2'] = {'phase': 'unknown'}
out = st.build_batched_status([], deps)
assert set(out['batches'].keys()) == {'b1', 'b2'}
def test_unified_downloads_response_includes_album_bundle_summary():
deps, _ = _build_deps()
download_batches['b1'] = {
'phase': 'album_downloading',
'playlist_id': 'pl1',
'playlist_name': 'GNX',
'queue': [],
'album_bundle_state': 'downloading',
'album_bundle_source': 'torrent',
'album_bundle_progress': 75,
}
out = st.build_unified_downloads_response(300, deps)
assert out['batches'][0]['album_bundle'] == {
'state': 'downloading',
'source': 'torrent',
'progress': 75,
'progress_percent': 75,
}
def test_batched_status_metadata_present():
deps, _ = _build_deps()
download_batches['b1'] = {'phase': 'unknown'}
out = st.build_batched_status([], deps)
assert out['metadata']['total_batches'] == 1
assert out['metadata']['requested_batch_ids'] == []
assert isinstance(out['metadata']['timestamp'], (int, float))
def test_batched_status_per_batch_failure_isolated():
deps, _ = _build_deps()
# b1 valid, b2 raises
download_batches['b1'] = {'phase': 'downloading', 'queue': []}
class _BadBatch(dict):
def get(self, k, default=None):
if k == 'phase':
raise RuntimeError("batch boom")
return super().get(k, default)
download_batches['b2'] = _BadBatch()
out = st.build_batched_status([], deps)
assert 'error' in out['batches']['b2']
assert out['batches']['b1'].get('phase') == 'downloading'
def test_batched_status_debug_info_pre_existing_bug_never_populates():
"""Documents a pre-existing bug: every batch payload includes
`"error": batch.get('error')` (key always present, value usually None).
The debug_info loop checks `if "error" not in batch_status:` which is
therefore always False → debug_info stays empty in production.
Lift preserves this exactly. A future PR can flip the check to
`if batch_status.get('error') is None` to fix it.
"""
deps, _ = _build_deps()
download_tasks['t1'] = {'track_index': 0, 'status': 'downloading', 'track_info': {}}
download_tasks['t2'] = {'track_index': 1, 'status': 'downloading', 'track_info': {}}
download_batches['b1'] = {
'phase': 'downloading', 'queue': ['t1', 't2'],
'active_count': 5,
'max_concurrent': 5,
}
out = st.build_batched_status([], deps)
# Bug: stays empty because "error" key is always present in payload
assert out['debug_info'] == {}
# ---------------------------------------------------------------------------
# build_unified_downloads_response
# ---------------------------------------------------------------------------
def test_unified_response_sorts_by_priority_then_recency():
deps, _ = _build_deps()
download_tasks['old_complete'] = {
'track_index': 0, 'status': 'completed', 'track_info': {'name': 'A'},
'status_change_time': 100,
}
download_tasks['new_active'] = {
'track_index': 1, 'status': 'downloading', 'track_info': {'name': 'B'},
'status_change_time': 50,
}
out = st.build_unified_downloads_response(100, deps)
# Active downloads come first regardless of timestamp
assert out['downloads'][0]['title'] == 'B'
def test_unified_response_artist_list_of_dicts_normalized():
deps, _ = _build_deps()
download_tasks['t1'] = {
'track_index': 0, 'status': 'queued',
'track_info': {'name': 'X', 'artists': [{'name': 'Pink Floyd'}, {'name': 'Roger'}]},
}
out = st.build_unified_downloads_response(100, deps)
assert out['downloads'][0]['artist'] == 'Pink Floyd, Roger'
def test_unified_response_album_dict_extracted_to_name():
deps, _ = _build_deps()
download_tasks['t1'] = {
'track_index': 0, 'status': 'queued',
'track_info': {'name': 'X', 'album': {'name': 'DSOTM', 'images': [{'url': 'http://thumb.jpg'}]}},
}
out = st.build_unified_downloads_response(100, deps)
assert out['downloads'][0]['album'] == 'DSOTM'
assert out['downloads'][0]['artwork'] == 'http://thumb.jpg'
def test_unified_response_completed_task_progress_is_100():
deps, _ = _build_deps()
download_tasks['t1'] = {
'track_index': 0, 'status': 'completed',
'track_info': {'name': 'X'},
}
out = st.build_unified_downloads_response(100, deps)
assert out['downloads'][0]['progress'] == 100
def test_unified_response_post_processing_progress_is_95():
deps, _ = _build_deps()
download_tasks['t1'] = {
'track_index': 0, 'status': 'post_processing',
'track_info': {'name': 'X'},
}
out = st.build_unified_downloads_response(100, deps)
assert out['downloads'][0]['progress'] == 95
def test_unified_response_includes_batch_summaries():
deps, _ = _build_deps()
download_tasks['t1'] = {'track_index': 0, 'status': 'completed', 'track_info': {}}
download_tasks['t2'] = {'track_index': 1, 'status': 'failed', 'track_info': {}}
download_tasks['t3'] = {'track_index': 2, 'status': 'downloading', 'track_info': {}}
download_tasks['t4'] = {'track_index': 3, 'status': 'queued', 'track_info': {}}
download_batches['b1'] = {
'phase': 'downloading', 'playlist_name': 'PL',
'queue': ['t1', 't2', 't3', 't4'],
}
out = st.build_unified_downloads_response(100, deps)
bs = out['batches'][0]
assert bs['total'] == 4
assert bs['completed'] == 1
assert bs['failed'] == 1
assert bs['active'] == 1
assert bs['queued'] == 1
def test_unified_response_respects_limit():
deps, _ = _build_deps()
for i in range(20):
download_tasks[f't{i}'] = {
'track_index': i, 'status': 'completed', 'track_info': {},
}
out = st.build_unified_downloads_response(5, deps)
assert len(out['downloads']) == 5
assert out['total'] == 20 # total still reflects all
def test_unified_response_includes_persistent_download_history():
deps, _ = _build_deps(
persistent_history=lambda limit: [
{
'id': 42,
'title': 'Persisted Track',
'artist_name': 'Deezer Artist',
'album_name': 'Persistent Album',
'thumb_url': 'http://cover.jpg',
'download_source': 'Deezer',
'quality': 'FLAC',
'created_at': '2026-05-24 12:34:56',
}
]
)
out = st.build_unified_downloads_response(100, deps)
assert out['total'] == 1
item = out['downloads'][0]
assert item['task_id'] == 'history-42'
assert item['title'] == 'Persisted Track'
assert item['artist'] == 'Deezer Artist'
assert item['album'] == 'Persistent Album'
assert item['artwork'] == 'http://cover.jpg'
assert item['status'] == 'completed'
assert item['progress'] == 100
assert item['batch_name'] == 'Deezer'
assert item['is_persistent_history'] is True
def test_unified_response_dedupes_history_against_live_task():
download_tasks['live'] = {
'track_index': 0,
'status': 'completed',
'track_info': {
'name': 'Same Track',
'artist': 'Same Artist',
'album': 'Same Album',
},
}
deps, _ = _build_deps(
persistent_history=lambda limit: [
{
'id': 7,
'title': 'Same Track',
'artist_name': 'Same Artist',
'album_name': 'Same Album',
'created_at': '2026-05-24 12:34:56',
}
]
)
out = st.build_unified_downloads_response(100, deps)
assert len(out['downloads']) == 1
assert out['downloads'][0]['task_id'] == 'live'
assert out['downloads'][0]['is_persistent_history'] is False
def test_unified_response_caps_persistent_history_tail():
requested_limits = []
def _history(limit):
requested_limits.append(limit)
return [
{
'id': i,
'title': f'Track {i}',
'artist_name': 'Artist',
'album_name': 'Album',
'created_at': '2026-05-24 12:34:56',
}
for i in range(limit + 10)
]
deps, _ = _build_deps(persistent_history=_history)
out = st.build_unified_downloads_response(300, deps)
assert requested_limits == [50]
assert len(out['downloads']) == 50
assert out['total'] == 50
# ---------------------------------------------------------------------------
# #836 — a rejected slskd transfer must not hang the task at 'downloading'
# forever. The monitor normally retries; when it can't make progress, a backstop
# in the status formatter marks the task failed after a grace window so the
# worker frees and the batch can complete.
# ---------------------------------------------------------------------------
def test_rejected_within_grace_keeps_downloading_for_monitor():
import time
deps, _ = _build_deps()
now = time.time()
download_tasks['t1'] = {
'track_index': 0, 'status': 'downloading', 'track_info': {},
'filename': 'song.flac', 'username': 'u1',
'status_change_time': now - 5,
}
live = {'u1::song.flac': {'state': 'Completed, Rejected', 'percentComplete': 0}}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, live, deps)
# inside the grace window — give the monitor its chance, don't fail yet
assert out['tasks'][0]['status'] == 'downloading'
assert download_tasks['t1']['status'] == 'downloading'
def test_rejected_beyond_grace_marks_failed_and_frees_worker():
import time
completed = []
deps, _ = _build_deps()
deps.on_download_completed = lambda b, t, s: completed.append((b, t, s))
now = time.time()
download_tasks['t1'] = {
'track_index': 0, 'status': 'downloading', 'track_info': {},
'filename': 'song.flac', 'username': 'u1',
# error persisted past the grace, no monitor transition since
'status_change_time': now - 130,
'_error_state_since': now - (st.ERROR_STATE_TERMINAL_GRACE_SECONDS + 30),
}
live = {'u1::song.flac': {'state': 'Completed, Rejected', 'errorMessage': 'peer rejected'}}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, live, deps)
assert out['tasks'][0]['status'] == 'failed'
assert download_tasks['t1']['status'] == 'failed'
assert download_tasks['t1']['error_message'] == 'peer rejected'
time.sleep(0.05) # completion callback runs on a daemon thread
assert completed == [('b1', 't1', False)] # batch can now finish
def test_manual_pick_rejected_fails_immediately_without_grace():
import time
deps, _ = _build_deps()
now = time.time()
download_tasks['t1'] = {
'track_index': 0, 'status': 'downloading', 'track_info': {},
'filename': 'song.flac', 'username': 'u1',
'_user_manual_pick': True, 'status_change_time': now,
}
live = {'u1::song.flac': {'state': 'Completed, Rejected'}}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, live, deps)
assert out['tasks'][0]['status'] == 'failed' # immediate, no 60s wait
assert download_tasks['t1']['status'] == 'failed'