Skip to content

Commit 5d1f8ca

Browse files
committed
Fixed #83
1 parent 9f779d5 commit 5d1f8ca

3 files changed

Lines changed: 149 additions & 22 deletions

File tree

core/src/main/java/xyz/gianlu/librespot/cdn/CdnManager.java

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import xyz.gianlu.librespot.common.Utils;
1010
import xyz.gianlu.librespot.common.proto.Metadata;
1111
import xyz.gianlu.librespot.core.Session;
12+
import xyz.gianlu.librespot.core.TokenProvider;
1213
import xyz.gianlu.librespot.mercury.MercuryClient;
1314
import xyz.gianlu.librespot.player.AbsChunckedInputStream;
1415
import xyz.gianlu.librespot.player.GeneralAudioStream;
@@ -26,6 +27,7 @@
2627
import java.sql.SQLException;
2728
import java.util.concurrent.ExecutorService;
2829
import java.util.concurrent.Executors;
30+
import java.util.concurrent.atomic.AtomicReference;
2931

3032
import static xyz.gianlu.librespot.player.feeders.storage.ChannelManager.CHUNK_SIZE;
3133

@@ -65,20 +67,20 @@ private InputStream getHead(@NotNull ByteString fileId) throws IOException {
6567
}
6668

6769
@NotNull
68-
public Streamer streamEpisode(@NotNull Metadata.Episode episode, @NotNull HttpUrl externalUrl, @Nullable AbsChunckedInputStream.HaltListener haltListener) throws IOException {
69-
return new Streamer(new StreamId(episode), SuperAudioFormat.MP3, externalUrl, session.cache(), new NoopAudioDecrypt(), haltListener);
70+
public Streamer streamEpisode(@NotNull Metadata.Episode episode, @NotNull HttpUrl externalUrl, @Nullable AbsChunckedInputStream.HaltListener haltListener) throws IOException, CdnException {
71+
return new Streamer(new StreamId(episode), SuperAudioFormat.MP3, new CdnUrl(externalUrl), session.cache(), new NoopAudioDecrypt(), haltListener);
7072
}
7173

7274
@NotNull
7375
public Streamer streamTrack(@NotNull Metadata.AudioFile file, @NotNull byte[] key, @Nullable AbsChunckedInputStream.HaltListener haltListener) throws IOException, MercuryClient.MercuryException, CdnException {
7476
return new Streamer(new StreamId(file), SuperAudioFormat.get(file.getFormat()),
75-
getAudioUrl(file.getFileId()), session.cache(), new AesAudioDecrypt(key), haltListener);
77+
getCdnUrl(file.getFileId()), session.cache(), new AesAudioDecrypt(key), haltListener);
7678
}
7779

