mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Make manual mappings permanent and persist to file
- Manual mappings now have NO expiration (permanent in Redis) - Save manual mappings to /app/cache/mappings/*.json files - Load manual mappings on startup via CacheWarmingService - Manual mappings are first-order and survive restarts/cache clears - User decisions are now truly permanent
This commit is contained in:
@@ -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
|
||||
|
||||
/// <summary>
|
||||
/// Saves a manual mapping to file for persistence across restarts.
|
||||
/// Manual mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
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<string, ManualMappingEntry>();
|
||||
if (System.IO.File.Exists(filePath))
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json)
|
||||
?? new Dictionary<string, ManualMappingEntry>();
|
||||
}
|
||||
|
||||
// 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<string, string> Updates { get; set; } = new();
|
||||
|
||||
@@ -13,6 +13,7 @@ public class CacheWarmingService : IHostedService
|
||||
private readonly ILogger<CacheWarmingService> _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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warms manual mappings cache from file system.
|
||||
/// Manual mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
private async Task<int> 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<Dictionary<string, ManualMappingEntry>>(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; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user