Prioritize local Jellyfin lyrics over LRCLib in prefetch
Some checks failed
CI / build-and-test (push) Has been cancelled

- 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
This commit is contained in:
2026-02-05 15:09:59 -05:00
parent e0dbd1d4fd
commit 7ff6dbbe7a
2 changed files with 125 additions and 2 deletions

View File

@@ -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);

View File

@@ -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;
}
}
}