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.
159 lines
5.5 KiB
Python
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'
|