diff --git a/allstarr/Services/Spotify/SpotifyApiClient.cs b/allstarr/Services/Spotify/SpotifyApiClient.cs
index 7158cef..3d51b92 100644
--- a/allstarr/Services/Spotify/SpotifyApiClient.cs
+++ b/allstarr/Services/Spotify/SpotifyApiClient.cs
@@ -283,8 +283,8 @@ public class SpotifyApiClient : IDisposable
}
///
- /// 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.
///
/// Spotify playlist ID or URI
/// Cancellation token
@@ -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
}
}
+ ///
+ /// 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
+ ///
+ private async Task FetchPlaylistViaGraphQLAsync(
+ string playlistId,
+ string token,
+ CancellationToken cancellationToken)
+ {
+ const int pageLimit = 50;
+ var offset = 0;
+ var totalTrackCount = pageLimit;
+ var tracks = new List();
+
+ 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
+ {
+ { "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()
+ };
+ }
+ 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();
+ 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 FetchPlaylistMetadataAsync(
string playlistId,
string token,