mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
- Added 5-minute cooldown between matching runs to prevent spam - Improved cache checking to skip unnecessary matching - Persist matched tracks cache to file for faster restarts - Cache warming service now loads matched tracks on startup - Suppress verbose HTTP client logs (LogicalHandler/ClientHandler) - Only run matching when cache is missing or manual mappings added
225 lines
7.6 KiB
C#
225 lines
7.6 KiB
C#
using System.Text.Json;
|
|
using allstarr.Models.Domain;
|
|
|
|
namespace allstarr.Services.Common;
|
|
|
|
/// <summary>
|
|
/// Background service that warms up Redis cache from file system on startup.
|
|
/// Ensures fast access to cached data after container restarts.
|
|
/// </summary>
|
|
public class CacheWarmingService : IHostedService
|
|
{
|
|
private readonly RedisCacheService _cache;
|
|
private readonly ILogger<CacheWarmingService> _logger;
|
|
private const string GenreCacheDirectory = "/app/cache/genres";
|
|
private const string PlaylistCacheDirectory = "/app/cache/spotify";
|
|
|
|
public CacheWarmingService(
|
|
RedisCacheService cache,
|
|
ILogger<CacheWarmingService> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Warms genre cache from file system.
|
|
/// </summary>
|
|
private async Task<int> 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<GenreCacheEntry>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Warms playlist cache from file system.
|
|
/// </summary>
|
|
private async Task<int> WarmPlaylistCacheAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!Directory.Exists(PlaylistCacheDirectory))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var itemsFiles = Directory.GetFiles(PlaylistCacheDirectory, "*_items.json");
|
|
var matchedFiles = Directory.GetFiles(PlaylistCacheDirectory, "*_matched.json");
|
|
var warmedCount = 0;
|
|
|
|
// Warm playlist items cache
|
|
foreach (var file in itemsFiles)
|
|
{
|
|
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<List<Dictionary<string, object?>>>(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 items cache for {Playlist} ({Count} items)",
|
|
playlistName, items.Count);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to warm playlist items cache from file: {File}", file);
|
|
}
|
|
}
|
|
|
|
// Warm matched tracks cache
|
|
foreach (var file in matchedFiles)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
break;
|
|
|
|
try
|
|
{
|
|
// Check if cache is expired (1 hour)
|
|
var fileInfo = new FileInfo(file);
|
|
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromHours(1))
|
|
{
|
|
continue; // Skip expired matched tracks
|
|
}
|
|
|
|
var json = await File.ReadAllTextAsync(file, cancellationToken);
|
|
var matchedTracks = JsonSerializer.Deserialize<List<MatchedTrack>>(json);
|
|
|
|
if (matchedTracks != null && matchedTracks.Count > 0)
|
|
{
|
|
// Extract playlist name from filename
|
|
var fileName = Path.GetFileNameWithoutExtension(file);
|
|
var playlistName = fileName.Replace("_matched", "");
|
|
|
|
var redisKey = $"spotify:matched:ordered:{playlistName}";
|
|
await _cache.SetAsync(redisKey, matchedTracks, TimeSpan.FromHours(1));
|
|
warmedCount++;
|
|
|
|
_logger.LogDebug("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
|
|
playlistName, matchedTracks.Count);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to warm matched tracks 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; }
|
|
}
|
|
|
|
private class MatchedTrack
|
|
{
|
|
public int Position { get; set; }
|
|
public string SpotifyId { get; set; } = "";
|
|
public string SpotifyTitle { get; set; } = "";
|
|
public string SpotifyArtist { get; set; } = "";
|
|
public string? Isrc { get; set; }
|
|
public string MatchType { get; set; } = "";
|
|
public Song? MatchedSong { get; set; }
|
|
}
|
|
}
|