Your Albums: selectable wishlist modal + Tidal album resolution

Two-part fix to the Your Albums "Download Missing" flow on Discover.

Part A — UX redesign

The prior `downloadMissingYourAlbums()` ran a per-album loop that
fired direct-download tasks via `openDownloadMissingModalForYouTube`.
Reported as silently failing — "Queuing 2/2" toast with no actual
transfer activity. Even when downloads worked, bypassing the
wishlist meant no retry / dedup / rate-limit / source-fallback
handling.

Replaced with a selectable-grid modal mirroring the Download
Discography pattern from the library page. Click the download
button → opens a checkbox grid showing every missing album (cover,
title, artist, year, track count, source) → user picks what they
actually want → click "Add to Wishlist" → each album's tracks get
resolved + queued through the existing wishlist auto-download
processor. NDJSON progress stream renders ✓/✗ per album.

New JS helpers:
- `_openYourAlbumsBatchModal(missingAlbums)` — builds the modal
- `_renderYourAlbumsBatchCard(row, index)` — per-album card
- `_yourAlbumsBatchSelectAll(select)` — bulk toggle
- `_updateYourAlbumsBatchFooterCount()` — live count + button text
- `_closeYourAlbumsBatchModal()` — overlay teardown
- `_startYourAlbumsBatchAddToWishlist()` — submit handler, NDJSON
  progress consumer
- `_yourAlbumsPickSource(album)` — picks the single best source-id
  per row (priority: spotify → deezer → tidal → discogs)

Reuses the `.discog-*` CSS classes from the library Download
Discography modal — no new CSS. Reuses the existing
`/api/artist/<id>/download-discography` endpoint. The endpoint's URL
artist_id param is functionally unused (per-album payload carries
everything — verified by reading the endpoint body), so the modal
posts with placeholder `your-albums` and gets multi-artist
resolution for free without backend changes.

Part B — Tidal album resolution

Reported as the original bug: clicking download on Tidal-only albums
did nothing because `/api/discover/album/<source>/<album_id>` had no
`tidal` branch and `tidal_client` had no `get_album_tracks` method.

`core/tidal_client.py`: new `get_album_tracks(album_id, limit=None)`
method. Two-phase: cursor-walk
`/v2/albums/<id>/relationships/items?include=items` for track refs +
position metadata (`meta.trackNumber` + `meta.volumeNumber`),
batch-hydrate via existing `_get_tracks_batch` for artist/album
names. Returns `Track` objects with `track_number` and `disc_number`
attached. Sort by (disc, track) so multi-disc compilations render in
album order.

`web_server.py`: new `'tidal'` source branch in
`/api/discover/album/<source>/<album_id>`. Resolves album metadata
via `get_album`, tracks via `get_album_tracks`, cover art via inline
`?include=coverArt` lookup. Same response shape as Spotify/Deezer
branches.

`webui/static/discover.js`:
- `tidal_album_id` added to `trySources` for the single-album click
  flow (`openYourAlbumDownload`)
- Same source picker drives the new batch modal
- Virtual-id generation includes `tidal_album_id` so Tidal-only
  albums get stable identifiers across discover-album-* / your-
  albums-* contexts

10 new tests in `tests/test_tidal_album_tracks.py` pin:
- Single-page walk + hydration
- Multi-page cursor chain
- Multi-disc sort order (disc 1 → 2 in track order each)
- `limit` short-circuit at page boundary
- No-token short-circuit (no API call)
- HTTP error returns empty
- 429 raises (propagates to `rate_limited` decorator for retry)
- Forward-compat type filter (skips non-track entries)
- Partial-batch hydration failure containment
- Empty-album short-circuit (no batch call)

Full pytest: 2693 passed.
pull/552/head
Broque Thomas 6 days ago
parent 0c6aaac0d0
commit 4fb9f38798

