soulsync/tests/wishlist/test_album_grouping.py
Broque Thomas dd32e3bbe1 Wishlist: only engage album-bundle when multiple tracks from same album (PR 1/4)
Real-world wishlist case the original c3b88e69 design missed: user with
26 missing tracks from 26 different albums. Each item used to promote
to its own album-bundle sub-batch (``min_tracks_per_album=1``), which
downloaded the ENTIRE album (5-42 files) to claim one track. Confirmed
in app.log:

- "Licensed To Ill" downloaded 3 times across cycles (3-4 files each)
- "The Understanding" 17 files for 1 wishlist track
- "Alright, Still" 42 files for 1 wishlist track
- ~85% wasted bandwidth, slskd hammered with 26 concurrent searches

PR 1 of a 4-PR fix series — see commit body footer for the other PRs.

Default ``min_tracks_per_album`` 1 → 2. Single-track wishlist items
fall to ``residual_tracks`` → classic per-track batch (already works,
already efficient). Album-bundle kept for the case it was designed
for: user has 2+ tracks missing from the same album.

Override via the new ``wishlist.album_bundle_min_tracks`` config key:
- 1 = previous behaviour (bundle every item)
- 2 = new default
- 3+ = stricter, for users who want bundle only on bigger gaps

Helper ``_resolve_album_bundle_threshold`` lives in
``core/wishlist/processing.py``. Defensive shape mirrors the existing
config-driven knobs (``get_poll_interval`` / ``get_transient_miss_threshold``):
non-numeric, non-positive, or config-manager-raise all fall back to
the safe default. Three test cases pin the fallback chain.

Both wishlist entry points wired through the same helper:
- ``process_wishlist_automatically`` (auto cycle, line 812)
- ``start_manual_wishlist_download_batch`` (manual run, line 539)

Tests:
- ``tests/wishlist/test_album_grouping.py`` — old ``test_default_threshold_promotes_solo_albums`` flipped to ``test_default_threshold_demotes_solo_albums`` with explanatory docstring naming the real-world cause. New ``test_default_threshold_promotes_multi_track_albums`` pins the 2+ promotion. New ``test_explicit_threshold_one_restores_solo_promotion`` pins that the kwarg still works for opt-back-in.
- ``tests/wishlist/test_processing.py`` — 3 new tests for ``_resolve_album_bundle_threshold``: default-when-config-missing, honors-config-override, falls-back-on-garbage.
- ``tests/wishlist/test_automation.py`` — ``test_wishlist_albums_cycle_splits_into_per_album_batches`` updated to use 2+ tracks per album (5 tracks across 2 albums instead of 3 across 2 with 1 solo). ``test_wishlist_albums_cycle_residual_for_orphan_tracks`` updated to include 2 tracks from Album One so it still promotes.
- ``tests/wishlist/test_manual_download.py`` — same shape update for the manual path test.
- ``tests/wishlist/test_album_grouping.py:test_multiple_albums_emit_separate_groups`` updated to reflect new default (alb1 with 2 tracks promotes, alb2 with 1 track goes residual).
- ``tests/wishlist/test_album_grouping.py:test_nested_track_data_payloads_normalized`` pinned with explicit ``min_tracks_per_album=1`` so the test stays focused on payload-shape parsing, not the threshold rule.

114 wishlist tests pass; 866 across wishlist + automation + downloads +
album_bundle + album_bundle_dispatch suites still green. Ruff clean.

Sibling PRs queued in TaskCreate:
- PR 2 — investigate post-process staging-match miss (the second-order
  bug that causes the same album to redownload every cycle when the
  staging step doesn't claim the requested track).
- PR 3 — fix sibling-completion gate that fires on first sibling
  instead of last (log evidence: run a4945c88 finalized 1/26 batches).
- PR 4 — UI distinguish Queued from Analyzing for batches waiting
  on the executor (23/26 batches sit at "Analyzing..." while really
  queued at max_workers=3).
2026-05-27 13:42:04 -07:00

192 lines
7.3 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():
"""Two tracks in alb1 promotes that album to a group at the default
threshold of 2; alb2's one solo track falls to residual."""
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) == 1
assert res.album_groups[0].album_key == 'alb1'
assert len(res.album_groups[0].tracks) == 2
assert len(res.residual_tracks) == 1
assert res.residual_tracks[0]['track_name'] == 'Song C'
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`` (the default), 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_demotes_solo_albums():
"""Default ``min_tracks_per_album=2`` — a wishlist with one missing
track from one album falls to residual so the per-track flow
handles it instead of engaging album-bundle for a single file.
Real-world reason: typical wishlist looks like "26 single tracks
from 26 different albums" and bundling each one downloads ~85%
bandwidth as unwanted files, hammers slskd with concurrent
searches, and re-downloads the same album every cycle when the
staging-match step doesn't claim the requested track."""
tracks = [_wt('Solo', 'Artist 1', 'alb1', 'Album 1')]
res = group_wishlist_tracks_by_album(tracks)
assert res.album_groups == []
assert len(res.residual_tracks) == 1
assert res.residual_tracks[0]['track_name'] == 'Solo'
def test_default_threshold_promotes_multi_track_albums():
"""Default still promotes albums with 2+ missing tracks — the
album-bundle path is the right tool when several files from the
same release are missing (downloading 5 tracks to claim 2 still
beats five separate per-track searches against the same album)."""
tracks = [
_wt('Song A', 'Artist', 'alb1', 'Album'),
_wt('Song B', 'Artist', 'alb1', 'Album'),
]
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 1
assert len(res.album_groups[0].tracks) == 2
assert res.residual_tracks == []
def test_explicit_threshold_one_restores_solo_promotion():
"""Power users / tests can opt back into the old "bundle even one
track" behaviour by passing ``min_tracks_per_album=1`` explicitly.
Default change is opt-out via the public kwarg, not a removed
capability."""
tracks = [_wt('Solo', 'Artist 1', 'alb1', 'Album 1')]
res = group_wishlist_tracks_by_album(tracks, min_tracks_per_album=1)
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'},
},
},
}]
# Use threshold=1 here — the test is about payload-shape parsing,
# not the threshold rule, so don't conflate the two.
res = group_wishlist_tracks_by_album(tracks, min_tracks_per_album=1)
assert len(res.album_groups) == 1
assert res.album_groups[0].album_key == 'a'