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):
|
for i, track in enumerate(wishlist_tracks):
|
||||||
track['_original_index'] = i
|
track['_original_index'] = i
|
||||||
|
|
||||||
# Create batch for automatic processing
|
# When the cycle is 'albums', try to split the wishlist
|
||||||
batch_id = str(uuid.uuid4())
|
# into per-album sub-batches so each album fires ONE
|
||||||
playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})"
|
# 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
|
if grouping and grouping.album_groups:
|
||||||
with runtime.tasks_lock:
|
for album_idx, group in enumerate(grouping.album_groups):
|
||||||
runtime.download_batches[batch_id] = {
|
album_batch_id = str(uuid.uuid4())
|
||||||
'phase': 'analysis',
|
album_batch_name = (
|
||||||
'playlist_id': playlist_id,
|
f"Wishlist (Auto - Album: {group.album_context.get('name', 'Unknown')})"
|
||||||
'playlist_name': playlist_name,
|
)
|
||||||
'queue': [],
|
with runtime.tasks_lock:
|
||||||
'active_count': 0,
|
runtime.download_batches[album_batch_id] = {
|
||||||
'max_concurrent': runtime.get_batch_max_concurrent(), # Wishlist always does single-track downloads, not folder grabs
|
'phase': 'analysis',
|
||||||
'queue_index': 0,
|
'playlist_id': playlist_id,
|
||||||
'analysis_total': len(wishlist_tracks),
|
'playlist_name': album_batch_name,
|
||||||
'analysis_processed': 0,
|
'queue': [],
|
||||||
'analysis_results': [],
|
'active_count': 0,
|
||||||
# Track state management (replicating sync.py)
|
'max_concurrent': runtime.get_batch_max_concurrent(),
|
||||||
'permanently_failed_tracks': [],
|
'queue_index': 0,
|
||||||
'cancelled_tracks': set(),
|
'analysis_total': len(group.tracks),
|
||||||
# Wishlist tracks are already known-missing — skip the expensive library check
|
'analysis_processed': 0,
|
||||||
'force_download_all': True,
|
'analysis_results': [],
|
||||||
# Mark as auto-initiated
|
'permanently_failed_tracks': [],
|
||||||
'auto_initiated': True,
|
'cancelled_tracks': set(),
|
||||||
'auto_processing_timestamp': runtime.current_time_fn(),
|
'force_download_all': True,
|
||||||
# Store current cycle for toggling after completion
|
'auto_initiated': True,
|
||||||
'current_cycle': current_cycle,
|
'auto_processing_timestamp': runtime.current_time_fn(),
|
||||||
# Profile context for failed track wishlist re-adds (auto = profile 1 default)
|
'current_cycle': current_cycle,
|
||||||
'profile_id': runtime.profile_id,
|
'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")
|
# Residual tracks (no album group could be formed, OR
|
||||||
runtime.update_automation_progress(automation_id, progress=50, phase=f'Downloading {len(wishlist_tracks)} tracks',
|
# singles cycle): one classic per-track batch as before.
|
||||||
log_line=f'Started batch: {len(wishlist_tracks)} {current_cycle}', log_type='success')
|
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
|
_summary_parts: list[str] = []
|
||||||
runtime.missing_download_executor.submit(runtime.run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
|
if grouping and grouping.album_groups:
|
||||||
|
_summary_parts.append(f"{len(grouping.album_groups)} album batch(es)")
|
||||||
# Don't mark auto_processing as False here - let completion handler do it
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error in automatic wishlist processing: {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 batch["analysis_total"] == 1
|
||||||
assert any(kwargs.get("progress") == 50 for _args, kwargs in progress_calls)
|
assert any(kwargs.get("progress") == 50 for _args, kwargs in progress_calls)
|
||||||
assert guard_events == ["enter", "exit"]
|
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():
|
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: '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: '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: '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': [
|
'2.6.2': [
|
||||||
{ date: 'May 24, 2026 — 2.6.2 release' },
|
{ date: 'May 24, 2026 — 2.6.2 release' },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue