Add Your Albums — multi-source liked albums pool (Spotify, Tidal, Deezer)

Builds a new Your Albums section on the Discover page that aggregates
saved/liked albums from all connected services, mirroring the Your Artists
pattern. Deezer works via both OAuth and ARL.

- tidal_client: add get_favorite_albums() with V2/V1 API fallback
- deezer_client: add get_user_favorite_albums() via OAuth (user/me/albums)
- deezer_download_client: add get_user_favorite_albums() via ARL session
- music_database: add liked_albums_pool table (deduped by artist::album
  normalized key), upsert_liked_album, get_liked_albums,
  get_liked_albums_last_fetch, clear_liked_albums
- web_server: GET /api/discover/your-albums (ownership-checked, paginated),
  GET /api/discover/your-albums/sources, POST /api/discover/your-albums/refresh,
  _fetch_liked_albums background worker (Spotify + Tidal + Deezer OAuth/ARL)
- frontend: Your Albums section with source selector cog, album grid reusing
  spotify-library-card styles, search/filter/sort/pagination, download missing
  button, auto-refresh poll on first load

Also fix: Deezer greyed out in Your Artists sources when using ARL — connection
check now accepts ARL auth (deezer_dl.is_authenticated()) in addition to OAuth,
and _fetch_and_match_liked_artists falls back to ARL client for artist fetching.
This commit is contained in:
Broque Thomas 2026-04-13 23:02:37 -07:00
parent 453eb90f19
commit 3b8b369492
8 changed files with 1726 additions and 540 deletions

View file

@ -738,6 +738,49 @@ class DeezerClient:
logger.error(f"Error fetching Deezer favorite artists: {e}")
return []
@rate_limited
def get_user_favorite_albums(self, limit: int = 200) -> list:
"""Fetch user's favorite albums from Deezer. Requires OAuth access token.
Returns list of dicts with deezer_id, album_name, artist_name, image_url, release_date, total_tracks."""
if not self._access_token:
logger.debug("Deezer not user-authenticated — cannot fetch favorite albums")
return []
try:
albums = []
index = 0
while len(albums) < limit:
data = self._api_get('user/me/albums', params={
'limit': min(100, limit - len(albums)),
'index': index
})
if not data or 'data' not in data:
break
items = data['data']
if not items:
break
for a in items:
artist_name = ''
if isinstance(a.get('artist'), dict):
artist_name = a['artist'].get('name', '')
albums.append({
'deezer_id': str(a.get('id', '')),
'album_name': a.get('title', ''),
'artist_name': artist_name,
'image_url': a.get('cover_xl') or a.get('cover_big') or a.get('cover_medium', ''),
'release_date': a.get('release_date', ''),
'total_tracks': a.get('nb_tracks', 0),
})
if not data.get('next'):
break
index += len(items)
time.sleep(0.3)
logger.info(f"Retrieved {len(albums)} favorite albums from Deezer")
return albums
except Exception as e:
logger.error(f"Error fetching Deezer favorite albums: {e}")
return []
# ==================== Stub Methods (match iTunesClient interface) ====================
def get_user_playlists(self) -> List[Playlist]:

View file

@ -323,6 +323,53 @@ class DeezerDownloadClient:
logger.info(f"Fetched {len(artists)} favorite artists from Deezer (ARL)")
return artists
def get_user_favorite_albums(self, limit: int = 200) -> list:
"""Fetch the authenticated user's favorite albums via public API with ARL cookies."""
if not self._authenticated or not self._user_data:
return []
user_id = self._user_data.get('USER_ID')
if not user_id:
return []
albums = []
index = 0
while len(albums) < limit:
try:
resp = self._session.get(
f'https://api.deezer.com/user/{user_id}/albums',
params={'index': index, 'limit': min(100, limit - len(albums))},
timeout=15
)
resp.raise_for_status()
data = resp.json()
if 'error' in data:
logger.warning(f"Deezer albums error: {data['error']}")
break
items = data.get('data', [])
if not items:
break
for a in items:
artist_name = ''
if isinstance(a.get('artist'), dict):
artist_name = a['artist'].get('name', '')
albums.append({
'deezer_id': str(a.get('id', '')),
'album_name': a.get('title', ''),
'artist_name': artist_name,
'image_url': a.get('cover_xl') or a.get('cover_big') or a.get('cover_medium', ''),
'release_date': a.get('release_date', ''),
'total_tracks': a.get('nb_tracks', 0),
})
if not data.get('next'):
break
index += len(items)
except Exception as e:
logger.error(f"Error fetching favorite albums at index {index}: {e}")
break
logger.info(f"Fetched {len(albums)} favorite albums from Deezer (ARL)")
return albums
def get_playlist_tracks(self, playlist_id: str) -> Optional[dict]:
"""Fetch full playlist details with tracks via public API (ARL cookies grant private access)."""
try:

