mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 15:45:10 -05:00
Compare commits
2 Commits
328a6a0eea
...
7ff6dbbe7a
| Author | SHA1 | Date | |
|---|---|---|---|
|
7ff6dbbe7a
|
|||
|
e0dbd1d4fd
|
@@ -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,13 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// Check lyrics status (only from our cache - lrclib/Spotify)
|
||||
// Note: For local tracks, Jellyfin may have embedded lyrics that we don't check here
|
||||
// Those will be served directly by Jellyfin when requested
|
||||
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 +754,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 +2732,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 +2942,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 +2960,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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Warms lyrics cache from file system using the LyricsPrefetchService.
|
||||
/// </summary>
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Spotify;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -17,6 +18,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
private readonly LrclibService _lrclibService;
|
||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<LyricsPrefetchService> _logger;
|
||||
private readonly string _lyricsCacheDir = "/app/cache/lyrics";
|
||||
private const int DelayBetweenRequestsMs = 500; // 500ms = 2 requests/second to be respectful
|
||||
@@ -26,12 +28,14 @@ public class LyricsPrefetchService : BackgroundService
|
||||
LrclibService lrclibService,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<LyricsPrefetchService> logger)
|
||||
{
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_lrclibService = lrclibService;
|
||||
_playlistFetcher = playlistFetcher;
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -141,7 +145,20 @@ public class LyricsPrefetchService : BackgroundService
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch lyrics
|
||||
// Check if this track has local Jellyfin lyrics (embedded in file)
|
||||
var hasLocalLyrics = await CheckForLocalJellyfinLyricsAsync(track.SpotifyId);
|
||||
if (hasLocalLyrics)
|
||||
{
|
||||
cached++;
|
||||
_logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping LRCLib fetch",
|
||||
track.PrimaryArtist, track.Title);
|
||||
|
||||
// Remove any previously cached LRCLib lyrics for this track
|
||||
await RemoveCachedLyricsAsync(track.PrimaryArtist, track.Title, track.Album, track.DurationMs / 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch lyrics from LRCLib
|
||||
var lyrics = await _lrclibService.GetLyricsAsync(
|
||||
track.Title,
|
||||
track.Artists.ToArray(),
|
||||
@@ -255,4 +272,108 @@ public class LyricsPrefetchService : BackgroundService
|
||||
.Replace(" ", "_")
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes cached LRCLib lyrics from both Redis and file cache.
|
||||
/// Used when a track has local Jellyfin lyrics, making the LRCLib cache obsolete.
|
||||
/// </summary>
|
||||
private async Task RemoveCachedLyricsAsync(string artist, string title, string album, int duration)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Remove from Redis cache
|
||||
var cacheKey = $"lyrics:{artist}:{title}:{album}:{duration}";
|
||||
await _cache.DeleteAsync(cacheKey);
|
||||
|
||||
// Remove from file cache
|
||||
var fileName = $"{SanitizeFileName(artist)}_{SanitizeFileName(title)}_{duration}.json";
|
||||
var filePath = Path.Combine(_lyricsCacheDir, fileName);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
_logger.LogDebug("🗑️ Removed cached LRCLib lyrics file: {FileName}", fileName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to remove cached lyrics for {Artist} - {Track}", artist, title);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API.
|
||||
/// This prevents downloading lyrics from LRCLib when the local file already has them.
|
||||
/// </summary>
|
||||
private async Task<bool> CheckForLocalJellyfinLyricsAsync(string spotifyTrackId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
|
||||
if (proxyService == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search for the track in Jellyfin by Spotify provider ID
|
||||
var searchParams = new Dictionary<string, string>
|
||||
{
|
||||
["anyProviderIdEquals"] = $"Spotify.{spotifyTrackId}",
|
||||
["includeItemTypes"] = "Audio",
|
||||
["recursive"] = "true",
|
||||
["limit"] = "1"
|
||||
};
|
||||
|
||||
var (searchResult, statusCode) = await proxyService.GetJsonAsync("Items", searchParams, null);
|
||||
|
||||
if (searchResult == null || statusCode != 200)
|
||||
{
|
||||
// Track not found in local library
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we found any items
|
||||
if (!searchResult.RootElement.TryGetProperty("Items", out var items) ||
|
||||
items.GetArrayLength() == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the first matching track's ID
|
||||
var firstItem = items[0];
|
||||
if (!firstItem.TryGetProperty("Id", out var idElement))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var jellyfinTrackId = idElement.GetString();
|
||||
if (string.IsNullOrEmpty(jellyfinTrackId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this track has lyrics
|
||||
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsync(
|
||||
$"Audio/{jellyfinTrackId}/Lyrics",
|
||||
null,
|
||||
null);
|
||||
|
||||
if (lyricsResult != null && lyricsStatusCode == 200)
|
||||
{
|
||||
// Track has embedded lyrics in Jellyfin
|
||||
_logger.LogDebug("Found embedded lyrics in Jellyfin for Spotify track {SpotifyId} (Jellyfin ID: {JellyfinId})",
|
||||
spotifyTrackId, jellyfinTrackId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error checking for local Jellyfin lyrics for Spotify track {SpotifyId}", spotifyTrackId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user