Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Separating library from player #245

Merged
merged 10 commits into from
Jul 28, 2020
47 changes: 5 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,55 +19,18 @@ Its main features are:
- Gapless playback
- Mixed playlists (cuepoints and transitions)

## Get started
All the configuration you need is inside the `config.toml` file. If none is present, a sample `config.toml` will be generated the first time the jar is run. There you can decide to authenticate with:
- Username and password
- Zeroconf
- Facebook
- Auth blob
## The library
The `lib` module provides all the necessary components and tools to interact with Spotify. More [here](lib).

### Username and password
This is pretty straightforward, but remember that having hardcoded passwords isn't the best thing on earth.

### Zeroconf
In this mode `librespot` becomes discoverable with Spotify Connect by devices on the same network. Just open a Spotify client and select `librespot-java` from the available devices list.

If you have a firewall, you need to open the UDP port `5355` for mDNS. Then specify some random port in `zeroconf.listenPort` and open that TCP port too.

### Facebook
Authenticate with Facebook. The console will provide a link to visit in order to continue the login process.

### Auth blob
This is more advanced and should only be used if you saved an authentication blob. The blob should have already been Base64-decoded. Generating one is currently not a feature of librespot-java

### Storing credentials
If the configurations `storeCredentials=true` and `credentialsFile="somepath.json"` have been set, the credentials will be saved in a more secure format inside the json file. After having run the application once and successfully authenticating, the authentication config fields above are no longer needed and should be made blank for security purposes.

## Run
You can download the latest release from [here](https://github.com/librespot-org/librespot-java/releases) and then run `java -jar ./librespot-core-jar-with-dependencies.jar` from the command line.

### Audio output configuration
On some systems, many mixers could be installed making librespot-java playback on the wrong one, therefore you won't hear anything and likely see an exception in the logs. If that's the case, follow the guide below:

1) In your configuration file (`config.toml` by default), under the `player` section, make sure `logAvailableMixers` is set to `true` and restart the application
2) Connect to the client and start playing something
3) Along with the previous exception there'll be a log message saying "Available mixers: ..."
4) Pick the right mixer and copy its name inside the `mixerSearchKeywords` option. If you need to specify more search keywords, you can separate them with a semicolon
5) Restart and enjoy

> **Linux note:** librespot-java will not be able to detect the mixers available on the system if you are running headless OpenJDK. You'll need to install a headful version of OpenJDK (usually doesn't end with `-headless`).

