mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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
This commit is contained in:
@@ -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<GenreEnrichmentService> _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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -37,18 +42,32 @@ public class GenreEnrichmentService
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
var cacheKey = $"{GenreCachePrefix}{song.Title}:{song.Artist}";
|
||||
var cachedGenre = await _cache.GetAsync<string>(cacheKey);
|
||||
var cacheKey = $"{song.Title}:{song.Artist}";
|
||||
|
||||
// Check Redis cache first
|
||||
var redisCacheKey = $"{GenreCachePrefix}{cacheKey}";
|
||||
var cachedGenre = await _cache.GetAsync<string>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets genre from file cache.
|
||||
/// </summary>
|
||||
private async Task<string?> 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<GenreCacheEntry>(json);
|
||||
|
||||
return cacheEntry?.Genre;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read genre from file cache for {Key}", cacheKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves genre to file cache.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a safe file name from cache key.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user