Add Discogs collection as a Your Albums source

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/<source>/<album_id>` — 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.
pull/489/head
Broque Thomas 2 weeks ago
parent 8b41670717
commit 4b23bee4a9

@ -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]:

@ -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
))

@ -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()

@ -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}")

@ -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); });

@ -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' },

Loading…
Cancel
Save