soulsync/core/wishlist/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

201 lines
6.8 KiB
Python

"""Wishlist album grouping for the per-album bundle dispatch.
When the auto-wishlist cycle is ``'albums'`` the user expects each
album with missing tracks to fire ONE album-bundle search instead
of one per-track search per missing track. Track lists in the
wishlist may span multiple albums in one cycle, so we group them
upfront + emit one sub-batch per album.
Pure function — no IO, no runtime-state dependency — so it can be
unit-tested without standing up the wishlist runner.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
def _extract_track_data(track: Dict[str, Any]) -> Dict[str, Any]:
"""Mirror of ``classification._extract_track_data``: unwrap nested
Spotify payloads regardless of which key the wishlist row chose
to stash them under."""
for key in ("track_data", "spotify_data", "metadata", "track"):
data = track.get(key)
if isinstance(data, str):
try:
data = json.loads(data)
except Exception:
data = {}
if isinstance(data, dict) and data:
nested = (
data.get("track_data")
or data.get("spotify_data")
or data.get("metadata")
or data.get("track")
)
if isinstance(nested, str):
try:
nested = json.loads(nested)
except Exception:
nested = {}
if isinstance(nested, dict) and nested:
return nested
return data
return {}
def _album_key(spotify_data: Dict[str, Any]) -> Optional[str]:
"""Derive a stable grouping key from a track's Spotify metadata.
Prefers album id (canonical). Falls back to a name-normalized
key when the album row has no id (older wishlist rows can be
missing it). Returns ``None`` when no album information is
available at all — those tracks can't participate in an
album-bundle search and stay on the residual per-track flow.
"""
album = spotify_data.get('album') or {}
if not isinstance(album, dict):
return None
album_id = album.get('id')
if isinstance(album_id, str) and album_id.strip():
return album_id.strip()
name = album.get('name')
if isinstance(name, str) and name.strip():
return f"_name_{name.strip().lower()}"
return None
def _artist_name_from_track(spotify_data: Dict[str, Any], track: Dict[str, Any]) -> str:
"""Pick a primary artist name from the track's metadata.
Album-bundle search needs an artist string. Prefer the first
Spotify artist (most accurate), fall back to ``track_info['artist']``
or ``track['artist_name']`` from the wishlist row, then to empty
string (caller will skip the bundle).
"""
artists = spotify_data.get('artists') or []
if isinstance(artists, list) and artists:
first = artists[0]
if isinstance(first, dict):
name = first.get('name')
if isinstance(name, str) and name.strip():
return name.strip()
elif isinstance(first, str) and first.strip():
return first.strip()
for key in ('artist_name', 'artist'):
val = track.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
return ''
@dataclass
class WishlistAlbumGroup:
"""One album's worth of wishlist tracks ready for a sub-batch."""
album_key: str
album_context: Dict[str, Any]
artist_context: Dict[str, Any]
tracks: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
class WishlistGroupingResult:
"""Aggregated grouping output.
- ``album_groups``: one entry per resolvable album. Each carries
enough context to be submitted as an album-bundle batch.
- ``residual_tracks``: tracks that couldn't be grouped (no
album metadata + no artist). They fall through to the normal
per-track flow.
"""
album_groups: List[WishlistAlbumGroup] = field(default_factory=list)
residual_tracks: List[Dict[str, Any]] = field(default_factory=list)
def group_wishlist_tracks_by_album(
tracks: List[Dict[str, Any]],
*,
min_tracks_per_album: int = 1,
) -> WishlistGroupingResult:
"""Group wishlist tracks by their owning album.
``min_tracks_per_album`` controls the threshold for promoting an
album to its own sub-batch. Default ``1`` means even a single
missing track gets the album-bundle treatment (which is what the
user wants for releases where they only need one track from the
album). Set higher to require multiple missing tracks before
engaging the bundle search.
"""
result = WishlistGroupingResult()
if not tracks:
return result
# First pass: bucket by album key.
buckets: Dict[str, WishlistAlbumGroup] = {}
unbucketable: List[Dict[str, Any]] = []
for track in tracks:
spotify_data = _extract_track_data(track)
key = _album_key(spotify_data)
if key is None:
unbucketable.append(track)
continue
artist_name = _artist_name_from_track(spotify_data, track)
if not artist_name:
unbucketable.append(track)
continue
album = spotify_data.get('album') or {}
if not isinstance(album, dict):
album = {}
album_name = album.get('name', '')
if not (isinstance(album_name, str) and album_name.strip()):
unbucketable.append(track)
continue
group = buckets.get(key)
if group is None:
album_context = {
'id': album.get('id') or key,
'name': album_name.strip(),
'release_date': album.get('release_date', ''),
'total_tracks': album.get('total_tracks', 0),
'album_type': album.get('album_type', 'album'),
'images': album.get('images', []),
'artists': album.get('artists', []),
}
artist_context = {
'id': 'wishlist',
'name': artist_name,
'genres': [],
}
group = WishlistAlbumGroup(
album_key=key,
album_context=album_context,
artist_context=artist_context,
)
buckets[key] = group
group.tracks.append(track)
# Second pass: promote groups meeting the threshold; demote
# smaller groups to residual.
for group in buckets.values():
if len(group.tracks) >= min_tracks_per_album:
result.album_groups.append(group)
else:
result.residual_tracks.extend(group.tracks)
result.residual_tracks.extend(unbucketable)
return result
__all__ = [
'group_wishlist_tracks_by_album',
'WishlistAlbumGroup',
'WishlistGroupingResult',
]