feat: re-add manual local Jellyfin track mapping support

- Allow mapping Spotify tracks to local Jellyfin tracks via JellyfinId
- Supports both local (Jellyfin) and external (provider) manual mappings
- Local mappings take priority over fuzzy matching
- Helps when automatic matching fails for tracks already in Jellyfin library
This commit is contained in:
2026-02-06 20:29:29 -05:00
parent 3ffa09dcfa
commit 791e6a69d9
2 changed files with 219 additions and 129 deletions

View File

@@ -673,6 +673,22 @@ public class AdminController : ControllerBase
string? manualMappingType = null; string? manualMappingType = null;
string? manualMappingId = null; string? manualMappingId = null;
// FIRST: Check for manual Jellyfin mapping
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{
// Manual Jellyfin mapping exists - this track is definitely local
isLocal = true;
isManualMapping = true;
manualMappingType = "jellyfin";
manualMappingId = manualJellyfinId;
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
track.Title, manualJellyfinId);
}
else
{
// Check for external manual mapping // Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
@@ -714,10 +730,9 @@ public class AdminController : ControllerBase
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title); _logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
} }
} }
else if (localTracks.Count > 0)
// If no manual mapping, try fuzzy matching with local tracks
if (!isManualMapping && localTracks.Count > 0)
{ {
// SECOND: No manual mapping, try fuzzy matching with local tracks
var bestMatch = localTracks var bestMatch = localTracks
.Select(local => new .Select(local => new
{ {
@@ -741,6 +756,7 @@ public class AdminController : ControllerBase
isLocal = true; isLocal = true;
} }
} }
}
// If not local, check if it's externally matched or missing // If not local, check if it's externally matched or missing
if (isLocal != true) if (isLocal != true)
@@ -824,6 +840,16 @@ public class AdminController : ControllerBase
bool? isLocal = null; bool? isLocal = null;
string? externalProvider = null; string? externalProvider = null;
// Check for manual Jellyfin mapping
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{
isLocal = true;
}
else
{
// Check for external manual mapping // Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
@@ -865,6 +891,7 @@ public class AdminController : ControllerBase
isLocal = null; isLocal = null;
externalProvider = null; externalProvider = null;
} }
}
tracksWithStatus.Add(new tracksWithStatus.Add(new
{ {
@@ -1158,8 +1185,7 @@ public class AdminController : ControllerBase
} }
/// <summary> /// <summary>
/// Save manual external track mapping (SquidWTF/Deezer/Qobuz) /// Save manual track mapping (local Jellyfin or external provider)
/// Note: Local Jellyfin mappings should be done via Spotify Import plugin
/// </summary> /// </summary>
[HttpPost("playlists/{name}/map")] [HttpPost("playlists/{name}/map")]
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request) public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
@@ -1171,18 +1197,41 @@ public class AdminController : ControllerBase
return BadRequest(new { error = "SpotifyId is required" }); return BadRequest(new { error = "SpotifyId is required" });
} }
// Only external mappings are supported now // Validate that either Jellyfin mapping or external mapping is provided
// Local Jellyfin mappings should be done via Spotify Import plugin var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId);
if (string.IsNullOrWhiteSpace(request.ExternalProvider) || string.IsNullOrWhiteSpace(request.ExternalId)) var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId);
if (!hasJellyfinMapping && !hasExternalMapping)
{ {
return BadRequest(new { error = "ExternalProvider and ExternalId are required. Use Spotify Import plugin for local Jellyfin mappings." }); return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" });
}
if (hasJellyfinMapping && hasExternalMapping)
{
return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" });
} }
try try
{
string? normalizedProvider = null;
if (hasJellyfinMapping)
{
// 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!);
// 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 (NO EXPIRATION - manual mappings are permanent) // 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 normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId }; var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
await _cache.SetAsync(externalMappingKey, externalMapping); await _cache.SetAsync(externalMappingKey, externalMapping);
@@ -1191,6 +1240,7 @@ public class AdminController : ControllerBase
_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, normalizedProvider, request.ExternalId); decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
}
// Clear all related caches to force rebuild // Clear all related caches to force rebuild
var matchedCacheKey = $"spotify:matched:{decodedName}"; var matchedCacheKey = $"spotify:matched:{decodedName}";
@@ -1228,11 +1278,13 @@ public class AdminController : ControllerBase
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName); _logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
// Fetch external provider track details to return to the UI // Fetch external provider track details to return to the UI (only for external mappings)
string? trackTitle = null; string? trackTitle = null;
string? trackArtist = null; string? trackArtist = null;
string? trackAlbum = null; string? trackAlbum = null;
if (hasExternalMapping && normalizedProvider != null)
{
try try
{ {
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>(); var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
@@ -1255,6 +1307,7 @@ public class AdminController : ControllerBase
{ {
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved"); _logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
} }
}
// Trigger immediate playlist rebuild with the new mapping // Trigger immediate playlist rebuild with the new mapping
if (_matchingService != null) if (_matchingService != null)

View File

@@ -829,7 +829,44 @@ public class SpotifyTrackMatchingService : BackgroundService
JsonElement? matchedJellyfinItem = null; JsonElement? matchedJellyfinItem = null;
string? matchedKey = null; string? matchedKey = null;
// Check for external manual mapping // FIRST: Check for manual Jellyfin mapping
var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{
// Find the Jellyfin item by ID
foreach (var kvp in jellyfinItemsByName)
{
var item = kvp.Value;
if (item.TryGetProperty("Id", out var idEl) && idEl.GetString() == manualJellyfinId)
{
matchedJellyfinItem = item;
matchedKey = kvp.Key;
_logger.LogInformation("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
spotifyTrack.Title, manualJellyfinId);
break;
}
}
if (matchedJellyfinItem.HasValue)
{
// Use the raw Jellyfin item (preserves ALL metadata)
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
if (itemDict != null)
{
finalItems.Add(itemDict);
if (matchedKey != null)
{
usedJellyfinItems.Add(matchedKey);
}
localUsedCount++;
}
continue; // Skip to next track
}
}
// SECOND: Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}"; var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);