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:
parent
453eb90f19
commit
3b8b369492
8 changed files with 1726 additions and 540 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
242
web_server.py
242
web_server.py
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue