From 0793c4614bfd924c23cadb8677eded2c5754ca2b Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Wed, 4 Feb 2026 16:44:35 -0500 Subject: [PATCH] Add file-based caching for MusicBrainz genres - Dual-layer caching: Redis (fast) + file system (persistent) - File cache survives container restarts - 30-day cache expiration for both layers - Negative result caching to avoid repeated failed lookups - Safe file names using base64 encoding - Automatic cache restoration to Redis on startup - Cache directory: /app/cache/genres --- .../Services/Common/GenreEnrichmentService.cs | 123 +++++++++++++++++- 1 file changed, 117 insertions(+), 6 deletions(-) diff --git a/allstarr/Services/Common/GenreEnrichmentService.cs b/allstarr/Services/Common/GenreEnrichmentService.cs index c8e0ce5..d73077b 100644 --- a/allstarr/Services/Common/GenreEnrichmentService.cs +++ b/allstarr/Services/Common/GenreEnrichmentService.cs @@ -1,6 +1,7 @@ using allstarr.Models.Domain; using allstarr.Services.MusicBrainz; using allstarr.Services.Common; +using System.Text.Json; namespace allstarr.Services.Common; @@ -13,6 +14,7 @@ public class GenreEnrichmentService private readonly RedisCacheService _cache; private readonly ILogger _logger; private const string GenreCachePrefix = "genre:"; + private const string GenreCacheDirectory = "/app/cache/genres"; private static readonly TimeSpan GenreCacheDuration = TimeSpan.FromDays(30); public GenreEnrichmentService( @@ -23,6 +25,9 @@ public class GenreEnrichmentService _musicBrainz = musicBrainz; _cache = cache; _logger = logger; + + // Ensure cache directory exists + Directory.CreateDirectory(GenreCacheDirectory); } /// @@ -37,18 +42,32 @@ public class GenreEnrichmentService return; } - // Check cache first - var cacheKey = $"{GenreCachePrefix}{song.Title}:{song.Artist}"; - var cachedGenre = await _cache.GetAsync(cacheKey); + var cacheKey = $"{song.Title}:{song.Artist}"; + + // Check Redis cache first + var redisCacheKey = $"{GenreCachePrefix}{cacheKey}"; + var cachedGenre = await _cache.GetAsync(redisCacheKey); if (cachedGenre != null) { song.Genre = cachedGenre; - _logger.LogDebug("Using cached genre for {Title} - {Artist}: {Genre}", + _logger.LogDebug("Using Redis cached genre for {Title} - {Artist}: {Genre}", song.Title, song.Artist, cachedGenre); return; } + // Check file cache + var fileCachedGenre = await GetFromFileCacheAsync(cacheKey); + if (fileCachedGenre != null) + { + song.Genre = fileCachedGenre; + // Restore to Redis cache + await _cache.SetAsync(redisCacheKey, fileCachedGenre, GenreCacheDuration); + _logger.LogDebug("Using file cached genre for {Title} - {Artist}: {Genre}", + song.Title, song.Artist, fileCachedGenre); + return; + } + // Fetch from MusicBrainz try { @@ -59,12 +78,18 @@ public class GenreEnrichmentService // Use the top genre song.Genre = genres[0]; - // Cache the result - await _cache.SetAsync(cacheKey, song.Genre, GenreCacheDuration); + // Cache in both Redis and file + await _cache.SetAsync(redisCacheKey, song.Genre, GenreCacheDuration); + await SaveToFileCacheAsync(cacheKey, song.Genre); _logger.LogInformation("Enriched {Title} - {Artist} with genre: {Genre}", song.Title, song.Artist, song.Genre); } + else + { + // Cache negative result to avoid repeated lookups + await SaveToFileCacheAsync(cacheKey, ""); + } } catch (Exception ex) { @@ -114,4 +139,90 @@ public class GenreEnrichmentService .Select(kvp => kvp.Key) .ToList(); } + + /// + /// Gets genre from file cache. + /// + private async Task GetFromFileCacheAsync(string cacheKey) + { + try + { + var fileName = GetCacheFileName(cacheKey); + var filePath = Path.Combine(GenreCacheDirectory, fileName); + + if (!File.Exists(filePath)) + { + return null; + } + + // Check if cache is expired (30 days) + var fileInfo = new FileInfo(filePath); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > GenreCacheDuration) + { + File.Delete(filePath); + return null; + } + + var json = await File.ReadAllTextAsync(filePath); + var cacheEntry = JsonSerializer.Deserialize(json); + + return cacheEntry?.Genre; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read genre from file cache for {Key}", cacheKey); + return null; + } + } + + /// + /// Saves genre to file cache. + /// + private async Task SaveToFileCacheAsync(string cacheKey, string genre) + { + try + { + var fileName = GetCacheFileName(cacheKey); + var filePath = Path.Combine(GenreCacheDirectory, fileName); + + var cacheEntry = new GenreCacheEntry + { + CacheKey = cacheKey, + Genre = genre, + CachedAt = DateTime.UtcNow + }; + + var json = JsonSerializer.Serialize(cacheEntry, new JsonSerializerOptions + { + WriteIndented = true + }); + + await File.WriteAllTextAsync(filePath, json); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to save genre to file cache for {Key}", cacheKey); + } + } + + /// + /// Generates a safe file name from cache key. + /// + private static string GetCacheFileName(string cacheKey) + { + // Use base64 encoding to create safe file names + var bytes = System.Text.Encoding.UTF8.GetBytes(cacheKey); + var base64 = Convert.ToBase64String(bytes) + .Replace("+", "-") + .Replace("/", "_") + .Replace("=", ""); + return $"{base64}.json"; + } + + private class GenreCacheEntry + { + public string CacheKey { get; set; } = ""; + public string Genre { get; set; } = ""; + public DateTime CachedAt { get; set; } + } }