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)
|
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}";
|
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}",
|
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
||||||
decodedName, request.SpotifyId, request.JellyfinId);
|
decodedName, request.SpotifyId, request.JellyfinId);
|
||||||
}
|
}
|
||||||
else
|
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 externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||||
var externalMapping = new { provider = request.ExternalProvider, id = request.ExternalId };
|
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}",
|
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
||||||
decodedName, request.SpotifyId, request.ExternalProvider, request.ExternalId);
|
decodedName, request.SpotifyId, request.ExternalProvider, request.ExternalId);
|
||||||
@@ -2486,6 +2492,60 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#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
|
public class ManualMappingRequest
|
||||||
@@ -2496,6 +2556,15 @@ public class ManualMappingRequest
|
|||||||
public string? ExternalId { get; set; }
|
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 class ConfigUpdateRequest
|
||||||
{
|
{
|
||||||
public Dictionary<string, string> Updates { get; set; } = new();
|
public Dictionary<string, string> Updates { get; set; } = new();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class CacheWarmingService : IHostedService
|
|||||||
private readonly ILogger<CacheWarmingService> _logger;
|
private readonly ILogger<CacheWarmingService> _logger;
|
||||||
private const string GenreCacheDirectory = "/app/cache/genres";
|
private const string GenreCacheDirectory = "/app/cache/genres";
|
||||||
private const string PlaylistCacheDirectory = "/app/cache/spotify";
|
private const string PlaylistCacheDirectory = "/app/cache/spotify";
|
||||||
|
private const string MappingsCacheDirectory = "/app/cache/mappings";
|
||||||
|
|
||||||
public CacheWarmingService(
|
public CacheWarmingService(
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
@@ -29,6 +30,7 @@ public class CacheWarmingService : IHostedService
|
|||||||
var startTime = DateTime.UtcNow;
|
var startTime = DateTime.UtcNow;
|
||||||
var genresWarmed = 0;
|
var genresWarmed = 0;
|
||||||
var playlistsWarmed = 0;
|
var playlistsWarmed = 0;
|
||||||
|
var mappingsWarmed = 0;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -38,10 +40,13 @@ public class CacheWarmingService : IHostedService
|
|||||||
// Warm playlist cache
|
// Warm playlist cache
|
||||||
playlistsWarmed = await WarmPlaylistCacheAsync(cancellationToken);
|
playlistsWarmed = await WarmPlaylistCacheAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Warm manual mappings cache
|
||||||
|
mappingsWarmed = await WarmManualMappingsCacheAsync(cancellationToken);
|
||||||
|
|
||||||
var duration = DateTime.UtcNow - startTime;
|
var duration = DateTime.UtcNow - startTime;
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists",
|
"✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings",
|
||||||
duration.TotalSeconds, genresWarmed, playlistsWarmed);
|
duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -204,6 +209,73 @@ public class CacheWarmingService : IHostedService
|
|||||||
return warmedCount;
|
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
|
private class GenreCacheEntry
|
||||||
{
|
{
|
||||||
public string CacheKey { get; set; } = "";
|
public string CacheKey { get; set; } = "";
|
||||||
@@ -221,4 +293,13 @@ public class CacheWarmingService : IHostedService
|
|||||||
public string MatchType { get; set; } = "";
|
public string MatchType { get; set; } = "";
|
||||||
public Song? MatchedSong { 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