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
This commit is contained in:
2026-02-09 16:32:18 -05:00
parent 0dca6b792d
commit 670544a9d6
2 changed files with 84 additions and 93 deletions

View File

@@ -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<string>(
@@ -1952,82 +1946,24 @@ public class AdminController : ControllerBase
StringComparer.OrdinalIgnoreCase
);
var playlists = new List<object>();
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<object>() });
}
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)

View File

@@ -746,6 +746,18 @@ public class SpotifyApiClient : IDisposable
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
string searchName,
CancellationToken cancellationToken = default)
{
return await GetUserPlaylistsAsync(searchName, cancellationToken);
}
/// <summary>
/// Gets all playlists from the user's library, optionally filtered by name.
/// Uses GraphQL API which is less rate-limited than REST API.
/// </summary>
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
public async Task<List<SpotifyPlaylist>> 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<SpotifyPlaylist>();
}
}