Add file-based persistence for Spotify missing tracks cache

- Save missing tracks to /app/cache/spotify/*.json files
- 24-hour TTL for file cache
- Fallback to file cache when Redis is empty/restarted
- Controller checks file cache before returning empty playlists
- Ensures playlist data persists across Redis restarts
This commit is contained in:
2026-02-01 11:07:19 -05:00
parent da1d28d292
commit ae8afa20f8
2 changed files with 133 additions and 1 deletions

View File

@@ -1962,6 +1962,20 @@ public class JellyfinController : ControllerBase
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}"; var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey); var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
// Fallback to file cache if Redis is empty
if (missingTracks == null || missingTracks.Count == 0)
{
missingTracks = await LoadMissingTracksFromFile(spotifyPlaylistName);
// If we loaded from file, restore to Redis
if (missingTracks != null && missingTracks.Count > 0)
{
await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromHours(24));
_logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist}",
missingTracks.Count, spotifyPlaylistName);
}
}
if (missingTracks == null || missingTracks.Count == 0) if (missingTracks == null || missingTracks.Count == 0)
{ {
_logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks", _logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks",
@@ -2069,6 +2083,44 @@ public class JellyfinController : ControllerBase
} }
} }
/// <summary>
/// Loads missing tracks from file cache as fallback when Redis is empty.
/// </summary>
private async Task<List<allstarr.Models.Spotify.MissingTrack>?> LoadMissingTracksFromFile(string playlistName)
{
try
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_missing.json");
if (!System.IO.File.Exists(filePath))
{
_logger.LogDebug("No file cache found for {Playlist} at {Path}", playlistName, filePath);
return null;
}
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
if (fileAge > TimeSpan.FromHours(24))
{
_logger.LogDebug("File cache for {Playlist} is too old ({Age:F1}h)", playlistName, fileAge.TotalHours);
return null;
}
var json = await System.IO.File.ReadAllTextAsync(filePath);
var tracks = JsonSerializer.Deserialize<List<allstarr.Models.Spotify.MissingTrack>>(json);
_logger.LogInformation("Loaded {Count} missing tracks from file cache for {Playlist} (age: {Age:F1}h)",
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
return tracks;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load missing tracks from file for {Playlist}", playlistName);
return null;
}
}
/// <summary> /// <summary>
/// Manual trigger endpoint to force fetch Spotify missing tracks. /// Manual trigger endpoint to force fetch Spotify missing tracks.
/// GET /spotify/sync?api_key=YOUR_KEY /// GET /spotify/sync?api_key=YOUR_KEY

View File

@@ -17,6 +17,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private bool _hasRunOnce = false; private bool _hasRunOnce = false;
private Dictionary<string, string> _playlistIdToName = new(); private Dictionary<string, string> _playlistIdToName = new();
private const string CacheDirectory = "/app/cache/spotify";
public SpotifyMissingTracksFetcher( public SpotifyMissingTracksFetcher(
IOptions<SpotifyImportSettings> spotifySettings, IOptions<SpotifyImportSettings> spotifySettings,
@@ -39,6 +40,9 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger.LogInformation("========================================"); _logger.LogInformation("========================================");
_logger.LogInformation("SpotifyMissingTracksFetcher: Starting up..."); _logger.LogInformation("SpotifyMissingTracksFetcher: Starting up...");
// Ensure cache directory exists
Directory.CreateDirectory(CacheDirectory);
if (!_spotifySettings.Value.Enabled) if (!_spotifySettings.Value.Enabled)
{ {
_logger.LogInformation("Spotify playlist injection is DISABLED"); _logger.LogInformation("Spotify playlist injection is DISABLED");
@@ -125,9 +129,28 @@ public class SpotifyMissingTracksFetcher : BackgroundService
private async Task<bool> ShouldRunOnStartupAsync() private async Task<bool> ShouldRunOnStartupAsync()
{ {
// Check if any playlist has cached data from the last 24 hours // Check file cache first, then Redis
foreach (var playlistName in _playlistIdToName.Values) foreach (var playlistName in _playlistIdToName.Values)
{ {
var filePath = GetCacheFilePath(playlistName);
if (File.Exists(filePath))
{
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
if (fileAge < TimeSpan.FromHours(24))
{
_logger.LogInformation("Found recent file cache for {Playlist} (age: {Age:F1}h)",
playlistName, fileAge.TotalHours);
// Load from file into Redis if not already there
var cacheKey = $"spotify:missing:{playlistName}";
if (!await _cache.ExistsAsync(cacheKey))
{
await LoadFromFileCache(playlistName);
}
return false;
}
}
var cacheKey = $"spotify:missing:{playlistName}"; var cacheKey = $"spotify:missing:{playlistName}";
if (await _cache.ExistsAsync(cacheKey)) if (await _cache.ExistsAsync(cacheKey))
{ {
@@ -137,6 +160,59 @@ public class SpotifyMissingTracksFetcher : BackgroundService
return true; // No recent data, should fetch return true; // No recent data, should fetch
} }
private string GetCacheFilePath(string playlistName)
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
return Path.Combine(CacheDirectory, $"{safeName}_missing.json");
}
private async Task LoadFromFileCache(string playlistName)
{
try
{
var filePath = GetCacheFilePath(playlistName);
if (!File.Exists(filePath))
return;
var json = await File.ReadAllTextAsync(filePath);
var tracks = JsonSerializer.Deserialize<List<MissingTrack>>(json);
if (tracks != null && tracks.Count > 0)
{
var cacheKey = $"spotify:missing:{playlistName}";
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
var ttl = TimeSpan.FromHours(24) - fileAge;
if (ttl > TimeSpan.Zero)
{
await _cache.SetAsync(cacheKey, tracks, ttl);
_logger.LogInformation("Loaded {Count} tracks from file cache for {Playlist}",
tracks.Count, playlistName);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load file cache for {Playlist}", playlistName);
}
}
private async Task SaveToFileCache(string playlistName, List<MissingTrack> tracks)
{
try
{
var filePath = GetCacheFilePath(playlistName);
var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("Saved {Count} tracks to file cache for {Playlist}",
tracks.Count, playlistName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save file cache for {Playlist}", playlistName);
}
}
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken) private async Task FetchMissingTracksAsync(CancellationToken cancellationToken)
{ {
var settings = _spotifySettings.Value; var settings = _spotifySettings.Value;
@@ -262,7 +338,11 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (tracks.Count > 0) if (tracks.Count > 0)
{ {
var cacheKey = $"spotify:missing:{playlistName}"; var cacheKey = $"spotify:missing:{playlistName}";
// Save to both Redis and file
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24)); await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
await SaveToFileCache(playlistName, tracks);
_logger.LogInformation( _logger.LogInformation(
"✓ Cached {Count} missing tracks for {Playlist} from {Filename}", "✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
tracks.Count, playlistName, filename); tracks.Count, playlistName, filename);