diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index e9a35fc..c2b2162 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -492,10 +492,18 @@ public class AdminController : ControllerBase _logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}", localTracks.Count, decodedName); + // Get matched external tracks cache + var matchedTracksKey = $"spotify:matched:ordered:{decodedName}"; + var matchedTracks = await _cache.GetAsync>(matchedTracksKey); + var matchedSpotifyIds = new HashSet( + matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty() + ); + // Match Spotify tracks to local tracks by name (fuzzy matching) foreach (var track in spotifyTracks) { - var isLocal = false; + bool? isLocal = null; + string? externalProvider = null; // FIRST: Check for manual mapping (same as SpotifyTrackMatchingService) var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}"; @@ -503,12 +511,39 @@ public class AdminController : ControllerBase if (!string.IsNullOrEmpty(manualJellyfinId)) { - // Manual mapping exists - this track is definitely local + // Manual Jellyfin mapping exists - this track is definitely local isLocal = true; - _logger.LogDebug("✓ Manual mapping found for {Title}: Jellyfin ID {Id}", + _logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}", track.Title, manualJellyfinId); } - else if (localTracks.Count > 0) + else + { + // Check for external manual mapping + var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; + var externalMapping = await _cache.GetAsync(externalMappingKey); + + if (externalMapping != null) + { + try + { + var provider = externalMapping.provider?.ToString(); + var externalId = externalMapping.id?.ToString(); + + if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId)) + { + // External manual mapping exists + isLocal = false; + externalProvider = provider; + _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 var bestMatch = localTracks @@ -535,6 +570,24 @@ public class AdminController : ControllerBase } } + // If not local, check if it's externally matched or missing + if (isLocal != true) + { + if (matchedSpotifyIds.Contains(track.SpotifyId)) + { + // Track is externally matched + isLocal = false; + externalProvider = _configuration.GetValue("Subsonic:MusicService") ?? + _configuration.GetValue("Jellyfin:MusicService") ?? "Deezer"; + } + else + { + // Track is missing (not local and not externally matched) + isLocal = null; + externalProvider = null; + } + } + tracksWithStatus.Add(new { position = track.Position, @@ -546,9 +599,8 @@ public class AdminController : ControllerBase durationMs = track.DurationMs, albumArtUrl = track.AlbumArtUrl, isLocal = isLocal, - // For external tracks, show what will be searched - externalProvider = isLocal ? null : _configuration.GetValue("Subsonic:MusicService") ?? _configuration.GetValue("Jellyfin:MusicService") ?? "Deezer", - searchQuery = isLocal ? null : $"{track.Title} {track.PrimaryArtist}" + externalProvider = externalProvider, + searchQuery = isLocal == false ? $"{track.Title} {track.PrimaryArtist}" : null }); } @@ -567,6 +619,90 @@ public class AdminController : ControllerBase } } + // If we get here, we couldn't get local tracks from Jellyfin + // Just return tracks with basic external/missing status based on cache + var tracksWithStatus = new List(); + + // Get matched external tracks cache + var matchedTracksKey = $"spotify:matched:ordered:{decodedName}"; + var matchedTracks = await _cache.GetAsync>(matchedTracksKey); + var matchedSpotifyIds = new HashSet( + matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty() + ); + + foreach (var track in spotifyTracks) + { + bool? isLocal = null; + string? externalProvider = null; + + // Check for manual mappings + var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}"; + var manualJellyfinId = await _cache.GetAsync(manualMappingKey); + + if (!string.IsNullOrEmpty(manualJellyfinId)) + { + isLocal = true; + } + else + { + // Check for external manual mapping + var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; + var externalMapping = await _cache.GetAsync(externalMappingKey); + + if (externalMapping != null) + { + try + { + var provider = externalMapping.provider?.ToString(); + 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 (matchedSpotifyIds.Contains(track.SpotifyId)) + { + // Track is externally matched + isLocal = false; + externalProvider = _configuration.GetValue("Subsonic:MusicService") ?? + _configuration.GetValue("Jellyfin:MusicService") ?? "Deezer"; + } + else + { + // Track is missing + isLocal = null; + externalProvider = null; + } + } + + tracksWithStatus.Add(new + { + position = track.Position, + title = track.Title, + artists = track.Artists, + album = track.Album, + isrc = track.Isrc, + spotifyId = track.SpotifyId, + durationMs = track.DurationMs, + albumArtUrl = track.AlbumArtUrl, + isLocal = isLocal, + externalProvider = externalProvider, + searchQuery = isLocal == false ? $"{track.Title} {track.PrimaryArtist}" : null + }); + } + + return Ok(new + { + name = decodedName, + trackCount = spotifyTracks.Count, + tracks = tracksWithStatus + }); + // Fallback: return tracks without local/external status return Ok(new { @@ -586,7 +722,6 @@ public class AdminController : ControllerBase externalProvider = _configuration.GetValue("Subsonic:MusicService") ?? _configuration.GetValue("Jellyfin:MusicService") ?? "Deezer", searchQuery = $"{t.Title} {t.PrimaryArtist}" }) - }); } /// @@ -785,19 +920,46 @@ public class AdminController : ControllerBase { var decodedName = Uri.UnescapeDataString(name); - if (string.IsNullOrWhiteSpace(request.SpotifyId) || string.IsNullOrWhiteSpace(request.JellyfinId)) + if (string.IsNullOrWhiteSpace(request.SpotifyId)) { - return BadRequest(new { error = "SpotifyId and JellyfinId are required" }); + return BadRequest(new { error = "SpotifyId is required" }); + } + + // 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 = "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 mapping in cache (you could also persist to a file) - var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}"; - await _cache.SetAsync(mappingKey, request.JellyfinId, TimeSpan.FromDays(365)); // Long TTL - - _logger.LogInformation("Manual mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}", - decodedName, request.SpotifyId, request.JellyfinId); + if (hasJellyfinMapping) + { + // Store Jellyfin mapping in cache + var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}"; + await _cache.SetAsync(mappingKey, request.JellyfinId!, TimeSpan.FromDays(365)); + + _logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}", + decodedName, request.SpotifyId, request.JellyfinId); + } + else + { + // Store external mapping in cache + var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}"; + var externalMapping = new { provider = request.ExternalProvider, id = request.ExternalId }; + await _cache.SetAsync(externalMappingKey, externalMapping, TimeSpan.FromDays(365)); + + _logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}", + decodedName, request.SpotifyId, request.ExternalProvider, request.ExternalId); + } // Clear all related caches to force rebuild var matchedCacheKey = $"spotify:matched:{decodedName}"; @@ -907,7 +1069,9 @@ public class AdminController : ControllerBase public class ManualMappingRequest { public string SpotifyId { get; set; } = ""; - public string JellyfinId { get; set; } = ""; + public string? JellyfinId { get; set; } + public string? ExternalProvider { get; set; } + public string? ExternalId { get; set; } } /// diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index 700d682..8e7601b 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -314,6 +314,15 @@ public class SpotifyTrackMatchingService : BackgroundService hasManualMappings = true; break; } + + // Also check for external manual mappings + var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}"; + var externalMapping = await _cache.GetAsync(externalMappingKey); + if (externalMapping != null) + { + hasManualMappings = true; + break; + } } // Skip if cache exists AND no manual mappings need to be applied @@ -783,7 +792,7 @@ public class SpotifyTrackMatchingService : BackgroundService if (!string.IsNullOrEmpty(manualJellyfinId)) { - // Manual mapping exists - fetch the Jellyfin item by ID + // Manual Jellyfin mapping exists - fetch the Jellyfin item by ID try { var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}"; @@ -792,12 +801,12 @@ public class SpotifyTrackMatchingService : BackgroundService if (itemStatusCode == 200 && itemResponse != null) { matchedJellyfinItem = itemResponse.RootElement; - _logger.LogDebug("✓ Using manual mapping for {Title}: Jellyfin ID {Id}", + _logger.LogDebug("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}", spotifyTrack.Title, manualJellyfinId); } else { - _logger.LogWarning("Manual mapping points to invalid Jellyfin ID {Id} for {Title}", + _logger.LogWarning("Manual Jellyfin mapping points to invalid Jellyfin ID {Id} for {Title}", manualJellyfinId, spotifyTrack.Title); } } @@ -807,6 +816,54 @@ public class SpotifyTrackMatchingService : BackgroundService } } + // Check for external manual mapping if no Jellyfin mapping found + if (!matchedJellyfinItem.HasValue) + { + var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}"; + var externalMapping = await _cache.GetAsync(externalMappingKey); + + if (externalMapping != null) + { + try + { + var provider = externalMapping.provider?.ToString(); + var externalId = externalMapping.id?.ToString(); + + if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId)) + { + // Create a matched track entry for the external mapping + var externalSong = new Song + { + Title = spotifyTrack.Title, + Artist = spotifyTrack.PrimaryArtist, + Album = spotifyTrack.Album, + Duration = spotifyTrack.DurationMs / 1000, + Isrc = spotifyTrack.Isrc, + SpotifyId = spotifyTrack.SpotifyId, + IsLocal = false, + ExternalProvider = provider, + ExternalId = externalId + }; + + matchedTracks.Add(new MatchedTrack + { + Position = spotifyTrack.Position, + SpotifyId = spotifyTrack.SpotifyId, + MatchedSong = externalSong + }); + + _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 if (!matchedJellyfinItem.HasValue) {