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; }
+ }
}