diff --git a/core/tidal_client.py b/core/tidal_client.py index c24bc321..a983944c 100644 --- a/core/tidal_client.py +++ b/core/tidal_client.py @@ -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//` + 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.""" diff --git a/tests/test_tidal_album_tracks.py b/tests/test_tidal_album_tracks.py new file mode 100644 index 00000000..83c52edc --- /dev/null +++ b/tests/test_tidal_album_tracks.py @@ -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//` 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() diff --git a/web_server.py b/web_server.py index 019f8fb5..dd5632a7 100644 --- a/web_server.py +++ b/web_server.py @@ -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 — diff --git a/webui/static/discover.js b/webui/static/discover.js index f5ead10d..3142a22b 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -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//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 = ` +
+
+
+
+

Add Missing Albums to Wishlist

+

${rows.length} albums missing from your library

+
+ +
+
+
+
+ + +
+
+
+ ${rows.map((r, i) => _renderYourAlbumsBatchCard(r, i)).join('')} +
+ + +
+ `; + + 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 ` + + `; +} + + +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 = ` +
${row.image_url ? `` : '🎵'}
+
+
${escapeHtml(row.album_name || '')}
+
Waiting...
+
+
+ `; + 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'); } } diff --git a/webui/static/helper.js b/webui/static/helper.js index 16a4efa4..bd520f30 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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//` 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//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//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//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' },