Add Deezer user playlists tab via ARL authentication

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/<id>.

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.
This commit is contained in:
Broque Thomas 2026-04-07 12:47:15 -07:00
parent e65b6bab67
commit 31518a3ef3
4 changed files with 477 additions and 6 deletions

View file

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

View file

@ -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/<playlist_id>', methods=['GET'])
def get_deezer_arl_playlist_tracks(playlist_id):
"""Fetch full playlist with tracks via ARL (like /api/spotify/playlist/<id>)."""
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/<playlist_id>', methods=['GET'])
def get_deezer_playlist(playlist_id):
"""Fetch a Deezer playlist by ID or URL"""

View file

@ -1145,6 +1145,9 @@
<button class="sync-tab-button" data-tab="deezer">
<span class="tab-icon deezer-icon"></span> Deezer
</button>
<button class="sync-tab-button" data-tab="deezer-link">
<span class="tab-icon deezer-icon"></span> Deezer Link
</button>
<button class="sync-tab-button" data-tab="youtube">
<span class="tab-icon youtube-icon"></span> YouTube
</button>
@ -1181,8 +1184,19 @@
</div>
</div>
<!-- Deezer Tab Content -->
<!-- Deezer Tab Content (ARL User Playlists) -->
<div class="sync-tab-content" id="deezer-tab-content">
<div class="playlist-header">
<h3>Your Deezer Playlists</h3>
<button class="refresh-button deezer" id="deezer-arl-refresh-btn">🔄 Refresh</button>
</div>
<div class="playlist-scroll-container" id="deezer-arl-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Deezer playlists.</div>
</div>
</div>
<!-- Deezer Link Tab Content (URL Import) -->
<div class="sync-tab-content" id="deezer-link-tab-content">
<div class="youtube-input-section">
<input type="text" id="deezer-url-input"
placeholder="Paste Deezer Playlist URL...">

View file