## Build it
This project uses [Maven](https://maven.apache.org/), after installing it you can compile with `mvn clean package` in the project root, if the compilation succeeds you'll be pleased with a JAR executable in `core/target`.
To run the newly build jar run `java -jar ./core/target/librespot-core-jar-with-dependencies.jar`.
## The player
The `player` module provides the full player experience. You can use it from Spotify Connect, and it operates in full headless mode. More [here](player).

## Protobuf generation
The compiled Java protobuf definitions aren't versioned, therefore, if you want to open the project inside your IDE, you'll need to run `mvn compile` first to ensure that all the necessary files are created. If the build fails due to missing `protoc` you can install it manually and use the `-DprotocExecutable=/path/to/protoc` flag.

The `com.spotify` package is reserved for the generated files.

## Logging
The application uses Log4J for logging purposes, the configuration file is placed inside `core/src/main/resources` or `api/src/main/resources` depending on what you're working with. You can also toggle the log level with `logLevel` option in the configuration.
The application uses Log4J for logging purposes, the configuration file is placed inside `lib/src/main/resources`, `player/src/main/resources` or `api/src/main/resources` depending on what you're working with. You can also toggle the log level with `logLevel` option in the configuration.

## Related Projects
- [librespot](https://github.com/librespot-org/librespot)
Expand Down
4 changes: 2 additions & 2 deletions api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@
<dependencies>
<dependency>
<groupId>xyz.gianlu.librespot</groupId>
<artifactId>librespot-core</artifactId>
<artifactId>librespot-player</artifactId>
<version>${project.version}</version>
</dependency>

<dependency>
<groupId>io.undertow</groupId>
<artifactId>undertow-core</artifactId>
<version>2.1.0.Final</version>
<version>2.1.3.Final</version>
</dependency>
</dependencies>
</project>
23 changes: 10 additions & 13 deletions api/src/main/java/xyz/gianlu/librespot/api/ApiServer.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package xyz.gianlu.librespot.api;

import io.undertow.Undertow;
import io.undertow.server.HttpHandler;
import io.undertow.server.RoutingHandler;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
Expand All @@ -10,32 +9,30 @@

public class ApiServer {
private static final Logger LOGGER = LogManager.getLogger(ApiServer.class);
protected final RoutingHandler handler;
protected final EventsHandler events = new EventsHandler();
private final int port;
private final String host;
private final HttpHandler handler;
private Undertow undertow = null;

public ApiServer(@NotNull ApiConfiguration conf, @NotNull SessionWrapper wrapper) {
this.port = conf.apiPort();
this.host = conf.apiHost();

EventsHandler events = new EventsHandler();
wrapper.setListener(events);

handler = new CorsHandler(new RoutingHandler()
.post("/player/{cmd}", new PlayerHandler(wrapper))
public ApiServer(int port, @NotNull String host, @NotNull SessionWrapper wrapper) {
this.port = port;
this.host = host;
this.handler = new RoutingHandler()
.post("/metadata/{type}/{uri}", new MetadataHandler(wrapper, true))
.post("/metadata/{uri}", new MetadataHandler(wrapper, false))
.post("/search/{query}", new SearchHandler(wrapper))
.post("/token/{scope}", new TokensHandler(wrapper))
.post("/profile/{user_id}/{action}", new ProfileHandler(wrapper))
.get("/events", events));
.get("/events", events);

wrapper.setListener(events);
}

public void start() {
if (undertow != null) throw new IllegalStateException("Already started!");

undertow = Undertow.builder().addHttpListener(port, host, handler).build();
undertow = Undertow.builder().addHttpListener(port, host, new CorsHandler(handler)).build();
undertow.start();
LOGGER.info("Server started on port {}!", port);
}
Expand Down
37 changes: 28 additions & 9 deletions api/src/main/java/xyz/gianlu/librespot/api/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@


import org.apache.logging.log4j.core.config.Configurator;
import xyz.gianlu.librespot.AbsConfiguration;
import xyz.gianlu.librespot.FileConfiguration;
import org.jetbrains.annotations.NotNull;
import xyz.gianlu.librespot.common.Log4JUncaughtExceptionHandler;
import xyz.gianlu.librespot.core.AuthConfiguration;
import xyz.gianlu.librespot.core.Session;
import xyz.gianlu.librespot.core.ZeroconfServer;
import xyz.gianlu.librespot.mercury.MercuryClient;
import xyz.gianlu.librespot.player.FileConfiguration;
import xyz.gianlu.librespot.player.FileConfiguration.AuthStrategy;

import java.io.IOException;
import java.security.GeneralSecurityException;
Expand All @@ -19,17 +18,37 @@
public class Main {

public static void main(String[] args) throws IOException, MercuryClient.MercuryException, GeneralSecurityException, Session.SpotifyAuthenticationException {
AbsConfiguration conf = new FileConfiguration(args);
FileConfiguration conf = new FileConfiguration(args);
Configurator.setRootLevel(conf.loggingLevel());
Thread.setDefaultUncaughtExceptionHandler(new Log4JUncaughtExceptionHandler());

String host = conf.apiHost();
int port = conf.apiPort();

if (args.length > 0 && args[0].equals("noPlayer")) withoutPlayer(port, host, conf);
else withPlayer(port, host, conf);
}

private static void withPlayer(int port, @NotNull String host, @NotNull FileConfiguration conf) throws IOException, MercuryClient.MercuryException, GeneralSecurityException, Session.SpotifyAuthenticationException {
PlayerWrapper wrapper;
if (conf.authStrategy() == AuthStrategy.ZEROCONF)
wrapper = PlayerWrapper.fromZeroconf(conf.initZeroconfBuilder().create(), conf.toPlayer());
else
wrapper = PlayerWrapper.fromSession(conf.initSessionBuilder().create(), conf.toPlayer());

PlayerApiServer server = new PlayerApiServer(port, host, wrapper);
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
server.start();
}

private static void withoutPlayer(int port, @NotNull String host, @NotNull FileConfiguration conf) throws IOException, MercuryClient.MercuryException, GeneralSecurityException, Session.SpotifyAuthenticationException {
SessionWrapper wrapper;
if (conf.authStrategy() == AuthConfiguration.Strategy.ZEROCONF && !conf.hasStoredCredentials())
wrapper = SessionWrapper.fromZeroconf(ZeroconfServer.create(conf));
if (conf.authStrategy() == AuthStrategy.ZEROCONF)
wrapper = SessionWrapper.fromZeroconf(conf.initZeroconfBuilder().create());
else
wrapper = SessionWrapper.fromSession(new Session.Builder(conf).create());
wrapper = SessionWrapper.fromSession(conf.initSessionBuilder().create());

ApiServer server = new ApiServer(conf, wrapper);
ApiServer server = new ApiServer(port, host, wrapper);
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
server.start();
}
Expand Down
16 changes: 16 additions & 0 deletions api/src/main/java/xyz/gianlu/librespot/api/PlayerApiServer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package xyz.gianlu.librespot.api;

import org.jetbrains.annotations.NotNull;
import xyz.gianlu.librespot.api.handlers.PlayerHandler;

/**
* @author devgianlu
*/
public class PlayerApiServer extends ApiServer {
public PlayerApiServer(int port, @NotNull String host, @NotNull PlayerWrapper wrapper) {
super(port, host, wrapper);

handler.post("/player/{cmd}", new PlayerHandler(wrapper));
wrapper.setListener(events);
}
}
92 changes: 92 additions & 0 deletions api/src/main/java/xyz/gianlu/librespot/api/PlayerWrapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package xyz.gianlu.librespot.api;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.gianlu.librespot.ZeroconfServer;
import xyz.gianlu.librespot.core.Session;
import xyz.gianlu.librespot.player.Player;
import xyz.gianlu.librespot.player.PlayerConfiguration;

import java.util.concurrent.atomic.AtomicReference;

/**
* @author devgianlu
*/
public class PlayerWrapper extends SessionWrapper {
private final AtomicReference<Player> playerRef = new AtomicReference<>(null);
private final PlayerConfiguration conf;
private Listener listener = null;

private PlayerWrapper(@NotNull PlayerConfiguration conf) {
this.conf = conf;
}

/**
* Convenience method to create an instance of {@link PlayerWrapper} that is updated by {@link ZeroconfServer}
*
* @param server The {@link ZeroconfServer}
* @param conf The player configuration
* @return A wrapper that holds a changing session-player tuple
*/
@NotNull
public static PlayerWrapper fromZeroconf(@NotNull ZeroconfServer server, @NotNull PlayerConfiguration conf) {
PlayerWrapper wrapper = new PlayerWrapper(conf);
server.addSessionListener(wrapper::set);
return wrapper;
}

/**
* Convenience method to create an instance of {@link PlayerWrapper} that holds a static session and player
*
* @param session The static session
* @param conf The player configuration
* @return A wrapper that holds a never-changing session-player tuple
*/
@NotNull
public static PlayerWrapper fromSession(@NotNull Session session, @NotNull PlayerConfiguration conf) {
PlayerWrapper wrapper = new PlayerWrapper(conf);
wrapper.sessionRef.set(session);
wrapper.playerRef.set(new Player(conf, session));
return wrapper;
}

public void setListener(@NotNull Listener listener) {
super.setListener(listener);
this.listener = listener;

Player p;
if ((p = playerRef.get()) != null) listener.onNewPlayer(p);
}

@Override
protected void set(@NotNull Session session) {
super.set(session);

Player player = new Player(conf, session);
playerRef.set(player);

if (listener != null) listener.onNewPlayer(player);
}

@Override
protected void clear() {
super.clear();

Player old = playerRef.get();
if (old != null) old.close();
playerRef.set(null);

if (listener != null && old != null) listener.onPlayerCleared(old);
}

@Nullable
public Player getPlayer() {
return playerRef.get();
}

public interface Listener extends SessionWrapper.Listener {
void onPlayerCleared(@NotNull Player old);

void onNewPlayer(@NotNull Player player);
}
}
26 changes: 13 additions & 13 deletions api/src/main/java/xyz/gianlu/librespot/api/SessionWrapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.gianlu.librespot.ZeroconfServer;
import xyz.gianlu.librespot.core.Session;
import xyz.gianlu.librespot.core.ZeroconfServer;

import java.util.concurrent.atomic.AtomicReference;

/**
* @author Gianlu
*/
public final class SessionWrapper {
private final AtomicReference<Session> ref = new AtomicReference<>(null);
public class SessionWrapper {
protected final AtomicReference<Session> sessionRef = new AtomicReference<>(null);
private Listener listener = null;

private SessionWrapper() {
protected SessionWrapper() {
}

/**
Expand All @@ -39,32 +39,32 @@ public static SessionWrapper fromZeroconf(@NotNull ZeroconfServer server) {
@NotNull
public static SessionWrapper fromSession(@NotNull Session session) {
SessionWrapper wrapper = new SessionWrapper();
wrapper.ref.set(session);
wrapper.sessionRef.set(session);
return wrapper;
}

public void setListener(@NotNull Listener listener) {
this.listener = listener;

Session s;
if ((s = ref.get()) != null) listener.onNewSession(s);
if ((s = sessionRef.get()) != null) listener.onNewSession(s);
}

private void set(@NotNull Session session) {
ref.set(session);
protected void set(@NotNull Session session) {
sessionRef.set(session);
session.addCloseListener(this::clear);
if (listener != null) listener.onNewSession(session);
}

private void clear() {
Session old = ref.get();
ref.set(null);
protected void clear() {
Session old = sessionRef.get();
sessionRef.set(null);
if (listener != null && old != null) listener.onSessionCleared(old);
}

@Nullable
public Session get() {
Session s = ref.get();
public Session getSession() {
Session s = sessionRef.get();
if (s != null) {
if (s.isValid()) return s;
else clear();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package xyz.gianlu.librespot.api.handlers;

import io.undertow.server.HttpServerExchange;
import io.undertow.util.StatusCodes;
import org.jetbrains.annotations.NotNull;
import xyz.gianlu.librespot.api.PlayerWrapper;
import xyz.gianlu.librespot.core.Session;
import xyz.gianlu.librespot.player.Player;

/**
* @author devgianlu
*/
public abstract class AbsPlayerHandler extends AbsSessionHandler {
private final PlayerWrapper wrapper;

public AbsPlayerHandler(@NotNull PlayerWrapper wrapper) {
super(wrapper);
this.wrapper = wrapper;
}

@Override
protected final void handleRequest(@NotNull HttpServerExchange exchange, @NotNull Session session) throws Exception {
Player player = wrapper.getPlayer();
if (player == null) {
exchange.setStatusCode(StatusCodes.NO_CONTENT);
return;
}

handleRequest(exchange, session, player);
}

protected abstract void handleRequest(@NotNull HttpServerExchange exchange, @NotNull Session session, @NotNull Player player) throws Exception;
}
Loading