mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
@@ -673,72 +673,88 @@ public class AdminController : ControllerBase
|
|||||||
string? manualMappingType = null;
|
string? manualMappingType = null;
|
||||||
string? manualMappingId = null;
|
string? manualMappingId = null;
|
||||||
|
|
||||||
// Check for external manual mapping
|
// FIRST: Check for manual Jellyfin mapping
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
{
|
{
|
||||||
try
|
// Manual Jellyfin mapping exists - this track is definitely local
|
||||||
{
|
isLocal = true;
|
||||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
isManualMapping = true;
|
||||||
var extRoot = extDoc.RootElement;
|
manualMappingType = "jellyfin";
|
||||||
|
manualMappingId = manualJellyfinId;
|
||||||
string? provider = null;
|
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
|
||||||
string? externalId = null;
|
track.Title, manualJellyfinId);
|
||||||
|
|
||||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
|
||||||
{
|
|
||||||
provider = providerEl.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extRoot.TryGetProperty("id", out var idEl))
|
|
||||||
{
|
|
||||||
externalId = idEl.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
|
||||||
{
|
|
||||||
// External manual mapping exists
|
|
||||||
isLocal = false;
|
|
||||||
externalProvider = provider;
|
|
||||||
isManualMapping = true;
|
|
||||||
manualMappingType = "external";
|
|
||||||
manualMappingId = externalId;
|
|
||||||
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
|
|
||||||
track.Title, provider, externalId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
// If no manual mapping, try fuzzy matching with local tracks
|
|
||||||
if (!isManualMapping && localTracks.Count > 0)
|
|
||||||
{
|
{
|
||||||
var bestMatch = localTracks
|
// Check for external manual mapping
|
||||||
.Select(local => new
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||||
{
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
Local = local,
|
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
|
||||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
|
||||||
})
|
|
||||||
.Select(x => new
|
|
||||||
{
|
|
||||||
x.Local,
|
|
||||||
x.TitleScore,
|
|
||||||
x.ArtistScore,
|
|
||||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
|
||||||
})
|
|
||||||
.OrderByDescending(x => x.TotalScore)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
// Use 70% threshold (same as playback matching)
|
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
|
||||||
{
|
{
|
||||||
isLocal = true;
|
try
|
||||||
|
{
|
||||||
|
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||||
|
var extRoot = extDoc.RootElement;
|
||||||
|
|
||||||
|
string? provider = null;
|
||||||
|
string? externalId = null;
|
||||||
|
|
||||||
|
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||||
|
{
|
||||||
|
provider = providerEl.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extRoot.TryGetProperty("id", out var idEl))
|
||||||
|
{
|
||||||
|
externalId = idEl.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||||
|
{
|
||||||
|
// External manual mapping exists
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = provider;
|
||||||
|
isManualMapping = true;
|
||||||
|
manualMappingType = "external";
|
||||||
|
manualMappingId = externalId;
|
||||||
|
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
|
||||||
|
track.Title, provider, externalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (localTracks.Count > 0)
|
||||||
|
{
|
||||||
|
// SECOND: No manual mapping, try fuzzy matching with local tracks
|
||||||
|
var bestMatch = localTracks
|
||||||
|
.Select(local => new
|
||||||
|
{
|
||||||
|
Local = local,
|
||||||
|
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
||||||
|
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
||||||
|
})
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Local,
|
||||||
|
x.TitleScore,
|
||||||
|
x.ArtistScore,
|
||||||
|
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.TotalScore)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
// Use 70% threshold (same as playback matching)
|
||||||
|
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
||||||
|
{
|
||||||
|
isLocal = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,46 +840,57 @@ public class AdminController : ControllerBase
|
|||||||
bool? isLocal = null;
|
bool? isLocal = null;
|
||||||
string? externalProvider = null;
|
string? externalProvider = null;
|
||||||
|
|
||||||
// Check for external manual mapping
|
// Check for manual Jellyfin mapping
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
{
|
{
|
||||||
try
|
isLocal = true;
|
||||||
{
|
|
||||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
|
||||||
var extRoot = extDoc.RootElement;
|
|
||||||
|
|
||||||
string? provider = null;
|
|
||||||
|
|
||||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
|
||||||
{
|
|
||||||
provider = providerEl.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(provider))
|
|
||||||
{
|
|
||||||
isLocal = false;
|
|
||||||
externalProvider = provider;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
|
||||||
{
|
|
||||||
// Track is externally matched (search succeeded)
|
|
||||||
isLocal = false;
|
|
||||||
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Track is missing (search failed)
|
// Check for external manual mapping
|
||||||
isLocal = null;
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||||
externalProvider = null;
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||||
|
var extRoot = extDoc.RootElement;
|
||||||
|
|
||||||
|
string? provider = null;
|
||||||
|
|
||||||
|
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||||
|
{
|
||||||
|
provider = providerEl.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(provider))
|
||||||
|
{
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||||
|
{
|
||||||
|
// Track is externally matched (search succeeded)
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Track is missing (search failed)
|
||||||
|
isLocal = 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,26 +1197,50 @@ 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
|
||||||
{
|
{
|
||||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
string? normalizedProvider = null;
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
|
||||||
var normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
|
||||||
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
|
|
||||||
await _cache.SetAsync(externalMappingKey, externalMapping);
|
|
||||||
|
|
||||||
// Also save to file for persistence across restarts
|
if (hasJellyfinMapping)
|
||||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
|
{
|
||||||
|
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||||
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||||
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
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)
|
||||||
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||||
|
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||||
|
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
|
||||||
|
await _cache.SetAsync(externalMappingKey, externalMapping);
|
||||||
|
|
||||||
|
// Also save to file for persistence across restarts
|
||||||
|
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
|
||||||
|
|
||||||
|
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {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,33 +1278,36 @@ 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;
|
||||||
|
|
||||||
try
|
if (hasExternalMapping && normalizedProvider != null)
|
||||||
{
|
{
|
||||||
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
try
|
||||||
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
|
||||||
|
|
||||||
if (externalSong != null)
|
|
||||||
{
|
{
|
||||||
trackTitle = externalSong.Title;
|
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
||||||
trackArtist = externalSong.Artist;
|
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
||||||
trackAlbum = externalSong.Album;
|
|
||||||
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
|
if (externalSong != null)
|
||||||
|
{
|
||||||
|
trackTitle = externalSong.Title;
|
||||||
|
trackArtist = externalSong.Artist;
|
||||||
|
trackAlbum = externalSong.Album;
|
||||||
|
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
|
||||||
|
normalizedProvider, request.ExternalId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
|
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
||||||
normalizedProvider, request.ExternalId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user