Add lyrics prefetching for injected playlists with file cache

New LyricsPrefetchService automatically fetches lyrics for all tracks in
Spotify injected playlists. Lyrics are cached in Redis and persisted to
disk for fast loading on startup.

Features:
- Prefetches lyrics for all playlist tracks on startup (after 3min delay)
- Daily refresh to catch new tracks
- File cache at /app/cache/lyrics for persistence
- Cache warming on startup loads lyrics from disk into Redis
- Rate limited to 2 requests/second to be respectful to LRCLIB API
- Logs fetched/cached/missing counts per playlist

Benefits:
- Instant lyrics availability for playlist tracks
- Survives container restarts
- Reduces API calls during playback
- Better user experience with pre-loaded lyrics
This commit is contained in:
2026-02-05 11:15:42 -05:00
parent 3b24ef3e78
commit 8966fb1fa2
3 changed files with 303 additions and 2 deletions

View File

@@ -566,6 +566,10 @@ builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracks
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
// Register lyrics prefetch service (prefetches lyrics for all playlist tracks)
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPrefetchService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Lyrics.LyricsPrefetchService>());
// Register MusicBrainz service for metadata enrichment
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
{

View File

@@ -11,15 +11,19 @@ public class CacheWarmingService : IHostedService
{
private readonly RedisCacheService _cache;
private readonly ILogger<CacheWarmingService> _logger;
private readonly IServiceProvider _serviceProvider;
private const string GenreCacheDirectory = "/app/cache/genres";
private const string PlaylistCacheDirectory = "/app/cache/spotify";
private const string MappingsCacheDirectory = "/app/cache/mappings";
private const string LyricsCacheDirectory = "/app/cache/lyrics";
public CacheWarmingService(
RedisCacheService cache,
IServiceProvider serviceProvider,
ILogger<CacheWarmingService> logger)
{
_cache = cache;
_serviceProvider = serviceProvider;
_logger = logger;
}
@@ -31,6 +35,7 @@ public class CacheWarmingService : IHostedService
var genresWarmed = 0;
var playlistsWarmed = 0;
var mappingsWarmed = 0;
var lyricsWarmed = 0;
try
{
@@ -42,11 +47,14 @@ public class CacheWarmingService : IHostedService
// Warm manual mappings cache
mappingsWarmed = await WarmManualMappingsCacheAsync(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",
duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed);
"✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings, {Lyrics} lyrics",
duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed, lyricsWarmed);
}
catch (Exception ex)
{
@@ -275,6 +283,37 @@ public class CacheWarmingService : IHostedService
return warmedCount;
}
/// <summary>
/// Warms lyrics cache from file system using the LyricsPrefetchService.
/// </summary>
private async Task<int> WarmLyricsCacheAsync(CancellationToken cancellationToken)
{
try
{
// Get the LyricsPrefetchService from DI
using var scope = _serviceProvider.CreateScope();
var lyricsPrefetchService = scope.ServiceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
if (lyricsPrefetchService != null)
{
await lyricsPrefetchService.WarmCacheFromFilesAsync();
// Count files to return warmed count
if (Directory.Exists(LyricsCacheDirectory))
{
return Directory.GetFiles(LyricsCacheDirectory, "*.json").Length;
}
}
return 0;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm lyrics cache");
return 0;
}
}
private class GenreCacheEntry
{

View File

@@ -0,0 +1,258 @@
using System.Text.Json;
using allstarr.Models.Lyrics;
using allstarr.Models.Settings;
using allstarr.Services.Common;
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 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,
ILogger<LyricsPrefetchService> logger)
{
_spotifySettings = spotifySettings.Value;
_lrclibService = lrclibService;
_playlistFetcher = playlistFetcher;
_cache = cache;
_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);
}
private 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);
}
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
var cacheKey = $"lyrics:{track.PrimaryArtist}:{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;
}
// Fetch lyrics
var 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
await SaveLyricsToFileAsync(track.PrimaryArtist, 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();
}
}