7880
@NotNull
79-
private HttpUrl getAudioUrl(@NotNull ByteString fileId) throws IOException, MercuryClient.MercuryException, CdnException {
81+
private HttpUrl getAudioUrl(@NotNull ByteString fileId, @NotNull TokenProvider.ExpireListener listener) throws IOException, CdnException, MercuryClient.MercuryException {
8082
try (Response resp = client.newCall(new Request.Builder().get()
81-
.header("Authorization", "Bearer " + session.tokens().get("playlist-read"))
83+
.header("Authorization", "Bearer " + session.tokens().get("playlist-read", listener))
8284
.url(String.format(STORAGE_RESOLVE_AUDIO_URL, Utils.bytesToHex(fileId)))
8385
.build()).execute()) {
8486

@@ -91,18 +93,31 @@ private HttpUrl getAudioUrl(@NotNull ByteString fileId) throws IOException, Merc
9193

9294
StorageResolve.StorageResolveResponse proto = StorageResolve.StorageResolveResponse.parseFrom(body.byteStream());
9395
if (proto.getResult() == StorageResolve.StorageResolveResponse.Result.CDN) {
94-
return HttpUrl.get(proto.getCdnurl(session.random().nextInt(proto.getCdnurlCount())));
96+
String url = proto.getCdnurl(session.random().nextInt(proto.getCdnurlCount()));
97+
LOGGER.debug(String.format("Fetched CDN url for %s: %s", Utils.bytesToHex(fileId), url));
98+
return HttpUrl.get(url);
9599
} else {
96100
throw new CdnException(String.format("Could not retrieve CDN url! {result: %s}", proto.getResult()));
97101
}
98102
}
99103
}
100104

105+
@NotNull
106+
private CdnUrl getCdnUrl(@NotNull ByteString fileId) throws IOException, MercuryClient.MercuryException, CdnException {
107+
CdnUrl cdnUrl = new CdnUrl(fileId);
108+
cdnUrl.url = getAudioUrl(fileId, cdnUrl);
109+
return cdnUrl;
110+
}
111+
101112
public static class CdnException extends Exception {
102113

103114
CdnException(@NotNull String message) {
104115
super(message);
105116
}
117+
118+
CdnException(Throwable ex) {
119+
super(ex);
120+
}
106121
}
107122

108123
private static class InternalResponse {
@@ -115,12 +130,72 @@ private static class InternalResponse {
115130
}
116131
}
117132

133+
private class CdnUrl implements TokenProvider.ExpireListener {
134+
private final ByteString fileId;
135+
private final AtomicReference<Exception> urlLock = new AtomicReference<>(null);
136+
private HttpUrl url;
137+
138+
CdnUrl(@NotNull HttpUrl url) {
139+
this.url = url;
140+
this.fileId = null;
141+
}
142+
143+
CdnUrl(@NotNull ByteString fileId) {
144+
this.fileId = fileId;
145+
this.url = null;
146+
}
147+
148+
private void waitUrl() throws CdnException {
149+
if (url == null) {
150+
synchronized (urlLock) {
151+
try {
152+
urlLock.wait();
153+
} catch (InterruptedException ex) {
154+
throw new CdnException(ex);
155+
}
156+
157+
Exception ex = urlLock.get();
158+
if (ex != null) throw new CdnException(ex);
159+
}
160+
}
161+
}
162+
163+
@Nullable
164+
String host() {
165+
return url == null ? null : url.host();
166+
}
167+
168+
@NotNull
169+
HttpUrl url() throws CdnException {
170+
waitUrl();
171+
return url;
172+
}
173+
174+
@Override
175+
public void tokenExpired() {
176+
if (fileId == null) throw new IllegalStateException();
177+
178+
url = null;
179+
180+
synchronized (urlLock) {
181+
try {
182+
url = getAudioUrl(fileId, this);
183+
urlLock.set(null);
184+
} catch (IOException | CdnException | MercuryClient.MercuryException ex) {
185+
urlLock.set(ex);
186+
}
187+
188+
urlLock.notifyAll();
189+
}
190+
}
191+
}
192+
118193
public class Streamer implements GeneralAudioStream, GeneralWritableStream {
119194
private final StreamId streamId;
120195
private final ExecutorService executorService = Executors.newCachedThreadPool();
121196
private final SuperAudioFormat format;
122197
private final AudioDecrypt audioDecrypt;
123-
private final HttpUrl cdnUrl;
198+
private final CdnUrl cdnUrl;
124199
private final int size;
125200
private final byte[][] buffer;
126201
private final boolean[] available;
@@ -129,8 +204,8 @@ public class Streamer implements GeneralAudioStream, GeneralWritableStream {
129204
private final InternalStream internalStream;
130205
private final CacheManager.Handler cacheHandler;
131206

132-
private Streamer(@NotNull StreamId streamId, @NotNull SuperAudioFormat format, @NotNull HttpUrl cdnUrl, @Nullable CacheManager cache,
133-
@Nullable AudioDecrypt audioDecrypt, @Nullable AbsChunckedInputStream.HaltListener haltListener) throws IOException {
207+
private Streamer(@NotNull StreamId streamId, @NotNull SuperAudioFormat format, @NotNull CdnUrl cdnUrl, @Nullable CacheManager cache,
208+
@Nullable AudioDecrypt audioDecrypt, @Nullable AbsChunckedInputStream.HaltListener haltListener) throws IOException, CdnException {
134209
this.streamId = streamId;
135210
this.format = format;
136211
this.audioDecrypt = audioDecrypt;
@@ -223,21 +298,21 @@ private void requestChunk(int index, boolean retried) {
223298
try {
224299
InternalResponse resp = request(index);
225300
writeChunk(resp.buffer, index, false);
226-
} catch (IOException ex) {
301+
} catch (IOException | CdnException ex) {
227302
LOGGER.fatal(String.format("Failed requesting chunk from network, index: %d, retried: %b", index, retried), ex);
228303
if (retried) internalStream.notifyChunkError(index, new AbsChunckedInputStream.ChunkException(ex));
229304
else requestChunk(index, true);
230305
}
231306
}
232307

233308
@NotNull
234-
public synchronized InternalResponse request(int chunk) throws IOException {
309+
public synchronized InternalResponse request(int chunk) throws IOException, CdnException {
235310
return request(CHUNK_SIZE * chunk, (chunk + 1) * CHUNK_SIZE - 1);
236311
}
237312

238313
@NotNull
239-
public synchronized InternalResponse request(int rangeStart, int rangeEnd) throws IOException {
240-
try (Response resp = client.newCall(new Request.Builder().get().url(cdnUrl)
314+
public synchronized InternalResponse request(int rangeStart, int rangeEnd) throws IOException, CdnException {
315+
try (Response resp = client.newCall(new Request.Builder().get().url(cdnUrl.url())
241316
.header("Range", "bytes=" + rangeStart + "-" + rangeEnd)
242317
.build()).execute()) {
243318

core/src/main/java/xyz/gianlu/librespot/core/TokenProvider.java

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,44 +3,82 @@
33
import com.google.gson.JsonArray;
44
import org.apache.log4j.Logger;
55
import org.jetbrains.annotations.NotNull;
6+
import org.jetbrains.annotations.Nullable;
67
import xyz.gianlu.librespot.mercury.MercuryClient;
78
import xyz.gianlu.librespot.mercury.MercuryRequests;
89

10+
import javax.jmdns.impl.util.NamedThreadFactory;
911
import java.io.IOException;
10-
import java.util.Objects;
12+
import java.util.*;
13+
import java.util.concurrent.Executors;
14+
import java.util.concurrent.ScheduledExecutorService;
15+
import java.util.concurrent.TimeUnit;
1116

1217
/**
1318
* @author Gianlu
1419
*/
1520
public class TokenProvider {
1621
private final static Logger LOGGER = Logger.getLogger(TokenProvider.class);
22+
private final static int TOKEN_EXPIRE_THRESHOLD = 10;
1723
private final Session session;
18-
private StoredToken token;
24+
private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("token-expire-"));
25+
private final Map<String, StoredToken> tokens = new HashMap<>();
1926

2027
TokenProvider(@NotNull Session session) {
2128
this.session = session;
2229
}
2330

2431
@NotNull
25-
public String get(@NotNull String scope) throws IOException, MercuryClient.MercuryException {
26-
if (token != null && token.timestamp + token.expiresIn * 1000 > TimeProvider.currentTimeMillis()) {
27-
for (String avScope : token.scopes)
28-
if (Objects.equals(avScope, scope))
29-
return token.accessToken;
32+
public String get(@NotNull String scope, @Nullable ExpireListener expireListener) throws IOException, MercuryClient.MercuryException {
33+
if (scope.contains(",")) throw new UnsupportedOperationException("Only single scope tokens are supported.");
34+
35+
StoredToken token = tokens.get(scope);
36+
37+
if (token != null) {
38+
if (token.expired()) {
39+
token.expireListeners.clear();
40+
tokens.remove(scope);
41+
} else {
42+
if (expireListener != null) token.expireListeners.add(expireListener);
43+
return token.accessToken;
44+
}
3045
}
3146

32-
LOGGER.debug("Token expired or not suitable, requesting again.");
47+
LOGGER.debug(String.format("Token expired or not suitable, requesting again. {scope: %s, token: %s}", scope, token));
3348
MercuryRequests.KeymasterToken resp = session.mercury().sendSync(MercuryRequests.requestToken(session.deviceId(), scope));
3449
token = new StoredToken(resp);
3550

51+
if (expireListener != null)
52+
token.expireListeners.add(expireListener);
53+
54+
executorService.schedule(new ExpiredRunnable(token), token.expiresIn - TOKEN_EXPIRE_THRESHOLD, TimeUnit.SECONDS);
3655
return token.accessToken;
3756
}
3857

58+
public interface ExpireListener {
59+
void tokenExpired();
60+
}
61+
62+
private static class ExpiredRunnable implements Runnable {
63+
private final StoredToken token;
64+
65+
ExpiredRunnable(@NotNull StoredToken token) {
66+
this.token = token;
67+
}
68+
69+
@Override
70+
public void run() {
71+
for (ExpireListener listener : new ArrayList<>(token.expireListeners))
72+
listener.tokenExpired();
73+
}
74+
}
75+
3976
private static class StoredToken {
4077
final int expiresIn;
4178
final String accessToken;
4279
final String[] scopes;
4380
final long timestamp;
81+
final Set<ExpireListener> expireListeners = new HashSet<>();
4482

4583
private StoredToken(@NotNull MercuryRequests.KeymasterToken token) {
4684
timestamp = TimeProvider.currentTimeMillis();
@@ -52,5 +90,19 @@ private StoredToken(@NotNull MercuryRequests.KeymasterToken token) {
5290
for (int i = 0; i < scopesArray.size(); i++)
5391
scopes[i] = scopesArray.get(i).getAsString();
5492
}
93+
94+
private boolean expired() {
95+
return timestamp + (expiresIn - TOKEN_EXPIRE_THRESHOLD) * 1000 < TimeProvider.currentTimeMillis();
96+
}
97+
98+
@Override
99+
public String toString() {
100+
return "StoredToken{" +
101+
"expiresIn=" + expiresIn +
102+
", accessToken='" + accessToken + '\'' +
103+
", scopes=" + Arrays.toString(scopes) +
104+
", timestamp=" + timestamp +
105+
'}';
106+
}
55107
}
56108
}

core/src/main/java/xyz/gianlu/librespot/player/feeders/CdnFeeder.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public CdnFeeder(@NotNull Session session, @NotNull PlayableId id) {
4040
}
4141

4242
@Override
43-
public @NotNull LoadedStream loadEpisode(Metadata.@NotNull Episode episode, Metadata.@NotNull AudioFile file, @Nullable AbsChunckedInputStream.HaltListener haltListener) throws IOException {
43+
public @NotNull LoadedStream loadEpisode(Metadata.@NotNull Episode episode, Metadata.@NotNull AudioFile file, @Nullable AbsChunckedInputStream.HaltListener haltListener) throws IOException, CdnManager.CdnException {
4444
if (!episode.hasExternalUrl())
4545
throw new CanNotAvailable("Missing external_url!");
4646

0 commit comments

Comments
 (0)