View file

@ -1497,6 +1497,128 @@ class TidalClient:
logger.error(f"Error fetching Tidal favorite artists: {e}")
return []
def get_favorite_albums(self, limit: int = 200) -> list:
"""Fetch user's favorite albums from Tidal.
Returns list of dicts with tidal_id, album_name, artist_name, image_url, release_date, total_tracks."""
try:
if not self._ensure_valid_token():
logger.debug("Tidal not authenticated — cannot fetch favorite albums")
return []
user_id, api_version = self._get_user_id()
if not user_id:
logger.warning("Could not get Tidal user ID for favorite albums")
return []
albums = []
if api_version == 'v2':
offset = 0
while len(albums) < limit:
try:
headers = self.session.headers.copy()
headers['accept'] = 'application/vnd.api+json'
resp = requests.get(
f"{self.base_url}/favorites",
params={
'countryCode': 'US',
'filter[user.id]': user_id,
'filter[type]': 'ALBUMS',
'include': 'albums',
'page[limit]': min(50, limit - len(albums)),
'page[offset]': offset
},
headers=headers, timeout=15
)
if resp.status_code != 200:
logger.debug(f"Tidal V2 favorite albums returned {resp.status_code}, trying V1")
break
data = resp.json()
included = data.get('included', [])
items = included if included else data.get('data', [])
if not items:
break
for item in items:
if included and item.get('type') not in ('albums', 'album'):
continue
attrs = item.get('attributes', {})
title = attrs.get('title', '')
if not title:
continue
img = None
img_rel = item.get('relationships', {}).get('image', {}).get('data', {})
if isinstance(img_rel, dict) and img_rel.get('id'):
img = f"https://resources.tidal.com/images/{img_rel['id'].replace('-', '/')}/750x750.jpg"
artist_name = ''
artist_rel = attrs.get('artists', [{}])
if artist_rel and isinstance(artist_rel, list):
artist_name = artist_rel[0].get('name', '') if isinstance(artist_rel[0], dict) else ''
albums.append({
'tidal_id': str(item.get('id', '')),
'album_name': title,
'artist_name': artist_name,
'image_url': img,
'release_date': attrs.get('releaseDate', ''),
'total_tracks': attrs.get('numberOfTracks', 0),
})
if not data.get('links', {}).get('next'):
break
offset += 50
import time
time.sleep(0.5)
except Exception as e:
logger.debug(f"Tidal V2 favorite albums error: {e}")
break
# Fallback to V1 API
if not albums:
try:
offset = 0
while len(albums) < limit:
resp = self.session.get(
f"{self.alt_base_url}/users/{user_id}/favorites/albums",
params={'countryCode': 'US', 'limit': min(50, limit - len(albums)), 'offset': offset},
timeout=15
)
if resp.status_code != 200:
logger.debug(f"Tidal V1 favorite albums returned {resp.status_code}")
break
data = resp.json()
items = data.get('items', [])
if not items:
break
for item in items:
a = item.get('item', item)
img_id = (a.get('cover') or '').replace('-', '/')
img = f"https://resources.tidal.com/images/{img_id}/750x750.jpg" if img_id else None
artist_name = ''
if isinstance(a.get('artist'), dict):
artist_name = a['artist'].get('name', '')
elif isinstance(a.get('artists'), list) and a['artists']:
artist_name = a['artists'][0].get('name', '')
albums.append({
'tidal_id': str(a.get('id', '')),
'album_name': a.get('title', ''),
'artist_name': artist_name,
'image_url': img,
'release_date': a.get('releaseDate', ''),
'total_tracks': a.get('numberOfTracks', 0),
})
total = data.get('totalNumberOfItems', 0)
offset += len(items)
if offset >= total:
break
import time
time.sleep(0.5)
except Exception as e:
logger.debug(f"Tidal V1 favorite albums error: {e}")
logger.info(f"Retrieved {len(albums)} favorite albums from Tidal")
return albums
except Exception as e:
logger.error(f"Error fetching Tidal favorite albums: {e}")
return []
# Global instance
_tidal_client = None

