mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
- Fix LyricsPrefetchService to use server API key for Jellyfin lyrics checks - Remove Spotify lyrics caching (local Docker container is fast) - Disable lyrics prefetching service (not needed - Jellyfin/Spotify are fast) - Add POST /api/admin/cache/clear-lyrics endpoint to clear LRCLIB cache - Only LRCLIB lyrics are cached now (external API)
536 lines
21 KiB
C#
536 lines
21 KiB
C#
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;
|
|
|
|
namespace allstarr.Services.Lyrics;
|
|
|
|
/// <summary>
|
|
/// Background service that prefetches lyrics for all tracks in injected Spotify playlists.
|
|
/// Lyrics are cached in Redis and persisted to disk for fast loading on startup.
|
|
/// </summary>
|
|
public class LyricsPrefetchService : BackgroundService
|
|
{
|
|
private readonly SpotifyImportSettings _spotifySettings;
|
|
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
|
|
|
|
public LyricsPrefetchService(
|
|
IOptions<SpotifyImportSettings> spotifySettings,
|
|
LrclibService lrclibService,
|
|
SpotifyPlaylistFetcher playlistFetcher,
|
|
RedisCacheService cache,
|
|
IServiceProvider serviceProvider,
|
|
ILogger<LyricsPrefetchService> logger)
|
|
{
|
|
_spotifySettings = spotifySettings.Value;
|
|
_lrclibService = lrclibService;
|
|
_playlistFetcher = playlistFetcher;
|
|
_cache = cache;
|
|
_serviceProvider = serviceProvider;
|
|
_logger = logger;
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
_logger.LogInformation("LyricsPrefetchService: Starting up...");
|
|
|
|
if (!_spotifySettings.Enabled)
|
|
{
|
|
_logger.LogInformation("Spotify playlist injection is DISABLED, lyrics prefetch will not run");
|
|
return;
|
|
}
|
|
|
|
// Ensure cache directory exists
|
|
Directory.CreateDirectory(_lyricsCacheDir);
|
|
|
|
// Wait for playlist fetcher to initialize
|
|
await Task.Delay(TimeSpan.FromMinutes(3), stoppingToken);
|
|
|
|
// Run initial prefetch
|
|
try
|
|
{
|
|
_logger.LogInformation("Running initial lyrics prefetch on startup");
|
|
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error during startup lyrics prefetch");
|
|
}
|
|
|
|
// Run periodic prefetch (daily)
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
|
|
|
|
try
|
|
{
|
|
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error in lyrics prefetch service");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task PrefetchAllPlaylistLyricsAsync(CancellationToken cancellationToken)
|
|
{
|
|
_logger.LogInformation("🎵 Starting lyrics prefetch for {Count} playlists", _spotifySettings.Playlists.Count);
|
|
|
|
var totalFetched = 0;
|
|
var totalCached = 0;
|
|
var totalMissing = 0;
|
|
|
|
foreach (var playlist in _spotifySettings.Playlists)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested) break;
|
|
|
|
try
|
|
{
|
|
var (fetched, cached, missing) = await PrefetchPlaylistLyricsAsync(playlist.Name, cancellationToken);
|
|
totalFetched += fetched;
|
|
totalCached += cached;
|
|
totalMissing += missing;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error prefetching lyrics for playlist {Playlist}", playlist.Name);
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("✅ Lyrics prefetch complete: {Fetched} fetched, {Cached} already cached, {Missing} not found",
|
|
totalFetched, totalCached, totalMissing);
|
|
}
|
|
|
|
public async Task<(int Fetched, int Cached, int Missing)> PrefetchPlaylistLyricsAsync(
|
|
string playlistName,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
_logger.LogInformation("Prefetching lyrics for playlist: {Playlist}", playlistName);
|
|
|
|
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
|
if (tracks.Count == 0)
|
|
{
|
|
_logger.LogWarning("No tracks found for playlist {Playlist}", playlistName);
|
|
return (0, 0, 0);
|
|
}
|
|
|
|
// Get the pre-built playlist items cache which includes Jellyfin item IDs for local tracks
|
|
var playlistItemsKey = $"spotify:playlist:items:{playlistName}";
|
|
var playlistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
|
|
|
|
// Build a map of Spotify ID -> Jellyfin Item ID for quick lookup
|
|
var spotifyToJellyfinId = new Dictionary<string, string>();
|
|
if (playlistItems != null)
|
|
{
|
|
foreach (var item in playlistItems)
|
|
{
|
|
// Check if this is a local Jellyfin track (has Id field, no ProviderIds for external)
|
|
if (item.TryGetValue("Id", out var idObj) && idObj != null)
|
|
{
|
|
var jellyfinId = idObj.ToString();
|
|
|
|
// Try to get Spotify provider ID
|
|
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
|
{
|
|
var providerIdsJson = JsonSerializer.Serialize(providerIdsObj);
|
|
using var doc = JsonDocument.Parse(providerIdsJson);
|
|
if (doc.RootElement.TryGetProperty("Spotify", out var spotifyIdEl))
|
|
{
|
|
var spotifyId = spotifyIdEl.GetString();
|
|
if (!string.IsNullOrEmpty(spotifyId) && !string.IsNullOrEmpty(jellyfinId))
|
|
{
|
|
spotifyToJellyfinId[spotifyId] = jellyfinId;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_logger.LogDebug("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}",
|
|
spotifyToJellyfinId.Count, playlistName);
|
|
}
|
|
|
|
var fetched = 0;
|
|
var cached = 0;
|
|
var missing = 0;
|
|
|
|
foreach (var track in tracks)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested) break;
|
|
|
|
try
|
|
{
|
|
// Check if lyrics are already cached
|
|
// Use same cache key format as LrclibService: join all artists with ", "
|
|
var artistName = string.Join(", ", track.Artists);
|
|
var cacheKey = $"lyrics:{artistName}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
|
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
|
|
|
if (!string.IsNullOrEmpty(existingLyrics))
|
|
{
|
|
cached++;
|
|
_logger.LogDebug("✓ Lyrics already cached for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
|
continue;
|
|
}
|
|
|
|
// Priority 1: Check if this track has local Jellyfin lyrics (embedded in file)
|
|
// Use the Jellyfin item ID from the playlist cache if available
|
|
if (spotifyToJellyfinId.TryGetValue(track.SpotifyId, out var jellyfinItemId))
|
|
{
|
|
var hasLocalLyrics = await CheckForLocalJellyfinLyricsByIdAsync(jellyfinItemId, track.PrimaryArtist, track.Title);
|
|
if (hasLocalLyrics)
|
|
{
|
|
cached++;
|
|
_logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch",
|
|
track.PrimaryArtist, track.Title);
|
|
|
|
// Remove any previously cached LRCLib lyrics for this track
|
|
var artistNameForRemoval = string.Join(", ", track.Artists);
|
|
await RemoveCachedLyricsAsync(artistNameForRemoval, track.Title, track.Album, track.DurationMs / 1000);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Priority 2: Try Spotify lyrics if we have a Spotify ID
|
|
LyricsInfo? lyrics = null;
|
|
if (!string.IsNullOrEmpty(track.SpotifyId))
|
|
{
|
|
lyrics = await TryGetSpotifyLyricsAsync(track.SpotifyId, track.Title, track.PrimaryArtist);
|
|
}
|
|
|
|
// Priority 3: Fall back to LRCLib if no Spotify lyrics
|
|
if (lyrics == null)
|
|
{
|
|
lyrics = await _lrclibService.GetLyricsAsync(
|
|
track.Title,
|
|
track.Artists.ToArray(),
|
|
track.Album,
|
|
track.DurationMs / 1000);
|
|
}
|
|
|
|
if (lyrics != null)
|
|
{
|
|
fetched++;
|
|
_logger.LogInformation("✓ Fetched lyrics for {Artist} - {Track} (synced: {HasSynced})",
|
|
track.PrimaryArtist, track.Title, !string.IsNullOrEmpty(lyrics.SyncedLyrics));
|
|
|
|
// Save to file cache
|
|
var artistNameForSave = string.Join(", ", track.Artists);
|
|
await SaveLyricsToFileAsync(artistNameForSave, track.Title, track.Album, track.DurationMs / 1000, lyrics);
|
|
}
|
|
else
|
|
{
|
|
missing++;
|
|
_logger.LogDebug("✗ No lyrics found for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
|
}
|
|
|
|
// Rate limiting
|
|
await Task.Delay(DelayBetweenRequestsMs, cancellationToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to prefetch lyrics for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
|
missing++;
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
|
|
playlistName, fetched, cached, missing);
|
|
|
|
return (fetched, cached, missing);
|
|
}
|
|
|
|
private async Task SaveLyricsToFileAsync(string artist, string title, string album, int duration, LyricsInfo lyrics)
|
|
{
|
|
try
|
|
{
|
|
var fileName = $"{SanitizeFileName(artist)}_{SanitizeFileName(title)}_{duration}.json";
|
|
var filePath = Path.Combine(_lyricsCacheDir, fileName);
|
|
|
|
var json = JsonSerializer.Serialize(lyrics, new JsonSerializerOptions { WriteIndented = true });
|
|
await File.WriteAllTextAsync(filePath, json);
|
|
|
|
_logger.LogDebug("💾 Saved lyrics to file: {FileName}", fileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to save lyrics to file for {Artist} - {Track}", artist, title);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads lyrics from file cache into Redis on startup
|
|
/// </summary>
|
|
public async Task WarmCacheFromFilesAsync()
|
|
{
|
|
try
|
|
{
|
|
if (!Directory.Exists(_lyricsCacheDir))
|
|
{
|
|
_logger.LogInformation("Lyrics cache directory does not exist, skipping cache warming");
|
|
return;
|
|
}
|
|
|
|
var files = Directory.GetFiles(_lyricsCacheDir, "*.json");
|
|
if (files.Length == 0)
|
|
{
|
|
_logger.LogInformation("No lyrics cache files found");
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("🔥 Warming lyrics cache from {Count} files...", files.Length);
|
|
|
|
var loaded = 0;
|
|
foreach (var file in files)
|
|
{
|
|
try
|
|
{
|
|
var json = await File.ReadAllTextAsync(file);
|
|
var lyrics = JsonSerializer.Deserialize<LyricsInfo>(json);
|
|
|
|
if (lyrics != null)
|
|
{
|
|
var cacheKey = $"lyrics:{lyrics.ArtistName}:{lyrics.TrackName}:{lyrics.AlbumName}:{lyrics.Duration}";
|
|
await _cache.SetStringAsync(cacheKey, json, TimeSpan.FromDays(30));
|
|
loaded++;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to load lyrics from file {File}", Path.GetFileName(file));
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("✅ Warmed {Count} lyrics from file cache", loaded);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error warming lyrics cache from files");
|
|
}
|
|
}
|
|
|
|
private static string SanitizeFileName(string fileName)
|
|
{
|
|
var invalid = Path.GetInvalidFileNameChars();
|
|
return string.Join("_", fileName.Split(invalid, StringSplitOptions.RemoveEmptyEntries))
|
|
.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>
|
|
/// Tries to get lyrics from Spotify using the track's Spotify ID.
|
|
/// Returns null if Spotify API is not enabled or lyrics not found.
|
|
/// </summary>
|
|
private async Task<LyricsInfo?> TryGetSpotifyLyricsAsync(string spotifyTrackId, string trackTitle, string artistName)
|
|
{
|
|
try
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var spotifyLyricsService = scope.ServiceProvider.GetService<SpotifyLyricsService>();
|
|
|
|
if (spotifyLyricsService == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var spotifyLyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
|
|
|
|
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
|
{
|
|
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)",
|
|
artistName, trackTitle, spotifyLyrics.Lines.Count);
|
|
return spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track {SpotifyId}", spotifyTrackId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a track has embedded lyrics in Jellyfin using the Jellyfin item ID.
|
|
/// This is the most efficient method as it directly queries the lyrics endpoint.
|
|
/// </summary>
|
|
private async Task<bool> CheckForLocalJellyfinLyricsByIdAsync(string jellyfinItemId, string artistName, string trackTitle)
|
|
{
|
|
try
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
|
|
|
if (proxyService == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Directly check if this track has lyrics using the item ID
|
|
// Use internal method with server API key since this is a background operation
|
|
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal(
|
|
$"Audio/{jellyfinItemId}/Lyrics",
|
|
null);
|
|
|
|
if (lyricsResult != null && lyricsStatusCode == 200)
|
|
{
|
|
// Track has embedded lyrics in Jellyfin
|
|
_logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (ID: {JellyfinId})",
|
|
artistName, trackTitle, jellyfinItemId);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Error checking Jellyfin lyrics for item {ItemId}", jellyfinItemId);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <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, string artistName, string trackTitle)
|
|
{
|
|
try
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
|
|
|
if (proxyService == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Search for the track in Jellyfin by artist and title
|
|
// Jellyfin doesn't support anyProviderIdEquals - that's an Emby API parameter
|
|
var searchTerm = $"{artistName} {trackTitle}";
|
|
var searchParams = new Dictionary<string, string>
|
|
{
|
|
["searchTerm"] = searchTerm,
|
|
["includeItemTypes"] = "Audio",
|
|
["recursive"] = "true",
|
|
["limit"] = "5" // Get a few results to find best match
|
|
};
|
|
|
|
var (searchResult, statusCode) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
|
|
|
|
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;
|
|
}
|
|
|
|
// Find the best matching track by comparing artist and title
|
|
string? bestMatchId = null;
|
|
foreach (var item in items.EnumerateArray())
|
|
{
|
|
if (!item.TryGetProperty("Name", out var nameEl) ||
|
|
!item.TryGetProperty("Id", out var idEl))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var itemTitle = nameEl.GetString() ?? "";
|
|
var itemId = idEl.GetString();
|
|
|
|
// Check if title matches (case-insensitive)
|
|
if (itemTitle.Equals(trackTitle, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Also check artist if available
|
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
|
{
|
|
var itemArtist = artistsEl[0].GetString() ?? "";
|
|
if (itemArtist.Equals(artistName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
bestMatchId = itemId;
|
|
break; // Exact match found
|
|
}
|
|
}
|
|
|
|
// If no exact artist match but title matches, use it as fallback
|
|
if (bestMatchId == null)
|
|
{
|
|
bestMatchId = itemId;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(bestMatchId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Check if this track has lyrics
|
|
// Use internal method with server API key since this is a background operation
|
|
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal(
|
|
$"Audio/{bestMatchId}/Lyrics",
|
|
null);
|
|
|
|
if (lyricsResult != null && lyricsStatusCode == 200)
|
|
{
|
|
// Track has embedded lyrics in Jellyfin
|
|
_logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (Jellyfin ID: {JellyfinId})",
|
|
artistName, trackTitle, bestMatchId);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Error checking for local Jellyfin lyrics for Spotify track {SpotifyId}", spotifyTrackId);
|
|
return false;
|
|
}
|
|
}
|
|
}
|