From 4b23bee4a9754c0211b3470459ec393f0fb037bd Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 3 May 2026 21:27:46 -0700 Subject: [PATCH] Add Discogs collection as a Your Albums source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord request: pull user's Discogs collection into the Your Albums section on Discover, similar to how Spotify Liked Albums works. Implementation extends the existing 3-source pipeline (Spotify / Tidal / Deezer) to a 4-source pipeline with click-context dispatch — Discogs-only albums open with rich Discogs release detail (vinyl/CD format, year, label, country, tracklist). Mirrors the per-source dispatch pattern from enhanced/global search. Discogs client (`core/discogs_client.py`): - New `get_authenticated_username()` resolves the username for the configured personal token via Discogs's `/oauth/identity` endpoint. Cached on the instance so subsequent collection page-fetches don't re-hit it. - New `get_user_collection(username=None, folder_id=0, per_page=100, max_pages=50)` walks all pages of `/users/{username}/collection/ folders/{folder_id}/releases`. Returns normalized dicts ready for upsert_liked_album. folder_id=0 = Discogs's "All" folder. Pagination cap of max_pages*per_page = 5000 releases — bounds runtime on heavy collections. - New `get_release(release_id)` thin wrapper for `/releases/{id}` — returns the raw API response so the album-detail endpoint can render rich context. - Both methods defensive: missing token → empty list, malformed responses → skipped, falsy ids → None. Disambiguation suffix stripping (`Madonna (3)` → `Madonna`) so Discogs artist names match what Spotify/Tidal/Deezer use. Schema (`database/music_database.py`): - New `discogs_release_id TEXT` column on `liked_albums_pool`. Migration uses the established `try SELECT, except ALTER TABLE` pattern. Idempotent; safe on existing installs. - Added the column to the canonical CREATE TABLE for fresh installs. - `upsert_liked_album` extended with `'discogs': 'discogs_release_id'` in BOTH the INSERT and UPDATE id-column maps so Discogs source_id routes to the new column. INSERT statement column count + value count updated together. Backend (`web_server.py`): - `/api/discover/your-albums/sources` — adds Discogs to the `connected` list when `discogs.token` config is set. - `_fetch_liked_albums` — new branch for Discogs. Lazy-imports DiscogsClient, respects the `enabled_sources` config, walks the collection, upserts each release. Same try/except shape as the existing source branches. - `/api/discover/album//` — new `discogs` branch fetches the release via DiscogsClient.get_release, normalizes the Discogs tracklist format, parses Discogs's `MM:SS`/`HH:MM:SS` duration strings to milliseconds, returns the same response shape as the Spotify/Deezer/iTunes branches. Frontend (`webui/static/discover.js`): - `openYourAlbumsSourcesModal` — adds Discogs to `sourceInfo` with the vinyl emoji icon. Existing toggle/save plumbing handles it. - `openYourAlbumDownload` — restructured the per-source dispatch: builds an ordered list of (source, id) tuples, tries each in turn, breaks on the first successful response. Pure-Discogs albums go straight to the Discogs detail endpoint → modal opens with Discogs context. Multi-source albums prefer Spotify/Deezer first since their tracklists carry proper streaming IDs ready for download. Tests: `tests/test_discogs_collection_source.py` — 12 cases: - get_user_collection: empty without token, normalizes response shape, strips disambiguation suffix, handles missing year, skips malformed releases, paginates correctly, caps at max_pages, uses explicit username when provided. - get_release: passes id through to /releases/{id}, returns None for invalid ids without API call. - liked_albums_pool: discogs_release_id round-trips through upsert + get; multi-source dedup carries both Spotify and Discogs IDs on the same row. Verified: full suite 1825 pass (12 new), ruff clean, smoke test populating + reading the discogs_release_id column round-trips correctly via the real DB. WHATS_NEW entry under '2.4.2' dev cycle. --- core/discogs_client.py | 119 ++++++++++ database/music_database.py | 27 ++- tests/test_discogs_collection_source.py | 293 ++++++++++++++++++++++++ web_server.py | 116 ++++++++++ webui/static/discover.js | 33 ++- webui/static/helper.js | 1 + 6 files changed, 575 insertions(+), 14 deletions(-) create mode 100644 tests/test_discogs_collection_source.py 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' },