mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Switch to Spotify GraphQL API to match Jellyfin plugin approach
- Replace REST API calls with GraphQL API (api-partner.spotify.com/pathfinder/v1/query) - Use same persisted query approach as Jellyfin Spotify Import plugin - GraphQL API is more reliable and has better rate limits - Fetch playlists with operationName: fetchPlaylist - Parse GraphQL response structure (playlistV2, itemV2, etc) - This should eliminate TooManyRequests errors
This commit is contained in:
@@ -283,8 +283,8 @@ public class SpotifyApiClient : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a playlist with all its tracks from Spotify.
|
||||
/// Uses the web API to access editorial playlists that aren't available via the official API.
|
||||
/// Fetches a playlist with all its tracks from Spotify using the GraphQL API.
|
||||
/// This matches the approach used by the Jellyfin Spotify Import plugin.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">Spotify playlist ID or URI</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
@@ -303,17 +303,8 @@ public class SpotifyApiClient : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
// Use the official API with the web token for playlist data
|
||||
var playlist = await FetchPlaylistMetadataAsync(playlistId, token, cancellationToken);
|
||||
if (playlist == null) return null;
|
||||
|
||||
// Fetch all tracks with pagination
|
||||
var tracks = await FetchAllPlaylistTracksAsync(playlistId, token, cancellationToken);
|
||||
playlist.Tracks = tracks;
|
||||
playlist.TotalTracks = tracks.Count;
|
||||
|
||||
_logger.LogInformation("Fetched playlist '{Name}' with {Count} tracks", playlist.Name, tracks.Count);
|
||||
return playlist;
|
||||
// Use GraphQL API (same as Jellyfin plugin) - more reliable and less rate-limited
|
||||
return await FetchPlaylistViaGraphQLAsync(playlistId, token, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -322,6 +313,217 @@ public class SpotifyApiClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch playlist using Spotify's GraphQL API (api-partner.spotify.com/pathfinder/v1/query)
|
||||
/// This is the same approach used by the Jellyfin Spotify Import plugin
|
||||
/// </summary>
|
||||
private async Task<SpotifyPlaylist?> FetchPlaylistViaGraphQLAsync(
|
||||
string playlistId,
|
||||
string token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const int pageLimit = 50;
|
||||
var offset = 0;
|
||||
var totalTrackCount = pageLimit;
|
||||
var tracks = new List<SpotifyPlaylistTrack>();
|
||||
|
||||
SpotifyPlaylist? playlist = null;
|
||||
|
||||
while (tracks.Count < totalTrackCount && offset < totalTrackCount)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
// Build GraphQL query URL (same as Jellyfin plugin)
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
{ "operationName", "fetchPlaylist" },
|
||||
{ "variables", $"{{\"uri\":\"spotify:playlist:{playlistId}\",\"offset\":{offset},\"limit\":{pageLimit}}}" },
|
||||
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"19ff1327c29e99c208c86d7a9d8f1929cfdf3d3202a0ff4253c821f1901aa94d\"}}" }
|
||||
};
|
||||
|
||||
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
||||
var url = $"{WebApiBase}/query?{queryString}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("data", out var data) ||
|
||||
!data.TryGetProperty("playlistV2", out var playlistV2))
|
||||
{
|
||||
_logger.LogError("Invalid GraphQL response structure");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse playlist metadata on first iteration
|
||||
if (playlist == null)
|
||||
{
|
||||
playlist = ParseGraphQLPlaylist(playlistV2, playlistId);
|
||||
if (playlist == null) return null;
|
||||
}
|
||||
|
||||
// Parse tracks from this page
|
||||
if (playlistV2.TryGetProperty("content", out var content))
|
||||
{
|
||||
if (content.TryGetProperty("totalCount", out var totalCount))
|
||||
{
|
||||
totalTrackCount = totalCount.GetInt32();
|
||||
}
|
||||
|
||||
if (content.TryGetProperty("items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var track = ParseGraphQLTrack(item, offset + tracks.Count);
|
||||
if (track != null)
|
||||
{
|
||||
tracks.Add(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset += pageLimit;
|
||||
}
|
||||
|
||||
if (playlist != null)
|
||||
{
|
||||
playlist.Tracks = tracks;
|
||||
playlist.TotalTracks = tracks.Count;
|
||||
_logger.LogInformation("Fetched playlist '{Name}' with {Count} tracks via GraphQL", playlist.Name, tracks.Count);
|
||||
}
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
private SpotifyPlaylist? ParseGraphQLPlaylist(JsonElement playlistV2, string playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = playlistV2.TryGetProperty("name", out var n) ? n.GetString() : "Unknown Playlist";
|
||||
var description = playlistV2.TryGetProperty("description", out var d) ? d.GetString() : null;
|
||||
|
||||
string? ownerName = null;
|
||||
if (playlistV2.TryGetProperty("ownerV2", out var owner) &&
|
||||
owner.TryGetProperty("data", out var ownerData) &&
|
||||
ownerData.TryGetProperty("name", out var ownerNameProp))
|
||||
{
|
||||
ownerName = ownerNameProp.GetString();
|
||||
}
|
||||
|
||||
return new SpotifyPlaylist
|
||||
{
|
||||
SpotifyId = playlistId,
|
||||
Name = name ?? "Unknown Playlist",
|
||||
Description = description,
|
||||
OwnerName = ownerName,
|
||||
FetchedAt = DateTime.UtcNow,
|
||||
Tracks = new List<SpotifyPlaylistTrack>()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse GraphQL playlist metadata");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private SpotifyPlaylistTrack? ParseGraphQLTrack(JsonElement item, int position)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!item.TryGetProperty("itemV2", out var itemV2) ||
|
||||
!itemV2.TryGetProperty("data", out var data))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trackId = data.TryGetProperty("uri", out var uri) ? uri.GetString()?.Replace("spotify:track:", "") : null;
|
||||
var name = data.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
|
||||
if (string.IsNullOrEmpty(trackId) || string.IsNullOrEmpty(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse artists
|
||||
var artists = new List<string>();
|
||||
if (data.TryGetProperty("artists", out var artistsObj) &&
|
||||
artistsObj.TryGetProperty("items", out var artistItems))
|
||||
{
|
||||
foreach (var artist in artistItems.EnumerateArray())
|
||||
{
|
||||
if (artist.TryGetProperty("profile", out var profile) &&
|
||||
profile.TryGetProperty("name", out var artistName))
|
||||
{
|
||||
var artistNameStr = artistName.GetString();
|
||||
if (!string.IsNullOrEmpty(artistNameStr))
|
||||
{
|
||||
artists.Add(artistNameStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse album
|
||||
string? albumName = null;
|
||||
if (data.TryGetProperty("albumOfTrack", out var album) &&
|
||||
album.TryGetProperty("name", out var albumNameProp))
|
||||
{
|
||||
albumName = albumNameProp.GetString();
|
||||
}
|
||||
|
||||
// Parse duration
|
||||
int durationMs = 0;
|
||||
if (data.TryGetProperty("trackDuration", out var duration) &&
|
||||
duration.TryGetProperty("totalMilliseconds", out var durationMsProp))
|
||||
{
|
||||
durationMs = durationMsProp.GetInt32();
|
||||
}
|
||||
|
||||
// Parse album art
|
||||
string? albumArtUrl = null;
|
||||
if (data.TryGetProperty("albumOfTrack", out var albumOfTrack) &&
|
||||
albumOfTrack.TryGetProperty("coverArt", out var coverArt) &&
|
||||
coverArt.TryGetProperty("sources", out var sources) &&
|
||||
sources.GetArrayLength() > 0)
|
||||
{
|
||||
var firstSource = sources[0];
|
||||
if (firstSource.TryGetProperty("url", out var urlProp))
|
||||
{
|
||||
albumArtUrl = urlProp.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return new SpotifyPlaylistTrack
|
||||
{
|
||||
SpotifyId = trackId,
|
||||
Title = name,
|
||||
Artists = artists,
|
||||
Album = albumName ?? string.Empty,
|
||||
DurationMs = durationMs,
|
||||
Position = position,
|
||||
AlbumArtUrl = albumArtUrl,
|
||||
Isrc = null // GraphQL doesn't return ISRC, we'll fetch it separately if needed
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse GraphQL track");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SpotifyPlaylist?> FetchPlaylistMetadataAsync(
|
||||
string playlistId,
|
||||
string token,
|
||||
|
||||
Reference in New Issue
Block a user