Skip to content

Commit dbda8e7

Browse files
authored
Add support for custom anonymous & account token endpoint in Spotify integration (#286)
1 parent 3681f75 commit dbda8e7

File tree

7 files changed

+137
-80
lines changed

7 files changed

+137
-80
lines changed

README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ plugins:
129129
albumLoadLimit: 6 # The number of pages at 50 tracks each
130130
resolveArtistsInSearch: true # Whether to resolve artists in track search results (can be slow)
131131
localFiles: false # Enable local files support with Spotify playlists. Please note `uri` & `isrc` will be `null` & `identifier` will be `"local"`
132-
preferAnonymousToken: true # Whether to use the anonymous token for resolving tracks, artists and albums. Playlists are always resolved with the anonymous token to support autogenerated playlists.
132+
preferAnonymousToken: false # Whether to use the anonymous token for resolving tracks, artists and albums. Spotify generated playlists are always resolved with the anonymous tokens since they do not work otherwise. This requires the customTokenEndpoint to be set.
133+
customTokenEndpoint: "http://localhost:8080/api/token" # Optional custom endpoint for getting the anonymous token. If not set, spotify's default endpoint will be used which might not work. The response must match spotify's anonymous token response format.
133134
applemusic:
134135
countryCode: "US" # the country code you want to use for filtering the artists top tracks and language. See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
135136
mediaAPIToken: "your apple music api token" # apple music api token
@@ -289,11 +290,13 @@ PATCH /v4/lavasrc/config
289290

290291
##### Spotify Config Object
291292

292-
| Field | Type | Description |
293-
|---------------|--------|--------------------------|
294-
| ?clientId | string | The Spotify clientId |
295-
| ?clientSecret | string | The Spotify clientSecret |
296-
| ?spDc | string | The Spotify spDc cookie |
293+
| Field | Type | Description |
294+
|-----------------------|---------|-----------------------------------------------------------------------------|
295+
| ?clientId | string | The Spotify clientId |
296+
| ?clientSecret | string | The Spotify clientSecret |
297+
| ?spDc | string | The Spotify spDc cookie |
298+
| ?preferAnonymousToken | boolean | Whether to use the anonymous token for resolving tracks, artists and albums |
299+
| ?customTokenEndpoint | string | The custom endpoint for getting the anonymous token |
297300

298301
##### Apple Music Config Object
299302

@@ -347,7 +350,9 @@ PATCH /v4/lavasrc/config
347350
"spotify": {
348351
"clientId": "your client id",
349352
"clientSecret": "your client secret",
350-
"spDc": "your sp dc cookie"
353+
"spDc": "your sp dc cookie",
354+
"preferAnonymousToken": false,
355+
"customTokenEndpoint": "http://localhost/api/token"
351356
},
352357
"applemusic": {
353358
"mediaAPIToken": "your apple music api token"
@@ -466,6 +471,12 @@ var spotify = new SpotifySourceManager(clientId, clientSecret, spDc, countryCode
466471
playerManager.registerSourceManager(spotify);
467472
```
468473

474+
#### Access Tokens
475+
476+
Getting anonymous & account access tokens is generally optional but required if you want to resolve spotify generated playlists or lyrics.
477+
478+
You can use a service such as [Spotify Tokener](https://github.com/topi314/spotify-tokener) via the `customTokenEndpoint` option to support those.
479+
469480
#### LavaLyrics
470481

471482
<details>

application.example.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ plugins:
3333
albumLoadLimit: 6 # The number of pages at 50 tracks each
3434
resolveArtistsInSearch: true # Whether to resolve artists in track search results (can be slow)
3535
localFiles: false # Enable local files support with Spotify playlists. Please note `uri` & `isrc` will be `null` & `identifier` will be `"local"`
36-
preferAnonymousToken: true # Whether to use the anonymous token for resolving tracks, artists and albums. Playlists are always resolved with the anonymous token to support autogenerated playlists.
36+
preferAnonymousToken: false # Whether to use the anonymous token for resolving tracks, artists and albums. Spotify generated playlists are always resolved with the anonymous tokens since they do not work otherwise. This requires the customTokenEndpoint to be set.
37+
customTokenEndpoint: "http://localhost:8080/api/token" # Optional custom endpoint for getting the anonymous token. If not set, spotify's default endpoint will be used which might not work. The response must match spotify's anonymous token response format.
3738
applemusic:
3839
countryCode: "US" # the country code you want to use for filtering the artists top tracks and language. See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
3940
mediaAPIToken: "your apple music api token" # apple music api token

main/src/main/java/com/github/topi314/lavasrc/spotify/SpotifySourceManager.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
package com.github.topi314.lavasrc.spotify;
23

34
import com.github.topi314.lavalyrics.AudioLyricsManager;
@@ -86,9 +87,13 @@ public SpotifySourceManager(String clientId, String clientSecret, String spDc, S
8687
}
8788

8889
public SpotifySourceManager(String clientId, String clientSecret, boolean preferAnonymousToken, String spDc, String countryCode, Function<Void, AudioPlayerManager> audioPlayerManager, MirroringAudioTrackResolver mirroringAudioTrackResolver) {
90+
this(clientId, clientSecret, preferAnonymousToken, null, spDc, countryCode, audioPlayerManager, mirroringAudioTrackResolver);
91+
}
92+
93+
public SpotifySourceManager(String clientId, String clientSecret, boolean preferAnonymousToken, String customTokenEndpoint, String spDc, String countryCode, Function<Void, AudioPlayerManager> audioPlayerManager, MirroringAudioTrackResolver mirroringAudioTrackResolver) {
8994
super(audioPlayerManager, mirroringAudioTrackResolver);
9095

91-
this.tokenTracker = new SpotifyTokenTracker(this, clientId, clientSecret, spDc);
96+
this.tokenTracker = new SpotifyTokenTracker(this, clientId, clientSecret, spDc, customTokenEndpoint);
9297

9398
if (countryCode == null || countryCode.isEmpty()) {
9499
countryCode = "US";
@@ -125,6 +130,10 @@ public void setPreferAnonymousToken(boolean preferAnonymousToken) {
125130
this.preferAnonymousToken = preferAnonymousToken;
126131
}
127132

133+
public void setCustomTokenEndpoint(String customTokenEndpoint) {
134+
this.tokenTracker.setCustomTokenEndpoint(customTokenEndpoint);
135+
}
136+
128137
@NotNull
129138
@Override
130139
public String getSourceName() {
@@ -180,7 +189,7 @@ public AudioLyrics getLyrics(String id) throws IOException {
180189
var request = new HttpGet(CLIENT_API_BASE + "color-lyrics/v2/track/" + id + "?format=json&vocalRemoval=false");
181190
request.setHeader("User-Agent", USER_AGENT);
182191
request.setHeader("App-Platform", "WebPlayer");
183-
request.setHeader("Authorization", "Bearer " + this.tokenTracker.getAccountToken());
192+
request.setHeader("Authorization", "Bearer " + this.tokenTracker.getAccountAccessToken());
184193
var json = LavaSrcTools.fetchResponseAsJson(this.httpInterfaceManager.getInterface(), request);
185194
if (json == null) {
186195
return null;
@@ -420,7 +429,10 @@ public AudioItem getAlbum(String id, boolean preview) throws IOException {
420429
}
421430

422431
public AudioItem getPlaylist(String id, boolean preview) throws IOException {
423-
var json = this.getJson(API_BASE + "playlists/" + id, true, false);
432+
// autogenerated playlists seem to start with "37i9dQZ" and are not accessible without an anonymous token lol
433+
var anonymous = id.startsWith("37i9dQZ");
434+
435+
var json = this.getJson(API_BASE + "playlists/" + id, anonymous, this.preferAnonymousToken);
424436
if (json == null) {
425437
return AudioReference.NO_TRACK;
426438
}
@@ -430,7 +442,7 @@ public AudioItem getPlaylist(String id, boolean preview) throws IOException {
430442
var offset = 0;
431443
var pages = 0;
432444
do {
433-
page = this.getJson(API_BASE + "playlists/" + id + "/tracks?limit=" + PLAYLIST_MAX_PAGE_ITEMS + "&offset=" + offset, true, false);
445+
page = this.getJson(API_BASE + "playlists/" + id + "/tracks?limit=" + PLAYLIST_MAX_PAGE_ITEMS + "&offset=" + offset, anonymous, this.preferAnonymousToken);
434446
offset += PLAYLIST_MAX_PAGE_ITEMS;
435447

436448
for (var value : page.get("items").values()) {

main/src/main/java/com/github/topi314/lavasrc/spotify/SpotifyTokenTracker.java

Lines changed: 87 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
import java.util.ArrayList;
2828
import java.util.Base64;
2929
import java.util.List;
30-
import java.util.regex.Matcher;
3130
import java.util.regex.Pattern;
3231

3332
public class SpotifyTokenTracker {
@@ -42,17 +41,23 @@ public class SpotifyTokenTracker {
4241
private String accessToken;
4342
private Instant expires;
4443

44+
private String customTokenEndpoint;
4545
private String anonymousAccessToken;
4646
private Instant anonymousExpires;
4747

4848
private String spDc;
49-
private String accountToken;
50-
private Instant accountTokenExpire;
49+
private String accountAccessToken;
50+
private Instant accountAccessTokenExpire;
5151

5252
public SpotifyTokenTracker(SpotifySourceManager source, String clientId, String clientSecret, String spDc) {
53+
this(source, clientId, clientSecret, spDc, null);
54+
}
55+
56+
public SpotifyTokenTracker(SpotifySourceManager source, String clientId, String clientSecret, String spDc, String customTokenEndpoint) {
5357
this.sourceManager = source;
5458
this.clientId = clientId;
5559
this.clientSecret = clientSecret;
60+
this.customTokenEndpoint = customTokenEndpoint;
5661

5762
if (!hasValidCredentials()) {
5863
log.debug("Missing/invalid credentials, falling back to public token.");
@@ -72,6 +77,14 @@ public void setClientIDS(String clientId, String clientSecret) {
7277
this.expires = null;
7378
}
7479

80+
public void setCustomTokenEndpoint(String customTokenEndpoint) {
81+
this.customTokenEndpoint = customTokenEndpoint;
82+
this.anonymousAccessToken = null;
83+
this.anonymousExpires = null;
84+
this.accountAccessToken = null;
85+
this.accountAccessTokenExpire = null;
86+
}
87+
7588
private boolean hasValidCredentials() {
7689
return clientId != null && !clientId.isEmpty() && clientSecret != null && !clientSecret.isEmpty();
7790
}
@@ -125,11 +138,11 @@ private void refreshAnonymousAccessToken() throws IOException {
125138

126139
var json = LavaSrcTools.fetchResponseAsJson(sourceManager.getHttpInterface(), request);
127140
if (json == null) {
128-
throw new RuntimeException("No response from Spotify API");
141+
throw new RuntimeException("No response from Spotify API while fetching anonymous access token.");
129142
}
130143
if (!json.get("error").isNull()) {
131144
var error = json.get("error").text();
132-
throw new RuntimeException("Error while fetching access token: " + error);
145+
throw new RuntimeException("Error while fetching anonymous access token: " + error);
133146
}
134147

135148
anonymousAccessToken = json.get("accessToken").text();
@@ -138,36 +151,39 @@ private void refreshAnonymousAccessToken() throws IOException {
138151

139152
public void setSpDc(String spDc) {
140153
this.spDc = spDc;
141-
this.accountToken = null;
142-
this.accountTokenExpire = null;
154+
this.accountAccessToken = null;
155+
this.accountAccessTokenExpire = null;
143156
}
144157

145-
public String getAccountToken() throws IOException {
146-
if (this.accountToken == null || this.accountTokenExpire == null || this.accountTokenExpire.isBefore(Instant.now())) {
158+
public String getAccountAccessToken() throws IOException {
159+
if (this.accountAccessToken == null || this.accountAccessTokenExpire == null || this.accountAccessTokenExpire.isBefore(Instant.now())) {
147160
synchronized (this) {
148-
if (this.accountToken == null || this.accountTokenExpire == null || this.accountTokenExpire.isBefore(Instant.now())) {
161+
if (this.accountAccessToken == null || this.accountAccessTokenExpire == null || this.accountAccessTokenExpire.isBefore(Instant.now())) {
149162
log.debug("Account token is invalid or expired, refreshing token...");
150-
this.refreshAccountToken();
163+
this.refreshAccountAccessToken();
151164
}
152165
}
153166
}
154-
return this.accountToken;
167+
return this.accountAccessToken;
155168
}
156169

157-
public void refreshAccountToken() throws IOException {
170+
public void refreshAccountAccessToken() throws IOException {
158171
var request = new HttpGet(generateGetAccessTokenURL());
159172
request.addHeader("App-Platform", "WebPlayer");
160173
request.addHeader("Cookie", "sp_dc=" + this.spDc);
161174

162175
try {
163176
var json = LavaSrcTools.fetchResponseAsJson(this.sourceManager.getHttpInterface(), request);
177+
if (json == null) {
178+
throw new RuntimeException("No response from Spotify API while fetching account access token.");
179+
}
164180
if (!json.get("error").isNull()) {
165-
String error = json.get("error").text();
181+
var error = json.get("error").text();
166182
log.error("Error while fetching account token: {}", error);
167-
throw new RuntimeException("Error while fetching account token: " + error);
183+
throw new RuntimeException("Error while fetching account access token: " + error);
168184
}
169-
this.accountToken = json.get("accessToken").text();
170-
this.accountTokenExpire = Instant.ofEpochMilli(json.get("accessTokenExpirationTimestampMs").asLong(0));
185+
this.accountAccessToken = json.get("accessToken").text();
186+
this.accountAccessTokenExpire = Instant.ofEpochMilli(json.get("accessTokenExpirationTimestampMs").asLong(0));
171187
} catch (IOException e) {
172188
log.error("Account token refreshing failed.", e);
173189
throw new RuntimeException("Account token refreshing failed", e);
@@ -178,50 +194,23 @@ public boolean hasValidAccountCredentials() {
178194
return this.spDc != null && !this.spDc.isEmpty();
179195
}
180196

181-
public static String generateTOTP(String secret, int period, int digits) {
182-
long time = System.currentTimeMillis() / 1000 / period;
183-
ByteBuffer buffer = ByteBuffer.allocate(8);
184-
buffer.putLong(time);
185-
byte[] timeBytes = buffer.array();
186-
187-
try {
188-
SecretKeySpec keySpec = new SecretKeySpec(hexStringToByteArray(secret), "HmacSHA1");
189-
Mac mac = Mac.getInstance("HmacSHA1");
190-
mac.init(keySpec);
191-
byte[] hash = mac.doFinal(timeBytes);
192-
int offset = hash[hash.length - 1] & 0xF;
193-
int binary = ((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16) |
194-
((hash[offset + 2] & 0xFF) << 8) | (hash[offset + 3] & 0xFF);
195-
int otp = binary % (int) Math.pow(10, digits);
196-
return String.format("%0" + digits + "d", otp);
197-
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
198-
throw new RuntimeException("Error generating TOTP", e);
197+
private String generateGetAccessTokenURL() throws IOException {
198+
if (this.customTokenEndpoint != null && !this.customTokenEndpoint.isBlank()) {
199+
return this.customTokenEndpoint;
199200
}
200-
}
201201

202-
public static byte[] extractSecret(CloseableHttpClient client, String scriptUrl) throws IOException {
203-
HttpGet scriptRequest = new HttpGet(scriptUrl);
204-
try (CloseableHttpResponse scriptResponse = client.execute(scriptRequest)) {
205-
String scriptContent = EntityUtils.toString(scriptResponse.getEntity());
206-
207-
Matcher matcher = SECRET_PATTERN.matcher(scriptContent);
208-
if (matcher.find()) {
209-
String secretArrayString = matcher.group(1);
210-
String[] secretArray = secretArrayString.split(",");
211-
byte[] secretByteArray = new byte[secretArray.length];
212-
for (int i = 0; i < secretArray.length; i++) {
213-
secretByteArray[i] = (byte) Integer.parseInt(secretArray[i].trim());
214-
}
215-
216-
return secretByteArray;
217-
} else {
218-
log.error("No secret array found in script: {}", scriptUrl);
219-
return null;
220-
}
202+
var secret = requestSecret();
203+
if (secret == null) {
204+
throw new IOException("Failed to retrieve secret from Spotify.");
221205
}
206+
var transformedSecret = convertArrayToTransformedByteArray(secret);
207+
var hexSecret = toHexString(transformedSecret);
208+
var totp = generateTOTP(hexSecret, 30, 6);
209+
var ts = System.currentTimeMillis();
210+
return "https://open.spotify.com/api/token?reason=init&productType=web-player&totp=" + totp + "&totpVer=7&ts=" + ts;
222211
}
223212

224-
public static byte[] requestSecret() throws IOException {
213+
private byte[] requestSecret() throws IOException {
225214
String homepageUrl = "https://open.spotify.com/";
226215
String scriptPattern = "mobile-web-player";
227216

@@ -263,19 +252,50 @@ public static byte[] requestSecret() throws IOException {
263252
return null;
264253
}
265254

266-
public static String generateGetAccessTokenURL() throws IOException {
267-
var secret = requestSecret();
268-
if (secret == null) {
269-
throw new IOException("Failed to retrieve secret from Spotify.");
255+
private static String generateTOTP(String secret, int period, int digits) {
256+
var time = System.currentTimeMillis() / 1000 / period;
257+
var buffer = ByteBuffer.allocate(8);
258+
buffer.putLong(time);
259+
var timeBytes = buffer.array();
260+
261+
try {
262+
var keySpec = new SecretKeySpec(hexStringToByteArray(secret), "HmacSHA1");
263+
var mac = Mac.getInstance("HmacSHA1");
264+
mac.init(keySpec);
265+
var hash = mac.doFinal(timeBytes);
266+
var offset = hash[hash.length - 1] & 0xF;
267+
var binary = ((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16) |
268+
((hash[offset + 2] & 0xFF) << 8) | (hash[offset + 3] & 0xFF);
269+
var otp = binary % (int) Math.pow(10, digits);
270+
return String.format("%0" + digits + "d", otp);
271+
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
272+
throw new RuntimeException("Error generating TOTP", e);
273+
}
274+
}
275+
276+
private static byte[] extractSecret(CloseableHttpClient client, String scriptUrl) throws IOException {
277+
var scriptRequest = new HttpGet(scriptUrl);
278+
try (var scriptResponse = client.execute(scriptRequest)) {
279+
var scriptContent = EntityUtils.toString(scriptResponse.getEntity());
280+
281+
var matcher = SECRET_PATTERN.matcher(scriptContent);
282+
if (matcher.find()) {
283+
var secretArrayString = matcher.group(1);
284+
var secretArray = secretArrayString.split(",");
285+
byte[] secretByteArray = new byte[secretArray.length];
286+
for (int i = 0; i < secretArray.length; i++) {
287+
secretByteArray[i] = (byte) Integer.parseInt(secretArray[i].trim());
288+
}
289+
290+
return secretByteArray;
291+
} else {
292+
log.error("No secret array found in script: {}", scriptUrl);
293+
return null;
294+
}
270295
}
271-
byte[] transformedSecret = convertArrayToTransformedByteArray(secret);
272-
var hexSecret = toHexString(transformedSecret);
273-
var totp = generateTOTP(hexSecret, 30, 6);
274-
long ts = System.currentTimeMillis();
275-
return "https://open.spotify.com/api/token?reason=init&productType=web-player&totp=" + totp + "&totpVer=7&ts=" + ts;
276296
}
277297

278-
public static byte[] convertArrayToTransformedByteArray(byte[] array) {
298+
private static byte[] convertArrayToTransformedByteArray(byte[] array) {
279299
byte[] transformed = new byte[array.length];
280300
for (int i = 0; i < array.length; i++) {
281301
// XOR with dat transform
@@ -284,7 +304,7 @@ public static byte[] convertArrayToTransformedByteArray(byte[] array) {
284304
return transformed;
285305
}
286306

287-
public static String toHexString(byte[] transformed) {
307+
private static String toHexString(byte[] transformed) {
288308
StringBuilder joinedString = new StringBuilder();
289309
for (byte b : transformed) {
290310
joinedString.append(b);

plugin/src/main/java/com/github/topi314/lavasrc/plugin/LavaSrcPlugin.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public LavaSrcPlugin(
6868
this.lyricsSourcesConfig = lyricsSourcesConfig;
6969

7070
if (sourcesConfig.isSpotify() || lyricsSourcesConfig.isSpotify()) {
71-
this.spotify = new SpotifySourceManager(spotifyConfig.getClientId(), spotifyConfig.getClientSecret(), spotifyConfig.isPreferAnonymousToken(), spotifyConfig.getSpDc(), spotifyConfig.getCountryCode(), unused -> manager, new DefaultMirroringAudioTrackResolver(pluginConfig.getProviders()));
71+
this.spotify = new SpotifySourceManager(spotifyConfig.getClientId(), spotifyConfig.getClientSecret(), spotifyConfig.isPreferAnonymousToken(), spotifyConfig.getCustomTokenEndpoint(), spotifyConfig.getSpDc(), spotifyConfig.getCountryCode(), unused -> manager, new DefaultMirroringAudioTrackResolver(pluginConfig.getProviders()));
7272
if (spotifyConfig.getPlaylistLoadLimit() > 0) {
7373
this.spotify.setPlaylistPageLimit(spotifyConfig.getPlaylistLoadLimit());
7474
}
@@ -276,6 +276,9 @@ public void updateConfig(@RequestBody Config config) {
276276
if (spotifyConfig.getPreferAnonymousToken() != null) {
277277
this.spotify.setPreferAnonymousToken(spotifyConfig.getPreferAnonymousToken());
278278
}
279+
if (spotifyConfig.getCustomTokenEndpoint() != null) {
280+
this.spotify.setCustomTokenEndpoint(spotifyConfig.getCustomTokenEndpoint());
281+
}
279282
}
280283

281284
var appleMusicConfig = config.getAppleMusic();

0 commit comments

Comments
 (0)