feat: Add lyrics ID mapping system, fix playlist display, enhance track view

- Add complete lyrics ID mapping system with Redis cache, file persistence, and cache warming
- Manual lyrics mappings checked FIRST before automatic search in LrclibService
- Add lyrics status badge to track view (blue badge shows when lyrics are cached)
- Enhance search links to show 'Search: Track Title - Artist Name'
- Fix Active Playlists tab to read from .env file directly (shows all 18 playlists now)
- Add Map Lyrics ID button to every track with modal for entering lrclib.net IDs
- Add POST /api/admin/lyrics/map and GET /api/admin/lyrics/mappings endpoints
- Lyrics mappings stored in /app/cache/lyrics_mappings.json with no expiration
- Cache warming loads lyrics mappings on startup
- All mappings follow same pattern as track mappings (Redis + file + warming)
This commit is contained in:
2026-02-05 14:58:57 -05:00
parent 328a6a0eea
commit e0dbd1d4fd
4 changed files with 393 additions and 7 deletions

View File

@@ -1000,6 +1000,45 @@
</div>
</div>
<!-- Lyrics ID Mapping Modal -->
<div class="modal" id="lyrics-map-modal">
<div class="modal-content" style="max-width: 600px;">
<h3>Map Lyrics ID</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Manually map a track to a specific lyrics ID from lrclib.net. You can find lyrics IDs by searching on <a href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a>.
</p>
<!-- Track Info -->
<div class="form-group">
<label>Track</label>
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
<strong id="lyrics-map-title"></strong><br>
<span style="color: var(--text-secondary);" id="lyrics-map-artist"></span><br>
<small style="color: var(--text-secondary);" id="lyrics-map-album"></small>
</div>
</div>
<!-- Lyrics ID Input -->
<div class="form-group">
<label>Lyrics ID from lrclib.net</label>
<input type="number" id="lyrics-map-id" placeholder="Enter lyrics ID (e.g., 5929990)" min="1">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Search for the track on <a href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a> and copy the ID from the URL or API response
</small>
</div>
<input type="hidden" id="lyrics-map-artist-value">
<input type="hidden" id="lyrics-map-title-value">
<input type="hidden" id="lyrics-map-album-value">
<input type="hidden" id="lyrics-map-duration">
<div class="modal-actions">
<button onclick="closeModal('lyrics-map-modal')">Cancel</button>
<button class="primary" onclick="saveLyricsMapping()" id="lyrics-map-save-btn">Save Mapping</button>
</div>
</div>
</div>
<!-- Restart Overlay -->
<div class="restart-overlay" id="restart-overlay">
<div class="spinner-large"></div>
@@ -1830,6 +1869,12 @@
document.getElementById('tracks-list').innerHTML = data.tracks.map(t => {
let statusBadge = '';
let mapButton = '';
let lyricsBadge = '';
// Add lyrics status badge
if (t.hasLyrics) {
lyricsBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:#3b82f6;color:white;"><span class="status-dot" style="background:white;"></span>Lyrics</span>';
}
if (t.isLocal === true) {
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
@@ -1881,18 +1926,26 @@
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
}
// Build search link with track name and artist
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
const searchLinkText = `${t.title} - ${firstArtist}`;
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
// Add lyrics mapping button
const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || '')}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
return `
<div class="track-item" data-position="${t.position}">
<span class="track-position">${t.position + 1}</span>
<div class="track-info">
<h4>${escapeHtml(t.title)}${statusBadge}${mapButton}</h4>
<h4>${escapeHtml(t.title)}${statusBadge}${lyricsBadge}${mapButton}${lyricsMapButton}</h4>
<span class="artists">${escapeHtml((t.artists || []).join(', '))}</span>
</div>
<div class="track-meta">
${t.album ? escapeHtml(t.album) : ''}
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
${t.isLocal === false && t.searchQuery && t.externalProvider ? '<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'' + escapeJs(t.externalProvider) + '\'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search</a></small>' : ''}
${t.isLocal === null && t.searchQuery ? '<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'squidwtf\'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search</a></small>' : ''}
${t.isLocal === false && t.searchQuery && t.externalProvider ? '<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'' + escapeJs(t.externalProvider) + '\'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ' + escapeHtml(searchLinkText) + '</a></small>' : ''}
${t.isLocal === null && t.searchQuery ? '<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'squidwtf\'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ' + escapeHtml(searchLinkText) + '</a></small>' : ''}
</div>
</div>
`;
@@ -2380,6 +2433,70 @@
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
}
// Lyrics ID mapping functions
function openLyricsMap(artist, title, album, durationSeconds) {
document.getElementById('lyrics-map-artist').textContent = artist;
document.getElementById('lyrics-map-title').textContent = title;
document.getElementById('lyrics-map-album').textContent = album || '(No album)';
document.getElementById('lyrics-map-artist-value').value = artist;
document.getElementById('lyrics-map-title-value').value = title;
document.getElementById('lyrics-map-album-value').value = album || '';
document.getElementById('lyrics-map-duration').value = durationSeconds;
document.getElementById('lyrics-map-id').value = '';
openModal('lyrics-map-modal');
}
async function saveLyricsMapping() {
const artist = document.getElementById('lyrics-map-artist-value').value;
const title = document.getElementById('lyrics-map-title-value').value;
const album = document.getElementById('lyrics-map-album-value').value;
const durationSeconds = parseInt(document.getElementById('lyrics-map-duration').value);
const lyricsId = parseInt(document.getElementById('lyrics-map-id').value);
if (!lyricsId || lyricsId <= 0) {
showToast('Please enter a valid lyrics ID', 'error');
return;
}
const saveBtn = document.getElementById('lyrics-map-save-btn');
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
try {
const res = await fetch('/api/admin/lyrics/map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
artist,
title,
album,
durationSeconds,
lyricsId
})
});
const data = await res.json();
if (res.ok) {
if (data.cached && data.lyrics) {
showToast(`✓ Lyrics mapped and cached: ${data.lyrics.trackName} by ${data.lyrics.artistName}`, 'success', 5000);
} else {
showToast('✓ Lyrics mapping saved successfully', 'success');
}
closeModal('lyrics-map-modal');
} else {
showToast(data.error || 'Failed to save lyrics mapping', 'error');
}
} catch (error) {
showToast('Failed to save lyrics mapping', 'error');
} finally {
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
// Initial load
fetchStatus();
fetchPlaylists();