Skip to content

Commit 87e066c

Browse files
committed
Bump version to 1.5.0 and implement GraphQL API for track information
1 parent 1625d48 commit 87e066c

10 files changed

Lines changed: 219 additions & 115 deletions

File tree

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ plugins {
44
}
55

66
group 'de.labystudio'
7-
version '1.4.7'
7+
version '1.5.0'
88

99
compileJava {
1010
sourceCompatibility = '1.8'

src/main/java/de/labystudio/spotifyapi/open/OpenSpotifyAPI.java

Lines changed: 81 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
import com.google.gson.stream.JsonReader;
77
import de.labystudio.spotifyapi.model.Track;
88
import de.labystudio.spotifyapi.open.model.AccessTokenResponse;
9+
import de.labystudio.spotifyapi.open.model.GraphQLOperation;
10+
import de.labystudio.spotifyapi.open.model.track.Image;
911
import de.labystudio.spotifyapi.open.model.track.OpenTrack;
12+
import de.labystudio.spotifyapi.open.model.track.TrackResponse;
1013
import de.labystudio.spotifyapi.open.totp.TOTP;
1114
import de.labystudio.spotifyapi.open.totp.gson.SecretDeserializer;
1215
import de.labystudio.spotifyapi.open.totp.gson.SecretSerializer;
@@ -16,13 +19,11 @@
1619
import javax.imageio.ImageIO;
1720
import javax.net.ssl.HttpsURLConnection;
1821
import java.awt.image.BufferedImage;
19-
import java.io.BufferedReader;
20-
import java.io.IOException;
21-
import java.io.InputStream;
22-
import java.io.InputStreamReader;
22+
import java.io.*;
2323
import java.net.HttpURLConnection;
2424
import java.net.URL;
2525
import java.nio.charset.StandardCharsets;
26+
import java.util.List;
2627
import java.util.concurrent.Executor;
2728
import java.util.concurrent.Executors;
2829
import java.util.function.Consumer;
@@ -43,7 +44,7 @@ public class OpenSpotifyAPI {
4344
public static final String USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537." + (int) (Math.random() * 90);
4445
public static final String URL_API_GEN_ACCESS_TOKEN = "https://open.spotify.com/api/token?reason=%s&productType=web-player&totp=%s&totpServer=%s&totpVer=%s";
4546

46-
public static final String URL_API_TRACKS = "https://api.spotify.com/v1/tracks/%s";
47+
public static final String URL_API_GRAPHQL = "https://api-partner.spotify.com/pathfinder/v1/query";
4748
public static final String URL_API_SERVER_TIME = "https://open.spotify.com/api/server-time";
4849

4950
private final Executor executor = Executors.newSingleThreadExecutor();
@@ -292,12 +293,18 @@ public BufferedImage requestImage(String trackId) throws IOException {
292293
private String requestImageUrl(String trackId) throws IOException {
293294
// Request track information
294295
OpenTrack openTrack = this.requestOpenTrack(trackId);
295-
if (openTrack == null) {
296+
if (openTrack == null || openTrack.album == null) {
297+
return null;
298+
}
299+
300+
// Get images from album
301+
List<Image> images = openTrack.album.getImages();
302+
if (images == null || images.isEmpty()) {
296303
return null;
297304
}
298305

299-
// Get largest image url
300-
return openTrack.album.images.get(0).url;
306+
// Get largest image url (images are sorted by size in descending order from API)
307+
return images.get(0).url;
301308
}
302309

303310
/**
@@ -324,44 +331,99 @@ public OpenTrack requestOpenTrack(String trackId) throws IOException {
324331
return cachedOpenTrack;
325332
}
326333

327-
// Create REST API url
328-
String url = String.format(URL_API_TRACKS, trackId);
329-
OpenTrack openTrack = this.request(url, OpenTrack.class, true);
334+
// Use GraphQL API to get track information
335+
TrackResponse graphQLResponse = this.requestTrack(trackId);
336+
337+
// Extract OpenTrack from GraphQL response
338+
OpenTrack openTrack = graphQLResponse != null && graphQLResponse.data != null
339+
? graphQLResponse.data.trackUnion
340+
: null;
341+
342+
if (openTrack == null) {
343+
return null;
344+
}
330345

331346
// Cache the open track and return it
332347
this.openTrackCache.push(trackId, openTrack);
333348
return openTrack;
334349
}
335350

336351
/**
337-
* Request the open spotify api with the given url
352+
* Request track information using GraphQL API
353+
*
354+
* @param trackId The track id to lookup
355+
* @return GraphQL response
356+
* @throws IOException if the request failed
357+
*/
358+
private TrackResponse requestTrack(String trackId) throws IOException {
359+
JsonObject variables = new JsonObject();
360+
variables.addProperty("uri", "spotify:track:" + trackId);
361+
return this.requestGraphQL(GraphQLOperation.GET_TRACK, variables, TrackResponse.class);
362+
}
363+
364+
/**
365+
* Request the open spotify GraphQL api with the given operation
338366
* It will try again once if it fails
339367
*
340-
* @param url The url to request
341-
* @param clazz The class to parse the response to
342-
* @param canGenerateNewAccessToken It will try again once if it fails
343-
* @param <T> The type of the response
368+
* @param operation The GraphQL operation to execute
369+
* @param variables The variables for the GraphQL query
370+
* @param clazz The class to parse the response to
371+
* @param <T> The type of the response
344372
* @return The parsed response
345373
* @throws IOException if the request failed
346374
*/
347-
public <T> T request(String url, Class<?> clazz, boolean canGenerateNewAccessToken) throws IOException {
375+
public <T> T requestGraphQL(
376+
GraphQLOperation operation,
377+
JsonObject variables,
378+
Class<T> clazz
379+
) throws IOException {
380+
return this.requestGraphQL(operation, variables, clazz, true);
381+
}
382+
383+
private <T> T requestGraphQL(
384+
GraphQLOperation operation,
385+
JsonObject variables,
386+
Class<T> clazz,
387+
boolean canGenerateNewAccessToken
388+
) throws IOException {
348389
// Generate access token if not present
349390
if (this.accessTokenResponse == null) {
350391
this.accessTokenResponse = this.generateAccessToken();
351392
}
352393

394+
// Build GraphQL query using JsonObject
395+
JsonObject query = new JsonObject();
396+
query.addProperty("operationName", operation.getOperationName());
397+
query.add("variables", variables);
398+
399+
JsonObject extensions = new JsonObject();
400+
JsonObject persistedQuery = new JsonObject();
401+
persistedQuery.addProperty("version", 1);
402+
persistedQuery.addProperty("sha256Hash", operation.getSha256Hash());
403+
extensions.add("persistedQuery", persistedQuery);
404+
query.add("extensions", extensions);
405+
353406
// Connect
354-
HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection();
407+
HttpsURLConnection connection = (HttpsURLConnection) new URL(URL_API_GRAPHQL).openConnection();
408+
connection.setRequestMethod("POST");
409+
connection.setDoOutput(true);
355410
connection.addRequestProperty("User-Agent", USER_AGENT);
356411
connection.addRequestProperty("referer", "https://open.spotify.com/");
357412
connection.addRequestProperty("app-platform", "WebPlayer");
358413
connection.addRequestProperty("origin", "https://open.spotify.com");
414+
connection.addRequestProperty("content-type", "application/json");
359415

360416
// Add access token
361417
if (this.accessTokenResponse != null) {
362418
connection.addRequestProperty("authorization", "Bearer " + this.accessTokenResponse.accessToken);
363419
}
364420

421+
// Write the query
422+
try (OutputStream os = connection.getOutputStream()) {
423+
byte[] input = GSON.toJson(query).getBytes(StandardCharsets.UTF_8);
424+
os.write(input, 0, input.length);
425+
}
426+
365427
// Access token outdated
366428
if (connection.getResponseCode() / 100 != 2) {
367429
// Prevent infinite loop
@@ -370,7 +432,7 @@ public <T> T request(String url, Class<?> clazz, boolean canGenerateNewAccessTok
370432
this.accessTokenResponse = this.generateAccessToken();
371433

372434
// Try again
373-
return this.request(url, clazz, false);
435+
return this.requestGraphQL(operation, variables, clazz, false);
374436
} else {
375437
// Request failed twice
376438
return null;
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package de.labystudio.spotifyapi.open.model;
2+
3+
/**
4+
* Represents a GraphQL operation with its persisted query hash.
5+
* Spotify uses persisted queries (APQ) where operations are identified by their SHA-256 hash.
6+
*/
7+
public class GraphQLOperation {
8+
9+
public static final GraphQLOperation GET_TRACK = new GraphQLOperation(
10+
"getTrack",
11+
"612585ae06ba435ad26369870deaae23b5c8800a256cd8a57e08eddc25a37294"
12+
);
13+
14+
private final String operationName;
15+
private final String sha256Hash;
16+
17+
public GraphQLOperation(String operationName, String sha256Hash) {
18+
this.operationName = operationName;
19+
this.sha256Hash = sha256Hash;
20+
}
21+
22+
public String getOperationName() {
23+
return this.operationName;
24+
}
25+
26+
public String getSha256Hash() {
27+
return this.sha256Hash;
28+
}
29+
}
30+

src/main/java/de/labystudio/spotifyapi/open/model/track/Album.java

Lines changed: 0 additions & 38 deletions
This file was deleted.
Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
1-
21
package de.labystudio.spotifyapi.open.model.track;
32

4-
import com.google.gson.annotations.SerializedName;
5-
3+
/**
4+
* Artist information
5+
*/
66
public class Artist {
7-
8-
@SerializedName("external_urls")
9-
public ExternalUrls externalUrls;
10-
public String href;
117
public String id;
128
public String name;
13-
public String type;
149
public String uri;
1510

1611
}

src/main/java/de/labystudio/spotifyapi/open/model/track/ExternalIds.java

Lines changed: 0 additions & 8 deletions
This file was deleted.

src/main/java/de/labystudio/spotifyapi/open/model/track/ExternalUrls.java

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)