From 31518a3ef32c47c054df2f041a65c1cd7b0c696d Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:47:15 -0700 Subject: [PATCH] Add Deezer user playlists tab via ARL authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Deezer" tab on sync page shows authenticated user's playlists, identical to the Spotify tab pattern — same card layout, details modal with track list, sync button, and download missing tracks flow. Existing URL import renamed to "Deezer Link" tab (unchanged). Backend: get_user_playlists() and get_playlist_tracks() on the download client fetch via public API with ARL session cookies. Album release dates batch-fetched for $year template variable. Three new endpoints: arl-status, arl-playlists, arl-playlist/. Frontend: cards use Spotify-identical HTML structure with live sync status, progress indicators, and View Progress/Results buttons. Downloads reuse openDownloadMissingModal with zero modifications. Track data cached on first open, instant on subsequent clicks. --- core/deezer_download_client.py | 131 ++++++++++++++++ web_server.py | 69 +++++++++ webui/index.html | 16 +- webui/static/script.js | 267 ++++++++++++++++++++++++++++++++- 4 files changed, 477 insertions(+), 6 deletions(-) diff --git a/core/deezer_download_client.py b/core/deezer_download_client.py index fa844dbd..f20a963d 100644 --- a/core/deezer_download_client.py +++ b/core/deezer_download_client.py @@ -236,6 +236,137 @@ class DeezerDownloadClient: labels = {'flac': 'FLAC (Lossless)', 'mp3_320': 'MP3 320kbps', 'mp3_128': 'MP3 128kbps'} return labels.get(self._quality, 'MP3 320kbps') + # ─── User Playlists (ARL-authenticated) ───────────────────── + + def get_user_playlists(self) -> list: + """Fetch the authenticated user's playlists via Deezer 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 [] + + playlists = [] + index = 0 + while True: + try: + resp = self._session.get( + f'https://api.deezer.com/user/{user_id}/playlists', + params={'index': index, 'limit': 100}, + timeout=15 + ) + resp.raise_for_status() + data = resp.json() + if 'error' in data: + logger.warning(f"Deezer playlists error: {data['error']}") + break + items = data.get('data', []) + if not items: + break + for p in items: + playlists.append({ + 'id': str(p.get('id', '')), + 'name': p.get('title', ''), + 'track_count': p.get('nb_tracks', 0), + 'image_url': p.get('picture_medium', ''), + 'owner': p.get('creator', {}).get('name', ''), + 'description': p.get('description', ''), + }) + if not data.get('next'): + break + index += len(items) + except Exception as e: + logger.error(f"Error fetching user playlists at index {index}: {e}") + break + + logger.info(f"Fetched {len(playlists)} user playlists from Deezer") + return playlists + + 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: + resp = self._session.get( + f'https://api.deezer.com/playlist/{playlist_id}', + timeout=15 + ) + resp.raise_for_status() + data = resp.json() + if 'error' in data: + logger.error(f"Deezer playlist error: {data['error']}") + return None + + total_tracks = data.get('nb_tracks', 0) + raw_tracks = data.get('tracks', {}).get('data', []) + + # Paginate if needed + while len(raw_tracks) < total_tracks: + idx = len(raw_tracks) + page_resp = self._session.get( + f'https://api.deezer.com/playlist/{playlist_id}/tracks', + params={'index': idx, 'limit': 400}, + timeout=15 + ) + page_resp.raise_for_status() + page_data = page_resp.json() + if 'error' in page_data: + break + page_tracks = page_data.get('data', []) + if not page_tracks: + break + raw_tracks.extend(page_tracks) + + # Batch-fetch release dates for unique albums + album_ids = set() + for t in raw_tracks: + aid = t.get('album', {}).get('id') + if aid: + album_ids.add(str(aid)) + album_release_dates = {} + for aid in album_ids: + try: + time.sleep(0.3) # Respect rate limits + a_resp = self._session.get(f'https://api.deezer.com/album/{aid}', timeout=10) + if a_resp.ok: + a_data = a_resp.json() + album_release_dates[aid] = a_data.get('release_date', '') + except Exception: + pass + + tracks = [] + for i, t in enumerate(raw_tracks, start=1): + artist_name = t.get('artist', {}).get('name', 'Unknown Artist') + album_data = t.get('album', {}) + album_cover = album_data.get('cover_medium') or album_data.get('cover_small') or '' + album_id = str(album_data.get('id', '')) + tracks.append({ + 'id': str(t.get('id', '')), + 'name': t.get('title', ''), + 'artists': [{'name': artist_name}], + 'album': { + 'name': album_data.get('title', ''), + 'images': [{'url': album_cover}] if album_cover else [], + 'release_date': album_release_dates.get(album_id, ''), + 'album_type': 'album', + 'total_tracks': total_tracks, + 'id': album_id, + }, + 'duration_ms': t.get('duration', 0) * 1000, + 'track_number': i, + }) + + return { + 'id': str(data.get('id', '')), + 'name': data.get('title', ''), + 'description': data.get('description', ''), + 'track_count': total_tracks, + 'image_url': data.get('picture_medium', ''), + 'owner': data.get('creator', {}).get('name', ''), + 'tracks': tracks, + } + except Exception as e: + logger.error(f"Error fetching playlist {playlist_id}: {e}") + return None + # ─── Track Info ────────────────────────────────────────────── def _get_track_data(self, track_id: str) -> Optional[dict]: diff --git a/web_server.py b/web_server.py index e2c3614b..aa8f41a3 100644 --- a/web_server.py +++ b/web_server.py @@ -32982,6 +32982,75 @@ def _get_metadata_fallback_client(): from core.itunes_client import iTunesClient return iTunesClient() +@app.route('/api/deezer/arl-status', methods=['GET']) +def get_deezer_arl_status(): + """Check if Deezer ARL is configured and authenticated.""" + try: + deezer_dl = None + if soulseek_client and hasattr(soulseek_client, 'deezer_dl') and soulseek_client.deezer_dl: + deezer_dl = soulseek_client.deezer_dl + if deezer_dl and deezer_dl.is_authenticated(): + user_data = deezer_dl._user_data or {} + return jsonify({ + 'authenticated': True, + 'user_name': user_data.get('BLOG_NAME', 'Unknown'), + 'user_id': user_data.get('USER_ID'), + }) + return jsonify({'authenticated': False}) + except Exception as e: + return jsonify({'authenticated': False, 'error': str(e)}) + + +@app.route('/api/deezer/arl-playlists', methods=['GET']) +def get_deezer_arl_playlists(): + """Fetch user playlists via Deezer ARL authentication (like /api/spotify/playlists).""" + try: + deezer_dl = None + if soulseek_client and hasattr(soulseek_client, 'deezer_dl') and soulseek_client.deezer_dl: + deezer_dl = soulseek_client.deezer_dl + if not deezer_dl or not deezer_dl.is_authenticated(): + return jsonify({'error': 'Deezer ARL not authenticated. Configure your ARL token in Settings > Downloads.'}), 401 + + playlists = deezer_dl.get_user_playlists() + + # Add sync_status field to match Spotify format + playlist_data = [] + for p in playlists: + playlist_data.append({ + 'id': p['id'], + 'name': p['name'], + 'owner': p.get('owner', ''), + 'track_count': p.get('track_count', 0), + 'image_url': p.get('image_url', ''), + 'sync_status': 'Never Synced', + }) + + print(f"🎵 Loaded {len(playlist_data)} Deezer user playlists via ARL") + return jsonify(playlist_data) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/deezer/arl-playlist/', methods=['GET']) +def get_deezer_arl_playlist_tracks(playlist_id): + """Fetch full playlist with tracks via ARL (like /api/spotify/playlist/).""" + try: + deezer_dl = None + if soulseek_client and hasattr(soulseek_client, 'deezer_dl') and soulseek_client.deezer_dl: + deezer_dl = soulseek_client.deezer_dl + if not deezer_dl or not deezer_dl.is_authenticated(): + return jsonify({'error': 'Deezer ARL not authenticated.'}), 401 + + playlist = deezer_dl.get_playlist_tracks(playlist_id) + if not playlist: + return jsonify({'error': 'Playlist not found or unable to access.'}), 404 + + print(f"🎵 Loaded {len(playlist.get('tracks', []))} tracks from Deezer playlist: {playlist.get('name')}") + return jsonify(playlist) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/deezer/playlist/', methods=['GET']) def get_deezer_playlist(playlist_id): """Fetch a Deezer playlist by ID or URL""" diff --git a/webui/index.html b/webui/index.html index ebba27a7..ddfebae5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1145,6 +1145,9 @@ + @@ -1181,8 +1184,19 @@ - +
+
+

Your Deezer Playlists

+ +
+
+
Click 'Refresh' to load your Deezer playlists.
+
+
+ + +