From e0dbd1d4fd94db6cb51a5e2a1c815bdfcef9d4bf Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Thu, 5 Feb 2026 14:58:57 -0500 Subject: [PATCH] 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) --- allstarr/Controllers/AdminController.cs | 192 +++++++++++++++++- .../Services/Common/CacheWarmingService.cs | 60 +++++- allstarr/Services/Lyrics/LrclibService.cs | 25 +++ allstarr/wwwroot/index.html | 123 ++++++++++- 4 files changed, 393 insertions(+), 7 deletions(-) diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index db9896c..f1e3b07 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -215,7 +215,11 @@ public class AdminController : ControllerBase { var playlists = new List(); - 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 { @@ -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 { 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 isManualMapping = isManualMapping, manualMappingType = manualMappingType, - manualMappingId = manualMappingId + manualMappingId = manualMappingId, + hasLyrics = hasLyrics }); } @@ -2720,6 +2730,165 @@ public class AdminController : ControllerBase } } + /// + /// Save lyrics mapping to file for persistence across restarts. + /// Lyrics mappings NEVER expire - they are permanent user decisions. + /// + 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(); + if (System.IO.File.Exists(mappingsFile)) + { + var json = await System.IO.File.ReadAllTextAsync(mappingsFile); + mappings = JsonSerializer.Deserialize>(json) + ?? new List(); + } + + // 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); + } + } + + /// + /// Save manual lyrics ID mapping for a track + /// + [HttpPost("lyrics/map")] + public async Task 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(); + 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" }); + } + } + + /// + /// Get manual lyrics mappings + /// + [HttpGet("lyrics/mappings")] + public async Task GetLyricsMappings() + { + try + { + var mappingsFile = "/app/cache/lyrics_mappings.json"; + + if (!System.IO.File.Exists(mappingsFile)) + { + return Ok(new { mappings = new List() }); + } + + var json = await System.IO.File.ReadAllTextAsync(mappingsFile); + var mappings = JsonSerializer.Deserialize>(json) ?? new List(); + + 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" }); + } + } + /// /// Prefetch lyrics for a specific playlist /// @@ -2771,6 +2940,15 @@ public class ManualMappingRequest 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 string SpotifyId { get; set; } = ""; @@ -2780,6 +2958,16 @@ public class ManualMappingEntry 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 Dictionary Updates { get; set; } = new(); diff --git a/allstarr/Services/Common/CacheWarmingService.cs b/allstarr/Services/Common/CacheWarmingService.cs index c5eeb6d..7e0c2b8 100644 --- a/allstarr/Services/Common/CacheWarmingService.cs +++ b/allstarr/Services/Common/CacheWarmingService.cs @@ -36,6 +36,7 @@ public class CacheWarmingService : IHostedService var playlistsWarmed = 0; var mappingsWarmed = 0; var lyricsWarmed = 0; + var lyricsMappingsWarmed = 0; try { @@ -48,13 +49,16 @@ public class CacheWarmingService : IHostedService // Warm manual mappings cache mappingsWarmed = await WarmManualMappingsCacheAsync(cancellationToken); + // Warm lyrics mappings cache + lyricsMappingsWarmed = await WarmLyricsMappingsCacheAsync(cancellationToken); + // Warm lyrics cache lyricsWarmed = await WarmLyricsCacheAsync(cancellationToken); var duration = DateTime.UtcNow - startTime; _logger.LogInformation( - "✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings, {Lyrics} lyrics", - duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed, lyricsWarmed); + "✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings, {LyricsMappings} lyrics mappings, {Lyrics} lyrics", + duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed, lyricsMappingsWarmed, lyricsWarmed); } catch (Exception ex) { @@ -284,6 +288,48 @@ public class CacheWarmingService : IHostedService return warmedCount; } + /// + /// Warms lyrics mappings cache from file system. + /// Lyrics mappings NEVER expire - they are permanent user decisions. + /// + private async Task 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>(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; + } + /// /// Warms lyrics cache from file system using the LyricsPrefetchService. /// @@ -341,4 +387,14 @@ public class CacheWarmingService : IHostedService public string? ExternalId { 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; } + } } diff --git a/allstarr/Services/Lyrics/LrclibService.cs b/allstarr/Services/Lyrics/LrclibService.cs index ec2d2f5..98da02c 100644 --- a/allstarr/Services/Lyrics/LrclibService.cs +++ b/allstarr/Services/Lyrics/LrclibService.cs @@ -33,6 +33,31 @@ public class LrclibService var artistName = string.Join(", ", artistNames); 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); if (!string.IsNullOrEmpty(cached)) { diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index d61a8f4..f91f22f 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -1000,6 +1000,45 @@ + + +
@@ -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 = 'Lyrics'; + } if (t.isLocal === true) { statusBadge = 'Local'; @@ -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`; } + // 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 = ``; + return `
${t.position + 1}
-

${escapeHtml(t.title)}${statusBadge}${mapButton}

+

${escapeHtml(t.title)}${statusBadge}${lyricsBadge}${mapButton}${lyricsMapButton}

${escapeHtml((t.artists || []).join(', '))}
${t.album ? escapeHtml(t.album) : ''} ${t.isrc ? '
ISRC: ' + t.isrc + '' : ''} - ${t.isLocal === false && t.searchQuery && t.externalProvider ? '
🔍 Search' : ''} - ${t.isLocal === null && t.searchQuery ? '
🔍 Search' : ''} + ${t.isLocal === false && t.searchQuery && t.externalProvider ? '
🔍 Search: ' + escapeHtml(searchLinkText) + '' : ''} + ${t.isLocal === null && t.searchQuery ? '
🔍 Search: ' + escapeHtml(searchLinkText) + '' : ''}
`; @@ -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();