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>
|
/// <summary>
|
||||||
/// Fetches a playlist with all its tracks from Spotify.
|
/// Fetches a playlist with all its tracks from Spotify using the GraphQL API.
|
||||||
/// Uses the web API to access editorial playlists that aren't available via the official API.
|
/// This matches the approach used by the Jellyfin Spotify Import plugin.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="playlistId">Spotify playlist ID or URI</param>
|
/// <param name="playlistId">Spotify playlist ID or URI</param>
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
@@ -303,17 +303,8 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Use the official API with the web token for playlist data
|
// Use GraphQL API (same as Jellyfin plugin) - more reliable and less rate-limited
|
||||||
var playlist = await FetchPlaylistMetadataAsync(playlistId, token, cancellationToken);
|
return await FetchPlaylistViaGraphQLAsync(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;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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(
|
private async Task<SpotifyPlaylist?> FetchPlaylistMetadataAsync(
|
||||||
string playlistId,
|
string playlistId,
|
||||||
string token,
|
string token,
|
||||||
|
|||||||
Reference in New Issue
Block a user