soulsync/tests/wishlist/test_manual_download.py
Broque Thomas 7832acba31 Manual wishlist run: also split into per-album sub-batches
The Phase-1 fix (commit c3b88e69) only extended the per-album
bundle dispatch to ``process_wishlist_automatically``. The manual
"Run Wishlist Now" path goes through
``_prepare_and_run_manual_wishlist_batch`` instead, so the
behavior didn't change for users who triggered downloads from the
Wishlist tab UI — they still saw N per-track Soulseek searches
when N missing tracks all came from one album.

Caught in a real-app test: user added Katy Perry's PRISM (Deluxe)
to the wishlist + clicked "Download Wishlist" → app log shows
``_prepare_and_run_manual_wishlist_batch:421`` running a single
batch with 16 tracks + per-track searches firing one by one
("katy perry prism deluxe legendary lovers", "katy perry prism
deluxe roar", etc.), no album-bundle dispatch.

Fix:

- ``_prepare_and_run_manual_wishlist_batch`` now runs the same
  ``group_wishlist_tracks_by_album`` helper after filtering. For
  each detected album, it builds a sub-batch with
  ``is_album_download=True`` + populated album/artist context.
  Residual tracks (no resolvable album metadata) land in a single
  per-track residual batch.
- The first sub-batch re-uses the caller-allocated ``batch_id``
  so the frontend's existing poll against it keeps working;
  additional sub-batches get fresh ids materialized into
  ``download_batches`` so they show up in the Downloads view.
- Sub-batches dispatch serially — each ``run_full_missing_tracks_process``
  call blocks until the album-bundle staging + per-track tasks
  complete before the next album's bundle search fires.

New test ``test_manual_wishlist_splits_into_per_album_sub_batches``
pins the contract — multi-album wishlist content with
nested-spotify_data shape produces N master-worker calls (one per
album), each batch carries the album_context, first sub-batch
re-uses the original batch_id. 106 wishlist tests + 1099 across
the broader suite green.

Adding 16 Katy Perry PRISM tracks to wishlist + clicking download
should now fire ONE slskd album-bundle search for the release
instead of 16 individual searches.
2026-05-26 21:24:07 -07:00

323 lines
12 KiB
Python

from core.wishlist import processing
from core.wishlist.processing import WishlistManualDownloadRuntime
class _FakeLogger:
def __init__(self):
self.info_messages = []
self.warning_messages = []
self.error_messages = []
self.debug_messages = []
def info(self, msg):
self.info_messages.append(msg)
def warning(self, msg):
self.warning_messages.append(msg)
def error(self, msg):
self.error_messages.append(msg)
def debug(self, msg):
self.debug_messages.append(msg)
class _FakeWishlistService:
def __init__(self, tracks):
self._tracks = list(tracks)
self.removed_ids = set()
self.duplicate_removals = []
def remove_wishlist_duplicates(self, profile_id=1):
self.duplicate_removals.append(profile_id)
return 0
def get_wishlist_tracks_for_download(self, profile_id=1):
return [
track
for track in self._tracks
if (track.get("spotify_track_id") or track.get("id")) not in self.removed_ids
]
def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1):
self.removed_ids.add(spotify_track_id)
return True
class _FakeMusicDatabase:
def __init__(self, owned_matches=None):
self.owned_matches = set(owned_matches or [])
self.track_checks = []
self.duplicate_removals = []
def remove_wishlist_duplicates(self, profile_id=1):
self.duplicate_removals.append(profile_id)
return 0
def check_track_exists(self, track_name, artist_name, confidence_threshold=0.7, server_source=None, album=None):
self.track_checks.append((track_name, artist_name, server_source, album))
if (track_name, artist_name) in self.owned_matches:
return {"id": "db-track"}, 0.9
return None, 0.0
class _FakeExecutor:
def __init__(self):
self.submissions = []
def submit(self, fn, *args, **kwargs):
self.submissions.append((fn, args, kwargs))
return object()
class _FakeLock:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def _build_runtime(tracks, owned_matches=None, batch_map=None):
wishlist_service = _FakeWishlistService(tracks)
processing.get_wishlist_service = lambda: wishlist_service
music_db = _FakeMusicDatabase(owned_matches=owned_matches)
executor = _FakeExecutor()
logger = _FakeLogger()
activity_calls = []
master_calls = []
batch_map = batch_map or {}
runtime = WishlistManualDownloadRuntime(
get_music_database=lambda: music_db,
download_batches=batch_map,
tasks_lock=_FakeLock(),
missing_download_executor=executor,
run_full_missing_tracks_process=lambda *args, **kwargs: master_calls.append((args, kwargs)),
get_batch_max_concurrent=lambda: 4,
add_activity_item=lambda *args: activity_calls.append(args),
active_server="navidrome",
logger=logger,
profile_id=1,
)
return runtime, wishlist_service, music_db, executor, logger, activity_calls, batch_map, master_calls
def _run_submitted_bg_job(executor):
"""Execute the bg job the executor received — simulates ThreadPoolExecutor."""
assert len(executor.submissions) == 1, "expected exactly one bg submission"
fn, args, kwargs = executor.submissions[0]
fn(*args, **kwargs)
def test_start_manual_wishlist_download_batch_returns_immediately_with_placeholder():
"""Endpoint returns 200 immediately; cleanup runs in the bg job."""
runtime, service, _db, executor, _logger, activity_calls, batch_map, master_calls = _build_runtime(
tracks=[
{
"id": "track-1",
"name": "Song 1",
"artists": [{"name": "Artist 1"}],
"album": {"name": "Album 1", "album_type": "album"},
},
]
)
payload, status = processing.start_manual_wishlist_download_batch(runtime)
# Synchronous response: 200 with batch_id, batch entry created with placeholder count.
assert status == 200
assert payload["success"] is True
assert "batch_id" in payload
assert batch_map[payload["batch_id"]]["analysis_total"] == 0 # placeholder
assert batch_map[payload["batch_id"]]["phase"] == "analysis"
assert batch_map[payload["batch_id"]]["force_download_all"] is True
# Cleanup has NOT yet run (no DB calls, no master worker invocation).
assert service.removed_ids == set()
assert master_calls == []
assert activity_calls == []
# The bg job is queued.
assert len(executor.submissions) == 1
def test_start_manual_wishlist_download_batch_filters_track_ids_and_starts_batch():
runtime, _service, _db, executor, logger, activity_calls, batch_map, master_calls = _build_runtime(
tracks=[
{
"id": "track-1",
"name": "Song 1",
"artists": [{"name": "Artist 1"}],
"album": {"name": "Album 1", "album_type": "album"},
},
{
"id": "track-2",
"name": "Song 2",
"artists": [{"name": "Artist 2"}],
"album": {"name": "Album 2", "album_type": "album"},
},
]
)
payload, status = processing.start_manual_wishlist_download_batch(
runtime,
track_ids=["track-2"],
category=None,
)
assert status == 200
assert payload["success"] is True
assert "batch_id" in payload
# Run the bg job that the executor would have run on a real thread.
_run_submitted_bg_job(executor)
assert activity_calls == [("", "Wishlist Download Started", "1 tracks", "Now")]
assert len(master_calls) == 1
master_args, _ = master_calls[0]
assert master_args[1] == "wishlist"
assert master_args[2][0]["id"] == "track-2"
assert master_args[2][0]["_original_index"] == 0
assert batch_map[payload["batch_id"]]["analysis_total"] == 1
assert batch_map[payload["batch_id"]]["force_download_all"] is True
assert any("Filtered to 1 specific tracks by ID" in msg for msg in logger.info_messages)
def test_start_manual_wishlist_download_batch_does_not_run_library_cleanup():
"""Manual flow does NOT scan the library for already-owned tracks.
The batch sets force_download_all=True so owned tracks get downloaded
anyway. Running remove_tracks_already_in_library here would just add a
serial DB query per track (~30s on a 24-track wishlist) and contradict
force_download_all. The standalone /api/wishlist/cleanup endpoint
still exposes that pass for users who want explicit maintenance.
"""
runtime, service, db, executor, _logger, activity_calls, batch_map, master_calls = _build_runtime(
tracks=[
{
"id": "enhance-1",
"name": "Enhance Song",
"artists": [{"name": "Artist A"}],
"album": {"name": "Enhance Album", "album_type": "album"},
"source_type": "enhance",
},
{
"id": "owned-1",
"name": "Owned Song",
"artists": [{"name": "Artist B"}],
"album": {"name": "Owned Album", "album_type": "album"},
},
],
owned_matches={("Owned Song", "Artist B")},
)
payload, status = processing.start_manual_wishlist_download_batch(runtime)
assert status == 200
assert payload["success"] is True
_run_submitted_bg_job(executor)
# Owned-track removal pass does NOT run — wishlist still has the owned track.
assert service.removed_ids == set()
# The library check is skipped entirely — no per-track DB lookups.
assert db.track_checks == []
# All tracks are submitted to the master worker — including the "owned" one.
assert len(master_calls) == 1
master_args, _ = master_calls[0]
assert [track["id"] for track in master_args[2]] == ["enhance-1", "owned-1"]
assert batch_map[payload["batch_id"]]["analysis_total"] == 2
assert activity_calls == [("", "Wishlist Download Started", "2 tracks", "Now")]
def test_manual_wishlist_splits_into_per_album_sub_batches():
"""Manual wishlist run with multi-album content splits into one
sub-batch per album. Each sub-batch flips
``is_album_download=True`` + populates album/artist context so
the slskd / Prowlarr album-bundle dispatch engages.
Pinned to verify the manual path matches the auto path's
behavior — the user's first real-world test hit the manual
flow, not the auto flow."""
runtime, _service, _db, executor, _logger, _activity, batch_map, master_calls = _build_runtime(
tracks=[
{
"id": "trk-a1",
"spotify_track_id": "trk-a1",
"name": "Song A1",
"artists": [{"name": "Artist 1"}],
"spotify_data": {
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
"artists": [{"name": "Artist 1"}],
},
},
{
"id": "trk-a2",
"spotify_track_id": "trk-a2",
"name": "Song A2",
"artists": [{"name": "Artist 1"}],
"spotify_data": {
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
"artists": [{"name": "Artist 1"}],
},
},
{
"id": "trk-b1",
"spotify_track_id": "trk-b1",
"name": "Song B1",
"artists": [{"name": "Artist 2"}],
"spotify_data": {
"album": {"id": "alb2", "name": "Album Two", "album_type": "album"},
"artists": [{"name": "Artist 2"}],
},
},
]
)
payload, status = processing.start_manual_wishlist_download_batch(runtime)
assert status == 200
_run_submitted_bg_job(executor)
# Two album groups → two master-worker calls.
assert len(master_calls) == 2
# First sub-batch uses the caller-allocated batch_id.
first_args, _ = master_calls[0]
assert first_args[0] == payload["batch_id"]
assert batch_map[payload["batch_id"]].get("is_album_download") is True
# Second sub-batch gets a fresh uuid; its row exists in batch_map.
second_args, _ = master_calls[1]
assert second_args[0] != payload["batch_id"]
assert second_args[0] in batch_map
assert batch_map[second_args[0]].get("is_album_download") is True
# Track counts across the two sub-batches sum to the wishlist total.
counts = sorted(len(args[2]) for args, _ in master_calls)
assert counts == [1, 2]
# Both sub-batches carry album context populated from spotify_data.
album_names = {
batch_map[args[0]]["album_context"]["name"]
for args, _ in master_calls
}
assert album_names == {"Album One", "Album Two"}
def test_bg_job_marks_batch_complete_when_wishlist_genuinely_empty():
"""If the wishlist is empty before the manual click, the bg job marks the batch complete."""
runtime, _service, _db, executor, _logger, _activity, batch_map, master_calls = _build_runtime(
tracks=[],
)
payload, status = processing.start_manual_wishlist_download_batch(runtime)
assert status == 200
_run_submitted_bg_job(executor)
# No tracks → master worker never called, batch marked complete with explanatory error.
assert master_calls == []
assert batch_map[payload["batch_id"]]["phase"] == "complete"
assert batch_map[payload["batch_id"]]["error"] == "No tracks in wishlist"