mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55: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>();
|
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,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
|
tracksWithStatus.Add(new
|
||||||
{
|
{
|
||||||
position = track.Position,
|
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
|
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 +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>
|
/// <summary>
|
||||||
/// Prefetch lyrics for a specific playlist
|
/// Prefetch lyrics for a specific playlist
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2771,6 +2942,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 +2960,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))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using allstarr.Models.Lyrics;
|
using allstarr.Models.Lyrics;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
|
using allstarr.Services.Jellyfin;
|
||||||
using allstarr.Services.Spotify;
|
using allstarr.Services.Spotify;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
private readonly LrclibService _lrclibService;
|
private readonly LrclibService _lrclibService;
|
||||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly ILogger<LyricsPrefetchService> _logger;
|
private readonly ILogger<LyricsPrefetchService> _logger;
|
||||||
private readonly string _lyricsCacheDir = "/app/cache/lyrics";
|
private readonly string _lyricsCacheDir = "/app/cache/lyrics";
|
||||||
private const int DelayBetweenRequestsMs = 500; // 500ms = 2 requests/second to be respectful
|
private const int DelayBetweenRequestsMs = 500; // 500ms = 2 requests/second to be respectful
|
||||||
@@ -26,12 +28,14 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
LrclibService lrclibService,
|
LrclibService lrclibService,
|
||||||
SpotifyPlaylistFetcher playlistFetcher,
|
SpotifyPlaylistFetcher playlistFetcher,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
ILogger<LyricsPrefetchService> logger)
|
ILogger<LyricsPrefetchService> logger)
|
||||||
{
|
{
|
||||||
_spotifySettings = spotifySettings.Value;
|
_spotifySettings = spotifySettings.Value;
|
||||||
_lrclibService = lrclibService;
|
_lrclibService = lrclibService;
|
||||||
_playlistFetcher = playlistFetcher;
|
_playlistFetcher = playlistFetcher;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +145,20 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
continue;
|
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(
|
var lyrics = await _lrclibService.GetLyricsAsync(
|
||||||
track.Title,
|
track.Title,
|
||||||
track.Artists.ToArray(),
|
track.Artists.ToArray(),
|
||||||
@@ -255,4 +272,108 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
.Replace(" ", "_")
|
.Replace(" ", "_")
|
||||||
.ToLowerInvariant();
|
.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>
|
||||||
</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