soulsync/tests/wishlist/test_album_grouping.py
Broque Thomas c3b88e6963 Wishlist albums cycle: split into per-album bundle batches
Auto-wishlist's "albums" cycle used to dump every missing album
track into one batch and run per-track Soulseek / Prowlarr searches
for each (~50 searches for a typical scan). The album-bundle
dispatch (introduced in 2.5.9 for explicit album downloads) was
gated on ``is_album_download=True`` + populated
``album_context``/``artist_context``, none of which the wishlist
batch ever set — so wishlist runs always took the per-track flow
even when 12 missing tracks all belonged to the same album.

Fix: split wishlist albums-cycle tracks into per-album sub-batches
at submission time. Each sub-batch carries its own album context,
trips the existing dispatch gate, and engages one slskd / torrent
/ usenet album-bundle search per album. Tracks the helper can't
group (no album metadata, no artist) fall through to a residual
per-track batch.

- New ``core/wishlist/album_grouping.py``:
  ``group_wishlist_tracks_by_album(tracks)`` returns
  ``WishlistGroupingResult(album_groups, residual_tracks)``.
  Pure function — extracts album_id (or name-normalized fallback)
  + primary artist + album context from each track's nested
  spotify_data, buckets, and threshold-promotes. Independent of
  runtime state so it can be unit-tested without the wishlist
  executor.
- ``core/wishlist/processing.py``: when ``current_cycle ==
  'albums'``, run the grouping helper, submit one batch per album
  with ``is_album_download=True`` + the group's album/artist
  context, then a single residual batch for orphans. Singles
  cycle path unchanged.
- 9 new tests in ``test_album_grouping.py`` pin the bucketing
  contract (empty / single album / multi album / orphan / threshold
  / nested payloads / no-id fallback / no artist).
- 2 new tests in ``test_automation.py`` exercise the per-album
  split end-to-end through ``process_wishlist_automatically``:
  multi-album batch → two sub-batches each with album context;
  mixed orphan + real album → one bundle batch + one residual.

1099 tests across wishlist + imports + downloads + automation +
playlist-sources + staging-provenance + track-number-repair
suites green. WHATS_NEW entry added under 2.6.3.

Now when an auto-wishlist scan finds 12 missing tracks from
Ryoto's "Cha-La Head-Cha-La", it runs ONE slskd / Prowlarr
album-bundle search for the release instead of 12 per-track
searches.
2026-05-26 21:13:34 -07:00

159 lines
5.5 KiB
Python

"""Tests for the wishlist-cycle album grouping helper that drives
the per-album bundle dispatch.
Pins the bucketing contract so future changes to the dispatch flow
don't silently regress the user-visible behavior: wishlist 'albums'
cycle should emit one album-bundle search per missing album, not
one per missing track.
"""
from __future__ import annotations
from core.wishlist.album_grouping import (
WishlistAlbumGroup,
WishlistGroupingResult,
group_wishlist_tracks_by_album,
)
def _wt(track_name, artist, album_id, album_name, **extra):
"""Build a wishlist row in the shape the wishlist service returns."""
return {
'track_name': track_name,
'artist_name': artist,
'spotify_data': {
'name': track_name,
'artists': [{'name': artist}],
'album': {
'id': album_id,
'name': album_name,
**extra,
},
},
}
def test_empty_input_returns_empty_result():
res = group_wishlist_tracks_by_album([])
assert res.album_groups == []
assert res.residual_tracks == []
def test_single_album_groups_all_tracks_together():
tracks = [
_wt('Dragon Soul', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'),
_wt('Cha-La Head-Cha-La', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'),
_wt('Zenkai Power', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'),
]
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 1
g = res.album_groups[0]
assert g.album_key == 'alb1'
assert g.album_context['name'] == 'Cha-La Head-Cha-La'
assert g.artist_context['name'] == 'Ryoto'
assert len(g.tracks) == 3
def test_multiple_albums_emit_separate_groups():
tracks = [
_wt('Song A', 'Artist 1', 'alb1', 'Album 1'),
_wt('Song B', 'Artist 1', 'alb1', 'Album 1'),
_wt('Song C', 'Artist 2', 'alb2', 'Album 2'),
]
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 2
keys = {g.album_key for g in res.album_groups}
assert keys == {'alb1', 'alb2'}
for g in res.album_groups:
if g.album_key == 'alb1':
assert len(g.tracks) == 2
else:
assert len(g.tracks) == 1
def test_missing_album_metadata_falls_through_to_residual():
tracks = [
# No spotify_data.album at all
{'track_name': 'Orphan', 'artist_name': 'X', 'spotify_data': {'artists': [{'name': 'X'}]}},
# Empty album dict
{'track_name': 'Empty Album', 'artist_name': 'X', 'spotify_data': {'album': {}, 'artists': [{'name': 'X'}]}},
]
res = group_wishlist_tracks_by_album(tracks)
assert res.album_groups == []
assert len(res.residual_tracks) == 2
def test_missing_artist_demotes_to_residual():
"""Album-bundle search needs an artist; if we can't recover one,
skip the bundle path and let the track go through per-track."""
tracks = [{
'track_name': 'Song',
'spotify_data': {
'artists': [],
'album': {'id': 'a', 'name': 'Album'},
},
}]
res = group_wishlist_tracks_by_album(tracks)
assert res.album_groups == []
assert res.residual_tracks == tracks
def test_min_tracks_threshold_demotes_solos():
"""When ``min_tracks_per_album=2``, single-track albums fall to
residual so the user doesn't fire a bundle search for a 1-track
rip when per-track would do."""
tracks = [
_wt('Solo Track', 'Artist 1', 'alb1', 'Album 1'),
_wt('Song A', 'Artist 2', 'alb2', 'Album 2'),
_wt('Song B', 'Artist 2', 'alb2', 'Album 2'),
]
res = group_wishlist_tracks_by_album(tracks, min_tracks_per_album=2)
assert len(res.album_groups) == 1
assert res.album_groups[0].album_key == 'alb2'
assert len(res.residual_tracks) == 1
assert res.residual_tracks[0]['track_name'] == 'Solo Track'
def test_default_threshold_promotes_solo_albums():
"""Default ``min_tracks_per_album=1`` — even one missing track
triggers the album-bundle path. Matches the user's stated
preference (don't gate on track count)."""
tracks = [_wt('Solo', 'Artist 1', 'alb1', 'Album 1')]
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 1
assert res.residual_tracks == []
def test_album_without_id_uses_name_normalized_key():
"""Some older wishlist rows are missing the album id. Group by
a name-normalized key so they still bucket together."""
tracks = [
_wt('S1', 'Artist', None, 'Same Album'),
_wt('S2', 'Artist', None, 'Same Album'),
]
# First track has explicit id=None which is filtered; the fallback
# is ``_name_<lowercase trimmed name>``. Build manually so the
# helper sees no id at all.
for t in tracks:
del t['spotify_data']['album']['id']
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 1
assert res.album_groups[0].album_key == '_name_same album'
assert len(res.album_groups[0].tracks) == 2
def test_nested_track_data_payloads_normalized():
"""The wishlist service sometimes nests spotify_data under
track_data (JSON-string in DB → re-parsed). Ensure the grouper
digs through the same shapes ``classify_wishlist_track`` does."""
tracks = [{
'track_data': {
'spotify_data': {
'artists': [{'name': 'Artist'}],
'album': {'id': 'a', 'name': 'Album'},
},
},
}]
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 1
assert res.album_groups[0].album_key == 'a'