mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
@@ -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();
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user