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

@@ -215,7 +215,11 @@ public class AdminController : ControllerBase
{ {
var playlists = new List<object>(); var playlists = new List<object>();
foreach (var config in _spotifyImportSettings.Playlists) // Read playlists directly from .env file to get the latest configuration
// (IOptions is cached and doesn't reload after .env changes)
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
foreach (var config in configuredPlaylists)
{ {
var playlistInfo = new Dictionary<string, object?> var playlistInfo = new Dictionary<string, object?>
{ {
@@ -728,6 +732,11 @@ public class AdminController : ControllerBase
} }
} }
// Check lyrics status
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
var existingLyrics = await _cache.GetStringAsync(cacheKey);
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
tracksWithStatus.Add(new tracksWithStatus.Add(new
{ {
position = track.Position, position = track.Position,
@@ -743,7 +752,8 @@ public class AdminController : ControllerBase
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null, // Set for both external and missing searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null, // Set for both external and missing
isManualMapping = isManualMapping, isManualMapping = isManualMapping,
manualMappingType = manualMappingType, manualMappingType = manualMappingType,
manualMappingId = manualMappingId manualMappingId = manualMappingId,
hasLyrics = hasLyrics
}); });
} }
@@ -2720,6 +2730,165 @@ public class AdminController : ControllerBase
} }
} }
/// <summary>
/// Save lyrics mapping to file for persistence across restarts.
/// Lyrics mappings NEVER expire - they are permanent user decisions.
/// </summary>
private async Task SaveLyricsMappingToFileAsync(
string artist,
string title,
string album,
int durationSeconds,
int lyricsId)
{
try
{
var mappingsFile = "/app/cache/lyrics_mappings.json";
// Load existing mappings
var mappings = new List<LyricsMappingEntry>();
if (System.IO.File.Exists(mappingsFile))
{
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json)
?? new List<LyricsMappingEntry>();
}
// Remove any existing mapping for this track
mappings.RemoveAll(m =>
m.Artist.Equals(artist, StringComparison.OrdinalIgnoreCase) &&
m.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
// Add new mapping
mappings.Add(new LyricsMappingEntry
{
Artist = artist,
Title = title,
Album = album,
DurationSeconds = durationSeconds,
LyricsId = lyricsId,
CreatedAt = DateTime.UtcNow
});
// Save back to file
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(mappingsFile, updatedJson);
_logger.LogDebug("💾 Saved lyrics mapping to file: {Artist} - {Title} → Lyrics ID {LyricsId}",
artist, title, lyricsId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save lyrics mapping to file for {Artist} - {Title}", artist, title);
}
}
/// <summary>
/// Save manual lyrics ID mapping for a track
/// </summary>
[HttpPost("lyrics/map")]
public async Task<IActionResult> SaveLyricsMapping([FromBody] LyricsMappingRequest request)
{
if (string.IsNullOrWhiteSpace(request.Artist) || string.IsNullOrWhiteSpace(request.Title))
{
return BadRequest(new { error = "Artist and Title are required" });
}
if (request.LyricsId <= 0)
{
return BadRequest(new { error = "Valid LyricsId is required" });
}
try
{
// Store lyrics mapping in cache (NO EXPIRATION - manual mappings are permanent)
var mappingKey = $"lyrics:manual-map:{request.Artist}:{request.Title}";
await _cache.SetStringAsync(mappingKey, request.LyricsId.ToString());
// Also save to file for persistence across restarts
await SaveLyricsMappingToFileAsync(request.Artist, request.Title, request.Album ?? "", request.DurationSeconds, request.LyricsId);
_logger.LogInformation("Manual lyrics mapping saved: {Artist} - {Title} → Lyrics ID {LyricsId}",
request.Artist, request.Title, request.LyricsId);
// Optionally fetch and cache the lyrics immediately
try
{
var lyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.LrclibService>();
if (lyricsService != null)
{
var lyricsInfo = await lyricsService.GetLyricsByIdAsync(request.LyricsId);
if (lyricsInfo != null && !string.IsNullOrEmpty(lyricsInfo.PlainLyrics))
{
// Cache the lyrics using the standard cache key
var lyricsCacheKey = $"lyrics:{request.Artist}:{request.Title}:{request.Album ?? ""}:{request.DurationSeconds}";
await _cache.SetAsync(lyricsCacheKey, lyricsInfo.PlainLyrics);
_logger.LogInformation("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
return Ok(new
{
message = "Lyrics mapping saved and lyrics cached successfully",
lyricsId = request.LyricsId,
cached = true,
lyrics = new
{
id = lyricsInfo.Id,
trackName = lyricsInfo.TrackName,
artistName = lyricsInfo.ArtistName,
albumName = lyricsInfo.AlbumName,
duration = lyricsInfo.Duration,
instrumental = lyricsInfo.Instrumental
}
});
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch lyrics after mapping, but mapping was saved");
}
return Ok(new
{
message = "Lyrics mapping saved successfully",
lyricsId = request.LyricsId,
cached = false
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save lyrics mapping");
return StatusCode(500, new { error = "Failed to save lyrics mapping" });
}
}
/// <summary>
/// Get manual lyrics mappings
/// </summary>
[HttpGet("lyrics/mappings")]
public async Task<IActionResult> GetLyricsMappings()
{
try
{
var mappingsFile = "/app/cache/lyrics_mappings.json";
if (!System.IO.File.Exists(mappingsFile))
{
return Ok(new { mappings = new List<object>() });
}
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
var mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json) ?? new List<LyricsMappingEntry>();
return Ok(new { mappings });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get lyrics mappings");
return StatusCode(500, new { error = "Failed to get lyrics mappings" });
}
}
/// <summary> /// <summary>
/// Prefetch lyrics for a specific playlist /// Prefetch lyrics for a specific playlist
/// </summary> /// </summary>
@@ -2771,6 +2940,15 @@ public class ManualMappingRequest
public string? ExternalId { get; set; } public string? ExternalId { get; set; }
} }
public class LyricsMappingRequest
{
public string Artist { get; set; } = "";
public string Title { get; set; } = "";
public string? Album { get; set; }
public int DurationSeconds { get; set; }
public int LyricsId { get; set; }
}
public class ManualMappingEntry public class ManualMappingEntry
{ {
public string SpotifyId { get; set; } = ""; public string SpotifyId { get; set; } = "";
@@ -2780,6 +2958,16 @@ public class ManualMappingEntry
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
} }
public class LyricsMappingEntry
{
public string Artist { get; set; } = "";
public string Title { get; set; } = "";
public string? Album { get; set; }
public int DurationSeconds { get; set; }
public int LyricsId { get; set; }
public DateTime CreatedAt { get; set; }
}
public class ConfigUpdateRequest public class ConfigUpdateRequest
{ {
public Dictionary<string, string> Updates { get; set; } = new(); public Dictionary<string, string> Updates { get; set; } = new();

View File

@@ -36,6 +36,7 @@ public class CacheWarmingService : IHostedService
var playlistsWarmed = 0; var playlistsWarmed = 0;
var mappingsWarmed = 0; var mappingsWarmed = 0;
var lyricsWarmed = 0; var lyricsWarmed = 0;
var lyricsMappingsWarmed = 0;
try try
{ {
@@ -48,13 +49,16 @@ public class CacheWarmingService : IHostedService
// Warm manual mappings cache // Warm manual mappings cache
mappingsWarmed = await WarmManualMappingsCacheAsync(cancellationToken); mappingsWarmed = await WarmManualMappingsCacheAsync(cancellationToken);
// Warm lyrics mappings cache
lyricsMappingsWarmed = await WarmLyricsMappingsCacheAsync(cancellationToken);
// Warm lyrics cache // Warm lyrics cache
lyricsWarmed = await WarmLyricsCacheAsync(cancellationToken); lyricsWarmed = await WarmLyricsCacheAsync(cancellationToken);
var duration = DateTime.UtcNow - startTime; var duration = DateTime.UtcNow - startTime;
_logger.LogInformation( _logger.LogInformation(
"✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings, {Lyrics} lyrics", "✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings, {LyricsMappings} lyrics mappings, {Lyrics} lyrics",
duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed, lyricsWarmed); duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed, lyricsMappingsWarmed, lyricsWarmed);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -284,6 +288,48 @@ public class CacheWarmingService : IHostedService
return warmedCount; return warmedCount;
} }
/// <summary>
/// Warms lyrics mappings cache from file system.
/// Lyrics mappings NEVER expire - they are permanent user decisions.
/// </summary>
private async Task<int> WarmLyricsMappingsCacheAsync(CancellationToken cancellationToken)
{
var mappingsFile = "/app/cache/lyrics_mappings.json";
if (!File.Exists(mappingsFile))
{
return 0;
}
try
{
var json = await File.ReadAllTextAsync(mappingsFile, cancellationToken);
var mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json);
if (mappings != null && mappings.Count > 0)
{
foreach (var mapping in mappings)
{
if (cancellationToken.IsCancellationRequested)
break;
// Store in Redis with NO EXPIRATION (permanent)
var redisKey = $"lyrics:manual-map:{mapping.Artist}:{mapping.Title}";
await _cache.SetStringAsync(redisKey, mapping.LyricsId.ToString());
}
_logger.LogInformation("🔥 Warmed {Count} lyrics mappings from file system", mappings.Count);
return mappings.Count;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm lyrics mappings from file: {File}", mappingsFile);
}
return 0;
}
/// <summary> /// <summary>
/// Warms lyrics cache from file system using the LyricsPrefetchService. /// Warms lyrics cache from file system using the LyricsPrefetchService.
/// </summary> /// </summary>
@@ -341,4 +387,14 @@ public class CacheWarmingService : IHostedService
public string? ExternalId { get; set; } public string? ExternalId { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
} }
private class LyricsMappingEntry
{
public string Artist { get; set; } = "";
public string Title { get; set; } = "";
public string? Album { get; set; }
public int DurationSeconds { get; set; }
public int LyricsId { get; set; }
public DateTime CreatedAt { get; set; }
}
} }

View File

@@ -33,6 +33,31 @@ public class LrclibService
var artistName = string.Join(", ", artistNames); var artistName = string.Join(", ", artistNames);
var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}"; var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}";
// FIRST: Check for manual lyrics mapping
var manualMappingKey = $"lyrics:manual-map:{artistName}:{trackName}";
var manualLyricsIdStr = await _cache.GetStringAsync(manualMappingKey);
if (!string.IsNullOrEmpty(manualLyricsIdStr) && int.TryParse(manualLyricsIdStr, out var manualLyricsId) && manualLyricsId > 0)
{
_logger.LogInformation("✓ Manual lyrics mapping found for {Artist} - {Track}: Lyrics ID {Id}",
artistName, trackName, manualLyricsId);
// Fetch lyrics by ID
var manualLyrics = await GetLyricsByIdAsync(manualLyricsId);
if (manualLyrics != null && !string.IsNullOrEmpty(manualLyrics.PlainLyrics))
{
// Cache the lyrics using the standard cache key
await _cache.SetAsync(cacheKey, manualLyrics.PlainLyrics!);
return manualLyrics;
}
else
{
_logger.LogWarning("Manual lyrics mapping points to invalid ID {Id} for {Artist} - {Track}",
manualLyricsId, artistName, trackName);
}
}
// SECOND: Check standard cache
var cached = await _cache.GetStringAsync(cacheKey); var cached = await _cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cached)) if (!string.IsNullOrEmpty(cached))
{ {

View File

@@ -1000,6 +1000,45 @@
</div> </div>
</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 --> <!-- Restart Overlay -->
<div class="restart-overlay" id="restart-overlay"> <div class="restart-overlay" id="restart-overlay">
<div class="spinner-large"></div> <div class="spinner-large"></div>
@@ -1830,6 +1869,12 @@
document.getElementById('tracks-list').innerHTML = data.tracks.map(t => { document.getElementById('tracks-list').innerHTML = data.tracks.map(t => {
let statusBadge = ''; let statusBadge = '';
let mapButton = ''; 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) { 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>'; 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>`; 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 ` return `
<div class="track-item" data-position="${t.position}"> <div class="track-item" data-position="${t.position}">
<span class="track-position">${t.position + 1}</span> <span class="track-position">${t.position + 1}</span>
<div class="track-info"> <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> <span class="artists">${escapeHtml((t.artists || []).join(', '))}</span>
</div> </div>
<div class="track-meta"> <div class="track-meta">
${t.album ? escapeHtml(t.album) : ''} ${t.album ? escapeHtml(t.album) : ''}
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''} ${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 === 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</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>
</div> </div>
`; `;
@@ -2380,6 +2433,70 @@
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); 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 // Initial load
fetchStatus(); fetchStatus();
fetchPlaylists(); fetchPlaylists();