From f135db3f607ab3ba933210d56ad9cd3e1484d55c Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Mon, 9 Feb 2026 15:10:14 -0500 Subject: [PATCH] fix: use GraphQL for user playlists to avoid 429 rate limits - Switched from REST API /me/playlists to GraphQL fetchLibraryPlaylists - GraphQL endpoint is less aggressively rate-limited by Spotify - Fixes 429 errors when using 'Select from My Playlists' dropdown - Background services already use GraphQL and work fine --- allstarr/Services/Spotify/SpotifyApiClient.cs | 91 ++++++++++++++++--- 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/allstarr/Services/Spotify/SpotifyApiClient.cs b/allstarr/Services/Spotify/SpotifyApiClient.cs index 3d51b92..8bb5cf2 100644 --- a/allstarr/Services/Spotify/SpotifyApiClient.cs +++ b/allstarr/Services/Spotify/SpotifyApiClient.cs @@ -744,61 +744,126 @@ public class SpotifyApiClient : IDisposable try { + // Use GraphQL endpoint instead of REST API to avoid rate limiting + // GraphQL is less aggressive with rate limits var playlists = new List(); var offset = 0; const int limit = 50; while (true) { - var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}"; + // GraphQL query to fetch user playlists + var graphqlQuery = new + { + operationName = "fetchLibraryPlaylists", + variables = new + { + offset, + limit + }, + query = @" + query fetchLibraryPlaylists($offset: Int!, $limit: Int!) { + me { + library { + playlists(offset: $offset, limit: $limit) { + totalCount + items { + playlist { + uri + name + description + images { + url + } + ownerV2 { + data { + __typename + ... on User { + id + name + } + } + } + } + } + } + } + } + }" + }; - var request = new HttpRequestMessage(HttpMethod.Get, url); + var request = new HttpRequestMessage(HttpMethod.Post, $"{WebApiBase}/query") + { + Content = new StringContent( + JsonSerializer.Serialize(graphqlQuery), + System.Text.Encoding.UTF8, + "application/json") + }; request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); var response = await _httpClient.SendAsync(request, cancellationToken); - if (!response.IsSuccessStatusCode) break; + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode); + break; + } var json = await response.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) + if (!root.TryGetProperty("data", out var data) || + !data.TryGetProperty("me", out var me) || + !me.TryGetProperty("library", out var library) || + !library.TryGetProperty("playlists", out var playlistsData) || + !playlistsData.TryGetProperty("items", out var items)) + { break; + } + var itemCount = 0; foreach (var item in items.EnumerateArray()) { - var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; + itemCount++; + + if (!item.TryGetProperty("playlist", out var playlist)) + continue; + + var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; // Check if name matches (case-insensitive) if (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 = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "", + SpotifyId = spotifyId, Name = itemName, - Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null, - TotalTracks = item.TryGetProperty("tracks", out var tracks) && - tracks.TryGetProperty("total", out var total) - ? total.GetInt32() : 0, - SnapshotId = item.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null + Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null, + TotalTracks = 0, // GraphQL doesn't return track count in this query + SnapshotId = null }); } } - if (items.GetArrayLength() < limit) break; + if (itemCount < limit) break; offset += limit; + // GraphQL is less rate-limited, but still add a small delay if (_settings.RateLimitDelayMs > 0) { await Task.Delay(_settings.RateLimitDelayMs, cancellationToken); } } + _logger.LogInformation("Found {Count} playlists matching '{SearchName}' via GraphQL", playlists.Count, searchName); return playlists; } catch (Exception ex) { - _logger.LogError(ex, "Error searching user playlists for '{SearchName}'", searchName); + _logger.LogError(ex, "Error searching user playlists for '{SearchName}' via GraphQL", searchName); return new List(); } }