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,