fix: use GraphQL for user playlists to avoid 429 rate limits
Some checks failed
CI / build-and-test (push) Has been cancelled

- 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
This commit is contained in:
2026-02-09 15:10:14 -05:00
parent 2b76fa9e6f
commit f135db3f60

View File

@@ -744,61 +744,126 @@ public class SpotifyApiClient : IDisposable
try try
{ {
// Use GraphQL endpoint instead of REST API to avoid rate limiting
// GraphQL is less aggressive with rate limits
var playlists = new List<SpotifyPlaylist>(); var playlists = new List<SpotifyPlaylist>();
var offset = 0; var offset = 0;
const int limit = 50; const int limit = 50;
while (true) 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); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request, cancellationToken); 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); var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
var root = doc.RootElement; 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; break;
}
var itemCount = 0;
foreach (var item in items.EnumerateArray()) 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) // Check if name matches (case-insensitive)
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase)) 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 playlists.Add(new SpotifyPlaylist
{ {
SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "", SpotifyId = spotifyId,
Name = itemName, Name = itemName,
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null, Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
TotalTracks = item.TryGetProperty("tracks", out var tracks) && TotalTracks = 0, // GraphQL doesn't return track count in this query
tracks.TryGetProperty("total", out var total) SnapshotId = null
? total.GetInt32() : 0,
SnapshotId = item.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null
}); });
} }
} }
if (items.GetArrayLength() < limit) break; if (itemCount < limit) break;
offset += limit; offset += limit;
// GraphQL is less rate-limited, but still add a small delay
if (_settings.RateLimitDelayMs > 0) if (_settings.RateLimitDelayMs > 0)
{ {
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken); await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
} }
} }
_logger.LogInformation("Found {Count} playlists matching '{SearchName}' via GraphQL", playlists.Count, searchName);
return playlists; return playlists;
} }
catch (Exception ex) 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<SpotifyPlaylist>(); return new List<SpotifyPlaylist>();
} }
} }