View file

@ -1358,6 +1358,30 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_profile ON liked_artists_pool (profile_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_status ON liked_artists_pool (profile_id, match_status)")
# Liked albums pool — aggregated saved/liked albums from connected services
cursor.execute("""
CREATE TABLE IF NOT EXISTS liked_albums_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
album_name TEXT NOT NULL,
artist_name TEXT NOT NULL,
normalized_key TEXT NOT NULL,
spotify_album_id TEXT,
tidal_album_id TEXT,
deezer_album_id TEXT,
image_url TEXT,
release_date TEXT,
total_tracks INTEGER DEFAULT 0,
source_services TEXT DEFAULT '[]',
profile_id INTEGER DEFAULT 1,
last_fetched_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, normalized_key)
)
""")
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)")
logger.info("Discovery tables added/verified successfully")
except Exception as e:
@ -9298,6 +9322,181 @@ class MusicDatabase:
logger.error(f"Error clearing liked artists: {e}")
return 0
# ==================== Liked Albums Pool Methods ====================
@staticmethod
def _normalize_album_key(artist_name: str, album_name: str) -> str:
"""Normalize artist+album into a dedup key."""
import unicodedata
def _norm(s):
if not s:
return ''
n = unicodedata.normalize('NFKD', s)
n = ''.join(c for c in n if not unicodedata.combining(c))
n = n.lower().strip()
if n.startswith('the '):
n = n[4:]
return ' '.join(n.split())
return f"{_norm(artist_name)}::{_norm(album_name)}"
def upsert_liked_album(self, album_name: str, artist_name: str, source_service: str,
source_id: str = None, source_id_type: str = None,
image_url: str = None, release_date: str = None,
total_tracks: int = 0, profile_id: int = 1) -> bool:
"""Insert or merge a liked album into the pool. Deduplicates by normalized artist+album key."""
try:
import json
if self._is_placeholder_image(image_url):
image_url = None
normalized = self._normalize_album_key(artist_name, album_name)
if not normalized or '::' not in normalized:
return False
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT id, source_services FROM liked_albums_pool WHERE profile_id = ? AND normalized_key = ?",
(profile_id, normalized)
)
existing = cursor.fetchone()
if existing:
current_sources = json.loads(existing['source_services'] or '[]')
if source_service not in current_sources:
current_sources.append(source_service)
set_parts = [
"source_services = ?",
"updated_at = CURRENT_TIMESTAMP",
"last_fetched_at = CURRENT_TIMESTAMP",
]
params = [json.dumps(current_sources)]
if source_id and source_id_type:
col = {'spotify': 'spotify_album_id', 'tidal': 'tidal_album_id',
'deezer': 'deezer_album_id'}.get(source_id_type)
if col:
set_parts.append(f"{col} = COALESCE({col}, ?)")
params.append(source_id)
if image_url:
set_parts.append("image_url = COALESCE(image_url, ?)")
params.append(image_url)
if release_date:
set_parts.append("release_date = COALESCE(release_date, ?)")
params.append(release_date)
if total_tracks:
set_parts.append("total_tracks = COALESCE(NULLIF(total_tracks, 0), ?)")
params.append(total_tracks)
params.extend([profile_id, normalized])
cursor.execute(
f"UPDATE liked_albums_pool SET {', '.join(set_parts)} WHERE profile_id = ? AND normalized_key = ?",
params
)
else:
sources_json = json.dumps([source_service])
id_cols = {'spotify': 'spotify_album_id', 'tidal': 'tidal_album_id',
'deezer': 'deezer_album_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
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)
""", (
album_name, artist_name, normalized,
col_values['spotify_album_id'], col_values['tidal_album_id'],
col_values['deezer_album_id'],
image_url, release_date, total_tracks or 0,
sources_json, profile_id
))
conn.commit()
return True
except Exception as e:
logger.error(f"Error upserting liked album '{album_name}' by '{artist_name}': {e}")
return False
def get_liked_albums(self, profile_id: int = 1, page: int = 1, per_page: int = 50,
search: str = None, source_filter: str = None,
sort: str = 'artist_name') -> dict:
"""Get liked albums from the pool. Returns {albums: [...], total: N}."""
try:
conn = self._get_connection()
cursor = conn.cursor()
where = ["profile_id = ?"]
params = [profile_id]
if search:
where.append("(album_name LIKE ? COLLATE NOCASE OR artist_name LIKE ? COLLATE NOCASE)")
params.extend([f"%{search}%", f"%{search}%"])
if source_filter:
where.append("source_services LIKE ?")
params.append(f'%"{source_filter}"%')
where_clause = " AND ".join(where)
cursor.execute(f"SELECT COUNT(*) FROM liked_albums_pool WHERE {where_clause}", params)
total = cursor.fetchone()[0]
order = {
'artist_name': 'artist_name COLLATE NOCASE, album_name COLLATE NOCASE',
'album_name': 'album_name COLLATE NOCASE',
'recent': 'created_at DESC',
'release_date': 'release_date DESC',
}.get(sort, 'artist_name COLLATE NOCASE')
offset = (page - 1) * per_page
cursor.execute(f"""
SELECT * FROM liked_albums_pool
WHERE {where_clause}
ORDER BY {order}
LIMIT ? OFFSET ?
""", params + [per_page, offset])
import json
albums = []
for r in cursor.fetchall():
d = dict(r)
d['source_services'] = json.loads(d['source_services'] or '[]')
albums.append(d)
return {'albums': albums, 'total': total}
except Exception as e:
logger.error(f"Error getting liked albums: {e}")
return {'albums': [], 'total': 0}
def get_liked_albums_last_fetch(self, profile_id: int = 1):
"""Get the most recent fetch timestamp."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT MAX(last_fetched_at) FROM liked_albums_pool WHERE profile_id = ?",
(profile_id,)
)
row = cursor.fetchone()
return row[0] if row and row[0] else None
except Exception:
return None
def clear_liked_albums(self, profile_id: int = 1) -> int:
"""Clear all liked albums for a profile."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM liked_albums_pool WHERE profile_id = ?", (profile_id,))
conn.commit()
return cursor.rowcount
except Exception as e:
logger.error(f"Error clearing liked albums: {e}")
return 0
# ==================== Track Download Provenance Methods ====================
def record_track_download(self, file_path: str, source_service: str, source_username: str,

View file

@ -42815,6 +42815,248 @@ def _backfill_liked_artist_images(database, profile_id: int, search_clients: dic
logger.debug(f"[Your Artists] Image backfill error: {e}")
# ── Your Albums (Liked Albums Pool) ──
@app.route('/api/discover/your-albums', methods=['GET'])
def get_your_albums():
"""Get liked albums with library ownership status, paginated."""
try:
database = get_database()
profile_id = get_current_profile_id()
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 48, type=int)
search = request.args.get('search', '', type=str).strip()
status_filter = request.args.get('status', 'all', type=str)
source_filter = request.args.get('source', '', type=str).strip()
sort = request.args.get('sort', 'artist_name', type=str)
# Auto-trigger refresh if stale (>24h or empty)
last_fetch = database.get_liked_albums_last_fetch(profile_id)
stale = True
if last_fetch:
from datetime import datetime, timedelta
try:
if isinstance(last_fetch, str):
last_dt = datetime.fromisoformat(last_fetch.replace('Z', '+00:00'))
else:
last_dt = last_fetch
stale = (datetime.now() - last_dt.replace(tzinfo=None)) > timedelta(hours=24)
except Exception:
stale = True
if stale:
_trigger_your_albums_refresh(profile_id)
# Fetch all (ownership check requires full set)
all_result = database.get_liked_albums(
profile_id=profile_id, page=1, per_page=100000,
search=search, source_filter=source_filter or None, sort=sort
)
all_albums = all_result['albums']
if not all_albums:
return jsonify({
"success": True, "albums": [], "total": 0,
"page": page, "per_page": per_page, "stale": stale,
"stats": {"total": 0, "owned": 0, "missing": 0}
})
# Ownership check — same strategy as Spotify library endpoint
library_spotify_ids = database.get_library_spotify_album_ids(profile_id)
library_album_names = database.get_library_album_names()
owned_count = 0
for album in all_albums:
if album.get('spotify_album_id') and album['spotify_album_id'] in library_spotify_ids:
album['in_library'] = True
elif (album['artist_name'].lower(), album['album_name'].lower()) in library_album_names:
album['in_library'] = True
else:
album['in_library'] = False
if album['in_library']:
owned_count += 1
# Apply status filter
if status_filter == 'missing':
filtered = [a for a in all_albums if not a['in_library']]
elif status_filter == 'owned':
filtered = [a for a in all_albums if a['in_library']]
else:
filtered = all_albums
filtered_total = len(filtered)
offset = (page - 1) * per_page
albums = filtered[offset:offset + per_page]
stats = {
'total': all_result['total'],
'owned': owned_count,
'missing': all_result['total'] - owned_count,
}
return jsonify({
"success": True, "albums": albums,
"total": filtered_total, "page": page, "per_page": per_page,
"stale": stale, "stats": stats,
})
except Exception as e:
logger.error(f"Error getting your albums: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/your-albums/refresh', methods=['POST'])
def refresh_your_albums():
"""Force-trigger a fetch cycle for liked albums. ?clear=true wipes pool first."""
try:
profile_id = get_current_profile_id()
if request.args.get('clear', '').lower() == 'true':
database = get_database()
cleared = database.clear_liked_albums(profile_id)
print(f"[Your Albums] Cleared {cleared} entries before refresh")
_trigger_your_albums_refresh(profile_id)
return jsonify({"success": True, "message": "Refresh started"})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/your-albums/sources', methods=['GET'])
def get_your_albums_sources():
"""Return current source config + which services are connected (albums)."""
try:
enabled_raw = config_manager.get('discover.your_albums_sources', 'spotify,tidal,deezer')
enabled = [s.strip() for s in enabled_raw.split(',') if s.strip()]
connected = []
if spotify_client and spotify_client.is_spotify_authenticated():
connected.append('spotify')
try:
if tidal_client and hasattr(tidal_client, '_ensure_valid_token') and tidal_client._ensure_valid_token():
connected.append('tidal')
except Exception:
pass
try:
deezer_cl = _get_deezer_client()
deezer_oauth = deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated()
deezer_arl = (hasattr(soulseek_client, 'deezer_dl') and soulseek_client.deezer_dl
and soulseek_client.deezer_dl.is_authenticated())
if deezer_oauth or deezer_arl:
connected.append('deezer')
except Exception:
pass
return jsonify({"success": True, "enabled": enabled, "connected": connected})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
_your_albums_refresh_lock = threading.Lock()
_your_albums_refreshing = False
def _trigger_your_albums_refresh(profile_id: int):
"""Start background album fetch if not already running."""
global _your_albums_refreshing
if _your_albums_refreshing:
return
with _your_albums_refresh_lock:
if _your_albums_refreshing:
return
_your_albums_refreshing = True
def _run():
global _your_albums_refreshing
try:
_fetch_liked_albums(profile_id)
except Exception as e:
logger.error(f"Your albums refresh failed: {e}")
import traceback
traceback.print_exc()
finally:
_your_albums_refreshing = False
threading.Thread(target=_run, daemon=True, name="YourAlbumsRefresh").start()
def _fetch_liked_albums(profile_id: int):
"""Background worker: fetch liked/saved albums from all connected services."""
database = get_database()
fetched = 0
enabled_raw = config_manager.get('discover.your_albums_sources', 'spotify,tidal,deezer')
enabled_sources = {s.strip() for s in enabled_raw.split(',') if s.strip()}
# 1. Fetch from Spotify (saved albums)
try:
if 'spotify' not in enabled_sources:
print("[Your Albums] Spotify skipped (disabled in sources config)")
elif spotify_client and spotify_client.is_spotify_authenticated():
print("[Your Albums] Fetching saved albums from Spotify...")
albums = spotify_client.get_saved_albums()
for a in albums:
database.upsert_liked_album(
album_name=a['album_name'], artist_name=a['artist_name'],
source_service='spotify',
source_id=a['spotify_album_id'], source_id_type='spotify',
image_url=a.get('image_url'), release_date=a.get('release_date'),
total_tracks=a.get('total_tracks', 0), profile_id=profile_id
)
fetched += len(albums)
print(f"[Your Albums] Fetched {len(albums)} from Spotify")
except Exception as e:
logger.error(f"[Your Albums] Spotify fetch error: {e}")
# 2. Fetch from Tidal (favorite albums)
try:
if 'tidal' not in enabled_sources:
print("[Your Albums] Tidal skipped (disabled in sources config)")
elif tidal_client and hasattr(tidal_client, 'get_favorite_albums'):
tidal_auth = tidal_client._ensure_valid_token() if hasattr(tidal_client, '_ensure_valid_token') else False
if tidal_auth:
print("[Your Albums] Fetching favorite albums from Tidal...")
albums = tidal_client.get_favorite_albums(limit=500)
for a in albums:
database.upsert_liked_album(
album_name=a['album_name'], artist_name=a['artist_name'],
source_service='tidal',
source_id=a.get('tidal_id'), source_id_type='tidal',
image_url=a.get('image_url'), release_date=a.get('release_date'),
total_tracks=a.get('total_tracks', 0), profile_id=profile_id
)
fetched += len(albums)
print(f"[Your Albums] Fetched {len(albums)} from Tidal")
except Exception as e:
logger.error(f"[Your Albums] Tidal fetch error: {e}")
# 3. Fetch from Deezer (favorite albums — OAuth or ARL)
try:
if 'deezer' not in enabled_sources:
print("[Your Albums] Deezer skipped (disabled in sources config)")
else:
deezer_cl = _get_deezer_client()
albums = []
if deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated():
print("[Your Albums] Fetching favorite albums from Deezer (OAuth)...")
albums = deezer_cl.get_user_favorite_albums(limit=500)
elif (hasattr(soulseek_client, 'deezer_dl') and soulseek_client.deezer_dl
and soulseek_client.deezer_dl.is_authenticated()):
print("[Your Albums] Fetching favorite albums from Deezer (ARL)...")
albums = soulseek_client.deezer_dl.get_user_favorite_albums(limit=500)
for a in albums:
database.upsert_liked_album(
album_name=a['album_name'], artist_name=a['artist_name'],
source_service='deezer',
source_id=a.get('deezer_id'), source_id_type='deezer',
image_url=a.get('image_url'), release_date=a.get('release_date'),
total_tracks=a.get('total_tracks', 0), profile_id=profile_id
)
fetched += len(albums)
if albums:
print(f"[Your Albums] Fetched {len(albums)} from Deezer")
except Exception as e:
logger.error(f"[Your Albums] Deezer fetch error: {e}")
print(f"[Your Albums] Total fetched: {fetched}")
@app.route('/api/discover/your-artists/info/<artist_id>', methods=['GET'])
def get_your_artist_info(artist_id):
"""Get artist info for the Your Artists info modal. Checks library, cache, then API."""

View file

@ -3298,6 +3298,49 @@
</div>
</div>
<!-- Your Albums Section -->
<div class="discover-section" id="your-albums-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">Your Albums</h2>
<p class="discover-section-subtitle" id="your-albums-subtitle">Albums you've saved across your music services</p>
</div>
<div class="discover-section-actions">
<button class="ya-header-btn ya-refresh-btn" id="your-albums-refresh-btn" onclick="refreshYourAlbums()" title="Refresh from services">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button>
<button class="ya-header-btn ya-settings-btn" onclick="openYourAlbumsSourcesModal()" title="Configure sources">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<button class="ya-header-btn" id="your-albums-download-btn" onclick="downloadMissingYourAlbums()" style="display:none;" title="Download missing albums">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
</button>
</div>
</div>
<div class="spotify-library-filters" id="your-albums-filters" style="display: none;">
<input type="text" class="spotify-library-search" id="your-albums-search"
placeholder="Search by artist or album..." oninput="debouncedYourAlbumsSearch()">
<select id="your-albums-status-filter" class="spotify-library-select" onchange="loadYourAlbumsGrid()">
<option value="all">All Albums</option>
<option value="missing">Missing</option>
<option value="owned">Owned</option>
</select>
<select id="your-albums-sort" class="spotify-library-select" onchange="loadYourAlbumsGrid()">
<option value="artist_name">Artist</option>
<option value="album_name">Album</option>
<option value="release_date">Release Date</option>
<option value="recent">Date Added</option>
</select>
</div>
<div class="spotify-library-grid" id="your-albums-grid">
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading your albums...</p>
</div>
</div>
<div class="spotify-library-pagination" id="your-albums-pagination" style="display: none;"></div>
</div>
<!-- Spotify Library Section -->
<div class="discover-section" id="spotify-library-section" style="display: none;">
<div class="discover-section-header">

View file

@ -2290,6 +2290,11 @@ const HELPER_CONTENT = {
description: 'Carousel of artists from your watchlist. Quick access to view their latest releases, discography, or manage watchlist settings.',
},
'#your-albums-section': {
title: 'Your Albums',
description: 'Albums you\'ve saved or liked across connected services (Spotify, Tidal, Deezer). Shows which are already in your library and lets you download missing ones.',
},
// ─── PERSONAL SETTINGS ─────────────────────────────────────────
'#personal-settings-btn': {

File diff suppressed because it is too large Load diff