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.
This commit is contained in:
parent
6198fc37d8
commit
38e35930a9
7 changed files with 212 additions and 14 deletions
|
|
@ -30253,6 +30253,7 @@ def get_listenbrainz_lastfm_radio():
|
|||
"identifier": f"https://listenbrainz.org/playlist/{p['playlist_mbid']}",
|
||||
"title": p['title'],
|
||||
"creator": p['creator'],
|
||||
"track_count": p.get('track_count', 0),
|
||||
"annotation": p.get('annotation', {}),
|
||||
"track": [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1030,6 +1030,9 @@
|
|||
<button class="sync-tab-button" data-tab="listenbrainz-sync">
|
||||
<span class="tab-icon listenbrainz-icon"></span> ListenBrainz
|
||||
</button>
|
||||
<button class="sync-tab-button" data-tab="lastfm-sync">
|
||||
<span class="tab-icon lastfm-icon"></span> Last.fm
|
||||
</button>
|
||||
<button class="sync-tab-button" data-tab="import-file">
|
||||
<span class="tab-icon import-file-icon"></span> Import
|
||||
</button>
|
||||
|
|
@ -1915,6 +1918,17 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last.fm Radio Sync Tab Content -->
|
||||
<div class="sync-tab-content" id="lastfm-sync-tab-content">
|
||||
<div class="playlist-header">
|
||||
<h3>Your Last.fm Radio Playlists</h3>
|
||||
<button class="refresh-button lastfm" id="lastfm-sync-refresh-btn">🔄 Refresh</button>
|
||||
</div>
|
||||
<div class="playlist-scroll-container" id="lastfm-sync-playlist-container">
|
||||
<div class="playlist-placeholder">Click 'Refresh' to load your Last.fm Radio playlists. Generate new ones from the Discover page.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ListenBrainz Sync Tab Content (separate ID from Discover-page LB UI) -->
|
||||
<div class="sync-tab-content" id="listenbrainz-sync-tab-content">
|
||||
<div class="playlist-header">
|
||||
|
|
@ -7982,6 +7996,7 @@
|
|||
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script>
|
||||
|
|
|
|||
|
|
@ -3419,6 +3419,7 @@ const WHATS_NEW = {
|
|||
{ title: 'Auto-Sync refresh now routes through the unified source layer', desc: 'follow-up to the groundwork above. the mirrored-playlist auto-refresh handler used to have a ~190-line if/elif chain branching per source (one branch each for Spotify, Spotify public, Deezer, Tidal, YouTube). now it asks the source registry for the right adapter and calls one refresh method. behavior identical — same matched_data, same Tidal-skip-on-no-auth log, same Spotify-public-prefers-authed-API fallback. unlocks ListenBrainz / Last.fm / SoulSync Discovery as future Sync-page mirror sources without a fresh elif branch each time.' },
|
||||
{ title: 'Discovery folded into the unified source contract', desc: 'next slice of the groundwork. each playlist source can now answer one extra question — "match these raw tracks against Spotify / iTunes" — through the same adapter interface. Spotify / Tidal / Qobuz / YouTube / Deezer / Spotify-public / iTunes-link / SoulSync-Discovery all answer trivially (their tracks already have provider IDs); ListenBrainz + Last.fm run the matching engine. mirror-refresh now calls this automatically when a source returns MB-metadata-only tracks, so when ListenBrainz becomes a Sync-page tab next commit, its mirrors land already discovered + ready to sync — no separate Discover-page round-trip needed.' },
|
||||
{ title: 'ListenBrainz Sync tab', desc: 'new ListenBrainz tab on the Sync page, between Beatport and Import. lists your "For You" / "My Playlists" / "Collaborative" LB playlists in one place. clicking a card kicks off the same discovery → sync → mirror flow you already get from the Discover page (no duplicate UI behind the scenes, just a new entry point). once mirrored, LB playlists participate in Auto-Sync schedules + pipeline automations like any other source. needs ListenBrainz connected in Settings → Connections.', page: 'sync' },
|
||||
{ title: 'Last.fm Radio Sync tab', desc: 'sibling to the ListenBrainz tab — lists your generated Last.fm Radio playlists alongside the rest of the Sync sources. same discovery → mirror flow under the hood, just a different entry point. new Last.fm radios are still generated from the Discover page by picking a seed track; this tab is for syncing existing ones. mirrors auto-trim when Last.fm Radio cache rotates so old radios don\'t pile up.', page: 'sync' },
|
||||
],
|
||||
'2.6.2': [
|
||||
{ date: 'May 24, 2026 — 2.6.2 release' },
|
||||
|
|
|
|||
|
|
@ -13272,6 +13272,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ffffff"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm-1 13.5v-9l6 4.5-6 4.5z"/></svg>');
|
||||
}
|
||||
|
||||
.lastfm-icon {
|
||||
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23d51007"><path d="M10.584 17.21l-.88-2.392s-1.43 1.594-3.573 1.594c-1.897 0-3.244-1.649-3.244-4.288 0-3.382 1.705-4.591 3.381-4.591 2.42 0 3.189 1.567 3.849 3.574l.88 2.749c.88 2.667 2.529 4.81 7.285 4.81 3.409 0 5.717-1.044 5.717-3.793 0-2.227-1.27-3.381-3.629-3.931l-1.762-.385c-1.21-.275-1.567-.77-1.567-1.595 0-.935.742-1.484 1.952-1.484 1.32 0 2.034.495 2.144 1.677l2.749-.33c-.22-2.474-1.926-3.491-4.755-3.491-2.502 0-4.948.935-4.948 3.931 0 1.87.907 3.052 3.189 3.601l1.87.44c1.402.33 1.87 1.622 1.87 1.622-.044 1.21-1.21 1.485-2.667 1.485-2.166 0-3.078-1.142-3.601-2.722l-.907-2.722c-1.16-3.601-3.024-4.921-6.708-4.921C2.193 5.948 0 8.642 0 13.21c0 4.398 2.255 6.781 6.296 6.781 3.244 0 4.811-1.539 4.811-1.539-.082.001-.247-.385-.523-1.242z"/></svg>');
|
||||
}
|
||||
|
||||
.sync-tab-button.active .lastfm-icon {
|
||||
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ffffff"><path d="M10.584 17.21l-.88-2.392s-1.43 1.594-3.573 1.594c-1.897 0-3.244-1.649-3.244-4.288 0-3.382 1.705-4.591 3.381-4.591 2.42 0 3.189 1.567 3.849 3.574l.88 2.749c.88 2.667 2.529 4.81 7.285 4.81 3.409 0 5.717-1.044 5.717-3.793 0-2.227-1.27-3.381-3.629-3.931l-1.762-.385c-1.21-.275-1.567-.77-1.567-1.595 0-.935.742-1.484 1.952-1.484 1.32 0 2.034.495 2.144 1.677l2.749-.33c-.22-2.474-1.926-3.491-4.755-3.491-2.502 0-4.948.935-4.948 3.931 0 1.87.907 3.052 3.189 3.601l1.87.44c1.402.33 1.87 1.622 1.87 1.622-.044 1.21-1.21 1.485-2.667 1.485-2.166 0-3.078-1.142-3.601-2.722l-.907-2.722c-1.16-3.601-3.024-4.921-6.708-4.921C2.193 5.948 0 8.642 0 13.21c0 4.398 2.255 6.781 6.296 6.781 3.244 0 4.811-1.539 4.811-1.539-.082.001-.247-.385-.523-1.242z"/></svg>');
|
||||
}
|
||||
|
||||
/* ListenBrainz Sync tab sub-tabs (For You / My Playlists / Collaborative).
|
||||
* Neutral dark surface; orange used only as a subtle accent on the
|
||||
* active state — matches the rest of the app's tone. */
|
||||
|
|
@ -17536,7 +17544,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.tidal-playlist-card,
|
||||
.deezer-playlist-card,
|
||||
.spotify-public-card,
|
||||
.listenbrainz-playlist-card {
|
||||
.listenbrainz-playlist-card,
|
||||
.lastfm-playlist-card {
|
||||
background: rgba(18, 18, 22, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 16px;
|
||||
|
|
@ -17558,7 +17567,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.tidal-playlist-card::before,
|
||||
.deezer-playlist-card::before,
|
||||
.spotify-public-card::before,
|
||||
.listenbrainz-playlist-card::before {
|
||||
.listenbrainz-playlist-card::before,
|
||||
.lastfm-playlist-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -17574,6 +17584,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.deezer-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(162, 56, 255, 0.4), transparent); }
|
||||
.spotify-public-card::before { background: linear-gradient(90deg, transparent, rgba(29, 185, 84, 0.4), transparent); }
|
||||
.listenbrainz-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(235, 116, 59, 0.4), transparent); }
|
||||
.lastfm-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(213, 16, 7, 0.4), transparent); }
|
||||
|
||||
/* Hover — brand glow */
|
||||
.youtube-playlist-card:hover { border-color: rgba(255, 0, 0, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(255, 0, 0, 0.06); transform: translateY(-2px); }
|
||||
|
|
@ -17581,19 +17592,22 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.deezer-playlist-card:hover { border-color: rgba(162, 56, 255, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(162, 56, 255, 0.06); transform: translateY(-2px); }
|
||||
.spotify-public-card:hover { border-color: rgba(29, 185, 84, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(29, 185, 84, 0.06); transform: translateY(-2px); }
|
||||
.listenbrainz-playlist-card:hover { border-color: rgba(235, 116, 59, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(235, 116, 59, 0.06); transform: translateY(-2px); }
|
||||
.lastfm-playlist-card:hover { border-color: rgba(213, 16, 7, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(213, 16, 7, 0.06); transform: translateY(-2px); }
|
||||
|
||||
.youtube-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(255, 0, 0, 0.7), transparent); }
|
||||
.tidal-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(255, 102, 0, 0.7), transparent); }
|
||||
.deezer-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(162, 56, 255, 0.7), transparent); }
|
||||
.spotify-public-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(29, 185, 84, 0.7), transparent); }
|
||||
.listenbrainz-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(235, 116, 59, 0.7), transparent); }
|
||||
.lastfm-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(213, 16, 7, 0.7), transparent); }
|
||||
|
||||
/* Source icons */
|
||||
.youtube-playlist-card .playlist-card-icon,
|
||||
.tidal-playlist-card .playlist-card-icon,
|
||||
.deezer-playlist-card .playlist-card-icon,
|
||||
.spotify-public-card .playlist-card-icon,
|
||||
.listenbrainz-playlist-card .playlist-card-icon {
|
||||
.listenbrainz-playlist-card .playlist-card-icon,
|
||||
.lastfm-playlist-card .playlist-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
|
|
@ -17612,24 +17626,28 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.deezer-playlist-card .playlist-card-icon { background: rgba(162, 56, 255, 0.12); border: 1px solid rgba(162, 56, 255, 0.2); color: #a238ff; }
|
||||
.spotify-public-card .playlist-card-icon { background: rgba(29, 185, 84, 0.12); border: 1px solid rgba(29, 185, 84, 0.2); color: #1DB954; }
|
||||
.listenbrainz-playlist-card .playlist-card-icon { background: rgba(235, 116, 59, 0.12); border: 1px solid rgba(235, 116, 59, 0.2); color: #eb743b; }
|
||||
.lastfm-playlist-card .playlist-card-icon { background: rgba(213, 16, 7, 0.12); border: 1px solid rgba(213, 16, 7, 0.2); color: #d51007; }
|
||||
|
||||
.youtube-playlist-card .playlist-card-content,
|
||||
.tidal-playlist-card .playlist-card-content,
|
||||
.deezer-playlist-card .playlist-card-content,
|
||||
.spotify-public-card .playlist-card-content,
|
||||
.listenbrainz-playlist-card .playlist-card-content { flex: 1; min-width: 0; }
|
||||
.listenbrainz-playlist-card .playlist-card-content,
|
||||
.lastfm-playlist-card .playlist-card-content { flex: 1; min-width: 0; }
|
||||
|
||||
.youtube-playlist-card .playlist-card-name,
|
||||
.tidal-playlist-card .playlist-card-name,
|
||||
.deezer-playlist-card .playlist-card-name,
|
||||
.spotify-public-card .playlist-card-name,
|
||||
.listenbrainz-playlist-card .playlist-card-name { font-size: 15px; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin-bottom: 4px; }
|
||||
.listenbrainz-playlist-card .playlist-card-name,
|
||||
.lastfm-playlist-card .playlist-card-name { font-size: 15px; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin-bottom: 4px; }
|
||||
|
||||
.youtube-playlist-card .playlist-card-info,
|
||||
.tidal-playlist-card .playlist-card-info,
|
||||
.deezer-playlist-card .playlist-card-info,
|
||||
.spotify-public-card .playlist-card-info,
|
||||
.listenbrainz-playlist-card .playlist-card-info { font-size: 13px; color: rgba(255, 255, 255, 0.4); display: flex; align-items: center; gap: 10px; }
|
||||
.listenbrainz-playlist-card .playlist-card-info,
|
||||
.lastfm-playlist-card .playlist-card-info { font-size: 13px; color: rgba(255, 255, 255, 0.4); display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.youtube-playlist-card .playlist-card-track-count { color: rgba(255, 255, 255, 0.7); }
|
||||
.youtube-playlist-card .playlist-card-phase-text { font-weight: 500; }
|
||||
|
|
@ -17640,7 +17658,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.tidal-playlist-card .playlist-card-action-btn,
|
||||
.deezer-playlist-card .playlist-card-action-btn,
|
||||
.spotify-public-card .playlist-card-action-btn,
|
||||
.listenbrainz-playlist-card .playlist-card-action-btn {
|
||||
.listenbrainz-playlist-card .playlist-card-action-btn,
|
||||
.lastfm-playlist-card .playlist-card-action-btn {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
|
|
@ -17660,7 +17679,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.tidal-playlist-card .playlist-card-action-btn::before,
|
||||
.deezer-playlist-card .playlist-card-action-btn::before,
|
||||
.spotify-public-card .playlist-card-action-btn::before,
|
||||
.listenbrainz-playlist-card .playlist-card-action-btn::before {
|
||||
.listenbrainz-playlist-card .playlist-card-action-btn::before,
|
||||
.lastfm-playlist-card .playlist-card-action-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
@ -17674,24 +17694,28 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.deezer-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(162, 56, 255, 0.2), rgba(162, 56, 255, 0.05)); }
|
||||
.spotify-public-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(29, 185, 84, 0.2), rgba(29, 185, 84, 0.05)); }
|
||||
.listenbrainz-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(235, 116, 59, 0.2), rgba(235, 116, 59, 0.05)); }
|
||||
.lastfm-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(213, 16, 7, 0.2), rgba(213, 16, 7, 0.05)); }
|
||||
|
||||
.youtube-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before,
|
||||
.tidal-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before,
|
||||
.deezer-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before,
|
||||
.spotify-public-card .playlist-card-action-btn:hover:not(:disabled)::before,
|
||||
.listenbrainz-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before { opacity: 1; }
|
||||
.listenbrainz-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before,
|
||||
.lastfm-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before { opacity: 1; }
|
||||
|
||||
.youtube-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(255, 0, 0, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(255, 0, 0, 0.15); }
|
||||
.tidal-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(255, 102, 0, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(255, 102, 0, 0.15); }
|
||||
.deezer-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(162, 56, 255, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(162, 56, 255, 0.15); }
|
||||
.spotify-public-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(29, 185, 84, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(29, 185, 84, 0.15); }
|
||||
.listenbrainz-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(235, 116, 59, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(235, 116, 59, 0.15); }
|
||||
.lastfm-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(213, 16, 7, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(213, 16, 7, 0.15); }
|
||||
|
||||
.youtube-playlist-card .playlist-card-action-btn:disabled,
|
||||
.tidal-playlist-card .playlist-card-action-btn:disabled,
|
||||
.deezer-playlist-card .playlist-card-action-btn:disabled,
|
||||
.spotify-public-card .playlist-card-action-btn:disabled,
|
||||
.listenbrainz-playlist-card .playlist-card-action-btn:disabled {
|
||||
.listenbrainz-playlist-card .playlist-card-action-btn:disabled,
|
||||
.lastfm-playlist-card .playlist-card-action-btn:disabled {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
|
|
|
|||
131
webui/static/sync-lastfm.js
Normal file
131
webui/static/sync-lastfm.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// ===================================================================
|
||||
// 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);
|
||||
});
|
||||
|
|
@ -284,15 +284,28 @@ function _refreshOneLbSyncCard(card) {
|
|||
}
|
||||
|
||||
function _refreshAllLbSyncCards() {
|
||||
document.querySelectorAll('#listenbrainz-sync-tab-content .listenbrainz-playlist-card')
|
||||
.forEach(_refreshOneLbSyncCard);
|
||||
// Both LB and Last.fm-radio tabs render MB-track cards that share
|
||||
// the ``listenbrainzPlaylistStates`` state machine (Last.fm radios
|
||||
// are stored in the same listenbrainz_playlists table with
|
||||
// ``playlist_type='lastfm_radio'``). The refresh loop iterates
|
||||
// visible cards from either tab so we don't need a second loop.
|
||||
document.querySelectorAll(
|
||||
'#listenbrainz-sync-tab-content .listenbrainz-playlist-card, ' +
|
||||
'#lastfm-sync-tab-content .lastfm-playlist-card'
|
||||
).forEach(_refreshOneLbSyncCard);
|
||||
}
|
||||
|
||||
function _isMbStyleSyncTabActive() {
|
||||
const lb = document.getElementById('listenbrainz-sync-tab-content');
|
||||
const lfm = document.getElementById('lastfm-sync-tab-content');
|
||||
return (lb && lb.classList.contains('active'))
|
||||
|| (lfm && lfm.classList.contains('active'));
|
||||
}
|
||||
|
||||
function _startLbSyncCardRefreshLoop() {
|
||||
if (_lbSyncCardRefreshInterval) return;
|
||||
_lbSyncCardRefreshInterval = setInterval(() => {
|
||||
const tab = document.getElementById('listenbrainz-sync-tab-content');
|
||||
if (!tab || !tab.classList.contains('active')) {
|
||||
if (!_isMbStyleSyncTabActive()) {
|
||||
_stopLbSyncCardRefreshLoop();
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3751,6 +3751,19 @@ function initializeSyncPage() {
|
|||
_startLbSyncCardRefreshLoop();
|
||||
}
|
||||
}
|
||||
|
||||
// Last.fm Sync tab — same MB-track shape as LB, shares the
|
||||
// listenbrainzPlaylistStates machinery + refresh loop.
|
||||
if (tabId === 'lastfm-sync') {
|
||||
if (typeof loadLastfmSyncPlaylists === 'function'
|
||||
&& !window._lastfmSyncTabLoaded) {
|
||||
window._lastfmSyncTabLoaded = true;
|
||||
loadLastfmSyncPlaylists();
|
||||
}
|
||||
if (typeof _startLbSyncCardRefreshLoop === 'function') {
|
||||
_startLbSyncCardRefreshLoop();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue