soulsync/tests/downloads/test_downloads_candidates.py
Broque Thomas 61ba3a15de Cin-6: Rename soulseek_client global → download_orchestrator
The global handle in web_server.py was named soulseek_client for
historical reasons but the type has long been DownloadOrchestrator,
not SoulseekClient. Renamed the global plus every parameter/attribute
that carried the legacy name.

- web_server.py: global var renamed; all 99 references updated.
- api/, core/downloads/*, core/search/*, core/streaming/*,
  services/sync_service.py: parameter names, dataclass fields, and
  init() arg names renamed.
- Test fixtures (CandidatesDeps, MasterDeps, SearchDeps, etc.) and
  the _build_deps helpers updated accordingly.

The core.soulseek_client module path and SoulseekClient class name
(the actual soulseek-only client) are unchanged — only the orchestrator
handle renamed. Module imports of TrackResult/AlbumResult/DownloadStatus
from core.soulseek_client preserved.
2026-05-04 23:23:32 -07:00

415 lines
14 KiB
Python

"""Tests for core/downloads/candidates.py — candidate fallback download logic."""
from __future__ import annotations
import threading
from dataclasses import dataclass
import pytest
from core.downloads import candidates as dc
from core.runtime_state import (
download_tasks,
matched_context_lock,
matched_downloads_context,
tasks_lock,
)
# ---------------------------------------------------------------------------
# Fixtures + fakes
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def reset_state():
download_tasks.clear()
matched_downloads_context.clear()
yield
download_tasks.clear()
matched_downloads_context.clear()
@dataclass
class _Candidate:
username: str = "user1"
filename: str = "song.flac"
confidence: float = 0.9
size: int = 1000
title: str = "Song"
artist: str = "Artist"
album: str = "Album"
@dataclass
class _Track:
name: str = "Song Title"
album: str = "Album Name"
artists: list = None
id: str = "spt-1"
def __post_init__(self):
if self.artists is None:
self.artists = ["Artist Name"]
class _FakeSoulseek:
def __init__(self, download_id="dl-1"):
self._download_id = download_id
self.download_calls = []
self.cancel_calls = []
async def download(self, username, filename, size):
self.download_calls.append((username, filename, size))
return self._download_id
async def cancel_download(self, download_id, username, remove=True):
self.cancel_calls.append((download_id, username, remove))
class _FakeSpotify:
def __init__(self, track_details=None):
self._track_details = track_details
def get_track_details(self, track_id):
return self._track_details
class _FakeDB:
def __init__(self, blacklisted=None):
self._blacklisted = blacklisted or set()
def is_blacklisted(self, username, filename):
return (username, filename) in self._blacklisted
def _run_async(coro):
"""Drive async functions synchronously for tests."""
import asyncio
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def _build_deps(
*,
soulseek=None,
spotify=None,
db=None,
update_status=None,
on_complete=None,
):
deps = dc.CandidatesDeps(
download_orchestrator=soulseek or _FakeSoulseek(),
spotify_client=spotify or _FakeSpotify(),
run_async=_run_async,
get_database=lambda: db or _FakeDB(),
update_task_status=update_status or (lambda task_id, status: None),
make_context_key=lambda u, f: f"{u}::{f}",
on_download_completed=on_complete or (lambda *a, **kw: None),
)
return deps
def _seed_task(task_id, *, status="pending", track_info=None, used_sources=None,
download_id=None):
download_tasks[task_id] = {
"status": status,
"track_info": track_info or {},
"used_sources": used_sources or set(),
"download_id": download_id,
}
# ---------------------------------------------------------------------------
# Happy path — first candidate succeeds
# ---------------------------------------------------------------------------
def test_first_candidate_starts_download_and_returns_true():
"""High-confidence candidate accepts → download_id stored, context populated, returns True."""
deps = _build_deps()
_seed_task("t1")
candidates = [_Candidate(filename="best.flac", confidence=0.95)]
track = _Track()
result = dc.attempt_download_with_candidates("t1", candidates, track, batch_id="b1", deps=deps)
assert result is True
assert deps.download_orchestrator.download_calls == [("user1", "best.flac", 1000)]
assert download_tasks["t1"]["download_id"] == "dl-1"
assert "user1::best.flac" in matched_downloads_context
def test_candidates_tried_in_confidence_order():
"""Multiple candidates → tried highest-confidence first."""
deps = _build_deps()
_seed_task("t2")
candidates = [
_Candidate(filename="low.flac", confidence=0.5),
_Candidate(filename="high.flac", confidence=0.95),
_Candidate(filename="mid.flac", confidence=0.7),
]
track = _Track()
dc.attempt_download_with_candidates("t2", candidates, track, batch_id=None, deps=deps)
# First call should be the highest-confidence one
assert deps.download_orchestrator.download_calls[0][1] == "high.flac"
# ---------------------------------------------------------------------------
# used_sources dedupe
# ---------------------------------------------------------------------------
def test_already_tried_source_skipped():
"""Source in used_sources is skipped (no duplicate download attempt)."""
deps = _build_deps()
_seed_task("t3", used_sources={"user1_already.flac"})
candidates = [
_Candidate(filename="already.flac", confidence=0.9),
_Candidate(filename="fresh.flac", confidence=0.85),
]
track = _Track()
dc.attempt_download_with_candidates("t3", candidates, track, batch_id=None, deps=deps)
# First candidate skipped (already used), second one tried
assert len(deps.download_orchestrator.download_calls) == 1
assert deps.download_orchestrator.download_calls[0][1] == "fresh.flac"
# ---------------------------------------------------------------------------
# Blacklist
# ---------------------------------------------------------------------------
def test_blacklisted_source_skipped():
"""Blacklisted candidate is skipped."""
db = _FakeDB(blacklisted={("user1", "blacklisted.flac")})
deps = _build_deps(db=db)
_seed_task("t4")
candidates = [
_Candidate(filename="blacklisted.flac", confidence=0.95),
_Candidate(filename="ok.flac", confidence=0.85),
]
track = _Track()
dc.attempt_download_with_candidates("t4", candidates, track, batch_id=None, deps=deps)
assert deps.download_orchestrator.download_calls[0][1] == "ok.flac"
# ---------------------------------------------------------------------------
# Cancellation paths
# ---------------------------------------------------------------------------
def test_cancellation_before_attempt_returns_false():
"""status=cancelled at top of loop → return False, no download attempted."""
deps = _build_deps()
_seed_task("t5", status="cancelled")
candidates = [_Candidate()]
track = _Track()
result = dc.attempt_download_with_candidates("t5", candidates, track, batch_id=None, deps=deps)
assert result is False
assert deps.download_orchestrator.download_calls == []
def test_task_deleted_returns_false():
"""Task removed from download_tasks mid-loop → return False."""
deps = _build_deps()
# Task NOT seeded — looks deleted
candidates = [_Candidate()]
track = _Track()
result = dc.attempt_download_with_candidates("missing", candidates, track, batch_id=None, deps=deps)
assert result is False
def test_active_download_id_skips_new_download():
"""If task already has download_id, candidate skipped (race protection)."""
deps = _build_deps()
_seed_task("t6", download_id="existing-dl")
candidates = [_Candidate(), _Candidate(filename="other.flac")]
track = _Track()
dc.attempt_download_with_candidates("t6", candidates, track, batch_id=None, deps=deps)
# Both candidates skipped (download_id already present)
assert deps.download_orchestrator.download_calls == []
def test_cancellation_after_download_starts_calls_cancel_and_lifecycle():
"""If task is cancelled after download_id assigned, cancel_download fires + on_complete(False)."""
completion_calls = []
deps = _build_deps(on_complete=lambda batch_id, task_id, success=None: completion_calls.append((batch_id, task_id, success)))
_seed_task("t7")
candidates = [_Candidate()]
track = _Track()
# Simulate cancel happening between download_id assignment and final lock check.
# update_task_status is the callback that runs RIGHT after the download starts.
# We use it to flip status to cancelled.
def cancel_mid_flight(task_id, status):
if status == "downloading":
with tasks_lock:
if task_id in download_tasks:
pass # status set legitimately
# No-op here; we'll cancel via the lock directly below
download_tasks[task_id]["status"] = "cancelled"
deps.update_task_status = cancel_mid_flight
result = dc.attempt_download_with_candidates("t7", candidates, track, batch_id="b7", deps=deps)
assert result is False
# cancel_download was called for the in-flight transfer
assert deps.download_orchestrator.cancel_calls
# on_download_completed fired with success=False to free the worker slot
assert completion_calls == [("b7", "t7", False)]
# ---------------------------------------------------------------------------
# Failure path — all candidates exhausted
# ---------------------------------------------------------------------------
def test_all_candidates_failed_returns_false():
"""If download_orchestrator.download returns None (failure) for all candidates, returns False."""
soulseek = _FakeSoulseek(download_id=None)
deps = _build_deps(soulseek=soulseek)
_seed_task("t8")
candidates = [_Candidate(filename="c1.flac"), _Candidate(filename="c2.flac")]
track = _Track()
result = dc.attempt_download_with_candidates("t8", candidates, track, batch_id=None, deps=deps)
assert result is False
# Both candidates were tried
assert len(soulseek.download_calls) == 2
def test_exception_during_download_continues_to_next_candidate():
"""An exception on one candidate → continue to the next."""
call_count = [0]
class _FlakySoulseek(_FakeSoulseek):
async def download(self, username, filename, size):
call_count[0] += 1
if call_count[0] == 1:
raise RuntimeError("network blip")
return "dl-2"
soulseek = _FlakySoulseek()
deps = _build_deps(soulseek=soulseek)
_seed_task("t9")
candidates = [_Candidate(filename="c1.flac"), _Candidate(filename="c2.flac")]
track = _Track()
result = dc.attempt_download_with_candidates("t9", candidates, track, batch_id=None, deps=deps)
assert result is True
assert download_tasks["t9"]["download_id"] == "dl-2"
# ---------------------------------------------------------------------------
# Context payload
# ---------------------------------------------------------------------------
def test_explicit_album_context_uses_real_album_data():
"""track_info with _is_explicit_album_download=True copies real album/artist context."""
deps = _build_deps()
explicit_album = {
"id": "alb-real",
"name": "Real Album",
"release_date": "2024-05-05",
"total_tracks": 12,
"total_discs": 2,
"album_type": "album",
"image_url": "http://img/a.jpg",
}
explicit_artist = {"id": "art-real", "name": "Real Artist"}
_seed_task("t10", track_info={
"_is_explicit_album_download": True,
"_explicit_album_context": explicit_album,
"_explicit_artist_context": explicit_artist,
"track_number": 5,
"disc_number": 2,
})
candidates = [_Candidate(filename="explicit.flac")]
track = _Track(album="Real Album", artists=["Real Artist"])
dc.attempt_download_with_candidates("t10", candidates, track, batch_id=None, deps=deps)
ctx = matched_downloads_context["user1::explicit.flac"]
assert ctx["spotify_album"]["id"] == "alb-real"
assert ctx["spotify_album"]["total_discs"] == 2
assert ctx["spotify_artist"]["id"] == "art-real"
assert ctx["is_album_download"] is True
def test_track_number_from_track_info_preferred_over_api():
"""track_number from track_info wins over track object and API."""
api_track = {"track_number": 99, "disc_number": 9, "album": {}}
deps = _build_deps(spotify=_FakeSpotify(track_details=api_track))
_seed_task("t11", track_info={"track_number": 5, "disc_number": 1})
candidates = [_Candidate()]
track = _Track()
dc.attempt_download_with_candidates("t11", candidates, track, batch_id=None, deps=deps)
enhanced = matched_downloads_context["user1::song.flac"]["original_search_result"]
assert enhanced["track_number"] == 5
assert enhanced["disc_number"] == 1
def test_api_backfills_album_context_when_missing():
"""When local album context is incomplete, Spotify API backfills release_date / album_type."""
api_track = {
"track_number": 7,
"disc_number": 1,
"album": {
"id": "alb-from-api",
"release_date": "2025-01-01",
"album_type": "album",
"total_tracks": 10,
"images": [{"url": "http://api/img.jpg"}],
},
}
deps = _build_deps(spotify=_FakeSpotify(track_details=api_track))
# No track_info track_number → triggers API fallback
_seed_task("t12")
candidates = [_Candidate()]
track = _Track(album="Album Name") # truthy but no detailed metadata locally
dc.attempt_download_with_candidates("t12", candidates, track, batch_id=None, deps=deps)
ctx = matched_downloads_context["user1::song.flac"]
# release_date defaults to '' in the fallback context, so backfill fires.
assert ctx["spotify_album"]["release_date"] == "2025-01-01"
# Note: id stays "from_sync_modal" because the fallback assigns a non-empty
# placeholder, and the backfill only fires when `not spotify_album_context.get('id')`.
# The current behavior is what production does — assertion documents that.
assert ctx["spotify_album"]["id"] == "from_sync_modal"
# ---------------------------------------------------------------------------
# Sort by confidence is stable for equal scores
# ---------------------------------------------------------------------------
def test_candidates_with_equal_confidence_both_tried():
"""Equal-confidence candidates are tried in their existing order."""
deps = _build_deps()
_seed_task("t13")
candidates = [
_Candidate(filename="a.flac", confidence=0.9),
_Candidate(filename="b.flac", confidence=0.9),
]
track = _Track()
dc.attempt_download_with_candidates("t13", candidates, track, batch_id=None, deps=deps)
# First one wins — second never tried because download succeeded
assert len(deps.download_orchestrator.download_calls) == 1
assert deps.download_orchestrator.download_calls[0][1] == "a.flac"