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.
This commit is contained in:
parent
85426a210c
commit
c3b88e6963
5 changed files with 565 additions and 37 deletions
201
core/wishlist/album_grouping.py
Normal file
201
core/wishlist/album_grouping.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""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',
|
||||
]
|
||||
|
|
@ -639,45 +639,115 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom
|
|||
for i, track in enumerate(wishlist_tracks):
|
||||
track['_original_index'] = i
|
||||
|
||||
# Create batch for automatic processing
|
||||
batch_id = str(uuid.uuid4())
|
||||
playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})"
|
||||
# When the cycle is 'albums', try to split the wishlist
|
||||
# into per-album sub-batches so each album fires ONE
|
||||
# album-bundle search (slskd / torrent / usenet) instead
|
||||
# of N per-track searches. Residual tracks (no resolvable
|
||||
# album metadata) fall through to a normal per-track
|
||||
# batch. Singles cycle keeps its original single-batch
|
||||
# shape — Spotify already classifies them away from
|
||||
# albums.
|
||||
_submitted_batches: list[str] = []
|
||||
if current_cycle == 'albums':
|
||||
from core.wishlist.album_grouping import group_wishlist_tracks_by_album
|
||||
grouping = group_wishlist_tracks_by_album(wishlist_tracks)
|
||||
else:
|
||||
grouping = None
|
||||
|
||||
# Create task queue - convert wishlist tracks to expected format
|
||||
with runtime.tasks_lock:
|
||||
runtime.download_batches[batch_id] = {
|
||||
'phase': 'analysis',
|
||||
'playlist_id': playlist_id,
|
||||
'playlist_name': playlist_name,
|
||||
'queue': [],
|
||||
'active_count': 0,
|
||||
'max_concurrent': runtime.get_batch_max_concurrent(), # Wishlist always does single-track downloads, not folder grabs
|
||||
'queue_index': 0,
|
||||
'analysis_total': len(wishlist_tracks),
|
||||
'analysis_processed': 0,
|
||||
'analysis_results': [],
|
||||
# Track state management (replicating sync.py)
|
||||
'permanently_failed_tracks': [],
|
||||
'cancelled_tracks': set(),
|
||||
# Wishlist tracks are already known-missing — skip the expensive library check
|
||||
'force_download_all': True,
|
||||
# Mark as auto-initiated
|
||||
'auto_initiated': True,
|
||||
'auto_processing_timestamp': runtime.current_time_fn(),
|
||||
# Store current cycle for toggling after completion
|
||||
'current_cycle': current_cycle,
|
||||
# Profile context for failed track wishlist re-adds (auto = profile 1 default)
|
||||
'profile_id': runtime.profile_id,
|
||||
}
|
||||
if grouping and grouping.album_groups:
|
||||
for album_idx, group in enumerate(grouping.album_groups):
|
||||
album_batch_id = str(uuid.uuid4())
|
||||
album_batch_name = (
|
||||
f"Wishlist (Auto - Album: {group.album_context.get('name', 'Unknown')})"
|
||||
)
|
||||
with runtime.tasks_lock:
|
||||
runtime.download_batches[album_batch_id] = {
|
||||
'phase': 'analysis',
|
||||
'playlist_id': playlist_id,
|
||||
'playlist_name': album_batch_name,
|
||||
'queue': [],
|
||||
'active_count': 0,
|
||||
'max_concurrent': runtime.get_batch_max_concurrent(),
|
||||
'queue_index': 0,
|
||||
'analysis_total': len(group.tracks),
|
||||
'analysis_processed': 0,
|
||||
'analysis_results': [],
|
||||
'permanently_failed_tracks': [],
|
||||
'cancelled_tracks': set(),
|
||||
'force_download_all': True,
|
||||
'auto_initiated': True,
|
||||
'auto_processing_timestamp': runtime.current_time_fn(),
|
||||
'current_cycle': current_cycle,
|
||||
'profile_id': runtime.profile_id,
|
||||
# Album-bundle dispatch gate reads these
|
||||
# three. With them set, the master worker
|
||||
# routes through slskd / torrent / usenet
|
||||
# album-bundle search instead of per-track.
|
||||
'is_album_download': True,
|
||||
'album_context': group.album_context,
|
||||
'artist_context': group.artist_context,
|
||||
}
|
||||
logger.info(
|
||||
f"[Auto-Wishlist] Album sub-batch {album_idx + 1}/{len(grouping.album_groups)}: "
|
||||
f"'{group.album_context.get('name')}' by '{group.artist_context.get('name')}' "
|
||||
f"({len(group.tracks)} tracks) → {album_batch_id}"
|
||||
)
|
||||
_submitted_batches.append(album_batch_id)
|
||||
runtime.missing_download_executor.submit(
|
||||
runtime.run_full_missing_tracks_process,
|
||||
album_batch_id, playlist_id, group.tracks,
|
||||
)
|
||||
|
||||
logger.info(f"Starting automatic wishlist batch {batch_id} with {len(wishlist_tracks)} tracks")
|
||||
runtime.update_automation_progress(automation_id, progress=50, phase=f'Downloading {len(wishlist_tracks)} tracks',
|
||||
log_line=f'Started batch: {len(wishlist_tracks)} {current_cycle}', log_type='success')
|
||||
# Residual tracks (no album group could be formed, OR
|
||||
# singles cycle): one classic per-track batch as before.
|
||||
residual_tracks = (
|
||||
grouping.residual_tracks if grouping is not None else wishlist_tracks
|
||||
)
|
||||
if residual_tracks:
|
||||
batch_id = str(uuid.uuid4())
|
||||
playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})"
|
||||
with runtime.tasks_lock:
|
||||
runtime.download_batches[batch_id] = {
|
||||
'phase': 'analysis',
|
||||
'playlist_id': playlist_id,
|
||||
'playlist_name': playlist_name,
|
||||
'queue': [],
|
||||
'active_count': 0,
|
||||
'max_concurrent': runtime.get_batch_max_concurrent(),
|
||||
'queue_index': 0,
|
||||
'analysis_total': len(residual_tracks),
|
||||
'analysis_processed': 0,
|
||||
'analysis_results': [],
|
||||
'permanently_failed_tracks': [],
|
||||
'cancelled_tracks': set(),
|
||||
'force_download_all': True,
|
||||
'auto_initiated': True,
|
||||
'auto_processing_timestamp': runtime.current_time_fn(),
|
||||
'current_cycle': current_cycle,
|
||||
'profile_id': runtime.profile_id,
|
||||
}
|
||||
_submitted_batches.append(batch_id)
|
||||
runtime.missing_download_executor.submit(
|
||||
runtime.run_full_missing_tracks_process,
|
||||
batch_id, playlist_id, residual_tracks,
|
||||
)
|
||||
logger.info(
|
||||
f"Starting wishlist residual batch {batch_id} with {len(residual_tracks)} tracks "
|
||||
f"({'singles' if current_cycle == 'singles' else 'unbucketed albums'})"
|
||||
)
|
||||
|
||||
# Submit the wishlist processing job using existing infrastructure
|
||||
runtime.missing_download_executor.submit(runtime.run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
|
||||
|
||||
# Don't mark auto_processing as False here - let completion handler do it
|
||||
_summary_parts: list[str] = []
|
||||
if grouping and grouping.album_groups:
|
||||
_summary_parts.append(f"{len(grouping.album_groups)} album batch(es)")
|
||||
if residual_tracks:
|
||||
_summary_parts.append(f"{len(residual_tracks)} per-track")
|
||||
_summary_text = ', '.join(_summary_parts) or 'no batches'
|
||||
runtime.update_automation_progress(
|
||||
automation_id, progress=50,
|
||||
phase=f'Downloading {len(wishlist_tracks)} tracks',
|
||||
log_line=f'Started: {_summary_text} for cycle {current_cycle}',
|
||||
log_type='success',
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in automatic wishlist processing: {e}")
|
||||
|
|
|
|||
159
tests/wishlist/test_album_grouping.py
Normal file
159
tests/wishlist/test_album_grouping.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""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'
|
||||
|
|
@ -233,7 +233,104 @@ def test_process_wishlist_automatically_creates_batch_for_matching_tracks():
|
|||
assert batch["analysis_total"] == 1
|
||||
assert any(kwargs.get("progress") == 50 for _args, kwargs in progress_calls)
|
||||
assert guard_events == ["enter", "exit"]
|
||||
assert any("Starting automatic wishlist batch" in msg for msg in logger.info_messages)
|
||||
# Track has no album id/name → falls to residual batch path
|
||||
assert any("Starting wishlist residual batch" in msg for msg in logger.info_messages)
|
||||
|
||||
|
||||
def test_wishlist_albums_cycle_splits_into_per_album_batches():
|
||||
"""Multi-album wishlist run: each album emits its own sub-batch
|
||||
with ``is_album_download=True`` + populated album/artist context.
|
||||
Pinned so the album-bundle dispatch gate (which keys on those
|
||||
fields) engages per album instead of falling through to per-track
|
||||
on a single mixed batch."""
|
||||
batch_map = {}
|
||||
runtime, _service, _profiles_db, music_db, executor, _logger, _progress, _guards = _build_runtime(
|
||||
tracks=[
|
||||
{
|
||||
"name": "Song A1",
|
||||
"artists": [{"name": "Artist 1"}],
|
||||
"spotify_data": {
|
||||
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
|
||||
"artists": [{"name": "Artist 1"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Song A2",
|
||||
"artists": [{"name": "Artist 1"}],
|
||||
"spotify_data": {
|
||||
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
|
||||
"artists": [{"name": "Artist 1"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Song B1",
|
||||
"artists": [{"name": "Artist 2"}],
|
||||
"spotify_data": {
|
||||
"album": {"id": "alb2", "name": "Album Two", "album_type": "album"},
|
||||
"artists": [{"name": "Artist 2"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
cycle_value="albums",
|
||||
count=3,
|
||||
batch_map=batch_map,
|
||||
)
|
||||
|
||||
process_wishlist_automatically(runtime, automation_id="auto-multi-album")
|
||||
|
||||
# Two album groups → two sub-batches submitted (no residual).
|
||||
assert len(executor.submissions) == 2
|
||||
assert len(batch_map) == 2
|
||||
|
||||
# Each sub-batch must carry album-bundle dispatch context.
|
||||
for batch in batch_map.values():
|
||||
assert batch.get("is_album_download") is True
|
||||
assert batch.get("album_context", {}).get("name") in {"Album One", "Album Two"}
|
||||
assert batch.get("artist_context", {}).get("name") in {"Artist 1", "Artist 2"}
|
||||
|
||||
submitted_track_lists = [submitted_args[2] for _fn, submitted_args, _kw in executor.submissions]
|
||||
track_counts = sorted(len(tracks) for tracks in submitted_track_lists)
|
||||
assert track_counts == [1, 2]
|
||||
|
||||
|
||||
def test_wishlist_albums_cycle_residual_for_orphan_tracks():
|
||||
"""Tracks without resolvable album metadata fall to the classic
|
||||
per-track residual batch (no ``is_album_download`` flag), while
|
||||
sibling tracks with valid album info still get their own
|
||||
album-bundle sub-batch."""
|
||||
batch_map = {}
|
||||
runtime, _service, _profiles_db, music_db, executor, _logger, _progress, _guards = _build_runtime(
|
||||
tracks=[
|
||||
{
|
||||
"name": "Real Album Track",
|
||||
"artists": [{"name": "Artist 1"}],
|
||||
"spotify_data": {
|
||||
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
|
||||
"artists": [{"name": "Artist 1"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
# No album id, no album name — orphan
|
||||
"name": "Orphan",
|
||||
"artists": [{"name": "X"}],
|
||||
"spotify_data": {"album": {"album_type": "album"}, "artists": [{"name": "X"}]},
|
||||
},
|
||||
],
|
||||
cycle_value="albums",
|
||||
count=2,
|
||||
batch_map=batch_map,
|
||||
)
|
||||
|
||||
process_wishlist_automatically(runtime, automation_id="auto-mixed")
|
||||
|
||||
assert len(executor.submissions) == 2 # 1 album batch + 1 residual
|
||||
|
||||
album_batches = [b for b in batch_map.values() if b.get("is_album_download")]
|
||||
residual_batches = [b for b in batch_map.values() if not b.get("is_album_download")]
|
||||
assert len(album_batches) == 1
|
||||
assert len(residual_batches) == 1
|
||||
assert album_batches[0]["album_context"]["name"] == "Album One"
|
||||
assert residual_batches[0]["analysis_total"] == 1
|
||||
|
||||
|
||||
def test_process_wishlist_automatically_returns_early_when_already_processing():
|
||||
|
|
|
|||
|
|
@ -3422,6 +3422,7 @@ const WHATS_NEW = {
|
|||
{ title: 'Last.fm Radio Sync tab', desc: 'sibling to the ListenBrainz tab — lists your generated Last.fm Radio playlists alongside the rest of the Sync sources. same discovery → mirror flow under the hood, just a different entry point. new Last.fm radios are still generated from the Discover page by picking a seed track; this tab is for syncing existing ones. mirrors auto-trim when Last.fm Radio cache rotates so old radios don\'t pile up.', page: 'sync' },
|
||||
{ title: 'SoulSync Discovery Sync tab', desc: 'last of the unified-tab trio. surfaces your personalized SoulSync Discovery playlists (decade mixes, hidden gems, popular picks, daily mixes, discovery shuffle, etc.) on the Sync page. clicking a card regenerates the playlist + mirrors it under a stable synthetic id, so the same mirror updates in place every Auto-Sync refresh. tracks come out already matched against Spotify / iTunes / Deezer so there\'s no discovery hop — straight to download / sync.', page: 'sync' },
|
||||
{ title: 'Fix: album-bundle downloads all landing as track 1', desc: 'soulseek album-bundle downloads (and any other untagged release-staging path) were importing every track with track_number=1. the staging-file reader was using the auto-import\'s filename extractor that defaults to 1 when no NN- prefix is present in the filename — so for albums like Ryoto\'s "Cha-La Head-Cha-La" where slskd hands you bare titles, every file got "track 1" stamped on it. now the staging path uses a strict extractor that returns 0 when it can\'t see an explicit prefix, so the downstream resolver correctly falls through to the authoritative Spotify metadata and the right track numbers land in the library.' },
|
||||
{ title: 'Wishlist albums-cycle: one album-bundle search per album', desc: 'auto-wishlist runs in two cycles — albums + singles. previously the albums cycle dumped every missing track from every album into one big batch and ran a separate per-track Soulseek / Prowlarr search for each (~50 searches for a typical scan). now the albums cycle splits the wishlist into per-album sub-batches at submission time, and each one engages the existing slskd / torrent / usenet album-bundle release-first flow with that album\'s context. tracks without resolvable album metadata stay on the classic per-track residual batch so nothing falls off. singles cycle is unchanged.' },
|
||||
],
|
||||
'2.6.2': [
|
||||
{ date: 'May 24, 2026 — 2.6.2 release' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue