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.
201 lines
6.8 KiB
Python
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',
|
|
]
|