Remove local Jellyfin manual mapping, keep only external mappings

This commit is contained in:
2026-02-06 12:05:26 -05:00
parent a3830c54c4
commit 28c4f8f5df
3 changed files with 272 additions and 513 deletions

View File

@@ -669,92 +669,76 @@ public class AdminController : ControllerBase
{ {
bool? isLocal = null; bool? isLocal = null;
string? externalProvider = null; string? externalProvider = null;
// FIRST: Check for manual mapping (same as SpotifyTrackMatchingService)
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
bool isManualMapping = false; bool isManualMapping = false;
string? manualMappingType = null; string? manualMappingType = null;
string? manualMappingId = null; string? manualMappingId = null;
if (!string.IsNullOrEmpty(manualJellyfinId)) // Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
{ {
// Manual Jellyfin mapping exists - this track is definitely local try
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
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
{ {
try using var extDoc = JsonDocument.Parse(externalMappingJson);
var extRoot = extDoc.RootElement;
string? provider = null;
string? externalId = null;
if (extRoot.TryGetProperty("provider", out var providerEl))
{ {
using var extDoc = JsonDocument.Parse(externalMappingJson); provider = providerEl.GetString();
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)
if (extRoot.TryGetProperty("id", out var idEl))
{ {
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title); 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);
} }
} }
else if (localTracks.Count > 0) catch (Exception ex)
{ {
// SECOND: No manual mapping, try fuzzy matching _logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
var bestMatch = localTracks }
.Select(local => new }
{
Local = local, // If no manual mapping, try fuzzy matching with local tracks
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title), if (!isManualMapping && localTracks.Count > 0)
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist) {
}) var bestMatch = localTracks
.Select(x => new .Select(local => 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; 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;
} }
} }
@@ -832,62 +816,54 @@ public class AdminController : ControllerBase
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>() fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
); );
// Clear and reuse tracksWithStatus for fallback
tracksWithStatus.Clear();
foreach (var track in spotifyTracks) foreach (var track in spotifyTracks)
{ {
bool? isLocal = null; bool? isLocal = null;
string? externalProvider = null; string? externalProvider = null;
// Check for manual mappings // Check for external manual mapping
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}"; var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey); var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId)) if (!string.IsNullOrEmpty(externalMappingJson))
{ {
isLocal = true; 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 else
{ {
// Check for external manual mapping // Track is missing (search failed)
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; isLocal = null;
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); externalProvider = null;
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
@@ -1111,7 +1087,8 @@ public class AdminController : ControllerBase
} }
/// <summary> /// <summary>
/// Save manual track mapping /// Save manual external track mapping (SquidWTF/Deezer/Qobuz)
/// 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)
@@ -1123,48 +1100,26 @@ public class AdminController : ControllerBase
return BadRequest(new { error = "SpotifyId is required" }); return BadRequest(new { error = "SpotifyId is required" });
} }
// Validate that either Jellyfin mapping or external mapping is provided // Only external mappings are supported now
var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId); // Local Jellyfin mappings should be done via Spotify Import plugin
var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId); if (string.IsNullOrWhiteSpace(request.ExternalProvider) || string.IsNullOrWhiteSpace(request.ExternalId))
if (!hasJellyfinMapping && !hasExternalMapping)
{ {
return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" }); return BadRequest(new { error = "ExternalProvider and ExternalId are required. Use Spotify Import plugin for local Jellyfin mappings." });
}
if (hasJellyfinMapping && hasExternalMapping)
{
return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" });
} }
try try
{ {
if (hasJellyfinMapping) // Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
{ var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent) var normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}"; var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
await _cache.SetAsync(mappingKey, request.JellyfinId!); await _cache.SetAsync(externalMappingKey, externalMapping);
// Also save to file for persistence across restarts // Also save to file for persistence across restarts
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null); await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}", _logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
decodedName, request.SpotifyId, request.JellyfinId); decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
}
else
{
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
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
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}";
@@ -1202,77 +1157,32 @@ 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 the mapped track details to return to the UI // Fetch external provider track details to return to the UI
string? trackTitle = null; string? trackTitle = null;
string? trackArtist = null; string? trackArtist = null;
string? trackAlbum = null; string? trackAlbum = null;
bool isLocalMapping = hasJellyfinMapping;
if (hasJellyfinMapping) try
{ {
// Fetch Jellyfin track details var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
try var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
if (externalSong != null)
{ {
var userId = _jellyfinSettings.UserId; trackTitle = externalSong.Title;
var trackUrl = $"{_jellyfinSettings.Url}/Items/{request.JellyfinId}"; trackArtist = externalSong.Artist;
if (!string.IsNullOrEmpty(userId)) trackAlbum = externalSong.Album;
{ _logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
trackUrl += $"?UserId={userId}";
}
var trackRequest = new HttpRequestMessage(HttpMethod.Get, trackUrl);
trackRequest.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
var response = await _jellyfinHttpClient.SendAsync(trackRequest);
if (response.IsSuccessStatusCode)
{
var trackData = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(trackData);
var track = doc.RootElement;
trackTitle = track.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
trackArtist = track.TryGetProperty("AlbumArtist", out var artistEl) ? artistEl.GetString() :
(track.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0
? artistsEl[0].GetString() : null);
trackAlbum = track.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : null;
}
else
{
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode}", request.JellyfinId, response.StatusCode);
}
} }
catch (Exception ex) else
{ {
_logger.LogWarning(ex, "Failed to fetch mapped track details, but mapping was saved"); _logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
normalizedProvider, request.ExternalId);
} }
} }
else catch (Exception ex)
{ {
// Fetch external provider track details _logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
try
{
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
var normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
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);
}
}
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
@@ -1307,12 +1217,12 @@ public class AdminController : ControllerBase
// Return success with track details if available // Return success with track details if available
var mappedTrack = new var mappedTrack = new
{ {
id = hasJellyfinMapping ? request.JellyfinId : request.ExternalId, id = request.ExternalId,
title = trackTitle ?? "Unknown", title = trackTitle ?? "Unknown",
artist = trackArtist ?? "Unknown", artist = trackArtist ?? "Unknown",
album = trackAlbum ?? "Unknown", album = trackAlbum ?? "Unknown",
isLocal = isLocalMapping, isLocal = false,
externalProvider = hasExternalMapping ? request.ExternalProvider!.ToLowerInvariant() : null externalProvider = request.ExternalProvider!.ToLowerInvariant()
}; };
return Ok(new return Ok(new

View File

@@ -805,172 +805,134 @@ public class SpotifyTrackMatchingService : BackgroundService
var usedJellyfinItems = new HashSet<string>(); var usedJellyfinItems = new HashSet<string>();
var localUsedCount = 0; var localUsedCount = 0;
var externalUsedCount = 0; var externalUsedCount = 0;
var manualLocalCount = 0;
var manualExternalCount = 0; var manualExternalCount = 0;
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{ {
if (cancellationToken.IsCancellationRequested) break; if (cancellationToken.IsCancellationRequested) break;
// FIRST: Check for manual mapping
var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
JsonElement? matchedJellyfinItem = null; JsonElement? matchedJellyfinItem = null;
string? matchedKey = null; string? matchedKey = null;
if (!string.IsNullOrEmpty(manualJellyfinId)) // Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
{ {
// Manual Jellyfin mapping exists - fetch the Jellyfin item by ID
try try
{ {
var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}"; using var doc = JsonDocument.Parse(externalMappingJson);
var (itemResponse, itemStatusCode) = await proxyService.GetJsonAsync(itemUrl, null, headers); var root = doc.RootElement;
if (itemStatusCode == 200 && itemResponse != null) string? provider = null;
string? externalId = null;
if (root.TryGetProperty("provider", out var providerEl))
{ {
matchedJellyfinItem = itemResponse.RootElement; provider = providerEl.GetString();
manualLocalCount++;
_logger.LogDebug("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
spotifyTrack.Title, manualJellyfinId);
} }
else
if (root.TryGetProperty("id", out var idEl))
{ {
_logger.LogWarning("Manual Jellyfin mapping points to invalid Jellyfin ID {Id} for {Title}", externalId = idEl.GetString();
manualJellyfinId, spotifyTrack.Title); }
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{
// Fetch full metadata from the provider instead of using minimal Spotify data
Song? externalSong = null;
try
{
using var metadataScope = _serviceProvider.CreateScope();
var metadataServiceForFetch = metadataScope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
externalSong = await metadataServiceForFetch.GetSongAsync(provider, externalId);
if (externalSong != null)
{
_logger.LogInformation("✓ Fetched full metadata for manual external mapping: {Title} by {Artist}",
externalSong.Title, externalSong.Artist);
}
else
{
_logger.LogWarning("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
provider, externalId);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching metadata for {Provider} ID {ExternalId}, using fallback",
provider, externalId);
}
// Fallback to minimal metadata if fetch failed
if (externalSong == null)
{
externalSong = new Song
{
Id = $"ext-{provider}-song-{externalId}",
Title = spotifyTrack.Title,
Artist = spotifyTrack.PrimaryArtist,
Album = spotifyTrack.Album,
Duration = spotifyTrack.DurationMs / 1000,
Isrc = spotifyTrack.Isrc,
IsLocal = false,
ExternalProvider = provider,
ExternalId = externalId
};
}
var matchedTrack = new MatchedTrack
{
Position = spotifyTrack.Position,
SpotifyId = spotifyTrack.SpotifyId,
MatchedSong = externalSong
};
matchedTracks.Add(matchedTrack);
// Convert external song to Jellyfin item format and add to finalItems
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
finalItems.Add(externalItem);
externalUsedCount++;
manualExternalCount++;
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
spotifyTrack.Title, provider, externalId);
continue; // Skip to next track
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to fetch manually mapped Jellyfin item {Id}", manualJellyfinId); _logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
} }
} }
// Check for external manual mapping if no Jellyfin mapping found // If no manual external mapping, try fuzzy matching with local Jellyfin tracks
if (!matchedJellyfinItem.HasValue) double bestScore = 0;
{
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
{
try
{
using var doc = JsonDocument.Parse(externalMappingJson);
var root = doc.RootElement;
string? provider = null;
string? externalId = null;
if (root.TryGetProperty("provider", out var providerEl))
{
provider = providerEl.GetString();
}
if (root.TryGetProperty("id", out var idEl))
{
externalId = idEl.GetString();
}
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{
// Fetch full metadata from the provider instead of using minimal Spotify data
Song? externalSong = null;
try
{
using var metadataScope = _serviceProvider.CreateScope();
var metadataServiceForFetch = metadataScope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
externalSong = await metadataServiceForFetch.GetSongAsync(provider, externalId);
if (externalSong != null)
{
_logger.LogInformation("✓ Fetched full metadata for manual external mapping: {Title} by {Artist}",
externalSong.Title, externalSong.Artist);
}
else
{
_logger.LogWarning("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
provider, externalId);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching metadata for {Provider} ID {ExternalId}, using fallback",
provider, externalId);
}
// Fallback to minimal metadata if fetch failed
if (externalSong == null)
{
externalSong = new Song
{
Id = $"ext-{provider}-song-{externalId}",
Title = spotifyTrack.Title,
Artist = spotifyTrack.PrimaryArtist,
Album = spotifyTrack.Album,
Duration = spotifyTrack.DurationMs / 1000,
Isrc = spotifyTrack.Isrc,
IsLocal = false,
ExternalProvider = provider,
ExternalId = externalId
};
}
var matchedTrack = new MatchedTrack
{
Position = spotifyTrack.Position,
SpotifyId = spotifyTrack.SpotifyId,
MatchedSong = externalSong
};
matchedTracks.Add(matchedTrack);
// Convert external song to Jellyfin item format and add to finalItems
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
finalItems.Add(externalItem);
externalUsedCount++;
manualExternalCount++;
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
spotifyTrack.Title, provider, externalId);
continue; // Skip to next track
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
}
}
}
// SECOND: If no manual mapping, try fuzzy matching foreach (var kvp in jellyfinItemsByName)
if (!matchedJellyfinItem.HasValue)
{ {
double bestScore = 0; if (usedJellyfinItems.Contains(kvp.Key)) continue;
foreach (var kvp in jellyfinItemsByName) var item = kvp.Value;
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{ {
if (usedJellyfinItems.Contains(kvp.Key)) continue; artist = artistsEl[0].GetString() ?? "";
}
var item = kvp.Value;
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
var artist = ""; var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
{
artist = artistsEl[0].GetString() ?? ""; if (totalScore > bestScore && totalScore >= 70)
} {
bestScore = totalScore;
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title); matchedJellyfinItem = item;
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist); matchedKey = kvp.Key;
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
if (totalScore > bestScore && totalScore >= 70)
{
bestScore = totalScore;
matchedJellyfinItem = item;
matchedKey = kvp.Key;
}
} }
} }
@@ -1012,9 +974,9 @@ public class SpotifyTrackMatchingService : BackgroundService
await SavePlaylistItemsToFileAsync(playlistName, finalItems); await SavePlaylistItemsToFileAsync(playlistName, finalItems);
var manualMappingInfo = ""; var manualMappingInfo = "";
if (manualLocalCount > 0 || manualExternalCount > 0) if (manualExternalCount > 0)
{ {
manualMappingInfo = $" [Manual: {manualLocalCount} local, {manualExternalCount} external]"; manualMappingInfo = $" [Manual external: {manualExternalCount}]";
} }
_logger.LogInformation( _logger.LogInformation(

View File

@@ -686,17 +686,13 @@
</div> </div>
</h2> </h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;"> <p style="color: var(--text-secondary); margin-bottom: 12px;">
Manual mappings override automatic matching. <strong>Local (Jellyfin)</strong> mappings will be phased out in favor of the Spotify Import plugin. Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
</p> </p>
<div id="mappings-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;"> <div id="mappings-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div> <div>
<span style="color: var(--text-secondary);">Total:</span> <span style="color: var(--text-secondary);">Total:</span>
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span> <span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
</div> </div>
<div>
<span style="color: var(--text-secondary);">Jellyfin (Local):</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="mappings-jellyfin">0</span>
</div>
<div> <div>
<span style="color: var(--text-secondary);">External:</span> <span style="color: var(--text-secondary);">External:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--success);" id="mappings-external">0</span> <span style="font-weight: 600; margin-left: 8px; color: var(--success);" id="mappings-external">0</span>
@@ -942,9 +938,9 @@
<!-- Manual Track Mapping Modal --> <!-- Manual Track Mapping Modal -->
<div class="modal" id="manual-map-modal"> <div class="modal" id="manual-map-modal">
<div class="modal-content" style="max-width: 600px;"> <div class="modal-content" style="max-width: 600px;">
<h3>Map Track</h3> <h3>Map Track to External Provider</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;"> <p style="color: var(--text-secondary); margin-bottom: 16px;">
Map this track to either a local Jellyfin track or provide an external provider ID. Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
</p> </p>
<!-- Track Info --> <!-- Track Info -->
@@ -956,41 +952,8 @@
</div> </div>
</div> </div>
<!-- Mapping Type Selection -->
<div class="form-group">
<label>Mapping Type</label>
<select id="map-type-select" onchange="toggleMappingType()" style="width: 100%;">
<option value="jellyfin">Map to Local Jellyfin Track</option>
<option value="external">Map to External Provider ID</option>
</select>
</div>
<!-- Jellyfin Mapping Section -->
<div id="jellyfin-mapping-section">
<div class="form-group">
<label>Search Jellyfin Tracks</label>
<input type="text" id="map-search-query" placeholder="Search by title, artist, or album..." oninput="searchJellyfinTracks()">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Tip: Use commas to search multiple terms (e.g., "It Ain't Easy, David Bowie")
</small>
</div>
<div style="text-align: center; color: var(--text-secondary); margin: 12px 0;">— OR —</div>
<div class="form-group">
<label>Paste Jellyfin Track URL</label>
<input type="text" id="map-jellyfin-url" placeholder="https://jellyfin.example.com/web/#/details?id=..." oninput="extractJellyfinId()">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Paste the full URL from your Jellyfin web interface
</small>
</div>
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">
Type to search for local tracks or paste a Jellyfin URL...
</p>
</div>
</div>
<!-- External Mapping Section --> <!-- External Mapping Section -->
<div id="external-mapping-section" style="display: none;"> <div id="external-mapping-section">
<div class="form-group"> <div class="form-group">
<label>External Provider</label> <label>External Provider</label>
<select id="map-external-provider" style="width: 100%;"> <select id="map-external-provider" style="width: 100%;">
@@ -1012,7 +975,6 @@
<input type="hidden" id="map-playlist-name"> <input type="hidden" id="map-playlist-name">
<input type="hidden" id="map-spotify-id"> <input type="hidden" id="map-spotify-id">
<input type="hidden" id="map-selected-jellyfin-id">
<div class="modal-actions"> <div class="modal-actions">
<button onclick="closeModal('manual-map-modal')">Cancel</button> <button onclick="closeModal('manual-map-modal')">Cancel</button>
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button> <button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
@@ -1388,9 +1350,8 @@
const res = await fetch('/api/admin/mappings/tracks'); const res = await fetch('/api/admin/mappings/tracks');
const data = await res.json(); const data = await res.json();
// Update summary // Update summary (only external now)
document.getElementById('mappings-total').textContent = data.totalCount || 0; document.getElementById('mappings-total').textContent = data.externalCount || 0;
document.getElementById('mappings-jellyfin').textContent = data.jellyfinCount || 0;
document.getElementById('mappings-external').textContent = data.externalCount || 0; document.getElementById('mappings-external').textContent = data.externalCount || 0;
const tbody = document.getElementById('mappings-table-body'); const tbody = document.getElementById('mappings-table-body');
@@ -1400,16 +1361,19 @@
return; return;
} }
tbody.innerHTML = data.mappings.map((m, index) => { // Filter to only show external mappings
const typeColor = m.type === 'jellyfin' ? 'var(--accent)' : 'var(--success)'; const externalMappings = data.mappings.filter(m => m.type === 'external');
const typeBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">${m.type}</span>`;
if (externalMappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No external mappings found. Local Jellyfin mappings should be managed via Spotify Import plugin.</td></tr>';
return;
}
tbody.innerHTML = externalMappings.map((m, index) => {
const typeColor = 'var(--success)';
const typeBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">external</span>`;
let targetDisplay = ''; const targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
if (m.type === 'jellyfin') {
targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;">${m.jellyfinId}</span>`;
} else {
targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
}
const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : '-'; const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : '-';
@@ -1442,7 +1406,7 @@
} }
async function deleteTrackMapping(playlist, spotifyId) { async function deleteTrackMapping(playlist, spotifyId) {
if (!confirm(`Remove manual mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\nFor local (Jellyfin) tracks: Stop injecting locally if now available via Spotify Import plugin\n• For external tracks: Allow re-matching with potentially better results\n\nThis action cannot be undone.`)) { if (!confirm(`Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\nThe track may be re-matched with potentially better results\n\nThis action cannot be undone.`)) {
return; return;
} }
@@ -2318,34 +2282,6 @@
document.getElementById('map-save-btn').disabled = false; document.getElementById('map-save-btn').disabled = false;
} }
// Toggle between Jellyfin and external mapping modes
function toggleMappingType() {
const mappingType = document.getElementById('map-type-select').value;
const jellyfinSection = document.getElementById('jellyfin-mapping-section');
const externalSection = document.getElementById('external-mapping-section');
const saveBtn = document.getElementById('map-save-btn');
if (mappingType === 'jellyfin') {
jellyfinSection.style.display = 'block';
externalSection.style.display = 'none';
// Reset external fields
document.getElementById('map-external-id').value = '';
// Check if Jellyfin track is selected
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
saveBtn.disabled = !jellyfinId;
} else {
jellyfinSection.style.display = 'none';
externalSection.style.display = 'block';
// Reset Jellyfin fields
document.getElementById('map-search-query').value = '';
document.getElementById('map-jellyfin-url').value = '';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Enter an external provider ID above</p>';
// Check if external mapping is valid
validateExternalMapping();
}
}
// Validate external mapping input // Validate external mapping input
function validateExternalMapping() { function validateExternalMapping() {
const externalId = document.getElementById('map-external-id').value.trim(); const externalId = document.getElementById('map-external-id').value.trim();
@@ -2355,7 +2291,7 @@
saveBtn.disabled = !externalId; saveBtn.disabled = !externalId;
} }
// Update the openManualMap function to reset the modal state // Open manual mapping modal (external only)
function openManualMap(playlistName, position, title, artist, spotifyId) { function openManualMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('map-playlist-name').value = playlistName; document.getElementById('map-playlist-name').value = playlistName;
document.getElementById('map-position').textContent = position + 1; document.getElementById('map-position').textContent = position + 1;
@@ -2363,75 +2299,39 @@
document.getElementById('map-spotify-artist').textContent = artist; document.getElementById('map-spotify-artist').textContent = artist;
document.getElementById('map-spotify-id').value = spotifyId; document.getElementById('map-spotify-id').value = spotifyId;
// Reset to Jellyfin mapping mode // Reset fields
document.getElementById('map-type-select').value = 'jellyfin';
document.getElementById('jellyfin-mapping-section').style.display = 'block';
document.getElementById('external-mapping-section').style.display = 'none';
// Reset all fields
document.getElementById('map-search-query').value = '';
document.getElementById('map-jellyfin-url').value = '';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-external-id').value = ''; document.getElementById('map-external-id').value = '';
document.getElementById('map-external-provider').value = 'SquidWTF'; document.getElementById('map-external-provider').value = 'SquidWTF';
document.getElementById('map-save-btn').disabled = true; document.getElementById('map-save-btn').disabled = true;
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
openModal('manual-map-modal'); openModal('manual-map-modal');
} }
// Open external mapping modal (pre-set to external mode) // Alias for backward compatibility
function openExternalMap(playlistName, position, title, artist, spotifyId) { function openExternalMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('map-playlist-name').value = playlistName; openManualMap(playlistName, position, title, artist, spotifyId);
document.getElementById('map-position').textContent = position + 1;
document.getElementById('map-spotify-title').textContent = title;
document.getElementById('map-spotify-artist').textContent = artist;
document.getElementById('map-spotify-id').value = spotifyId;
// Set to external mapping mode
document.getElementById('map-type-select').value = 'external';
document.getElementById('jellyfin-mapping-section').style.display = 'none';
document.getElementById('external-mapping-section').style.display = 'block';
// Reset all fields
document.getElementById('map-search-query').value = '';
document.getElementById('map-jellyfin-url').value = '';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-external-id').value = '';
document.getElementById('map-external-provider').value = 'SquidWTF';
document.getElementById('map-save-btn').disabled = true;
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Enter an external provider ID above</p>';
openModal('manual-map-modal');
} }
// Update the saveManualMapping function to handle both types // Save manual mapping (external only)
async function saveManualMapping() { async function saveManualMapping() {
const playlistName = document.getElementById('map-playlist-name').value; const playlistName = document.getElementById('map-playlist-name').value;
const spotifyId = document.getElementById('map-spotify-id').value; const spotifyId = document.getElementById('map-spotify-id').value;
const mappingType = document.getElementById('map-type-select').value;
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
let requestBody = { spotifyId }; const externalProvider = document.getElementById('map-external-provider').value;
const externalId = document.getElementById('map-external-id').value.trim();
if (mappingType === 'jellyfin') { if (!externalId) {
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value; showToast('Please enter an external provider ID', 'error');
if (!jellyfinId) { return;
showToast('Please select a track', 'error');
return;
}
requestBody.jellyfinId = jellyfinId;
} else {
const externalProvider = document.getElementById('map-external-provider').value;
const externalId = document.getElementById('map-external-id').value.trim();
if (!externalId) {
showToast('Please enter an external provider ID', 'error');
return;
}
requestBody.externalProvider = externalProvider;
requestBody.externalId = externalId;
} }
const requestBody = {
spotifyId,
externalProvider,
externalId
};
// Show loading state // Show loading state
const saveBtn = document.getElementById('map-save-btn'); const saveBtn = document.getElementById('map-save-btn');
const originalText = saveBtn.textContent; const originalText = saveBtn.textContent;
@@ -2453,8 +2353,7 @@
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
const mappingTypeText = mappingType === 'jellyfin' ? 'local Jellyfin track' : `${requestBody.externalProvider} ID`; showToast(`✓ Track mapped to ${requestBody.externalProvider} - rebuilding playlist...`, 'success');
showToast(`✓ Track mapped to ${mappingTypeText} - rebuilding playlist...`, 'success');
closeModal('manual-map-modal'); closeModal('manual-map-modal');
// Show rebuilding indicator // Show rebuilding indicator
@@ -2462,27 +2361,15 @@
// Show detailed info toast after a moment // Show detailed info toast after a moment
setTimeout(() => { setTimeout(() => {
if (mappingType === 'jellyfin') { showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
showToast('🔄 Rebuilding playlist with your local track mapping...', 'info', 8000);
} else {
showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
}
}, 1000); }, 1000);
// Update the track in the UI without refreshing // Update the track in the UI without refreshing
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`); const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
if (trackItem) { if (trackItem) {
const titleEl = trackItem.querySelector('.track-info h4'); const titleEl = trackItem.querySelector('.track-info h4');
if (titleEl && mappingType === 'jellyfin' && data.track) { if (titleEl) {
// For Jellyfin mappings, update with actual track info // Update status badge to show provider
const titleText = data.track.title;
const newStatusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
titleEl.innerHTML = escapeHtml(titleText) + newStatusBadge;
const artistEl = trackItem.querySelector('.track-info .artists');
if (artistEl) artistEl.textContent = data.track.artist;
} else if (titleEl && mappingType === 'external') {
// For external mappings, update status badge to show provider
const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status
const capitalizedProvider = capitalizeProvider(requestBody.externalProvider); const capitalizedProvider = capitalizeProvider(requestBody.externalProvider);
const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(capitalizedProvider)}</span>`; const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(capitalizedProvider)}</span>`;