diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 4e3c1a7..b256802 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -930,19 +930,25 @@ public class AdminController : ControllerBase { if (hasJellyfinMapping) { - // Store Jellyfin mapping in cache + // Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent) var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}"; - await _cache.SetAsync(mappingKey, request.JellyfinId!, TimeSpan.FromDays(365)); + await _cache.SetAsync(mappingKey, request.JellyfinId!); + + // Also save to file for persistence across restarts + await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null); _logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}", decodedName, request.SpotifyId, request.JellyfinId); } else { - // Store external mapping in cache + // Store external mapping in cache (NO EXPIRATION - manual mappings are permanent) var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}"; var externalMapping = new { provider = request.ExternalProvider, id = request.ExternalId }; - await _cache.SetAsync(externalMappingKey, externalMapping, TimeSpan.FromDays(365)); + await _cache.SetAsync(externalMappingKey, externalMapping); + + // Also save to file for persistence across restarts + await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, request.ExternalProvider!, request.ExternalId!); _logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}", decodedName, request.SpotifyId, request.ExternalProvider, request.ExternalId); @@ -2486,6 +2492,60 @@ public class AdminController : ControllerBase } #endregion + + #region Private Helper Methods + + /// + /// Saves a manual mapping to file for persistence across restarts. + /// Manual mappings NEVER expire - they are permanent user decisions. + /// + private async Task SaveManualMappingToFileAsync( + string playlistName, + string spotifyId, + string? jellyfinId, + string? externalProvider, + string? externalId) + { + try + { + var mappingsDir = "/app/cache/mappings"; + Directory.CreateDirectory(mappingsDir); + + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); + var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json"); + + // Load existing mappings + var mappings = new Dictionary(); + if (System.IO.File.Exists(filePath)) + { + var json = await System.IO.File.ReadAllTextAsync(filePath); + mappings = JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + } + + // Add or update mapping + mappings[spotifyId] = new ManualMappingEntry + { + SpotifyId = spotifyId, + JellyfinId = jellyfinId, + ExternalProvider = externalProvider, + ExternalId = externalId, + CreatedAt = DateTime.UtcNow + }; + + // Save back to file + var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true }); + await System.IO.File.WriteAllTextAsync(filePath, updatedJson); + + _logger.LogDebug("💾 Saved manual mapping to file: {Playlist} - {SpotifyId}", playlistName, spotifyId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save manual mapping to file for {Playlist}", playlistName); + } + } + + #endregion } public class ManualMappingRequest @@ -2496,6 +2556,15 @@ public class ManualMappingRequest public string? ExternalId { get; set; } } +public class ManualMappingEntry +{ + public string SpotifyId { get; set; } = ""; + public string? JellyfinId { get; set; } + public string? ExternalProvider { get; set; } + public string? ExternalId { get; set; } + public DateTime CreatedAt { get; set; } +} + public class ConfigUpdateRequest { public Dictionary Updates { get; set; } = new(); diff --git a/allstarr/Services/Common/CacheWarmingService.cs b/allstarr/Services/Common/CacheWarmingService.cs index 082d3d4..2869f7e 100644 --- a/allstarr/Services/Common/CacheWarmingService.cs +++ b/allstarr/Services/Common/CacheWarmingService.cs @@ -13,6 +13,7 @@ public class CacheWarmingService : IHostedService private readonly ILogger _logger; private const string GenreCacheDirectory = "/app/cache/genres"; private const string PlaylistCacheDirectory = "/app/cache/spotify"; + private const string MappingsCacheDirectory = "/app/cache/mappings"; public CacheWarmingService( RedisCacheService cache, @@ -29,6 +30,7 @@ public class CacheWarmingService : IHostedService var startTime = DateTime.UtcNow; var genresWarmed = 0; var playlistsWarmed = 0; + var mappingsWarmed = 0; try { @@ -37,11 +39,14 @@ public class CacheWarmingService : IHostedService // Warm playlist cache playlistsWarmed = await WarmPlaylistCacheAsync(cancellationToken); + + // Warm manual mappings cache + mappingsWarmed = await WarmManualMappingsCacheAsync(cancellationToken); var duration = DateTime.UtcNow - startTime; _logger.LogInformation( - "✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists", - duration.TotalSeconds, genresWarmed, playlistsWarmed); + "✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings", + duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed); } catch (Exception ex) { @@ -203,6 +208,73 @@ public class CacheWarmingService : IHostedService return warmedCount; } + + /// + /// Warms manual mappings cache from file system. + /// Manual mappings NEVER expire - they are permanent user decisions. + /// + private async Task WarmManualMappingsCacheAsync(CancellationToken cancellationToken) + { + if (!Directory.Exists(MappingsCacheDirectory)) + { + return 0; + } + + var files = Directory.GetFiles(MappingsCacheDirectory, "*_mappings.json"); + var warmedCount = 0; + + foreach (var file in files) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + var json = await File.ReadAllTextAsync(file, cancellationToken); + var mappings = JsonSerializer.Deserialize>(json); + + if (mappings != null && mappings.Count > 0) + { + // Extract playlist name from filename + var fileName = Path.GetFileNameWithoutExtension(file); + var playlistName = fileName.Replace("_mappings", ""); + + foreach (var mapping in mappings.Values) + { + if (!string.IsNullOrEmpty(mapping.JellyfinId)) + { + // Jellyfin mapping + var redisKey = $"spotify:manual-map:{playlistName}:{mapping.SpotifyId}"; + await _cache.SetAsync(redisKey, mapping.JellyfinId); + warmedCount++; + } + else if (!string.IsNullOrEmpty(mapping.ExternalProvider) && !string.IsNullOrEmpty(mapping.ExternalId)) + { + // External mapping + var redisKey = $"spotify:external-map:{playlistName}:{mapping.SpotifyId}"; + var externalMapping = new { provider = mapping.ExternalProvider, id = mapping.ExternalId }; + await _cache.SetAsync(redisKey, externalMapping); + warmedCount++; + } + } + + _logger.LogDebug("🔥 Warmed {Count} manual mappings for {Playlist}", + mappings.Count, playlistName); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to warm manual mappings from file: {File}", file); + } + } + + if (warmedCount > 0) + { + _logger.LogInformation("🔥 Warmed {Count} manual mappings from file system", warmedCount); + } + + return warmedCount; + } private class GenreCacheEntry { @@ -221,4 +293,13 @@ public class CacheWarmingService : IHostedService public string MatchType { get; set; } = ""; public Song? MatchedSong { get; set; } } + + private class ManualMappingEntry + { + public string SpotifyId { get; set; } = ""; + public string? JellyfinId { get; set; } + public string? ExternalProvider { get; set; } + public string? ExternalId { get; set; } + public DateTime CreatedAt { get; set; } + } }