soulsync/webui/static/sync-lastfm.js
Broque Thomas 38e35930a9 Add Last.fm Radio tab to Sync page (Phase 1c.2)
Sibling to the ListenBrainz Sync tab from Phase 1c.1. Last.fm Radio
playlists already live in the same ``listenbrainz_playlists`` table
as LB ones (``playlist_type='lastfm_radio'``) and run through the
same MB-track discovery worker, so this tab is intentionally thin
— list + render + delegate. Card click hands straight off to the
LB Sync-tab click handler since the downstream modal + state
machine are identical.

- ``webui/index.html``: new ``<button data-tab="lastfm-sync">``
  + tab content container between the LB tab and the existing
  Import / Mirrored tabs. Plus a ``<script>`` tag for the new
  module.
- ``webui/static/sync-lastfm.js`` (new): ``loadLastfmSyncPlaylists``
  hits the existing ``/api/discover/listenbrainz/lastfm-radio``
  endpoint, ``renderLastfmSyncPlaylists`` mirrors the LB card
  shape with a ``📻`` icon + a ``.lastfm-playlist-card`` brand
  class, click handler forwards to
  ``handleListenBrainzSyncCardClick``.
- ``webui/static/sync-listenbrainz.js``: the shared 500ms refresh
  loop now iterates LB + Last.fm cards in one pass and treats
  either tab as "active" for liveness. No second loop needed.
- ``webui/static/sync-services.js``: new tab-activation branch in
  ``initializeSyncPage`` mirrors the LB pattern.
- ``webui/static/style.css``: ``.lastfm-icon`` SVG (Last.fm "as"
  logo, red), and ``.lastfm-playlist-card`` joins the unified
  card selector group with the Last.fm-red accent
  (``rgba(213, 16, 7, ...)``).
- ``web_server.py``: the lastfm-radio endpoint now includes
  ``track_count`` in its JSPF payload (same fix as the LB
  endpoints last commit).
- WHATS_NEW entry added under 2.6.3.

Mirrors created from Last.fm radios participate in the same auto-
trim Phase 1c.1's cascade-delete hook does — when the LB manager
rotates a stale ``lastfm_radio`` row out of its 5-most-recent
window, the matching ``source='lastfm'`` mirror row is removed
along with it. Library files stay on disk.

225 tests across adapter + automation suites still green; this
commit adds no Python paths to test.
2026-05-26 15:24:23 -07:00

131 lines
6 KiB
JavaScript

