From 1d31784ff88549af5428b2767c4a3017dc5b9436 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Wed, 4 Feb 2026 18:38:25 -0500 Subject: [PATCH] Fix manual mapping: add immediate playlist rebuild and manual mapping priority in cache builder --- allstarr/Controllers/AdminController.cs | 77 ++++++++++++++---- .../Spotify/SpotifyTrackMatchingService.cs | 79 ++++++++++++++----- 2 files changed, 121 insertions(+), 35 deletions(-) diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 77776fe..57421ff 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -696,10 +696,23 @@ public class AdminController : ControllerBase _logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName); // Fetch the mapped Jellyfin track details to return to the UI + string? trackTitle = null; + string? trackArtist = null; + string? trackAlbum = null; + try { - var trackUrl = $"{_jellyfinSettings.Url}/Items/{request.JellyfinId}?api_key={_jellyfinSettings.ApiKey}"; - var response = await _jellyfinHttpClient.GetAsync(trackUrl); + var userId = _jellyfinSettings.UserId; + var trackUrl = $"{_jellyfinSettings.Url}/Items/{request.JellyfinId}"; + if (!string.IsNullOrEmpty(userId)) + { + 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) { @@ -707,18 +720,15 @@ public class AdminController : ControllerBase using var doc = JsonDocument.Parse(trackData); var track = doc.RootElement; - var mappedTrack = new - { - id = request.JellyfinId, - title = track.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "", - artist = track.TryGetProperty("AlbumArtist", out var artistEl) ? artistEl.GetString() : + 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() : ""), - album = track.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "", - isLocal = true - }; - - return Ok(new { message = "Mapping saved successfully", track = mappedTrack }); + ? 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) @@ -726,7 +736,46 @@ public class AdminController : ControllerBase _logger.LogWarning(ex, "Failed to fetch mapped track details, but mapping was saved"); } - return Ok(new { message = "Mapping saved successfully" }); + // Trigger immediate playlist rebuild with the new mapping + if (_matchingService != null) + { + _logger.LogInformation("Triggering immediate playlist rebuild for {Playlist} with new manual mapping", decodedName); + + // Run in background so we don't block the response + _ = Task.Run(async () => + { + try + { + await _matchingService.TriggerMatchingForPlaylistAsync(decodedName); + _logger.LogInformation("✓ Playlist {Playlist} rebuilt successfully with manual mapping", decodedName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to rebuild playlist {Playlist} after manual mapping", decodedName); + } + }); + } + else + { + _logger.LogWarning("Matching service not available - playlist will rebuild on next scheduled run"); + } + + // Return success with track details if available + var mappedTrack = new + { + id = request.JellyfinId, + title = trackTitle ?? "Unknown", + artist = trackArtist ?? "Unknown", + album = trackAlbum ?? "Unknown", + isLocal = true + }; + + return Ok(new + { + message = "Mapping saved and playlist rebuild triggered", + track = mappedTrack, + rebuildTriggered = _matchingService != null + }); } catch (Exception ex) { diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index 542d239..a60ccba 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -754,43 +754,80 @@ public class SpotifyTrackMatchingService : BackgroundService { if (cancellationToken.IsCancellationRequested) break; - // Try to find matching Jellyfin item by fuzzy matching + // FIRST: Check for manual mapping + var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}"; + var manualJellyfinId = await _cache.GetAsync(manualMappingKey); + JsonElement? matchedJellyfinItem = null; string? matchedKey = null; - double bestScore = 0; - foreach (var kvp in jellyfinItemsByName) + if (!string.IsNullOrEmpty(manualJellyfinId)) { - if (usedJellyfinItems.Contains(kvp.Key)) continue; - - 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) + // Manual mapping exists - fetch the Jellyfin item by ID + try { - artist = artistsEl[0].GetString() ?? ""; + var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}"; + var (itemResponse, itemStatusCode) = await proxyService.GetJsonAsync(itemUrl, null, headers); + + if (itemStatusCode == 200 && itemResponse != null) + { + matchedJellyfinItem = itemResponse.RootElement; + _logger.LogDebug("✓ Using manual mapping for {Title}: Jellyfin ID {Id}", + spotifyTrack.Title, manualJellyfinId); + } + else + { + _logger.LogWarning("Manual mapping points to invalid Jellyfin ID {Id} for {Title}", + manualJellyfinId, spotifyTrack.Title); + } } - - var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title); - var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist); - var totalScore = (titleScore * 0.7) + (artistScore * 0.3); - - if (totalScore > bestScore && totalScore >= 70) + catch (Exception ex) { - bestScore = totalScore; - matchedJellyfinItem = item; - matchedKey = kvp.Key; + _logger.LogWarning(ex, "Failed to fetch manually mapped Jellyfin item {Id}", manualJellyfinId); } } - if (matchedJellyfinItem.HasValue && matchedKey != null) + // SECOND: If no manual mapping, try fuzzy matching + if (!matchedJellyfinItem.HasValue) + { + double bestScore = 0; + + foreach (var kvp in jellyfinItemsByName) + { + if (usedJellyfinItems.Contains(kvp.Key)) continue; + + 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) + { + artist = artistsEl[0].GetString() ?? ""; + } + + var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title); + var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist); + var totalScore = (titleScore * 0.7) + (artistScore * 0.3); + + if (totalScore > bestScore && totalScore >= 70) + { + bestScore = totalScore; + matchedJellyfinItem = item; + matchedKey = kvp.Key; + } + } + } + + if (matchedJellyfinItem.HasValue) { // Use the raw Jellyfin item (preserves ALL metadata) var itemDict = JsonSerializer.Deserialize>(matchedJellyfinItem.Value.GetRawText()); if (itemDict != null) { finalItems.Add(itemDict); - usedJellyfinItems.Add(matchedKey); + if (matchedKey != null) + { + usedJellyfinItems.Add(matchedKey); + } localUsedCount++; } }