soulsync/tests/downloads/test_downloads_candidates.py
Broque Thomas 1c43ca2eef PR6: lift _attempt_download_with_candidates to core/downloads/candidates.py
First lift in the new PR6 batch. Pulls the 312-line candidate-fallback
download dispatcher out of `web_server.py` into a new module under the
existing `core/downloads/` package. Pure 1:1 lift — wrapper keeps the
original entry-point name so all callers (search/match pipeline) work
unchanged.

What `attempt_download_with_candidates` does:

1. Sort candidates by descending confidence.
2. For each candidate:
   - Cancellation gates (3 points: top of loop, before download starts,
     after download_id is assigned).
   - Skip already-tried sources via the per-task `used_sources` set.
   - Skip blacklisted sources (user-flagged bad matches).
   - Race protection: bail when the task already has an active
     download_id.
   - `update_task_status('downloading')`, then `soulseek_client.download`.
3. On a successful download_id:
   - Build `matched_downloads_context` entry keyed by
     `make_context_key(username, filename)`.
   - For tracks with clean Spotify metadata, pull track_number /
     disc_number from (1) track_info → (2) track object → (3) Spotify
     API call. When local album context is incomplete, the API response
     backfills release_date / album_type / total_tracks / images / id.
   - Set `is_album_download` based on explicit context flag or
     heuristic (album differs from title, isn't "Unknown Album").
   - Store task/batch IDs and track_info on the context for post-
     processing + playlist-folder mode.
4. On a cancellation that wins the race after the download started:
   - `cancel_download(...)` to stop the in-flight Soulseek transfer.
   - `on_download_completed(batch_id, task_id, success=False)` to free
     the worker slot.
5. On exception or download-start failure: reset task status to
   'searching', continue to next candidate.

Dependencies injected via `CandidatesDeps` (7 fields) — soulseek_client,
spotify_client, run_async, get_database, update_task_status,
make_context_key, on_download_completed.

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 312 lines orig = 312 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 14 new under tests/downloads/test_downloads_candidates.py
covering happy path (first candidate succeeds, confidence ordering),
used_sources dedup, blacklist skip, cancellation gates (cancelled
status, deleted task, active download_id, mid-flight cancel + cleanup
callback), failure paths (all candidates failed, exception during
download falls through to next), context payload (explicit album
context, track_number priority order, API backfill of incomplete album
metadata), and equal-confidence stable order.

Pre-existing behavior documented in tests:
`spotify_album_context['id']` initializes to a non-empty placeholder
'from_sync_modal' in the fallback path, so the API-backfill condition
`if not spotify_album_context.get('id')` never fires for the id field
specifically. Other album fields (release_date, album_type) backfill
fine because they default to empty.

Full suite: 1290 passing (was 1276). Ruff clean.
2026-04-28 22:06:33 -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(
soulseek_client=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.soulseek_client.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.soulseek_client.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.soulseek_client.download_calls) == 1
assert deps.soulseek_client.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.soulseek_client.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.soulseek_client.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.soulseek_client.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.soulseek_client.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 soulseek_client.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.soulseek_client.download_calls) == 1
assert deps.soulseek_client.download_calls[0][1] == "a.flac"