diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 93b7596..1d37077 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -482,6 +482,9 @@ builder.Services.AddHostedService(); // Register cache cleanup service (only runs when StorageMode is Cache) builder.Services.AddHostedService(); +// Register cache warming service (loads file caches into Redis on startup) +builder.Services.AddHostedService(); + // Register Spotify API client, lyrics service, and settings for direct API access // Configure from environment variables with SPOTIFY_API_ prefix builder.Services.Configure(options => diff --git a/allstarr/Services/Common/CacheWarmingService.cs b/allstarr/Services/Common/CacheWarmingService.cs new file mode 100644 index 0000000..47b1c1f --- /dev/null +++ b/allstarr/Services/Common/CacheWarmingService.cs @@ -0,0 +1,172 @@ +using System.Text.Json; + +namespace allstarr.Services.Common; + +/// +/// Background service that warms up Redis cache from file system on startup. +/// Ensures fast access to cached data after container restarts. +/// +public class CacheWarmingService : IHostedService +{ + private readonly RedisCacheService _cache; + private readonly ILogger _logger; + private const string GenreCacheDirectory = "/app/cache/genres"; + private const string PlaylistCacheDirectory = "/app/cache/spotify"; + + public CacheWarmingService( + RedisCacheService cache, + ILogger logger) + { + _cache = cache; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("🔥 Starting cache warming from file system..."); + + var startTime = DateTime.UtcNow; + var genresWarmed = 0; + var playlistsWarmed = 0; + + try + { + // Warm genre cache + genresWarmed = await WarmGenreCacheAsync(cancellationToken); + + // Warm playlist cache + playlistsWarmed = await WarmPlaylistCacheAsync(cancellationToken); + + var duration = DateTime.UtcNow - startTime; + _logger.LogInformation( + "✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists", + duration.TotalSeconds, genresWarmed, playlistsWarmed); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to warm cache from file system"); + } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + /// Warms genre cache from file system. + /// + private async Task WarmGenreCacheAsync(CancellationToken cancellationToken) + { + if (!Directory.Exists(GenreCacheDirectory)) + { + return 0; + } + + var files = Directory.GetFiles(GenreCacheDirectory, "*.json"); + var warmedCount = 0; + + foreach (var file in files) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + // Check if cache is expired (30 days) + var fileInfo = new FileInfo(file); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromDays(30)) + { + File.Delete(file); + continue; + } + + var json = await File.ReadAllTextAsync(file, cancellationToken); + var cacheEntry = JsonSerializer.Deserialize(json); + + if (cacheEntry != null && !string.IsNullOrEmpty(cacheEntry.CacheKey)) + { + var redisKey = $"genre:{cacheEntry.CacheKey}"; + await _cache.SetAsync(redisKey, cacheEntry.Genre, TimeSpan.FromDays(30)); + warmedCount++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to warm genre cache from file: {File}", file); + } + } + + if (warmedCount > 0) + { + _logger.LogInformation("🔥 Warmed {Count} genre entries from file cache", warmedCount); + } + + return warmedCount; + } + + /// + /// Warms playlist cache from file system. + /// + private async Task WarmPlaylistCacheAsync(CancellationToken cancellationToken) + { + if (!Directory.Exists(PlaylistCacheDirectory)) + { + return 0; + } + + var files = Directory.GetFiles(PlaylistCacheDirectory, "*_items.json"); + var warmedCount = 0; + + foreach (var file in files) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + // Check if cache is expired (24 hours) + var fileInfo = new FileInfo(file); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromHours(24)) + { + continue; // Don't delete, let the normal flow handle it + } + + var json = await File.ReadAllTextAsync(file, cancellationToken); + var items = JsonSerializer.Deserialize>>(json); + + if (items != null && items.Count > 0) + { + // Extract playlist name from filename + var fileName = Path.GetFileNameWithoutExtension(file); + var playlistName = fileName.Replace("_items", ""); + + var redisKey = $"spotify:playlist:items:{playlistName}"; + await _cache.SetAsync(redisKey, items, TimeSpan.FromHours(24)); + warmedCount++; + + _logger.LogDebug("🔥 Warmed playlist cache for {Playlist} ({Count} items)", + playlistName, items.Count); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to warm playlist cache from file: {File}", file); + } + } + + if (warmedCount > 0) + { + _logger.LogInformation("🔥 Warmed {Count} playlist caches from file system", warmedCount); + } + + return warmedCount; + } + + private class GenreCacheEntry + { + public string CacheKey { get; set; } = ""; + public string Genre { get; set; } = ""; + public DateTime CachedAt { get; set; } + } +}