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>();
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?>
{
@@ -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
}
}
/// <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>
/// Prefetch lyrics for a specific playlist
/// </summary>
@@ -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<string, string> Updates { get; set; } = new();