Files
allstarr/allstarr/Services/Lyrics/LyricsPrefetchService.cs
Josh Patra 8966fb1fa2 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
2026-02-05 11:15:42 -05:00

259 lines
9.2 KiB
C#

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();
}
}