diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 5ac01a7..ee5727c 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -566,6 +566,10 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +// Register lyrics prefetch service (prefetches lyrics for all playlist tracks) +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + // Register MusicBrainz service for metadata enrichment builder.Services.Configure(options => { diff --git a/allstarr/Services/Common/CacheWarmingService.cs b/allstarr/Services/Common/CacheWarmingService.cs index 2869f7e..c5eeb6d 100644 --- a/allstarr/Services/Common/CacheWarmingService.cs +++ b/allstarr/Services/Common/CacheWarmingService.cs @@ -11,15 +11,19 @@ public class CacheWarmingService : IHostedService { private readonly RedisCacheService _cache; private readonly ILogger _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 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; } + + /// + /// Warms lyrics cache from file system using the LyricsPrefetchService. + /// + private async Task WarmLyricsCacheAsync(CancellationToken cancellationToken) + { + try + { + // Get the LyricsPrefetchService from DI + using var scope = _serviceProvider.CreateScope(); + var lyricsPrefetchService = scope.ServiceProvider.GetService(); + + 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 { diff --git a/allstarr/Services/Lyrics/LyricsPrefetchService.cs b/allstarr/Services/Lyrics/LyricsPrefetchService.cs new file mode 100644 index 0000000..e219ada --- /dev/null +++ b/allstarr/Services/Lyrics/LyricsPrefetchService.cs @@ -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; + +/// +/// 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. +/// +public class LyricsPrefetchService : BackgroundService +{ + private readonly SpotifyImportSettings _spotifySettings; + private readonly LrclibService _lrclibService; + private readonly SpotifyPlaylistFetcher _playlistFetcher; + private readonly RedisCacheService _cache; + private readonly ILogger _logger; + private readonly string _lyricsCacheDir = "/app/cache/lyrics"; + private const int DelayBetweenRequestsMs = 500; // 500ms = 2 requests/second to be respectful + + public LyricsPrefetchService( + IOptions spotifySettings, + LrclibService lrclibService, + SpotifyPlaylistFetcher playlistFetcher, + RedisCacheService cache, + ILogger 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); + } + } + + /// + /// Loads lyrics from file cache into Redis on startup + /// + 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(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(); + } +}