@ -1075,6 +1075,121 @@ class TidalClient:
logger.error(f"Error getting Tidal album {album_id}: {e}")
return None
@rate_limited
def get_album_tracks(self, album_id: str, limit: Optional[int] = None) -> List[Track]:
"""Fetch every track on an album with full artist + name + duration
metadata hydrated.
Two-phase: walk `/v2/albums/{id}/relationships/items?include=items`
cursor chain to enumerate track IDs (with their position metadata
`meta.trackNumber` + `meta.volumeNumber` for multi-disc), then
feed the IDs through the existing `_get_tracks_batch` helper for
artist + album-name resolution.
Returns a list of `Track` dataclasses with `track_number` and
`disc_number` attached as ad-hoc attributes so callers that need
per-position info (download modal, virtual playlist build) can
read them. Backend `/api/discover/album/<source>/<album_id>`
serializes these to the same shape Spotify/Deezer return."""
if not self._ensure_valid_token():
return []
# Phase 1: enumerate track IDs + position metadata via cursor pagination.
# The relationship endpoint pages at 20 items by default. The `meta`
# dict on each ref carries `trackNumber` + `volumeNumber` (multi-disc).
track_meta_by_id: Dict[str, Dict[str, int]] = {}
track_ids: List[str] = []
next_path: Optional[str] = None
while True:
if next_path:
url = (next_path if next_path.startswith('http')
else f"https://openapi.tidal.com/v2{next_path}")
params = None
else:
url = f"{self.base_url}/albums/{album_id}/relationships/items"
params = {'countryCode': 'US', 'include': 'items'}
try:
resp = self.session.get(
url, params=params,
headers={'accept': 'application/vnd.api+json'},
timeout=15,
)
except Exception as e:
logger.debug(f"Tidal album-tracks page request failed: {e}")
break
if resp.status_code != 200:
if resp.status_code == 429:
raise Exception("Rate limited (429) on get_album_tracks")
logger.debug(
f"Tidal album-tracks page returned {resp.status_code}: {resp.text[:200]}"
)
break
try:
data = resp.json()
except ValueError:
break
for item in data.get('data', []):
if item.get('type') != 'tracks':
continue
tid = item.get('id')
if not tid:
continue
tid = str(tid)
meta = item.get('meta', {}) or {}
track_meta_by_id[tid] = {
'track_number': int(meta.get('trackNumber') or 0),
'disc_number': int(meta.get('volumeNumber') or 1),
}
track_ids.append(tid)
if limit is not None and len(track_ids) >= limit:
break
if limit is not None and len(track_ids) >= limit:
break
next_path = data.get('links', {}).get('next')
if not next_path:
break
time.sleep(0.3)
if not track_ids:
return []
# Phase 2: batch hydrate via existing helper (artists + album names).
# Annotate each Track with position metadata so callers can build the
# per-track-number payload the download pipeline expects.
hydrated: List[Track] = []
for i in range(0, len(track_ids), self._COLLECTION_BATCH_SIZE):
batch_ids = track_ids[i:i + self._COLLECTION_BATCH_SIZE]
try:
batch_tracks = self._get_tracks_batch(batch_ids)
except Exception as e:
logger.debug(f"Tidal album-tracks batch hydration failed: {e}")
continue
for t in batch_tracks:
meta = track_meta_by_id.get(str(t.id), {})
t.track_number = meta.get('track_number', 0)
t.disc_number = meta.get('disc_number', 1)
hydrated.append(t)
# Tidal's relationship walk returns tracks in album order; the
# batch endpoint may not preserve order. Sort by (disc, track)
# so the modal renders the album top-down.
hydrated.sort(key=lambda t: (
getattr(t, 'disc_number', 1),
getattr(t, 'track_number', 0),
))
logger.info(
f"Retrieved {len(hydrated)}/{len(track_ids)} tracks for Tidal album {album_id}"
)
return hydrated
@rate_limited
def get_track(self, track_id: str) -> Optional[Dict]:
"""Get full track details by Tidal ID."""