// ===================================================================
// LAST.FM RADIO SYNC TAB
// ===================================================================
// Phase 1c.2 of the Discover-to-Sync unification. Surfaces the user's
// generated Last.fm Radio playlists as a Sync-page tab so they can be
// discovered + mirrored alongside ListenBrainz, Tidal, Qobuz, etc.
//
// Last.fm Radio playlists live in the same ``listenbrainz_playlists``
// SQLite table as ListenBrainz playlists (with
// ``playlist_type='lastfm_radio'``) and run through the same
// ``openDownloadModalForListenBrainzPlaylist`` discovery flow. So this
// module is intentionally thin — list + render + click handoff.
// The refresh loop, discovery polling, sync→mirror creation, and the
// modal itself are all shared with the ListenBrainz tab.
//
// New Last.fm radios are GENERATED from the Discover page (with a
// seed track). This tab is for listing existing radios + syncing
// them to a mirror — not for generation.
let _lastfmSyncPlaylists = [];
async function loadLastfmSyncPlaylists() {
const container = document.getElementById('lastfm-sync-playlist-container');
const refreshBtn = document.getElementById('lastfm-sync-refresh-btn');
if (!container) return;
container.innerHTML = `<div class="playlist-placeholder">🔄 Loading Last.fm Radio playlists...</div>`;
if (refreshBtn) {
refreshBtn.disabled = true;
refreshBtn.textContent = '🔄 Loading...';
}
try {
const resp = await fetch('/api/discover/listenbrainz/lastfm-radio');
const data = await resp.json();
if (!data.success && data.error) {
container.innerHTML = `<div class="playlist-placeholder">❌ ${escapeHtml(data.error)}</div>`;
return;
}
_lastfmSyncPlaylists = data.playlists || [];
renderLastfmSyncPlaylists();
console.log(`📻 Last.fm Sync tab loaded: ${_lastfmSyncPlaylists.length} radios`);
} catch (err) {
container.innerHTML = `<div class="playlist-placeholder">❌ Error loading Last.fm radios: ${err.message}</div>`;
if (typeof showToast === 'function') {
showToast(`Error loading Last.fm radios: ${err.message}`, 'error');
}
} finally {
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.textContent = '🔄 Refresh';
}
}
}
function renderLastfmSyncPlaylists() {
const container = document.getElementById('lastfm-sync-playlist-container');
if (!container) return;
if (_lastfmSyncPlaylists.length === 0) {
container.innerHTML = `<div class="playlist-placeholder">No Last.fm Radio playlists yet. Generate one from the Discover page by picking a seed track.</div>`;
return;
}
container.innerHTML = _lastfmSyncPlaylists.map(p => {
const inner = p.playlist || p;
const mbid = (inner.identifier || '').split('/').pop() || inner.id || '';
const title = inner.title || 'Last.fm Radio';
const creator = inner.creator || 'Last.fm';
let count = 0;
if (inner.track_count) count = inner.track_count;
else if (inner.annotation && inner.annotation.track_count) count = inner.annotation.track_count;
else if (Array.isArray(inner.track) && inner.track.length > 0) count = inner.track.length;
const state = (typeof listenbrainzPlaylistStates !== 'undefined'
&& listenbrainzPlaylistStates[mbid]) || null;
const phase = state && state.phase ? state.phase : 'fresh';
const phaseText = (typeof getPhaseText === 'function')
? getPhaseText(phase) : (phase === 'fresh' ? 'Ready to discover' : phase);
const phaseColor = (typeof getPhaseColor === 'function')
? getPhaseColor(phase) : '#999';
const buttonText = (typeof getActionButtonText === 'function')
? getActionButtonText(phase) : 'Discover';
return `
<div class="youtube-playlist-card lastfm-playlist-card"
id="lastfm-sync-card-${escapeHtml(mbid)}"
data-lb-mbid="${escapeHtml(mbid)}"
data-lb-title="${escapeHtml(title)}">
<div class="playlist-card-icon">📻</div>
<div class="playlist-card-content">
<div class="playlist-card-name">${escapeHtml(title)}</div>
<div class="playlist-card-info">
<span class="playlist-card-track-count">${count} tracks</span>
<span class="playlist-card-owner">by ${escapeHtml(creator)}</span>
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
</div>
</div>
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}"></div>
<button class="playlist-card-action-btn">${buttonText}</button>
</div>
`;
}).join('');
container.querySelectorAll('.lastfm-playlist-card').forEach(card => {
card.addEventListener('click', () => {
const mbid = card.dataset.lbMbid;
const title = card.dataset.lbTitle;
// Reuses the LB Sync-tab click handler — Last.fm radios are
// stored in the same table + matched by the same discovery
// worker, so the click flow is byte-identical.
if (typeof handleListenBrainzSyncCardClick === 'function') {
handleListenBrainzSyncCardClick(mbid, title);
}
});
});
// Reuse the shared refresh loop from sync-listenbrainz.js — it
// already iterates Last.fm cards alongside LB cards.
if (typeof _startLbSyncCardRefreshLoop === 'function') {
const tab = document.getElementById('lastfm-sync-tab-content');
if (tab && tab.classList.contains('active')) {
_startLbSyncCardRefreshLoop();
}
}
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('lastfm-sync-refresh-btn');
if (btn) btn.addEventListener('click', loadLastfmSyncPlaylists);
});