@ -58,6 +58,8 @@ let tidalPlaylistStates = {}; // Key: playlist_id, Value: playlist state with ph
let tidalPlaylistsLoaded = false;
let deezerPlaylists = [];
let deezerPlaylistStates = {};
let deezerArlPlaylists = [];
let deezerArlPlaylistsLoaded = false;
// --- Beatport Chart State Management (Similar to YouTube/Tidal) ---
let beatportChartStates = {}; // Key: chart_hash, Value: chart state with phases
@ -15742,7 +15744,11 @@ async function startPlaylistSync(playlistId) {
const trackFetchStart = Date.now();
console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] Cache miss - fetching tracks for playlist ${playlistId}`);
try {
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
// Use the right endpoint based on playlist source
const fetchUrl = playlistId.startsWith('deezer_arl_')
? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}`
: `/api/spotify/playlist/${playlistId}`;
const response = await fetch(fetchUrl);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
tracks = fullPlaylist.tracks;
@ -15785,7 +15791,7 @@ async function startPlaylistSync(playlistId) {
showToast(`Sync started for "${playlist.name}"`, 'success');
// Show initial sync state in modal if open
const modal = document.getElementById('playlist-details-modal');
const modal = document.getElementById('playlist-details-modal') || document.getElementById('deezer-arl-playlist-details-modal');
if (modal && modal.style.display !== 'none') {
const statusDisplay = document.getElementById(`modal-sync-status-${playlist.id}`);
if (statusDisplay) {
@ -15853,7 +15859,7 @@ function startSyncPolling(playlistId) {
stopSyncPolling(playlistId);
updateCardToDefault(playlistId, state);
// Also update the modal if it's open
closePlaylistDetailsModal(); // Close modal on completion/error
closePlaylistDetailsModal(); closeDeezerArlPlaylistDetailsModal(); // Close modal on completion/error
}
} catch (error) {
console.error(`❌ Error polling sync status for ${playlistId}:`, error);
@ -16071,7 +16077,7 @@ function updateCardToDefault(playlistId, finalState = null) {
// Update the modal's sync progress display (matches GUI functionality)
function updateModalSyncProgress(playlistId, progress) {
const modal = document.getElementById('playlist-details-modal');
const modal = document.getElementById('playlist-details-modal') || document.getElementById('deezer-arl-playlist-details-modal');
if (modal && modal.style.display !== 'none') {
console.log(`📊 Updating modal sync progress for ${playlistId}:`, progress);
@ -26349,6 +26355,237 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
}
// ===================================================================
// DEEZER ARL PLAYLIST MANAGEMENT (Spotify-identical pattern)
// ===================================================================
async function loadDeezerArlPlaylists() {
const container = document.getElementById('deezer-arl-playlist-container');
const refreshBtn = document.getElementById('deezer-arl-refresh-btn');
container.innerHTML = `<div class="playlist-placeholder">🔄 Loading playlists...</div>`;
refreshBtn.disabled = true;
refreshBtn.textContent = '🔄 Loading...';
try {
const response = await fetch('/api/deezer/arl-playlists');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Deezer playlists');
}
deezerArlPlaylists = await response.json();
renderDeezerArlPlaylists();
deezerArlPlaylistsLoaded = true;
} catch (error) {
container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${error.message}</div>`;
showToast(`Error loading Deezer playlists: ${error.message}`, 'error');
} finally {
refreshBtn.disabled = false;
refreshBtn.textContent = '🔄 Refresh';
}
}
function renderDeezerArlPlaylists() {
const container = document.getElementById('deezer-arl-playlist-container');
if (deezerArlPlaylists.length === 0) {
container.innerHTML = `<div class="playlist-placeholder">No Deezer playlists found.</div>`;
return;
}
container.innerHTML = deezerArlPlaylists.map(p => {
const arlId = `deezer_arl_${p.id}`;
let statusClass = 'status-never-synced';
if (p.sync_status && p.sync_status.startsWith('Synced')) statusClass = 'status-synced';
return `
<div class="playlist-card deezer-arl-playlist-card" data-playlist-id="${arlId}">
<div class="playlist-card-main">
<div class="playlist-card-content">
<div class="playlist-card-name">${escapeHtml(p.name)}</div>
<div class="playlist-card-info">
<span>${p.track_count} tracks</span>
<span class="playlist-card-status ${statusClass}">${p.sync_status || 'Never Synced'}</span>
</div>
<div class="sync-progress-indicator" id="progress-${arlId}"></div>
</div>
<div class="playlist-card-actions">
<button id="action-btn-${arlId}" onclick="openDeezerArlPlaylistDetailsModal(event, '${p.id}')">Sync / Download</button>
<button id="progress-btn-${arlId}" class="view-progress-btn hidden" onclick="handleDeezerArlViewProgressClick(event, '${p.id}')">
View Progress
</button>
</div>
</div>
</div>
`;
}).join('');
}
function handleDeezerArlViewProgressClick(event, playlistId) {
event.stopPropagation();
const arlPlaylistId = `deezer_arl_${playlistId}`;
const process = activeDownloadProcesses[arlPlaylistId];
if (process && process.modalElement) {
process.modalElement.style.display = 'flex';
}
}
async function openDeezerArlPlaylistDetailsModal(event, playlistId) {
event.stopPropagation();
const playlist = deezerArlPlaylists.find(p => String(p.id) === String(playlistId));
if (!playlist) return;
const arlPlaylistId = `deezer_arl_${playlistId}`;
showLoadingOverlay(`Loading playlist: ${playlist.name}...`);
try {
if (playlistTrackCache[arlPlaylistId]) {
const fullPlaylist = { ...playlist, id: arlPlaylistId, tracks: playlistTrackCache[arlPlaylistId] };
showDeezerArlPlaylistDetailsModal(fullPlaylist, playlistId);
} else {
const response = await fetch(`/api/deezer/arl-playlist/${playlistId}`);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
playlistTrackCache[arlPlaylistId] = fullPlaylist.tracks;
// Auto-mirror
mirrorPlaylist('deezer', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({
track_name: t.name,
artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '',
album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '',
duration_ms: t.duration_ms || 0,
source_track_id: t.id || ''
})), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url });
showDeezerArlPlaylistDetailsModal({ ...fullPlaylist, id: arlPlaylistId }, playlistId);
}
} catch (error) {
showToast(`Error: ${error.message}`, 'error');
} finally {
hideLoadingOverlay();
}
}
function showDeezerArlPlaylistDetailsModal(playlist, originalDeezerPlaylistId) {
let modal = document.getElementById('deezer-arl-playlist-details-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'deezer-arl-playlist-details-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
const playlistId = playlist.id;
const activeProcess = activeDownloadProcesses[playlistId];
const hasCompletedProcess = activeProcess && activeProcess.status === 'complete';
const isSyncing = !!activeSyncPollers[playlistId];
modal.innerHTML = `
<div class="modal-container playlist-modal">
<div class="playlist-modal-header">
<div class="playlist-header-content">
<h2>${escapeHtml(playlist.name)}</h2>
<div class="playlist-quick-info">
<span class="playlist-track-count">${playlist.track_count || (playlist.tracks ? playlist.tracks.length : 0)} tracks</span>
<span class="playlist-owner">by ${escapeHtml(playlist.owner || '')}</span>
</div>
<div class="playlist-modal-sync-status" id="modal-sync-status-${playlistId}" style="display: none;">
<span class="sync-stat total-tracks"> <span id="modal-total-${playlistId}">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat matched-tracks"> <span id="modal-matched-${playlistId}">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat failed-tracks"> <span id="modal-failed-${playlistId}">0</span></span>
<span class="sync-stat percentage">(<span id="modal-percentage-${playlistId}">0</span>%)</span>
</div>
</div>
<span class="playlist-modal-close" onclick="closeDeezerArlPlaylistDetailsModal()">&times;</span>
</div>
<div class="playlist-modal-body">
${playlist.description ? `<div class="playlist-description">${escapeHtml(playlist.description)}</div>` : ''}
<div class="playlist-tracks-container">
<div class="playlist-tracks-list">
${(playlist.tracks || []).map((track, index) => `
<div class="playlist-track-item">
<span class="playlist-track-number">${index + 1}</span>
<div class="playlist-track-info">
<div class="playlist-track-name">${escapeHtml(track.name)}</div>
<div class="playlist-track-artists">${formatArtists(track.artists)}</div>
</div>
<div class="playlist-track-duration">${formatDuration(track.duration_ms)}</div>
</div>
`).join('')}
</div>
</div>
</div>
<div class="playlist-modal-footer">
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeDeezerArlPlaylistDetailsModal()">Close</button>
<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="closeDeezerArlPlaylistDetailsModal(); openDownloadMissingModal('${playlistId}')">
${hasCompletedProcess ? '📊 View Download Results' : '📥 Download Missing Tracks'}
</button>
<button id="sync-btn-${playlistId}" class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlistId}')" ${isSyncing ? 'disabled' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>
</div>
</div>
`;
// Store playlist in spotifyPlaylists-compatible format for openDownloadMissingModal
if (!spotifyPlaylists.find(p => p.id === playlistId)) {
spotifyPlaylists.push({
id: playlistId,
name: playlist.name,
track_count: playlist.tracks ? playlist.tracks.length : 0,
image_url: playlist.image_url || '',
owner: playlist.owner || '',
});
}
modal.style.display = 'flex';
}
function closeDeezerArlPlaylistDetailsModal() {
const modal = document.getElementById('deezer-arl-playlist-details-modal');
if (modal) modal.style.display = 'none';
}
function updateDeezerArlPlaylistCardUI(playlistId) {
const arlPlaylistId = `deezer_arl_${playlistId}`;
const process = activeDownloadProcesses[arlPlaylistId];
const progressBtn = document.getElementById(`progress-btn-${arlPlaylistId}`);
const actionBtn = document.getElementById(`action-btn-${arlPlaylistId}`);
const card = document.querySelector(`.playlist-card[data-playlist-id="${arlPlaylistId}"]`);
if (!progressBtn || !actionBtn) return;
if (process && process.status === 'running') {
progressBtn.classList.remove('hidden');
progressBtn.textContent = 'View Progress';
progressBtn.style.backgroundColor = '';
actionBtn.textContent = '📥 Downloading...';
actionBtn.disabled = true;
if (card) card.classList.remove('download-complete');
} else if (process && process.status === 'complete') {
progressBtn.classList.remove('hidden');
progressBtn.textContent = '📋 View Results';
progressBtn.style.backgroundColor = '#28a745';
progressBtn.style.color = 'white';
actionBtn.textContent = '✅ Ready for Review';
actionBtn.disabled = false;
if (card) card.classList.add('download-complete');
} else {
progressBtn.classList.add('hidden');
progressBtn.style.backgroundColor = '';
progressBtn.style.color = '';
actionBtn.textContent = 'Sync / Download';
actionBtn.disabled = false;
if (card) card.classList.remove('download-complete');
}
}
// ===================================================================
// DEEZER PLAYLIST MANAGEMENT (URL-input like YouTube, reuses YouTube modal)
// ===================================================================
@ -27347,6 +27584,19 @@ function initializeSyncPage() {
syncContentArea.style.gridTemplateColumns = '1fr';
}
// Auto-load Deezer ARL playlists on first tab activation
if (tabId === 'deezer' && !deezerArlPlaylistsLoaded) {
// Check ARL status first
fetch('/api/deezer/arl-status').then(r => r.json()).then(data => {
const container = document.getElementById('deezer-arl-playlist-container');
if (data.authenticated) {
loadDeezerArlPlaylists();
} else if (container) {
container.innerHTML = `<div class="playlist-placeholder">Deezer ARL not configured. Add your ARL token in Settings &gt; Downloads to see your playlists here.</div>`;
}
}).catch(() => {});
}
// Auto-load mirrored playlists on first tab activation
if (tabId === 'mirrored' && !mirroredPlaylistsLoaded) {
loadMirroredPlaylists();
@ -27380,7 +27630,14 @@ function initializeSyncPage() {
tidalRefreshBtn.addEventListener('click', loadTidalPlaylists);
}
// Logic for the Deezer parse button
// Logic for the Deezer ARL refresh button
const deezerArlRefreshBtn = document.getElementById('deezer-arl-refresh-btn');
if (deezerArlRefreshBtn) {
deezerArlRefreshBtn.removeEventListener('click', loadDeezerArlPlaylists);
deezerArlRefreshBtn.addEventListener('click', loadDeezerArlPlaylists);
}
// Logic for the Deezer Link parse button
const deezerParseBtn = document.getElementById('deezer-parse-btn');
if (deezerParseBtn) {
deezerParseBtn.addEventListener('click', loadDeezerPlaylist);