@ -0,0 +1,276 @@
"""Pin Tidal `get_album_tracks` — fetches every track on an album
with full artist + name + duration metadata hydrated.
Discord report: clicking 'Download All' on the Your Albums section
showed "Queuing..." but never actually queued any Tidal-only albums.
Root cause: `/api/discover/album/<source>/<album_id>` had no `tidal`
branch and tidal_client had no `get_album_tracks` method the
frontend's trySources fell back to spotify/deezer which returned
None for Tidal-only IDs.
This test suite covers the new tidal_client method:
- Cursor-paginated walk of `/v2/albums/{id}/relationships/items`
- Track meta (trackNumber + volumeNumber for multi-disc)
- Batch hydration via `_get_tracks_batch` for artist/album names
- Sort by (disc_number, track_number) so the modal renders in
album order across multi-disc releases
- Empty / error paths return [] without raising
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from core.tidal_client import Track, TidalClient
def _make_client():
client = TidalClient.__new__(TidalClient)
client.access_token = "fake-token"
client.token_expires_at = 9_999_999_999
client.base_url = "https://openapi.tidal.com/v2"
client.alt_base_url = "https://api.tidal.com/v1"
client.session = MagicMock()
return client
class _FakeResp:
def __init__(self, status_code=200, json_body=None, text=""):
self.status_code = status_code
self._body = json_body if json_body is not None else {}
self.text = text or str(self._body)
def json(self):
return self._body
# ---------------------------------------------------------------------------
# Single-page album (12 tracks, single disc)
# ---------------------------------------------------------------------------
_SINGLE_PAGE = {
'data': [
{'id': '1001', 'type': 'tracks', 'meta': {'volumeNumber': 1, 'trackNumber': 1}},
{'id': '1002', 'type': 'tracks', 'meta': {'volumeNumber': 1, 'trackNumber': 2}},
{'id': '1003', 'type': 'tracks', 'meta': {'volumeNumber': 1, 'trackNumber': 3}},
],
'links': {}, # no `next` — single-page album
}
class TestSinglePageAlbum:
def test_walks_page_and_hydrates(self):
"""Happy path: 3-track album, single page, single disc.
IDs enumerated batch hydrated returned in album order."""
client = _make_client()
client.session.get = MagicMock(return_value=_FakeResp(200, _SINGLE_PAGE))
client._get_tracks_batch = MagicMock(return_value=[
Track(id='1001', name='Track One', artists=['Artist'], duration_ms=180000),
Track(id='1002', name='Track Two', artists=['Artist'], duration_ms=200000),
Track(id='1003', name='Track Three', artists=['Artist'], duration_ms=220000),
])
with patch('core.tidal_client.time.sleep'):
tracks = client.get_album_tracks('album-1')
assert [t.id for t in tracks] == ['1001', '1002', '1003']
assert [t.track_number for t in tracks] == [1, 2, 3]
# Single disc → all volumeNumber=1
assert all(t.disc_number == 1 for t in tracks)
def test_no_token_returns_empty_without_request(self):
"""Auth precheck failure short-circuits."""
client = _make_client()
client.session.get = MagicMock()
with patch.object(client, '_ensure_valid_token', return_value=False):
assert client.get_album_tracks('album-1') == []
client.session.get.assert_not_called()
def test_http_error_returns_empty(self):
client = _make_client()
client.session.get = MagicMock(return_value=_FakeResp(404, text='not found'))
with patch('core.tidal_client.time.sleep'):
assert client.get_album_tracks('album-1') == []
def test_429_raises_for_rate_limit_decorator(self):
"""The `rate_limited` decorator looks for '429' in the exception
message to trigger retry/backoff. Don't swallow rate-limit
responses propagate so the decorator can handle them."""
client = _make_client()
client.session.get = MagicMock(return_value=_FakeResp(429, text='rate limited'))
with patch('core.tidal_client.time.sleep'):
with pytest.raises(Exception, match='429'):
client.get_album_tracks('album-1')
def test_skips_non_track_data_entries(self):
"""Forward-compat: schema additions might surface non-track
types alongside tracks only collect entries with type='tracks'."""
client = _make_client()
client.session.get = MagicMock(return_value=_FakeResp(200, {
'data': [
{'id': '1', 'type': 'tracks', 'meta': {'trackNumber': 1, 'volumeNumber': 1}},
{'id': '99', 'type': 'videos', 'meta': {'trackNumber': 99}},
],
'links': {},
}))
client._get_tracks_batch = MagicMock(return_value=[
Track(id='1', name='Track', artists=['A'], duration_ms=100),
])
with patch('core.tidal_client.time.sleep'):
tracks = client.get_album_tracks('album-1')
assert len(tracks) == 1
assert tracks[0].id == '1'
# ---------------------------------------------------------------------------
# Multi-disc album — sort order matters
# ---------------------------------------------------------------------------
class TestMultiDiscAlbum:
def test_sorts_by_disc_then_track(self):
"""Reporter's albums could be multi-disc compilations. After
batch hydration the tracks may not be in album order
(filter[id] endpoint doesn't guarantee preservation). Verify
the final list is sorted by (disc, track) so the download
modal renders disc 1 2 in track order each."""
client = _make_client()
# Page returns IDs in scrambled order intentionally
client.session.get = MagicMock(return_value=_FakeResp(200, {
'data': [
{'id': 'd1t2', 'type': 'tracks', 'meta': {'volumeNumber': 1, 'trackNumber': 2}},
{'id': 'd2t1', 'type': 'tracks', 'meta': {'volumeNumber': 2, 'trackNumber': 1}},
{'id': 'd1t1', 'type': 'tracks', 'meta': {'volumeNumber': 1, 'trackNumber': 1}},
{'id': 'd2t2', 'type': 'tracks', 'meta': {'volumeNumber': 2, 'trackNumber': 2}},
],
'links': {},
}))
client._get_tracks_batch = MagicMock(return_value=[
# Batch endpoint may not preserve order — return scrambled too
Track(id='d2t1', name='D2T1', artists=['A'], duration_ms=100),
Track(id='d1t1', name='D1T1', artists=['A'], duration_ms=100),
Track(id='d2t2', name='D2T2', artists=['A'], duration_ms=100),
Track(id='d1t2', name='D1T2', artists=['A'], duration_ms=100),
])
with patch('core.tidal_client.time.sleep'):
tracks = client.get_album_tracks('album-1')
# Expect: disc 1 first (tracks 1,2), then disc 2 (tracks 1,2)
assert [t.id for t in tracks] == ['d1t1', 'd1t2', 'd2t1', 'd2t2']
assert [(t.disc_number, t.track_number) for t in tracks] == [
(1, 1), (1, 2), (2, 1), (2, 2),
]
# ---------------------------------------------------------------------------
# Multi-page album — cursor walk
# ---------------------------------------------------------------------------
class TestMultiPageAlbum:
def test_follows_cursor_chain(self):
"""Big album (>20 tracks) — cursor chain must be walked.
First page returns links.next, second page returns no next."""
client = _make_client()
page1 = {
'data': [
{'id': '1', 'type': 'tracks', 'meta': {'trackNumber': 1, 'volumeNumber': 1}},
{'id': '2', 'type': 'tracks', 'meta': {'trackNumber': 2, 'volumeNumber': 1}},
],
'links': {'next': '/albums/x/relationships/items?cursor=ABC'},
}
page2 = {
'data': [
{'id': '3', 'type': 'tracks', 'meta': {'trackNumber': 3, 'volumeNumber': 1}},
],
'links': {},
}
responses = iter([_FakeResp(200, page1), _FakeResp(200, page2)])
client.session.get = MagicMock(side_effect=lambda *a, **kw: next(responses))
client._get_tracks_batch = MagicMock(return_value=[
Track(id='1', name='T1', artists=['A'], duration_ms=100),
Track(id='2', name='T2', artists=['A'], duration_ms=100),
Track(id='3', name='T3', artists=['A'], duration_ms=100),
])
with patch('core.tidal_client.time.sleep'):
tracks = client.get_album_tracks('album-1')
assert [t.id for t in tracks] == ['1', '2', '3']
# Two page requests must have happened
assert client.session.get.call_count == 2
def test_limit_short_circuits_at_page_boundary(self):
"""`limit` arg caps the walk early — useful for callers that
only want a preview, not the full tracklist."""
client = _make_client()
page1 = {
'data': [
{'id': '1', 'type': 'tracks', 'meta': {'trackNumber': 1, 'volumeNumber': 1}},
{'id': '2', 'type': 'tracks', 'meta': {'trackNumber': 2, 'volumeNumber': 1}},
],
'links': {'next': '/albums/x/relationships/items?cursor=ABC'},
}
client.session.get = MagicMock(return_value=_FakeResp(200, page1))
client._get_tracks_batch = MagicMock(return_value=[
Track(id='1', name='T1', artists=['A'], duration_ms=100),
])
with patch('core.tidal_client.time.sleep'):
tracks = client.get_album_tracks('album-1', limit=1)
# Only one page fetched even though links.next was set
assert client.session.get.call_count == 1
assert len(tracks) == 1
# ---------------------------------------------------------------------------
# Batch hydration robustness
# ---------------------------------------------------------------------------
class TestHydrationRobustness:
def test_hydration_exception_returns_partial_results(self):
"""If one batch fails to hydrate, other batches still return.
Defensive against transient Tidal errors mid-walk on big albums."""
client = _make_client()
# Big single-page album → 21 IDs split into two batches (20 + 1)
big_page = {
'data': [
{'id': str(i), 'type': 'tracks', 'meta': {'trackNumber': i, 'volumeNumber': 1}}
for i in range(1, 22)
],
'links': {},
}
client.session.get = MagicMock(return_value=_FakeResp(200, big_page))
# First batch succeeds, second raises
def batch_side_effect(batch_ids):
if len(batch_ids) == 1: # The trailing batch
raise RuntimeError("transient")
return [
Track(id=tid, name=f'T{tid}', artists=['A'], duration_ms=100)
for tid in batch_ids
]
client._get_tracks_batch = MagicMock(side_effect=batch_side_effect)
with patch('core.tidal_client.time.sleep'):
tracks = client.get_album_tracks('album-1')
# 20 from the first batch — second batch failed but didn't crash
assert len(tracks) == 20
def test_no_track_ids_returns_empty_without_hydrating(self):
"""Empty album → no batch call (no point hydrating zero IDs)."""
client = _make_client()
client.session.get = MagicMock(return_value=_FakeResp(200, {'data': [], 'links': {}}))
client._get_tracks_batch = MagicMock()
with patch('core.tidal_client.time.sleep'):
tracks = client.get_album_tracks('album-1')
assert tracks == []
client._get_tracks_batch.assert_not_called()

@ -20130,6 +20130,81 @@ def get_discover_album(source, album_id):
'source': fallback_source,
})
elif source == 'tidal':
# Tidal albums from Your Albums (sourced via the V2 user-
# collection endpoint). Two-call resolution: get_album for
# metadata, get_album_tracks for the cursor-paginated
# tracklist. `get_album_tracks` returns `Track` objects
# with `track_number` / `disc_number` annotated so the
# download modal renders in album order across multi-disc
# releases. Serialise to the same shape Spotify/Deezer
# return so the frontend track-mapping stays uniform.
if not tidal_client or not tidal_client.is_authenticated():
return jsonify({"error": "Tidal not authenticated"}), 401
album_meta = tidal_client.get_album(album_id)
tidal_tracks = tidal_client.get_album_tracks(album_id)
if not album_meta and not tidal_tracks:
return jsonify({"error": "Tidal album not found"}), 404
album_name = (album_meta or {}).get('title') or request.args.get('name', '')
release_date = (album_meta or {}).get('releaseDate', '')
total_tracks = (album_meta or {}).get('numberOfItems') or len(tidal_tracks)
album_artist_name = request.args.get('artist', '')
# Build cover image URL from the album metadata. Tidal
# exposes cover art via the `coverArt` relationship which
# `get_album` doesn't fetch (it's a one-shot attributes
# call). Best-effort: request it inline.
cover_url = ''
try:
cover_resp = tidal_client.session.get(
f"{tidal_client.base_url}/albums/{album_id}",
params={'countryCode': 'US', 'include': 'coverArt'},
headers={'accept': 'application/vnd.api+json'},
timeout=10,
)
if cover_resp.status_code == 200:
payload = cover_resp.json()
_, artworks = tidal_client._build_included_maps(payload.get('included', []))
cover_rel = (payload.get('data') or {}).get('relationships', {}).get('coverArt', {})
cover_url = tidal_client._first_artwork_url(cover_rel, artworks) or ''
except Exception as e:
logger.debug(f"Tidal cover-art resolve failed for album {album_id}: {e}")
tracks_out = []
for t in tidal_tracks:
tracks_out.append({
'id': t.id,
'name': t.name,
'artists': [{'name': a} for a in (t.artists or [])],
'duration_ms': t.duration_ms,
'track_number': getattr(t, 'track_number', 0),
'disc_number': getattr(t, 'disc_number', 1),
})
# Album-level artist name preference: explicit ?artist=
# query (passed by frontend with the saved-album row) wins
# over guessing from the first track. The saved-album row
# already resolved the canonical artist via the V2
# collection endpoint.
if not album_artist_name and tidal_tracks:
first_artists = tidal_tracks[0].artists or []
album_artist_name = first_artists[0] if first_artists else ''
return jsonify({
'id': album_id,
'name': album_name or 'Unknown Album',
'artists': [{'name': album_artist_name}] if album_artist_name else [],
'release_date': release_date,
'total_tracks': total_tracks,
'album_type': 'album',
'images': [{'url': cover_url}] if cover_url else [],
'tracks': tracks_out,
'source': 'tidal',
})
elif source == 'discogs':
# Discogs release detail. release_id comes from the Your
# Albums Discogs source. Tracklist needs normalizing —

@ -1085,6 +1085,7 @@ async function openYourAlbumDownload(index) {
const trySources = [];
if (album.spotify_album_id) trySources.push(['spotify', album.spotify_album_id]);
if (album.deezer_album_id) trySources.push(['deezer', album.deezer_album_id]);
if (album.tidal_album_id) trySources.push(['tidal', album.tidal_album_id]);
if (discogsId) trySources.push(['discogs', discogsId]);
for (const [src, id] of trySources) {
@ -1283,6 +1284,13 @@ async function _yaaSourcesSave() {
}
async function downloadMissingYourAlbums() {
// Opens the same selectable-grid modal pattern used by Download
// Discography on the library page. User picks which missing albums
// they want, clicks Add to Wishlist, each album's tracks get
// resolved + added to the wishlist for the existing auto-download
// processor to pick up. Replaces the prior per-album direct-download
// loop which was silently failing — actual downloads should go
// through the wishlist queue, not bypass it.
try {
const resp = await fetch('/api/discover/your-albums?page=1&per_page=1000&status=missing');
const data = await resp.json();
@ -1291,50 +1299,297 @@ async function downloadMissingYourAlbums() {
return;
}
const missing = data.albums.filter(a => !a.in_library);
if (missing.length === 0) { showToast('All albums are already in your library!', 'success'); return; }
if (!confirm(`Download ${missing.length} missing album${missing.length > 1 ? 's' : ''} from your saved albums?`)) return;
showToast(`Starting download for ${missing.length} albums...`, 'info');
for (let i = 0; i < missing.length; i++) {
const album = missing[i];
try {
showToast(`Queuing ${i + 1}/${missing.length}: ${album.album_name}`, 'info');
const nameParams = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
let albumData = null;
if (album.spotify_album_id) {
const r = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${nameParams}`);
if (r.ok) albumData = await r.json();
}
if (!albumData && album.deezer_album_id) {
const r = await fetch(`/api/discover/album/deezer/${album.deezer_album_id}?${nameParams}`);
if (r.ok) albumData = await r.json();
}
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) continue;
const tracks = albumData.tracks.map(track => {
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) artists = artists.map(a => a.name || a);
return {
id: track.id, name: track.name, artists,
album: {
id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '',
images: albumData.images || []
},
duration_ms: track.duration_ms || 0, track_number: track.track_number || 0
};
});
const virtualId = `your_albums_${album.spotify_album_id || album.deezer_album_id || i}`;
await openDownloadMissingModalForYouTube(virtualId, albumData.name, tracks,
{ name: album.artist_name, source: albumData.source || 'spotify' },
{
id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '',
images: albumData.images || []
if (missing.length === 0) {
showToast('All albums are already in your library!', 'success');
return;
}
_openYourAlbumsBatchModal(missing);
} catch (e) {
console.error('Error loading missing your albums:', e);
showToast(`Error: ${e.message}`, 'error');
}
}
// Map a Your Albums row to the single best source-id the
// /api/artist/<id>/download-discography endpoint can resolve. Each row
// in the missing list typically only has one populated source-id (the
// service it was saved on), so this is just a priority pick.
function _yourAlbumsPickSource(album) {
if (album.spotify_album_id) return { id: String(album.spotify_album_id), source: 'spotify' };
if (album.deezer_album_id) return { id: String(album.deezer_album_id), source: 'deezer' };
if (album.tidal_album_id) return { id: String(album.tidal_album_id), source: 'tidal' };
const discogsId = album.discogs_release_id || album.discogs_id;
if (discogsId) return { id: String(discogsId), source: 'discogs' };
return null;
}
function _openYourAlbumsBatchModal(missingAlbums) {
// Reuses the .discog-modal styling from the library Download
// Discography flow — same checkboxes, same Select All / Deselect
// All semantics, same footer. Single difference: each card carries
// its own artist+source (multi-artist) instead of all being one
// artist's discography.
const existing = document.getElementById('your-albums-batch-modal-overlay');
if (existing) existing.remove();
// Stash the source-id picks on the cards so the submit handler
// can build the per-album payload without re-mapping the array.
const rows = missingAlbums
.map((a, i) => ({ ...a, _src: _yourAlbumsPickSource(a), _index: i }))
.filter(a => a._src); // Skip albums with no usable source-id
if (rows.length === 0) {
showToast('No missing albums have a usable source ID to resolve', 'warning');
return;
}
const overlay = document.createElement('div');
overlay.className = 'discog-modal-overlay';
overlay.id = 'your-albums-batch-modal-overlay';
overlay.innerHTML = `
<div class="discog-modal">
<div class="discog-modal-hero">
<div class="discog-modal-hero-overlay"></div>
<div class="discog-modal-hero-content">
<h2 class="discog-modal-title">Add Missing Albums to Wishlist</h2>
<p class="discog-modal-artist">${rows.length} albums missing from your library</p>
</div>
<button class="discog-modal-close" onclick="_closeYourAlbumsBatchModal()">&times;</button>
</div>
<div class="discog-filter-bar">
<div class="discog-filters"></div>
<div class="discog-select-actions">
<button class="discog-select-btn" onclick="_yourAlbumsBatchSelectAll(true)">Select All</button>
<button class="discog-select-btn" onclick="_yourAlbumsBatchSelectAll(false)">Deselect All</button>
</div>
</div>
<div class="discog-grid" id="your-albums-batch-grid">
${rows.map((r, i) => _renderYourAlbumsBatchCard(r, i)).join('')}
</div>
<div class="discog-progress" id="your-albums-batch-progress" style="display:none;"></div>
<div class="discog-footer" id="your-albums-batch-footer">
<div class="discog-footer-info" id="your-albums-batch-footer-info"></div>
<div class="discog-footer-actions">
<button class="discog-cancel-btn" onclick="_closeYourAlbumsBatchModal()">Cancel</button>
<button class="discog-submit-btn" id="your-albums-batch-submit-btn">
<span class="discog-submit-icon"></span>
<span id="your-albums-batch-submit-text">Add to Wishlist</span>
</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
// Stash row data on the overlay for the submit handler — keeps the
// multi-artist source info available without re-fetching.
overlay._yourAlbumsRows = rows;
requestAnimationFrame(() => overlay.classList.add('visible'));
_updateYourAlbumsBatchFooterCount();
document.getElementById('your-albums-batch-submit-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
_startYourAlbumsBatchAddToWishlist();
});
}
function _renderYourAlbumsBatchCard(row, index) {
const albumName = row.album_name || '';
const artistName = row.artist_name || '';
const year = row.release_date ? row.release_date.substring(0, 4) : '';
const tracks = row.total_tracks || 0;
const img = row.image_url || '';
const src = row._src?.source || '';
return `
<label class="discog-card" data-type="album" style="animation-delay:${index * 0.03}s">
<input type="checkbox" class="your-albums-batch-cb"
data-row-index="${row._index}" data-tracks="${tracks}" checked
onchange="_updateYourAlbumsBatchFooterCount()">
<div class="discog-card-art">
${img ? `<img src="${escapeHtml(img)}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'}
</div>
<div class="discog-card-info">
<div class="discog-card-title">${escapeHtml(albumName)}</div>
<div class="discog-card-meta">${escapeHtml(artistName)}${year ? ' · ' + year : ''}${tracks ? ' · ' + tracks + ' tracks' : ''}${src ? ' · ' + src : ''}</div>
</div>
<div class="discog-card-check"></div>
</label>
`;
}
function _yourAlbumsBatchSelectAll(select) {
document.querySelectorAll('.your-albums-batch-cb').forEach(cb => {
if (cb.closest('.discog-card').style.display !== 'none') cb.checked = select;
});
_updateYourAlbumsBatchFooterCount();
}
function _updateYourAlbumsBatchFooterCount() {
const checked = document.querySelectorAll('.your-albums-batch-cb:checked');
let releases = 0, tracks = 0;
checked.forEach(cb => {
if (cb.closest('.discog-card').style.display !== 'none') {
releases++;
tracks += parseInt(cb.dataset.tracks) || 0;
}
});
const info = document.getElementById('your-albums-batch-footer-info');
const btn = document.getElementById('your-albums-batch-submit-text');
if (info) info.textContent = `${releases} album${releases !== 1 ? 's' : ''}${tracks ? ' · ' + tracks + ' tracks' : ''}`;
if (btn) btn.textContent = releases > 0 ? `Add ${releases} to Wishlist` : 'Select albums';
const submitBtn = document.getElementById('your-albums-batch-submit-btn');
if (submitBtn) submitBtn.disabled = releases === 0;
}
function _closeYourAlbumsBatchModal() {
const overlay = document.getElementById('your-albums-batch-modal-overlay');
if (overlay) {
overlay.classList.remove('visible');
setTimeout(() => overlay.remove(), 200);
}
}
async function _startYourAlbumsBatchAddToWishlist() {
const overlay = document.getElementById('your-albums-batch-modal-overlay');
if (!overlay) return;
const rows = overlay._yourAlbumsRows || [];
// Collect selected row indices from the checked checkboxes.
const selectedRowIndices = [];
document.querySelectorAll('.your-albums-batch-cb:checked').forEach(cb => {
if (cb.closest('.discog-card').style.display !== 'none') {
selectedRowIndices.push(parseInt(cb.dataset.rowIndex));
}
});
const selected = rows.filter(r => selectedRowIndices.includes(r._index));
if (selected.length === 0) return;
// Switch to progress view.
const grid = document.getElementById('your-albums-batch-grid');
const progress = document.getElementById('your-albums-batch-progress');
const footer = document.getElementById('your-albums-batch-footer');
const filterBar = overlay.querySelector('.discog-filter-bar');
if (grid) grid.style.display = 'none';
if (filterBar) filterBar.style.display = 'none';
if (progress) {
progress.style.display = '';
progress.innerHTML = '';
}
selected.forEach(row => {
const item = document.createElement('div');
item.className = 'discog-progress-item active';
item.id = `your-albums-batch-prog-${row._src.source}-${row._src.id}`;
item.innerHTML = `
<div class="discog-prog-art">${row.image_url ? `<img src="${escapeHtml(row.image_url)}">` : '🎵'}</div>
<div class="discog-prog-info">
<div class="discog-prog-title">${escapeHtml(row.album_name || '')}</div>
<div class="discog-prog-status">Waiting...</div>
</div>
<div class="discog-prog-icon"><div class="discog-spinner"></div></div>
`;
progress.appendChild(item);
});
const submitBtn = document.getElementById('your-albums-batch-submit-btn');
if (submitBtn) submitBtn.style.display = 'none';
if (footer) {
const info = document.getElementById('your-albums-batch-footer-info');
if (info) info.textContent = 'Processing... this may take a moment';
}
// Build per-album payload matching the discography endpoint contract.
// URL artist_id is functionally unused by the endpoint when per-album
// metadata is supplied — backend resolves each album through its own
// `source` + `artist_name`. Placeholder 'your-albums' makes the route
// match without picking an arbitrary library artist.
const albumsPayload = selected.map(r => ({
id: r._src.id,
name: r.album_name || '',
artist_name: r.artist_name || '',
source: r._src.source,
}));
try {
const response = await fetch(`/api/artist/your-albums/download-discography`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
albums: albumsPayload,
artist_name: 'Your Albums',
source: null,
}),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let totalAdded = 0, totalSkipped = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === 'complete') {
totalAdded = data.total_added || 0;
totalSkipped = data.total_skipped || 0;
} else if (data.album_id) {
// Find the matching progress card — match by composite source-id
// pair since the same album_id could appear across sources.
const matching = selected.find(s => s._src.id === String(data.album_id));
if (matching) {
const item = document.getElementById(`your-albums-batch-prog-${matching._src.source}-${matching._src.id}`);
if (item) {
const status = item.querySelector('.discog-prog-status');
const icon = item.querySelector('.discog-prog-icon');
if (data.status === 'done') {
if (status) status.textContent = `${data.tracks_added || 0} added · ${data.tracks_skipped || 0} skipped`;
if (icon) icon.innerHTML = '✓';
item.classList.add('done');
item.classList.remove('active');
} else if (data.status === 'error') {
if (status) status.textContent = `Error: ${data.message || 'unknown'}`;
if (icon) icon.innerHTML = '✗';
item.classList.add('error');
item.classList.remove('active');
}
}
}
}
);
} catch (err) { console.error(`Error queuing ${album.album_name}:`, err); }
} catch (parseErr) {
console.debug('your-albums batch ndjson parse:', parseErr);
}
}
}
if (footer) {
const info = document.getElementById('your-albums-batch-footer-info');
if (info) info.textContent = `${totalAdded} tracks added to wishlist · ${totalSkipped} skipped`;
}
if (submitBtn) {
submitBtn.style.display = '';
submitBtn.disabled = true;
const txt = document.getElementById('your-albums-batch-submit-text');
if (txt) txt.textContent = 'Done';
}
showToast(`${totalAdded} tracks added to wishlist`, totalAdded > 0 ? 'success' : 'info');
} catch (e) {
console.error('Error downloading missing your albums:', e);
console.error('Error adding your albums to wishlist:', e);
showToast(`Error: ${e.message}`, 'error');
}
}

@ -3416,6 +3416,7 @@ const WHATS_NEW = {
'2.5.1': [
// --- post-release patch work on the 2.5.1 line — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
{ date: 'Unreleased — 2.5.1 patch work' },
{ title: 'Your Albums: Download Missing Now Opens Selectable Modal + Tidal Resolution', desc: 'two-part fix to the your albums "download missing" flow on discover. (1) replaced the broken per-album direct-download loop with a selectable-grid modal mirroring the library page\'s download discography flow. clicking the download button now opens a checkbox grid showing every missing album (cover, title, artist, year, track count, source) with select all / deselect all controls. user picks what they actually want, hits "add to wishlist", each album\'s tracks get resolved + queued through the existing wishlist auto-download processor. matches the discography flow\'s per-album ndjson progress stream so users see ✓/✗ per album as it processes. previous loop fired direct downloads via `openDownloadMissingModalForYouTube` which the user reported as silently failing — "queuing 2/2" toast with no actual transfer activity. wishlist is the right destination for batch missing-album adds since it already handles retry, source fallback, dedup, and rate limiting. (2) added tidal source resolution. backend `/api/discover/album/<source>/<album_id>` got a new `tidal` source branch that calls a NEW `tidal_client.get_album_tracks(album_id)` method — two-phase fetch (cursor-walk `/v2/albums/<id>/relationships/items?include=items` for track refs + position metadata, batch-hydrate via existing `_get_tracks_batch` for artist/album names). track refs carry `meta.trackNumber` + `meta.volumeNumber` so multi-disc compilations render in album order. inline `?include=coverArt` lookup pulls the album cover too. single-album click flow (`openYourAlbumDownload`) gets `tidal_album_id` added to `trySources`. virtual-id generation includes tidal_album_id for stable identifiers. backend reuses the existing `/api/artist/<id>/download-discography` endpoint — its url artist_id param is functionally unused (per-album payload carries everything), so the modal posts with placeholder `your-albums` and gets multi-artist resolution for free. 10 new tests pin the tidal album-tracks method: single-page walk + hydration, multi-page cursor chain, multi-disc sort order, limit short-circuit, no-token short-circuit, http error returns empty, 429 propagates to rate_limited decorator, forward-compat type filter, partial-batch failure containment, empty-album short-circuit.', page: 'discover' },
{ title: 'AcoustID Scanner: File-Tag Fallback For Legacy Compilation Tracks', desc: 'follow-up to the compilation-album scanner fix. previous patch made the scanner read `tracks.track_artist` (per-track artist column) via COALESCE so compilation tracks would compare against the right value. but tracks downloaded BEFORE that column existed have track_artist=NULL — COALESCE falls back to album artist (the curator) and we\'re back to the wrong-comparison case. fix: explicit 3-tier resolution in `_scan_file` — (1) `tracks.track_artist` from DB if populated → trust it (respects manual edits from the enhanced library view), (2) audio file\'s ARTIST tag via mutagen if present → use it (tidal/spotify/deezer all write the per-track artist into the file at download time, so it\'s ground truth even when DB is stale), (3) album artist → final fallback for files without proper ARTIST tags AND no DB track_artist. file open is essentially free since acoustid is opening it for fingerprinting anyway. critical guard: when DB track_artist is populated (curated value), it always wins over file tag — protects users who edited DB but didn\'t re-tag the file from getting false-positive flags. closes the legacy-data gap without requiring a one-time DB backfill or a re-download. 5 new tests pin: file-tag-resolves-skowl-case (legacy NULL track_artist → file tag wins → no flag), tag-missing-falls-back-to-album-artist (preserves existing genuine-mismatch contract), mutagen-exception-swallowed (debug log, fall-through), tag-matches-DB no behavioral change, and the false-positive guard (DB populated → trumps stale file tag).', page: 'tools' },
{ title: 'Tidal Favorite Albums + Artists Now Show Up On Discover', desc: 'discover → your albums (and your artists) was returning nothing for tidal users regardless of how many albums/artists they\'d favorited. cause: `get_favorite_albums` and `get_favorite_artists` were calling the deprecated `/v2/favorites?filter[type]=ALBUMS|ARTISTS` endpoint, which returns 404 for personal favorites — that endpoint is scoped to collections the third-party app created itself, not the user\'s app-level favorites. the V1 fallback was also dead because modern OAuth tokens carry `collection.read` instead of the legacy `r_usr` scope V1 requires (returns 403). same root cause as the favorited tracks fix from #502. fix: rewire to the working V2 user-collection endpoints — `/v2/userCollectionAlbums/me/relationships/items` and `/v2/userCollectionArtists/me/relationships/items` — using the same cursor-paginated pattern shipped for tracks. ID enumeration lifted into a generic `_iter_collection_resource_ids(path, expected_type, max_ids)` helper so tracks/albums/artists all share one walker (~80 lines deduped). batch hydration via `/v2/{albums|artists}?filter[id]=...&include=...` with extended JSON:API include semantics — single request returns 20 albums + their artists + cover artworks all in `included[]`, parsed via two static helpers (`_first_artist_name`, `_first_artwork_url`) that map relationship refs to the included map. cover/profile images pick `files[0]` (largest variant Tidal returns, typically 1280×1280). public methods preserve the prior return shape so the discover aggregator in web_server.py stays byte-identical. 24 new tests pin: cursor-walker dispatch (correct path + type), included-map building, artist + artwork relationship resolution (full + missing + unknown-id), batch hydration parse for albums + artists, empty-input + HTTP-error short-circuits, BATCH_SIZE chunking (41 IDs → 20/20/1), end-to-end orchestrator behavior.', page: 'discover' },
{ title: 'Server Playlist Sync: Append Mode (Stop Overwriting User-Added Tracks)', desc: 'discord report (cjfc, 2026-04-26): syncing a spotify playlist to your server overwrote anything you\'d manually added to the server-side playlist. now there\'s a per-sync mode picker next to the Sync button on the playlist details modal: "Replace" (default, current behavior — delete + recreate) or "Append only" (preserve existing, only add tracks not already there). useful when the source platform caps playlist size (spotify 100-track limit) and you\'re manually building beyond it on the server. each server client (plex / jellyfin / navidrome) gets a new `append_to_playlist(name, tracks)` method that uses the server\'s native append api — plex `addItems`, jellyfin `POST /Playlists/<id>/Items`, navidrome subsonic `updatePlaylist?songIdToAdd=...`. no delete-recreate, no backup playlist created in append mode (preserves playlist creation date + metadata + non-soulsync-managed tracks). dedup-by-id ensures we never add a track that\'s already on the playlist (matched by ratingKey for plex, jellyfin guid id for jellyfin, song id for navidrome — server-native identity, not fuzzy title+artist match). falls back to `create_playlist` when the playlist doesn\'t exist yet (first sync). sync_service dispatches via the new mode flag through /api/sync/start; soulsync standalone has no playlist methods at all so the dispatch falls back to update_playlist with a warning log when append is requested against it. 15 new tests pin: missing playlist → create delegation, dedup filtering (existing ids skipped), short-circuit on no-new-tracks (no api call), failure paths return False without raising, contract listing for each server client.', page: 'sync' },

Loading…
Cancel
Save