Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.5.2] - 17-11-2020
### Added
- Added `Player#waitReady()` method (d3149d3843e066986524e14369c5871c22629810)
- Added pass through endpoints for official Spotify API (#255)
- Store and check hash of first chunk of cache data (9ab9f43a91ebbce0e9a3a3c6f3c55a714c756525)

### Fixed
- Fixed `UnsupportedOperationException` when starting playback (#251)
- Close cache files correctly (e953129ed5f0dc4e9931660bd216267557d6010a, #253)
- Fixed starting playback from API (#254)


## [1.5.1] - 31-07-2020
### Fixed
- Fixed issue with Zeroconf (#246)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
`librespot-java` is a port of [librespot](https://github.com/librespot-org/librespot), originally written in Rust, which has evolved into the most up-to-date open-source Spotify client. Additionally, this implementation provides a useful API to request metadata or control the player, more [here](api).

## Disclaimer!
We (the librespot-org organization and me) **DO NOT** encourage piracy and **DO NOT** support any form of downloader/recorder designed with the help of this repository. If you're brave enough to put at risk this entire project, just don't publish it. This is meant to provide support for all those devices that are not officially supported by Spotify.
We (the librespot-org organization and me) **DO NOT** encourage piracy and **DO NOT** support any form of downloader/recorder designed with the help of this repository and in general anything that goes against the Spotify ToS. If you're brave enough to put at risk this entire project, just don't publish it. This is meant to provide support for all those devices that are not officially supported by Spotify.

## Features
This client is pretty much capable of playing anything that's available on Spotify.
Expand Down
6 changes: 6 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,15 @@ The currently available events are:
- `connectionEstablished` Successfully reconnected
- `panic` Entered the panic state, playback is stopped. This is usually recoverable.

### Web API pass through
Use any endpoint from the [public Web API](https://developer.spotify.com/documentation/web-api/reference/) by appending it to `/web-api/`, the request will be made to the API with the correct `Authorization` header and the result will be returned.
The method, body, and content type headers will pass through. Additionally, you can specify an `X-Spotify-Scope` header to override the requested scope, by default all will be requested.

## Examples
`curl -X POST -d "uri=spotify:track:xxxxxxxxxxxxxxxxxxxxxx&play=true" http://localhost:24879/player/load`

`curl -X POST http://localhost:24879/metadata/track/spotify:track:xxxxxxxxxxxxxxxxxxxxxx`

`curl -X POST http://localhost:24879/metadata/spotify:track:xxxxxxxxxxxxxxxxxxxxxx`

`curl -X GET http://localhost:24879/web-api/v1/me/top/artists`
2 changes: 1 addition & 1 deletion api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>xyz.gianlu.librespot</groupId>
<artifactId>librespot-java</artifactId>
<version>1.5.1</version>
<version>1.5.2</version>
<relativePath>../</relativePath>
</parent>

Expand Down
7 changes: 6 additions & 1 deletion api/src/main/java/xyz/gianlu/librespot/api/ApiServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import io.undertow.Undertow;
import io.undertow.server.RoutingHandler;
import io.undertow.server.handlers.PathHandler;
import io.undertow.server.handlers.ResponseCodeHandler;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.NotNull;
Expand All @@ -24,7 +26,10 @@ public ApiServer(int port, @NotNull String host, @NotNull SessionWrapper wrapper
.post("/search/{query}", new SearchHandler(wrapper))
.post("/token/{scope}", new TokensHandler(wrapper))
.post("/profile/{user_id}/{action}", new ProfileHandler(wrapper))
.get("/events", events);
.post("/web-api/{endpoint}", new WebApiHandler(wrapper))
.get("/events", events)
.setFallbackHandler(new PathHandler(ResponseCodeHandler.HANDLE_404)
.addPrefixPath("/web-api", new WebApiHandler(wrapper)));

wrapper.setListener(events);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package xyz.gianlu.librespot.api.handlers;

import io.undertow.server.HttpServerExchange;
import io.undertow.util.FileUtils;
import io.undertow.util.HeaderValues;
import io.undertow.util.Headers;
import io.undertow.util.HttpString;
import okhttp3.*;
import org.jetbrains.annotations.NotNull;
import xyz.gianlu.librespot.api.SessionWrapper;
import xyz.gianlu.librespot.core.Session;
import xyz.gianlu.librespot.core.TokenProvider;

public final class WebApiHandler extends AbsSessionHandler {
private static final String[] API_TOKENS_ALL = new String[]{"ugc-image-upload", "playlist-read-collaborative", "playlist-modify-private", "playlist-modify-public", "playlist-read-private", "user-read-playback-position", "user-read-recently-played", "user-top-read", "user-modify-playback-state", "user-read-currently-playing", "user-read-playback-state", "user-read-private", "user-read-email", "user-library-modify", "user-library-read", "user-follow-modify", "user-follow-read", "streaming", "app-remote-control"};
private static final HttpUrl BASE_API_URL = HttpUrl.get("https://api.spotify.com");
private static final HttpString HEADER_X_SCOPE = HttpString.tryFromString("X-Spotify-Scope");

public WebApiHandler(@NotNull SessionWrapper wrapper) {
super(wrapper);
}

@Override
protected void handleRequest(@NotNull HttpServerExchange exchange, @NotNull Session session) throws Exception {
exchange.startBlocking();
if (exchange.isInIoThread()) {
exchange.dispatch(this);
return;
}

String body = FileUtils.readFile(exchange.getInputStream());
HeaderValues contentType = exchange.getRequestHeaders().get(Headers.CONTENT_TYPE);

String[] scopes = API_TOKENS_ALL;
if (exchange.getRequestHeaders().contains(HEADER_X_SCOPE))
scopes = exchange.getRequestHeaders().get(HEADER_X_SCOPE).toArray(new String[0]);

TokenProvider.StoredToken token = session.tokens().getToken(scopes);

HttpUrl.Builder url = BASE_API_URL.newBuilder()
.addPathSegments(exchange.getRelativePath().substring(1))
.query(exchange.getQueryString());

Request.Builder req = new Request.Builder()
.url(url.build())
.addHeader("Authorization", "Bearer " + token.accessToken);

String method = exchange.getRequestMethod().toString();
if (!body.isEmpty() && contentType != null)
req.method(method, RequestBody.create(body, MediaType.get(contentType.getFirst())));
else
req.method(method, null);

try (Response resp = session.client().newCall(req.build()).execute()) {
exchange.setStatusCode(resp.code());

String respContentType = resp.header("Content-Type");
if (respContentType != null) exchange.getResponseHeaders().add(Headers.CONTENT_TYPE, respContentType);

ResponseBody respBody = resp.body();
if (respBody != null) exchange.getOutputStream().write(respBody.bytes());
}
}
}
4 changes: 2 additions & 2 deletions lib/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>xyz.gianlu.librespot</groupId>
<artifactId>librespot-java</artifactId>
<version>1.5.1</version>
<version>1.5.2</version>
<relativePath>../</relativePath>
</parent>

Expand Down Expand Up @@ -97,7 +97,7 @@
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>3.6</version>
<version>3.7.2</version>
</dependency>
</dependencies>
</project>
12 changes: 9 additions & 3 deletions lib/src/main/java/xyz/gianlu/librespot/audio/cdn/CdnManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -206,15 +206,14 @@ private Streamer(@NotNull StreamId streamId, @NotNull SuperAudioFormat format, @
boolean fromCache;
byte[] firstChunk;
byte[] sizeHeader;

if (cacheHandler != null && (sizeHeader = cacheHandler.getHeader(AudioFileFetch.HEADER_SIZE)) != null) {
size = ByteBuffer.wrap(sizeHeader).getInt() * 4;
chunks = (size + CHUNK_SIZE - 1) / CHUNK_SIZE;

try {
firstChunk = cacheHandler.readChunk(0);
fromCache = true;
} catch (IOException ex) {
} catch (IOException | CacheManager.BadChunkHashException ex) {
LOGGER.error("Failed getting first chunk from cache.", ex);

InternalResponse resp = request(0, CHUNK_SIZE - 1);
Expand Down Expand Up @@ -294,7 +293,7 @@ private void requestChunk(int index) {
cacheHandler.readChunk(index, this);
return;
}
} catch (IOException ex) {
} catch (IOException | CacheManager.BadChunkHashException ex) {
LOGGER.fatal("Failed requesting chunk from cache, index: {}", index, ex);
}
}
Expand Down Expand Up @@ -344,6 +343,13 @@ private InternalStream(boolean retryOnChunkError) {
public void close() {
super.close();
executorService.shutdown();

if (cacheHandler != null) {
try {
cacheHandler.close();
} catch (IOException ignored) {
}
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ private boolean tryCacheChunk(int index) {
if (!cacheHandler.hasChunk(index)) return false;
cacheHandler.readChunk(index, this);
return true;
} catch (IOException ex) {
} catch (IOException | CacheManager.BadChunkHashException ex) {
LOGGER.fatal("Failed requesting chunk from cache, index: {}", index, ex);
return false;
}
Expand Down Expand Up @@ -156,6 +156,13 @@ public void close() {
executorService.shutdown();
if (chunksBuffer != null)
chunksBuffer.close();

if (cacheHandler != null) {
try {
cacheHandler.close();
} catch (IOException ignored) {
}
}
}

private class ChunksBuffer implements Closeable {
Expand Down Expand Up @@ -194,6 +201,7 @@ AbsChunkedInputStream stream() {
@Override
public void close() {
internalStream.close();
AudioFileStreaming.this.close();
}

private class InternalStream extends AbsChunkedInputStream {
Expand Down
68 changes: 61 additions & 7 deletions lib/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@
import org.jetbrains.annotations.Nullable;
import xyz.gianlu.librespot.audio.GeneralWritableStream;
import xyz.gianlu.librespot.audio.StreamId;
import xyz.gianlu.librespot.common.Utils;
import xyz.gianlu.librespot.core.Session;

import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

Expand All @@ -29,7 +29,14 @@
public class CacheManager implements Closeable {
private static final long CLEAN_UP_THRESHOLD = TimeUnit.DAYS.toMillis(7);
private static final Logger LOGGER = LogManager.getLogger(CacheManager.class);
/**
* The header indicating when the file was last read or written to.
*/
private static final int HEADER_TIMESTAMP = 254;
/**
* The header indicating the hash of the first chunk of the file.
*/
private static final int HEADER_HASH = 253;
private final File parent;
private final CacheJournal journal;
private final Map<String, Handler> fileHandlers = new ConcurrentHashMap<>();
Expand Down Expand Up @@ -127,6 +134,13 @@ public Handler getHandler(@NotNull StreamId streamId) throws IOException {
return getHandler(streamId.isEpisode() ? streamId.getEpisodeGid() : streamId.getFileId());
}

public static class BadChunkHashException extends Exception {
BadChunkHashException(@NotNull String streamId, byte[] expected, byte[] actual) {
super(String.format("Failed verifying chunk hash for %s, expected: %s, actual: %s",
streamId, Utils.bytesToHex(expected), Utils.bytesToHex(actual)));
}
}

public class Handler implements Closeable {
private final String streamId;
private final RandomAccessFile io;
Expand Down Expand Up @@ -173,21 +187,35 @@ public byte[] getHeader(byte id) throws IOException {
return header == null ? null : header.value;
}

/**
* Checks if the chunk is present in the cache, WITHOUT checking the hash.
*
* @param index The index of the chunk
* @return Whether the chunk is available
*/
public boolean hasChunk(int index) throws IOException {
updateTimestamp();

synchronized (io) {
if (io.length() < (index + 1) * CHUNK_SIZE) return false;
if (io.length() < (index + 1) * CHUNK_SIZE)
return false;
}

return journal.hasChunk(streamId, index);
}

public void readChunk(int index, @NotNull GeneralWritableStream stream) throws IOException {
public void readChunk(int index, @NotNull GeneralWritableStream stream) throws IOException, BadChunkHashException {
stream.writeChunk(readChunk(index), index, true);
}

public byte[] readChunk(int index) throws IOException {
/**
* Reads the given chunk.
*
* @param index The index of the chunk
* @return The buffer containing the content of the chunk
* @throws BadChunkHashException If {@code index == 0} and the hash doesn't match
*/
public byte[] readChunk(int index) throws IOException, BadChunkHashException {
updateTimestamp();

synchronized (io) {
Expand All @@ -198,6 +226,22 @@ public byte[] readChunk(int index) throws IOException {
if (read != buffer.length)
throw new IOException(String.format("Couldn't read full chunk, read: %d, needed: %d", read, buffer.length));

if (index == 0) {
JournalHeader header = journal.getHeader(streamId, HEADER_HASH);
if (header != null) {
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
byte[] hash = digest.digest(buffer);
if (!Arrays.equals(header.value, hash)) {
journal.setChunk(streamId, index, false);
throw new BadChunkHashException(streamId, header.value, hash);
}
} catch (NoSuchAlgorithmException ex) {
LOGGER.error("Failed initializing MD5 digest.", ex);
}
}
}

return buffer;
}
}
Expand All @@ -210,6 +254,16 @@ public void writeChunk(byte[] buffer, int index) throws IOException {

try {
journal.setChunk(streamId, index, true);

if (index == 0) {
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
byte[] hash = digest.digest(buffer);
journal.setHeader(streamId, HEADER_HASH, hash);
} catch (NoSuchAlgorithmException ex) {
LOGGER.error("Failed initializing MD5 digest.", ex);
}
}
} finally {
updateTimestamp();
}
Expand Down
10 changes: 6 additions & 4 deletions lib/src/main/java/xyz/gianlu/librespot/common/ProtoUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -181,12 +181,14 @@ public static Player.PlayOrigin convertPlayOrigin(@Nullable PlayOrigin po) {


@NotNull
public static ContextPlayerOptions jsonToPlayerOptions(@NotNull JsonObject obj, @Nullable ContextPlayerOptions old) {
public static ContextPlayerOptions jsonToPlayerOptions(@Nullable JsonObject obj, @Nullable ContextPlayerOptions old) {
ContextPlayerOptions.Builder builder = old == null ? ContextPlayerOptions.newBuilder() : old.toBuilder();

Optional.ofNullable(obj.get("repeating_context")).ifPresent(elm -> builder.setRepeatingContext(elm.getAsBoolean()));
Optional.ofNullable(obj.get("repeating_track")).ifPresent(elm -> builder.setRepeatingTrack(elm.getAsBoolean()));
Optional.ofNullable(obj.get("shuffling_context")).ifPresent(elm -> builder.setShufflingContext(elm.getAsBoolean()));
if (obj != null) {
Optional.ofNullable(obj.get("repeating_context")).ifPresent(elm -> builder.setRepeatingContext(elm.getAsBoolean()));
Optional.ofNullable(obj.get("repeating_track")).ifPresent(elm -> builder.setRepeatingTrack(elm.getAsBoolean()));
Optional.ofNullable(obj.get("shuffling_context")).ifPresent(elm -> builder.setShufflingContext(elm.getAsBoolean()));
}

return builder.build();
}
Expand Down
2 changes: 1 addition & 1 deletion player/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>xyz.gianlu.librespot</groupId>
<artifactId>librespot-java</artifactId>
<version>1.5.1</version>
<version>1.5.2</version>
<relativePath>../</relativePath>
</parent>

Expand Down
Loading