diff --git a/core/discogs_client.py b/core/discogs_client.py index 351fe7bc..33fa1d8e 100644 --- a/core/discogs_client.py +++ b/core/discogs_client.py @@ -369,6 +369,125 @@ class DiscogsClient: logger.error(f"Discogs API error ({endpoint}): {e}") return None + # --- User Collection (powers Your Albums Discogs source) --- + + def get_authenticated_username(self) -> Optional[str]: + """Resolve the username for the configured personal token. + + Discogs's `/oauth/identity` endpoint returns the user's + username when called with a valid token. Cached on the + instance so subsequent calls don't re-hit the API. + """ + if hasattr(self, '_cached_username'): + return self._cached_username + if not self.is_authenticated(): + self._cached_username = None + return None + data = self._api_get('/oauth/identity') + username = data.get('username') if data else None + self._cached_username = username + return username + + def get_user_collection(self, username: Optional[str] = None, + folder_id: int = 0, + per_page: int = 100, + max_pages: int = 50) -> List[Dict[str, Any]]: + """Fetch a Discogs user's collection (folder 0 = "All"). + + Returns a list of normalized release dicts ready for + ``database.upsert_liked_album``: + { + 'album_name': str, + 'artist_name': str, + 'release_id': int, # Discogs release id + 'image_url': str | None, + 'release_date': str, # 'YYYY' (Discogs only stores year) + 'total_tracks': int, + } + + Pagination caps at ``max_pages`` to bound runtime — at 100/page + that's 5000 releases, more than enough for typical collections. + Authenticated calls only (Discogs collection is private). + """ + if not self.is_authenticated(): + logger.warning("Discogs collection fetch attempted without token") + return [] + + if not username: + username = self.get_authenticated_username() + if not username: + logger.warning("Could not resolve Discogs username for token") + return [] + + results: List[Dict[str, Any]] = [] + page = 1 + while page <= max_pages: + data = self._api_get( + f'/users/{username}/collection/folders/{folder_id}/releases', + {'page': page, 'per_page': per_page, 'sort': 'added', 'sort_order': 'desc'}, + ) + if not data: + break + + releases = data.get('releases', []) or [] + if not releases: + break + + for entry in releases: + info = entry.get('basic_information') or {} + release_id = entry.get('id') or info.get('id') + if not release_id: + continue + title = info.get('title') or '' + # Discogs `artists` is a list of {name, id, ...}; first is primary. + artists = info.get('artists') or [] + artist_name = '' + if artists and isinstance(artists[0], dict): + artist_name = (artists[0].get('name') or '').strip() + # Strip trailing "(N)" disambiguation suffix Discogs adds. + artist_name = re.sub(r'\s*\(\d+\)$', '', artist_name) + if not title or not artist_name: + continue + + # Image URLs: cover_image is the primary, also has thumb. + image_url = (info.get('cover_image') + or info.get('thumb') + or '') + + year = info.get('year') + release_date = str(year) if year and year > 0 else '' + + results.append({ + 'album_name': title.strip(), + 'artist_name': artist_name, + 'release_id': int(release_id), + 'image_url': image_url or None, + 'release_date': release_date, + 'total_tracks': 0, # Not in basic_information; populated via get_release if needed + }) + + pagination = data.get('pagination') or {} + if page >= int(pagination.get('pages') or 1): + break + page += 1 + + logger.info(f"Discogs collection: fetched {len(results)} releases for {username}") + return results + + def get_release(self, release_id: int) -> Optional[Dict[str, Any]]: + """Fetch full Discogs release detail including tracklist. + + Returns the raw API response so callers can render rich + Discogs context (year, format, label, country, tracklist). + """ + if not release_id: + return None + try: + release_id = int(release_id) + except (TypeError, ValueError): + return None + return self._api_get(f'/releases/{release_id}') + # --- Search Methods (same signatures as iTunes/Deezer) --- def search_artists(self, query: str, limit: int = 10) -> List[Artist]: diff --git a/database/music_database.py b/database/music_database.py index 7fc9b901..9fe2ccd5 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -1503,6 +1503,7 @@ class MusicDatabase: spotify_album_id TEXT, tidal_album_id TEXT, deezer_album_id TEXT, + discogs_release_id TEXT, image_url TEXT, release_date TEXT, total_tracks INTEGER DEFAULT 0, @@ -1517,6 +1518,18 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_lalp_profile ON liked_albums_pool (profile_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_lalp_spotify ON liked_albums_pool (spotify_album_id)") + # Migration: add discogs_release_id column for the Discogs + # collection source on the Your Albums section. Idempotent — + # safe on existing installs that already have the table. + try: + cursor.execute("SELECT discogs_release_id FROM liked_albums_pool LIMIT 1") + except Exception: + try: + cursor.execute("ALTER TABLE liked_albums_pool ADD COLUMN discogs_release_id TEXT") + logger.info("Added discogs_release_id column to liked_albums_pool") + except Exception: + pass + logger.info("Discovery tables added/verified successfully") except Exception as e: @@ -9967,7 +9980,8 @@ class MusicDatabase: if source_id and source_id_type: col = {'spotify': 'spotify_album_id', 'tidal': 'tidal_album_id', - 'deezer': 'deezer_album_id'}.get(source_id_type) + 'deezer': 'deezer_album_id', + 'discogs': 'discogs_release_id'}.get(source_id_type) if col: set_parts.append(f"{col} = COALESCE({col}, ?)") params.append(source_id) @@ -9989,7 +10003,8 @@ class MusicDatabase: else: sources_json = json.dumps([source_service]) id_cols = {'spotify': 'spotify_album_id', 'tidal': 'tidal_album_id', - 'deezer': 'deezer_album_id'} + 'deezer': 'deezer_album_id', + 'discogs': 'discogs_release_id'} col_values = {v: None for v in id_cols.values()} if source_id and source_id_type and source_id_type in id_cols: col_values[id_cols[source_id_type]] = source_id @@ -9997,13 +10012,13 @@ class MusicDatabase: cursor.execute(""" INSERT INTO liked_albums_pool (album_name, artist_name, normalized_key, spotify_album_id, tidal_album_id, - deezer_album_id, image_url, release_date, total_tracks, source_services, - profile_id, last_fetched_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + deezer_album_id, discogs_release_id, image_url, release_date, total_tracks, + source_services, profile_id, last_fetched_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """, ( album_name, artist_name, normalized, col_values['spotify_album_id'], col_values['tidal_album_id'], - col_values['deezer_album_id'], + col_values['deezer_album_id'], col_values['discogs_release_id'], image_url, release_date, total_tracks or 0, sources_json, profile_id )) diff --git a/tests/test_discogs_collection_source.py b/tests/test_discogs_collection_source.py new file mode 100644 index 00000000..6e0fbabc --- /dev/null +++ b/tests/test_discogs_collection_source.py @@ -0,0 +1,293 @@ +"""Tests for the Discogs collection source on Your Albums. + +Discord request (Jhones + BoulderBadgeDad): pull user's Discogs +collection into the Your Albums section on Discover, similar to how +Spotify Liked Albums works. Implementation adds Discogs as a fourth +source to the existing 3-source pipeline (Spotify / Tidal / Deezer) +with click-context dispatch so Discogs albums open with Discogs +release detail (vinyl/CD format info, year, label, tracklist). + +Tests pin: +- DiscogsClient.get_user_collection — pagination, response + normalization, disambiguation suffix stripping, missing-token + defensive return. +- DiscogsClient.get_release — passthrough to /releases/{id}. +- liked_albums_pool — discogs_release_id column round-trips through + the upsert + get path. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from core.discogs_client import DiscogsClient + + +# --------------------------------------------------------------------------- +# DiscogsClient.get_user_collection +# --------------------------------------------------------------------------- + + +@pytest.fixture +def authed_client(): + """A DiscogsClient with a fake token so is_authenticated() returns True + without hitting the real API.""" + return DiscogsClient(token='dummy_test_token') + + +def test_get_user_collection_returns_empty_without_token(): + """Defensive: no token → empty list, never raises. Discogs collection + is private so an unauthenticated call would 403 anyway.""" + client = DiscogsClient(token='') + assert client.get_user_collection() == [] + + +def test_get_user_collection_normalizes_response_shape(authed_client): + """Each release becomes the dict shape upsert_liked_album expects.""" + fake_response = { + 'pagination': {'pages': 1, 'page': 1}, + 'releases': [ + {'id': 12345, 'basic_information': { + 'title': 'GNX', + 'artists': [{'name': 'Kendrick Lamar'}], + 'cover_image': 'https://img.discogs.com/x.jpg', + 'year': 2024, + }}, + ], + } + + def _fake_get(endpoint, params=None): + if endpoint == '/oauth/identity': + return {'username': 'testuser'} + return fake_response + + with patch.object(authed_client, '_api_get', side_effect=_fake_get): + result = authed_client.get_user_collection() + + assert len(result) == 1 + r = result[0] + assert r['album_name'] == 'GNX' + assert r['artist_name'] == 'Kendrick Lamar' + assert r['release_id'] == 12345 + assert r['image_url'] == 'https://img.discogs.com/x.jpg' + assert r['release_date'] == '2024' + + +def test_get_user_collection_strips_discogs_disambiguation_suffix(authed_client): + """Discogs appends '(N)' to artist names when there are multiple + artists with the same name (e.g. 'Madonna (3)'). Strip it so the + name matches what Spotify/Tidal/Deezer use.""" + fake_response = { + 'pagination': {'pages': 1, 'page': 1}, + 'releases': [ + {'id': 1, 'basic_information': { + 'title': 'X', 'artists': [{'name': 'Madonna (3)'}], + 'cover_image': '', 'year': 2020, + }}, + ], + } + with patch.object(authed_client, '_api_get', + side_effect=lambda e, p=None: ({'username': 'u'} if e == '/oauth/identity' else fake_response)): + result = authed_client.get_user_collection() + + assert result[0]['artist_name'] == 'Madonna' + + +def test_get_user_collection_handles_missing_year(authed_client): + """Year 0 / missing → empty release_date string (NOT '0').""" + fake_response = { + 'pagination': {'pages': 1, 'page': 1}, + 'releases': [ + {'id': 1, 'basic_information': { + 'title': 'Album', + 'artists': [{'name': 'Artist'}], + 'cover_image': '', + 'year': 0, + }}, + ], + } + with patch.object(authed_client, '_api_get', + side_effect=lambda e, p=None: ({'username': 'u'} if e == '/oauth/identity' else fake_response)): + result = authed_client.get_user_collection() + + assert result[0]['release_date'] == '' + + +def test_get_user_collection_skips_releases_with_missing_required_fields(authed_client): + """Defensive: releases without title or artist are skipped, not crashed on.""" + fake_response = { + 'pagination': {'pages': 1, 'page': 1}, + 'releases': [ + {'id': 1, 'basic_information': {'title': 'Has Both', 'artists': [{'name': 'Artist'}]}}, + {'id': 2, 'basic_information': {'title': '', 'artists': [{'name': 'No Title'}]}}, + {'id': 3, 'basic_information': {'title': 'No Artists', 'artists': []}}, + ], + } + with patch.object(authed_client, '_api_get', + side_effect=lambda e, p=None: ({'username': 'u'} if e == '/oauth/identity' else fake_response)): + result = authed_client.get_user_collection() + + assert len(result) == 1 + assert result[0]['album_name'] == 'Has Both' + + +def test_get_user_collection_paginates(authed_client): + """Walks all pages until pagination.pages is reached.""" + page_responses = { + 1: {'pagination': {'pages': 2, 'page': 1}, + 'releases': [{'id': 1, 'basic_information': {'title': 'A', 'artists': [{'name': 'X'}]}}]}, + 2: {'pagination': {'pages': 2, 'page': 2}, + 'releases': [{'id': 2, 'basic_information': {'title': 'B', 'artists': [{'name': 'Y'}]}}]}, + } + call_count = {'n': 0} + + def _fake_get(endpoint, params=None): + if endpoint == '/oauth/identity': + return {'username': 'u'} + page = (params or {}).get('page', 1) + call_count['n'] += 1 + return page_responses.get(page) + + with patch.object(authed_client, '_api_get', side_effect=_fake_get): + result = authed_client.get_user_collection() + + assert len(result) == 2 + assert {r['release_id'] for r in result} == {1, 2} + + +def test_get_user_collection_caps_at_max_pages(authed_client): + """Guard against runaway pagination — stops after max_pages even if + the API claims more pages exist.""" + fake_response = { + 'pagination': {'pages': 9999, 'page': 1}, + 'releases': [{'id': 1, 'basic_information': {'title': 'A', 'artists': [{'name': 'X'}]}}], + } + with patch.object(authed_client, '_api_get', + side_effect=lambda e, p=None: ({'username': 'u'} if e == '/oauth/identity' else fake_response)): + # max_pages=2 — should request exactly 2 pages and stop + result = authed_client.get_user_collection(max_pages=2) + + # Each page returned 1 release — capped at 2 pages = 2 releases + assert len(result) == 2 + + +def test_get_user_collection_uses_explicit_username(authed_client): + """When username is passed explicitly, skip the /oauth/identity + lookup. Useful for callers that already know the username.""" + captured_endpoints = [] + + def _fake_get(endpoint, params=None): + captured_endpoints.append(endpoint) + return {'pagination': {'pages': 1, 'page': 1}, 'releases': []} + + with patch.object(authed_client, '_api_get', side_effect=_fake_get): + authed_client.get_user_collection(username='explicituser') + + # /oauth/identity should NOT have been called + assert '/oauth/identity' not in captured_endpoints + # Collection endpoint includes the explicit username + assert any('explicituser' in e for e in captured_endpoints) + + +# --------------------------------------------------------------------------- +# DiscogsClient.get_release +# --------------------------------------------------------------------------- + + +def test_get_release_passes_id_through_to_api(authed_client): + """Thin wrapper — confirm endpoint shape.""" + captured = [] + with patch.object(authed_client, '_api_get', + side_effect=lambda e, p=None: captured.append(e) or {'id': 999}): + result = authed_client.get_release(999) + assert captured == ['/releases/999'] + assert result == {'id': 999} + + +def test_get_release_returns_none_for_invalid_id(authed_client): + """Defensive: non-numeric / falsy id → None, no API call.""" + with patch.object(authed_client, '_api_get') as mock_api: + assert authed_client.get_release(None) is None + assert authed_client.get_release('not_a_number') is None + assert authed_client.get_release(0) is None + mock_api.assert_not_called() + + +# --------------------------------------------------------------------------- +# liked_albums_pool — discogs_release_id column +# --------------------------------------------------------------------------- + + +def test_liked_albums_discogs_release_id_roundtrip(): + """upsert with source_id_type='discogs' stores in discogs_release_id; + get_liked_albums returns it on the row.""" + from database.music_database import get_database + db = get_database() + + # Use a high profile_id to avoid colliding with real data + test_profile = 9991 + try: + ok = db.upsert_liked_album( + album_name='Test Disc Album', artist_name='Test Disc Artist', + source_service='discogs', + source_id='987654', source_id_type='discogs', + image_url=None, release_date='2023', total_tracks=10, + profile_id=test_profile, + ) + assert ok is True + + result = db.get_liked_albums(profile_id=test_profile, page=1, per_page=10) + assert result['total'] == 1 + row = result['albums'][0] + assert row['discogs_release_id'] == '987654' + assert row['album_name'] == 'Test Disc Album' + assert 'discogs' in row['source_services'] + finally: + # Clean up + conn = db._get_connection() + cur = conn.cursor() + cur.execute("DELETE FROM liked_albums_pool WHERE profile_id = ?", (test_profile,)) + conn.commit() + conn.close() + + +def test_liked_albums_multi_source_carries_both_ids(): + """If an album is added from Spotify AND from Discogs, both + spotify_album_id and discogs_release_id end up on the same row + via the dedup-by-normalized-key upsert.""" + from database.music_database import get_database + db = get_database() + + test_profile = 9992 + try: + # Add via Spotify first + db.upsert_liked_album( + album_name='Same Album', artist_name='Same Artist', + source_service='spotify', + source_id='spotify_id_xyz', source_id_type='spotify', + image_url=None, release_date='', total_tracks=0, + profile_id=test_profile, + ) + # Then add the same album via Discogs — should dedupe + db.upsert_liked_album( + album_name='Same Album', artist_name='Same Artist', + source_service='discogs', + source_id='discogs_id_999', source_id_type='discogs', + image_url=None, release_date='', total_tracks=0, + profile_id=test_profile, + ) + + result = db.get_liked_albums(profile_id=test_profile, page=1, per_page=10) + assert result['total'] == 1 # deduped to one row + row = result['albums'][0] + assert row['spotify_album_id'] == 'spotify_id_xyz' + assert row['discogs_release_id'] == 'discogs_id_999' + assert set(row['source_services']) == {'spotify', 'discogs'} + finally: + conn = db._get_connection() + cur = conn.cursor() + cur.execute("DELETE FROM liked_albums_pool WHERE profile_id = ?", (test_profile,)) + conn.commit() + conn.close() diff --git a/web_server.py b/web_server.py index b56c158b..b9e18809 100644 --- a/web_server.py +++ b/web_server.py @@ -19641,6 +19641,81 @@ def get_discover_album(source, album_id): 'source': fallback_source, }) + elif source == 'discogs': + # Discogs release detail. release_id comes from the Your + # Albums Discogs source. Tracklist needs normalizing — + # Discogs uses {position, title, duration} (duration as + # string like "3:45") so map to the standard + # {name, track_number, duration_ms, artists} shape the + # download modal expects. + from core.discogs_client import DiscogsClient + try: + rel_id = int(album_id) + except (TypeError, ValueError): + return jsonify({"error": "Invalid Discogs release id"}), 400 + + release = DiscogsClient().get_release(rel_id) + if not release: + return jsonify({"error": "Discogs release not found"}), 404 + + import re as _re + _disambig_re = _re.compile(r'\s*\(\d+\)$') + artists_raw = release.get('artists') or [] + artist_names = [] + for a in artists_raw: + name = (a.get('name') or '').strip() if isinstance(a, dict) else str(a) + # Strip Discogs disambiguation suffix "(N)" + name = _disambig_re.sub('', name) + if name: + artist_names.append({'name': name}) + + tracks_out = [] + for idx, t in enumerate(release.get('tracklist', []) or [], start=1): + if not isinstance(t, dict): + continue + title = (t.get('title') or '').strip() + if not title: + continue + # Discogs duration: "3:45" or "1:23:45". Convert to ms. + dur_ms = 0 + dur_str = (t.get('duration') or '').strip() + if dur_str: + try: + parts = [int(p) for p in dur_str.split(':')] + if len(parts) == 2: + dur_ms = (parts[0] * 60 + parts[1]) * 1000 + elif len(parts) == 3: + dur_ms = (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000 + except (ValueError, TypeError): + dur_ms = 0 + tracks_out.append({ + 'id': f"discogs_{rel_id}_{idx}", + 'name': title, + 'track_number': idx, + 'duration_ms': dur_ms, + 'artists': artist_names, + }) + + images = release.get('images') or [] + cover_url = '' + if images and isinstance(images[0], dict): + cover_url = images[0].get('uri') or images[0].get('uri150') or '' + + year = release.get('year') + release_date = str(year) if year and int(year) > 0 else '' + + return jsonify({ + 'id': str(rel_id), + 'name': release.get('title', ''), + 'artists': artist_names, + 'release_date': release_date, + 'total_tracks': len(tracks_out), + 'album_type': 'album', + 'images': [{'url': cover_url}] if cover_url else [], + 'tracks': tracks_out, + 'source': 'discogs', + }) + else: return jsonify({"error": f"Unknown source: {source}"}), 400 @@ -27578,6 +27653,15 @@ def get_your_albums_sources(): except Exception: pass + # Discogs: counts as "connected" when a personal access token is + # configured. Username comes from /oauth/identity at fetch time; + # not required up front. + try: + if config_manager.get('discogs.token', ''): + connected.append('discogs') + except Exception: + pass + return jsonify({"success": True, "enabled": enabled, "connected": connected}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @@ -27688,6 +27772,38 @@ def _fetch_liked_albums(profile_id: int): except Exception as e: logger.error(f"[Your Albums] Deezer fetch error: {e}") + # 4. Fetch from Discogs (user's collection) — uses personal access + # token from `discogs.token` config. Username resolved via the + # `/oauth/identity` endpoint at fetch time. Discogs is physical- + # media-first so many releases won't have streaming equivalents, + # but the click-context dispatch in the frontend opens the Discogs + # release detail and the user can manually trigger a download + # search if a digital match exists. + try: + if 'discogs' not in enabled_sources: + logger.warning("[Your Albums] Discogs skipped (disabled in sources config)") + elif not config_manager.get('discogs.token', ''): + logger.info("[Your Albums] Discogs skipped (no token configured)") + else: + from core.discogs_client import DiscogsClient + discogs_cl = DiscogsClient() + if discogs_cl.is_authenticated(): + logger.info("[Your Albums] Fetching collection from Discogs...") + releases = discogs_cl.get_user_collection() + for r in releases: + database.upsert_liked_album( + album_name=r['album_name'], artist_name=r['artist_name'], + source_service='discogs', + source_id=str(r['release_id']), source_id_type='discogs', + image_url=r.get('image_url'), release_date=r.get('release_date', ''), + total_tracks=r.get('total_tracks', 0), profile_id=profile_id + ) + fetched += len(releases) + if releases: + logger.info(f"[Your Albums] Fetched {len(releases)} from Discogs") + except Exception as e: + logger.error(f"[Your Albums] Discogs fetch error: {e}") + logger.info(f"[Your Albums] Total fetched: {fetched}") diff --git a/webui/static/discover.js b/webui/static/discover.js index ebe426a1..79a9cdc4 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -1060,17 +1060,33 @@ async function openYourAlbumDownload(index) { if (!album) { showToast('Album data not found', 'error'); return; } showLoadingOverlay(`Loading tracks for ${album.album_name}...`); try { - // Prefer Spotify ID, fall back to Deezer, then search by name + // Per-source dispatch: open with whichever source has an ID for + // this album. For pure-Discogs collection items (no Spotify/ + // Deezer match), dispatch goes straight to Discogs so the + // modal opens with Discogs context (vinyl/CD release detail, + // tracklist from Discogs). For Spotify saved albums (no + // discogs id), goes to Spotify. For multi-source albums + // (album exists in BOTH Spotify saved and Discogs collection, + // rare), tries streaming sources first since they have + // tracklists with proper IDs ready for download. let albumData = null; const nameParams = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); - 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(); + const discogsId = album.discogs_release_id || album.discogs_id; + + 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 (discogsId) trySources.push(['discogs', discogsId]); + + for (const [src, id] of trySources) { + const r = await fetch(`/api/discover/album/${src}/${id}?${nameParams}`); + if (r.ok) { + albumData = await r.json(); + if (albumData && albumData.tracks && albumData.tracks.length > 0) break; + albumData = null; // empty payload — try next + } } + if (!albumData) { // Last resort — search by name const r = await fetch(`/api/discover/album/spotify/search?${nameParams}`); @@ -1156,6 +1172,7 @@ async function openYourAlbumsSourcesModal() { { id: 'spotify', label: 'Spotify', icon: '\uD83C\uDFB5' }, { id: 'tidal', label: 'Tidal', icon: '\uD83C\uDF0A' }, { id: 'deezer', label: 'Deezer', icon: '\uD83C\uDFB6' }, + { id: 'discogs', label: 'Discogs', icon: '\uD83D\uDCBF' }, ]; const state = {}; sourceInfo.forEach(s => { state[s.id] = enabled.includes(s.id); }); diff --git a/webui/static/helper.js b/webui/static/helper.js index 2dc6b0ca..d4611861 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3432,6 +3432,7 @@ const WHATS_NEW = { '2.4.2': [ // --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps --- { date: 'Unreleased — 2.4.2 dev cycle' }, + { title: 'Discogs Collection in "Your Albums"', desc: 'discord request: pull your discogs collection into the your albums section on discover, similar to spotify liked albums. set your discogs personal access token on settings → connections (already there from prior work) and add discogs as one of the configured sources via the gear button on your albums. background fetcher pulls your full collection (all folders, all pages — capped at 5000 releases), normalizes artist names (strips discogs `(N)` disambiguation suffix), dedupes against any spotify/tidal/deezer-saved versions of the same album. clicking a discogs-only album opens with discogs context — full release detail (year, format, label, country, tracklist) from the /releases endpoint. clicking an album that exists in both your spotify saved AND discogs collection prefers spotify (download flow is more direct). discogs is physical-media-first so many releases won\'t have streaming equivalents — those still show in the grid but the modal flow may need to fall back to a name search to find a downloadable digital version.', page: 'discover' }, { title: 'Drop Redundant "Your Spotify Library" Section on Discover', desc: 'discover page used to show two near-identical sections: "Your Albums" (cross-source aggregator across spotify/deezer/etc) AND "Your Spotify Library" (spotify-only). same UI, same grid, same filter / sort / download-missing controls — the spotify-only one was a strict subset of what your albums already covers. removed it. spotify saved albums still surface via the your albums section with spotify as one of its configured sources (gear button → configure sources). backend collection / storage is unchanged — the watchlist scanner still populates the spotify_library_albums cache for your albums to read.', page: 'discover' }, { title: 'Library Disk Usage on Stats Page', desc: 'discord request (samuel [KC]): show how much disk space the library takes. new card on stats → system statistics shows total bytes + per-format breakdown (FLAC vs MP3 vs M4A bars). data comes from `tracks.file_size` populated during deep scan from whatever the media server already returns (plex MediaPart.size, jellyfin MediaSources[].Size, navidrome song.size, soulsync standalone os.path.getsize) — zero filesystem walk overhead. existing libraries see "Run a Deep Scan to populate" until the next deep scan fills in sizes; partial coverage shown as "X tracks measured (+Y pending)". migration is additive (NULL on legacy rows) so upgrading users have nothing to do.', page: 'stats' }, { title: 'Fix: ReplayGain Wrote Same +52 dB Gain to Every Track', desc: 'noticed every downloaded track came out with `replaygain_track_gain: +52.00 dB` regardless of actual loudness. cause: parser used `re.search` which returned the FIRST `I:` (integrated loudness) reading from ffmpeg\'s ebur128 output. that\'s the per-window measurement at t=0.5s — almost always ~-70 LUFS because tracks start with silence/encoder padding. -18 (RG2 reference) - (-70) = +52 dB on every track. fix: parser now anchors to the `Summary:` block at the end of ffmpeg\'s output and reads the actual integrated loudness from there, not the silent-intro partial. defensive fallback uses the LAST per-window reading if Summary is missing (still better than the first). gains now reflect real per-track loudness.', page: 'downloads' },