diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 65a8900..0ab62a0 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -201,35 +201,64 @@ public class AdminController : ControllerBase { try { - var url = $"{_jellyfinSettings.Url}/Playlists/{config.JellyfinId}/Items?Fields=Path"; - var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + // Jellyfin requires UserId parameter to fetch playlist items + var userId = _jellyfinSettings.UserId; - _logger.LogDebug("Fetching Jellyfin playlist items for {Name} from {Url}", config.Name, url); - - var response = await _jellyfinHttpClient.SendAsync(request); - if (response.IsSuccessStatusCode) + // If no user configured, try to get the first user + if (string.IsNullOrEmpty(userId)) { - var jellyfinJson = await response.Content.ReadAsStringAsync(); - using var jellyfinDoc = JsonDocument.Parse(jellyfinJson); + var usersResponse = await _jellyfinHttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users") + { + Headers = { { "X-Emby-Authorization", GetJellyfinAuthHeader() } } + }); - if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items)) + if (usersResponse.IsSuccessStatusCode) { - var localCount = items.GetArrayLength(); - playlistInfo["localTracks"] = localCount; - playlistInfo["externalTracks"] = Math.Max(0, spotifyTrackCount - localCount); - _logger.LogDebug("Playlist {Name}: {Local} local tracks, {Missing} missing", - config.Name, localCount, spotifyTrackCount - localCount); - } - else - { - _logger.LogWarning("No Items property in Jellyfin response for {Name}", config.Name); + var usersJson = await usersResponse.Content.ReadAsStringAsync(); + using var usersDoc = JsonDocument.Parse(usersJson); + if (usersDoc.RootElement.GetArrayLength() > 0) + { + userId = usersDoc.RootElement[0].GetProperty("Id").GetString(); + } } } + + if (string.IsNullOrEmpty(userId)) + { + _logger.LogWarning("No user ID available to fetch playlist items for {Name}", config.Name); + } else { - _logger.LogWarning("Failed to get Jellyfin playlist {Name}: {StatusCode}", - config.Name, response.StatusCode); + var url = $"{_jellyfinSettings.Url}/Playlists/{config.JellyfinId}/Items?UserId={userId}&Fields=Path"; + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + + _logger.LogDebug("Fetching Jellyfin playlist items for {Name} from {Url}", config.Name, url); + + var response = await _jellyfinHttpClient.SendAsync(request); + if (response.IsSuccessStatusCode) + { + var jellyfinJson = await response.Content.ReadAsStringAsync(); + using var jellyfinDoc = JsonDocument.Parse(jellyfinJson); + + if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items)) + { + var localCount = items.GetArrayLength(); + playlistInfo["localTracks"] = localCount; + playlistInfo["externalTracks"] = Math.Max(0, spotifyTrackCount - localCount); + _logger.LogDebug("Playlist {Name}: {Local} local tracks, {Missing} missing", + config.Name, localCount, spotifyTrackCount - localCount); + } + else + { + _logger.LogWarning("No Items property in Jellyfin response for {Name}", config.Name); + } + } + else + { + _logger.LogWarning("Failed to get Jellyfin playlist {Name}: {StatusCode}", + config.Name, response.StatusCode); + } } } catch (Exception ex) @@ -302,7 +331,7 @@ public class AdminController : ControllerBase try { - await _matchingService.TriggerMatchingAsync(); + await _matchingService.TriggerMatchingForPlaylistAsync(decodedName); return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow }); } catch (Exception ex) @@ -312,6 +341,31 @@ public class AdminController : ControllerBase } } + /// + /// Trigger track matching for all playlists + /// + [HttpPost("playlists/match-all")] + public async Task MatchAllPlaylistTracks() + { + _logger.LogInformation("Manual track matching triggered for all playlists"); + + if (_matchingService == null) + { + return BadRequest(new { error = "Track matching service is not available" }); + } + + try + { + await _matchingService.TriggerMatchingAsync(); + return Ok(new { message = "Track matching triggered for all playlists", timestamp = DateTime.UtcNow }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to trigger track matching for all playlists"); + return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message }); + } + } + /// /// Get current configuration (safe values only) /// @@ -346,6 +400,7 @@ public class AdminController : ControllerBase { url = _jellyfinSettings.Url, apiKey = MaskValue(_jellyfinSettings.ApiKey), + userId = _jellyfinSettings.UserId ?? "(not set)", libraryId = _jellyfinSettings.LibraryId }, deezer = new diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 2f90e6f..bbb096c 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -2856,8 +2856,21 @@ public class JellyfinController : ControllerBase orderedTracks.Count, spotifyPlaylistName); // Get existing Jellyfin playlist items (tracks the Spotify Import plugin already found) + // CRITICAL: Must include UserId parameter or Jellyfin returns empty results + var userId = _settings.UserId; + if (string.IsNullOrEmpty(userId)) + { + _logger.LogWarning("No UserId configured - attempting to fetch existing playlist tracks may fail"); + } + + var playlistItemsUrl = $"Playlists/{playlistId}/Items"; + if (!string.IsNullOrEmpty(userId)) + { + playlistItemsUrl += $"?UserId={userId}"; + } + var (existingTracksResponse, _) = await _proxyService.GetJsonAsync( - $"Playlists/{playlistId}/Items", + playlistItemsUrl, null, Request.Headers); @@ -2881,6 +2894,7 @@ public class JellyfinController : ControllerBase if (!string.IsNullOrEmpty(spotifyId)) { existingBySpotifyId[spotifyId] = song; + _logger.LogDebug("Indexed local track by Spotify ID: {SpotifyId} -> {Title}", spotifyId, song.Title); } } @@ -2888,11 +2902,16 @@ public class JellyfinController : ControllerBase if (!string.IsNullOrEmpty(song.Isrc)) { existingByIsrc[song.Isrc] = song; + _logger.LogDebug("Indexed local track by ISRC: {Isrc} -> {Title}", song.Isrc, song.Title); } } _logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist ({SpotifyIds} with Spotify IDs, {Isrcs} with ISRCs)", existingTracks.Count, existingBySpotifyId.Count, existingByIsrc.Count); } + else + { + _logger.LogWarning("No existing tracks found in Jellyfin playlist {PlaylistId} - may need UserId parameter", playlistId); + } // Get the full playlist from Spotify to know the correct order var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName); @@ -2915,12 +2934,16 @@ public class JellyfinController : ControllerBase if (existingBySpotifyId.TryGetValue(spotifyTrack.SpotifyId, out var trackBySpotifyId)) { localTrack = trackBySpotifyId; + _logger.LogDebug("#{Pos} {Title} - Found LOCAL by Spotify ID: {SpotifyId}", + spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.SpotifyId); } // Try to find by ISRC (most reliable for matching) else if (!string.IsNullOrEmpty(spotifyTrack.Isrc) && existingByIsrc.TryGetValue(spotifyTrack.Isrc, out var trackByIsrc)) { localTrack = trackByIsrc; + _logger.LogDebug("#{Pos} {Title} - Found LOCAL by ISRC: {Isrc}", + spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.Isrc); } // If we found a local track, use it @@ -2937,6 +2960,14 @@ public class JellyfinController : ControllerBase { finalTracks.Add(matched.MatchedSong); externalUsedCount++; + _logger.LogDebug("#{Pos} {Title} - Using EXTERNAL match: {Provider}/{Id}", + spotifyTrack.Position, spotifyTrack.Title, + matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId); + } + else + { + _logger.LogDebug("#{Pos} {Title} - NO MATCH (skipping)", + spotifyTrack.Position, spotifyTrack.Title); } // If no match, the track is simply omitted (not available from any source) } @@ -2986,8 +3017,20 @@ public class JellyfinController : ControllerBase } // Get existing Jellyfin playlist items (tracks the plugin already found) + // CRITICAL: Must include UserId parameter or Jellyfin returns empty results + var userId = _settings.UserId; + var playlistItemsUrl = $"Playlists/{playlistId}/Items"; + if (!string.IsNullOrEmpty(userId)) + { + playlistItemsUrl += $"?UserId={userId}"; + } + else + { + _logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks"); + } + var (existingTracksResponse, _) = await _proxyService.GetJsonAsync( - $"Playlists/{playlistId}/Items", + playlistItemsUrl, null, Request.Headers); @@ -3011,6 +3054,10 @@ public class JellyfinController : ControllerBase } _logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count); } + else + { + _logger.LogWarning("No existing tracks found in Jellyfin playlist - may need UserId parameter"); + } var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}"; var missingTracks = await _cache.GetAsync>(missingTracksKey); diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index c130734..93e2734 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -87,13 +87,61 @@ public class SpotifyTrackMatchingService : BackgroundService } /// - /// Public method to trigger matching manually (called from controller). + /// Public method to trigger matching manually for all playlists (called from controller). /// public async Task TriggerMatchingAsync() { - _logger.LogInformation("Manual track matching triggered"); + _logger.LogInformation("Manual track matching triggered for all playlists"); await MatchAllPlaylistsAsync(CancellationToken.None); } + + /// + /// Public method to trigger matching for a specific playlist (called from controller). + /// + public async Task TriggerMatchingForPlaylistAsync(string playlistName) + { + _logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName); + + var playlist = _spotifySettings.Playlists + .FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); + + if (playlist == null) + { + _logger.LogWarning("Playlist {Playlist} not found in configuration", playlistName); + return; + } + + using var scope = _serviceProvider.CreateScope(); + var metadataService = scope.ServiceProvider.GetRequiredService(); + + // Check if we should use the new SpotifyPlaylistFetcher + SpotifyPlaylistFetcher? playlistFetcher = null; + if (_spotifyApiSettings.Enabled) + { + playlistFetcher = scope.ServiceProvider.GetService(); + } + + try + { + if (playlistFetcher != null) + { + // Use new direct API mode with ISRC support + await MatchPlaylistTracksWithIsrcAsync( + playlist.Name, playlistFetcher, metadataService, CancellationToken.None); + } + else + { + // Fall back to legacy mode + await MatchPlaylistTracksLegacyAsync( + playlist.Name, metadataService, CancellationToken.None); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name); + throw; + } + } private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken) { @@ -176,13 +224,26 @@ public class SpotifyTrackMatchingService : BackgroundService // Get existing tracks from Jellyfin playlist to avoid re-matching using var scope = _serviceProvider.CreateScope(); var proxyService = scope.ServiceProvider.GetService(); + var jellyfinSettings = scope.ServiceProvider.GetService>()?.Value; - if (proxyService != null) + if (proxyService != null && jellyfinSettings != null) { try { + // CRITICAL: Must include UserId parameter or Jellyfin returns empty results + var userId = jellyfinSettings.UserId; + var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items"; + if (!string.IsNullOrEmpty(userId)) + { + playlistItemsUrl += $"?UserId={userId}"; + } + else + { + _logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName); + } + var (existingTracksResponse, _) = await proxyService.GetJsonAsync( - $"Playlists/{playlistConfig.JellyfinId}/Items", + playlistItemsUrl, null, null); @@ -204,6 +265,10 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogInformation("Found {Count} tracks already in Jellyfin playlist {Playlist}, will skip matching these", existingSpotifyIds.Count, playlistName); } + else + { + _logger.LogWarning("No Items found in Jellyfin playlist response for {Playlist} - may need UserId parameter", playlistName); + } } catch (Exception ex) { diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index f7787c6..af966a6 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -646,6 +646,7 @@

Active Spotify Playlists
+

@@ -665,7 +666,7 @@ - + Loading playlists... @@ -762,6 +763,11 @@ - +
+ User ID + - + +
Library ID - @@ -1126,6 +1132,7 @@ // Jellyfin settings document.getElementById('config-jellyfin-url').textContent = data.jellyfin.url || '-'; document.getElementById('config-jellyfin-api-key').textContent = data.jellyfin.apiKey; + document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)'; document.getElementById('config-jellyfin-library-id').textContent = data.jellyfin.libraryId || '-'; // Sync settings @@ -1347,6 +1354,22 @@ } } + async function matchAllPlaylists() { + try { + showToast('Matching tracks for all playlists...', 'success'); + const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' }); + const data = await res.json(); + + if (res.ok) { + showToast(data.message, 'success'); + } else { + showToast(data.error || 'Failed to match tracks', 'error'); + } + } catch (error) { + showToast('Failed to match tracks', 'error'); + } + } + async function clearCache() { if (!confirm('Clear all cached playlist data?')) return;