Skip to content

Commit 08b7890

Browse files
committed
Support client-token header in API requests
1 parent 5bbad63 commit 08b7890

6 files changed

Lines changed: 256 additions & 5 deletions

File tree

lib/src/main/java/xyz/gianlu/librespot/core/Session.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2021 devgianlu
2+
* Copyright 2022 devgianlu
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -826,6 +826,7 @@ private Inner(@NotNull Connect.DeviceType deviceType, @NotNull String deviceName
826826
public static abstract class AbsBuilder<T extends AbsBuilder> {
827827
protected final Configuration conf;
828828
protected String deviceId = null;
829+
protected String clientToken = null;
829830
protected String deviceName = "librespot-java";
830831
protected Connect.DeviceType deviceType = Connect.DeviceType.COMPUTER;
831832
protected String preferredLocale = "en";
@@ -874,6 +875,16 @@ public T setDeviceId(@Nullable String deviceId) {
874875
return (T) this;
875876
}
876877

878+
/**
879+
* Sets the client token. If empty, it will be retrieved.
880+
*
881+
* @param token A 168 bytes Base64 encoded string
882+
*/
883+
public T setClientToken(@Nullable String token) {
884+
this.clientToken = token;
885+
return (T) this;
886+
}
887+
877888
/**
878889
* Sets the device type.
879890
*
@@ -1034,6 +1045,7 @@ public Session create() throws IOException, GeneralSecurityException, SpotifyAut
10341045
Session session = new Session(new Inner(deviceType, deviceName, deviceId, preferredLocale, conf));
10351046
session.connect();
10361047
session.authenticate(loginCredentials);
1048+
session.api().setClientToken(clientToken);
10371049
return session;
10381050
}
10391051
}

lib/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2021 devgianlu
2+
* Copyright 2022 devgianlu
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,6 +17,8 @@
1717
package xyz.gianlu.librespot.dealer;
1818

1919
import com.google.protobuf.Message;
20+
import com.spotify.clienttoken.data.v0.Connectivity;
21+
import com.spotify.clienttoken.http.v0.ClientToken;
2022
import com.spotify.connectstate.Connect;
2123
import com.spotify.extendedmetadata.ExtendedMetadata;
2224
import com.spotify.metadata.Metadata;
@@ -26,9 +28,10 @@
2628
import org.jetbrains.annotations.Nullable;
2729
import org.slf4j.Logger;
2830
import org.slf4j.LoggerFactory;
29-
import xyz.gianlu.librespot.core.ApResolver;
31+
import xyz.gianlu.librespot.Version;
3032
import xyz.gianlu.librespot.core.Session;
3133
import xyz.gianlu.librespot.mercury.MercuryClient;
34+
import xyz.gianlu.librespot.mercury.MercuryRequests;
3235
import xyz.gianlu.librespot.metadata.*;
3336

3437
import java.io.IOException;
@@ -43,6 +46,7 @@ public final class ApiClient {
4346
private static final Logger LOGGER = LoggerFactory.getLogger(ApiClient.class);
4447
private final Session session;
4548
private final String baseUrl;
49+
private String clientToken = null;
4650

4751
public ApiClient(@NotNull Session session) {
4852
this.session = session;
@@ -54,7 +58,7 @@ public static RequestBody protoBody(@NotNull Message msg) {
5458
return new RequestBody() {
5559
@Override
5660
public MediaType contentType() {
57-
return MediaType.get("application/protobuf");
61+
return MediaType.get("application/x-protobuf");
5862
}
5963

6064
@Override
@@ -66,10 +70,17 @@ public void writeTo(@NotNull BufferedSink sink) throws IOException {
6670

6771
@NotNull
6872
private Request buildRequest(@NotNull String method, @NotNull String suffix, @Nullable Headers headers, @Nullable RequestBody body) throws IOException, MercuryClient.MercuryException {
73+
if (clientToken == null) {
74+
ClientToken.ClientTokenResponse resp = clientToken();
75+
clientToken = resp.getGrantedToken().getToken();
76+
LOGGER.debug("Updated client token: {}", clientToken);
77+
}
78+
6979
Request.Builder request = new Request.Builder();
7080
request.method(method, body);
7181
if (headers != null) request.headers(headers);
7282
request.addHeader("Authorization", "Bearer " + session.tokens().get("playlist-read"));
83+
request.addHeader("client-token", clientToken);
7384
request.url(baseUrl + suffix);
7485
return request.build();
7586
}
@@ -201,6 +212,49 @@ public ExtendedMetadata.BatchedExtensionResponse getExtendedMetadata(@NotNull Ex
201212
}
202213
}
203214

215+
@NotNull
216+
private ClientToken.ClientTokenResponse clientToken() throws IOException {
217+
ClientToken.ClientTokenRequest protoReq = ClientToken.ClientTokenRequest.newBuilder()
218+
.setRequestType(ClientToken.ClientTokenRequestType.REQUEST_CLIENT_DATA_REQUEST)
219+
.setClientData(ClientToken.ClientDataRequest.newBuilder()
220+
.setClientId(MercuryRequests.KEYMASTER_CLIENT_ID)
221+
.setClientVersion(Version.versionNumber())
222+
.setConnectivitySdkData(Connectivity.ConnectivitySdkData.newBuilder()
223+
.setDeviceId(session.deviceId())
224+
.setPlatformSpecificData(Connectivity.PlatformSpecificData.newBuilder()
225+
.setWindows(Connectivity.NativeWindowsData.newBuilder()
226+
.setSomething1(10)
227+
.setSomething3(21370)
228+
.setSomething4(2)
229+
.setSomething6(9)
230+
.setSomething7(332)
231+
.setSomething8(34404)
232+
.setSomething10(true)
233+
.build())
234+
.build())
235+
.build())
236+
.build())
237+
.build();
238+
239+
Request.Builder req = new Request.Builder()
240+
.url("https://clienttoken.spotify.com/v1/clienttoken")
241+
.header("Accept", "application/x-protobuf")
242+
.header("Content-Encoding", "")
243+
.post(protoBody(protoReq));
244+
245+
try (Response resp = session.client().newCall(req.build()).execute()) {
246+
StatusCodeException.checkStatus(resp);
247+
248+
ResponseBody body = resp.body();
249+
if (body == null) throw new IOException();
250+
return ClientToken.ClientTokenResponse.parseFrom(body.byteStream());
251+
}
252+
}
253+
254+
public void setClientToken(@Nullable String clientToken) {
255+
this.clientToken = clientToken;
256+
}
257+
204258
public static class StatusCodeException extends IOException {
205259
public final int code;
206260

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
syntax = "proto3";
2+
3+
package spotify.clienttoken.http.v0;
4+
5+
import "connectivity.proto";
6+
7+
option optimize_for = CODE_SIZE;
8+
option java_package = "com.spotify.clienttoken.http.v0";
9+
10+
message ClientTokenRequest {
11+
ClientTokenRequestType request_type = 1;
12+
13+
oneof request {
14+
ClientDataRequest client_data = 2;
15+
ChallengeAnswersRequest challenge_answers = 3;
16+
}
17+
}
18+
19+
message ClientDataRequest {
20+
string client_version = 1;
21+
string client_id = 2;
22+
23+
oneof data {
24+
data.v0.ConnectivitySdkData connectivity_sdk_data = 3;
25+
}
26+
}
27+
28+
message ChallengeAnswersRequest {
29+
string state = 1;
30+
repeated ChallengeAnswer answers = 2;
31+
}
32+
33+
message ClientTokenResponse {
34+
ClientTokenResponseType response_type = 1;
35+
36+
oneof response {
37+
GrantedTokenResponse granted_token = 2;
38+
ChallengesResponse challenges = 3;
39+
}
40+
}
41+
42+
message TokenDomain {
43+
string domain = 1;
44+
}
45+
46+
message GrantedTokenResponse {
47+
string token = 1;
48+
int32 expires_after_seconds = 2;
49+
int32 refresh_after_seconds = 3;
50+
repeated TokenDomain domains = 4;
51+
}
52+
53+
message ChallengesResponse {
54+
string state = 1;
55+
repeated Challenge challenges = 2;
56+
}
57+
58+
message ClientSecretParameters {
59+
string salt = 1;
60+
}
61+
62+
message EvaluateJSParameters {
63+
string code = 1;
64+
repeated string libraries = 2;
65+
}
66+
67+
message HashCashParameters {
68+
int32 length = 1;
69+
string prefix = 2;
70+
}
71+
72+
message Challenge {
73+
ChallengeType type = 1;
74+
75+
oneof parameters {
76+
ClientSecretParameters client_secret_parameters = 2;
77+
EvaluateJSParameters evaluate_js_parameters = 3;
78+
HashCashParameters evaluate_hashcash_parameters = 4;
79+
}
80+
}
81+
82+
message ClientSecretHMACAnswer {
83+
string hmac = 1;
84+
}
85+
86+
message EvaluateJSAnswer {
87+
string result = 1;
88+
}
89+
90+
message HashCashAnswer {
91+
string suffix = 1;
92+
}
93+
94+
message ChallengeAnswer {
95+
ChallengeType ChallengeType = 1;
96+
97+
oneof answer {
98+
ClientSecretHMACAnswer client_secret = 2;
99+
EvaluateJSAnswer evaluate_js = 3;
100+
HashCashAnswer hash_cash = 4;
101+
}
102+
}
103+
104+
message ClientTokenBadRequest {
105+
string message = 1;
106+
}
107+
108+
enum ClientTokenRequestType {
109+
REQUEST_UNKNOWN = 0;
110+
REQUEST_CLIENT_DATA_REQUEST = 1;
111+
REQUEST_CHALLENGE_ANSWERS_REQUEST = 2;
112+
}
113+
114+
enum ClientTokenResponseType {
115+
RESPONSE_UNKNOWN = 0;
116+
RESPONSE_GRANTED_TOKEN_RESPONSE = 1;
117+
RESPONSE_CHALLENGES_RESPONSE = 2;
118+
}
119+
120+
enum ChallengeType {
121+
CHALLENGE_UNKNOWN = 0;
122+
CHALLENGE_CLIENT_SECRET_HMAC = 1;
123+
CHALLENGE_EVALUATE_JS = 2;
124+
CHALLENGE_HASH_CASH = 3;
125+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
syntax = "proto3";
2+
3+
package spotify.clienttoken.data.v0;
4+
5+
option optimize_for = CODE_SIZE;
6+
option java_package = "com.spotify.clienttoken.data.v0";
7+
8+
message ConnectivitySdkData {
9+
PlatformSpecificData platform_specific_data = 1;
10+
string device_id = 2;
11+
}
12+
13+
message PlatformSpecificData {
14+
oneof data {
15+
NativeAndroidData android = 1;
16+
NativeIOSData ios = 2;
17+
NativeWindowsData windows = 4;
18+
}
19+
}
20+
21+
message NativeAndroidData {
22+
int32 major_sdk_version = 1;
23+
int32 minor_sdk_version = 2;
24+
int32 patch_sdk_version = 3;
25+
uint32 api_version = 4;
26+
Screen screen_dimensions = 5;
27+
}
28+
29+
message NativeIOSData {
30+
int32 user_interface_idiom = 1;
31+
bool target_iphone_simulator = 2;
32+
string hw_machine = 3;
33+
string system_version = 4;
34+
string simulator_model_identifier = 5;
35+
}
36+
37+
message NativeWindowsData {
38+
int32 something1 = 1;
39+
int32 something3 = 3;
40+
int32 something4 = 4;
41+
int32 something6 = 6;
42+
int32 something7 = 7;
43+
int32 something8 = 8;
44+
bool something10 = 10;
45+
}
46+
47+
message Screen {
48+
int32 width = 1;
49+
int32 height = 2;
50+
int32 density = 3;
51+
}

player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2021 devgianlu
2+
* Copyright 2022 devgianlu
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -279,6 +279,12 @@ private String deviceId() {
279279
return val == null || val.isEmpty() ? null : val;
280280
}
281281

282+
@Nullable
283+
private String clientToken() {
284+
String val = config.get("clientToken");
285+
return val == null || val.isEmpty() ? null : val;
286+
}
287+
282288
@NotNull
283289
private String deviceName() {
284290
return config.get("deviceName");
@@ -363,6 +369,7 @@ public ZeroconfServer.Builder initZeroconfBuilder() {
363369
.setPreferredLocale(preferredLocale())
364370
.setDeviceType(deviceType())
365371
.setDeviceName(deviceName())
372+
.setClientToken(clientToken())
366373
.setDeviceId(deviceId())
367374
.setListenPort(config.get("zeroconf.listenPort"));
368375

@@ -378,6 +385,7 @@ public Session.Builder initSessionBuilder() throws IOException, GeneralSecurityE
378385
.setPreferredLocale(preferredLocale())
379386
.setDeviceType(deviceType())
380387
.setDeviceName(deviceName())
388+
.setClientToken(clientToken())
381389
.setDeviceId(deviceId());
382390

383391
switch (authStrategy()) {

player/src/main/resources/default.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
deviceId = "" ### Device ID (40 chars, leave empty for random) ###
2+
clientToken = "" ### Client Token (168 bytes Base64 encoded) ###
23
deviceName = "librespot-java" ### Device name ###
34
deviceType = "COMPUTER" ### Device type (COMPUTER, TABLET, SMARTPHONE, SPEAKER, TV, AVR, STB, AUDIO_DONGLE, GAME_CONSOLE, CAST_VIDEO, CAST_AUDIO, AUTOMOBILE, WEARABLE, UNKNOWN_SPOTIFY, CAR_THING, UNKNOWN) ###
45
preferredLocale = "en" ### Preferred locale ###

0 commit comments

Comments
 (0)