From 7ff6dbbe7a396f68a3c66ce36d497e4babb2f940 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Thu, 5 Feb 2026 15:09:59 -0500 Subject: [PATCH] Prioritize local Jellyfin lyrics over LRCLib in prefetch - Check for embedded lyrics in local Jellyfin tracks before fetching from LRCLib - Remove previously cached LRCLib lyrics when local lyrics are found - Prevents unnecessary API calls and respects user's embedded lyrics - Tracks with local lyrics are counted as 'cached' in prefetch stats --- allstarr/Controllers/AdminController.cs | 4 +- .../Services/Lyrics/LyricsPrefetchService.cs | 123 +++++++++++++++++- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index f1e3b07..b96b547 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -732,7 +732,9 @@ public class AdminController : ControllerBase } } - // Check lyrics status + // 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); diff --git a/allstarr/Services/Lyrics/LyricsPrefetchService.cs b/allstarr/Services/Lyrics/LyricsPrefetchService.cs index 295a8ab..1ae1eb3 100644 --- a/allstarr/Services/Lyrics/LyricsPrefetchService.cs +++ b/allstarr/Services/Lyrics/LyricsPrefetchService.cs @@ -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 _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 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(); } + + /// + /// Removes cached LRCLib lyrics from both Redis and file cache. + /// Used when a track has local Jellyfin lyrics, making the LRCLib cache obsolete. + /// + 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); + } + } + + /// + /// 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. + /// + private async Task CheckForLocalJellyfinLyricsAsync(string spotifyTrackId) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var proxyService = scope.ServiceProvider.GetService(); + + if (proxyService == null) + { + return false; + } + + // Search for the track in Jellyfin by Spotify provider ID + var searchParams = new Dictionary + { + ["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; + } + } }