diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index b1e74e1..887b4ce 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -673,72 +673,88 @@ public class AdminController : ControllerBase string? manualMappingType = null; string? manualMappingId = null; - // Check for external manual mapping - var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; - var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); + // FIRST: Check for manual Jellyfin mapping + var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}"; + var manualJellyfinId = await _cache.GetAsync(manualMappingKey); - if (!string.IsNullOrEmpty(externalMappingJson)) + if (!string.IsNullOrEmpty(manualJellyfinId)) { - 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); - } + // 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); } - - // If no manual mapping, try fuzzy matching with local tracks - if (!isManualMapping && localTracks.Count > 0) + else { - 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(); + // Check for external manual mapping + var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; + var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); - // Use 70% threshold (same as playback matching) - if (bestMatch != null && bestMatch.TotalScore >= 70) + if (!string.IsNullOrEmpty(externalMappingJson)) { - 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; string? externalProvider = null; - // Check for external manual mapping - var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; - var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); + // Check for manual Jellyfin mapping + var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}"; + var manualJellyfinId = await _cache.GetAsync(manualMappingKey); - if (!string.IsNullOrEmpty(externalMappingJson)) + if (!string.IsNullOrEmpty(manualJellyfinId)) { - 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 + isLocal = true; } else { - // Track is missing (search failed) - isLocal = null; - externalProvider = null; + // 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; + + 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 @@ -1158,8 +1185,7 @@ public class AdminController : ControllerBase } /// - /// Save manual external track mapping (SquidWTF/Deezer/Qobuz) - /// Note: Local Jellyfin mappings should be done via Spotify Import plugin + /// Save manual track mapping (local Jellyfin or external provider) /// [HttpPost("playlists/{name}/map")] public async Task SaveManualMapping(string name, [FromBody] ManualMappingRequest request) @@ -1171,26 +1197,50 @@ public class AdminController : ControllerBase return BadRequest(new { error = "SpotifyId is required" }); } - // Only external mappings are supported now - // Local Jellyfin mappings should be done via Spotify Import plugin - if (string.IsNullOrWhiteSpace(request.ExternalProvider) || string.IsNullOrWhiteSpace(request.ExternalId)) + // Validate that either Jellyfin mapping or external mapping is provided + var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId); + 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 { - // 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); + string? normalizedProvider = null; - // 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); + 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) + 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 var matchedCacheKey = $"spotify:matched:{decodedName}"; @@ -1228,33 +1278,36 @@ public class AdminController : ControllerBase _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? trackArtist = null; string? trackAlbum = null; - try + if (hasExternalMapping && normalizedProvider != null) { - var metadataService = HttpContext.RequestServices.GetRequiredService(); - var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!); - - if (externalSong != null) + try { - trackTitle = externalSong.Title; - trackArtist = externalSong.Artist; - trackAlbum = externalSong.Album; - _logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist); + var metadataService = HttpContext.RequestServices.GetRequiredService(); + 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); + } } - else + catch (Exception ex) { - _logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}", - normalizedProvider, request.ExternalId); + _logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved"); } } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved"); - } // Trigger immediate playlist rebuild with the new mapping if (_matchingService != null) diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index 45bf227..1c4115c 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -829,7 +829,44 @@ public class SpotifyTrackMatchingService : BackgroundService JsonElement? matchedJellyfinItem = 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(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>(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 externalMappingJson = await _cache.GetStringAsync(externalMappingKey);