From 670544a9d67dcaf2e40ee92fc0d5e9c8c050508b Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Mon, 9 Feb 2026 16:32:18 -0500 Subject: [PATCH] Fix AdminController Spotify 429 rate limiting in Link Playlists tab - Replace direct REST API calls with SpotifyApiClient GraphQL method - Add GetUserPlaylistsAsync() method to fetch all user playlists via GraphQL - GraphQL endpoint is much less rate-limited than REST API /me/playlists - Enhanced playlist data with track count, owner, and image URL from GraphQL - Simplified AdminController code by delegating to SpotifyApiClient --- allstarr/Controllers/AdminController.cs | 92 +++---------------- allstarr/Services/Spotify/SpotifyApiClient.cs | 85 ++++++++++++++--- 2 files changed, 84 insertions(+), 93 deletions(-) diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 7d5217b..a9e2aca 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -1939,12 +1939,6 @@ public class AdminController : ControllerBase try { - var token = await _spotifyClient.GetWebAccessTokenAsync(); - if (string.IsNullOrEmpty(token)) - { - return StatusCode(401, new { error = "Failed to authenticate with Spotify. Check your sp_dc cookie." }); - } - // Get list of already-configured Spotify playlist IDs var configuredPlaylists = await ReadPlaylistsFromEnvFile(); var linkedSpotifyIds = new HashSet( @@ -1952,82 +1946,24 @@ public class AdminController : ControllerBase StringComparer.OrdinalIgnoreCase ); - var playlists = new List(); - var offset = 0; - const int limit = 50; + // Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API + var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null); - while (true) + if (spotifyPlaylists == null || spotifyPlaylists.Count == 0) { - var url = $"https://api.spotify.com/v1/me/playlists?offset={offset}&limit={limit}"; - - var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - var response = await _jellyfinHttpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) - { - if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) - { - _logger.LogWarning("Spotify rate limit hit (429) when fetching playlists"); - return StatusCode(429, new { error = "Spotify rate limit exceeded. Please wait a moment and try again." }); - } - - _logger.LogWarning("Failed to fetch Spotify playlists: {StatusCode}", response.StatusCode); - break; - } - - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) - break; - - foreach (var item in items.EnumerateArray()) - { - var id = item.TryGetProperty("id", out var itemId) ? itemId.GetString() : null; - var name = item.TryGetProperty("name", out var n) ? n.GetString() : null; - var trackCount = 0; - - if (item.TryGetProperty("tracks", out var tracks) && - tracks.TryGetProperty("total", out var total)) - { - trackCount = total.GetInt32(); - } - - var owner = ""; - if (item.TryGetProperty("owner", out var ownerObj) && - ownerObj.TryGetProperty("display_name", out var displayName)) - { - owner = displayName.GetString() ?? ""; - } - - var isPublic = item.TryGetProperty("public", out var pub) && pub.GetBoolean(); - - // Check if this playlist is already linked - var isLinked = !string.IsNullOrEmpty(id) && linkedSpotifyIds.Contains(id); - - playlists.Add(new - { - id, - name, - trackCount, - owner, - isPublic, - isLinked - }); - } - - if (items.GetArrayLength() < limit) break; - offset += limit; - - // Rate limiting - if (_spotifyApiSettings.RateLimitDelayMs > 0) - { - await Task.Delay(_spotifyApiSettings.RateLimitDelayMs); - } + return Ok(new { playlists = new List() }); } + var playlists = spotifyPlaylists.Select(p => new + { + id = p.SpotifyId, + name = p.Name, + trackCount = p.TotalTracks, + owner = p.OwnerName ?? "", + isPublic = p.Public, + isLinked = linkedSpotifyIds.Contains(p.SpotifyId) + }).ToList(); + return Ok(new { playlists }); } catch (Exception ex) diff --git a/allstarr/Services/Spotify/SpotifyApiClient.cs b/allstarr/Services/Spotify/SpotifyApiClient.cs index 65286ca..5372c3b 100644 --- a/allstarr/Services/Spotify/SpotifyApiClient.cs +++ b/allstarr/Services/Spotify/SpotifyApiClient.cs @@ -746,6 +746,18 @@ public class SpotifyApiClient : IDisposable public async Task> SearchUserPlaylistsAsync( string searchName, CancellationToken cancellationToken = default) + { + return await GetUserPlaylistsAsync(searchName, cancellationToken); + } + + /// + /// Gets all playlists from the user's library, optionally filtered by name. + /// Uses GraphQL API which is less rate-limited than REST API. + /// + /// Optional name filter (case-insensitive). If null, returns all playlists. + public async Task> GetUserPlaylistsAsync( + string? searchName = null, + CancellationToken cancellationToken = default) { var token = await GetWebAccessTokenAsync(cancellationToken); if (string.IsNullOrEmpty(token)) @@ -854,21 +866,61 @@ public class SpotifyApiClient : IDisposable var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; - // Check if name matches (case-insensitive) - if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase)) + // Check if name matches (case-insensitive) - if searchName is provided + if (!string.IsNullOrEmpty(searchName) && + !itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase)) { - var uri = playlist.TryGetProperty("uri", out var u) ? u.GetString() ?? "" : ""; - var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase); - - playlists.Add(new SpotifyPlaylist - { - SpotifyId = spotifyId, - Name = itemName, - Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null, - TotalTracks = 0, // GraphQL doesn't return track count in this query - SnapshotId = null - }); + continue; } + + var uri = playlist.TryGetProperty("uri", out var u) ? u.GetString() ?? "" : ""; + var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase); + + // Get track count if available + var trackCount = 0; + if (playlist.TryGetProperty("content", out var content) && + content.TryGetProperty("totalCount", out var totalCount)) + { + trackCount = totalCount.GetInt32(); + } + + // Get owner name + string? ownerName = null; + if (playlist.TryGetProperty("ownerV2", out var ownerV2) && + ownerV2.TryGetProperty("data", out var ownerData) && + ownerData.TryGetProperty("name", out var ownerNameProp)) + { + ownerName = ownerNameProp.GetString(); + } + + // Get image URL + string? imageUrl = null; + if (playlist.TryGetProperty("images", out var images) && + images.TryGetProperty("items", out var imageItems) && + imageItems.GetArrayLength() > 0) + { + var firstImage = imageItems[0]; + if (firstImage.TryGetProperty("sources", out var sources) && + sources.GetArrayLength() > 0) + { + var firstSource = sources[0]; + if (firstSource.TryGetProperty("url", out var urlProp)) + { + imageUrl = urlProp.GetString(); + } + } + } + + playlists.Add(new SpotifyPlaylist + { + SpotifyId = spotifyId, + Name = itemName, + Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null, + TotalTracks = trackCount, + OwnerName = ownerName, + ImageUrl = imageUrl, + SnapshotId = null + }); } if (itemCount < limit) break; @@ -881,12 +933,15 @@ public class SpotifyApiClient : IDisposable await Task.Delay(delayMs, cancellationToken); } - _logger.LogInformation("Found {Count} playlists matching '{SearchName}' via GraphQL", playlists.Count, searchName); + _logger.LogInformation("Found {Count} playlists{Filter} via GraphQL", + playlists.Count, + string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'"); return playlists; } catch (Exception ex) { - _logger.LogError(ex, "Error searching user playlists for '{SearchName}' via GraphQL", searchName); + _logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL", + string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'"); return new List(); } }